From b674eaf4e6c7051965628e7c8e5d704b6a91085b Mon Sep 17 00:00:00 2001 From: Laurin-Notemann <47634664+Laurin-Notemann@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:48:08 +0000 Subject: [PATCH 1/2] feat(otterware): one environment-owned Otter Account, Google per device - The environment's own login (the one Code's relay link uses) now drives the Otter Account for Code, Home, Mail, Calendar and Drive. It renews on the server. The attach/detach RPCs, "Use on this environment" and the settings-page renewal timer are gone. - Clients sign an environment in through Accounts' device flow (account.session.signIn/signOut). Signing out revokes the environment's grant. - Google is connected per device. One consent (Mail, Calendar, contacts) is stored in the environment's secret store (DeviceGoogle), never in Otter Cloud. Disconnect forgets it locally and never revokes at Google, which revokes per project. Headless servers get no provider grants (VM Mail/Calendar deferred). - Calendar's developer OAuth controls are removed, and its remove path no longer revokes the Google grant. - Accounts: device-flow grants get a stable grant ID, so they can hold a suite session. New /code/grants list and revoke endpoints back the Sessions list in Account settings, now shown in every profile including Code. - Drive CLI signs in through Accounts' device flow (otter-code-cli) and calls Otter Cloud /api/v1 with an otterware:drive capability. Logout revokes the grant. Credentials go only to the API origin. - Mail: undefined RPC results encode as null, which fixes the preferences:setUi sync. New "Needs Google on this device" and "Needs its password on this device" statuses. Co-Authored-By: Claude Opus 5.5 --- apps/drive-cli/src/account.ts | 121 ++++++++++ apps/drive-cli/src/artifacts.ts | 4 +- apps/drive-cli/src/auth.ts | 72 +++--- apps/drive-cli/src/client.ts | 35 ++- apps/drive-cli/src/config.ts | 26 +++ apps/drive-cli/src/sharing.ts | 4 +- apps/server/src/otterware/AccountSession.ts | 136 +++++++++-- .../otterware/CalendarAccountCredential.ts | 45 ---- apps/server/src/otterware/DeviceGoogle.ts | 214 ++++++++++++++++++ apps/server/src/otterware/HomeAccount.ts | 82 ++++--- apps/server/src/otterware/OtterwareServer.ts | 2 - .../otterware/account/AccountConnections.tsx | 164 ++++++-------- .../src/otterware/account/AccountSessions.tsx | 94 ++++++++ .../otterware/account/AccountSettingsPage.tsx | 6 +- .../otterware/account/ProviderConnections.tsx | 41 +--- apps/web/src/otterware/account/rpc.ts | 12 +- .../otterware/account/useConnectProvider.ts | 39 ++++ .../otterware/calendar/CalendarWebModule.tsx | 8 +- .../calendar/ui/CalendarSettings.tsx | 212 +---------------- apps/web/src/otterware/mail/MailRuntime.tsx | 12 +- .../src/otterware/mail/content/host/bridge.ts | 8 + .../mail/content/main/gmail/capabilities.ts | 5 + .../otterware/mail/content/main/home-view.tsx | 13 +- .../content/main/settings/accounts-pane.tsx | 32 ++- apps/web/src/otterware/settingsRegistry.ts | 14 +- infra/cloud/accounts/src/code.ts | 72 ++++++ infra/cloud/accounts/src/worker.ts | 2 + .../engine/src/CalendarAccountSession.test.ts | 10 +- .../engine/src/CalendarAccountSession.ts | 43 +++- .../engine/src/CalendarPreferenceSync.test.ts | 9 +- .../src/google/AccountGoogleAuth.test.ts | 9 +- .../engine/src/google/AccountGoogleAuth.ts | 21 +- .../calendar/node/src/GoogleAuthLive.test.ts | 5 +- modules/calendar/node/src/GoogleAuthLive.ts | 39 ++-- modules/drive/engine/src/DriveService.test.ts | 13 +- modules/mail/engine/src/MailModule.ts | 9 +- modules/mail/node/src/index.ts | 2 +- packages/composition/src/contracts.test.ts | 2 +- packages/module-kit/src/account/index.ts | 211 +++++++++++++---- .../module-kit/src/account/session.test.ts | 83 +++---- packages/module-kit/src/contracts/account.ts | 27 ++- packages/module-kit/src/server/AccountRpc.ts | 41 ++-- packages/otter-cloud-client/package.json | 1 + packages/otter-cloud-client/src/contracts.ts | 1 + packages/otter-cloud-client/src/scopes.ts | 16 ++ 45 files changed, 1305 insertions(+), 712 deletions(-) create mode 100644 apps/drive-cli/src/account.ts delete mode 100644 apps/server/src/otterware/CalendarAccountCredential.ts create mode 100644 apps/server/src/otterware/DeviceGoogle.ts create mode 100644 apps/web/src/otterware/account/AccountSessions.tsx create mode 100644 apps/web/src/otterware/account/useConnectProvider.ts create mode 100644 packages/otter-cloud-client/src/scopes.ts diff --git a/apps/drive-cli/src/account.ts b/apps/drive-cli/src/account.ts new file mode 100644 index 000000000..90bc15b57 --- /dev/null +++ b/apps/drive-cli/src/account.ts @@ -0,0 +1,121 @@ +/** + * The CLI signs in to the Otter Account itself (device authorization), like Code's server CLI: + * each machine keeps its own renewable grant. Drive requests use a short-lived Otter Cloud + * capability for `otterware:drive`, so revoking this grant at Accounts ends access at once. + */ +import { DRIVE_CLOUD_AUDIENCE } from "@otterware/drive-contracts/constants"; +import type { AccountGrant } from "./config"; + +const ACCOUNTS_URL = (process.env.OTTER_ACCOUNTS_URL ?? "https://accounts.otterware.app/v1/auth") + .trim() + .replace(/\/$/, ""); +/** Accounts' existing CLI client; its tokens are scoped to the Code relay resource. */ +const CLIENT_ID = "otter-code-cli"; +const RESOURCE = "https://relay.code.otterware.app"; + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; +} + +const form = async (path: string, params: Record) => { + const response = await fetch(`${ACCOUNTS_URL}${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + signal: AbortSignal.timeout(15_000), + }); + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) + throw new Error(String(body.error ?? `Otter Accounts answered ${response.status}.`)); + return body; +}; + +const grantOf = (token: TokenResponse, previous?: AccountGrant): AccountGrant => { + const refreshToken = token.refresh_token ?? previous?.refreshToken; + if (!refreshToken) throw new Error("Otter Accounts did not grant offline access."); + return { + accessToken: token.access_token, + refreshToken, + expiresAt: Date.now() + token.expires_in * 1_000, + }; +}; + +export async function startDeviceLogin() { + const code = await form("/device/code", { + client_id: CLIENT_ID, + resource: RESOURCE, + scope: "openid profile email offline_access", + }); + return { + deviceCode: String(code.device_code), + userCode: String(code.user_code), + verificationUrl: String(code.verification_uri_complete ?? code.verification_uri), + expiresIn: Number(code.expires_in), + interval: Number(code.interval ?? 5), + }; +} + +/** One poll of the device grant; null while the user has not approved yet. */ +export async function pollDeviceLogin( + deviceCode: string, +): Promise { + try { + return grantOf( + (await form("/oauth2/token", { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: CLIENT_ID, + resource: RESOURCE, + })) as unknown as TokenResponse, + ); + } catch (error) { + const reason = (error as Error).message; + if (reason === "authorization_pending") return null; + if (reason === "slow_down") return "slow_down"; + throw error; + } +} + +/** A current grant, renewed (and rotated) a minute before its access token expires. */ +export async function freshGrant(grant: AccountGrant): Promise { + if (grant.expiresAt - 60_000 > Date.now()) return grant; + return grantOf( + (await form("/oauth2/token", { + grant_type: "refresh_token", + refresh_token: grant.refreshToken, + client_id: CLIENT_ID, + resource: RESOURCE, + })) as unknown as TokenResponse, + grant, + ); +} + +/** Exchanges the grant for Drive's capability: Accounts' suite session, then Otter Cloud. */ +export async function driveCapability(grant: AccountGrant, cloudOrigin: string): Promise { + const session = await fetch(`${ACCOUNTS_URL}/suite/session`, { + method: "POST", + headers: { authorization: `Bearer ${grant.accessToken}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!session.ok) throw new Error("Your Otter Account sign-in ended. Run otterdrive auth login."); + const { token } = (await session.json()) as { token: string }; + const capability = await fetch(`${cloudOrigin}/v1/cloud/token`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ audience: DRIVE_CLOUD_AUDIENCE }), + signal: AbortSignal.timeout(15_000), + }); + if (!capability.ok) throw new Error(`Otter Cloud answered ${capability.status}.`); + return ((await capability.json()) as { accessToken: string }).accessToken; +} + +/** Revokes this machine's grant at Accounts (RFC 7009); other devices stay signed in. */ +export async function revokeGrant(grant: AccountGrant): Promise { + await form("/oauth2/revoke", { + token: grant.refreshToken, + token_type_hint: "refresh_token", + client_id: CLIENT_ID, + }).catch(() => {}); +} diff --git a/apps/drive-cli/src/artifacts.ts b/apps/drive-cli/src/artifacts.ts index ce09d0d3f..013f3b385 100644 --- a/apps/drive-cli/src/artifacts.ts +++ b/apps/drive-cli/src/artifacts.ts @@ -23,7 +23,7 @@ import type { Command } from "commander"; import open from "open"; import pc from "picocolors"; import { ApiClient } from "./client"; -import { getProfile } from "./config"; +import { getProfile, isUserLogin } from "./config"; import { resolveFolderReference, type Folder } from "./folders"; import { discoverFiles, resolveEntryPath, type LocalArtifactFile } from "./files"; import type { GlobalOptions } from "./output"; @@ -322,7 +322,7 @@ export function registerArtifactCommands(program: Command): void { .description("Move an artifact to another folder") .action(async (id: string, folderRef: string, command: Command) => { const { profile } = await getProfile(globals(command).profile); - if (!profile.accessToken || profile.apiKey) { + if (!isUserLogin(profile)) { throw new Error("Moving artifacts requires a user login, not an API key."); } const client = new ApiClient(profile); diff --git a/apps/drive-cli/src/auth.ts b/apps/drive-cli/src/auth.ts index 0629d57ef..1b4e05276 100644 --- a/apps/drive-cli/src/auth.ts +++ b/apps/drive-cli/src/auth.ts @@ -1,13 +1,11 @@ import * as NodeTimersPromises from "node:timers/promises"; const { setTimeout: delay } = NodeTimersPromises; -import { - deviceCodeResponseSchema, - deviceTokenResponseSchema, -} from "@otterware/drive-contracts/api"; +import { DRIVE_CLOUD_ORIGIN } from "@otterware/drive-contracts/constants"; import type { Command } from "commander"; import open from "open"; import pc from "picocolors"; +import { pollDeviceLogin, revokeGrant, startDeviceLogin } from "./account"; import { ApiClient } from "./client"; import { clearCredentials, configPath, getProfile, updateProfile } from "./config"; import type { GlobalOptions } from "./output"; @@ -53,47 +51,31 @@ export function registerAuthCommands(program: Command): void { return; } - const client = new ApiClient(profile); - const code = deviceCodeResponseSchema.parse( - await client.post("/api/auth/device/code", { - client_id: "otterdrive-cli", - scope: "openid profile email offline_access", - }), - ); - const verificationUrl = new URL( - code.verification_uri_complete ?? code.verification_uri, - profile.apiUrl, - ).toString(); + // This machine's own Otter Account grant; Drive requests go to Otter Cloud with it. + const code = await startDeviceLogin(); + process.stdout.write(`\nEnter ${pc.bold(pc.cyan(code.userCode))} at:\n`); + process.stdout.write(`${code.verificationUrl}\n\n`); + if (options.noOpen !== true) await open(code.verificationUrl); - process.stdout.write(`\nEnter ${pc.bold(pc.cyan(code.user_code))} at:\n`); - process.stdout.write(`${verificationUrl}\n\n`); - if (options.noOpen !== true) await open(verificationUrl); - - const expiresAt = Date.now() + code.expires_in * 1_000; - let interval = (code.interval ?? 5) * 1_000; + const expiresAt = Date.now() + code.expiresIn * 1_000; + let interval = code.interval * 1_000; while (Date.now() < expiresAt) { await delay(interval); - try { - const token = deviceTokenResponseSchema.parse( - await client.post("/api/auth/device/token", { - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: code.device_code, - client_id: "otterdrive-cli", - }), - ); - await updateProfile(name, { - apiUrl: profile.apiUrl, - accessToken: token.access_token, - apiKey: undefined, - }); - if (globals.json) printJson(token); - else success(`Authenticated profile ${pc.bold(name)}.`); - return; - } catch (error) { - const message = (error as Error).message; - if (message.includes("slow_down")) interval += 5_000; - else if (!message.includes("authorization_pending")) throw error; + const grant = await pollDeviceLogin(code.deviceCode); + if (grant === "slow_down") { + interval += 5_000; + continue; } + if (!grant) continue; + await updateProfile(name, { + apiUrl: options.url ?? DRIVE_CLOUD_ORIGIN, + accountGrant: grant, + accessToken: undefined, + apiKey: undefined, + }); + if (globals.json) printJson({ success: true, profile: name }); + else success(`Authenticated profile ${pc.bold(name)}.`); + return; } throw new Error("The login code expired before it was approved."); }); @@ -104,7 +86,7 @@ export function registerAuthCommands(program: Command): void { .action(async (_options: unknown, command: Command) => { const globals = globalOptions(command); const { name, profile } = await getProfile(globals.profile); - if (!profile.accessToken && !profile.apiKey) { + if (!profile.accessToken && !profile.apiKey && !profile.accountGrant) { const status = { authenticated: false, profile: name, @@ -124,10 +106,12 @@ export function registerAuthCommands(program: Command): void { auth .command("logout") - .description("Remove locally stored credentials") + .description("Sign this machine out and remove its credentials") .action(async (_options: unknown, command: Command) => { const globals = globalOptions(command); - const { name } = await getProfile(globals.profile); + const { name, profile } = await getProfile(globals.profile); + // Ends this machine's sign-in at Otter Accounts too; other devices stay signed in. + if (profile.accountGrant) await revokeGrant(profile.accountGrant); await clearCredentials(name); if (globals.json) printJson({ success: true, profile: name }); else success(`Removed credentials for profile ${pc.bold(name)}.`); diff --git a/apps/drive-cli/src/client.ts b/apps/drive-cli/src/client.ts index 3d730830e..72e488450 100644 --- a/apps/drive-cli/src/client.ts +++ b/apps/drive-cli/src/client.ts @@ -1,5 +1,10 @@ import { apiErrorSchema } from "@otterware/drive-contracts/api"; -import type { Profile } from "./config"; +import { DRIVE_CLOUD_AUDIENCE } from "@otterware/drive-contracts/constants"; +import { driveCapability, freshGrant } from "./account"; +import { profileNameOf, updateProfile, type Profile } from "./config"; + +/** One capability per process and profile; Cloud's capabilities last 15 minutes. */ +const capabilities = new WeakMap(); export class ApiClient { readonly baseUrl: string; @@ -11,11 +16,18 @@ export class ApiClient { } async request(path: string, init: RequestInit & { raw?: boolean } = {}): Promise { + const url = path.startsWith("http") ? path : `${this.baseUrl}${path}`; + // Credentials go only to the configured API, never to a server-supplied upload host. + if (new URL(url).origin !== new URL(this.baseUrl).origin) + throw new Error(`Refusing to send Drive credentials to ${new URL(url).origin}.`); const headers = new Headers(init.headers); headers.set("accept", init.raw ? "*/*" : "application/json"); if (this.profile.apiKey) headers.set("x-api-key", this.profile.apiKey); if (this.profile.accessToken) { headers.set("authorization", `Bearer ${this.profile.accessToken}`); + } else if (this.profile.accountGrant && !this.profile.apiKey) { + headers.set("authorization", `Bearer ${await this.capability()}`); + headers.set("x-otter-audience", DRIVE_CLOUD_AUDIENCE); } if (this.profile.folderId) { headers.set("x-otterdrive-folder", this.profile.folderId); @@ -24,7 +36,7 @@ export class ApiClient { headers.set("content-type", "application/json"); } - const response = await fetch(path.startsWith("http") ? path : `${this.baseUrl}${path}`, { + const response = await fetch(url, { ...init, headers, }); @@ -34,7 +46,9 @@ export class ApiClient { const generic = typeof body === "object" && body !== null ? (body as Record) : null; const message = parsed.success - ? parsed.data.error.message + ? parsed.data.error.code === "identity_conflict" + ? "Your Drive account was created before Otter Accounts and needs a one-time migration. Contact Otterware support, or use an API key meanwhile." + : parsed.data.error.message : typeof generic?.error === "string" ? `${generic.error}: ${String(generic.error_description ?? generic.message ?? "")}` : `${response.status} ${response.statusText}`; @@ -45,6 +59,21 @@ export class ApiClient { return (await response.json()) as T; } + private async capability(): Promise { + const cached = capabilities.get(this.profile); + if (cached && Date.now() - cached.at < 14 * 60_000) return cached.token; + const previous = this.profile.accountGrant!; + const grant = await freshGrant(previous); + if (grant !== previous) { + this.profile.accountGrant = grant; + const name = profileNameOf(this.profile); + if (name) await updateProfile(name, { accountGrant: grant }, false); + } + const token = await driveCapability(grant, this.baseUrl); + capabilities.set(this.profile, { token, at: Date.now() }); + return token; + } + get(path: string): Promise { return this.request(path); } diff --git a/apps/drive-cli/src/config.ts b/apps/drive-cli/src/config.ts index 11d101745..05027cc54 100644 --- a/apps/drive-cli/src/config.ts +++ b/apps/drive-cli/src/config.ts @@ -9,8 +9,17 @@ const { dirname, join } = NodePath; import { DEFAULT_API_URL } from "@otterware/drive-contracts/api"; +/** This machine's own Otter Account grant (device login); never shared between machines. */ +export interface AccountGrant { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + export interface Profile { apiUrl: string; + accountGrant?: AccountGrant | undefined; + /** A legacy Drive session or OTTERDRIVE_TOKEN bearer. */ accessToken?: string | undefined; apiKey?: string | undefined; folderId?: string | undefined; @@ -33,6 +42,12 @@ export function mergeProfile(existing: Profile | undefined, update: Partial { await chmod(path, 0o600); } +const profileNames = new WeakMap(); +/** The stored profile a loaded profile came from, so a renewed grant is saved back to it. */ +export const profileNameOf = (profile: Profile) => profileNames.get(profile); + +/** A person's own sign-in (not an agent API key): required for sharing and moving. */ +export const isUserLogin = (profile: Profile) => + Boolean((profile.accessToken || profile.accountGrant) && !profile.apiKey); + export async function getProfile(name?: string): Promise<{ name: string; profile: Profile; @@ -105,6 +128,7 @@ export async function getProfile(name?: string): Promise<{ if (token) { delete profile.apiKey; delete profile.accessToken; + delete profile.accountGrant; if (token.startsWith("otw_")) profile.apiKey = token; else profile.accessToken = token; } @@ -116,6 +140,7 @@ export async function getProfile(name?: string): Promise<{ if (folder) { profile.folderId = folder; } + profileNames.set(profile, profileName); return { name: profileName, profile }; } @@ -138,6 +163,7 @@ export async function clearCredentials(name: string): Promise { if (profile) { delete profile.accessToken; delete profile.apiKey; + delete profile.accountGrant; await writeConfig(config); } } diff --git a/apps/drive-cli/src/sharing.ts b/apps/drive-cli/src/sharing.ts index 1acb39626..5c146eaa6 100644 --- a/apps/drive-cli/src/sharing.ts +++ b/apps/drive-cli/src/sharing.ts @@ -8,7 +8,7 @@ import { import type { Command } from "commander"; import pc from "picocolors"; import { ApiClient } from "./client"; -import { getProfile } from "./config"; +import { getProfile, isUserLogin } from "./config"; import { resolveFolderReference, type Folder } from "./folders"; import type { GlobalOptions } from "./output"; import { note, printJson, success, table } from "./output"; @@ -28,7 +28,7 @@ function globals(command: Command): GlobalOptions { async function userClient(command: Command): Promise { const { profile } = await getProfile(globals(command).profile); - if (!profile.accessToken || profile.apiKey) { + if (!isUserLogin(profile)) { throw new Error("Sharing requires a user login, not an API key."); } return new ApiClient(profile); diff --git a/apps/server/src/otterware/AccountSession.ts b/apps/server/src/otterware/AccountSession.ts index 25498b3c3..ca5ff05fa 100644 --- a/apps/server/src/otterware/AccountSession.ts +++ b/apps/server/src/otterware/AccountSession.ts @@ -1,19 +1,49 @@ -import { makeOtterAccountSession, OtterAccountSession } from "@otterware/module-kit/account"; +import { + makeOtterAccountSession, + OtterAccountSession, + type EnvironmentLogin, +} from "@otterware/module-kit/account"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { ServerEnvironmentIdentity, layerIdentity } from "../environment/ServerEnvironment.ts"; import { OTTER_ACCOUNTS_URL } from "@t3tools/shared/otterAccounts"; +import * as ServerConfig from "../config.ts"; +import { + CloudCliTokenManager, + deviceAuthorizationLogin, + type DeviceAuthorizationPrompt, +} from "../cloud/CliTokenManager.ts"; +import { cloudCliOAuthConfig } from "../cloud/publicConfig.ts"; +import * as DeviceGoogle from "./DeviceGoogle.ts"; + +const signedOut: EnvironmentLogin = { + accessToken: async () => null, + signIn: () => Promise.reject(new Error("This environment cannot sign in to an Otter Account.")), + signOut: async () => {}, +}; -const SECRET = "otterware-account-session"; -/** The environment keeps the suite grant in its secret store, never in module databases. */ +/** + * The environment's Otter Account is its own login (the one Code's relay link uses), kept + * and renewed in the server secret store. Every module and Home use it; no client attaches it. + * Provider grants are separate: Google is connected on this device only (`DeviceGoogle`). + */ export const layer = Layer.effect( OtterAccountSession, Effect.gen(function* () { - const secrets = yield* ServerSecretStore; const identity = yield* ServerEnvironmentIdentity; + const config = yield* ServerConfig.ServerConfig; + const tokens = yield* Effect.serviceOption(CloudCliTokenManager); + const http = yield* HttpClient.HttpClient; const audience = `environment:${yield* identity.getEnvironmentId}`; const accountsUrl = yield* Config.String("T3CODE_ACCOUNTS_URL").pipe( Config.withDefault(OTTER_ACCOUNTS_URL), @@ -21,32 +51,90 @@ export const layer = Layer.effect( const cloudOrigin = yield* Config.String("OTTERWARE_CLOUD_ORIGIN").pipe( Config.withDefault("https://cloud.otterware.app"), ); + const run = (effect: Effect.Effect) => + Effect.runPromise(Effect.provideService(effect, HttpClient.HttpClient, http)); + let signingIn: Fiber.Fiber | null = null; + const login: EnvironmentLogin = Option.match(tokens, { + onNone: () => signedOut, + onSome: (manager) => ({ + accessToken: () => + run(manager.getExisting).then((token) => + Option.isSome(token) ? token.value.accessToken : null, + ), + signIn: (signedIn) => + run( + Effect.gen(function* () { + const shown = yield* Deferred.make(); + if (signingIn) yield* Fiber.interrupt(signingIn); + const fiber = Effect.runFork( + Effect.provideService( + deviceAuthorizationLogin((prompt) => Deferred.succeed(shown, prompt)).pipe( + Effect.flatMap(({ token }) => manager.store(token)), + Effect.tap(() => Effect.sync(signedIn)), + Effect.onExit((exit) => + Exit.isFailure(exit) + ? Deferred.die(shown, Cause.squash(exit.cause)) + : Effect.void, + ), + ), + HttpClient.HttpClient, + http, + ), + ); + signingIn = fiber; + const prompt = yield* Deferred.await(shown); + const now = yield* Clock.currentTimeMillis; + return { + verificationUrl: prompt.verificationUriComplete ?? prompt.verificationUri, + userCode: prompt.userCode, + expiresAt: now + Duration.toMillis(prompt.expiresIn), + }; + }), + ), + signOut: () => + run( + Effect.gen(function* () { + const token = yield* manager.getExisting.pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(token)) { + // Revoke the grant itself, not just the local copy (RFC 7009). + const oauth = yield* cloudCliOAuthConfig; + yield* HttpClientRequest.post( + oauth.tokenEndpoint.replace(/\/token$/, "/revoke"), + ).pipe( + HttpClientRequest.bodyUrlParams({ + token: token.value.refreshToken, + token_type_hint: "refresh_token", + client_id: oauth.clientId, + }), + http.execute, + Effect.timeout("15 seconds"), + Effect.ignore, + ); + } + yield* manager.clear; + }), + ), + }), + }); + // Google is authorized per device; headless servers keep Mail and Calendar off (VMs deferred). + const devices = config.mode === "desktop" ? yield* DeviceGoogle.make : null; const host = yield* Effect.acquireRelease( - Effect.tryPromise(() => + Effect.sync(() => makeOtterAccountSession({ accountsUrl, cloudOrigin, audience, - storage: { - read: async () => { - const saved = await Effect.runPromise(secrets.get(SECRET)); - if (Option.isNone(saved)) return null; - try { - return JSON.parse(new TextDecoder().decode(saved.value)) as unknown; - } catch { - return null; - } - }, - write: (value) => - Effect.runPromise( - value === null - ? secrets.remove(SECRET) - : secrets.set(SECRET, new TextEncoder().encode(JSON.stringify(value))), - ), - }, + login, + devices, }), ), - (host) => Effect.sync(host.close), + (host) => + Effect.gen(function* () { + if (signingIn) yield* Fiber.interrupt(signingIn); + host.close(); + }), ); return host.service; }), diff --git a/apps/server/src/otterware/CalendarAccountCredential.ts b/apps/server/src/otterware/CalendarAccountCredential.ts deleted file mode 100644 index 578c30028..000000000 --- a/apps/server/src/otterware/CalendarAccountCredential.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CalendarAccountCredentialHost } from "@otterware/calendar-node"; -import { CalendarError } from "@otterware/calendar-contracts"; -import { OTTER_ACCOUNTS_URL } from "@t3tools/shared/otterAccounts"; -import * as Config from "effect/Config"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; -import { CloudCliTokenManager } from "../cloud/CliTokenManager.ts"; - -/** Uses the environment's existing Account login; connecting a renderer does not change it. */ -export const layer = Layer.effect( - CalendarAccountCredentialHost, - Effect.gen(function* () { - const manager = yield* Effect.serviceOption(CloudCliTokenManager); - const environment = yield* Effect.serviceOption(ServerEnvironment); - const accountsUrl = yield* Config.String("T3CODE_ACCOUNTS_URL").pipe( - Config.withDefault(OTTER_ACCOUNTS_URL), - ); - const cloudOrigin = yield* Config.String("OTTERWARE_CLOUD_ORIGIN").pipe( - Config.withDefault("https://cloud.otterware.app"), - ); - return { - read: Effect.gen(function* () { - if (Option.isNone(manager) || Option.isNone(environment)) return Option.none(); - const saved = yield* manager.value.getExisting.pipe( - Effect.mapError( - () => - new CalendarError({ - code: "signed_out", - detail: "Sign in to Otter Accounts on this environment again.", - }), - ), - ); - const environmentId = yield* environment.value.getEnvironmentId; - return Option.map(saved, (credential) => ({ - oauthToken: credential.accessToken, - accountsUrl, - cloudOrigin, - audience: `calendar:${environmentId}`, - })); - }), - }; - }), -); diff --git a/apps/server/src/otterware/DeviceGoogle.ts b/apps/server/src/otterware/DeviceGoogle.ts new file mode 100644 index 000000000..646df9bd3 --- /dev/null +++ b/apps/server/src/otterware/DeviceGoogle.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics globalFetch:off globalTimers:off globalDate:off nodeBuiltinImport:off -- Promise-based provider port for module workers; the layer scope closes it. +/** + * Google on this device: one consent (Mail, Calendar and contacts) per environment, with the + * refresh token kept in this environment's secret store. It never reaches Otter Cloud and does + * not follow the account to other devices; each device connects Google itself. + * + * The client is a Google "Desktop app" OAuth client (`T3CODE_GOOGLE_CLIENT_ID`/`_SECRET`, the + * same configuration Calendar's local sign-in reads); its loopback redirect runs on this machine. + * + * @module DeviceGoogle + */ +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; +import type { DeviceProviders } from "@otterware/module-kit/account"; +import { PROVIDER_SCOPES } from "@otterware/otter-cloud-client/scopes"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; + +const SECRET = "otterware-device-google"; +const ID_PREFIX = "device:google:"; + +interface Grant { + readonly email: string; + readonly refreshToken: string; + readonly scopes: ReadonlyArray; + readonly updatedAt: number; +} +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; +} + +export const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore; + const client = yield* Config.all({ + clientId: Config.String("T3CODE_GOOGLE_CLIENT_ID").pipe(Config.withDefault("")), + clientSecret: Config.String("T3CODE_GOOGLE_CLIENT_SECRET").pipe(Config.withDefault("")), + }); + const listeners = new Set<() => void>(); + const access = new Map(); + let cancel: (() => void) | undefined; + const read = async (): Promise> => { + const saved = await Effect.runPromise(secrets.get(SECRET)); + if (Option.isNone(saved)) return {}; + try { + return JSON.parse(new TextDecoder().decode(saved.value)) as Record; + } catch { + return {}; + } + }; + const write = (grants: Record) => + Effect.runPromise(secrets.set(SECRET, new TextEncoder().encode(JSON.stringify(grants)))); + const changed = () => { + for (const listener of [...listeners]) listener(); + }; + const exchange = async (body: Record): Promise => { + if (!client.clientId) + throw new Error("Google sign-in is not configured in this build of Otterware."); + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + body: new URLSearchParams({ + client_id: client.clientId, + ...(client.clientSecret ? { client_secret: client.clientSecret } : {}), + ...body, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) + throw new Error("Google refused this device's sign-in. Connect Google on this device again."); + return (await response.json()) as TokenResponse; + }; + const complete = async (code: string, redirect: string, verifier: string) => { + const token = await exchange({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + code_verifier: verifier, + }); + const profile = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { + headers: { authorization: `Bearer ${token.access_token}` }, + signal: AbortSignal.timeout(30_000), + }); + const user = profile.ok ? ((await profile.json()) as { email?: string }) : {}; + if (!user.email || !token.refresh_token) + throw new Error("Google did not grant offline access to a mailbox."); + const grants = await read(); + const id = `${ID_PREFIX}${user.email}`; + grants[id] = { + email: user.email, + refreshToken: token.refresh_token, + scopes: token.scope?.split(" ").filter(Boolean) ?? [], + updatedAt: Date.now(), + }; + await write(grants); + access.set(id, { + accessToken: token.access_token, + expiresAt: Date.now() + token.expires_in * 1000, + }); + changed(); + }; + /** Loopback PKCE (RFC 8252): the browser on this machine returns to a one-shot local port. */ + const connect: DeviceProviders["connect"] = async (provider) => { + if (provider !== "google") + throw new Error("Only Google can be connected on this device for now."); + if (!client.clientId) + throw new Error("Google sign-in is not configured in this build of Otterware."); + cancel?.(); + const verifier = NodeCrypto.randomBytes(32).toString("base64url"); + const state = NodeCrypto.randomBytes(24).toString("base64url"); + let finish: (code: string | null) => void = () => {}; + const server = NodeHttp.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== "/callback" || url.searchParams.get("state") !== state) { + response.writeHead(400).end("Invalid Google sign-in callback."); + return; + } + response + .writeHead(200, { "content-type": "text/plain; charset=utf-8" }) + .end("Google is connected on this device. Return to Otterware."); + finish(url.searchParams.get("code")); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not open Google sign-in."); + const redirect = `http://127.0.0.1:${address.port}/callback`; + const close = () => { + clearTimeout(timer); + server.closeAllConnections(); + server.close(); + }; + const timer = setTimeout(() => finish(null), 5 * 60_000); + cancel = () => finish(null); + finish = (code) => { + finish = () => {}; + close(); + if (code) void complete(code, redirect, verifier).catch(() => {}); + }; + const challenge = NodeCrypto.createHash("sha256").update(verifier).digest("base64url"); + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + url.search = new URLSearchParams({ + client_id: client.clientId, + redirect_uri: redirect, + response_type: "code", + scope: ["openid", "email", "profile", ...PROVIDER_SCOPES.google].join(" "), + access_type: "offline", + prompt: "consent", + include_granted_scopes: "true", + state, + code_challenge: challenge, + code_challenge_method: "S256", + }).toString(); + return { url: url.href }; + }; + const devices: DeviceProviders = { + connections: async () => + Object.entries(await read()).map(([id, grant]) => ({ + id, + provider: "google" as const, + email: grant.email, + scopes: [...grant.scopes], + updatedAt: grant.updatedAt, + })), + providerToken: async (id) => { + const grant = (await read())[id]; + if (!grant) throw new Error("Google is not connected on this device."); + let current = access.get(id); + if (!current || current.expiresAt < Date.now() + 60_000) { + const token = await exchange({ + grant_type: "refresh_token", + refresh_token: grant.refreshToken, + }); + current = { + accessToken: token.access_token, + expiresAt: Date.now() + token.expires_in * 1000, + }; + access.set(id, current); + } + return { + connectionId: id, + accessToken: current.accessToken, + expiresAt: current.expiresAt, + audience: "device", + scopes: [...grant.scopes], + }; + }, + connect, + disconnect: async (id) => { + const grants = await read(); + const grant = grants[id]; + if (!grant) return; + delete grants[id]; + access.delete(id); + // Forget it here only. Google revokes per project, not per device, so revoking would + // also end other devices' sign-ins; the user removes access in their Google Account. + await write(grants); + changed(); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + yield* Effect.addFinalizer(() => Effect.sync(() => cancel?.())); + return devices; +}); diff --git a/apps/server/src/otterware/HomeAccount.ts b/apps/server/src/otterware/HomeAccount.ts index da01bc402..7e0a0bd1c 100644 --- a/apps/server/src/otterware/HomeAccount.ts +++ b/apps/server/src/otterware/HomeAccount.ts @@ -1,60 +1,54 @@ -// @effect-diagnostics globalFetch:off -- The Cloud client is promise-based and takes the runtime's fetch. /** * Home's account data sync on an environment (ADR 0019): the environment's - * existing Account login, exchanged for a Cloud client bound to - * `home:`. Without a login Home stays local. Changes from other - * devices arrive by Home's periodic pull until the environment channel - * (ADR 0018) carries `sync.changed` here. + * Otter Account, the same one every module uses. Without a login Home stays + * local. Sign-in, sign-out and `sync.changed` from other devices wake Home's sync. */ import { HomeAccount } from "@otterware/composition/server"; -import { AccountSessions } from "@otterware/otter-cloud-client/session"; -import { OTTER_ACCOUNTS_URL } from "@t3tools/shared/otterAccounts"; -import * as Config from "effect/Config"; +import { OtterAccountSession } from "@otterware/module-kit/account"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import { CloudCliTokenManager } from "../cloud/CliTokenManager.ts"; -import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; - export const layer = Layer.effect( HomeAccount, Effect.gen(function* () { - const manager = yield* Effect.serviceOption(CloudCliTokenManager); - const environment = yield* Effect.serviceOption(ServerEnvironment); - const accountsUrl = yield* Config.String("T3CODE_ACCOUNTS_URL").pipe( - Config.withDefault(OTTER_ACCOUNTS_URL), - ); - const cloudOrigin = yield* Config.String("OTTERWARE_CLOUD_ORIGIN").pipe( - Config.withDefault("https://cloud.otterware.app"), + const session = yield* OtterAccountSession; + const current = Effect.sync(() => + Option.map(Option.fromNullishOr(session.current()), (account) => ({ + accountId: account.accountId, + push: (records: Parameters[0]) => account.push(records), + pull: (cursor: number) => account.pull(cursor), + })), ); - const sessions = new AccountSessions({ fetch: globalThis.fetch }); - const current = Effect.gen(function* () { - if (Option.isNone(manager) || Option.isNone(environment)) return Option.none(); - const saved = yield* manager.value.getExisting.pipe( - Effect.option, - Effect.map(Option.flatten), - ); - const environmentId = yield* environment.value.getEnvironmentId; - const cloud = yield* Effect.tryPromise(() => - sessions.current( - Option.isNone(saved) - ? null - : { - oauthToken: saved.value.accessToken, - accountsUrl, - cloudOrigin, - audience: `home:${environmentId}`, - }, + const changes = Stream.callback( + (queue) => + Effect.acquireRelease( + Effect.sync(() => { + let unsubscribeEvents: (() => void) | undefined; + let accountId: string | undefined; + const follow = () => { + const account = session.current(); + if (account?.accountId === accountId) return; + unsubscribeEvents?.(); + accountId = account?.accountId; + unsubscribeEvents = account?.subscribe((event) => { + if (event.type === "sync.changed") Queue.offerUnsafe(queue, undefined); + }); + Queue.offerUnsafe(queue, undefined); + }; + const unsubscribe = session.subscribe(follow); + follow(); + return () => { + unsubscribe(); + unsubscribeEvents?.(); + }; + }), + (cleanup) => Effect.sync(cleanup), ), - ).pipe(Effect.orElseSucceed(() => null)); - return Option.map(Option.fromNullishOr(cloud), ({ accountId, client }) => ({ - accountId, - push: (records: Parameters[0]) => client.push(records), - pull: (cursor: number) => client.pull(cursor), - })); - }); - return HomeAccount.of({ current, changes: Stream.never }); + { strategy: "sliding", bufferSize: 1 }, + ); + return HomeAccount.of({ current, changes }); }), ); diff --git a/apps/server/src/otterware/OtterwareServer.ts b/apps/server/src/otterware/OtterwareServer.ts index dc539fd4a..97a8a442b 100644 --- a/apps/server/src/otterware/OtterwareServer.ts +++ b/apps/server/src/otterware/OtterwareServer.ts @@ -12,7 +12,6 @@ import * as AccountSession from "./AccountSession.ts"; * the two hooks contribute nothing. */ import { OtterwareWsRpcGroup } from "@otterware/composition/contracts"; -import * as CalendarAccountCredential from "./CalendarAccountCredential.ts"; import * as HomeAccount from "./HomeAccount.ts"; import * as Composition from "@otterware/composition/server"; import type { ModuleToolCallerScope } from "@otterware/module-kit/engine"; @@ -81,7 +80,6 @@ export const layer = layerHostRpcHandlers.pipe( Effect.map(ServerConfig.ServerConfig, (config) => Composition.layer(config.stateDir)), ), ), - Layer.provide(CalendarAccountCredential.layer), Layer.provide(HomeAccount.layer), Layer.provideMerge(AccountSession.layer), Layer.provide(CodeHomeContributor.layerHostContributors), diff --git a/apps/web/src/otterware/account/AccountConnections.tsx b/apps/web/src/otterware/account/AccountConnections.tsx index 951fd9647..9142a0645 100644 --- a/apps/web/src/otterware/account/AccountConnections.tsx +++ b/apps/web/src/otterware/account/AccountConnections.tsx @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useAuth } from "../../accounts/AccountProvider"; import { SettingsRow } from "../../components/settings/settingsLayout"; import { Button } from "../../components/ui/button"; @@ -14,34 +14,32 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { accountState, accountConnections, - attachAccount, - detachAccount, - connectProvider, + signInEnvironment, + signOutEnvironment, disconnectProvider, } from "./rpc"; +import { useConnectProvider } from "./useConnectProvider"; function checked(result: AtomCommandResult): A { if (result._tag === "Failure") throw squashAtomCommandFailure(result); return result.value; } -/** The account is attached explicitly to the selected environment, including remote ones. */ -export function AccountConnections({ - environmentId, - accountOnly = false, -}: { - environmentId: EnvironmentId; - accountOnly?: boolean; -}) { +/** + * The environment's own Otter Account, which Code, Home, Mail, Calendar and Drive there use. + * It is signed in on the environment itself (any client can approve it), so it keeps working + * when this client signs out. + */ +export function AccountConnections({ environmentId }: { environmentId: EnvironmentId }) { const auth = useAuth(); const state = useEnvironmentQuery(accountState({ environmentId, input: {} })); const connections = useEnvironmentQuery( state.data?.accountId ? accountConnections({ environmentId, input: {} }) : null, ); - const canWrite = useAtomValue(attachAccount.permissionAtom(environmentId)); - const attach = useAtomCommand(attachAccount); - const detach = useAtomCommand(detachAccount); - const connect = useAtomCommand(connectProvider); + const canWrite = useAtomValue(signInEnvironment.permissionAtom(environmentId)); + const signIn = useAtomCommand(signInEnvironment); + const signOut = useAtomCommand(signOutEnvironment); + const connect = useConnectProvider(environmentId); const disconnect = useAtomCommand(disconnectProvider); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -53,122 +51,86 @@ export function AccountConnections({ state.refresh(); connections.refresh(); } catch (cause) { - setError(cause instanceof Error ? cause.message : "Could not connect the account."); + setError(cause instanceof Error ? cause.message : "Could not update the Otter Account."); } finally { setBusy(false); } }; - // The environment keeps the suite session; refresh its grant only while this owner is signed in. - useEffect(() => { - if (!canWrite || !auth.userId || state.data?.accountId !== auth.userId) return; - let active = true; - const refresh = async () => { - const accessToken = await auth.getToken().catch(() => null); - if (active && accessToken) - await attach({ environmentId, input: { accessToken } }) - .then(checked) - .catch(() => {}); - }; - const timer = window.setInterval(() => void refresh(), 20 * 60_000); - return () => { - active = false; - window.clearInterval(timer); - }; - }, [canWrite, auth.userId, auth.getToken, state.data?.accountId, attach, environmentId]); - const add = (provider: "google" | "microsoft") => - run(async () => { - const result = await connect({ - environmentId, - input: { - provider, - scopes: - provider === "google" - ? [ - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/gmail.settings.basic", - "https://www.googleapis.com/auth/contacts.readonly", - "https://www.googleapis.com/auth/calendar", - ] - : [ - "User.Read", - "Mail.ReadWrite", - "Mail.Send", - "Contacts.Read", - "Calendars.ReadWrite", - ], - returnTo: window.location.href, - }, - }).then(checked); - await ensureLocalApi().shell.openExternal(result.url); - }); - const disconnectEnvironment = () => - void run(() => detach({ environmentId, input: {} }).then(checked)); + const account = state.data; + const pending = account?.pendingSignIn ?? null; + const differs = Boolean(account?.accountId && auth.userId && account.accountId !== auth.userId); // Rows only: the host's "Otter Account connections" card draws the frame. return ( <> void run(auth.signIn)} disabled={busy}> - Sign in - - ) : !state.data?.accountId ? ( + account?.accountId ? ( + ) : pending ? ( + ) : ( ) } /> - {auth.isSignedIn && state.data?.accountId && !accountOnly ? ( + {account?.accountId ? ( <> - - - + } /> {connections.data?.map((connection) => ( - Disconnect + Remove from this device } /> ))} ) : null} - {error || state.error ? ( + {error || account?.error || state.error ? (

- {error ?? state.error} + {error ?? account?.error ?? state.error}

) : null} diff --git a/apps/web/src/otterware/account/AccountSessions.tsx b/apps/web/src/otterware/account/AccountSessions.tsx new file mode 100644 index 000000000..74bb2eb6a --- /dev/null +++ b/apps/web/src/otterware/account/AccountSessions.tsx @@ -0,0 +1,94 @@ +import { OTTER_ACCOUNTS_URL } from "@t3tools/shared/otterAccounts"; +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "../../accounts/AccountProvider"; +import { SettingsRow, SettingsSection } from "../../components/settings/settingsLayout"; +import { Button } from "../../components/ui/button"; + +interface Grant { + readonly id: string; + readonly clientId: string; + readonly usedAt: number; + readonly current: boolean; +} + +const clientNames: Record = { + "otter-code-desktop": "Desktop app", + "otter-code-web": "Web browser", + "otter-code-mobile": "Mobile app", + "otter-code-cli": "Environment or command line", +}; + +const call = async (token: string, path: string, body?: unknown) => { + const response = await fetch(`${OTTER_ACCOUNTS_URL}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? null : JSON.stringify(body), + credentials: "omit", + cache: "no-store", + }); + if (!response.ok) throw new Error("Could not reach Otter Accounts."); + return response.json() as Promise; +}; + +/** + * Every sign-in of this Otter Account (apps, environments, command lines), each its own grant. + * Revoking one ends it and the Mail, Calendar and Drive access derived from it. + */ +export function AccountSessions() { + const auth = useAuth(); + const [grants, setGrants] = useState | null>(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const load = useCallback(async () => { + const token = await auth.getToken(); + if (!token) return setGrants(null); + const result = (await call(token, "/code/grants")) as { grants: ReadonlyArray }; + setGrants(result.grants); + }, [auth]); + useEffect(() => { + if (!auth.isSignedIn) return; + void load().catch((cause: unknown) => + setError(cause instanceof Error ? cause.message : "Could not load sessions."), + ); + }, [auth.isSignedIn, load]); + const revoke = async (grant: Grant) => { + setBusy(true); + setError(null); + try { + const token = await auth.getToken(); + if (!token) throw new Error("Sign in to your Otter Account again."); + await call(token, "/code/grants/revoke", { id: grant.id }); + if (grant.current) await auth.signOut(); + else await load(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not revoke that session."); + } finally { + setBusy(false); + } + }; + if (!auth.isSignedIn) return null; + return ( + + {grants?.map((grant) => ( + void revoke(grant)}> + {grant.current ? "Sign out" : "Revoke"} + + } + /> + ))} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/otterware/account/AccountSettingsPage.tsx b/apps/web/src/otterware/account/AccountSettingsPage.tsx index 662c77bd3..ba933398d 100644 --- a/apps/web/src/otterware/account/AccountSettingsPage.tsx +++ b/apps/web/src/otterware/account/AccountSettingsPage.tsx @@ -2,7 +2,7 @@ import { OTTER_ACCOUNTS_URL } from "@t3tools/shared/otterAccounts"; import { ensureLocalApi } from "../../localApi"; /** * Settings › Account (ADR 0020): the Otter Account this client is signed in - * to, and the environment it is attached to. Modules add their own rows here + * to, the environment's own sign-in and provider connections, and every session. Modules add their own rows here * (Mail's devices and agent tokens, Drive's API keys) through a settings page * placed on `account`; they never draw an account screen of their own. */ @@ -16,6 +16,7 @@ import { Button } from "../../components/ui/button"; import { useAuth } from "../../accounts/AccountProvider"; import { useModuleEnvironmentId } from "../browser/moduleEndpoint"; import { AccountConnections } from "./AccountConnections"; +import { AccountSessions } from "./AccountSessions"; export function AccountSettingsPage() { const auth = useAuth(); @@ -72,9 +73,10 @@ export function AccountSettingsPage() { {environmentId !== null ? ( - + ) : null} + ); } diff --git a/apps/web/src/otterware/account/ProviderConnections.tsx b/apps/web/src/otterware/account/ProviderConnections.tsx index 1ebf8e713..d4d560093 100644 --- a/apps/web/src/otterware/account/ProviderConnections.tsx +++ b/apps/web/src/otterware/account/ProviderConnections.tsx @@ -3,11 +3,10 @@ import * as Effect from "effect/Effect"; import { useAuth } from "../../accounts/AccountProvider"; import { Button } from "../../components/ui/button"; import { SettingsRow } from "../../components/settings/settingsLayout"; -import { ensureLocalApi } from "../../localApi"; import { useModuleTarget, browserClient, runInBrowser } from "../browser/runtime"; import { AccountConnections } from "./AccountConnections"; -/** One Google connection supplies Mail and Calendar, including standalone profiles. */ +/** Google on this device for Mail, Calendar and contacts, including standalone profiles. */ export function ProviderConnections() { const target = useModuleTarget(); return target._tag === "environment" ? ( @@ -43,45 +42,11 @@ function BrowserConnections() { window.removeEventListener("focus", refresh); }; }, [auth.userId]); - const connect = async () => { - setBusy(true); - setError(null); - try { - if (!auth.isSignedIn) { - await auth.signIn(); - return; - } - const result = await runInBrowser( - Effect.flatMap(browserClient, (client) => - client["browser.account.connect"]({ - provider: "google", - scopes: [ - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/gmail.settings.basic", - "https://www.googleapis.com/auth/contacts.readonly", - "https://www.googleapis.com/auth/calendar", - ], - returnTo: window.location.href, - }), - ), - ); - await ensureLocalApi().shell.openExternal(result.url); - } catch (cause) { - setError(String(cause)); - } finally { - setBusy(false); - } - }; return ( <> void connect()}> - {auth.isSignedIn ? "Connect Google" : "Sign in to Otterware"} - - } + title="Google on this device" + description="Not available in this browser yet. Google is connected separately on each device and does not carry over between them." /> {connections.map((connection) => ( { + const input = { + provider, + scopes: [...PROVIDER_SCOPES[provider]], + returnTo: window.location.href, + }; + // Google is authorized per device. The in-browser environment has no device-local + // Google yet, and Cloud no longer holds new grants, so only environments connect it. + if (!targetEnvironment) throw new Error("Google can't be connected in this browser yet."); + const result = await connect({ environmentId: targetEnvironment, input }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = result.value.url; + await ensureLocalApi().shell.openExternal(url); + }, + [targetEnvironment, connect], + ); +} diff --git a/apps/web/src/otterware/calendar/CalendarWebModule.tsx b/apps/web/src/otterware/calendar/CalendarWebModule.tsx index 95632715f..72a79c881 100644 --- a/apps/web/src/otterware/calendar/CalendarWebModule.tsx +++ b/apps/web/src/otterware/calendar/CalendarWebModule.tsx @@ -46,12 +46,6 @@ const settingsPages = [ placement: "page", rows: ["Accounts", "Color", "Visibility", "Watch only", "Sync window"], }, - { - id: "connections", - label: "Calendar connections", - placement: "connections", - rows: ["Google sign-in", "OAuth client", "Demo accounts"], - }, { id: "time", label: "Time", placement: "general", rows: ["Time format"] }, ] as const; @@ -242,7 +236,7 @@ export const CalendarWebModule = createCalendarWebModule({ icon: CalendarDaysIcon, searchTerms: page.rows, placement: { grouped: page.placement, standalone: page.placement }, - rows: (page.id === "calendars" || page.id === "connections" ? [] : page.rows).map((title) => ({ + rows: (page.id === "calendars" ? [] : page.rows).map((title) => ({ id: ( { diff --git a/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx b/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx index fa8a2463a..24833c344 100644 --- a/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx +++ b/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx @@ -9,16 +9,14 @@ import { Input } from "~/components/ui/input"; import { Switch } from "~/components/ui/switch"; import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; import { CalendarSelect } from "./CalendarSelect"; -import { useEffect, useRef, useState } from "react"; -import { type CalendarId, type GoogleConnectState } from "@otterware/calendar-contracts"; +import { useState } from "react"; +import { type CalendarId } from "@otterware/calendar-contracts"; import { useCalendarClient, useCalendarDirectory } from "./host.tsx"; import { calendarDesktopBridge, CALENDAR_NOTIFICATION_DEVICE_KEY, } from "./CalendarNotifications.tsx"; -const OAUTH_CLIENT_FORM = "calendar-oauth-client"; - const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const EVENT_LENGTH_CHOICES = [15, 30, 45, 60, 90, 120]; const CUSTOM_DAY_CHOICES = [2, 3, 4, 5, 6, 7, 10, 14]; @@ -51,22 +49,15 @@ function timeZoneChoices(saved: string | null): ReadonlyArray { export default function CalendarSettings({ page = "general", }: { - page?: "general" | "calendars" | "connections" | "time"; + page?: "general" | "calendars" | "time"; }) { const client = useCalendarClient(); const { directory, error: directoryError, canWrite } = useCalendarDirectory(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [flow, setFlow] = useState(null); - const [callback, setCallback] = useState(""); - const callbackAuthorization = useRef(null); const [deviceNotifications, setDeviceNotifications] = useState( () => localStorage.getItem(CALENDAR_NOTIFICATION_DEVICE_KEY) === "true", ); - const cancelFlow = useRef<(() => void) | null>(null); - useEffect(() => () => cancelFlow.current?.(), []); const run = async (work: () => Promise) => { setBusy(true); setError(null); @@ -78,212 +69,15 @@ export default function CalendarSettings({ setBusy(false); } }; - const connect = (source?: "account" | "local") => { - cancelFlow.current?.(); - setError(null); - setFlow(null); - const cancel = client.connect( - { ...(source ? { source } : {}), returnTo: window.location.href }, - (state) => { - setFlow(state); - const desktop = calendarDesktopBridge(); - if (state._tag === "waiting" && !state.flowId.startsWith("cloud:") && desktop) { - callbackAuthorization.current = state.authorizationUrl; - void desktop - .receiveGoogleCallback(state.authorizationUrl) - .then((callbackUrl) => { - if (callbackAuthorization.current === state.authorizationUrl) - return client.completeConnect(state.flowId, callbackUrl); - }) - .catch((cause) => { - if (callbackAuthorization.current === state.authorizationUrl) - setError( - cause instanceof Error - ? cause.message - : "Open the sign-in link or paste its callback address.", - ); - }); - } - }, - (message) => { - setError(message); - cancelFlow.current?.(); - }, - ); - cancelFlow.current = () => { - cancel(); - const url = callbackAuthorization.current; - callbackAuthorization.current = null; - if (url) - void calendarDesktopBridge() - ?.cancelGoogleCallback(url) - .catch(() => undefined); - }; - }; const preferences = directory?.preferences; const save = (patch: Parameters[0]) => void run(() => client.preferences(patch)); const disabled = !canWrite || busy; - const closeFlow = () => { - cancelFlow.current?.(); - cancelFlow.current = null; - setFlow(null); - }; const alert = (error ?? directoryError) && (

{error ?? directoryError}

); - if (page === "connections") - return ( - - {alert} - {flow?._tag === "waiting" && ( - - - - - } - /> - )} - {flow?._tag === "waiting" && !flow.flowId.startsWith("cloud:") && ( - { - event.preventDefault(); - void run(() => client.completeConnect(flow.flowId, callback)); - }} - > - setCallback(event.target.value)} - required - /> - - - } - /> - )} - {flow?._tag === "succeeded" && ( - - Done - - } - /> - )} - {flow?._tag === "failed" && ( - - Close - - } - /> - )} - connect("local")}> - Connect with local PKCE - - } - /> - setClientId(event.target.value)} - required - autoComplete="off" - /> - } - /> - setClientSecret(event.target.value)} - autoComplete="new-password" - /> - } - /> - { - event.preventDefault(); - void run(async () => { - await client.setClient({ clientId, clientSecret }); - setClientSecret(""); - }); - }} - > - - - - } - /> - void run(client.addDemo)}> - Add demo calendars - - } - /> - - ); return ( <> {alert} diff --git a/apps/web/src/otterware/mail/MailRuntime.tsx b/apps/web/src/otterware/mail/MailRuntime.tsx index 6f4691eaf..a2e28d98c 100644 --- a/apps/web/src/otterware/mail/MailRuntime.tsx +++ b/apps/web/src/otterware/mail/MailRuntime.tsx @@ -1,7 +1,7 @@ import { clearUndo } from "./content/main/gmail/undo"; import { MailKeybindingMigration } from "./MailKeybindingMigration"; import { useOpenLink } from "~/browser/useOpenLink"; -import { useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useShellSelection } from "@otterware/module-kit/web"; import { useModuleTarget } from "../browser/runtime"; @@ -32,6 +32,7 @@ import { useRailUnreadDots } from "./content/main/gmail/rail-preferences"; import { startSyncedPreferences } from "./content/main/synced-preferences"; import { useMailStore, type MailSnapshot } from "./mailStore"; import MailBackground from "./MailBackground"; +import { useConnectProvider } from "../account/useConnectProvider"; export default function MailRuntime() { const target = useModuleTarget(); @@ -51,6 +52,12 @@ export default function MailRuntime() { [navigate], ); useMailPageCapabilities(inBrowser, navigateMail); + // A ref keeps the host stable: rebuilding it reinstalls Mail and clears its state. + const connect = useConnectProvider(); + const connectProvider = useRef(connect); + useLayoutEffect(() => { + connectProvider.current = connect; + }, [connect]); const host = useMemo( () => environmentId || inBrowser @@ -66,6 +73,9 @@ export default function MailRuntime() { inBrowser ? { ...browserMailTransport, id: `browser:${browserIdentity}` } : undefined, ), openExternal: openLink, + // Without a signed-in environment the connect fails; Connections explains why. + connectGoogle: () => + connectProvider.current("google").catch(() => navigateMail("/settings/connections")), } : null, [environmentId, inBrowser, browserIdentity, navigateMail, desktop, openLink], diff --git a/apps/web/src/otterware/mail/content/host/bridge.ts b/apps/web/src/otterware/mail/content/host/bridge.ts index 357a69e39..50ad55c45 100644 --- a/apps/web/src/otterware/mail/content/host/bridge.ts +++ b/apps/web/src/otterware/mail/content/host/bridge.ts @@ -16,6 +16,8 @@ export interface MailWebHost extends DesktopBridge { navigate(path: string, replace?: boolean): void; openAssistant(): void; publishSelection(selection: MailPageSelection): void; + /** Starts the one Google connect the Otter Account shares with Calendar. */ + connectGoogle?(): Promise; } let current: MailWebHost | undefined; const subscriptions = new Set<{ @@ -41,6 +43,12 @@ export function installMailWebHost(host: MailWebHost): () => void { }; } export const mailHostOrNull = () => current ?? null; +/** Adds a Google mailbox through the shared connect, or opens Connections without one. */ +export const connectMailbox = () => { + const host = mailHost(); + if (host.connectGoogle) void host.connectGoogle(); + else host.navigate("/settings/connections"); +}; export const mailHost = () => { if (!current) throw new Error("Mail is not connected to an environment."); return current; diff --git a/apps/web/src/otterware/mail/content/main/gmail/capabilities.ts b/apps/web/src/otterware/mail/content/main/gmail/capabilities.ts index c5227aa5d..63e183a78 100644 --- a/apps/web/src/otterware/mail/content/main/gmail/capabilities.ts +++ b/apps/web/src/otterware/mail/content/main/gmail/capabilities.ts @@ -23,6 +23,11 @@ export function signsInWithPassword(account: GmailAccount): boolean { return account.imap != null; } +/** Signs in with Google, which is authorized on each device separately. */ +export function signsInWithGoogle(account: GmailAccount): boolean { + return account.imap == null && (account.provider ?? "gmail") === "gmail"; +} + /** Where the account's mail lives, for copy like "Nothing is deleted from Gmail". */ export function mailServerName(account: GmailAccount): string { return account.imap ? account.imap.imap.host : "Gmail"; diff --git a/apps/web/src/otterware/mail/content/main/home-view.tsx b/apps/web/src/otterware/mail/content/main/home-view.tsx index 89789a5a7..e2dfe70f3 100644 --- a/apps/web/src/otterware/mail/content/main/home-view.tsx +++ b/apps/web/src/otterware/mail/content/main/home-view.tsx @@ -10,7 +10,7 @@ import { useMailLocation } from "../host/surface"; import { Button } from "~/components/ui/button"; import { mailDocument } from "../host/dom"; -import { mailBridge, mailHost, useMailCanWrite } from "../host/bridge"; +import { connectMailbox, mailBridge, mailHost, useMailCanWrite } from "../host/bridge"; import { useLatest } from "./use-latest"; import { TodoistDialogs } from "./integrations/todoist"; import { @@ -278,11 +278,14 @@ export function HomeView() {
- +
+ + +
); } diff --git a/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx b/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx index f5917bfc5..bd3e09c2f 100644 --- a/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx +++ b/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx @@ -1,4 +1,4 @@ -import { mailHost } from "../../host/bridge"; +import { connectMailbox } from "../../host/bridge"; import { mailRoot } from "../../host/dom"; import { HintTooltip as ImportedMailTooltip } from "../gmail/ui"; import { useEffect, useRef, useState } from "react"; @@ -21,7 +21,12 @@ import { } from "../gmail/hooks"; import { RichTextArea, type RichTextRef } from "../gmail/rich-text"; import { ImapPasswordForm } from "../gmail/add-mailbox"; -import { capabilitiesOf, mailServerName, signsInWithPassword } from "../gmail/capabilities"; +import { + capabilitiesOf, + mailServerName, + signsInWithGoogle, + signsInWithPassword, +} from "../gmail/capabilities"; import { ACCOUNT_COLOR_PALETTE, getAccountColor, @@ -152,6 +157,22 @@ function ColorPicker({ /** Sync state for an account, with a signed-out account called out first. */ function accountStatus(account: GmailAccount, status: SyncStatus | undefined): StatusLine { + // IMAP settings follow the Otter Account; the password never leaves the device that has it. + if (account.signedOut && signsInWithPassword(account)) { + return { + text: "Needs its password on this device", + tone: "muted", + detail: "Mailbox passwords are never synced. Enter it once here to sync this mailbox.", + }; + } + // The mailbox list follows the account; Google authorization is per device. + if (account.signedOut && signsInWithGoogle(account)) { + return { + text: "Needs Google on this device", + tone: "muted", + detail: "Google is connected per device. Connect it here to sync this mailbox.", + }; + } // Never synced here: it was added on another device (through the Otter account). if (account.signedOut && status && !status.lastSyncAt) { return { @@ -227,7 +248,7 @@ function AccountAvatar({ */ function SignInButton({ email: _email, label }: { email?: string | undefined; label: string }) { return ( - mailHost().navigate("/settings/connections")}> + {label} ); @@ -461,7 +482,10 @@ function AccountEditor({ account }: { account: GmailAccount }) { account.signedOut && signsInWithPassword(account) ? ( ) : account.signedOut ? ( - + ) : ( = [ - ...(shellKind === "code" ? [] : [ACCOUNT_SEARCH_ITEM]), + ACCOUNT_SEARCH_ITEM, ...(shellKind === "combined" ? [MODULES_SEARCH_ITEM] : []), ...MODULE_SETTINGS_PAGES.flatMap(pageSearchItems), ]; @@ -276,7 +276,8 @@ const CODE_SETTINGS_PATHS = [ /** Code is hidden in standalone profiles; the shared agents and connections stay. */ export function isProfileSettingsPathVisible(path: string): boolean { if (path === MODULES_SETTINGS_PATH) return shellKind === "combined"; - if (path === ACCOUNT_SETTINGS_PATH) return shellKind !== "code"; + // Every profile, Code included, uses the one Otter Account and lists its sessions. + if (path === ACCOUNT_SETTINGS_PATH) return true; return ( activeProfile.codeVisible || !(CODE_SETTINGS_PATHS as ReadonlyArray).includes(path) ); @@ -333,7 +334,14 @@ export function settingsNavLayout(input: { readonly onModuleIds: ReadonlyArray; }): ReadonlyArray { if (shellKind === "code") { - return [{ id: "all", label: null, paths: input.upstreamOrder }]; + const general = input.upstreamOrder.indexOf("/settings/general"); + return [ + { + id: "all", + label: null, + paths: input.upstreamOrder.toSpliced(general + 1, 0, ACCOUNT_SETTINGS_PATH), + }, + ]; } const pagesOf = (moduleId: string) => ownPages.filter((entry) => entry.module.id === moduleId).map((entry) => entry.path); diff --git a/infra/cloud/accounts/src/code.ts b/infra/cloud/accounts/src/code.ts index 0efccef17..18a836fef 100644 --- a/infra/cloud/accounts/src/code.ts +++ b/infra/cloud/accounts/src/code.ts @@ -187,6 +187,13 @@ export function codeIdentity(env: Env, db: Db) { where: eq(oauthRefreshToken.token, refreshHash), }); if (!refresh || refresh.revoked) throw new APIError("INTERNAL_SERVER_ERROR"); + // Device-flow grants have no authorization code. Rotation copies this ID forward, so + // every sign-in is one grant: it can hold a suite session and be listed or revoked. + if (!refresh.authorizationCodeId) + await db + .update(oauthRefreshToken) + .set({ authorizationCodeId: `grant:${refresh.id}` }) + .where(eq(oauthRefreshToken.id, refresh.id)); // JWTs normally have no revocation record. Code uses the provider's grant table // as well so native sign-out revokes access immediately, without a second identity store. await db @@ -281,6 +288,71 @@ export function codeIdentity(env: Env, db: Db) { user: { id: user.id, email: user.email, name: user.name || null, image: user.image }, }); }), + /** Every app, device and command line signed in to this account, one row per sign-in. */ + codeGrants: createAuthEndpoint("/code/grants", { method: "GET" }, async (ctx) => { + const { user, grant } = await identity(ctx); + const rows = await db.query.oauthRefreshToken.findMany({ + where: and( + eq(oauthRefreshToken.userId, user.id), + isNull(oauthRefreshToken.revoked), + gt(oauthRefreshToken.expiresAt, new Date()), + ), + orderBy: [desc(oauthRefreshToken.createdAt)], + }); + const grants = new Map(); + for (const row of rows) + if (row.authorizationCodeId && !grants.has(row.authorizationCodeId)) + grants.set(row.authorizationCodeId, { + clientId: row.clientId, + usedAt: row.createdAt.getTime(), + }); + return ctx.json({ + grants: [...grants].map(([id, row]) => ({ + id, + clientId: row.clientId, + usedAt: row.usedAt, + current: id === grant.authorizationCodeId, + })), + }); + }), + revokeCodeGrant: createAuthEndpoint( + "/code/grants/revoke", + { method: "POST" }, + async (ctx) => { + const { user } = await identity(ctx); + const body: unknown = ctx.body; + const id = + body && typeof body === "object" && "id" in body && typeof body.id === "string" + ? body.id + : null; + if (!id) throw new APIError("BAD_REQUEST"); + const now = new Date(); + const chain = and( + eq(oauthRefreshToken.userId, user.id), + eq(oauthRefreshToken.authorizationCodeId, id), + ); + const revoked = await db + .update(oauthRefreshToken) + .set({ revoked: now }) + .where(and(chain, isNull(oauthRefreshToken.revoked))) + .returning({ id: oauthRefreshToken.id }); + for (const row of revoked) + await db + .update(oauthAccessToken) + .set({ revoked: now }) + .where(eq(oauthAccessToken.refreshId, row.id)); + // The suite session derived from this sign-in ends with it (Mail, Calendar, Drive, Cloud). + const link = await db.query.suiteSession.findFirst({ + where: eq(suiteSession.authorizationCodeId, id), + }); + const device = link + ? await db.query.session.findFirst({ where: eq(session.id, link.sessionId) }) + : null; + if (device?.userId === user.id) + await ctx.context.internalAdapter.deleteSession(device.token); + return ctx.json({ revoked: revoked.length > 0 }); + }, + ), codeSession: createAuthEndpoint("/code/session", { method: "GET" }, async (ctx) => { const { user } = await identity(ctx); return ctx.json({ diff --git a/infra/cloud/accounts/src/worker.ts b/infra/cloud/accounts/src/worker.ts index 67b21f9cc..a9a39701b 100644 --- a/infra/cloud/accounts/src/worker.ts +++ b/infra/cloud/accounts/src/worker.ts @@ -32,6 +32,8 @@ app.use(async (c, next) => { "/v1/auth/oauth2/token", "/v1/auth/oauth2/revoke", "/v1/auth/code/session", + "/v1/auth/code/grants", + "/v1/auth/code/grants/revoke", "/v1/auth/suite/session", ].includes(c.req.path); const responseHeaders = () => { diff --git a/modules/calendar/engine/src/CalendarAccountSession.test.ts b/modules/calendar/engine/src/CalendarAccountSession.test.ts index 74e417493..5aacf3d55 100644 --- a/modules/calendar/engine/src/CalendarAccountSession.test.ts +++ b/modules/calendar/engine/src/CalendarAccountSession.test.ts @@ -108,6 +108,7 @@ it.effect("uses the shared host account and observes detach without a second Goo }); const identity: ConnectedAccount = { accountId: "alice", + email: "alice@example.test", client, connections: () => client.connections(), providerToken: (id) => client.providerToken(id), @@ -118,8 +119,13 @@ it.effect("uses the shared host account and observes detach without a second Goo let attached = true; const host = Layer.succeed(OtterAccountSession, { current: () => (attached ? identity : null), - attach: async () => "alice", - detach: async () => {}, + providerAccount: () => + attached + ? { ...identity, connect: async () => ({ url: "" }), disconnect: async () => {} } + : null, + status: () => ({ pendingSignIn: null, error: null }), + signIn: () => Promise.reject(new Error("unused")), + signOut: async () => {}, subscribe: () => () => {}, }); const context = yield* Layer.build( diff --git a/modules/calendar/engine/src/CalendarAccountSession.ts b/modules/calendar/engine/src/CalendarAccountSession.ts index f99ce4fd9..085fe87f7 100644 --- a/modules/calendar/engine/src/CalendarAccountSession.ts +++ b/modules/calendar/engine/src/CalendarAccountSession.ts @@ -2,6 +2,9 @@ import { OtterAccountSession } from "@otterware/module-kit/account"; import * as Config from "effect/Config"; import { OtterCloudClient, CloudError } from "@otterware/otter-cloud-client"; import { CalendarError } from "@otterware/calendar-contracts"; +import { PROVIDER_SCOPES } from "@otterware/otter-cloud-client/scopes"; + +const GOOGLE_SCOPES = [...PROVIDER_SCOPES.google]; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -21,10 +24,28 @@ export interface CalendarAccountCredential { readonly cloudOrigin: string; readonly audience: string; } +/** Where Google grants come from: this device on a host, Cloud only without one. */ +export interface CalendarProviders { + readonly connections: OtterCloudClient["connections"]; + readonly providerToken: OtterCloudClient["providerToken"]; + readonly connect: (provider: "google") => Promise<{ readonly url: string }>; + readonly disconnect: (connectionId: string) => Promise; +} +/** Cloud-held grants, only for hosts without an account session of their own. */ +export const cloudProviders = ( + client: OtterCloudClient, + cloudOrigin: string, +): CalendarProviders => ({ + connections: () => client.connections(), + providerToken: (id) => client.providerToken(id), + connect: (provider) => client.connect(provider, GOOGLE_SCOPES, cloudOrigin), + disconnect: (id) => client.disconnect(id), +}); export interface CalendarCloudContext { readonly accountId: string; readonly client: OtterCloudClient; readonly cloudOrigin: string; + readonly providers: CalendarProviders; } export class CalendarAccountSession extends Context.Service< CalendarAccountSession, @@ -107,9 +128,20 @@ export const accountSessionLayer = ( const current = lock.withPermits(1)( Effect.gen(function* () { if (Option.isSome(host)) { - const account = host.value.current(); + // Mail and Calendar take provider connections only where the host allows them. + const account = host.value.providerAccount(); return account - ? Option.some({ accountId: account.accountId, client: account.client, cloudOrigin }) + ? Option.some({ + accountId: account.accountId, + client: account.client, + cloudOrigin, + providers: { + connections: async () => [...(await account.connections())], + providerToken: account.providerToken, + connect: account.connect, + disconnect: account.disconnect, + }, + }) : Option.none(); } const saved = yield* readCredential.pipe(Effect.mapError(failed)); @@ -153,7 +185,12 @@ export const accountSessionLayer = ( sessionToken: async () => session.token, fetch: makeCloudFetch(http, abort.signal), }); - const context = { accountId: session.user.id, client, cloudOrigin: key.cloudOrigin }; + const context = { + accountId: session.user.id, + client, + cloudOrigin: key.cloudOrigin, + providers: cloudProviders(client, key.cloudOrigin), + }; cached = { credential: key, expiresAt: session.expiresAt, context, abort }; return Option.some(context); }), diff --git a/modules/calendar/engine/src/CalendarPreferenceSync.test.ts b/modules/calendar/engine/src/CalendarPreferenceSync.test.ts index c8bcdad7a..a8022a403 100644 --- a/modules/calendar/engine/src/CalendarPreferenceSync.test.ts +++ b/modules/calendar/engine/src/CalendarPreferenceSync.test.ts @@ -13,7 +13,11 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as SqlClient from "effect/sql/SqlClient"; import { SqlitePersistenceMemory } from "../test/Sqlite.ts"; -import { CalendarAccountSession, type CalendarCloudContext } from "./CalendarAccountSession.ts"; +import { + CalendarAccountSession, + cloudProviders, + type CalendarCloudContext, +} from "./CalendarAccountSession.ts"; import { makePreferenceSync } from "./CalendarPreferenceSync.ts"; const database = SqlitePersistenceMemory.pipe(Layer.provideMerge(NodeServices.layer)); @@ -24,6 +28,9 @@ const setup = Effect.gen(function* () { const cloud = (accountId: string): CalendarCloudContext => ({ accountId, cloudOrigin: "https://cloud.test", + get providers() { + return cloudProviders(this.client, this.cloudOrigin); + }, client: new OtterCloudClient({ origin: "https://cloud.test", audience: "env", diff --git a/modules/calendar/engine/src/google/AccountGoogleAuth.test.ts b/modules/calendar/engine/src/google/AccountGoogleAuth.test.ts index 4e5ee7602..89424be16 100644 --- a/modules/calendar/engine/src/google/AccountGoogleAuth.test.ts +++ b/modules/calendar/engine/src/google/AccountGoogleAuth.test.ts @@ -10,7 +10,11 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CalendarAccountSession, type CalendarCloudContext } from "../CalendarAccountSession.ts"; +import { + CalendarAccountSession, + cloudProviders, + type CalendarCloudContext, +} from "../CalendarAccountSession.ts"; import * as GoogleAuthUnavailable from "../GoogleAuthUnavailable.ts"; import { GoogleAuth } from "./GoogleAuth.ts"; import * as AccountGoogleAuth from "./AccountGoogleAuth.ts"; @@ -22,6 +26,9 @@ const setup = Effect.gen(function* () { const cloud = (accountId: string, grantedScopes = scopes): CalendarCloudContext => ({ accountId, cloudOrigin: "https://cloud.test", + get providers() { + return cloudProviders(this.client, this.cloudOrigin); + }, client: new OtterCloudClient({ origin: "https://cloud.test", audience: "env", diff --git a/modules/calendar/engine/src/google/AccountGoogleAuth.ts b/modules/calendar/engine/src/google/AccountGoogleAuth.ts index 870e036be..6c01060a7 100644 --- a/modules/calendar/engine/src/google/AccountGoogleAuth.ts +++ b/modules/calendar/engine/src/google/AccountGoogleAuth.ts @@ -33,7 +33,7 @@ export const layer = Layer.effect( const accounts = Effect.gen(function* () { const context = yield* account.current; if (Option.isNone(context)) return []; - return (yield* cloudCall(() => context.value.client.connections())) + return (yield* cloudCall(() => context.value.providers.connections())) .filter((connection) => connection.provider === "google" && required(connection.scopes)) .map((connection) => ({ sub: `${PREFIX}${connection.id}`, email: connection.email })); }); @@ -59,7 +59,7 @@ export const layer = Layer.effect( const cached = tokens.get(key); if (cached && cached.expiresAt > now + 60_000 && cached.accessToken !== rejected) return cached.accessToken; - const result = yield* cloudCall(() => context.value.client.providerToken(id)).pipe( + const result = yield* cloudCall(() => context.value.providers.providerToken(id)).pipe( Effect.mapError( (error) => new CalendarProviderError({ @@ -83,23 +83,18 @@ export const layer = Layer.effect( if (input.source === "local") return local.connect(input); const context = yield* account.current; if (Option.isNone(context)) return local.connect(input); - const before = yield* cloudCall(() => context.value.client.connections()); - const authorization = yield* cloudCall(() => - context.value.client.connect( - "google", - ["openid", "email", "profile", ...GOOGLE_CALENDAR_SCOPES], - input.returnTo ?? context.value.cloudOrigin, - ), - ); + const before = yield* cloudCall(() => context.value.providers.connections()); + // The one Google consent Mail and Calendar share on this device. + const authorization = yield* cloudCall(() => context.value.providers.connect("google")); const waiting = { _tag: "waiting" as const, flowId: "cloud:google", authorizationUrl: authorization.url, - redirectUri: `${context.value.cloudOrigin}/v1/cloud/providers/google/callback`, + redirectUri: new URL(authorization.url).searchParams.get("redirect_uri") ?? "", }; const connected = Stream.fromEffect( Effect.sleep("3 seconds").pipe( - Effect.andThen(cloudCall(() => context.value.client.connections())), + Effect.andThen(cloudCall(() => context.value.providers.connections())), ), ).pipe( Stream.repeat(Schedule.forever), @@ -165,7 +160,7 @@ export const layer = Layer.effect( code: "signed_out", detail: "Sign in before disconnecting this Google account.", }); - yield* cloudCall(() => context.value.client.disconnect(sub.slice(PREFIX.length))); + yield* cloudCall(() => context.value.providers.disconnect(sub.slice(PREFIX.length))); tokens.clear(); }) : Effect.void, diff --git a/modules/calendar/node/src/GoogleAuthLive.test.ts b/modules/calendar/node/src/GoogleAuthLive.test.ts index beeaff1bf..4746fa579 100644 --- a/modules/calendar/node/src/GoogleAuthLive.test.ts +++ b/modules/calendar/node/src/GoogleAuthLive.test.ts @@ -429,7 +429,7 @@ describe("tokens", () => { }), ); - it.effect("forgets and revokes an account's tokens", () => + it.effect("forgets an account's tokens on this device without revoking the Google grant", () => Effect.gen(function* () { const secrets = new Map(); storeTokens(secrets); @@ -438,7 +438,8 @@ describe("tokens", () => { yield* auth.removeTokens(SUB); expect(secrets.size).toBe(0); expect(yield* auth.hasTokens(SUB)).toBe(false); - expect(yield* Deferred.await(google.revoked)).toBe("refresh-1"); + // Google revokes per project, so a revoke here would sign out the user's other devices. + expect(Deferred.isDoneUnsafe(google.revoked)).toBe(false); }), ); }); diff --git a/modules/calendar/node/src/GoogleAuthLive.ts b/modules/calendar/node/src/GoogleAuthLive.ts index a54b5071c..fdf57d6fd 100644 --- a/modules/calendar/node/src/GoogleAuthLive.ts +++ b/modules/calendar/node/src/GoogleAuthLive.ts @@ -251,17 +251,6 @@ export const make = Effect.fn("GoogleAuthLive.make")(function* ( }); }); - /** - * Revokes a grant, best effort. Google revokes every token of the account for the whole - * OAuth project, so this only runs when an account is removed, never when it is replaced. - */ - const revoke = (token: string) => - http - .execute( - HttpClientRequest.post(REVOKE_ENDPOINT).pipe(HttpClientRequest.bodyUrlParams({ token })), - ) - .pipe(Effect.timeout(TOKEN_REQUEST_TIMEOUT), Effect.ignore); - // ── Access tokens ────────────────────────────────────────────────── const accessToken = (sub: string, rejected?: string) => @@ -599,22 +588,20 @@ export const make = Effect.fn("GoogleAuthLive.make")(function* ( ); }); + /** + * Forgets the sign-in on this device only. Google revokes per OAuth project, not per device, + * so revoking here would also end the user's other devices' Mail and Calendar sign-ins. + */ const removeTokens: GoogleAuthShape["removeTokens"] = (sub) => - Effect.gen(function* () { - const stored = yield* withAccountLock(sub)( - Effect.gen(function* () { - const stored = yield* readTokens(sub).pipe(Effect.orElseSucceed(() => Option.none())); - if (SUBJECT.test(sub)) - yield* secrets - .remove(tokenSecretName(sub)) - .pipe(Effect.catch(() => Effect.logWarning("Could not delete a Google sign-in."))); - cache.delete(sub); - return stored; - }), - ); - if (Option.isSome(stored)) - yield* Effect.forkIn(revoke(stored.value.refreshToken), scope, { startImmediately: true }); - }); + withAccountLock(sub)( + Effect.gen(function* () { + if (SUBJECT.test(sub)) + yield* secrets + .remove(tokenSecretName(sub)) + .pipe(Effect.catch(() => Effect.logWarning("Could not delete a Google sign-in."))); + cache.delete(sub); + }), + ); return GoogleAuth.of({ clientStatus: clients.status, diff --git a/modules/drive/engine/src/DriveService.test.ts b/modules/drive/engine/src/DriveService.test.ts index 74e480ce5..e4c4ab829 100644 --- a/modules/drive/engine/src/DriveService.test.ts +++ b/modules/drive/engine/src/DriveService.test.ts @@ -204,6 +204,7 @@ function hostAccount(): ConnectedAccount { }); return { accountId: "u-1", + email: "u-1@example.test", client, connections: () => client.connections(), providerToken: (id) => client.providerToken(id), @@ -214,8 +215,10 @@ function hostAccount(): ConnectedAccount { } const connected = Layer.succeed(OtterAccountSession, { current: () => hostAccount(), - attach: async () => "u-1", - detach: async () => {}, + providerAccount: () => null, + status: () => ({ pendingSignIn: null, error: null }), + signIn: () => Promise.reject(new Error("unused")), + signOut: async () => {}, subscribe: () => () => {}, }); const setup = ( @@ -247,8 +250,10 @@ describe("DriveService", () => { const identity = hostAccount(); const account = Layer.succeed(OtterAccountSession, { current: () => (signedIn ? identity : null), - attach: async () => "u-1", - detach: async () => {}, + providerAccount: () => null, + status: () => ({ pendingSignIn: null, error: null }), + signIn: () => Promise.reject(new Error("unused")), + signOut: async () => {}, subscribe: () => () => {}, }); const drive = yield* setup(fake, account, (api) => ({ diff --git a/modules/mail/engine/src/MailModule.ts b/modules/mail/engine/src/MailModule.ts index b996496b7..a50e83d71 100644 --- a/modules/mail/engine/src/MailModule.ts +++ b/modules/mail/engine/src/MailModule.ts @@ -11,7 +11,14 @@ import { MailToolkit, toolHandlers } from "./MailToolkit.ts"; export { MailRuntime } from "./MailRuntime.ts"; const rpcHandlers = Effect.gen(function* () { - const runtime = yield* MailRuntime; + const engine = yield* MailRuntime; + // Handlers that finish without a value answer null: `undefined` is not JSON, and an + // `Unknown` success would fail to encode (Mail preferences never synced). + const runtime: typeof engine = { + ...engine, + call: (operation, input) => + Effect.map(engine.call(operation, input), (value) => (value === undefined ? null : value)), + }; return MailContract.group.of({ "mail.gmail.addOutlookAccount": (input) => runtime.call("gmail:addOutlookAccount", input), "mail.gmail.mailProviders": (input) => diff --git a/modules/mail/node/src/index.ts b/modules/mail/node/src/index.ts index 3b749f0a5..beb23cfda 100644 --- a/modules/mail/node/src/index.ts +++ b/modules/mail/node/src/index.ts @@ -42,7 +42,7 @@ export const mailNodeModule = (options: MailNodeOptions) => ({ try: () => workerLifecycle( () => { - const session = account?.current(); + const session = account?.providerAccount(); return openMailWorker({ ...options, ...(session ? { session } : {}) }, (event) => Effect.runSync(PubSub.publish(events, event)), ); diff --git a/packages/composition/src/contracts.test.ts b/packages/composition/src/contracts.test.ts index 9e8601cbb..a1f152244 100644 --- a/packages/composition/src/contracts.test.ts +++ b/packages/composition/src/contracts.test.ts @@ -10,7 +10,7 @@ describe("composeContracts", () => { expect([...code.group.requests.keys()]).toContain("modules.list"); expect([...code.group.requests.keys()].some((tag) => tag.startsWith("mail."))).toBe(false); - expect(code.scopes["account.session.attach"]).toBe("settings:write"); + expect(code.scopes["account.session.signIn"]).toBe("settings:write"); expect(code.scopes["account.connections.list"]).toBe("orchestration:read"); const calendar = composeContracts(PROFILES.calendar); diff --git a/packages/module-kit/src/account/index.ts b/packages/module-kit/src/account/index.ts index cfdace9c2..c0d4921ef 100644 --- a/packages/module-kit/src/account/index.ts +++ b/packages/module-kit/src/account/index.ts @@ -3,6 +3,9 @@ import { OtterCloudClient, type ChannelEvent, type CloudChannel, + type Connection, + type Provider, + type ProviderToken, } from "@otterware/otter-cloud-client"; import * as Context from "effect/Context"; import * as Schema from "effect/Schema"; @@ -17,14 +20,57 @@ export interface ProviderAccount { subscribe: (listener: (event: ChannelEvent) => void) => () => void; } export interface ConnectedAccount extends ProviderAccount { + readonly email: string; readonly client: OtterCloudClient; } +/** + * Google authorized on this device only. One consent covers Mail, Calendar and contacts here; + * the refresh token stays in this environment's secret store and never reaches Otter Cloud. + */ +export interface DeviceProviders { + readonly connections: () => Promise>; + readonly providerToken: (connectionId: string) => Promise; + /** Returns the consent URL; the connection appears once the user approves it. */ + readonly connect: (provider: typeof Provider.Type) => Promise<{ readonly url: string }>; + readonly disconnect: (connectionId: string) => Promise; + readonly subscribe: (listener: () => void) => () => void; +} +/** What Mail and Calendar use: account data from Cloud, provider grants from this device. */ +export interface DeviceProviderAccount extends ConnectedAccount { + readonly connect: DeviceProviders["connect"]; + readonly disconnect: DeviceProviders["disconnect"]; +} +/** Where the user approves this environment's own sign-in, from any browser. */ +export interface SignInPrompt { + readonly verificationUrl: string; + readonly userCode: string; + readonly expiresAt: number; +} +/** The environment's own Otter Account login, owned and renewed by the host. */ +export interface EnvironmentLogin { + /** A current access token for the environment's login, or null while signed out. */ + readonly accessToken: () => Promise; + /** Starts a device sign-in; the host stores the login and calls `signedIn` once approved. */ + readonly signIn: (signedIn: () => void) => Promise; + /** Revokes the environment's login at Accounts and forgets it. */ + readonly signOut: () => Promise; +} export class OtterAccountSession extends Context.Service< OtterAccountSession, { + /** The environment's account, for Code, Home and Drive. */ readonly current: () => ConnectedAccount | null; - readonly attach: (accessToken: string) => Promise; - readonly detach: () => Promise; + /** + * The account Mail and Calendar take provider connections from. Null on headless servers: + * Mail and Calendar on VMs are deferred, so signing a VM in must not start their sync. + */ + readonly providerAccount: () => DeviceProviderAccount | null; + readonly status: () => { + readonly pendingSignIn: SignInPrompt | null; + readonly error: string | null; + }; + readonly signIn: () => Promise; + readonly signOut: () => Promise; readonly subscribe: (listener: () => void) => () => void; } >()("@otterware/module-kit/account/OtterAccountSession") {} @@ -32,19 +78,26 @@ export class OtterAccountSession extends Context.Service< const SuiteSession = Schema.Struct({ token: Schema.String, expiresAt: Schema.Number, - user: Schema.Struct({ id: Schema.String }), + user: Schema.Struct({ id: Schema.String, email: Schema.String }), }); type SuiteSession = typeof SuiteSession.Type; const decodeSession = Schema.decodeUnknownSync(SuiteSession); +/** Accounts hands out the same suite session until a day before it ends, so hourly is cheap. */ +const RENEW_EVERY_MS = 60 * 60_000; -/** One scoped host account, shared by all enabled modules in an environment. */ -export async function makeOtterAccountSession(options: { +/** + * One scoped host account, shared by all enabled modules in an environment. It follows the + * environment's own login: no client attaches it and no open screen keeps it alive. + */ +export function makeOtterAccountSession(options: { readonly accountsUrl: string; readonly cloudOrigin: string; readonly audience: string; + readonly login: EnvironmentLogin; + /** This device's provider grants; null on headless servers (Mail and Calendar on VMs deferred). */ + readonly devices: DeviceProviders | null; readonly fetch?: typeof fetch; readonly WebSocket?: typeof WebSocket; - readonly storage?: { read(): Promise; write(value: SuiteSession | null): Promise }; }) { const fetcher = options.fetch ?? globalThis.fetch; const listeners = new Set<() => void>(); @@ -53,14 +106,12 @@ export async function makeOtterAccountSession(options: { let session: SuiteSession | null = null; let channel: CloudChannel | null = null; let retry: ReturnType | undefined; + let renewal: ReturnType | undefined; + let pendingSignIn: SignInPrompt | null = null; + let error: string | null = null; let generation = 0; let closed = false; - let pendingStorage = Promise.resolve(); - const persist = (value: SuiteSession | null) => { - const next = pendingStorage.catch(() => {}).then(() => options.storage?.write(value)); - pendingStorage = next; - return next; - }; + let refreshing: Promise | null = null; const changed = () => { for (const listener of [...listeners]) listener(); }; @@ -96,6 +147,13 @@ export async function makeOtterAccountSession(options: { if (!closed && current === account) retry = setTimeout(() => connect(account), 5_000); }); }; + const uninstall = () => { + if (!current && !session) return; + disconnect(); + current = null; + session = null; + changed(); + }; const install = (next: SuiteSession) => { session = next; if (current?.accountId === next.user.id) return; @@ -107,13 +165,15 @@ export async function makeOtterAccountSession(options: { fetch: fetcher, ...(options.WebSocket ? { WebSocket: options.WebSocket } : {}), sessionToken: async () => { + if (!session || session.expiresAt <= Date.now()) await refresh(); if (!session || session.user.id !== accountId || session.expiresAt <= Date.now()) - throw new Error("Sign in to your Otter Account again."); + throw new Error("Sign in to your Otter Account on this environment again."); return session.token; }, }); current = { accountId, + email: next.user.email, client, connections: () => client.connections(), providerToken: (id) => client.providerToken(id), @@ -129,43 +189,106 @@ export async function makeOtterAccountSession(options: { connect(current); changed(); }; - if (options.storage) { - const saved = await options.storage.read(); - if (saved !== null) { - const decoded = Schema.decodeUnknownOption(SuiteSession)(saved); - if (decoded._tag === "Some" && decoded.value.expiresAt > Date.now()) install(decoded.value); + const exchange = async (version: number) => { + const accessToken = await options.login.accessToken().catch(() => { + error = "This environment's Otter Account sign-in ended. Sign it in again."; + return null; + }); + if (closed || version !== generation) return; + if (!accessToken) { + uninstall(); + return; } - } + const response = await fetcher(`${options.accountsUrl.replace(/\/$/, "")}/suite/session`, { + method: "POST", + headers: { authorization: `Bearer ${accessToken}` }, + credentials: "omit", + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(15_000), + }); + if (closed || version !== generation) return; + // A rejected login signs the environment out; a transient failure keeps the last session. + if (response.status === 401 || response.status === 403) { + error = "This environment's Otter Account sign-in ended. Sign it in again."; + uninstall(); + return; + } + if (!response.ok) throw new Error(`Accounts answered ${response.status}.`); + const next = decodeSession(await response.json()); + if (closed || version !== generation) return; + error = null; + install(next); + }; + /** Re-reads the environment's login; concurrent callers share one exchange. */ + const refresh = (): Promise => { + if (refreshing) return refreshing; + const version = ++generation; + const before = error; + refreshing = exchange(version) + .catch(() => { + if (!current) error = "Could not reach Otter Accounts. Retrying."; + }) + .finally(() => { + refreshing = null; + // Identity changes notify from install/uninstall; renewals stay silent. + if (error !== before) changed(); + }); + return refreshing; + }; + const renew = () => { + if (closed) return; + void refresh().finally(() => { + if (!closed) renewal = setTimeout(renew, RENEW_EVERY_MS); + }); + }; + renew(); + const devices = options.devices; + const stopDevices = devices?.subscribe(() => { + for (const listener of [...events]) listener({ type: "connections.changed" }); + changed(); + }); + let providerAccount: { base: ConnectedAccount; account: DeviceProviderAccount } | null = null; const service: OtterAccountSession["Service"] = { current: () => current, - attach: async (accessToken) => { - const version = ++generation; - const response = await fetcher(`${options.accountsUrl.replace(/\/$/, "")}/suite/session`, { - method: "POST", - headers: { authorization: `Bearer ${accessToken}` }, - credentials: "omit", - cache: "no-store", - redirect: "error", - signal: AbortSignal.timeout(15_000), + providerAccount: () => { + if (!devices || !current) return null; + if (providerAccount?.base !== current) + providerAccount = { + base: current, + account: { + ...current, + connections: async () => [...(await devices.connections())], + providerToken: devices.providerToken, + connect: devices.connect, + disconnect: devices.disconnect, + }, + }; + return providerAccount.account; + }, + status: () => ({ + pendingSignIn: pendingSignIn && pendingSignIn.expiresAt > Date.now() ? pendingSignIn : null, + error, + }), + signIn: async () => { + const active = service.status().pendingSignIn; + if (active) return active; + const prompt = await options.login.signIn(() => { + pendingSignIn = null; + error = null; + void refresh(); }); - if (!response.ok) throw new Error("Could not connect your Otter Account. Sign in again."); - const next = decodeSession(await response.json()); - if (next.expiresAt <= Date.now()) throw new Error("Your Otter session has expired."); - if (closed || generation !== version) throw new Error("Account connection was cancelled."); - await persist(next); - if (closed || generation !== version) throw new Error("Account connection was cancelled."); - install(next); - return next.user.id; + pendingSignIn = prompt; + changed(); + return prompt; }, - detach: async () => { + signOut: async () => { + await options.login.signOut(); + pendingSignIn = null; + error = null; ++generation; - const previous = current; - current = null; - session = null; - disconnect(); - previous?.client.clearSession(); + uninstall(); changed(); - await persist(null); }, subscribe: (listener) => { listeners.add(listener); @@ -179,6 +302,8 @@ export async function makeOtterAccountSession(options: { close: () => { closed = true; ++generation; + stopDevices?.(); + clearTimeout(renewal); disconnect(); current = null; session = null; diff --git a/packages/module-kit/src/account/session.test.ts b/packages/module-kit/src/account/session.test.ts index 3cc3c1ad7..35436c407 100644 --- a/packages/module-kit/src/account/session.test.ts +++ b/packages/module-kit/src/account/session.test.ts @@ -1,84 +1,67 @@ // @effect-diagnostics globalDate:off -- Fixture models the Cloud wire expiry timestamp. import { describe, expect, it, vi } from "vite-plus/test"; -import { makeOtterAccountSession } from "./index.ts"; +import { makeOtterAccountSession, type EnvironmentLogin } from "./index.ts"; const suite = (id: string) => ({ token: `suite-${id}`, expiresAt: Date.now() + 3_600_000, - user: { id }, + user: { id, email: `${id}@example.test` }, }); const options = { accountsUrl: "https://accounts.example.test/v1/auth", cloudOrigin: "https://cloud.example.test", audience: "environment:test", + devices: null, }; +const login = (accessToken: () => Promise): EnvironmentLogin => ({ + accessToken, + signIn: () => Promise.reject(new Error("unused")), + signOut: async () => {}, +}); +const changed = (host: ReturnType) => + new Promise((resolve) => { + const stop = host.service.subscribe(() => { + stop(); + resolve(); + }); + }); + describe("host account session", () => { - it("exchanges the access grant at Accounts and retains only the suite session", async () => { - const stored: unknown[] = []; + it("follows the environment's own login, with no client attaching it", async () => { const fetcher = vi.fn(async (url, init) => { - if (String(url).endsWith("/suite/session")) { - expect(init?.headers).toEqual({ authorization: "Bearer account-access" }); - expect(init?.redirect).toBe("error"); - return Response.json(suite("alice")); - } - throw new Error("channel offline"); + if (!String(url).endsWith("/suite/session")) throw new Error("channel offline"); + expect(init?.headers).toEqual({ authorization: "Bearer environment-access" }); + return Response.json(suite("alice")); }); - const host = await makeOtterAccountSession({ + const signOut = vi.fn(async () => {}); + const host = makeOtterAccountSession({ ...options, fetch: fetcher, - storage: { - read: async () => null, - write: async (value) => { - stored.push(value); - }, - }, + login: { ...login(async () => "environment-access"), signOut }, }); try { - expect(await host.service.attach("account-access")).toBe("alice"); - expect(host.service.current()?.accountId).toBe("alice"); - expect(JSON.stringify(stored)).not.toContain("account-access"); - await host.service.detach(); + await changed(host); + expect(host.service.current()?.email).toBe("alice@example.test"); + await host.service.signOut(); + expect(signOut).toHaveBeenCalledOnce(); expect(host.service.current()).toBeNull(); - expect(stored.at(-1)).toBeNull(); } finally { host.close(); } }); - it("does not restore an account after sign-out cancels an in-flight exchange", async () => { - let finish!: (value: Response) => void; - const host = await makeOtterAccountSession({ - ...options, - fetch: () => - new Promise((resolve) => { - finish = resolve; - }), - }); - try { - const attaching = host.service.attach("old-token"); - await host.service.detach(); - finish(Response.json(suite("alice"))); - await expect(attaching).rejects.toThrow("cancelled"); - expect(host.service.current()).toBeNull(); - } finally { - host.close(); - } - }); - it("refreshes a grant without replacing the account used by running modules", async () => { - const host = await makeOtterAccountSession({ + it("gives Mail and Calendar no provider account without this device's grants", async () => { + const host = makeOtterAccountSession({ ...options, fetch: async (url) => { if (String(url).endsWith("/suite/session")) return Response.json(suite("alice")); throw new Error("offline"); }, + login: login(async () => "environment-access"), }); try { - await host.service.attach("first"); - const account = host.service.current(); - const changed = vi.fn(); - host.service.subscribe(changed); - await host.service.attach("renewed"); - expect(host.service.current()).toBe(account); - expect(changed).not.toHaveBeenCalled(); + await changed(host); + expect(host.service.current()?.accountId).toBe("alice"); + expect(host.service.providerAccount()).toBeNull(); } finally { host.close(); } diff --git a/packages/module-kit/src/contracts/account.ts b/packages/module-kit/src/contracts/account.ts index 66d24b527..84b814780 100644 --- a/packages/module-kit/src/contracts/account.ts +++ b/packages/module-kit/src/contracts/account.ts @@ -15,19 +15,30 @@ export class AccountSessionError extends Schema.TaggedError }, ) {} const errors = Schema.Union([AccountSessionError, EnvironmentAuthorizationError]); -const state = Schema.Struct({ accountId: Schema.NullOr(Schema.String) }); +const SignInPrompt = Schema.Struct({ + verificationUrl: Schema.String, + userCode: Schema.String, + expiresAt: Schema.Number, +}); +/** The environment's own account; a client compares it with its own sign-in. */ +const state = Schema.Struct({ + accountId: Schema.NullOr(Schema.String), + email: Schema.NullOr(Schema.String), + pendingSignIn: Schema.NullOr(SignInPrompt), + error: Schema.NullOr(Schema.String), +}); export const AccountRpcGroup = RpcGroup.make( - Rpc.make("account.session.attach", { - payload: Schema.Struct({ accessToken: Schema.String }), - success: state, + Rpc.make("account.session.state", { payload: Schema.Struct({}), success: state, error: errors }), + Rpc.make("account.session.signIn", { + payload: Schema.Struct({}), + success: SignInPrompt, error: errors, }), - Rpc.make("account.session.detach", { + Rpc.make("account.session.signOut", { payload: Schema.Struct({}), success: Schema.Void, error: errors, }), - Rpc.make("account.session.state", { payload: Schema.Struct({}), success: state, error: errors }), Rpc.make("account.connections.list", { payload: Schema.Struct({}), success: Schema.Array(Connection), @@ -49,9 +60,9 @@ export const AccountRpcGroup = RpcGroup.make( }), ); export const ACCOUNT_RPC_SCOPES = { - "account.session.attach": AuthSettingsWriteScope, - "account.session.detach": AuthSettingsWriteScope, "account.session.state": AuthOrchestrationReadScope, + "account.session.signIn": AuthSettingsWriteScope, + "account.session.signOut": AuthSettingsWriteScope, "account.connections.list": AuthOrchestrationReadScope, "account.connections.connect": AuthSettingsWriteScope, "account.connections.disconnect": AuthSettingsWriteScope, diff --git a/packages/module-kit/src/server/AccountRpc.ts b/packages/module-kit/src/server/AccountRpc.ts index 01468ee61..d11900579 100644 --- a/packages/module-kit/src/server/AccountRpc.ts +++ b/packages/module-kit/src/server/AccountRpc.ts @@ -12,29 +12,42 @@ export const accountHandlers = Effect.gen(function* () { if (Option.isNone(service)) throw new Error("Account services are unavailable."); return run(service.value); }, - catch: () => + // Host messages are written for people and carry no credentials. + catch: (cause) => new AccountSessionError({ message: - "Could not connect to your Otter Account. Sign in again or check this environment's connection.", + cause instanceof Error + ? cause.message + : "Could not connect to your Otter Account. Sign in again or check this environment's connection.", }), }); - const connected = (host: OtterAccountSession["Service"]) => { - const account = host.current(); - if (!account) throw new Error("Sign in to your Otter Account."); + /** Provider grants belong to this device, not to Cloud; headless servers have none (VMs deferred). */ + const device = (host: OtterAccountSession["Service"]) => { + const account = host.providerAccount(); + if (!account) + throw new Error( + host.current() + ? "Google can't be connected on this environment yet." + : "Sign this environment in to your Otter Account first.", + ); return account; }; return AccountRpcGroup.of({ - "account.session.attach": ({ accessToken }) => - call(async (host) => ({ accountId: await host.attach(accessToken) })), - "account.session.detach": () => call((host) => host.detach()), "account.session.state": () => - call(async (host) => ({ accountId: host.current()?.accountId ?? null })), - "account.connections.list": () => call((host) => connected(host).connections()), - "account.connections.connect": ({ provider, scopes, returnTo }) => - call((host) => connected(host).client.connect(provider, scopes, returnTo)), - "account.connections.disconnect": ({ connectionId }) => call(async (host) => { - await connected(host).client.disconnect(connectionId); + const account = host.current(); + return { + accountId: account?.accountId ?? null, + email: account?.email ?? null, + ...host.status(), + }; }), + "account.session.signIn": () => call((host) => host.signIn()), + "account.session.signOut": () => call((host) => host.signOut()), + "account.connections.list": () => + call(async (host) => [...(await (host.providerAccount()?.connections() ?? []))]), + "account.connections.connect": ({ provider }) => call((host) => device(host).connect(provider)), + "account.connections.disconnect": ({ connectionId }) => + call((host) => device(host).disconnect(connectionId)), }); }); diff --git a/packages/otter-cloud-client/package.json b/packages/otter-cloud-client/package.json index 713282029..f6be8cb07 100644 --- a/packages/otter-cloud-client/package.json +++ b/packages/otter-cloud-client/package.json @@ -9,6 +9,7 @@ "types": "./src/drive/client.ts", "import": "./src/drive/client.ts" }, + "./scopes": "./src/scopes.ts", "./session": "./src/session.ts" }, "scripts": { diff --git a/packages/otter-cloud-client/src/contracts.ts b/packages/otter-cloud-client/src/contracts.ts index 65682fc87..29737bc4f 100644 --- a/packages/otter-cloud-client/src/contracts.ts +++ b/packages/otter-cloud-client/src/contracts.ts @@ -43,6 +43,7 @@ export const SyncPage = Schema.Struct({ }); export const ChannelTicket = Schema.Struct({ ticket: Schema.String, expiresAt: Schema.Number }); export const ConnectResult = Schema.Struct({ url: Schema.String }); +export { PROVIDER_SCOPES } from "./scopes.ts"; export const ChannelEvent = Schema.Union([ Schema.Struct({ type: Schema.Literal("sync.changed") }), Schema.Struct({ type: Schema.Literal("connections.changed") }), diff --git a/packages/otter-cloud-client/src/scopes.ts b/packages/otter-cloud-client/src/scopes.ts new file mode 100644 index 000000000..1fe603827 --- /dev/null +++ b/packages/otter-cloud-client/src/scopes.ts @@ -0,0 +1,16 @@ +// Dependency-free, so any host or worker can import it without bundling Effect. +/** + * The one consent a device asks for, so Mail, Calendar and contacts on it share one sign-in and + * adding a module never asks again. Google covers Mail (including Gmail IMAP fill, signatures, + * contacts and invitations) and Calendar. Each device connects Google itself. + */ +export const PROVIDER_SCOPES = { + google: [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/calendar", + ], + microsoft: ["User.Read", "Mail.ReadWrite", "Mail.Send", "Contacts.Read", "Calendars.ReadWrite"], +} as const; From 5636209ddae3314079fbd74061ce30a0f1101a96 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann <47634664+Laurin-Notemann@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:19:07 +0000 Subject: [PATCH 2/2] feat(otterware): Google in the browser through Mail's sealed relay, build's desktop client The browser environment keeps its own Google grant the way standalone Otter Mail web does: Mail's relay runs consent with its web client and seals the refresh token for the Otter Account; the browser stores it and asks the relay for access tokens with its suite session. Cloud-held grants are no longer read. Release bundles bake the existing T3CODE_GOOGLE_CLIENT_ID/_SECRET desktop client, checked against Google before the build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 24 +++ apps/server/package.json | 2 - apps/server/src/otterware/DeviceGoogle.ts | 22 ++- apps/server/vite.config.ts | 8 + .../otterware/account/ProviderConnections.tsx | 72 ++++--- .../src/otterware/account/browserGoogle.ts | 59 ++++++ .../otterware/account/useConnectProvider.ts | 10 +- .../browser/BrowserAccountBridge.tsx | 8 +- .../content/main/settings/settings-ui.tsx | 1 - infra/cloud/mail/src/gmail.ts | 57 +++++- infra/cloud/mail/src/worker.test.ts | 30 +++ infra/cloud/mail/src/worker.ts | 42 +++- infra/cloud/mail/vars.json | 1 + infra/cloud/mail/wrangler.test.jsonc | 1 + .../engine/src/CalendarAccountSession.ts | 4 +- .../src/account/BrowserAccount.ts | 3 + .../src/account/BrowserGoogle.ts | 182 ++++++++++++++++++ .../src/account/contracts.ts | 11 +- .../src/account/credential.ts | 8 +- .../src/account/handlers.ts | 40 ++-- .../src/account/mailHost.ts | 18 +- packages/browser-environment/src/host.ts | 2 + .../browser-environment/src/worker.test.ts | 53 ++++- pnpm-lock.yaml | 6 - 24 files changed, 579 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/otterware/account/browserGoogle.ts create mode 100644 packages/browser-environment/src/account/BrowserGoogle.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8d2f2837..045088567 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -458,7 +458,31 @@ jobs: T3CODE_ACCOUNTS_URL: ${{ needs.relay_public_config.outputs.accounts_url }} T3CODE_ACCOUNTS_CLI_CLIENT_ID: ${{ needs.relay_public_config.outputs.accounts_cli_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + # The desktop app's Google client ("Desktop app" type; Google treats its secret as not + # confidential), baked into the server bundle for "Connect Google on this device". + T3CODE_GOOGLE_CLIENT_ID: ${{ vars.T3CODE_GOOGLE_CLIENT_ID }} + T3CODE_GOOGLE_CLIENT_SECRET: ${{ secrets.T3CODE_GOOGLE_CLIENT_SECRET }} steps: + # A bogus code proves the pair: Google answers invalid_grant only once the client and its + # secret check out, so a wrong or missing secret stops the release before anything ships. + - name: Check the Google client + shell: bash + run: | + if [[ -z "$T3CODE_GOOGLE_CLIENT_ID" || -z "$T3CODE_GOOGLE_CLIENT_SECRET" ]]; then + echo "::error::Set the T3CODE_GOOGLE_CLIENT_ID variable and T3CODE_GOOGLE_CLIENT_SECRET secret; without them the app can't connect Google." + exit 1 + fi + error="$(curl -sS https://oauth2.googleapis.com/token \ + --data-urlencode grant_type=authorization_code \ + --data-urlencode code=release-check \ + --data-urlencode redirect_uri=http://127.0.0.1 \ + --data-urlencode "client_id=$T3CODE_GOOGLE_CLIENT_ID" \ + --data-urlencode "client_secret=$T3CODE_GOOGLE_CLIENT_SECRET" | jq -r .error)" + if [[ "$error" != "invalid_grant" ]]; then + echo "::error::Google rejected T3CODE_GOOGLE_CLIENT_ID with T3CODE_GOOGLE_CLIENT_SECRET ($error)." + exit 1 + fi + - name: Checkout uses: actions/checkout@v6 with: diff --git a/apps/server/package.json b/apps/server/package.json index f6b8d498f..bd372340e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,8 +37,6 @@ "@muse-code/sdk": "1.4.6", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", - "@otterware/calendar-contracts": "workspace:*", - "@otterware/calendar-node": "workspace:*", "@otterware/composition": "workspace:*", "@otterware/module-kit": "workspace:*", "@otterware/otter-cloud-client": "workspace:*", diff --git a/apps/server/src/otterware/DeviceGoogle.ts b/apps/server/src/otterware/DeviceGoogle.ts index 646df9bd3..92aac7c5f 100644 --- a/apps/server/src/otterware/DeviceGoogle.ts +++ b/apps/server/src/otterware/DeviceGoogle.ts @@ -4,8 +4,9 @@ * refresh token kept in this environment's secret store. It never reaches Otter Cloud and does * not follow the account to other devices; each device connects Google itself. * - * The client is a Google "Desktop app" OAuth client (`T3CODE_GOOGLE_CLIENT_ID`/`_SECRET`, the - * same configuration Calendar's local sign-in reads); its loopback redirect runs on this machine. + * The client is a Google "Desktop app" OAuth client: `T3CODE_GOOGLE_CLIENT_ID`/`_SECRET` when + * set, otherwise the one baked into the build (the same values Calendar's local sign-in reads). + * Google treats a desktop client's secret as not confidential. Its loopback redirect runs here. * * @module DeviceGoogle */ @@ -18,6 +19,9 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +declare const __T3CODE_BUILD_GOOGLE_CLIENT_ID__: string | undefined; +declare const __T3CODE_BUILD_GOOGLE_CLIENT_SECRET__: string | undefined; + const SECRET = "otterware-device-google"; const ID_PREFIX = "device:google:"; @@ -36,10 +40,22 @@ interface TokenResponse { export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore; - const client = yield* Config.all({ + const configured = yield* Config.all({ clientId: Config.String("T3CODE_GOOGLE_CLIENT_ID").pipe(Config.withDefault("")), clientSecret: Config.String("T3CODE_GOOGLE_CLIENT_SECRET").pipe(Config.withDefault("")), }); + const client = configured.clientId + ? configured + : { + clientId: + typeof __T3CODE_BUILD_GOOGLE_CLIENT_ID__ === "undefined" + ? "" + : __T3CODE_BUILD_GOOGLE_CLIENT_ID__, + clientSecret: + typeof __T3CODE_BUILD_GOOGLE_CLIENT_SECRET__ === "undefined" + ? "" + : __T3CODE_BUILD_GOOGLE_CLIENT_SECRET__, + }; const listeners = new Set<() => void>(); const access = new Map(); let cancel: (() => void) | undefined; diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index baad4abb8..7bdb7c660 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -132,6 +132,14 @@ export default mergeConfig( __T3CODE_BUILD_ACCOUNTS_CLI_CLIENT_ID__: JSON.stringify( repoEnv.T3CODE_ACCOUNTS_CLI_CLIENT_ID?.trim() ?? "", ), + // The desktop app's Google client ("Desktop app" type, secret not confidential): + // "Connect Google on this device" and Calendar's local sign-in. + __T3CODE_BUILD_GOOGLE_CLIENT_ID__: JSON.stringify( + repoEnv.T3CODE_GOOGLE_CLIENT_ID?.trim() ?? "", + ), + __T3CODE_BUILD_GOOGLE_CLIENT_SECRET__: JSON.stringify( + repoEnv.T3CODE_GOOGLE_CLIENT_SECRET?.trim() ?? "", + ), __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify( repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "", ), diff --git a/apps/web/src/otterware/account/ProviderConnections.tsx b/apps/web/src/otterware/account/ProviderConnections.tsx index d4d560093..26987bde9 100644 --- a/apps/web/src/otterware/account/ProviderConnections.tsx +++ b/apps/web/src/otterware/account/ProviderConnections.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import * as Effect from "effect/Effect"; import { useAuth } from "../../accounts/AccountProvider"; import { Button } from "../../components/ui/button"; import { SettingsRow } from "../../components/settings/settingsLayout"; import { useModuleTarget, browserClient, runInBrowser } from "../browser/runtime"; import { AccountConnections } from "./AccountConnections"; +import { connectGoogleInBrowser } from "./browserGoogle"; /** Google on this device for Mail, Calendar and contacts, including standalone profiles. */ export function ProviderConnections() { @@ -26,57 +27,72 @@ function BrowserConnections() { ); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const refresh = useCallback(() => { + void read() + .then((result) => setConnections(result.connections)) + .catch(() => {}); + }, []); useEffect(() => { - let active = true; - const refresh = () => { - void read() - .then((result) => { - if (active) setConnections(result.connections); - }) - .catch(() => {}); - }; refresh(); window.addEventListener("focus", refresh); - return () => { - active = false; - window.removeEventListener("focus", refresh); - }; - }, [auth.userId]); + return () => window.removeEventListener("focus", refresh); + }, [auth.userId, refresh]); + const run = (action: () => Promise) => { + setBusy(true); + setError(null); + void action() + .then(refresh) + .catch((cause: unknown) => + setError(cause instanceof Error ? cause.message : "Could not update Google."), + ) + .finally(() => setBusy(false)); + }; + if (!auth.isSignedIn) return null; return ( <> run(connectGoogleInBrowser)}> + Connect Google on this device + + } /> {connections.map((connection) => ( { - setBusy(true); - void runInBrowser( - Effect.flatMap(browserClient, (client) => - client["browser.account.disconnect"]({ connectionId: connection.id }), + onClick={() => + run(() => + runInBrowser( + Effect.flatMap(browserClient, (client) => + client["browser.account.disconnect"]({ connectionId: connection.id }), + ), ), ) - .then(read) - .then((result) => setConnections(result.connections)) - .catch((cause) => setError(String(cause))) - .finally(() => setBusy(false)); - }} + } > - Disconnect + Remove from this device } /> ))} - {error &&

{error}

} + {error ? ( +

+ {error} +

+ ) : null} ); } diff --git a/apps/web/src/otterware/account/browserGoogle.ts b/apps/web/src/otterware/account/browserGoogle.ts new file mode 100644 index 000000000..cb3e4b0cd --- /dev/null +++ b/apps/web/src/otterware/account/browserGoogle.ts @@ -0,0 +1,59 @@ +import * as Effect from "effect/Effect"; +import { browserClient, runInBrowser } from "../browser/runtime"; + +/** Otter Mail's relay: it runs Google's consent for browsers and seals the grant (BrowserGoogle). */ +export const MAIL_RELAY_URL = + (import.meta.env.VITE_OTTER_MAIL_RELAY_URL as string | undefined) || + "https://relay.mail.otterware.app"; + +/** + * Connects Google in this browser: the relay's consent opens in a popup and posts back a grant + * sealed for this Otter Account, which this browser keeps. Other devices connect their own. + */ +export async function connectGoogleInBrowser(): Promise { + // Opened before the first await so the browser treats it as part of the click. + const popup = window.open("about:blank", "otter-gmail-sign-in", "popup,width=520,height=680"); + if (!popup) throw new Error("Allow pop-ups for this site to connect Google."); + try { + const { url } = await runInBrowser( + Effect.flatMap(browserClient, (client) => + client["browser.account.connect"]({ + provider: "google", + scopes: [], + returnTo: window.location.href, + }), + ), + ); + popup.location.href = url; + const grant = await new Promise<{ email: string; sealed: string }>((resolve, reject) => { + const done = () => { + window.removeEventListener("message", onMessage); + window.clearInterval(closedCheck); + }; + const onMessage = (event: MessageEvent) => { + const data = event.data as { + type?: string; + result?: { email: string; sealed: string }; + error?: string; + }; + if (event.origin !== new URL(MAIL_RELAY_URL).origin || data?.type !== "otter:gmail-sign-in") + return; + done(); + if (data.result) resolve({ email: data.result.email, sealed: data.result.sealed }); + else reject(new Error(data.error ?? "Google sign-in didn't work.")); + }; + const closedCheck = window.setInterval(() => { + if (popup.closed) { + done(); + reject(new Error("Google sign-in was closed before it finished.")); + } + }, 500); + window.addEventListener("message", onMessage); + }); + await runInBrowser( + Effect.flatMap(browserClient, (client) => client["browser.account.saveGoogle"](grant)), + ); + } finally { + if (!popup.closed) popup.close(); + } +} diff --git a/apps/web/src/otterware/account/useConnectProvider.ts b/apps/web/src/otterware/account/useConnectProvider.ts index 3ad9ea8f8..cfaef5f12 100644 --- a/apps/web/src/otterware/account/useConnectProvider.ts +++ b/apps/web/src/otterware/account/useConnectProvider.ts @@ -5,14 +5,15 @@ import { useCallback } from "react"; import { ensureLocalApi } from "../../localApi"; import { useAtomCommand } from "../../state/use-atom-command"; import { useModuleTarget } from "../browser/runtime"; +import { connectGoogleInBrowser } from "./browserGoogle"; import { connectProvider } from "./rpc"; export type ConnectableProvider = keyof typeof PROVIDER_SCOPES; /** * Connects Google on this device: one consent for Mail, Calendar and contacts here, kept by - * the environment. It does not follow the account to other devices. Settings, Mail and - * Calendar all start it here. + * the environment (or this browser). It does not follow the account to other devices. + * Settings, Mail and Calendar all start it here. */ export function useConnectProvider(environmentId?: EnvironmentId) { const moduleTarget = useModuleTarget(); @@ -26,9 +27,8 @@ export function useConnectProvider(environmentId?: EnvironmentId) { scopes: [...PROVIDER_SCOPES[provider]], returnTo: window.location.href, }; - // Google is authorized per device. The in-browser environment has no device-local - // Google yet, and Cloud no longer holds new grants, so only environments connect it. - if (!targetEnvironment) throw new Error("Google can't be connected in this browser yet."); + // Google is authorized per device: this browser keeps its own sealed grant. + if (!targetEnvironment) return connectGoogleInBrowser(); const result = await connect({ environmentId: targetEnvironment, input }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); const url = result.value.url; diff --git a/apps/web/src/otterware/browser/BrowserAccountBridge.tsx b/apps/web/src/otterware/browser/BrowserAccountBridge.tsx index 680b89bd1..795de675a 100644 --- a/apps/web/src/otterware/browser/BrowserAccountBridge.tsx +++ b/apps/web/src/otterware/browser/BrowserAccountBridge.tsx @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import { useAuth } from "../../accounts/AccountProvider"; import { resolveCloudPublicConfig } from "../../cloud/publicConfig"; +import { MAIL_RELAY_URL } from "../account/browserGoogle"; import { browserClient, runInBrowser } from "./runtime"; const CLOUD_ORIGIN = @@ -32,7 +33,12 @@ export function BrowserAccountBridge() { if (!active || accessToken === null) return; await runInBrowser( Effect.flatMap(browserClient, (client) => - client["browser.account.attach"]({ accessToken, accountsUrl, cloudOrigin: CLOUD_ORIGIN }), + client["browser.account.attach"]({ + accessToken, + accountsUrl, + cloudOrigin: CLOUD_ORIGIN, + mailRelayUrl: MAIL_RELAY_URL, + }), ), ).catch(() => {}); }; diff --git a/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx b/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx index 7c72f9345..13d55c543 100644 --- a/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx +++ b/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx @@ -13,7 +13,6 @@ import { SelectItem, } from "~/components/ui/select"; import { Undo2Icon } from "lucide-react"; -export { SettingsGroup } from "~/components/settings/SettingsGroup"; export { SettingsSearchTarget } from "~/components/settings/settingsLayout"; export const MailSettingScope = createContext<"account" | "environment" | "client">("account"); export function SettingsRow(props: ComponentProps) { diff --git a/infra/cloud/mail/src/gmail.ts b/infra/cloud/mail/src/gmail.ts index 98a851a99..5b671c3d5 100644 --- a/infra/cloud/mail/src/gmail.ts +++ b/infra/cloud/mail/src/gmail.ts @@ -6,11 +6,14 @@ * nothing. The desktop app signs in to Gmail itself and never comes here. * * GET /v1/gmail/authorize (popup) → Google's consent + * POST /v1/gmail/start Otterware's apps (bearer session): `{ url }` of the same consent, + * asking for Mail, Calendar and contacts at once * GET /v1/gmail/callback → posts the sealed token and a first access token to the page * POST /v1/gmail/token `{ sealed }` → a fresh access token (410 once Google revoked it) * POST /v1/dev/gmail `pnpm dev:demo` only: a saved sign-in, sealed as the popup would */ +import { PROVIDER_SCOPES } from "@otterware/otter-cloud-client/scopes"; import { GMAIL_SCOPES } from "../contracts/google.ts"; import { EncryptJWT, jwtDecrypt, jwtVerify, SignJWT } from "jose"; @@ -30,9 +33,27 @@ export const callbackUrl = (env: Env) => `${env.BETTER_AUTH_URL}/v1/gmail/callba const key = (env: Env, purpose: "state" | "seal") => derivedKey(env.BETTER_AUTH_SECRET, `otter-mail gmail ${purpose}`); -/** Google's consent screen for the web client, for the signed-in user. */ -export async function authorizeUrl(env: Env, userId: string, loginHint?: string): Promise { - const state = await new SignJWT({}) +/** Otterware's browser apps: one consent covers Mail, Calendar and contacts in that browser. */ +const SUITE_SCOPES = ["openid", "email", "profile", ...PROVIDER_SCOPES.google]; + +/** Otterware app origins that sign in here with a bearer session (no relay cookie). */ +export const suiteOrigins = (env: Env) => + (env.SUITE_ORIGINS ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + +/** + * Google's consent screen for the web client, for the signed-in user. `suiteOrigin` is the + * Otterware app that asked; the state carries it, since Google's redirect back has no cookie. + */ +export async function authorizeUrl( + env: Env, + userId: string, + loginHint?: string, + suiteOrigin?: string, +): Promise { + const state = await new SignJWT(suiteOrigin ? { origin: suiteOrigin } : {}) .setProtectedHeader({ alg: "HS256" }) .setSubject(userId) .setExpirationTime("10m") @@ -42,7 +63,7 @@ export async function authorizeUrl(env: Env, userId: string, loginHint?: string) client_id: clientId(env), redirect_uri: callbackUrl(env), response_type: "code", - scope: GMAIL_SCOPES.join(" "), + scope: (suiteOrigin ? SUITE_SCOPES : GMAIL_SCOPES).join(" "), access_type: "offline", // Consent every time, so Google always returns a refresh token. prompt: "consent", @@ -52,10 +73,24 @@ export async function authorizeUrl(env: Env, userId: string, loginHint?: string) return url.toString(); } +/** Who a sign-in state was issued to, and for which Otterware app; throws if forged or stale. */ +export async function readState( + env: Env, + state: string, +): Promise<{ userId: string; origin: string | null }> { + const { payload } = await jwtVerify<{ origin?: string }>(state, await key(env, "state"), { + algorithms: ["HS256"], + }); + const origin = typeof payload.origin === "string" ? payload.origin : null; + return { + userId: payload.sub!, + origin: origin && suiteOrigins(env).includes(origin) ? origin : null, + }; +} + /** The Otter user a sign-in state was issued to; throws if forged or stale. */ export async function stateUser(env: Env, state: string): Promise { - const { payload } = await jwtVerify(state, await key(env, "state"), { algorithms: ["HS256"] }); - return payload.sub!; + return (await readState(env, state)).userId; } type GoogleTokens = { @@ -196,7 +231,11 @@ export async function refresh(env: Env, userId: string, sealed: string) { } /** The popup's last page: hands the result (or error) to the app and closes. */ -export function popupResponse(env: Env, message: { result?: SignInResult; error?: string }) { +export function popupResponse( + env: Env, + message: { result?: SignInResult; error?: string }, + suiteOrigin: string | null = null, +) { const payload = JSON.stringify({ type: "otter:gmail-sign-in", ...message }).replace( / const message = ${payload}; if (window.opener) { - window.opener.postMessage(message, ${JSON.stringify(env.APP_ORIGIN)}); + window.opener.postMessage(message, ${JSON.stringify(suiteOrigin ?? env.APP_ORIGIN)}); window.close(); -} else { +} else if (${JSON.stringify(suiteOrigin === null)}) { // Signed in in the app's own tab (the browser blocked the popup): take the answer back there. location.replace(${JSON.stringify(`${env.APP_ORIGIN}/app`)} + "#gmail-sign-in=" + encodeURIComponent(JSON.stringify(message))); } diff --git a/infra/cloud/mail/src/worker.test.ts b/infra/cloud/mail/src/worker.test.ts index 4600ff2cc..352c82f02 100644 --- a/infra/cloud/mail/src/worker.test.ts +++ b/infra/cloud/mail/src/worker.test.ts @@ -50,6 +50,7 @@ const IOS_CLIENT_ID = "test-ios-client.apps.googleusercontent.com"; const IOS_STORE_CLIENT_ID = "test-ios-store-client.apps.googleusercontent.com"; const APP_ORIGIN = "http://app.test"; const PUSH_AUDIENCE = "https://relay.test/push/gmail"; +const SUITE_ORIGIN = "http://otterware.test"; const PUSH_SERVICE_ACCOUNT = "push@test.iam.gserviceaccount.com"; const LEGACY_PUSH_SERVICE_ACCOUNT = "push@legacy.iam.gserviceaccount.com"; const root = NodePath.resolve(import.meta.dirname, ".."); @@ -184,6 +185,7 @@ beforeAll(async () => { GOOGLE_GMAIL_CLIENT_ID: { type: "plain_text", value: WEB_CLIENT_ID }, GOOGLE_GMAIL_CLIENT_SECRET: { type: "plain_text", value: "web-secret" }, APP_ORIGIN: { type: "plain_text", value: APP_ORIGIN }, + SUITE_ORIGINS: { type: "plain_text", value: SUITE_ORIGIN }, COOKIE_DOMAIN: { type: "plain_text", value: "" }, TUNNEL_TEST_TARGET: { type: "plain_text", value: mailTarget }, PUSH_AUDIENCE: { type: "plain_text", value: PUSH_AUDIENCE }, @@ -1114,6 +1116,34 @@ describe("web app", () => { ).toBe(410); }); + it("connects Google for an Otterware app that has only a bearer session", async () => { + const owner = await signIn("suite-owner@example.com"); + const start = (origin: string) => + fetch(`${base}/v1/gmail/start`, { + method: "POST", + headers: { authorization: `Bearer ${owner.token}`, origin }, + }); + expect((await start("https://evil.example")).status).toBe(403); + const started = await start(SUITE_ORIGIN); + expect(started.status).toBe(200); + const url = new URL(((await started.json()) as { url: string }).url); + expect(url.searchParams.get("scope")).toContain("https://www.googleapis.com/auth/calendar"); + // Google's redirect back carries no session: the signed state names the user and the app. + const html = await ( + await fetch( + `${base}/v1/gmail/callback?code=code:suite%40example.com&state=${url.searchParams.get("state")}`, + ) + ).text(); + expect(html).toContain(`postMessage(message, "${SUITE_ORIGIN}")`); + const message = JSON.parse(/const message = (\{.*?\});\n/s.exec(html)![1]!) as { + result: { sealed: string }; + }; + const refreshed = await call("POST", "/v1/gmail/token", owner.token, { + sealed: message.result.sealed, + }); + expect(refreshed.status).toBe(200); + }); + it("refuses a sign-in state issued to someone else, and reports declined consent", async () => { const alice = await signIn("state-alice@example.com"); const bob = await signIn("state-bob@example.com"); diff --git a/infra/cloud/mail/src/worker.ts b/infra/cloud/mail/src/worker.ts index c7276b4e0..40d2648ca 100644 --- a/infra/cloud/mail/src/worker.ts +++ b/infra/cloud/mail/src/worker.ts @@ -72,6 +72,8 @@ export interface Env { GOOGLE_LEGACY_CLIENT_IDS?: string; /** Where the web app runs (https://mail.otterware.app): trusted for CORS and redirects. */ APP_ORIGIN: string; + /** Otterware app origins (comma-separated) that connect Google here with a bearer session. */ + SUITE_ORIGINS?: string; /** The first-party Drive app, for the shared account lifecycle. */ DRIVE_ORIGIN: string; /** The session cookie's domain, shared with the web app ("mail.otterware.app"); unset locally. */ @@ -151,9 +153,17 @@ const mailbox = z const app = new Hono(); -// The web app calls the relay from its own origin, with its session cookie. +// The web app calls the relay from its own origin, with its session cookie; Otterware's apps +// call it with a bearer session. app.use("/v1/*", (c, next) => - cors({ origin: c.env.APP_ORIGIN, credentials: true, maxAge: 86_400 })(c, next), + cors({ + origin: (origin) => + origin === c.env.APP_ORIGIN || gmail.suiteOrigins(c.env).includes(origin) + ? origin + : c.env.APP_ORIGIN, + credentials: true, + maxAge: 86_400, + })(c, next), ); app.use(async (c, next) => { @@ -248,6 +258,27 @@ app.post( app.get("/.well-known/*", (c) => c.var.auth.handler(c.req.raw)); app.route("/otter", identity); +// Otterware's apps sign in with a bearer session, so Google's redirect back carries no cookie: +// the signed state names the user and the app, and the sealed token goes only to that app. +app.get("/v1/gmail/callback", async (c, next) => { + const { code, state, error } = c.req.query(); + const signed = state ? await gmail.readState(c.env, state).catch(() => null) : null; + if (!signed?.origin) return next(); + try { + if (error === "access_denied") + return gmail.popupResponse(c.env, { error: GMAIL_SIGN_IN_CANCELLED }, signed.origin); + if (error || !code) throw new Error(error ?? "missing code"); + return gmail.popupResponse( + c.env, + { result: await gmail.completeSignIn(c.env, signed.userId, code) }, + signed.origin, + ); + } catch (err) { + console.warn("Gmail sign-in failed", String(err)); + return gmail.popupResponse(c.env, { error: String(err) }, signed.origin); + } +}); + // ── Signed-in routes ──────────────────────────────────────────────────────── const authed = new Hono(); @@ -290,6 +321,13 @@ authed.get("/gmail/authorize", (c) => .then((url) => c.redirect(url)), ); +authed.post("/gmail/start", async (c) => { + const origin = c.req.header("origin"); + if (!origin || !gmail.suiteOrigins(c.env).includes(origin)) + throw new HTTPException(403, { message: "Not from an Otterware app." }); + return c.json({ url: await gmail.authorizeUrl(c.env, c.var.session.user.id, undefined, origin) }); +}); + authed.get("/gmail/callback", async (c) => { const { code, state, error } = c.req.query(); try { diff --git a/infra/cloud/mail/vars.json b/infra/cloud/mail/vars.json index 2b14f6ec3..758e9e1e7 100644 --- a/infra/cloud/mail/vars.json +++ b/infra/cloud/mail/vars.json @@ -8,6 +8,7 @@ "GOOGLE_LEGACY_CLIENT_IDS": "187875144740-aspevse90oidb4ra3t2fcdfmbagm6jep.apps.googleusercontent.com,187875144740-tegr2te43tofcdogbr72tesmfcg3vbk3.apps.googleusercontent.com,187875144740-vu3ordog4vcqnn0nvh2o4jocvtbkbbdi.apps.googleusercontent.com", "GOOGLE_WEB_CLIENT_ID": "187875144740-drhr59ablkv4ovonrjisd7fj5dv28d6o.apps.googleusercontent.com", "APP_ORIGIN": "https://mail.otterware.app", + "SUITE_ORIGINS": "https://code.otterware.app,https://nightly.code.otterware.app", "DRIVE_ORIGIN": "https://drive.otterware.app", "COOKIE_DOMAIN": "mail.otterware.app", "PUSH_TOPIC": "projects/otterware/topics/gmail-push", diff --git a/infra/cloud/mail/wrangler.test.jsonc b/infra/cloud/mail/wrangler.test.jsonc index 7750c1c86..2751975b8 100644 --- a/infra/cloud/mail/wrangler.test.jsonc +++ b/infra/cloud/mail/wrangler.test.jsonc @@ -60,6 +60,7 @@ "GOOGLE_WEB_CLIENT_ID": "187875144740-drhr59ablkv4ovonrjisd7fj5dv28d6o.apps.googleusercontent.com", // The web app: trusted for CORS and sign-in redirects; shares the session cookie. "APP_ORIGIN": "https://mail.otterware.app", + "SUITE_ORIGINS": "https://code.otterware.app", "DRIVE_ORIGIN": "https://drive.otterware.app", "COOKIE_DOMAIN": "mail.otterware.app", "PUSH_TOPIC": "projects/otterware/topics/gmail-push", diff --git a/modules/calendar/engine/src/CalendarAccountSession.ts b/modules/calendar/engine/src/CalendarAccountSession.ts index 085fe87f7..d0ec41300 100644 --- a/modules/calendar/engine/src/CalendarAccountSession.ts +++ b/modules/calendar/engine/src/CalendarAccountSession.ts @@ -23,6 +23,8 @@ export interface CalendarAccountCredential { readonly accountsUrl: string; readonly cloudOrigin: string; readonly audience: string; + /** This device's Google grants (the browser's); without them, Cloud-held grants. */ + readonly providers?: CalendarProviders; } /** Where Google grants come from: this device on a host, Cloud only without one. */ export interface CalendarProviders { @@ -189,7 +191,7 @@ export const accountSessionLayer = ( accountId: session.user.id, client, cloudOrigin: key.cloudOrigin, - providers: cloudProviders(client, key.cloudOrigin), + providers: key.providers ?? cloudProviders(client, key.cloudOrigin), }; cached = { credential: key, expiresAt: session.expiresAt, context, abort }; return Option.some(context); diff --git a/packages/browser-environment/src/account/BrowserAccount.ts b/packages/browser-environment/src/account/BrowserAccount.ts index 68ee07c33..cd522b98b 100644 --- a/packages/browser-environment/src/account/BrowserAccount.ts +++ b/packages/browser-environment/src/account/BrowserAccount.ts @@ -50,6 +50,8 @@ export class BrowserAccount extends Context.Service< readonly changes: Stream.Stream; /** The page's sign-in, for module engines that exchange it themselves. */ readonly credential: Effect.Effect>; + /** The current suite session token, for Otter services that take it as a bearer. */ + readonly sessionToken: Effect.Effect>; /** The account's Cloud client; fails `signed-out` while none is attached. */ readonly cloud: Effect.Effect; /** Channel events (push, `sync.changed`, `connections.changed`) while signed in. */ @@ -230,6 +232,7 @@ export const layer = Layer.effect( state: SubscriptionRef.get(state), changes: SubscriptionRef.changes(state), credential: Effect.sync(() => Option.fromNullishOr(attached?.credential)), + sessionToken: Effect.sync(() => Option.fromNullishOr(attached?.session.token)), cloud: Effect.suspend(() => attached === null ? Effect.fail(signedOut()) : Effect.succeed(attached.account), ), diff --git a/packages/browser-environment/src/account/BrowserGoogle.ts b/packages/browser-environment/src/account/BrowserGoogle.ts new file mode 100644 index 000000000..16de75d6d --- /dev/null +++ b/packages/browser-environment/src/account/BrowserGoogle.ts @@ -0,0 +1,182 @@ +// @effect-diagnostics globalDate:off -- Promise-based provider port for module workers, like the server's DeviceGoogle. +/** + * Google in this browser, the way standalone Otter Mail web does it: Otter Mail's relay runs + * Google's consent with its web client and seals the refresh token for this Otter Account. The + * browser keeps the sealed token (sealed again under this profile's key) and asks the relay for + * each access token. Neither the relay nor Otter Cloud stores it, and it does not follow the + * account to other devices; each device connects Google itself. + * + * @module BrowserGoogle + */ +import type { DeviceProviders } from "@otterware/module-kit/account"; +import { SuiteSqlClient } from "@otterware/module-kit/runtime"; +import { PROVIDER_SCOPES } from "@otterware/otter-cloud-client/scopes"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { BrowserPlatform } from "../BrowserPlatform.ts"; +import { BrowserSecrets } from "../BrowserSecrets.ts"; +import { BrowserAccount } from "./BrowserAccount.ts"; + +const DEFAULT_RELAY = "https://relay.mail.otterware.app"; +const ID_PREFIX = "browser:google:"; + +const Grants = Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ email: Schema.String, sealed: Schema.String, updatedAt: Schema.Number }), + ), +); +type Grants = { -readonly [id in keyof typeof Grants.Type]: (typeof Grants.Type)[id] }; +const decodeGrants = Schema.decodeEffect(Grants); +const encodeGrants = Schema.encodeEffect(Grants); + +export class BrowserGoogle extends Context.Service< + BrowserGoogle, + DeviceProviders & { + /** Keeps the sealed grant the relay's popup handed the page. */ + readonly save: (grant: { readonly email: string; readonly sealed: string }) => Promise; + } +>()("@otterware/browser-environment/account/BrowserGoogle") {} + +export const layer = Layer.effect( + BrowserGoogle, + Effect.gen(function* () { + const account = yield* BrowserAccount; + const platform = yield* BrowserPlatform; + const secrets = yield* BrowserSecrets; + const sql = yield* SuiteSqlClient; + const listeners = new Set<() => void>(); + const access = new Map(); + + const signedIn = () => + Effect.runPromise( + Effect.all([account.state, account.sessionToken, account.credential]).pipe( + Effect.map(([state, token, credential]) => + state.userId && Option.isSome(token) && Option.isSome(credential) + ? { + userId: state.userId, + token: token.value, + relay: (credential.value.mailRelayUrl ?? DEFAULT_RELAY).replace(/\/$/, ""), + } + : null, + ), + ), + ); + const required = async () => { + const current = await signedIn(); + if (!current) throw new Error("Sign in to your Otter Account."); + return current; + }; + const read = (userId: string) => + Effect.runPromise( + Effect.gen(function* () { + const rows = yield* sql<{ readonly value: string }>` + SELECT value FROM browser_meta WHERE key = ${`google:${userId}`} + `; + const stored = rows[0]?.value; + if (stored === undefined) return {}; + const text = yield* secrets.open(stored); + return yield* decodeGrants(text).pipe( + Effect.map((grants): Grants => ({ ...grants })), + Effect.orElseSucceed((): Grants => ({})), + ); + }), + ); + const write = (userId: string, grants: Grants) => + Effect.runPromise( + Effect.gen(function* () { + const value = yield* secrets.seal(yield* encodeGrants(grants)); + yield* sql` + INSERT INTO browser_meta (key, value) VALUES (${`google:${userId}`}, ${value}) + ON CONFLICT (key) DO UPDATE SET value = excluded.value + `; + }), + ); + const changed = () => { + for (const listener of [...listeners]) listener(); + }; + const relay = async (path: string, body: unknown) => { + const current = await required(); + const response = await platform.fetch(`${current.relay}${path}`, { + method: "POST", + headers: { authorization: `Bearer ${current.token}`, "content-type": "application/json" }, + body: JSON.stringify(body), + credentials: "omit", + cache: "no-store", + }); + if (response.status === 410) + throw new Error("Google ended this sign-in. Connect Google in this browser again."); + if (!response.ok) throw new Error("Could not reach Otter Mail's relay. Try again."); + return (await response.json()) as Record; + }; + + return BrowserGoogle.of({ + connections: async () => { + const current = await signedIn(); + if (!current) return []; + return Object.entries(await read(current.userId)).map(([id, grant]) => ({ + id, + provider: "google" as const, + email: grant.email, + scopes: [...PROVIDER_SCOPES.google], + updatedAt: grant.updatedAt, + })); + }, + providerToken: async (id) => { + const current = await required(); + const grant = (await read(current.userId))[id]; + if (!grant) throw new Error("Google is not connected in this browser."); + let token = access.get(id); + if (!token || token.expiresAt < Date.now() + 60_000) { + const fresh = await relay("/v1/gmail/token", { sealed: grant.sealed }); + token = { + accessToken: String(fresh.accessToken), + expiresAt: Date.now() + Number(fresh.expiresIn) * 1_000, + }; + access.set(id, token); + } + return { + connectionId: id, + accessToken: token.accessToken, + expiresAt: token.expiresAt, + audience: "device", + scopes: [...PROVIDER_SCOPES.google], + }; + }, + connect: async (provider) => { + if (provider !== "google") + throw new Error("Only Google can be connected in this browser for now."); + const { url } = await relay("/v1/gmail/start", {}); + return { url: String(url) }; + }, + disconnect: async (id) => { + const current = await required(); + const grants = await read(current.userId); + if (!grants[id]) return; + delete grants[id]; + access.delete(id); + // Forget it here only: Google revokes per project, which would end other devices too. + await write(current.userId, grants); + changed(); + }, + save: async ({ email, sealed }) => { + const current = await required(); + const grants = await read(current.userId); + grants[`${ID_PREFIX}${email}`] = { email, sealed, updatedAt: Date.now() }; + access.delete(`${ID_PREFIX}${email}`); + await write(current.userId, grants); + changed(); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }); + }), +); diff --git a/packages/browser-environment/src/account/contracts.ts b/packages/browser-environment/src/account/contracts.ts index 3399985db..1d72c1676 100644 --- a/packages/browser-environment/src/account/contracts.ts +++ b/packages/browser-environment/src/account/contracts.ts @@ -40,6 +40,8 @@ export const BrowserAccountCredential = Schema.Struct({ accountsUrl: Schema.String, /** Otter Cloud's origin, e.g. `https://cloud.otterware.app`. */ cloudOrigin: Schema.String, + /** Otter Mail's relay, which seals this browser's Google grants (`BrowserGoogle`). */ + mailRelayUrl: Schema.optional(Schema.String), }); export type BrowserAccountCredential = typeof BrowserAccountCredential.Type; @@ -50,6 +52,7 @@ export const BROWSER_ACCOUNT_METHODS = { connections: "browser.account.connections", connect: "browser.account.connect", disconnect: "browser.account.disconnect", + saveGoogle: "browser.account.saveGoogle", } as const; export const BrowserAccountRpcGroup = RpcGroup.make( @@ -70,7 +73,7 @@ export const BrowserAccountRpcGroup = RpcGroup.make( success: Schema.Struct({ connections: Schema.Array(Connection) }), error: BrowserAccountError, }), - /** Starts connecting a provider account; the page opens the returned URL. */ + /** Starts connecting Google in this browser; the page opens the returned URL in a popup. */ Rpc.make(BROWSER_ACCOUNT_METHODS.connect, { payload: Schema.Struct({ provider: Provider, @@ -80,8 +83,14 @@ export const BrowserAccountRpcGroup = RpcGroup.make( success: Schema.Struct({ url: Schema.String }), error: BrowserAccountError, }), + /** Forgets a Google grant in this browser only (Google revokes per project, not per device). */ Rpc.make(BROWSER_ACCOUNT_METHODS.disconnect, { payload: Schema.Struct({ connectionId: Schema.String }), error: BrowserAccountError, }), + /** Keeps the sealed grant the relay's popup handed the page. */ + Rpc.make(BROWSER_ACCOUNT_METHODS.saveGoogle, { + payload: Schema.Struct({ email: Schema.String, sealed: Schema.String }), + error: BrowserAccountError, + }), ); diff --git a/packages/browser-environment/src/account/credential.ts b/packages/browser-environment/src/account/credential.ts index 91bf26115..893ed7a79 100644 --- a/packages/browser-environment/src/account/credential.ts +++ b/packages/browser-environment/src/account/credential.ts @@ -1,7 +1,6 @@ /** - * The account port engines exchange themselves (Calendar's account-held - * Google connections): the page's sign-in, bound to an audience per engine - * on this browser. + * The account port engines exchange themselves (Calendar): the page's sign-in, bound to an + * audience per engine on this browser, with Google from this browser's own grants. */ import { BrowserAccountCredential } from "@otterware/composition/browser"; import * as Effect from "effect/Effect"; @@ -10,12 +9,14 @@ import * as Option from "effect/Option"; import { BrowserInstallation } from "../BrowserInstallation.ts"; import { BrowserAccount } from "./BrowserAccount.ts"; +import { BrowserGoogle } from "./BrowserGoogle.ts"; export const layerAccountCredential = Layer.effect( BrowserAccountCredential, Effect.gen(function* () { const account = yield* BrowserAccount; const installation = yield* BrowserInstallation; + const google = yield* BrowserGoogle; return BrowserAccountCredential.of({ read: (purpose) => Effect.map( @@ -25,6 +26,7 @@ export const layerAccountCredential = Layer.effect( accountsUrl: credential.accountsUrl, cloudOrigin: credential.cloudOrigin, audience: `${purpose}:browser:${installation.id}`, + providers: google, })), ), }); diff --git a/packages/browser-environment/src/account/handlers.ts b/packages/browser-environment/src/account/handlers.ts index 2d6540504..4c2c376cb 100644 --- a/packages/browser-environment/src/account/handlers.ts +++ b/packages/browser-environment/src/account/handlers.ts @@ -1,32 +1,40 @@ -/** The account RPCs: thin mappings onto `BrowserAccount` and its Cloud client. */ -import type { OtterCloudClient } from "@otterware/otter-cloud-client"; +/** The account RPCs: thin mappings onto `BrowserAccount` and this browser's Google grants. */ import * as Effect from "effect/Effect"; -import { BrowserAccount, cloudFailure } from "./BrowserAccount.ts"; -import { BrowserAccountRpcGroup, type BrowserAccountError } from "./contracts.ts"; +import { BrowserAccount } from "./BrowserAccount.ts"; +import { BrowserGoogle } from "./BrowserGoogle.ts"; +import { BrowserAccountError, BrowserAccountRpcGroup } from "./contracts.ts"; + +const failure = (cause: unknown) => + new BrowserAccountError({ + reason: "unavailable", + message: cause instanceof Error ? cause.message : "Could not reach Google. Try again.", + }); export const layerAccountHandlers = BrowserAccountRpcGroup.toLayer( Effect.gen(function* () { const account = yield* BrowserAccount; - const withCloud = ( - work: (client: OtterCloudClient) => Promise, - ): Effect.Effect => - Effect.flatMap(account.cloud, ({ client }) => - Effect.tryPromise({ try: () => work(client), catch: cloudFailure }), - ); + const google = yield* BrowserGoogle; + const withGoogle = (work: () => Promise): Effect.Effect => + Effect.tryPromise({ try: work, catch: failure }); return BrowserAccountRpcGroup.of({ "browser.account.attach": (credential) => account.attach(credential), "browser.account.detach": () => account.detach, "browser.account.watch": () => account.changes, + // Signed out, there are no grants to show or add; the page asks for a sign-in instead. "browser.account.connections": () => - Effect.map( - withCloud((client) => client.connections()), - (connections) => ({ connections }), + Effect.andThen( + account.cloud, + withGoogle(async () => ({ connections: [...(await google.connections())] })), + ), + "browser.account.connect": ({ provider }) => + Effect.andThen( + account.cloud, + withGoogle(() => google.connect(provider)), ), - "browser.account.connect": ({ provider, scopes, returnTo }) => - withCloud((client) => client.connect(provider, scopes, returnTo)), "browser.account.disconnect": ({ connectionId }) => - withCloud((client) => client.disconnect(connectionId)), + withGoogle(() => google.disconnect(connectionId)), + "browser.account.saveGoogle": (grant) => withGoogle(() => google.save(grant)), }); }), ); diff --git a/packages/browser-environment/src/account/mailHost.ts b/packages/browser-environment/src/account/mailHost.ts index 8d84540f6..5621ea986 100644 --- a/packages/browser-environment/src/account/mailHost.ts +++ b/packages/browser-environment/src/account/mailHost.ts @@ -17,24 +17,38 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { BrowserAccount } from "./BrowserAccount.ts"; +import { BrowserGoogle } from "./BrowserGoogle.ts"; export const layerMailHost = Layer.effect( MailBrowserHost, Effect.gen(function* () { const account = yield* BrowserAccount; + const google = yield* BrowserGoogle; const read = Effect.gen(function* () { const cloud = yield* Effect.option(account.cloud); const credential = yield* account.credential; if (Option.isNone(cloud) || Option.isNone(credential)) return Option.none(); + const client = cloud.value.client; const mail: MailBrowserAccount = { accountId: cloud.value.accountId, cloudOrigin: credential.value.cloudOrigin, - client: cloud.value.client, + // Account data through Cloud; Google from this browser's own grants. + client: { + connections: async () => [...(await google.connections())], + providerToken: google.providerToken, + push: (records) => client.push(records), + pull: (cursor) => client.pull(cursor), + request: (...args) => client.request(...args), + }, subscribe: (listener) => { const fiber = Effect.runFork( Stream.runForEach(account.events, (event) => Effect.sync(() => listener(event))), ); - return () => void Effect.runFork(Fiber.interrupt(fiber)); + const stop = google.subscribe(() => listener({ type: "connections.changed" })); + return () => { + stop(); + void Effect.runFork(Fiber.interrupt(fiber)); + }; }, }; return Option.some(mail); diff --git a/packages/browser-environment/src/host.ts b/packages/browser-environment/src/host.ts index d45c28c35..f2050e0c5 100644 --- a/packages/browser-environment/src/host.ts +++ b/packages/browser-environment/src/host.ts @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as BrowserAccount from "./account/BrowserAccount.ts"; +import * as BrowserGoogle from "./account/BrowserGoogle.ts"; import { layerAccountCredential } from "./account/credential.ts"; import { layerAccountHandlers } from "./account/handlers.ts"; import { layerHomeAccount, layerMailFollowsAccount, layerMailHost } from "./account/mailHost.ts"; @@ -76,6 +77,7 @@ export const layerHost = Layer.provideMerge( Layer.mergeAll(layerAccountCredential, layerMailHost, layerHomeAccount, layerMailPage), ), + Layer.provideMerge(BrowserGoogle.layer.pipe(Layer.provide(BrowserSecrets.layer))), Layer.provideMerge(BrowserAccount.layer), Layer.provideMerge(BrowserInstallation.layer), Layer.provideMerge(layerHttpClient), diff --git a/packages/browser-environment/src/worker.test.ts b/packages/browser-environment/src/worker.test.ts index 5e038fe20..5216c09de 100644 --- a/packages/browser-environment/src/worker.test.ts +++ b/packages/browser-environment/src/worker.test.ts @@ -73,6 +73,45 @@ it.layer(layerConnectedWorker, { timeout: 30_000 })( }), ); + it.effect("keeps Google as this browser's own grant, started at Mail's relay", () => + Effect.gen(function* () { + const client = yield* makeBrowserEnvironmentClient; + const relay = "https://relay.mail.test"; + cloud.external = (url) => + url === `${relay}/v1/gmail/start` + ? Response.json({ url: "https://accounts.google.com/o/oauth2/v2/auth?state=s" }) + : Response.json({ error: "not found" }, { status: 404 }); + yield* client["browser.account.attach"]({ + accessToken: "oauth-ada", + accountsUrl: ACCOUNTS_URL, + cloudOrigin: CLOUD_ORIGIN, + mailRelayUrl: relay, + }); + const started = yield* client["browser.account.connect"]({ + provider: "google", + scopes: [], + returnTo: "https://code.otterware.app/settings/account", + }); + assert.strictEqual(started.url, "https://accounts.google.com/o/oauth2/v2/auth?state=s"); + // The relay knows the account by its suite session; no Cloud grant is created. + const asked = cloud.requests.find((request) => request.url === `${relay}/v1/gmail/start`)!; + assert.strictEqual(asked.headers.get("authorization"), "Bearer session-user-ada"); + + yield* client["browser.account.saveGoogle"]({ email: "ada@gmail.test", sealed: "sealed" }); + const saved = yield* client["browser.account.connections"]({}); + assert.deepStrictEqual( + saved.connections.map((connection) => [connection.id, connection.email]), + [["browser:google:ada@gmail.test", "ada@gmail.test"]], + ); + yield* client["browser.account.disconnect"]({ + connectionId: "browser:google:ada@gmail.test", + }); + const removed = yield* client["browser.account.connections"]({}); + assert.deepStrictEqual(removed.connections, []); + yield* client["browser.account.detach"]({}); + }), + ); + it.effect("signs the browser in to Otter Cloud as its own device", () => Effect.gen(function* () { const client = yield* makeBrowserEnvironmentClient; @@ -93,6 +132,7 @@ it.layer(layerConnectedWorker, { timeout: 30_000 })( const refused = yield* Effect.flip(client["browser.account.connections"]({})); assert.strictEqual(refused.reason, "signed-out"); + const channel = cloud.sockets.length; const state = yield* client["browser.account.attach"]({ accessToken: "oauth-ada", @@ -101,12 +141,15 @@ it.layer(layerConnectedWorker, { timeout: 30_000 })( }); assert.deepStrictEqual(state, { status: "connected", userId: "user-ada", message: null }); + // Google is this browser's own grant; the account's Cloud-held ones are not read. const { connections } = yield* client["browser.account.connections"]({}); - assert.deepStrictEqual( - connections.map((connection) => connection.email), - ["ada@example.com"], - ); - const listed = cloud.requests.find((request) => request.url.endsWith("/connections"))!; + assert.deepStrictEqual(connections, []); + assert.isFalse(cloud.requests.some((request) => request.url.endsWith("/connections"))); + // The channel opens with this browser's own Cloud audience. + yield* Effect.promise(() => cloud.socket(channel)); + const listed = cloud.requests.findLast((request) => + request.url.endsWith("/channel-ticket"), + )!; const audience = listed.headers.get("x-otter-audience")!; assert.match(audience, /^browser:[0-9a-f-]{36}$/); assert.strictEqual(listed.headers.get("authorization"), `Bearer cloud-${audience}`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b5904a6..573e3a6b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,12 +665,6 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 - '@otterware/calendar-contracts': - specifier: workspace:* - version: link:../../modules/calendar/contracts - '@otterware/calendar-node': - specifier: workspace:* - version: link:../../modules/calendar/node '@otterware/composition': specifier: workspace:* version: link:../../packages/composition