diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4f80d397..d62aff81 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -10195,7 +10195,6 @@ fn connect_notion_with_broker(state_root: PathBuf, open_browser: bool) -> Result let start = broker .start(&NotionOAuthBrokerStart { redirect_uri: redirect_uri.clone(), - hosted_callback_handoff: true, }) .map_err(|error| format!("Could not start Notion OAuth broker flow: {error}"))?; let authorization_url = start.normalized_authorization_url(); @@ -10203,7 +10202,7 @@ fn connect_notion_with_broker(state_root: PathBuf, open_browser: bool) -> Result let authorization = run_local_oauth_authorization( "Notion", &authorization_url, - start.local_redirect_uri(), + &start.redirect_uri, &start.state, !open_browser, true, @@ -10213,7 +10212,6 @@ fn connect_notion_with_broker(state_root: PathBuf, open_browser: bool) -> Result let previous_connection = connection_id .as_ref() .and_then(|connection_id| store.get_connection(connection_id).ok().flatten()); - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); let options = BrokerOAuthConnectOptions { connection_id, broker_url, @@ -10221,7 +10219,7 @@ fn connect_notion_with_broker(state_root: PathBuf, open_browser: bool) -> Result session: start.session, state: start.state, code: authorization.code, - redirect_uri: exchange_redirect_uri, + redirect_uri: start.redirect_uri, }; let report = @@ -10263,19 +10261,17 @@ fn connect_google_docs_with_broker( .start(&OAuthBrokerStart { connector: GOOGLE_DOCS_CONNECTOR_ID.to_string(), redirect_uri, - hosted_callback_handoff: true, }) .map_err(|error| format!("Could not start Google Docs OAuth broker flow: {error}"))?; let authorization = run_local_oauth_authorization( "Google Docs", &start.authorization_url, - start.local_redirect_uri(), + &start.redirect_uri, &start.state, !open_browser, true, ) .map_err(|error| error.message)?; - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); let options = GoogleDocsBrokerOAuthConnectOptions { connection_id: None, broker_url, @@ -10283,7 +10279,7 @@ fn connect_google_docs_with_broker( session: start.session, state: start.state, code: authorization.code, - redirect_uri: exchange_redirect_uri, + redirect_uri: start.redirect_uri, }; let report = @@ -10317,19 +10313,17 @@ fn connect_google_calendar_with_broker( .start(&OAuthBrokerStart { connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), redirect_uri, - hosted_callback_handoff: true, }) .map_err(|error| format!("Could not start Google Calendar OAuth broker flow: {error}"))?; let authorization = run_local_oauth_authorization( "Google Calendar", &start.authorization_url, - start.local_redirect_uri(), + &start.redirect_uri, &start.state, !open_browser, true, ) .map_err(|error| error.message)?; - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); let options = GoogleCalendarBrokerOAuthConnectOptions { connection_id: Some(ConnectionId::new("google-calendar-default")), broker_url, @@ -10337,7 +10331,7 @@ fn connect_google_calendar_with_broker( session: start.session, state: start.state, code: authorization.code, - redirect_uri: exchange_redirect_uri, + redirect_uri: start.redirect_uri, }; let report = run_connect_google_calendar_broker_oauth( @@ -10377,19 +10371,17 @@ fn connect_gmail_with_broker(state_root: PathBuf, open_browser: bool) -> Result< .start(&OAuthBrokerStart { connector: GMAIL_CONNECTOR_ID.to_string(), redirect_uri, - hosted_callback_handoff: true, }) .map_err(|error| format!("Could not start Gmail OAuth broker flow: {error}"))?; let authorization = run_local_oauth_authorization( "Gmail", &start.authorization_url, - start.local_redirect_uri(), + &start.redirect_uri, &start.state, !open_browser, true, ) .map_err(|error| error.message)?; - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); let options = GmailBrokerOAuthConnectOptions { connection_id: None, broker_url, @@ -10397,7 +10389,7 @@ fn connect_gmail_with_broker(state_root: PathBuf, open_browser: bool) -> Result< session: start.session, state: start.state, code: authorization.code, - redirect_uri: exchange_redirect_uri, + redirect_uri: start.redirect_uri, }; let report = run_connect_gmail_broker_oauth(&mut store, credentials.as_ref(), options, &broker) @@ -10430,19 +10422,17 @@ fn connect_slack_with_broker(state_root: PathBuf, open_browser: bool) -> Result< .start(&OAuthBrokerStart { connector: SLACK_CONNECTOR_ID.to_string(), redirect_uri, - hosted_callback_handoff: true, }) .map_err(|error| format!("Could not start Slack OAuth broker flow: {error}"))?; let authorization = run_local_oauth_authorization( "Slack", &start.authorization_url, - start.local_redirect_uri(), + &start.redirect_uri, &start.state, !open_browser, true, ) .map_err(|error| error.message)?; - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); let options = SlackBrokerOAuthConnectOptions { connection_id: None, broker_url, @@ -10450,7 +10440,7 @@ fn connect_slack_with_broker(state_root: PathBuf, open_browser: bool) -> Result< session: start.session, state: start.state, code: authorization.code, - redirect_uri: exchange_redirect_uri, + redirect_uri: start.redirect_uri, }; let report = run_connect_slack_broker_oauth(&mut store, credentials.as_ref(), options, &broker) diff --git a/apps/oauth-service/README.md b/apps/oauth-service/README.md index 9ea641c5..9c1e3186 100644 --- a/apps/oauth-service/README.md +++ b/apps/oauth-service/README.md @@ -14,7 +14,7 @@ only performs the confidential token exchange and refresh calls. loc CLI -> broker /start loc CLI <- authorization_url, state, signed session loc CLI -> browser -> provider OAuth consent -provider -> localhost callback, or broker hosted callback -> localhost callback +provider -> localhost callback on the user's machine loc CLI -> broker /exchange with code, state, session, redirect_uri broker -> provider token endpoint with client_secret broker -> loc CLI with access token and refresh handle @@ -39,8 +39,7 @@ Request: ```json { - "redirect_uri": "http://localhost:8757/oauth/notion/callback", - "hosted_callback_handoff": true + "redirect_uri": "http://localhost:8757/oauth/notion/callback" } ``` @@ -52,56 +51,12 @@ Response: "client_id": "public-client-id", "authorization_url": "https://api.notion.com/v1/oauth/authorize?...", "redirect_uri": "http://localhost:8757/oauth/notion/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "expires_in": 600 } ``` -When `hosted_callback_handoff` is `true` and -`LOCALITY__HOSTED_CALLBACK_URI` is set, `redirect_uri` remains the -local loopback URI where the client listens. `authorization_redirect_uri` and -`exchange_redirect_uri` are the HTTPS provider callback URI registered with the -provider. The browser first returns to the broker callback, and the broker -redirects the browser to `redirect_uri` with the provider code or error. The -client then exchanges the code using `exchange_redirect_uri`, so the provider -sees the same redirect URI during authorization and token exchange. Older -clients that omit `hosted_callback_handoff` stay on the direct loopback flow and -do not receive hosted callback fields. - -### `GET /v1/oauth//callback` - -Hosted callback routes exist for: - -- `/v1/oauth/notion/callback` -- `/v1/oauth/google-docs/callback` -- `/v1/oauth/google-calendar/callback` -- `/v1/oauth/gmail/callback` -- `/v1/oauth/slack/callback` - -These browser-facing routes are used only when the corresponding -`LOCALITY__HOSTED_CALLBACK_URI` is configured. A route accepts -provider `code` and `state`, verifies the signed local-handoff state, and -returns `303 See Other` to the loopback callback held inside that state. - -Success redirects to: - -```text -http://localhost:8757/oauth//callback?state=...&code=... -``` - -Provider denial redirects to: - -```text -http://localhost:8757/oauth//callback?state=...&error=access_denied&error_description=... -``` - -The route sets `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. -It does not persist provider codes, tokens, refresh handles, or local callback -URIs. - ### `POST /v1/oauth/notion/exchange` Request: @@ -109,15 +64,12 @@ Request: ```json { "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "code": "provider-authorization-code", - "redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" + "redirect_uri": "http://localhost:8757/oauth/notion/callback" } ``` -Clients should send the returned `exchange_redirect_uri`; without hosted -handoff, this remains the local loopback redirect. - Response includes the provider access token and either `refresh_token_handle` or `refresh_token`, depending on `LOCALITY_TOKEN_MODE`. @@ -137,8 +89,7 @@ Request: ```json { - "redirect_uri": "http://localhost:8757/oauth/google-docs/callback", - "hosted_callback_handoff": true + "redirect_uri": "http://localhost:8757/oauth/google-docs/callback" } ``` @@ -150,10 +101,8 @@ Response: "client_id": "public-client-id", "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "http://localhost:8757/oauth/google-docs/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback", "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "expires_in": 600 } ``` @@ -165,15 +114,12 @@ Request: ```json { "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "code": "provider-authorization-code", - "redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback" + "redirect_uri": "http://localhost:8757/oauth/google-docs/callback" } ``` -Clients should send the returned `exchange_redirect_uri`; without hosted -handoff, this remains the local loopback redirect. - Response includes the Google OAuth access token, granted scopes, optional ID token, and either `refresh_token_handle` or `refresh_token`, depending on `LOCALITY_TOKEN_MODE`. @@ -194,8 +140,7 @@ Request: ```json { - "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback", - "hosted_callback_handoff": true + "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback" } ``` @@ -212,10 +157,8 @@ Response: "client_id": "public-client-id", "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback", "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "expires_in": 600 } ``` @@ -227,15 +170,12 @@ Request: ```json { "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "code": "provider-authorization-code", - "redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback" + "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback" } ``` -Clients should send the returned `exchange_redirect_uri`; without hosted -handoff, this remains the local loopback redirect. - Response includes the Google OAuth access token for Calendar event scopes, granted scopes, optional ID token, `workspace_id: "primary"`, `workspace_name: "Primary calendar"`, and either `refresh_token_handle` or @@ -257,8 +197,7 @@ Request: ```json { - "redirect_uri": "http://localhost:8757/oauth/gmail/callback", - "hosted_callback_handoff": true + "redirect_uri": "http://localhost:8757/oauth/gmail/callback" } ``` @@ -270,10 +209,8 @@ Response: "client_id": "public-client-id", "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "http://localhost:8757/oauth/gmail/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback", "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "expires_in": 600 } ``` @@ -285,15 +222,12 @@ Request: ```json { "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "code": "provider-authorization-code", - "redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" + "redirect_uri": "http://localhost:8757/oauth/gmail/callback" } ``` -Clients should send the returned `exchange_redirect_uri`; without hosted -handoff, this remains the local loopback redirect. - Response includes the Google OAuth access token for Gmail read/compose scopes, granted scopes, optional ID token, and either `refresh_token_handle` or `refresh_token`, depending on `LOCALITY_TOKEN_MODE`. @@ -314,8 +248,7 @@ Request: ```json { - "redirect_uri": "http://localhost:8757/oauth/slack/callback", - "hosted_callback_handoff": true + "redirect_uri": "http://localhost:8757/oauth/slack/callback" } ``` @@ -327,10 +260,8 @@ Response: "client_id": "public-client-id", "authorization_url": "https://slack.com/oauth/v2/authorize?...", "redirect_uri": "http://localhost:8757/oauth/slack/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback", "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "expires_in": 600 } ``` @@ -342,15 +273,12 @@ Request: ```json { "session": "signed-session", - "state": "signed-local-handoff-state", + "state": "opaque-state", "code": "provider-authorization-code", - "redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback" + "redirect_uri": "http://localhost:8757/oauth/slack/callback" } ``` -Clients should send the returned `exchange_redirect_uri`; without hosted -handoff, this remains the local loopback redirect. - Response includes the Slack OAuth access token, granted read-only scopes, workspace identifiers, bot user ID, and either `refresh_token_handle` or `refresh_token`, depending on `LOCALITY_TOKEN_MODE`. @@ -393,7 +321,6 @@ npm run check Optional connector overrides: - `LOCALITY_NOTION_REDIRECT_URIS`, `LOCALITY_GOOGLE_DOCS_REDIRECT_URIS`, `LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS`, `LOCALITY_GMAIL_REDIRECT_URIS`, `LOCALITY_SLACK_REDIRECT_URIS`: comma-separated allowed loopback redirect URIs. -- `LOCALITY_NOTION_HOSTED_CALLBACK_URI`, `LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI`, `LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI`, `LOCALITY_GMAIL_HOSTED_CALLBACK_URI`, `LOCALITY_SLACK_HOSTED_CALLBACK_URI`: exact HTTPS broker callback URI registered with the provider app for hosted local handoff. - `LOCALITY_NOTION_AUTH_BASE_URL`, `LOCALITY_GOOGLE_DOCS_AUTH_BASE_URL`, `LOCALITY_GOOGLE_CALENDAR_AUTH_BASE_URL`, `LOCALITY_GMAIL_AUTH_BASE_URL`, `LOCALITY_SLACK_AUTH_BASE_URL`: provider authorization base URL. - `LOCALITY_NOTION_API_BASE_URL`, `LOCALITY_GOOGLE_DOCS_API_BASE_URL`, `LOCALITY_GOOGLE_CALENDAR_API_BASE_URL`, `LOCALITY_GMAIL_API_BASE_URL`, `LOCALITY_SLACK_API_BASE_URL`: provider token API base URL. diff --git a/apps/oauth-service/docs/deployment.md b/apps/oauth-service/docs/deployment.md index cb5b9e9b..da4f0014 100644 --- a/apps/oauth-service/docs/deployment.md +++ b/apps/oauth-service/docs/deployment.md @@ -18,45 +18,35 @@ wrangler secret put LOCALITY_GOOGLE_CLIENT_ID wrangler secret put LOCALITY_GOOGLE_CLIENT_SECRET wrangler secret put LOCALITY_SLACK_CLIENT_ID wrangler secret put LOCALITY_SLACK_CLIENT_SECRET +wrangler deploy ``` -Register the exact hosted broker callbacks with each provider before deploying -matching `LOCALITY__HOSTED_CALLBACK_URI` values: +Configure the Notion OAuth integration with the exact localhost callback used by +Locality: ```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback +http://localhost:8757/oauth/notion/callback +http://127.0.0.1:8757/oauth/notion/callback ``` -The Google OAuth client must include the Google Docs, Google Calendar, and Gmail -hosted callback URIs. Slack must include the Slack hosted callback URI. Notion -must include the Notion hosted callback URI. Each hosted variable must exactly -match the reachable Worker callback URL registered with the provider. That can -be the `workers.dev` URL above or a custom domain such as -`https://auth.locality.dev/v1/oauth/notion/callback`. - -Keep the local loopback callbacks in the broker allowlists for the CLI handoff: +Configure one Google OAuth client with the exact localhost callbacks used by +Locality for Google Docs, Google Calendar, and Gmail: ```text -http://localhost:8757/oauth/notion/callback -http://127.0.0.1:8757/oauth/notion/callback http://localhost:8757/oauth/google-docs/callback http://127.0.0.1:8757/oauth/google-docs/callback http://localhost:8757/oauth/google-calendar/callback http://127.0.0.1:8757/oauth/google-calendar/callback http://localhost:8757/oauth/gmail/callback http://127.0.0.1:8757/oauth/gmail/callback -http://localhost:8757/oauth/slack/callback -http://127.0.0.1:8757/oauth/slack/callback ``` -Deploy after secrets and provider callback URLs are in place: +Configure the Slack OAuth app with the exact localhost callbacks used by +Locality: -```sh -wrangler deploy +```text +http://localhost:8757/oauth/slack/callback +http://127.0.0.1:8757/oauth/slack/callback ``` Use a stable production URL such as: @@ -81,24 +71,13 @@ return the same shared Google OAuth client ID. Optional broker environment overrides for connector local testing: ```text -LOCALITY_NOTION_REDIRECT_URIS=http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback -LOCALITY_NOTION_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -LOCALITY_NOTION_AUTH_BASE_URL=https://api.notion.com -LOCALITY_NOTION_API_BASE_URL=https://api.notion.com -LOCALITY_GOOGLE_DOCS_REDIRECT_URIS=http://localhost:8757/oauth/google-docs/callback,http://127.0.0.1:8757/oauth/google-docs/callback -LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -LOCALITY_GOOGLE_DOCS_AUTH_BASE_URL=https://accounts.google.com -LOCALITY_GOOGLE_DOCS_API_BASE_URL=https://oauth2.googleapis.com LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS=http://localhost:8757/oauth/google-calendar/callback,http://127.0.0.1:8757/oauth/google-calendar/callback -LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback LOCALITY_GOOGLE_CALENDAR_AUTH_BASE_URL=https://accounts.google.com LOCALITY_GOOGLE_CALENDAR_API_BASE_URL=https://oauth2.googleapis.com LOCALITY_GMAIL_REDIRECT_URIS=http://localhost:8757/oauth/gmail/callback,http://127.0.0.1:8757/oauth/gmail/callback -LOCALITY_GMAIL_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback LOCALITY_GMAIL_AUTH_BASE_URL=https://accounts.google.com LOCALITY_GMAIL_API_BASE_URL=https://oauth2.googleapis.com LOCALITY_SLACK_REDIRECT_URIS=http://localhost:8757/oauth/slack/callback,http://127.0.0.1:8757/oauth/slack/callback -LOCALITY_SLACK_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback LOCALITY_SLACK_AUTH_BASE_URL=https://slack.com LOCALITY_SLACK_API_BASE_URL=https://slack.com/api ``` diff --git a/apps/oauth-service/docs/security.md b/apps/oauth-service/docs/security.md index af34b81e..f317dae6 100644 --- a/apps/oauth-service/docs/security.md +++ b/apps/oauth-service/docs/security.md @@ -34,8 +34,8 @@ The broker supports two refresh modes: - OAuth sessions are short-lived HMAC-signed payloads. - Session verification checks state, connector, redirect URI, expiry, and payload shape before exchanging a code. -- Local callback URIs are restricted to configured allowlists before any browser - handoff or token exchange. +- Notion, Google Docs, and Gmail redirect URIs are restricted to configured + loopback callback URLs. - Production handle mode keeps provider refresh tokens inside encrypted opaque handles before returning them to local clients. - Upstream OAuth error bodies are not returned to callers. @@ -56,15 +56,6 @@ Deployment controls to add before public launch: ## Redirects -The broker keeps two redirect boundaries separate for every OAuth connector: - -- `LOCALITY__REDIRECT_URIS` is a loopback-only allowlist for local callbacks such as `http://localhost:8757/oauth/gmail/callback`. -- `LOCALITY__HOSTED_CALLBACK_URI` is one exact HTTPS callback served by this broker at the connector's `/v1/oauth//callback` path. - -When hosted handoff is enabled by both a configured hosted callback URI and a -start request with `hosted_callback_handoff: true`, the provider authorization -request uses the hosted callback URI. The callback route verifies a signed state -payload before redirecting to a loopback URI from the allowlist. The token -exchange also uses the hosted callback URI so the provider sees the same -redirect URI in both OAuth steps. Clients that do not opt in stay on the direct -loopback flow. +The broker accepts only configured loopback redirect URIs for Notion, Google +Docs, and Gmail. The Locality CLI should use stable localhost callbacks so each +provider integration can keep a small static redirect allowlist. diff --git a/apps/oauth-service/src/app.ts b/apps/oauth-service/src/app.ts index 0d70cd71..4e3b02a1 100644 --- a/apps/oauth-service/src/app.ts +++ b/apps/oauth-service/src/app.ts @@ -18,25 +18,13 @@ import { exchangeNotionCode, notionAuthorizeUrl, refreshNotionToken, type Notion import { exchangeSlackCode, refreshSlackToken, slackAuthorizeUrl, type SlackTokenResponse } from "./oauth/slack"; import { randomBase64Url, decryptJsonHandle, encryptJsonHandle } from "./security/crypto"; import { - hostedConnectorCallbackUri, - validateGmailExchangeRedirectUri, validateGmailRedirectUri, - validateGoogleCalendarExchangeRedirectUri, validateGoogleCalendarRedirectUri, - validateGoogleDocsExchangeRedirectUri, validateGoogleDocsRedirectUri, - validateNotionExchangeRedirectUri, validateNotionRedirectUri, - validateSlackExchangeRedirectUri, validateSlackRedirectUri } from "./security/redirects"; -import { - nowSeconds, - signLocalHandoffState, - signSession, - verifyLocalHandoffState, - verifySession -} from "./security/session"; +import { nowSeconds, signSession, verifySession } from "./security/session"; import type { ApiErrorBody, BrokerEnv, ConnectorId } from "./types"; const SESSION_TTL_SECONDS = 10 * 60; @@ -44,25 +32,6 @@ const OPERATIONAL_SECRET_MIN_LENGTH = 32; interface StartRequest { redirect_uri?: string; - hosted_callback_handoff?: boolean; -} - -interface StartRedirects { - localRedirectUri: string; - authorizationRedirectUri: string; - exchangeRedirectUri: string; - hostedHandoff: boolean; -} - -interface OAuthConnectorRuntime { - connector: ConnectorId; - defaultLocalRedirectUri: string; - clientId(env: BrokerEnv): string; - authorizeUrl(env: BrokerEnv, redirectUri: string, state: string): string; - validateLocalRedirectUri(env: BrokerEnv, redirectUri: string): string; - validateExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string; - exchangeCode(env: BrokerEnv, code: string, redirectUri: string): Promise; - shapeTokenResponse(env: BrokerEnv, token: TokenResponse): Promise; } interface ExchangeRequest { @@ -72,13 +41,6 @@ interface ExchangeRequest { redirect_uri?: string; } -interface HostedCallbackQuery { - state?: string; - code?: string; - error?: string; - error_description?: string; -} - interface RefreshRequest { refresh_token?: string; refresh_token_handle?: string; @@ -129,71 +91,52 @@ app.get("/.well-known/loc-auth-broker", (c) => }) ); -const oauthConnectors: Record> = { - notion: { - connector: "notion", - defaultLocalRedirectUri: "http://localhost:8757/oauth/notion/callback", - clientId: (env) => env.LOCALITY_NOTION_CLIENT_ID, - authorizeUrl: notionAuthorizeUrl, - validateLocalRedirectUri: validateNotionRedirectUri, - validateExchangeRedirectUri: validateNotionExchangeRedirectUri, - exchangeCode: exchangeNotionCode, - shapeTokenResponse: (env, token) => shapeNotionTokenResponse(env, token as NotionTokenResponse) - }, - "google-docs": { - connector: "google-docs", - defaultLocalRedirectUri: "http://localhost:8757/oauth/google-docs/callback", - clientId: googleClientId, - authorizeUrl: googleDocsAuthorizeUrl, - validateLocalRedirectUri: validateGoogleDocsRedirectUri, - validateExchangeRedirectUri: validateGoogleDocsExchangeRedirectUri, - exchangeCode: exchangeGoogleDocsCode, - shapeTokenResponse: (env, token) => shapeGoogleDocsTokenResponse(env, token as GoogleDocsTokenResponse) - }, - "google-calendar": { - connector: "google-calendar", - defaultLocalRedirectUri: "http://localhost:8757/oauth/google-calendar/callback", - clientId: googleClientId, - authorizeUrl: googleCalendarAuthorizeUrl, - validateLocalRedirectUri: validateGoogleCalendarRedirectUri, - validateExchangeRedirectUri: validateGoogleCalendarExchangeRedirectUri, - exchangeCode: exchangeGoogleCalendarCode, - shapeTokenResponse: (env, token) => shapeGoogleCalendarTokenResponse(env, token as GoogleCalendarTokenResponse) - }, - gmail: { - connector: "gmail", - defaultLocalRedirectUri: "http://localhost:8757/oauth/gmail/callback", - clientId: googleClientId, - authorizeUrl: gmailAuthorizeUrl, - validateLocalRedirectUri: validateGmailRedirectUri, - validateExchangeRedirectUri: validateGmailExchangeRedirectUri, - exchangeCode: exchangeGmailCode, - shapeTokenResponse: (env, token) => shapeGmailTokenResponse(env, token as GmailTokenResponse) - }, - slack: { - connector: "slack", - defaultLocalRedirectUri: "http://localhost:8757/oauth/slack/callback", - clientId: (env) => requireConfiguredString(env.LOCALITY_SLACK_CLIENT_ID, "LOCALITY_SLACK_CLIENT_ID"), - authorizeUrl: slackAuthorizeUrl, - validateLocalRedirectUri: validateSlackRedirectUri, - validateExchangeRedirectUri: validateSlackExchangeRedirectUri, - exchangeCode: exchangeSlackCode, - shapeTokenResponse: (env, token) => shapeSlackTokenResponse(env, token as SlackTokenResponse) - } -}; - app.post("/v1/oauth/notion/start", async (c) => { const body = await optionalJson(c.req.raw); - return c.json(await startOAuthConnector(c.env, "notion", body)); -}); - -app.get("/v1/oauth/notion/callback", async (c) => { - return hostedCallbackResponse(c.env, "notion", validateNotionRedirectUri, c.req.query() as HostedCallbackQuery); + const redirectUri = validateNotionRedirectUri( + c.env, + body.redirect_uri ?? "http://localhost:8757/oauth/notion/callback" + ); + const now = nowSeconds(); + const state = randomBase64Url(); + const session = await signSession( + { + v: 1, + connector: "notion", + state, + redirect_uri: redirectUri, + iat: now, + exp: now + SESSION_TTL_SECONDS, + nonce: randomBase64Url() + }, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + return c.json({ + connector: "notion", + client_id: c.env.LOCALITY_NOTION_CLIENT_ID, + authorization_url: notionAuthorizeUrl(c.env, redirectUri, state), + redirect_uri: redirectUri, + session, + state, + expires_in: SESSION_TTL_SECONDS + }); }); app.post("/v1/oauth/notion/exchange", async (c) => { const body = await requiredJson(c.req.raw); - return c.json(await exchangeOAuthConnector(c.env, "notion", body)); + const session = requireString(body.session, "session"); + const state = requireString(body.state, "state"); + const code = requireString(body.code, "code"); + const redirectUri = validateNotionRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const payload = await verifySession( + session, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + if (payload.connector !== "notion" || payload.state !== state || payload.redirect_uri !== redirectUri) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); + } + const token = await exchangeNotionCode(c.env, code, redirectUri); + return c.json(await shapeNotionTokenResponse(c.env, token)); }); app.post("/v1/oauth/notion/refresh", async (c) => { @@ -205,21 +148,50 @@ app.post("/v1/oauth/notion/refresh", async (c) => { app.post("/v1/oauth/google-docs/start", async (c) => { const body = await optionalJson(c.req.raw); - return c.json(await startOAuthConnector(c.env, "google-docs", body)); -}); - -app.get("/v1/oauth/google-docs/callback", async (c) => { - return hostedCallbackResponse( + const redirectUri = validateGoogleDocsRedirectUri( c.env, - "google-docs", - validateGoogleDocsRedirectUri, - c.req.query() as HostedCallbackQuery + body.redirect_uri ?? "http://localhost:8757/oauth/google-docs/callback" ); + const now = nowSeconds(); + const state = randomBase64Url(); + const session = await signSession( + { + v: 1, + connector: "google-docs", + state, + redirect_uri: redirectUri, + iat: now, + exp: now + SESSION_TTL_SECONDS, + nonce: randomBase64Url() + }, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + return c.json({ + connector: "google-docs", + client_id: googleClientId(c.env), + authorization_url: googleDocsAuthorizeUrl(c.env, redirectUri, state), + redirect_uri: redirectUri, + session, + state, + expires_in: SESSION_TTL_SECONDS + }); }); app.post("/v1/oauth/google-docs/exchange", async (c) => { const body = await requiredJson(c.req.raw); - return c.json(await exchangeOAuthConnector(c.env, "google-docs", body)); + const session = requireString(body.session, "session"); + const state = requireString(body.state, "state"); + const code = requireString(body.code, "code"); + const redirectUri = validateGoogleDocsRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const payload = await verifySession( + session, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + if (payload.connector !== "google-docs" || payload.state !== state || payload.redirect_uri !== redirectUri) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); + } + const token = await exchangeGoogleDocsCode(c.env, code, redirectUri); + return c.json(await shapeGoogleDocsTokenResponse(c.env, token)); }); app.post("/v1/oauth/google-docs/refresh", async (c) => { @@ -231,21 +203,50 @@ app.post("/v1/oauth/google-docs/refresh", async (c) => { app.post("/v1/oauth/google-calendar/start", async (c) => { const body = await optionalJson(c.req.raw); - return c.json(await startOAuthConnector(c.env, "google-calendar", body)); -}); - -app.get("/v1/oauth/google-calendar/callback", async (c) => { - return hostedCallbackResponse( + const redirectUri = validateGoogleCalendarRedirectUri( c.env, - "google-calendar", - validateGoogleCalendarRedirectUri, - c.req.query() as HostedCallbackQuery + body.redirect_uri ?? "http://localhost:8757/oauth/google-calendar/callback" + ); + const now = nowSeconds(); + const state = randomBase64Url(); + const session = await signSession( + { + v: 1, + connector: "google-calendar", + state, + redirect_uri: redirectUri, + iat: now, + exp: now + SESSION_TTL_SECONDS, + nonce: randomBase64Url() + }, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") ); + return c.json({ + connector: "google-calendar", + client_id: googleClientId(c.env), + authorization_url: googleCalendarAuthorizeUrl(c.env, redirectUri, state), + redirect_uri: redirectUri, + session, + state, + expires_in: SESSION_TTL_SECONDS + }); }); app.post("/v1/oauth/google-calendar/exchange", async (c) => { const body = await requiredJson(c.req.raw); - return c.json(await exchangeOAuthConnector(c.env, "google-calendar", body)); + const session = requireString(body.session, "session"); + const state = requireString(body.state, "state"); + const code = requireString(body.code, "code"); + const redirectUri = validateGoogleCalendarRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const payload = await verifySession( + session, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + if (payload.connector !== "google-calendar" || payload.state !== state || payload.redirect_uri !== redirectUri) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); + } + const token = await exchangeGoogleCalendarCode(c.env, code, redirectUri); + return c.json(await shapeGoogleCalendarTokenResponse(c.env, token)); }); app.post("/v1/oauth/google-calendar/refresh", async (c) => { @@ -257,16 +258,50 @@ app.post("/v1/oauth/google-calendar/refresh", async (c) => { app.post("/v1/oauth/gmail/start", async (c) => { const body = await optionalJson(c.req.raw); - return c.json(await startOAuthConnector(c.env, "gmail", body)); -}); - -app.get("/v1/oauth/gmail/callback", async (c) => { - return hostedCallbackResponse(c.env, "gmail", validateGmailRedirectUri, c.req.query() as HostedCallbackQuery); + const redirectUri = validateGmailRedirectUri( + c.env, + body.redirect_uri ?? "http://localhost:8757/oauth/gmail/callback" + ); + const now = nowSeconds(); + const state = randomBase64Url(); + const session = await signSession( + { + v: 1, + connector: "gmail", + state, + redirect_uri: redirectUri, + iat: now, + exp: now + SESSION_TTL_SECONDS, + nonce: randomBase64Url() + }, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + return c.json({ + connector: "gmail", + client_id: googleClientId(c.env), + authorization_url: gmailAuthorizeUrl(c.env, redirectUri, state), + redirect_uri: redirectUri, + session, + state, + expires_in: SESSION_TTL_SECONDS + }); }); app.post("/v1/oauth/gmail/exchange", async (c) => { const body = await requiredJson(c.req.raw); - return c.json(await exchangeOAuthConnector(c.env, "gmail", body)); + const session = requireString(body.session, "session"); + const state = requireString(body.state, "state"); + const code = requireString(body.code, "code"); + const redirectUri = validateGmailRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const payload = await verifySession( + session, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + if (payload.connector !== "gmail" || payload.state !== state || payload.redirect_uri !== redirectUri) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); + } + const token = await exchangeGmailCode(c.env, code, redirectUri); + return c.json(await shapeGmailTokenResponse(c.env, token)); }); app.post("/v1/oauth/gmail/refresh", async (c) => { @@ -278,16 +313,50 @@ app.post("/v1/oauth/gmail/refresh", async (c) => { app.post("/v1/oauth/slack/start", async (c) => { const body = await optionalJson(c.req.raw); - return c.json(await startOAuthConnector(c.env, "slack", body)); -}); - -app.get("/v1/oauth/slack/callback", async (c) => { - return hostedCallbackResponse(c.env, "slack", validateSlackRedirectUri, c.req.query() as HostedCallbackQuery); + const redirectUri = validateSlackRedirectUri( + c.env, + body.redirect_uri ?? "http://localhost:8757/oauth/slack/callback" + ); + const now = nowSeconds(); + const state = randomBase64Url(); + const session = await signSession( + { + v: 1, + connector: "slack", + state, + redirect_uri: redirectUri, + iat: now, + exp: now + SESSION_TTL_SECONDS, + nonce: randomBase64Url() + }, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + return c.json({ + connector: "slack", + client_id: c.env.LOCALITY_SLACK_CLIENT_ID, + authorization_url: slackAuthorizeUrl(c.env, redirectUri, state), + redirect_uri: redirectUri, + session, + state, + expires_in: SESSION_TTL_SECONDS + }); }); app.post("/v1/oauth/slack/exchange", async (c) => { const body = await requiredJson(c.req.raw); - return c.json(await exchangeOAuthConnector(c.env, "slack", body)); + const session = requireString(body.session, "session"); + const state = requireString(body.state, "state"); + const code = requireString(body.code, "code"); + const redirectUri = validateSlackRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const payload = await verifySession( + session, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + if (payload.connector !== "slack" || payload.state !== state || payload.redirect_uri !== redirectUri) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); + } + const token = await exchangeSlackCode(c.env, code, redirectUri); + return c.json(await shapeSlackTokenResponse(c.env, token)); }); app.post("/v1/oauth/slack/refresh", async (c) => { @@ -454,180 +523,6 @@ function requireString(value: string | undefined, field: string): string { return value; } -async function startOAuthConnector(env: BrokerEnv, connector: ConnectorId, body: StartRequest) { - const runtime = oauthConnectors[connector]; - const redirects = startRedirects( - env, - connector, - runtime, - body.redirect_uri ?? runtime.defaultLocalRedirectUri, - body.hosted_callback_handoff === true - ); - const now = nowSeconds(); - const secret = requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET"); - const state = redirects.hostedHandoff - ? await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector, - local_redirect_uri: redirects.localRedirectUri, - provider_redirect_uri: redirects.authorizationRedirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - secret - ) - : randomBase64Url(); - const session = await signSession( - { - v: 1, - connector, - state, - redirect_uri: redirects.exchangeRedirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - secret - ); - return { - connector: runtime.connector, - client_id: runtime.clientId(env), - authorization_url: runtime.authorizeUrl(env, redirects.authorizationRedirectUri, state), - redirect_uri: redirects.localRedirectUri, - ...(redirects.hostedHandoff - ? { - authorization_redirect_uri: redirects.authorizationRedirectUri, - exchange_redirect_uri: redirects.exchangeRedirectUri - } - : {}), - session, - state, - expires_in: SESSION_TTL_SECONDS - }; -} - -async function hostedCallbackResponse( - env: BrokerEnv, - connector: ConnectorId, - validateLocalRedirectUri: (env: BrokerEnv, redirectUri: string) => string, - query: HostedCallbackQuery -): Promise { - const state = callbackString(query.state, "state", 8192); - const payload = await verifyLocalHandoffState( - state, - requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); - if (payload.connector !== connector) { - throw badRequest("invalid_state", "OAuth state connector is invalid"); - } - const expectedProviderRedirectUri = hostedConnectorCallbackUri(env, connector); - if (!expectedProviderRedirectUri || payload.provider_redirect_uri !== expectedProviderRedirectUri) { - throw badRequest("invalid_state", "OAuth state provider redirect is invalid"); - } - const localRedirectUri = validateLocalRedirectUri(env, payload.local_redirect_uri); - const redirect = new URL(localRedirectUri); - redirect.searchParams.set("state", state); - const providerError = optionalCallbackString(query.error, "error", 256); - if (providerError) { - redirect.searchParams.set("error", providerError); - const description = optionalCallbackString(query.error_description, "error_description", 1024); - if (description) { - redirect.searchParams.set("error_description", description); - } - return localCallbackRedirect(redirect.toString()); - } - redirect.searchParams.set("code", callbackString(query.code, "code", 4096)); - return localCallbackRedirect(redirect.toString()); -} - -async function exchangeOAuthConnector(env: BrokerEnv, connector: ConnectorId, body: ExchangeRequest): Promise { - const runtime = oauthConnectors[connector]; - const session = requireString(body.session, "session"); - const state = requireString(body.state, "state"); - const code = requireString(body.code, "code"); - const redirectUri = runtime.validateExchangeRedirectUri(env, requireString(body.redirect_uri, "redirect_uri")); - const payload = await verifySession( - session, - requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); - if (payload.connector !== connector || payload.state !== state || payload.redirect_uri !== redirectUri) { - throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); - } - const token = await runtime.exchangeCode(env, code, redirectUri); - return runtime.shapeTokenResponse(env, token); -} - -function startRedirects( - env: BrokerEnv, - connector: ConnectorId, - runtime: OAuthConnectorRuntime, - requestedRedirectUri: string, - hostedCallbackHandoff: boolean -): StartRedirects { - const localRedirectUri = runtime.validateLocalRedirectUri(env, requestedRedirectUri); - if (!hostedCallbackHandoff) { - return { - localRedirectUri, - authorizationRedirectUri: localRedirectUri, - exchangeRedirectUri: localRedirectUri, - hostedHandoff: false - }; - } - const hostedCallbackUri = hostedConnectorCallbackUri(env, connector); - if (!hostedCallbackUri) { - return { - localRedirectUri, - authorizationRedirectUri: localRedirectUri, - exchangeRedirectUri: localRedirectUri, - hostedHandoff: false - }; - } - return { - localRedirectUri, - authorizationRedirectUri: hostedCallbackUri, - exchangeRedirectUri: hostedCallbackUri, - hostedHandoff: true - }; -} - -function callbackString(value: string | undefined, field: string, maxBytes: number): string { - if (!value || value.trim() === "" || new TextEncoder().encode(value).byteLength > maxBytes || hasControlCharacter(value)) { - throw badRequest("invalid_callback", `${field} is invalid`); - } - return value; -} - -function optionalCallbackString(value: string | undefined, field: string, maxBytes: number): string | undefined { - if (value === undefined) { - return undefined; - } - return callbackString(value, field, maxBytes); -} - -function hasControlCharacter(value: string): boolean { - for (const character of value) { - const codePoint = character.charCodeAt(0); - if (codePoint < 0x20 || codePoint === 0x7f) { - return true; - } - } - return false; -} - -function localCallbackRedirect(location: string): Response { - return new Response(null, { - status: 303, - headers: { - "Cache-Control": "no-store", - "Referrer-Policy": "no-referrer", - Location: location - } - }); -} - function tokenMode(env: BrokerEnv): "handle" | "raw" { const mode = env.LOCALITY_TOKEN_MODE ?? (env.LOCALITY_REFRESH_HANDLE_KEY ? "handle" : "raw"); if (mode !== "handle" && mode !== "raw") { @@ -643,11 +538,4 @@ function requireOperationalSecret(value: string | undefined, name: string): stri return value; } -function requireConfiguredString(value: string | undefined, name: string): string { - if (!value) { - throw configError(`${name} must be configured`); - } - return value; -} - export default app; diff --git a/apps/oauth-service/src/security/redirects.ts b/apps/oauth-service/src/security/redirects.ts index e699d476..8426c063 100644 --- a/apps/oauth-service/src/security/redirects.ts +++ b/apps/oauth-service/src/security/redirects.ts @@ -1,5 +1,5 @@ import { badRequest } from "../http/errors"; -import type { BrokerEnv, ConnectorId } from "../types"; +import type { BrokerEnv } from "../types"; const DEFAULT_NOTION_REDIRECT_URIS = [ "http://localhost:8757/oauth/notion/callback", @@ -26,187 +26,44 @@ const DEFAULT_SLACK_REDIRECT_URIS = [ "http://127.0.0.1:8757/oauth/slack/callback" ]; -export interface HostedConnectorRedirectConfig { - displayName: string; - hostedCallbackPath: string; - allowedRedirectUris(env: BrokerEnv): string[]; - hostedCallbackValue(env: BrokerEnv): string | undefined; -} - -const CONNECTOR_REDIRECT_CONFIGS: Record = { - notion: { - displayName: "Notion", - hostedCallbackPath: "/v1/oauth/notion/callback", - allowedRedirectUris: (env) => splitList(env.LOCALITY_NOTION_REDIRECT_URIS) ?? DEFAULT_NOTION_REDIRECT_URIS, - hostedCallbackValue: (env) => env.LOCALITY_NOTION_HOSTED_CALLBACK_URI - }, - "google-docs": { - displayName: "Google Docs", - hostedCallbackPath: "/v1/oauth/google-docs/callback", - allowedRedirectUris: (env) => - splitList(env.LOCALITY_GOOGLE_DOCS_REDIRECT_URIS) ?? DEFAULT_GOOGLE_DOCS_REDIRECT_URIS, - hostedCallbackValue: (env) => env.LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI - }, - "google-calendar": { - displayName: "Google Calendar", - hostedCallbackPath: "/v1/oauth/google-calendar/callback", - allowedRedirectUris: (env) => - splitList(env.LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS) ?? DEFAULT_GOOGLE_CALENDAR_REDIRECT_URIS, - hostedCallbackValue: (env) => env.LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI - }, - gmail: { - displayName: "Gmail", - hostedCallbackPath: "/v1/oauth/gmail/callback", - allowedRedirectUris: (env) => splitList(env.LOCALITY_GMAIL_REDIRECT_URIS) ?? DEFAULT_GMAIL_REDIRECT_URIS, - hostedCallbackValue: (env) => env.LOCALITY_GMAIL_HOSTED_CALLBACK_URI - }, - slack: { - displayName: "Slack", - hostedCallbackPath: "/v1/oauth/slack/callback", - allowedRedirectUris: (env) => splitList(env.LOCALITY_SLACK_REDIRECT_URIS) ?? DEFAULT_SLACK_REDIRECT_URIS, - hostedCallbackValue: (env) => env.LOCALITY_SLACK_HOSTED_CALLBACK_URI - } -}; - export function allowedNotionRedirectUris(env: BrokerEnv): string[] { - return CONNECTOR_REDIRECT_CONFIGS.notion.allowedRedirectUris(env); + return splitList(env.LOCALITY_NOTION_REDIRECT_URIS) ?? DEFAULT_NOTION_REDIRECT_URIS; } export function validateNotionRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorRedirectUri(env, "notion", redirectUri); -} - -export function hostedNotionCallbackUri(env: BrokerEnv): string | undefined { - return hostedConnectorCallbackUri(env, "notion"); -} - -export function validateHostedNotionCallbackUri(callbackUri: string): string { - return validateHostedConnectorCallbackUri(CONNECTOR_REDIRECT_CONFIGS.notion, callbackUri); -} - -export function validateNotionExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "notion", redirectUri); + return validateLoopbackRedirectUri("Notion", allowedNotionRedirectUris(env), redirectUri); } export function allowedGoogleDocsRedirectUris(env: BrokerEnv): string[] { - return CONNECTOR_REDIRECT_CONFIGS["google-docs"].allowedRedirectUris(env); + return splitList(env.LOCALITY_GOOGLE_DOCS_REDIRECT_URIS) ?? DEFAULT_GOOGLE_DOCS_REDIRECT_URIS; } export function validateGoogleDocsRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorRedirectUri(env, "google-docs", redirectUri); -} - -export function validateGoogleDocsExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "google-docs", redirectUri); + return validateLoopbackRedirectUri("Google Docs", allowedGoogleDocsRedirectUris(env), redirectUri); } export function allowedGoogleCalendarRedirectUris(env: BrokerEnv): string[] { - return CONNECTOR_REDIRECT_CONFIGS["google-calendar"].allowedRedirectUris(env); + return splitList(env.LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS) ?? DEFAULT_GOOGLE_CALENDAR_REDIRECT_URIS; } export function validateGoogleCalendarRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorRedirectUri(env, "google-calendar", redirectUri); -} - -export function validateGoogleCalendarExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "google-calendar", redirectUri); + return validateLoopbackRedirectUri("Google Calendar", allowedGoogleCalendarRedirectUris(env), redirectUri); } export function allowedGmailRedirectUris(env: BrokerEnv): string[] { - return CONNECTOR_REDIRECT_CONFIGS.gmail.allowedRedirectUris(env); + return splitList(env.LOCALITY_GMAIL_REDIRECT_URIS) ?? DEFAULT_GMAIL_REDIRECT_URIS; } export function validateGmailRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorRedirectUri(env, "gmail", redirectUri); -} - -export function validateGmailExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "gmail", redirectUri); + return validateLoopbackRedirectUri("Gmail", allowedGmailRedirectUris(env), redirectUri); } export function allowedSlackRedirectUris(env: BrokerEnv): string[] { - return CONNECTOR_REDIRECT_CONFIGS.slack.allowedRedirectUris(env); + return splitList(env.LOCALITY_SLACK_REDIRECT_URIS) ?? DEFAULT_SLACK_REDIRECT_URIS; } export function validateSlackRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorRedirectUri(env, "slack", redirectUri); -} - -export function validateSlackExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "slack", redirectUri); -} - -export function hostedConnectorCallbackUri(env: BrokerEnv, connector: ConnectorId): string | undefined { - const config = CONNECTOR_REDIRECT_CONFIGS[connector]; - const value = config.hostedCallbackValue(env)?.trim(); - if (!value) { - return undefined; - } - return validateHostedConnectorCallbackUri(config, value); -} - -export function validateHostedConnectorCallbackUri( - config: HostedConnectorRedirectConfig, - callbackUri: string -): string { - const hasExplicitPort = hasExplicitAuthorityPort(callbackUri); - const hasEmptyQueryOrFragmentDelimiter = /[?#]$/.test(callbackUri); - let parsed: URL; - try { - parsed = new URL(callbackUri); - } catch { - throw badRequest( - "invalid_hosted_callback_uri", - `hosted ${config.displayName} callback URI must be a valid URL` - ); - } - if ( - parsed.protocol !== "https:" || - parsed.username !== "" || - parsed.password !== "" || - parsed.hostname === "" || - hasExplicitPort || - hasEmptyQueryOrFragmentDelimiter || - parsed.port !== "" || - parsed.pathname !== config.hostedCallbackPath || - parsed.search !== "" || - parsed.hash !== "" - ) { - throw badRequest( - "invalid_hosted_callback_uri", - `hosted ${config.displayName} callback URI must be an HTTPS URL at ${config.hostedCallbackPath} without userinfo, port, query, or fragment` - ); - } - return parsed.toString(); -} - -export function validateConnectorRedirectUri(env: BrokerEnv, connector: ConnectorId, redirectUri: string): string { - const config = CONNECTOR_REDIRECT_CONFIGS[connector]; - return validateLoopbackRedirectUri(config.displayName, config.allowedRedirectUris(env), redirectUri); -} - -export function validateConnectorExchangeRedirectUri( - env: BrokerEnv, - connector: ConnectorId, - redirectUri: string -): string { - const hosted = hostedConnectorCallbackUri(env, connector); - if (hosted && redirectUri === hosted) { - return redirectUri; - } - return validateConnectorRedirectUri(env, connector, redirectUri); -} - -function hasExplicitAuthorityPort(value: string): boolean { - const authority = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#]*)/.exec(value)?.[1]; - if (!authority) { - return false; - } - const host = authority.slice(authority.lastIndexOf("@") + 1); - if (host.startsWith("[")) { - return host.includes("]:"); - } - return host.includes(":"); + return validateLoopbackRedirectUri("Slack", allowedSlackRedirectUris(env), redirectUri); } function validateLoopbackRedirectUri(connectorName: string, allowed: string[], redirectUri: string): string { diff --git a/apps/oauth-service/src/security/session.ts b/apps/oauth-service/src/security/session.ts index 574eaa41..07275f32 100644 --- a/apps/oauth-service/src/security/session.ts +++ b/apps/oauth-service/src/security/session.ts @@ -12,23 +12,27 @@ export interface OAuthSessionPayload { nonce: string; } -export interface OAuthLocalHandoffStatePayload { - v: 1; - kind: "local_handoff"; - connector: ConnectorId; - local_redirect_uri: string; - provider_redirect_uri: string; - iat: number; - exp: number; - nonce: string; -} - export async function signSession(payload: OAuthSessionPayload, secret: string): Promise { - return signPayload(payload, secret); + const body = utf8Base64Url(JSON.stringify(payload)); + const signature = await hmacSha256Base64Url(secret, body); + return `${body}.${signature}`; } export async function verifySession(token: string, secret: string, now = nowSeconds()): Promise { - const payload = await verifyPayload(token, secret, "session"); + const [body, signature] = token.split("."); + if (!body || !signature) { + throw badRequest("invalid_session", "OAuth session token is malformed"); + } + const expected = await hmacSha256Base64Url(secret, body); + if (!constantTimeEqual(signature, expected)) { + throw unauthorized("invalid_session", "OAuth session token signature is invalid"); + } + let payload: OAuthSessionPayload; + try { + payload = JSON.parse(parseUtf8Base64Url(body)) as OAuthSessionPayload; + } catch { + throw badRequest("invalid_session", "OAuth session token payload is invalid"); + } if (!isOAuthSessionPayload(payload)) { throw badRequest("invalid_session", "OAuth session token payload is invalid"); } @@ -38,52 +42,10 @@ export async function verifySession(token: string, secret: string, now = nowSeco return payload; } -export async function signLocalHandoffState(payload: OAuthLocalHandoffStatePayload, secret: string): Promise { - return signPayload(payload, secret); -} - -export async function verifyLocalHandoffState( - token: string, - secret: string, - now = nowSeconds() -): Promise { - const payload = await verifyPayload(token, secret, "state"); - if (!isOAuthLocalHandoffStatePayload(payload)) { - throw badRequest("invalid_state", "OAuth state payload is invalid"); - } - if (payload.exp <= now) { - throw unauthorized("expired_state", "OAuth state has expired"); - } - return payload; -} - export function nowSeconds(): number { return Math.floor(Date.now() / 1000); } -async function signPayload(payload: unknown, secret: string): Promise { - const body = utf8Base64Url(JSON.stringify(payload)); - const signature = await hmacSha256Base64Url(secret, body); - return `${body}.${signature}`; -} - -async function verifyPayload(token: string, secret: string, label: "session" | "state"): Promise { - const parts = token.split("."); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw badRequest(`invalid_${label}`, `OAuth ${label} token is malformed`); - } - const [body, signature] = parts; - const expected = await hmacSha256Base64Url(secret, body); - if (!constantTimeEqual(signature, expected)) { - throw unauthorized(`invalid_${label}`, `OAuth ${label} token signature is invalid`); - } - try { - return JSON.parse(parseUtf8Base64Url(body)) as T; - } catch { - throw badRequest(`invalid_${label}`, `OAuth ${label} token payload is invalid`); - } -} - function isOAuthSessionPayload(value: unknown): value is OAuthSessionPayload { if (!value || typeof value !== "object") { return false; @@ -91,7 +53,11 @@ function isOAuthSessionPayload(value: unknown): value is OAuthSessionPayload { const payload = value as Partial; return ( payload.v === 1 && - isConnectorId(payload.connector) && + (payload.connector === "notion" || + payload.connector === "google-docs" || + payload.connector === "google-calendar" || + payload.connector === "gmail" || + payload.connector === "slack") && typeof payload.state === "string" && typeof payload.redirect_uri === "string" && typeof payload.iat === "number" && @@ -99,30 +65,3 @@ function isOAuthSessionPayload(value: unknown): value is OAuthSessionPayload { typeof payload.nonce === "string" ); } - -function isOAuthLocalHandoffStatePayload(value: unknown): value is OAuthLocalHandoffStatePayload { - if (!value || typeof value !== "object") { - return false; - } - const payload = value as Partial; - return ( - payload.v === 1 && - payload.kind === "local_handoff" && - isConnectorId(payload.connector) && - typeof payload.local_redirect_uri === "string" && - typeof payload.provider_redirect_uri === "string" && - typeof payload.iat === "number" && - typeof payload.exp === "number" && - typeof payload.nonce === "string" - ); -} - -function isConnectorId(value: unknown): value is ConnectorId { - return ( - value === "notion" || - value === "google-docs" || - value === "google-calendar" || - value === "gmail" || - value === "slack" - ); -} diff --git a/apps/oauth-service/src/types.ts b/apps/oauth-service/src/types.ts index bc612882..015fac08 100644 --- a/apps/oauth-service/src/types.ts +++ b/apps/oauth-service/src/types.ts @@ -5,28 +5,23 @@ export interface BrokerEnv { LOCALITY_NOTION_CLIENT_ID: string; LOCALITY_NOTION_CLIENT_SECRET: string; LOCALITY_NOTION_REDIRECT_URIS?: string; - LOCALITY_NOTION_HOSTED_CALLBACK_URI?: string; LOCALITY_NOTION_AUTH_BASE_URL?: string; LOCALITY_NOTION_API_BASE_URL?: string; LOCALITY_NOTION_VERSION?: string; LOCALITY_GOOGLE_CLIENT_ID?: string; LOCALITY_GOOGLE_CLIENT_SECRET?: string; LOCALITY_GOOGLE_DOCS_REDIRECT_URIS?: string; - LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI?: string; LOCALITY_GOOGLE_DOCS_AUTH_BASE_URL?: string; LOCALITY_GOOGLE_DOCS_API_BASE_URL?: string; LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS?: string; - LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI?: string; LOCALITY_GOOGLE_CALENDAR_AUTH_BASE_URL?: string; LOCALITY_GOOGLE_CALENDAR_API_BASE_URL?: string; LOCALITY_GMAIL_REDIRECT_URIS?: string; - LOCALITY_GMAIL_HOSTED_CALLBACK_URI?: string; LOCALITY_GMAIL_AUTH_BASE_URL?: string; LOCALITY_GMAIL_API_BASE_URL?: string; LOCALITY_SLACK_CLIENT_ID?: string; LOCALITY_SLACK_CLIENT_SECRET?: string; LOCALITY_SLACK_REDIRECT_URIS?: string; - LOCALITY_SLACK_HOSTED_CALLBACK_URI?: string; LOCALITY_SLACK_AUTH_BASE_URL?: string; LOCALITY_SLACK_API_BASE_URL?: string; } diff --git a/apps/oauth-service/test/app.test.ts b/apps/oauth-service/test/app.test.ts index 060f6d68..f9a30bf8 100644 --- a/apps/oauth-service/test/app.test.ts +++ b/apps/oauth-service/test/app.test.ts @@ -1,16 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import app from "../src/app"; import { hmacSha256Base64Url, utf8Base64Url } from "../src/security/crypto"; -import { signLocalHandoffState } from "../src/security/session"; -import type { BrokerEnv, ConnectorId } from "../src/types"; +import type { BrokerEnv } from "../src/types"; interface StartResponse { connector: string; client_id: string; authorization_url: string; redirect_uri: string; - authorization_redirect_uri?: string; - exchange_redirect_uri?: string; session: string; state: string; } @@ -33,18 +30,6 @@ interface BrokerTokenResponse { refresh_token_handle?: string; } -interface HostedConnectorCase { - connector: ConnectorId; - startPath: string; - callbackPath: string; - exchangePath: string; - localRedirectUri: string; - hostedCallbackUri: string; - hostedEnv: BrokerEnv; - tokenResponse: unknown; - upstreamBody: (body: string) => Record | URLSearchParams; -} - const env: BrokerEnv = { LOCALITY_BROKER_SESSION_SECRET: "test-session-secret-with-enough-entropy", LOCALITY_REFRESH_HANDLE_KEY: "test-refresh-handle-key-with-enough-entropy", @@ -72,113 +57,6 @@ const env: BrokerEnv = { LOCALITY_SLACK_REDIRECT_URIS: "http://localhost:8757/oauth/slack/callback" }; -const brokerOrigin = "https://afs-oauth-broker.saurabh-b07.workers.dev"; -const hostedNotionCallbackUri = `${brokerOrigin}/v1/oauth/notion/callback`; - -const hostedConnectorCases: HostedConnectorCase[] = [ - { - connector: "notion", - startPath: "/v1/oauth/notion/start", - callbackPath: "/v1/oauth/notion/callback", - exchangePath: "/v1/oauth/notion/exchange", - localRedirectUri: "http://localhost:8757/oauth/notion/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/notion/callback`, - hostedEnv: withHostedEnv("LOCALITY_NOTION_HOSTED_CALLBACK_URI", `${brokerOrigin}/v1/oauth/notion/callback`), - tokenResponse: { - access_token: "notion-hosted-access-token", - refresh_token: "notion-hosted-refresh-token", - token_type: "bearer", - expires_in: 3600, - workspace_id: "workspace-id" - }, - upstreamBody: (body) => JSON.parse(body) as Record - }, - { - connector: "google-docs", - startPath: "/v1/oauth/google-docs/start", - callbackPath: "/v1/oauth/google-docs/callback", - exchangePath: "/v1/oauth/google-docs/exchange", - localRedirectUri: "http://localhost:8757/oauth/google-docs/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/google-docs/callback`, - hostedEnv: withHostedEnv( - "LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI", - `${brokerOrigin}/v1/oauth/google-docs/callback` - ), - tokenResponse: { - access_token: "google-docs-hosted-access-token", - refresh_token: "google-docs-hosted-refresh-token", - token_type: "Bearer", - expires_in: 3600, - scope: - "openid email profile https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/drive.metadata" - }, - upstreamBody: (body) => new URLSearchParams(body) - }, - { - connector: "google-calendar", - startPath: "/v1/oauth/google-calendar/start", - callbackPath: "/v1/oauth/google-calendar/callback", - exchangePath: "/v1/oauth/google-calendar/exchange", - localRedirectUri: "http://localhost:8757/oauth/google-calendar/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/google-calendar/callback`, - hostedEnv: withHostedEnv( - "LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI", - `${brokerOrigin}/v1/oauth/google-calendar/callback` - ), - tokenResponse: { - access_token: "google-calendar-hosted-access-token", - refresh_token: "google-calendar-hosted-refresh-token", - token_type: "Bearer", - expires_in: 3600, - scope: "openid email profile https://www.googleapis.com/auth/calendar.events", - id_token: "calendar-id-token" - }, - upstreamBody: (body) => new URLSearchParams(body) - }, - { - connector: "gmail", - startPath: "/v1/oauth/gmail/start", - callbackPath: "/v1/oauth/gmail/callback", - exchangePath: "/v1/oauth/gmail/exchange", - localRedirectUri: "http://localhost:8757/oauth/gmail/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/gmail/callback`, - hostedEnv: withHostedEnv("LOCALITY_GMAIL_HOSTED_CALLBACK_URI", `${brokerOrigin}/v1/oauth/gmail/callback`), - tokenResponse: { - access_token: "gmail-hosted-access-token", - refresh_token: "gmail-hosted-refresh-token", - token_type: "Bearer", - expires_in: 3600, - scope: - "openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose", - id_token: "gmail-id-token" - }, - upstreamBody: (body) => new URLSearchParams(body) - }, - { - connector: "slack", - startPath: "/v1/oauth/slack/start", - callbackPath: "/v1/oauth/slack/callback", - exchangePath: "/v1/oauth/slack/exchange", - localRedirectUri: "http://localhost:8757/oauth/slack/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/slack/callback`, - hostedEnv: withHostedEnv("LOCALITY_SLACK_HOSTED_CALLBACK_URI", `${brokerOrigin}/v1/oauth/slack/callback`), - tokenResponse: { - ok: true, - access_token: "slack-hosted-access-token", - refresh_token: "slack-hosted-refresh-token", - token_type: "bot", - expires_in: 43200, - scope: "channels:read,team:read", - team: { id: "T123", name: "Locality" } - }, - upstreamBody: (body) => new URLSearchParams(body) - } -]; - -function withHostedEnv(key: string, value: string): BrokerEnv { - return { ...env, [key]: value } as BrokerEnv; -} - describe("auth broker", () => { const originalFetch = globalThis.fetch; @@ -240,551 +118,6 @@ describe("auth broker", () => { expect(body.state).toBeTruthy(); }); - it("starts Notion OAuth with hosted provider callback and local loopback handoff", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - - const response = await app.request( - "/v1/oauth/notion/start", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - redirect_uri: "http://localhost:8757/oauth/notion/callback", - hosted_callback_handoff: true - }) - }, - hostedEnv - ); - - expect(response.status).toBe(200); - const body = (await response.json()) as StartResponse & { - authorization_redirect_uri: string; - exchange_redirect_uri: string; - }; - expect(body.redirect_uri).toBe("http://localhost:8757/oauth/notion/callback"); - expect(body.authorization_redirect_uri).toBe( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - ); - expect(body.exchange_redirect_uri).toBe( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - ); - const authorizationUrl = new URL(body.authorization_url); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - ); - expect(authorizationUrl.searchParams.get("state")).toBe(body.state); - expect(body.session).toBeTruthy(); - expect(body.state).toBeTruthy(); - expect(body.session).not.toBe(body.state); - }); - - it("rejects hosted Notion callback config with an explicit default HTTPS port", async () => { - const response = await app.request( - "/v1/oauth/notion/start", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - redirect_uri: "http://localhost:8757/oauth/notion/callback", - hosted_callback_handoff: true - }) - }, - { - ...env, - LOCALITY_NOTION_HOSTED_CALLBACK_URI: - "https://afs-oauth-broker.saurabh-b07.workers.dev:443/v1/oauth/notion/callback" - } - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: "invalid_hosted_callback_uri" } - }); - }); - - it("rejects hosted Google Docs callback config with an explicit default HTTPS port", async () => { - const response = await app.request( - "/v1/oauth/google-docs/start", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - redirect_uri: "http://localhost:8757/oauth/google-docs/callback", - hosted_callback_handoff: true - }) - }, - withHostedEnv( - "LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI", - "https://afs-oauth-broker.saurabh-b07.workers.dev:443/v1/oauth/google-docs/callback" - ) - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: "invalid_hosted_callback_uri" } - }); - }); - - it.each(["?", "#"])( - "rejects hosted Google Docs callback config with a bare %s delimiter", - async (delimiter) => { - const response = await app.request( - "/v1/oauth/google-docs/start", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - redirect_uri: "http://localhost:8757/oauth/google-docs/callback", - hosted_callback_handoff: true - }) - }, - withHostedEnv( - "LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI", - `${brokerOrigin}/v1/oauth/google-docs/callback${delimiter}` - ) - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: "invalid_hosted_callback_uri" } - }); - } - ); - - it("rejects a local handoff state sent to another connector callback route", async () => { - const state = await signedLocalHandoffState({ - connector: "google-docs", - local_redirect_uri: "http://localhost:8757/oauth/google-docs/callback", - provider_redirect_uri: `${brokerOrigin}/v1/oauth/google-docs/callback` - }); - - const callback = await app.request( - `/v1/oauth/gmail/callback?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - withHostedEnv("LOCALITY_GMAIL_HOSTED_CALLBACK_URI", `${brokerOrigin}/v1/oauth/gmail/callback`) - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - describe.each(hostedConnectorCases)("$connector hosted handoff", (caseDef) => { - it("keeps older start clients on direct local redirect flow", async () => { - const startResponse = await app.request( - caseDef.startPath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ redirect_uri: caseDef.localRedirectUri }) - }, - caseDef.hostedEnv - ); - - expect(startResponse.status).toBe(200); - const start = await startResponse.json() as StartResponse; - expect(start.redirect_uri).toBe(caseDef.localRedirectUri); - expect(start.authorization_redirect_uri).toBeUndefined(); - expect(start.exchange_redirect_uri).toBeUndefined(); - const authorizationUrl = new URL(start.authorization_url); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(caseDef.localRedirectUri); - - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - Response.json(caseDef.tokenResponse) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const exchange = await app.request( - caseDef.exchangePath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: start.redirect_uri - }) - }, - caseDef.hostedEnv - ); - - expect(exchange.status).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(1); - const requestBody = caseDef.upstreamBody((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); - expect(upstreamBodyParam(requestBody, "redirect_uri")).toBe(caseDef.localRedirectUri); - }); - - it("starts OAuth with hosted provider callback and local loopback handoff", async () => { - const body = await startHostedSession(caseDef); - - expect(body.connector).toBe(caseDef.connector); - expect(body.redirect_uri).toBe(caseDef.localRedirectUri); - expect(body.authorization_redirect_uri).toBe(caseDef.hostedCallbackUri); - expect(body.exchange_redirect_uri).toBe(caseDef.hostedCallbackUri); - const authorizationUrl = new URL(body.authorization_url); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(caseDef.hostedCallbackUri); - expect(authorizationUrl.searchParams.get("state")).toBe(body.state); - expect(body.session).toBeTruthy(); - expect(body.state).toBeTruthy(); - expect(body.session).not.toBe(body.state); - }); - - it("redirects a valid hosted callback to the local loopback listener", async () => { - const start = await startHostedSession(caseDef); - - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - caseDef.hostedEnv - ); - - expect(callback.status).toBe(303); - expect(callback.headers.get("cache-control")).toBe("no-store"); - expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); - const location = new URL(callback.headers.get("location") ?? ""); - const localRedirect = new URL(caseDef.localRedirectUri); - expect(location.origin).toBe(localRedirect.origin); - expect(location.pathname).toBe(localRedirect.pathname); - expect(location.searchParams.get("code")).toBe("authorization-code"); - expect(location.searchParams.get("state")).toBe(start.state); - }); - - it("redirects provider denial to the local loopback listener", async () => { - const start = await startHostedSession(caseDef); - - const callback = await app.request( - `${caseDef.callbackPath}?error=access_denied&error_description=User%20cancelled&state=${encodeURIComponent( - start.state - )}`, - { method: "GET" }, - caseDef.hostedEnv - ); - - expect(callback.status).toBe(303); - expect(callback.headers.get("cache-control")).toBe("no-store"); - expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); - const location = new URL(callback.headers.get("location") ?? ""); - const localRedirect = new URL(caseDef.localRedirectUri); - expect(location.origin).toBe(localRedirect.origin); - expect(location.pathname).toBe(localRedirect.pathname); - expect(location.searchParams.get("error")).toBe("access_denied"); - expect(location.searchParams.get("error_description")).toBe("User cancelled"); - expect(location.searchParams.get("state")).toBe(start.state); - expect(location.searchParams.get("code")).toBeNull(); - }); - - it("rejects unsigned callback state with invalid_state", async () => { - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=not-signed`, - { method: "GET" }, - caseDef.hostedEnv - ); - - expect(callback.status).toBe(400); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("rejects signed handoff state with unallowed local_redirect_uri", async () => { - const state = await signedLocalHandoffState({ - connector: caseDef.connector, - local_redirect_uri: `http://localhost:9999/oauth/${caseDef.connector}/callback`, - provider_redirect_uri: caseDef.hostedCallbackUri - }); - - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - caseDef.hostedEnv - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "redirect_uri_not_allowed" } - }); - }); - - it("rejects signed handoff state with wrong provider_redirect_uri", async () => { - const state = await signedLocalHandoffState({ - connector: caseDef.connector, - local_redirect_uri: caseDef.localRedirectUri, - provider_redirect_uri: `https://other-broker.example.test/v1/oauth/${caseDef.connector}/callback` - }); - - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - caseDef.hostedEnv - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("exchanges authorization code with hosted redirect URI and sends hosted redirect upstream", async () => { - const start = await startHostedSession(caseDef); - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - Response.json(caseDef.tokenResponse) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - caseDef.exchangePath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: start.exchange_redirect_uri - }) - }, - caseDef.hostedEnv - ); - - expect(response.status).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(1); - const requestBody = caseDef.upstreamBody((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); - expect(upstreamBodyParam(requestBody, "grant_type")).toBe("authorization_code"); - expect(upstreamBodyParam(requestBody, "code")).toBe("authorization-code"); - expect(upstreamBodyParam(requestBody, "redirect_uri")).toBe(caseDef.hostedCallbackUri); - }); - - it("rejects hosted exchange with arbitrary redirect_uri before upstream fetch", async () => { - const start = await startHostedSession(caseDef); - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - Response.json({ access_token: "unexpected" }) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - caseDef.exchangePath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: `https://attacker.example.test/v1/oauth/${caseDef.connector}/callback` - }) - }, - caseDef.hostedEnv - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: "invalid_redirect_uri" } - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - it("redirects a valid hosted Notion callback to the local loopback listener", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - const start = await startHostedNotionSession(hostedEnv); - - const callback = await app.request( - `/v1/oauth/notion/callback?code=authorization-code&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(303); - expect(callback.headers.get("cache-control")).toBe("no-store"); - expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); - const location = new URL(callback.headers.get("location") ?? ""); - expect(location.origin).toBe("http://localhost:8757"); - expect(location.pathname).toBe("/oauth/notion/callback"); - expect(location.searchParams.get("code")).toBe("authorization-code"); - expect(location.searchParams.get("state")).toBe(start.state); - }); - - it("redirects hosted Notion provider denial to the local listener with state", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - const start = await startHostedNotionSession(hostedEnv); - - const callback = await app.request( - `/v1/oauth/notion/callback?error=access_denied&error_description=User%20cancelled&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(303); - const location = new URL(callback.headers.get("location") ?? ""); - expect(location.origin).toBe("http://localhost:8757"); - expect(location.pathname).toBe("/oauth/notion/callback"); - expect(location.searchParams.get("error")).toBe("access_denied"); - expect(location.searchParams.get("error_description")).toBe("User cancelled"); - expect(location.searchParams.get("state")).toBe(start.state); - expect(location.searchParams.get("code")).toBeNull(); - }); - - it("rejects hosted Notion callback state that was not signed by the broker", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - - const callback = await app.request( - "/v1/oauth/notion/callback?code=authorization-code&state=not-signed", - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("rejects hosted Notion callback state with an unallowed local redirect URI", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: hostedNotionCallbackUri - }; - const state = await signedLocalHandoffState({ - local_redirect_uri: "http://localhost:9999/oauth/notion/callback" - }); - - const callback = await app.request( - `/v1/oauth/notion/callback?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "redirect_uri_not_allowed" } - }); - }); - - it("rejects hosted Notion callback state with the wrong provider redirect URI", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: hostedNotionCallbackUri - }; - const state = await signedLocalHandoffState({ - provider_redirect_uri: "https://other-broker.example.test/v1/oauth/notion/callback" - }); - - const callback = await app.request( - `/v1/oauth/notion/callback?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("exchanges hosted Notion authorization codes with the hosted redirect URI", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - const start = (await startHostedNotionSession(hostedEnv)) as StartResponse & { exchange_redirect_uri: string }; - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - Response.json({ - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "bearer", - expires_in: 3600, - workspace_id: "workspace-id" - }) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - "/v1/oauth/notion/exchange", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: start.exchange_redirect_uri - }) - }, - hostedEnv - ); - - expect(response.status).toBe(200); - const notionRequest = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); - expect(notionRequest).toMatchObject({ - grant_type: "authorization_code", - code: "authorization-code", - redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }); - }); - - it("rejects hosted Notion exchange with an arbitrary redirect URI", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: hostedNotionCallbackUri - }; - const start = await startHostedNotionSession(hostedEnv); - const fetchMock = vi.fn(async () => Response.json({ access_token: "unexpected" })); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - "/v1/oauth/notion/exchange", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: "https://attacker.example.test/v1/oauth/notion/callback" - }) - }, - hostedEnv - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: "invalid_redirect_uri" } - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it("rejects unconfigured redirect URIs", async () => { const response = await app.request( "/v1/oauth/notion/start", @@ -1587,65 +920,3 @@ async function startSlackSession() { expect(response.status).toBe(200); return response.json() as Promise; } - -async function startHostedNotionSession(hostedEnv: BrokerEnv) { - const response = await app.request( - "/v1/oauth/notion/start", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - redirect_uri: "http://localhost:8757/oauth/notion/callback", - hosted_callback_handoff: true - }) - }, - hostedEnv - ); - expect(response.status).toBe(200); - return response.json() as Promise; -} - -async function startHostedSession(caseDef: HostedConnectorCase) { - const response = await app.request( - caseDef.startPath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - redirect_uri: caseDef.localRedirectUri, - hosted_callback_handoff: true - }) - }, - caseDef.hostedEnv - ); - expect(response.status).toBe(200); - return response.json() as Promise; -} - -function upstreamBodyParam(body: Record | URLSearchParams, name: string): unknown { - return body instanceof URLSearchParams ? body.get(name) : body[name]; -} - -async function signedLocalHandoffState( - overrides: Partial<{ - connector: ConnectorId; - local_redirect_uri: string; - provider_redirect_uri: string; - }> = {} -) { - const now = Math.floor(Date.now() / 1000); - const connector = overrides.connector ?? "notion"; - return signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector, - local_redirect_uri: overrides.local_redirect_uri ?? `http://localhost:8757/oauth/${connector}/callback`, - provider_redirect_uri: overrides.provider_redirect_uri ?? `${brokerOrigin}/v1/oauth/${connector}/callback`, - iat: now, - exp: now + 600, - nonce: "test-nonce" - }, - env.LOCALITY_BROKER_SESSION_SECRET - ); -} diff --git a/apps/oauth-service/test/local-handoff-state.test.ts b/apps/oauth-service/test/local-handoff-state.test.ts deleted file mode 100644 index 4289f557..00000000 --- a/apps/oauth-service/test/local-handoff-state.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { signLocalHandoffState, signSession, verifyLocalHandoffState } from "../src/security/session"; - -const secret = "test-session-secret-with-enough-entropy"; - -describe("local OAuth handoff state", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-29T12:00:00Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("round-trips a Notion local handoff state without storing server state", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - - const payload = await verifyLocalHandoffState(token, secret, 1785326401); - - expect(payload).toEqual({ - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }); - }); - - it("rejects a tampered local handoff state", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - const [body, signature] = token.split("."); - if (!signature) { - throw new Error("signed state token is missing a signature"); - } - const replacement = signature.endsWith("A") ? "B" : "A"; - const tampered = `${body}.${signature.slice(0, -1)}${replacement}`; - - await expect(verifyLocalHandoffState(tampered, secret, 1785326401)).rejects.toMatchObject({ - status: 401, - code: "invalid_state" - }); - }); - - it("rejects a local handoff state token with extra segments", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - - await expect(verifyLocalHandoffState(`${token}.extra`, secret, 1785326401)).rejects.toMatchObject({ - status: 400, - code: "invalid_state" - }); - }); - - it("rejects a signed OAuth session payload as local handoff state", async () => { - const token = await signSession( - { - v: 1, - connector: "notion", - state: "state-1", - redirect_uri: "http://localhost:8757/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - - await expect(verifyLocalHandoffState(token, secret, 1785326401)).rejects.toMatchObject({ - status: 400, - code: "invalid_state" - }); - }); - - it("rejects an expired local handoff state", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - - await expect(verifyLocalHandoffState(token, secret, 1785327000)).rejects.toMatchObject({ - status: 401, - code: "expired_state" - }); - }); -}); diff --git a/apps/oauth-service/wrangler.toml b/apps/oauth-service/wrangler.toml index 7b9d916d..49cd6eae 100644 --- a/apps/oauth-service/wrangler.toml +++ b/apps/oauth-service/wrangler.toml @@ -8,13 +8,6 @@ enabled = true [vars] LOCALITY_TOKEN_MODE = "handle" -LOCALITY_NOTION_REDIRECT_URIS = "http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback" -LOCALITY_NOTION_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" +LOCALITY_NOTION_REDIRECT_URIS = "http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback,https://api.dev.locality.dev/v1/oauth/notion/callback" LOCALITY_GOOGLE_DOCS_REDIRECT_URIS = "http://localhost:8757/oauth/google-docs/callback,http://127.0.0.1:8757/oauth/google-docs/callback" -LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback" -LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS = "http://localhost:8757/oauth/google-calendar/callback,http://127.0.0.1:8757/oauth/google-calendar/callback" -LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback" LOCALITY_GMAIL_REDIRECT_URIS = "http://localhost:8757/oauth/gmail/callback,http://127.0.0.1:8757/oauth/gmail/callback" -LOCALITY_GMAIL_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" -LOCALITY_SLACK_REDIRECT_URIS = "http://localhost:8757/oauth/slack/callback,http://127.0.0.1:8757/oauth/slack/callback" -LOCALITY_SLACK_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback" diff --git a/crates/loc-cli/src/commands.rs b/crates/loc-cli/src/commands.rs index c3e9b8e2..e58bcf93 100644 --- a/crates/loc-cli/src/commands.rs +++ b/crates/loc-cli/src/commands.rs @@ -9,7 +9,7 @@ use std::time::Duration; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use locality_connector::ConnectorUndoApplier; -use locality_connector::oauth_broker::{OAuthBrokerStart, OAuthBrokerStartResponse}; +use locality_connector::oauth_broker::OAuthBrokerStart; use locality_core::LocalityError; use locality_core::freshness::RemoteVersion; use locality_core::hydration::{HydrationReason, HydrationRequest}; @@ -2093,7 +2093,6 @@ fn connect(args: &[String], json: bool) -> i32 { let broker = HttpNotionOAuthBrokerClient::new(broker_config.broker_url.clone()); let start = match broker.start(&NotionOAuthBrokerStart { redirect_uri: broker_config.redirect_uri, - hosted_callback_handoff: true, }) { Ok(start) => start, Err(error) => { @@ -2113,7 +2112,7 @@ fn connect(args: &[String], json: bool) -> i32 { let authorization = match run_local_oauth_authorization( "Notion", &authorization_url, - start.local_redirect_uri(), + &start.redirect_uri, &start.state, has_flag(args, "--no-browser"), json, @@ -2123,7 +2122,6 @@ fn connect(args: &[String], json: bool) -> i32 { return command_error(json, local_oauth_command_error(error), EXIT_INTERNAL); } }; - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); let options = BrokerOAuthConnectOptions { connection_id: flag_value(args, "--name").map(ConnectionId::new), broker_url: broker_config.broker_url, @@ -2131,7 +2129,7 @@ fn connect(args: &[String], json: bool) -> i32 { session: start.session, state: start.state, code: authorization.code, - redirect_uri: exchange_redirect_uri, + redirect_uri: start.redirect_uri, }; return match run_connect_notion_broker_oauth( &mut store, @@ -2271,136 +2269,6 @@ fn remove_path_if_exists(path: &Path) -> Result<(), String> { .map_err(|error| format!("Could not remove `{}`: {error}", path.display())) } -struct BrokerOAuthCommandStart { - provider_name: &'static str, - start: OAuthBrokerStartResponse, - connection_id: Option, - broker_url: String, - no_browser: bool, - json: bool, -} - -struct BrokerOAuthExchangeOptionsInput { - connection_id: Option, - broker_url: String, - client_id: String, - session: String, - state: String, - code: String, - redirect_uri: String, -} - -#[derive(Debug)] -enum BrokerOAuthCommandFlowError { - LocalOAuth(LocalOAuthError), - Connect(ConnectError), -} - -fn run_broker_oauth_command_flow( - command: BrokerOAuthCommandStart, - authorize: Authorize, - build_options: BuildOptions, - connect: Connect, -) -> Result -where - Authorize: FnOnce( - &str, - &str, - &str, - &str, - bool, - bool, - ) -> Result, - BuildOptions: FnOnce(BrokerOAuthExchangeOptionsInput) -> O, - Connect: FnOnce(O) -> Result, -{ - let BrokerOAuthCommandStart { - provider_name, - start, - connection_id, - broker_url, - no_browser, - json, - } = command; - let authorization = authorize( - provider_name, - &start.authorization_url, - start.local_redirect_uri(), - &start.state, - no_browser, - json, - ) - .map_err(BrokerOAuthCommandFlowError::LocalOAuth)?; - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); - let options = build_options(BrokerOAuthExchangeOptionsInput { - connection_id, - broker_url, - client_id: start.client_id, - session: start.session, - state: start.state, - code: authorization.code, - redirect_uri: exchange_redirect_uri, - }); - - connect(options).map_err(BrokerOAuthCommandFlowError::Connect) -} - -fn google_docs_broker_oauth_connect_options( - input: BrokerOAuthExchangeOptionsInput, -) -> GoogleDocsBrokerOAuthConnectOptions { - GoogleDocsBrokerOAuthConnectOptions { - connection_id: input.connection_id, - broker_url: input.broker_url, - client_id: input.client_id, - session: input.session, - state: input.state, - code: input.code, - redirect_uri: input.redirect_uri, - } -} - -fn google_calendar_broker_oauth_connect_options( - input: BrokerOAuthExchangeOptionsInput, -) -> GoogleCalendarBrokerOAuthConnectOptions { - GoogleCalendarBrokerOAuthConnectOptions { - connection_id: input.connection_id, - broker_url: input.broker_url, - client_id: input.client_id, - session: input.session, - state: input.state, - code: input.code, - redirect_uri: input.redirect_uri, - } -} - -fn gmail_broker_oauth_connect_options( - input: BrokerOAuthExchangeOptionsInput, -) -> GmailBrokerOAuthConnectOptions { - GmailBrokerOAuthConnectOptions { - connection_id: input.connection_id, - broker_url: input.broker_url, - client_id: input.client_id, - session: input.session, - state: input.state, - code: input.code, - redirect_uri: input.redirect_uri, - } -} - -fn slack_broker_oauth_connect_options( - input: BrokerOAuthExchangeOptionsInput, -) -> SlackBrokerOAuthConnectOptions { - SlackBrokerOAuthConnectOptions { - connection_id: input.connection_id, - broker_url: input.broker_url, - client_id: input.client_id, - session: input.session, - state: input.state, - code: input.code, - redirect_uri: input.redirect_uri, - } -} - fn connect_google_docs(args: &[String], json: bool) -> i32 { let state_root = default_state_root(); let mut store = match SqliteStateStore::open(state_root.clone()) { @@ -2422,7 +2290,6 @@ fn connect_google_docs(args: &[String], json: bool) -> i32 { let start = match broker.start(&OAuthBrokerStart { connector: GOOGLE_DOCS_CONNECTOR_ID.to_string(), redirect_uri: broker_config.redirect_uri, - hosted_callback_handoff: true, }) { Ok(start) => start, Err(error) => { @@ -2438,21 +2305,33 @@ fn connect_google_docs(args: &[String], json: bool) -> i32 { ); } }; - match run_broker_oauth_command_flow( - BrokerOAuthCommandStart { - provider_name: "Google Docs", - start, - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - no_browser: has_flag(args, "--no-browser"), - json, - }, - run_local_oauth_authorization, - google_docs_broker_oauth_connect_options, - |options| { - run_connect_google_docs_broker_oauth(&mut store, credentials.as_ref(), options, &broker) - }, + let authorization = match run_local_oauth_authorization( + "Google Docs", + &start.authorization_url, + &start.redirect_uri, + &start.state, + has_flag(args, "--no-browser"), + json, ) { + Ok(authorization) => authorization, + Err(error) => { + return command_error( + json, + google_docs_local_oauth_command_error(error), + EXIT_INTERNAL, + ); + } + }; + let options = GoogleDocsBrokerOAuthConnectOptions { + connection_id: flag_value(args, "--name").map(ConnectionId::new), + broker_url: broker_config.broker_url, + client_id: start.client_id, + session: start.session, + state: start.state, + code: authorization.code, + redirect_uri: start.redirect_uri, + }; + match run_connect_google_docs_broker_oauth(&mut store, credentials.as_ref(), options, &broker) { Ok(report) if json => { print_json(&report); EXIT_SUCCESS @@ -2461,14 +2340,7 @@ fn connect_google_docs(args: &[String], json: bool) -> i32 { print_connect_report(&report); EXIT_SUCCESS } - Err(BrokerOAuthCommandFlowError::LocalOAuth(error)) => command_error( - json, - google_docs_local_oauth_command_error(error), - EXIT_INTERNAL, - ), - Err(BrokerOAuthCommandFlowError::Connect(error)) => { - connect_command_error("connect", json, error) - } + Err(error) => connect_command_error("connect", json, error), } } @@ -2493,7 +2365,6 @@ fn connect_google_calendar(args: &[String], json: bool) -> i32 { let start = match broker.start(&OAuthBrokerStart { connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), redirect_uri: broker_config.redirect_uri, - hosted_callback_handoff: true, }) { Ok(start) => start, Err(error) => { @@ -2509,25 +2380,37 @@ fn connect_google_calendar(args: &[String], json: bool) -> i32 { ); } }; - match run_broker_oauth_command_flow( - BrokerOAuthCommandStart { - provider_name: "Google Calendar", - start, - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - no_browser: has_flag(args, "--no-browser"), - json, - }, - run_local_oauth_authorization, - google_calendar_broker_oauth_connect_options, - |options| { - run_connect_google_calendar_broker_oauth( - &mut store, - credentials.as_ref(), - options, - &broker, - ) - }, + let authorization = match run_local_oauth_authorization( + "Google Calendar", + &start.authorization_url, + &start.redirect_uri, + &start.state, + has_flag(args, "--no-browser"), + json, + ) { + Ok(authorization) => authorization, + Err(error) => { + return command_error( + json, + google_calendar_local_oauth_command_error(error), + EXIT_INTERNAL, + ); + } + }; + let options = GoogleCalendarBrokerOAuthConnectOptions { + connection_id: flag_value(args, "--name").map(ConnectionId::new), + broker_url: broker_config.broker_url, + client_id: start.client_id, + session: start.session, + state: start.state, + code: authorization.code, + redirect_uri: start.redirect_uri, + }; + match run_connect_google_calendar_broker_oauth( + &mut store, + credentials.as_ref(), + options, + &broker, ) { Ok(report) if json => { print_json(&report); @@ -2537,14 +2420,7 @@ fn connect_google_calendar(args: &[String], json: bool) -> i32 { print_connect_report(&report); EXIT_SUCCESS } - Err(BrokerOAuthCommandFlowError::LocalOAuth(error)) => command_error( - json, - google_calendar_local_oauth_command_error(error), - EXIT_INTERNAL, - ), - Err(BrokerOAuthCommandFlowError::Connect(error)) => { - connect_command_error("connect", json, error) - } + Err(error) => connect_command_error("connect", json, error), } } @@ -2569,7 +2445,6 @@ fn connect_gmail(args: &[String], json: bool) -> i32 { let start = match broker.start(&OAuthBrokerStart { connector: GMAIL_CONNECTOR_ID.to_string(), redirect_uri: broker_config.redirect_uri, - hosted_callback_handoff: true, }) { Ok(start) => start, Err(error) => { @@ -2585,21 +2460,29 @@ fn connect_gmail(args: &[String], json: bool) -> i32 { ); } }; - match run_broker_oauth_command_flow( - BrokerOAuthCommandStart { - provider_name: "Gmail", - start, - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - no_browser: has_flag(args, "--no-browser"), - json, - }, - run_local_oauth_authorization, - gmail_broker_oauth_connect_options, - |options| { - run_connect_gmail_broker_oauth(&mut store, credentials.as_ref(), options, &broker) - }, + let authorization = match run_local_oauth_authorization( + "Gmail", + &start.authorization_url, + &start.redirect_uri, + &start.state, + has_flag(args, "--no-browser"), + json, ) { + Ok(authorization) => authorization, + Err(error) => { + return command_error(json, gmail_local_oauth_command_error(error), EXIT_INTERNAL); + } + }; + let options = GmailBrokerOAuthConnectOptions { + connection_id: flag_value(args, "--name").map(ConnectionId::new), + broker_url: broker_config.broker_url, + client_id: start.client_id, + session: start.session, + state: start.state, + code: authorization.code, + redirect_uri: start.redirect_uri, + }; + match run_connect_gmail_broker_oauth(&mut store, credentials.as_ref(), options, &broker) { Ok(report) if json => { print_json(&report); EXIT_SUCCESS @@ -2608,12 +2491,7 @@ fn connect_gmail(args: &[String], json: bool) -> i32 { print_connect_report(&report); EXIT_SUCCESS } - Err(BrokerOAuthCommandFlowError::LocalOAuth(error)) => { - command_error(json, gmail_local_oauth_command_error(error), EXIT_INTERNAL) - } - Err(BrokerOAuthCommandFlowError::Connect(error)) => { - connect_command_error("connect", json, error) - } + Err(error) => connect_command_error("connect", json, error), } } @@ -2638,7 +2516,6 @@ fn connect_slack(args: &[String], json: bool) -> i32 { let start = match broker.start(&OAuthBrokerStart { connector: SLACK_CONNECTOR_ID.to_string(), redirect_uri: broker_config.redirect_uri, - hosted_callback_handoff: true, }) { Ok(start) => start, Err(error) => { @@ -2654,21 +2531,29 @@ fn connect_slack(args: &[String], json: bool) -> i32 { ); } }; - match run_broker_oauth_command_flow( - BrokerOAuthCommandStart { - provider_name: "Slack", - start, - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - no_browser: has_flag(args, "--no-browser"), - json, - }, - run_local_oauth_authorization, - slack_broker_oauth_connect_options, - |options| { - run_connect_slack_broker_oauth(&mut store, credentials.as_ref(), options, &broker) - }, + let authorization = match run_local_oauth_authorization( + "Slack", + &start.authorization_url, + &start.redirect_uri, + &start.state, + has_flag(args, "--no-browser"), + json, ) { + Ok(authorization) => authorization, + Err(error) => { + return command_error(json, slack_local_oauth_command_error(error), EXIT_INTERNAL); + } + }; + let options = SlackBrokerOAuthConnectOptions { + connection_id: flag_value(args, "--name").map(ConnectionId::new), + broker_url: broker_config.broker_url, + client_id: start.client_id, + session: start.session, + state: start.state, + code: authorization.code, + redirect_uri: start.redirect_uri, + }; + match run_connect_slack_broker_oauth(&mut store, credentials.as_ref(), options, &broker) { Ok(report) if json => { print_json(&report); EXIT_SUCCESS @@ -2677,12 +2562,7 @@ fn connect_slack(args: &[String], json: bool) -> i32 { print_connect_report(&report); EXIT_SUCCESS } - Err(BrokerOAuthCommandFlowError::LocalOAuth(error)) => { - command_error(json, slack_local_oauth_command_error(error), EXIT_INTERNAL) - } - Err(BrokerOAuthCommandFlowError::Connect(error)) => { - connect_command_error("connect", json, error) - } + Err(error) => connect_command_error("connect", json, error), } } @@ -9854,7 +9734,6 @@ mod tests { use clap::Parser; use clap::error::ErrorKind; - use locality_connector::oauth_broker::OAuthBrokerStartResponse; use locality_core::LocalityError; use locality_core::model::{EntityKind, HydrationState, MountId, RemoteId, TreeEntry}; use locality_core::shadow::ShadowDocument; @@ -9867,7 +9746,7 @@ mod tests { use crate::diff::{DiffReport, GuardrailOutput, PlanSummaryOutput}; use crate::history::{JournalEntryOutput, LogReport}; - use crate::local_oauth::{LocalOAuthAuthorization, local_redirect, parse_oauth_callback}; + use crate::local_oauth::{local_redirect, parse_oauth_callback}; use crate::push::PushReport; use crate::search::{ SearchOptions, SearchRemoteState, SearchReport, SearchResult, SearchSafety, @@ -9876,28 +9755,24 @@ mod tests { #[cfg(target_os = "windows")] use super::resolve_mount_target; use super::{ - BrokerOAuthCommandStart, BrokerOAuthExchangeOptionsInput, Cli, ConnectReport, - DEFAULT_DAEMON_CONTROL_TIMEOUT, DEFAULT_DAEMON_MUTATING_TIMEOUT, + Cli, ConnectReport, DEFAULT_DAEMON_CONTROL_TIMEOUT, DEFAULT_DAEMON_MUTATING_TIMEOUT, DEFAULT_DAEMON_PULL_TIMEOUT, DaemonRequest, DaemonUnavailableReason, EXIT_SUCCESS, EXIT_USAGE, EXIT_VALIDATION, FileProviderCommandReport, LocalityCommand, PushConfirmationPromptError, SLACK_CONNECTOR_ID, SandboxCommand, SandboxEncodingArg, VirtualProjectionRegistration, absolute_command_path, auto_registration_for_mounted_projection, daemon_request_timeout_for, default_mount_id_for_source, diff_report_exit_code, exact_located_entity_record, - file_provider_list_lines, gmail_broker_oauth_connect_options, - google_calendar_broker_oauth_connect_options, google_calendar_oauth_broker_config, - google_docs_broker_oauth_connect_options, google_docs_oauth_broker_config, - guard_linux_fuse_shared_root_unregister, guard_unresolved_linux_fuse_unregister, - guard_unresolved_windows_cloud_files_unregister, + file_provider_list_lines, google_calendar_oauth_broker_config, + google_docs_oauth_broker_config, guard_linux_fuse_shared_root_unregister, + guard_unresolved_linux_fuse_unregister, guard_unresolved_windows_cloud_files_unregister, guard_windows_cloud_files_shared_root_unregister, legacy_args_for_command, locality_error_code, locate_result_from_report, mount_slack, mount_usage, mounted_projection_preflight_error, notion_authorize_url, notion_oauth_broker_config, print_push_confirmation_preview, projection_mode_for_target, projection_usage_options_for_target, prompt_for_push_confirmation, pull_direct_fallback_error, push_confirmation_preview_matches_displayed, - push_preview_plan_matches, run_broker_oauth_command_flow, - should_prompt_for_push_confirmation, should_refresh_notion_url_search, - slack_broker_oauth_connect_options, slack_mount_missing_path_error, + push_preview_plan_matches, should_prompt_for_push_confirmation, + should_refresh_notion_url_search, slack_mount_missing_path_error, slack_oauth_broker_config, spinner_config_for_command, spinner_enabled, status as run_status_command, validate_virtual_projection_registration, write_connect_report, write_log_report, @@ -9915,147 +9790,6 @@ mod tests { ); } - #[test] - fn broker_oauth_command_flow_splits_local_listener_and_hosted_exchange_redirects() { - assert_broker_oauth_command_flow_splits_redirects( - "Google Docs", - "google-docs", - "docs-hosted-command", - "http://localhost:8757/oauth/google-docs/callback", - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback", - google_docs_broker_oauth_connect_options, - |options, connection_id, hosted_redirect_uri| { - assert_eq!( - options.connection_id.as_ref().map(ConnectionId::as_str), - Some(connection_id) - ); - assert_eq!(options.redirect_uri, hosted_redirect_uri); - }, - ); - assert_broker_oauth_command_flow_splits_redirects( - "Google Calendar", - "google-calendar", - "google-calendar-hosted-command", - "http://localhost:8757/oauth/google-calendar/callback", - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback", - google_calendar_broker_oauth_connect_options, - |options, connection_id, hosted_redirect_uri| { - assert_eq!( - options.connection_id.as_ref().map(ConnectionId::as_str), - Some(connection_id) - ); - assert_eq!(options.redirect_uri, hosted_redirect_uri); - }, - ); - assert_broker_oauth_command_flow_splits_redirects( - "Gmail", - "gmail", - "gmail-hosted-command", - "http://localhost:8757/oauth/gmail/callback", - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback", - gmail_broker_oauth_connect_options, - |options, connection_id, hosted_redirect_uri| { - assert_eq!( - options.connection_id.as_ref().map(ConnectionId::as_str), - Some(connection_id) - ); - assert_eq!(options.redirect_uri, hosted_redirect_uri); - }, - ); - assert_broker_oauth_command_flow_splits_redirects( - "Slack", - "slack", - "slack-hosted-command", - "http://localhost:8757/oauth/slack/callback", - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback", - slack_broker_oauth_connect_options, - |options, connection_id, hosted_redirect_uri| { - assert_eq!( - options.connection_id.as_ref().map(ConnectionId::as_str), - Some(connection_id) - ); - assert_eq!(options.redirect_uri, hosted_redirect_uri); - }, - ); - } - - fn assert_broker_oauth_command_flow_splits_redirects( - provider_name: &'static str, - connector_id: &'static str, - connection_id: &'static str, - local_redirect_uri: &'static str, - hosted_redirect_uri: &'static str, - build_options: impl FnOnce(BrokerOAuthExchangeOptionsInput) -> O, - assert_options: impl FnOnce(&O, &'static str, &'static str), - ) { - let start = OAuthBrokerStartResponse { - connector: connector_id.to_string(), - client_id: "client-id".to_string(), - authorization_url: "https://broker.example.test/authorize".to_string(), - redirect_uri: local_redirect_uri.to_string(), - authorization_redirect_uri: Some(hosted_redirect_uri.to_string()), - exchange_redirect_uri: Some(hosted_redirect_uri.to_string()), - session: "broker-session".to_string(), - state: "state-1".to_string(), - expires_in: 600, - }; - - let report = run_broker_oauth_command_flow( - BrokerOAuthCommandStart { - provider_name, - start, - connection_id: Some(ConnectionId::new(connection_id)), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - no_browser: true, - json: true, - }, - |actual_provider_name, - authorization_url, - redirect_uri, - expected_state, - no_browser, - json| { - assert_eq!(actual_provider_name, provider_name); - assert_eq!(authorization_url, "https://broker.example.test/authorize"); - assert_eq!(redirect_uri, local_redirect_uri); - assert_eq!(expected_state, "state-1"); - assert!(no_browser); - assert!(json); - Ok(LocalOAuthAuthorization { - code: "oauth-code".to_string(), - }) - }, - build_options, - |options| { - assert_options(&options, connection_id, hosted_redirect_uri); - Ok(connect_report_for_command_flow(connector_id, connection_id)) - }, - ) - .expect("broker OAuth command flow"); - - assert_eq!(report.connector, connector_id); - assert_eq!(report.connection_id, connection_id); - assert_eq!(report.auth_kind, "oauth"); - } - - fn connect_report_for_command_flow( - connector_id: &'static str, - connection_id: &'static str, - ) -> ConnectReport { - ConnectReport { - ok: true, - command: "connect", - connection_id: connection_id.to_string(), - profile_id: format!("{connector_id}-oauth-default"), - connector: connector_id.to_string(), - display_name: connector_id.to_string(), - account_label: Some("user@example.com".to_string()), - workspace_id: None, - workspace_name: None, - auth_kind: "oauth".to_string(), - } - } - #[test] fn clap_help_is_available_for_commands_and_nested_subcommands() { let cases = vec![ diff --git a/crates/loc-cli/tests/connect.rs b/crates/loc-cli/tests/connect.rs index 051a19f6..ff20ead9 100644 --- a/crates/loc-cli/tests/connect.rs +++ b/crates/loc-cli/tests/connect.rs @@ -232,7 +232,7 @@ fn connect_notion_oauth_stores_oauth_bundle_and_metadata() { fn connect_notion_broker_oauth_stores_refresh_handle_without_client_secret() { let mut store = InMemoryStateStore::new(); let credentials = InMemoryCredentialStore::new(); - let exchange = FakeBrokerOAuthExchange::default(); + let exchange = FakeBrokerOAuthExchange; let report = run_connect_notion_broker_oauth( &mut store, @@ -280,55 +280,11 @@ fn connect_notion_broker_oauth_stores_refresh_handle_without_client_secret() { assert!(!json.contains("secret_ref")); } -#[test] -fn connect_notion_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() { - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_notion_broker_oauth( - &mut store, - &credentials, - BrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("notion-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - .to_string(), - }, - &FakeBrokerOAuthExchange { - expected_redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "notion-hosted"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("notion-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:notion-hosted"); - let secret = credentials - .get("connection:notion-hosted") - .expect("credential saved"); - assert!( - secret - .contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"") - ); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} - #[test] fn connect_google_docs_broker_oauth_stores_refresh_handle_without_secrets() { let mut store = InMemoryStateStore::new(); let credentials = InMemoryCredentialStore::new(); - let exchange = FakeGoogleDocsBrokerOAuthExchange::default(); + let exchange = FakeGoogleDocsBrokerOAuthExchange; let report = run_connect_google_docs_broker_oauth( &mut store, @@ -373,56 +329,11 @@ fn connect_google_docs_broker_oauth_stores_refresh_handle_without_secrets() { assert!(!json.contains("secret_ref")); } -#[test] -fn connect_google_docs_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() { - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_google_docs_broker_oauth( - &mut store, - &credentials, - GoogleDocsBrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("docs-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "google-client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback" - .to_string(), - }, - &FakeGoogleDocsBrokerOAuthExchange { - expected_redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "docs-hosted"); - assert_eq!(report.connector, "google-docs"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("docs-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:docs-hosted"); - let secret = credentials - .get("connection:docs-hosted") - .expect("credential saved"); - assert!( - secret - .contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"") - ); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} - #[test] fn connect_gmail_broker_oauth_stores_refresh_handle_without_secrets() { let mut store = InMemoryStateStore::new(); let credentials = InMemoryCredentialStore::new(); - let exchange = FakeGmailBrokerOAuthExchange::default(); + let exchange = FakeGmailBrokerOAuthExchange; let report = run_connect_gmail_broker_oauth( &mut store, @@ -467,55 +378,11 @@ fn connect_gmail_broker_oauth_stores_refresh_handle_without_secrets() { assert!(!json.contains("secret_ref")); } -#[test] -fn connect_gmail_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() { - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_gmail_broker_oauth( - &mut store, - &credentials, - GmailBrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("gmail-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "google-client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - .to_string(), - }, - &FakeGmailBrokerOAuthExchange { - expected_redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "gmail-hosted"); - assert_eq!(report.connector, "gmail"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("gmail-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:gmail-hosted"); - let secret = credentials - .get("connection:gmail-hosted") - .expect("credential saved"); - assert!( - secret - .contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"") - ); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} - #[test] fn connect_google_calendar_broker_oauth_stores_refresh_handle_without_secrets() { let mut store = InMemoryStateStore::new(); let credentials = InMemoryCredentialStore::new(); - let exchange = FakeGoogleCalendarBrokerOAuthExchange::default(); + let exchange = FakeGoogleCalendarBrokerOAuthExchange; let report = run_connect_google_calendar_broker_oauth( &mut store, @@ -561,57 +428,11 @@ fn connect_google_calendar_broker_oauth_stores_refresh_handle_without_secrets() assert!(!json.contains("secret_ref")); } -#[test] -fn connect_google_calendar_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() -{ - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_google_calendar_broker_oauth( - &mut store, - &credentials, - GoogleCalendarBrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("google-calendar-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "google-client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback" - .to_string(), - }, - &FakeGoogleCalendarBrokerOAuthExchange { - expected_redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "google-calendar-hosted"); - assert_eq!(report.connector, "google-calendar"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("google-calendar-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:google-calendar-hosted"); - let secret = credentials - .get("connection:google-calendar-hosted") - .expect("credential saved"); - assert!( - secret - .contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"") - ); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} - #[test] fn connect_slack_broker_oauth_stores_refresh_handle_without_secrets() { let mut store = InMemoryStateStore::new(); let credentials = InMemoryCredentialStore::new(); - let exchange = FakeSlackBrokerOAuthExchange::default(); + let exchange = FakeSlackBrokerOAuthExchange; let report = run_connect_slack_broker_oauth( &mut store, @@ -655,50 +476,6 @@ fn connect_slack_broker_oauth_stores_refresh_handle_without_secrets() { assert!(!json.contains("secret_ref")); } -#[test] -fn connect_slack_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() { - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_slack_broker_oauth( - &mut store, - &credentials, - SlackBrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("slack-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "slack-client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback" - .to_string(), - }, - &FakeSlackBrokerOAuthExchange { - expected_redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "slack-hosted"); - assert_eq!(report.connector, "slack"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("slack-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:slack-hosted"); - let secret = credentials - .get("connection:slack-hosted") - .expect("credential saved"); - assert!( - secret - .contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"") - ); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} - #[test] fn connect_slack_broker_oauth_rejects_missing_channels_join_scope() { let mut store = InMemoryStateStore::new(); @@ -969,7 +746,7 @@ fn connect_gmail_broker_oauth_credential_store_failure_reports_gmail_guidance() let credentials = FailingCredentialStore { error: CredentialError::Unavailable("keychain locked".to_string()), }; - let exchange = FakeGmailBrokerOAuthExchange::default(); + let exchange = FakeGmailBrokerOAuthExchange; let error = run_connect_gmail_broker_oauth( &mut store, @@ -1179,7 +956,7 @@ fn connect_gmail_broker_oauth_rejects_full_mailbox_scope_from_worker_scope_strin fn connect_google_docs_reuses_default_id_when_previous_default_is_revoked() { let mut store = InMemoryStateStore::new(); let credentials = InMemoryCredentialStore::new(); - let exchange = FakeGoogleDocsBrokerOAuthExchange::default(); + let exchange = FakeGoogleDocsBrokerOAuthExchange; run_connect_google_docs_broker_oauth( &mut store, @@ -1355,17 +1132,7 @@ impl NotionOAuthExchange for FakeOAuthExchange { } #[derive(Clone, Debug)] -struct FakeBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/notion/callback", - } - } -} +struct FakeBrokerOAuthExchange; impl NotionOAuthBrokerExchange for FakeBrokerOAuthExchange { fn exchange_code( @@ -1375,7 +1142,10 @@ impl NotionOAuthBrokerExchange for FakeBrokerOAuthExchange { assert_eq!(request.session, "broker-session"); assert_eq!(request.state, "state-1"); assert_eq!(request.code, "oauth-code"); - assert_eq!(request.redirect_uri, self.expected_redirect_uri); + assert_eq!( + request.redirect_uri, + "http://localhost:8757/oauth/notion/callback" + ); Ok(NotionOAuthToken { access_token: "oauth-access-token".to_string(), token_type: Some("bearer".to_string()), @@ -1394,17 +1164,7 @@ impl NotionOAuthBrokerExchange for FakeBrokerOAuthExchange { } #[derive(Clone, Debug)] -struct FakeGoogleDocsBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeGoogleDocsBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/google-docs/callback", - } - } -} +struct FakeGoogleDocsBrokerOAuthExchange; impl GoogleDocsOAuthBrokerExchange for FakeGoogleDocsBrokerOAuthExchange { fn exchange_code( @@ -1415,7 +1175,10 @@ impl GoogleDocsOAuthBrokerExchange for FakeGoogleDocsBrokerOAuthExchange { assert_eq!(request.session, "broker-session"); assert_eq!(request.state, "state-1"); assert_eq!(request.code, "oauth-code"); - assert_eq!(request.redirect_uri, self.expected_redirect_uri); + assert_eq!( + request.redirect_uri, + "http://localhost:8757/oauth/google-docs/callback" + ); Ok(OAuthBrokerToken { access_token: "oauth-access-token".to_string(), token_type: Some("Bearer".to_string()), @@ -1434,17 +1197,7 @@ impl GoogleDocsOAuthBrokerExchange for FakeGoogleDocsBrokerOAuthExchange { } #[derive(Clone, Debug)] -struct FakeGmailBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeGmailBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/gmail/callback", - } - } -} +struct FakeGmailBrokerOAuthExchange; impl GmailOAuthBrokerExchange for FakeGmailBrokerOAuthExchange { fn exchange_code( @@ -1455,7 +1208,10 @@ impl GmailOAuthBrokerExchange for FakeGmailBrokerOAuthExchange { assert_eq!(request.session, "broker-session"); assert_eq!(request.state, "state-1"); assert_eq!(request.code, "oauth-code"); - assert_eq!(request.redirect_uri, self.expected_redirect_uri); + assert_eq!( + request.redirect_uri, + "http://localhost:8757/oauth/gmail/callback" + ); Ok(OAuthBrokerToken { access_token: "oauth-access-token".to_string(), token_type: Some("Bearer".to_string()), @@ -1475,17 +1231,7 @@ impl GmailOAuthBrokerExchange for FakeGmailBrokerOAuthExchange { } #[derive(Clone, Debug)] -struct FakeGoogleCalendarBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeGoogleCalendarBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/google-calendar/callback", - } - } -} +struct FakeGoogleCalendarBrokerOAuthExchange; impl GoogleCalendarOAuthBrokerExchange for FakeGoogleCalendarBrokerOAuthExchange { fn exchange_code( @@ -1496,7 +1242,10 @@ impl GoogleCalendarOAuthBrokerExchange for FakeGoogleCalendarBrokerOAuthExchange assert_eq!(request.session, "broker-session"); assert_eq!(request.state, "state-1"); assert_eq!(request.code, "oauth-code"); - assert_eq!(request.redirect_uri, self.expected_redirect_uri); + assert_eq!( + request.redirect_uri, + "http://localhost:8757/oauth/google-calendar/callback" + ); Ok(google_calendar_broker_token( GOOGLE_CALENDAR_OAUTH_SCOPES .iter() @@ -1530,17 +1279,7 @@ impl GoogleCalendarOAuthBrokerExchange for ScopedFakeGoogleCalendarBrokerOAuthEx } #[derive(Clone, Debug)] -struct FakeSlackBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeSlackBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/slack/callback", - } - } -} +struct FakeSlackBrokerOAuthExchange; impl SlackOAuthBrokerExchange for FakeSlackBrokerOAuthExchange { fn exchange_code( @@ -1551,7 +1290,10 @@ impl SlackOAuthBrokerExchange for FakeSlackBrokerOAuthExchange { assert_eq!(request.session, "broker-session"); assert_eq!(request.state, "state-1"); assert_eq!(request.code, "oauth-code"); - assert_eq!(request.redirect_uri, self.expected_redirect_uri); + assert_eq!( + request.redirect_uri, + "http://localhost:8757/oauth/slack/callback" + ); Ok(slack_broker_token(slack_scopes_with_join())) } } diff --git a/crates/locality-connector/src/oauth_broker.rs b/crates/locality-connector/src/oauth_broker.rs index 2cb7d08e..1616ad88 100644 --- a/crates/locality-connector/src/oauth_broker.rs +++ b/crates/locality-connector/src/oauth_broker.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct OAuthBrokerStart { pub connector: String, pub redirect_uri: String, - pub hosted_callback_handoff: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -13,33 +12,11 @@ pub struct OAuthBrokerStartResponse { pub client_id: String, pub authorization_url: String, pub redirect_uri: String, - #[serde(default)] - pub authorization_redirect_uri: Option, - #[serde(default)] - pub exchange_redirect_uri: Option, pub session: String, pub state: String, pub expires_in: u64, } -impl OAuthBrokerStartResponse { - pub fn local_redirect_uri(&self) -> &str { - &self.redirect_uri - } - - pub fn authorization_redirect_uri(&self) -> &str { - self.authorization_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } - - pub fn exchange_redirect_uri(&self) -> &str { - self.exchange_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct OAuthBrokerCodeExchange { pub connector: String, @@ -92,14 +69,13 @@ where #[cfg(test)] mod tests { - use super::{OAuthBrokerStart, OAuthBrokerStartResponse, OAuthBrokerToken}; + use super::{OAuthBrokerStart, OAuthBrokerToken}; #[test] fn start_request_carries_connector_and_redirect_uri() { let request = OAuthBrokerStart { connector: "google-docs".to_string(), redirect_uri: "http://localhost:8757/oauth/google-docs/callback".to_string(), - hosted_callback_handoff: true, }; let json = serde_json::to_value(&request).expect("serialize request"); @@ -109,72 +85,6 @@ mod tests { json["redirect_uri"], "http://localhost:8757/oauth/google-docs/callback" ); - assert_eq!(json["hosted_callback_handoff"], true); - } - - #[test] - fn start_response_defaults_hosted_redirects_to_local_redirect() { - let payload = serde_json::json!({ - "connector": "google-docs", - "client_id": "google-client-id", - "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=google-client-id", - "redirect_uri": "http://localhost:8757/oauth/google-docs/callback", - "session": "session-1", - "state": "state-1", - "expires_in": 600 - }); - - let response: OAuthBrokerStartResponse = - serde_json::from_value(payload).expect("decode start response"); - - assert_eq!( - response.local_redirect_uri(), - "http://localhost:8757/oauth/google-docs/callback" - ); - assert_eq!( - response.authorization_redirect_uri(), - "http://localhost:8757/oauth/google-docs/callback" - ); - assert_eq!( - response.exchange_redirect_uri(), - "http://localhost:8757/oauth/google-docs/callback" - ); - } - - #[test] - fn start_response_uses_hosted_redirects_when_present() { - let response = OAuthBrokerStartResponse { - connector: "gmail".to_string(), - client_id: "google-client-id".to_string(), - authorization_url: - "https://accounts.google.com/o/oauth2/v2/auth?client_id=google-client-id" - .to_string(), - redirect_uri: "http://localhost:8757/oauth/gmail/callback".to_string(), - authorization_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - .to_string(), - ), - exchange_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - .to_string(), - ), - session: "session-1".to_string(), - state: "state-1".to_string(), - expires_in: 600, - }; - - assert_eq!( - response.local_redirect_uri(), - "http://localhost:8757/oauth/gmail/callback" - ); - assert_eq!( - response.authorization_redirect_uri(), - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - ); - assert_eq!( - response.exchange_redirect_uri(), - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - ); } #[test] diff --git a/crates/locality-google-calendar/src/oauth.rs b/crates/locality-google-calendar/src/oauth.rs index 202a78d0..496d55c0 100644 --- a/crates/locality-google-calendar/src/oauth.rs +++ b/crates/locality-google-calendar/src/oauth.rs @@ -538,7 +538,6 @@ mod tests { .start(&OAuthBrokerStart { connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), redirect_uri: DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI.to_string(), - hosted_callback_handoff: true, }) .expect("start response"); @@ -683,7 +682,6 @@ mod tests { .start(&OAuthBrokerStart { connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), redirect_uri: DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI.to_string(), - hosted_callback_handoff: true, }) .expect_err("status should fail"); @@ -707,7 +705,6 @@ mod tests { .start(&OAuthBrokerStart { connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), redirect_uri: DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI.to_string(), - hosted_callback_handoff: true, }) .expect_err("decode should fail"); @@ -731,7 +728,6 @@ mod tests { .start(&OAuthBrokerStart { connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), redirect_uri: DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI.to_string(), - hosted_callback_handoff: true, }) .expect_err("request should fail"); diff --git a/crates/locality-notion/src/oauth.rs b/crates/locality-notion/src/oauth.rs index 2c66961a..d20284a4 100644 --- a/crates/locality-notion/src/oauth.rs +++ b/crates/locality-notion/src/oauth.rs @@ -69,7 +69,6 @@ impl fmt::Debug for NotionOAuthRefresh { #[derive(Clone, Debug, PartialEq, Eq)] pub struct NotionOAuthBrokerStart { pub redirect_uri: String, - pub hosted_callback_handoff: bool, } #[derive(Clone, PartialEq, Eq, Deserialize)] @@ -78,10 +77,6 @@ pub struct NotionOAuthBrokerStartResponse { pub client_id: String, pub authorization_url: String, pub redirect_uri: String, - #[serde(default)] - pub authorization_redirect_uri: Option, - #[serde(default)] - pub exchange_redirect_uri: Option, pub session: String, pub state: String, pub expires_in: u64, @@ -95,11 +90,6 @@ impl fmt::Debug for NotionOAuthBrokerStartResponse { .field("client_id", &self.client_id) .field("authorization_url", &REDACTED) .field("redirect_uri", &self.redirect_uri) - .field( - "authorization_redirect_uri", - &self.authorization_redirect_uri, - ) - .field("exchange_redirect_uri", &self.exchange_redirect_uri) .field("session", &REDACTED) .field("state", &REDACTED) .field("expires_in", &self.expires_in) @@ -112,26 +102,10 @@ impl NotionOAuthBrokerStartResponse { normalize_notion_authorization_url( &self.authorization_url, &self.client_id, - self.authorization_redirect_uri(), + &self.redirect_uri, &self.state, ) } - - pub fn local_redirect_uri(&self) -> &str { - &self.redirect_uri - } - - pub fn authorization_redirect_uri(&self) -> &str { - self.authorization_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } - - pub fn exchange_redirect_uri(&self) -> &str { - self.exchange_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } } #[derive(Clone, PartialEq, Eq)] @@ -414,7 +388,6 @@ impl HttpNotionOAuthBrokerClient { "/v1/oauth/notion/start", json!({ "redirect_uri": request.redirect_uri, - "hosted_callback_handoff": request.hosted_callback_handoff, }), ) } @@ -499,8 +472,6 @@ mod tests { "https://api.notion.com/v1/oauth/authorize?client_id=client-id&prompt=select" .to_string(), redirect_uri: "http://localhost:8757/oauth/notion/callback".to_string(), - authorization_redirect_uri: None, - exchange_redirect_uri: None, session: "session-1".to_string(), state: "state-1".to_string(), expires_in: 300, @@ -523,43 +494,6 @@ mod tests { assert_eq!(query_value(&url, "state").as_deref(), Some("state-1")); } - #[test] - fn broker_start_response_uses_hosted_authorization_redirect_when_present() { - let start = NotionOAuthBrokerStartResponse { - connector: "notion".to_string(), - client_id: "client-id".to_string(), - authorization_url: "https://api.notion.com/v1/oauth/authorize?client_id=wrong" - .to_string(), - redirect_uri: "http://localhost:8757/oauth/notion/callback".to_string(), - authorization_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - .to_string(), - ), - exchange_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - .to_string(), - ), - session: "session-1".to_string(), - state: "state-1".to_string(), - expires_in: 300, - }; - - let url = Url::parse(&start.normalized_authorization_url()).expect("normalized URL"); - - assert_eq!( - query_value(&url, "redirect_uri").as_deref(), - Some("https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback") - ); - assert_eq!( - start.local_redirect_uri(), - "http://localhost:8757/oauth/notion/callback" - ); - assert_eq!( - start.exchange_redirect_uri(), - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - ); - } - #[test] fn normalize_notion_authorization_url_replaces_managed_parameters() { let normalized = normalize_notion_authorization_url( @@ -701,8 +635,6 @@ mod tests { authorization_url: "https://api.notion.com/v1/oauth/authorize?state=secret-state" .to_string(), redirect_uri: "http://localhost/callback".to_string(), - authorization_redirect_uri: None, - exchange_redirect_uri: None, session: "secret-session".to_string(), state: "secret-state".to_string(), expires_in: 300, @@ -757,7 +689,7 @@ mod tests { ); assert_eq!( format!("{broker_start:?}"), - "NotionOAuthBrokerStartResponse { connector: \"notion\", client_id: \"client-id\", authorization_url: \"\", redirect_uri: \"http://localhost/callback\", authorization_redirect_uri: None, exchange_redirect_uri: None, session: \"\", state: \"\", expires_in: 300 }" + "NotionOAuthBrokerStartResponse { connector: \"notion\", client_id: \"client-id\", authorization_url: \"\", redirect_uri: \"http://localhost/callback\", session: \"\", state: \"\", expires_in: 300 }" ); assert_eq!( format!("{broker_exchange:?}"), diff --git a/crates/locality-slack/src/oauth.rs b/crates/locality-slack/src/oauth.rs index b55cd785..a02c8413 100644 --- a/crates/locality-slack/src/oauth.rs +++ b/crates/locality-slack/src/oauth.rs @@ -455,7 +455,6 @@ mod tests { .start(&OAuthBrokerStart { connector: "slack".to_string(), redirect_uri: DEFAULT_SLACK_OAUTH_REDIRECT_URI.to_string(), - hosted_callback_handoff: true, }) .expect_err("non-success broker response"); diff --git a/docs-site/cli-reference.mdx b/docs-site/cli-reference.mdx index 3c3c171d..85c188f7 100644 --- a/docs-site/cli-reference.mdx +++ b/docs-site/cli-reference.mdx @@ -30,7 +30,7 @@ loc push --json -y | Task | Commands | Use when | | --- | --- | --- | -| Connect accounts | `connect`, `connections`, `profiles`, `connection show`, `disconnect` | You need to authenticate Notion, Google Docs, Google Calendar, Gmail, or Slack and inspect saved connection metadata. | +| Connect accounts | `connect`, `connections`, `profiles`, `connection show`, `disconnect` | You need to authenticate Notion or Google Docs and inspect saved connection metadata. | | Register local files | `mount notion`, `mount google-docs`, `file-provider register`, `pull` | You want a remote workspace to appear as local Markdown. | | Navigate mounted content | `search`, `info`, `status`, `inspect` | You need to find content or understand what Locality knows about a path. | | Review sync state | `status`, `diff`, `pull`, `push`, `restore`, `log`, `undo` | You are deciding whether to update local files or write local edits back to the source. | @@ -96,8 +96,6 @@ Use this before mounting Notion. The default path uses Locality's OAuth broker, opens a browser, and stores credentials in the local credential store. Locality can mount only pages and workspaces shared with the Notion integration. -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_NOTION_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/notion/callback`; override it with `--redirect-uri ` or `LOCALITY_NOTION_OAUTH_REDIRECT_URI`. In production the broker may use its own HTTPS provider callback registered on the Notion public integration, then hand the browser back to the local callback. The command still stores the resulting OAuth credential locally. - Key arguments: | Argument | Meaning | @@ -128,8 +126,6 @@ loc connect google-docs [--name ] [--no-browser] [--broker-url ] [--red Use this before mounting a Google Drive workspace folder that contains Google Docs. Non-Google-Docs Drive files are ignored by the Google Docs connector. -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GOOGLE_DOCS_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/google-docs/callback`; override it with `--redirect-uri ` or `LOCALITY_GOOGLE_DOCS_OAUTH_REDIRECT_URI`. In production the shared Google OAuth client registers the broker's hosted HTTPS callback URIs for Google Docs, Google Calendar, and Gmail, and the broker hands the browser back to the local callback. Broker `LOCALITY_*_REDIRECT_URIS` allowlists keep the local loopback callbacks for that browser handoff. The command still stores the resulting OAuth credential locally. - Key arguments: | Argument | Meaning | @@ -145,87 +141,6 @@ Example: loc connect google-docs --name google-docs-default ``` -### `loc connect google-calendar` - -Connect Google Calendar through the Locality OAuth broker. - -```bash -loc connect google-calendar [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] -``` - -Use this before mounting primary-calendar events. - -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GOOGLE_CALENDAR_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/google-calendar/callback`; override it with `--redirect-uri ` or `LOCALITY_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI`. In production the shared Google OAuth client registers the broker's hosted HTTPS callback URIs for Google Docs, Google Calendar, and Gmail, and the broker hands the browser back to the local callback. Broker `LOCALITY_*_REDIRECT_URIS` allowlists keep the local loopback callbacks for that browser handoff. The command still stores the resulting OAuth credential locally. - -Key arguments: - -| Argument | Meaning | -| --- | --- | -| `--name ` | Connection id to save. Runtime default is `google-calendar-default` when no active Google Calendar connection exists; pass an explicit name for reproducible setup. | -| `--no-browser` | Print the OAuth URL instead of opening it. | -| `--broker-url ` | Override the OAuth broker base URL. | -| `--redirect-uri ` | Override the local callback URI. | - -Example: - -```bash -loc connect google-calendar --name google-calendar-default -``` - -### `loc connect gmail` - -Connect Gmail through the Locality OAuth broker. - -```bash -loc connect gmail [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] -``` - -Use this before mounting Gmail inbox, sent, and draft folders. - -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GMAIL_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/gmail/callback`; override it with `--redirect-uri ` or `LOCALITY_GMAIL_OAUTH_REDIRECT_URI`. In production the shared Google OAuth client registers the broker's hosted HTTPS callback URIs for Google Docs, Google Calendar, and Gmail, and the broker hands the browser back to the local callback. Broker `LOCALITY_*_REDIRECT_URIS` allowlists keep the local loopback callbacks for that browser handoff. The command still stores the resulting OAuth credential locally. - -Key arguments: - -| Argument | Meaning | -| --- | --- | -| `--name ` | Connection id to save. Runtime default is `gmail-default` when no active Gmail connection exists; pass an explicit name for reproducible setup. | -| `--no-browser` | Print the OAuth URL instead of opening it. | -| `--broker-url ` | Override the OAuth broker base URL. | -| `--redirect-uri ` | Override the local callback URI. | - -Example: - -```bash -loc connect gmail --name gmail-default -``` - -### `loc connect slack` - -Connect Slack through the Locality OAuth broker. - -```bash -loc connect slack [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] -``` - -Use this before mounting Slack conversation history. - -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_SLACK_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/slack/callback`; override it with `--redirect-uri ` or `LOCALITY_SLACK_OAUTH_REDIRECT_URI`. In production the Slack provider app registers the broker's hosted HTTPS callback, and the broker hands the browser back to the local callback. The command still stores the resulting OAuth credential locally. - -Key arguments: - -| Argument | Meaning | -| --- | --- | -| `--name ` | Connection id to save. Runtime default is `slack-default` when no active Slack connection exists; pass an explicit name for reproducible setup. | -| `--no-browser` | Print the OAuth URL instead of opening it. | -| `--broker-url ` | Override the OAuth broker base URL. | -| `--redirect-uri ` | Override the local callback URI. | - -Example: - -```bash -loc connect slack --name slack-default -``` - ### Inspect and remove connections ```bash diff --git a/docs/cli.md b/docs/cli.md index 44c35ed9..4e53f155 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,9 +6,7 @@ The `loc` command is the single supported control surface for users and coding a - `loc connect notion [--name ] [--token-stdin|--no-browser|--direct-oauth] [--broker-url ] [--redirect-uri ] [--json]` - `loc connect google-docs [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] [--json]` -- `loc connect google-calendar [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] [--json]` - `loc connect gmail [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] [--json]` -- `loc connect slack [--name ] [--no-browser] [--broker-url ] [--redirect-uri ] [--json]` - `loc connect granola --api-key-stdin [--name ] [--json]` - `loc connections [--json]` - `loc profiles [--json]` @@ -62,7 +60,7 @@ Remaining categories to assign before `loc push` applies remote mutations: `loc connect notion [--name ]` creates a local provider connection. OAuth is preferred. By default the command uses the Locality OAuth broker because Notion's REST OAuth token endpoint requires a confidential client secret. The CLI asks the broker for a Notion authorization URL, opens the browser, listens for the localhost callback, sends only the returned authorization code plus signed broker session back to the broker, then stores the returned access token and refresh handle in the credential store. SQLite stores only connection metadata and a `secret_ref`. -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_NOTION_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/notion/callback`; override it with `--redirect-uri ` or `LOCALITY_NOTION_OAUTH_REDIRECT_URI`. In production the broker may use its own HTTPS provider callback registered on the Notion public integration, then hand the browser back to the local callback. The command still stores the resulting OAuth credential locally. +The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_NOTION_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default callback is `http://localhost:8757/oauth/notion/callback`; override it with `--redirect-uri ` or `LOCALITY_NOTION_OAUTH_REDIRECT_URI`. The redirect URI must be registered on the Notion public integration. `--direct-oauth` keeps the developer BYO OAuth path. In that mode the command reads `LOCALITY_NOTION_OAUTH_CLIENT_ID` and `LOCALITY_NOTION_OAUTH_CLIENT_SECRET` (or `NOTION_OAUTH_CLIENT_ID` / `NOTION_OAUTH_CLIENT_SECRET`) and exchanges directly with Notion. Direct OAuth stores the user-supplied client secret in the credential store so refresh can work, and should not be the default product path. @@ -78,20 +76,16 @@ Connections now point at connector profiles. A profile is Locality's local auth- The default connection ID is `notion-default` when no Notion connection exists. If a Notion connection already exists, pass `--name ` to avoid overwriting by accident. -`loc connect google-docs [--name ]` uses the same Locality OAuth broker flow for Google Docs. The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GOOGLE_DOCS_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/google-docs/callback`; override it with `--redirect-uri ` or `LOCALITY_GOOGLE_DOCS_OAUTH_REDIRECT_URI`. In production the shared Google OAuth client registers the broker's hosted HTTPS callback URIs for Google Docs, Google Calendar, and Gmail, and the broker hands the browser back to the local callback. Broker `LOCALITY_*_REDIRECT_URIS` allowlists keep the local loopback callbacks for that browser handoff. The command still stores the resulting OAuth credential locally. The broker must be configured with `LOCALITY_GOOGLE_CLIENT_ID` and `LOCALITY_GOOGLE_CLIENT_SECRET`. +`loc connect google-docs [--name ]` uses the same Locality OAuth broker flow for Google Docs. The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GOOGLE_DOCS_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default callback is `http://localhost:8757/oauth/google-docs/callback`; override it with `--redirect-uri ` or `LOCALITY_GOOGLE_DOCS_OAUTH_REDIRECT_URI`. The shared Google OAuth client must allow the Google Docs and Gmail callbacks, and the broker must be configured with `LOCALITY_GOOGLE_CLIENT_ID` and `LOCALITY_GOOGLE_CLIENT_SECRET`. `loc mount google-docs --workspace-folder ` registers a Google Docs mount rooted at a Locality-owned Google Drive folder. Folder URLs and ids are verified through Drive; names reuse an accessible matching folder or create a new Locality workspace folder. The resolved Drive folder id is stored as the mount `remote_root_id`. Google Docs files project as page directories containing `page.md`, and Drive folders project as local directories. Google Docs mounts use Google Docs document access plus Drive `drive.file` and Drive metadata access. Pull enumerates Google Docs and Drive folders under the configured workspace folder, including Docs manually added inside that workspace folder. Non-Google-Docs Drive files are ignored by this connector in V1. -`loc connect google-calendar [--name ]` uses the Locality OAuth broker flow for Google Calendar. The default connection ID is `google-calendar-default` and the default OAuth profile is `google-calendar-oauth-default`. The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GOOGLE_CALENDAR_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/google-calendar/callback`; override it with `--redirect-uri ` or `LOCALITY_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI`. In production the shared Google OAuth client registers the broker's hosted HTTPS callback URIs for Google Docs, Google Calendar, and Gmail, and the broker hands the browser back to the local callback. Broker `LOCALITY_*_REDIRECT_URIS` allowlists keep the local loopback callbacks for that browser handoff. The command still stores the resulting OAuth credential locally. - -`loc connect gmail [--name ]` uses the Locality OAuth broker flow for Gmail. The default connection ID is `gmail-default` and the default OAuth profile is `gmail-oauth-default`. The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GMAIL_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/gmail/callback`; override it with `--redirect-uri ` or `LOCALITY_GMAIL_OAUTH_REDIRECT_URI`. In production the shared Google OAuth client registers the broker's hosted HTTPS callback URIs for Google Docs, Google Calendar, and Gmail, and the broker hands the browser back to the local callback. Broker `LOCALITY_*_REDIRECT_URIS` allowlists keep the local loopback callbacks for that browser handoff. The command still stores the resulting OAuth credential locally. +`loc connect gmail [--name ]` uses the Locality OAuth broker flow for Gmail. The default connection ID is `gmail-default` and the default OAuth profile is `gmail-oauth-default`. The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_GMAIL_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default callback is `http://localhost:8757/oauth/gmail/callback`; override it with `--redirect-uri ` or `LOCALITY_GMAIL_OAUTH_REDIRECT_URI`. The same shared Google OAuth client is used for Gmail and Google Docs, so the client and broker allowlists should include both Gmail callbacks and both Google Docs callbacks. Gmail OAuth uses `openid`, `email`, `profile`, `https://www.googleapis.com/auth/gmail.readonly`, and `https://www.googleapis.com/auth/gmail.compose`. No broader Gmail account scope is required. -`loc connect slack [--name ]` uses the Locality OAuth broker flow for Slack. The default connection ID is `slack-default` and the default OAuth profile is `slack-oauth-default`. The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_SLACK_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/slack/callback`; override it with `--redirect-uri ` or `LOCALITY_SLACK_OAUTH_REDIRECT_URI`. In production the Slack provider app registers the broker's hosted HTTPS callback, and the broker hands the browser back to the local callback. The command still stores the resulting OAuth credential locally. - `loc mount gmail ` registers a Gmail mount. If `--connection` is omitted, the daemon resolves the mount through the only active Gmail connection at runtime; with multiple active Gmail connections, pass `--connection `. When `--mount-id` is omitted, Locality uses `gmail-main` when available. Gmail mounts project `inbox/`, `sent/`, and `draft/` folders. `inbox/` and `sent/` are read-only; create a Markdown file directly under `draft/` to create an unsent Gmail UI draft on push. Gmail mount options: diff --git a/docs/gmail-connector.md b/docs/gmail-connector.md index 07bc7932..0ed385a3 100644 --- a/docs/gmail-connector.md +++ b/docs/gmail-connector.md @@ -41,20 +41,9 @@ The broker allowlist also supports: http://127.0.0.1:8757/oauth/gmail/callback ``` -Gmail, Google Calendar, and Google Docs use the same broker-configured Google -OAuth client: `LOCALITY_GOOGLE_CLIENT_ID` and -`LOCALITY_GOOGLE_CLIENT_SECRET`. In production, the Google OAuth client must -register the hosted HTTPS callbacks used by the broker: - -```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -``` - -The localhost callbacks remain broker loopback allowlists for the local handoff. -Developer-owned or direct OAuth setups may still register and use local callbacks -where applicable. +Gmail and Google Docs use the same broker-configured Google OAuth client: +`LOCALITY_GOOGLE_CLIENT_ID` and `LOCALITY_GOOGLE_CLIENT_SECRET`. Register both +the Gmail and Google Docs localhost callbacks on that Google OAuth client. The default connection ID is `gmail-default`, the default mount ID is `gmail-main`, and the default OAuth profile is `gmail-oauth-default`. diff --git a/docs/google-calendar-connector.md b/docs/google-calendar-connector.md index e59e2a3f..812be87e 100644 --- a/docs/google-calendar-connector.md +++ b/docs/google-calendar-connector.md @@ -44,18 +44,8 @@ http://127.0.0.1:8757/oauth/google-calendar/callback Google Calendar, Gmail, and Google Docs share the same broker-configured Google OAuth client: `LOCALITY_GOOGLE_CLIENT_ID` and -`LOCALITY_GOOGLE_CLIENT_SECRET`. In production, the Google OAuth client must -register the hosted HTTPS callbacks used by the broker: - -```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -``` - -The localhost callbacks remain broker loopback allowlists for the local handoff. -Developer-owned or direct OAuth setups may still register and use local callbacks -where applicable. +`LOCALITY_GOOGLE_CLIENT_SECRET`. Register each connector's localhost callback +on that Google OAuth client. The broker requests these scopes: diff --git a/docs/google-docs-connector.md b/docs/google-docs-connector.md index ed89dbcb..4308ac87 100644 --- a/docs/google-docs-connector.md +++ b/docs/google-docs-connector.md @@ -26,19 +26,8 @@ The Google Docs OAuth flow uses the Locality OAuth broker and requests: - `https://www.googleapis.com/auth/drive.metadata` The broker uses the shared `LOCALITY_GOOGLE_CLIENT_ID` and -`LOCALITY_GOOGLE_CLIENT_SECRET` pair for Google Docs, Google Calendar, and -Gmail. In production, the Google OAuth client must register the hosted HTTPS -callbacks used by the broker: - -```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -``` - -The localhost callbacks remain broker loopback allowlists for the local handoff. -Developer-owned or direct OAuth setups may still register and use local callbacks -where applicable. +`LOCALITY_GOOGLE_CLIENT_SECRET` pair for both Google Docs and Gmail. The Google +OAuth client must allow the Google Docs and Gmail localhost callbacks. `documents` is used for Google Docs body read/write. `drive.file` keeps write access limited to app-created or explicitly granted files. `drive.metadata` diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 7337f639..3d43f424 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -49,12 +49,8 @@ loc connect notion --name work The default product path uses the Locality OAuth broker so the local CLI never ships or stores the Notion OAuth client secret. The broker URL can be overridden with `--broker-url `, `LOCALITY_NOTION_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. -The CLI still listens on the local callback, defaulting to -`http://localhost:8757/oauth/notion/callback`. In production the Notion public -integration should register the broker's HTTPS hosted callback; the broker -receives that callback and redirects the browser back to the local listener. For -BYO/direct OAuth development, a developer-owned Notion app may still register -and use the local callback. +The Notion public integration must register the callback URI, which defaults to +`http://localhost:8757/oauth/notion/callback`. For development with a BYO Notion OAuth app, use direct OAuth: diff --git a/docs/superpowers/plans/2026-07-29-all-oauth-hosted-handoff.md b/docs/superpowers/plans/2026-07-29-all-oauth-hosted-handoff.md deleted file mode 100644 index 31422834..00000000 --- a/docs/superpowers/plans/2026-07-29-all-oauth-hosted-handoff.md +++ /dev/null @@ -1,1508 +0,0 @@ -# All OAuth Hosted Handoff Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extend the hosted OAuth callback handoff from Notion to Google Docs, Google Calendar, Gmail, and Slack while keeping local credential storage. - -**Architecture:** The Worker gets one generic hosted-handoff path per connector: `/start` signs local handoff state, provider callbacks verify that state, then redirect back to the connector's local loopback listener. Rust shared broker responses gain optional hosted redirect fields, and CLI/desktop consumers listen locally but exchange with `exchange_redirect_uri`. - -**Tech Stack:** Cloudflare Workers/Hono/TypeScript/Vitest, Rust crates `locality-connector`, `loc-cli`, connector crates, Tauri desktop command code, existing Locality OAuth broker abstractions. - ---- - -## File Structure - -- `apps/oauth-service/src/types.ts`: add hosted callback env vars for Google Docs, Google Calendar, Gmail, and Slack. -- `apps/oauth-service/src/security/redirects.ts`: replace Notion-only hosted validation with connector-aware hosted callback and exchange redirect helpers. -- `apps/oauth-service/src/app.ts`: replace Notion-only start/callback helpers with connector-aware helpers used by all five OAuth connectors. -- `apps/oauth-service/test/app.test.ts`: add parameterized hosted start/callback/exchange tests for every connector. -- `crates/locality-connector/src/oauth_broker.rs`: add optional hosted redirect fields and helper accessors to the shared broker start response. -- `crates/loc-cli/src/commands.rs`: use local listener redirect and exchange redirect helpers for Google Docs, Google Calendar, Gmail, and Slack. -- `apps/desktop/src-tauri/src/main.rs`: use the same local listener / exchange redirect split for desktop Google Docs, Google Calendar, Gmail, and Slack. -- `crates/loc-cli/tests/connect.rs`: make fake non-Notion broker exchanges configurable and add hosted exchange redirect storage tests. -- `apps/oauth-service/wrangler.toml`: configure hosted callback URIs for every OAuth connector and keep local loopback allowlists separate. -- `apps/oauth-service/README.md`, `apps/oauth-service/docs/deployment.md`, `apps/oauth-service/docs/security.md`, `docs/cli.md`, `docs-site/cli-reference.mdx`, `docs/notion-connector.md`: update docs from Notion-only to all OAuth connectors. - ---- - -### Task 1: Generalize Worker Hosted Handoff To Every Connector - -**Files:** -- Modify: `apps/oauth-service/src/types.ts` -- Modify: `apps/oauth-service/src/security/redirects.ts` -- Modify: `apps/oauth-service/src/app.ts` -- Modify: `apps/oauth-service/test/app.test.ts` - -- [ ] **Step 1: Add failing hosted-flow tests for every connector** - -In `apps/oauth-service/test/app.test.ts`, extend `StartResponse`: - -```ts -interface StartResponse { - connector: string; - client_id: string; - authorization_url: string; - redirect_uri: string; - authorization_redirect_uri?: string; - exchange_redirect_uri?: string; - session: string; - state: string; -} -``` - -Add a connector table near the existing `hostedNotionCallbackUri` constant: - -```ts -const brokerOrigin = "https://afs-oauth-broker.saurabh-b07.workers.dev"; - -const hostedConnectorCases = [ - { - connector: "notion", - startPath: "/v1/oauth/notion/start", - callbackPath: "/v1/oauth/notion/callback", - exchangePath: "/v1/oauth/notion/exchange", - localRedirectUri: "http://localhost:8757/oauth/notion/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/notion/callback`, - env: { LOCALITY_NOTION_HOSTED_CALLBACK_URI: `${brokerOrigin}/v1/oauth/notion/callback` }, - tokenResponse: { - access_token: "notion-access-token", - refresh_token: "notion-refresh-token", - token_type: "bearer", - expires_in: 3600, - workspace_id: "workspace-id" - }, - upstreamBody(input: RequestInfo | URL, init?: RequestInit) { - return JSON.parse((init as RequestInit).body as string) as Record; - } - }, - { - connector: "google-docs", - startPath: "/v1/oauth/google-docs/start", - callbackPath: "/v1/oauth/google-docs/callback", - exchangePath: "/v1/oauth/google-docs/exchange", - localRedirectUri: "http://localhost:8757/oauth/google-docs/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/google-docs/callback`, - env: { LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI: `${brokerOrigin}/v1/oauth/google-docs/callback` }, - tokenResponse: { - access_token: "google-docs-access-token", - refresh_token: "google-docs-refresh-token", - token_type: "Bearer", - expires_in: 3600, - scope: - "openid email profile https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/drive.metadata" - }, - upstreamBody(input: RequestInfo | URL, init?: RequestInit) { - return Object.fromEntries(new URLSearchParams((init as RequestInit).body as string)); - } - }, - { - connector: "google-calendar", - startPath: "/v1/oauth/google-calendar/start", - callbackPath: "/v1/oauth/google-calendar/callback", - exchangePath: "/v1/oauth/google-calendar/exchange", - localRedirectUri: "http://localhost:8757/oauth/google-calendar/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/google-calendar/callback`, - env: { LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI: `${brokerOrigin}/v1/oauth/google-calendar/callback` }, - tokenResponse: { - access_token: "calendar-access-token", - refresh_token: "calendar-refresh-token", - token_type: "Bearer", - expires_in: 3600, - scope: "openid email profile https://www.googleapis.com/auth/calendar.events", - id_token: "calendar-id-token" - }, - upstreamBody(input: RequestInfo | URL, init?: RequestInit) { - return Object.fromEntries(new URLSearchParams((init as RequestInit).body as string)); - } - }, - { - connector: "gmail", - startPath: "/v1/oauth/gmail/start", - callbackPath: "/v1/oauth/gmail/callback", - exchangePath: "/v1/oauth/gmail/exchange", - localRedirectUri: "http://localhost:8757/oauth/gmail/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/gmail/callback`, - env: { LOCALITY_GMAIL_HOSTED_CALLBACK_URI: `${brokerOrigin}/v1/oauth/gmail/callback` }, - tokenResponse: { - access_token: "gmail-access-token", - refresh_token: "gmail-refresh-token", - token_type: "Bearer", - expires_in: 3600, - scope: - "openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose", - id_token: "gmail-id-token" - }, - upstreamBody(input: RequestInfo | URL, init?: RequestInit) { - return Object.fromEntries(new URLSearchParams((init as RequestInit).body as string)); - } - }, - { - connector: "slack", - startPath: "/v1/oauth/slack/start", - callbackPath: "/v1/oauth/slack/callback", - exchangePath: "/v1/oauth/slack/exchange", - localRedirectUri: "http://localhost:8757/oauth/slack/callback", - hostedCallbackUri: `${brokerOrigin}/v1/oauth/slack/callback`, - env: { LOCALITY_SLACK_HOSTED_CALLBACK_URI: `${brokerOrigin}/v1/oauth/slack/callback` }, - tokenResponse: { - ok: true, - access_token: "xoxb-access-token", - refresh_token: "slack-refresh-token", - token_type: "bot", - expires_in: 43200, - scope: - "channels:read,channels:history,groups:read,groups:history,im:read,im:history,mpim:read,mpim:history,users:read,team:read,files:read,channels:join", - bot_user_id: "U999", - team: { id: "T123", name: "Locality" } - }, - upstreamBody(input: RequestInfo | URL, init?: RequestInit) { - return Object.fromEntries(new URLSearchParams((init as RequestInit).body as string)); - } - } -] as const; -``` - -Add a helper after the existing session helpers: - -```ts -async function startHostedSession(caseDef: (typeof hostedConnectorCases)[number]) { - const hostedEnv = { ...env, ...caseDef.env } as BrokerEnv; - const response = await app.request(caseDef.startPath, { method: "POST" }, hostedEnv); - expect(response.status).toBe(200); - return { - hostedEnv, - start: (await response.json()) as StartResponse - }; -} -``` - -Add this parameterized test block inside `describe("auth broker", ...)`: - -```ts -describe.each(hostedConnectorCases)("$connector hosted handoff", (caseDef) => { - it("starts OAuth with hosted provider callback and local loopback handoff", async () => { - const { start } = await startHostedSession(caseDef); - - expect(start.connector).toBe(caseDef.connector); - expect(start.redirect_uri).toBe(caseDef.localRedirectUri); - expect(start.authorization_redirect_uri).toBe(caseDef.hostedCallbackUri); - expect(start.exchange_redirect_uri).toBe(caseDef.hostedCallbackUri); - const authorizationUrl = new URL(start.authorization_url); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(caseDef.hostedCallbackUri); - expect(authorizationUrl.searchParams.get("state")).toBe(start.state); - expect(start.session).toBeTruthy(); - expect(start.state).toBeTruthy(); - expect(start.session).not.toBe(start.state); - }); - - it("redirects a valid hosted callback to the local loopback listener", async () => { - const { hostedEnv, start } = await startHostedSession(caseDef); - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(303); - expect(callback.headers.get("cache-control")).toBe("no-store"); - expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); - const location = new URL(callback.headers.get("location") ?? ""); - expect(`${location.origin}${location.pathname}`).toBe(caseDef.localRedirectUri); - expect(location.searchParams.get("code")).toBe("authorization-code"); - expect(location.searchParams.get("state")).toBe(start.state); - }); - - it("redirects provider denial to the local loopback listener", async () => { - const { hostedEnv, start } = await startHostedSession(caseDef); - const callback = await app.request( - `${caseDef.callbackPath}?error=access_denied&error_description=User%20cancelled&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(303); - const location = new URL(callback.headers.get("location") ?? ""); - expect(`${location.origin}${location.pathname}`).toBe(caseDef.localRedirectUri); - expect(location.searchParams.get("error")).toBe("access_denied"); - expect(location.searchParams.get("error_description")).toBe("User cancelled"); - expect(location.searchParams.get("state")).toBe(start.state); - expect(location.searchParams.get("code")).toBeNull(); - }); - - it("rejects hosted callback state that was not signed by the broker", async () => { - const hostedEnv = { ...env, ...caseDef.env } as BrokerEnv; - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=not-signed`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("rejects hosted callback state with an unallowed local redirect URI", async () => { - const hostedEnv = { ...env, ...caseDef.env } as BrokerEnv; - const state = await signedLocalHandoffState({ - connector: caseDef.connector, - local_redirect_uri: caseDef.localRedirectUri.replace("8757", "9999"), - provider_redirect_uri: caseDef.hostedCallbackUri - }); - - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "redirect_uri_not_allowed" } - }); - }); - - it("rejects hosted callback state with the wrong provider redirect URI", async () => { - const hostedEnv = { ...env, ...caseDef.env } as BrokerEnv; - const state = await signedLocalHandoffState({ - connector: caseDef.connector, - local_redirect_uri: caseDef.localRedirectUri, - provider_redirect_uri: `${brokerOrigin}/v1/oauth/not-the-right-callback` - }); - - const callback = await app.request( - `${caseDef.callbackPath}?code=authorization-code&state=${encodeURIComponent(state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - expect(callback.headers.get("location")).toBeNull(); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("exchanges authorization codes with the hosted redirect URI", async () => { - const { hostedEnv, start } = await startHostedSession(caseDef); - const fetchMock = vi.fn(async () => Response.json(caseDef.tokenResponse)); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - caseDef.exchangePath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: start.exchange_redirect_uri - }) - }, - hostedEnv - ); - - expect(response.status).toBe(200); - const upstreamBody = caseDef.upstreamBody( - fetchMock.mock.calls[0]?.[0] as RequestInfo | URL, - fetchMock.mock.calls[0]?.[1] as RequestInit - ); - expect(upstreamBody).toMatchObject({ - grant_type: "authorization_code", - code: "authorization-code", - redirect_uri: caseDef.hostedCallbackUri - }); - }); - - it("rejects hosted exchange with an arbitrary redirect URI", async () => { - const { hostedEnv, start } = await startHostedSession(caseDef); - const fetchMock = vi.fn(async () => Response.json({ access_token: "unexpected" })); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - caseDef.exchangePath, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: "https://attacker.example.test/v1/oauth/callback" - }) - }, - hostedEnv - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: "invalid_redirect_uri" } - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); -``` - -Update `signedLocalHandoffState` to accept `connector`: - -```ts -async function signedLocalHandoffState( - overrides: Partial<{ - connector: "notion" | "google-docs" | "google-calendar" | "gmail" | "slack"; - local_redirect_uri: string; - provider_redirect_uri: string; - }> -) { - return signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: overrides.connector ?? "notion", - local_redirect_uri: overrides.local_redirect_uri ?? "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: overrides.provider_redirect_uri ?? hostedNotionCallbackUri, - iat: 1781179200, - exp: 1781179800, - nonce: "nonce" - }, - env.LOCALITY_BROKER_SESSION_SECRET - ); -} -``` - -- [ ] **Step 2: Run the failing Worker test** - -Run: - -```bash -npm --prefix apps/oauth-service test -- app.test.ts -``` - -Expected: FAIL. Google Docs, Google Calendar, Gmail, and Slack hosted tests fail because those env fields, hosted callback routes, response fields, and exchange redirect validators do not exist yet. - -- [ ] **Step 3: Add hosted callback env fields** - -Modify `apps/oauth-service/src/types.ts`: - -```ts -export interface BrokerEnv { - LOCALITY_BROKER_SESSION_SECRET: string; - LOCALITY_REFRESH_HANDLE_KEY?: string; - LOCALITY_TOKEN_MODE?: "handle" | "raw"; - LOCALITY_NOTION_CLIENT_ID: string; - LOCALITY_NOTION_CLIENT_SECRET: string; - LOCALITY_NOTION_REDIRECT_URIS?: string; - LOCALITY_NOTION_HOSTED_CALLBACK_URI?: string; - LOCALITY_NOTION_AUTH_BASE_URL?: string; - LOCALITY_NOTION_API_BASE_URL?: string; - LOCALITY_NOTION_VERSION?: string; - LOCALITY_GOOGLE_CLIENT_ID?: string; - LOCALITY_GOOGLE_CLIENT_SECRET?: string; - LOCALITY_GOOGLE_DOCS_REDIRECT_URIS?: string; - LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI?: string; - LOCALITY_GOOGLE_DOCS_AUTH_BASE_URL?: string; - LOCALITY_GOOGLE_DOCS_API_BASE_URL?: string; - LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS?: string; - LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI?: string; - LOCALITY_GOOGLE_CALENDAR_AUTH_BASE_URL?: string; - LOCALITY_GOOGLE_CALENDAR_API_BASE_URL?: string; - LOCALITY_GMAIL_REDIRECT_URIS?: string; - LOCALITY_GMAIL_HOSTED_CALLBACK_URI?: string; - LOCALITY_GMAIL_AUTH_BASE_URL?: string; - LOCALITY_GMAIL_API_BASE_URL?: string; - LOCALITY_SLACK_CLIENT_ID?: string; - LOCALITY_SLACK_CLIENT_SECRET?: string; - LOCALITY_SLACK_REDIRECT_URIS?: string; - LOCALITY_SLACK_HOSTED_CALLBACK_URI?: string; - LOCALITY_SLACK_AUTH_BASE_URL?: string; - LOCALITY_SLACK_API_BASE_URL?: string; -} -``` - -- [ ] **Step 4: Generalize redirect validation** - -Replace the Notion-only hosted helpers in `apps/oauth-service/src/security/redirects.ts` with connector-aware helpers: - -```ts -interface ConnectorRedirectConfig { - connector: "notion" | "google-docs" | "google-calendar" | "gmail" | "slack"; - displayName: string; - hostedCallbackPath: string; - allowedRedirectUris(env: BrokerEnv): string[]; - hostedCallbackValue(env: BrokerEnv): string | undefined; -} - -const CONNECTOR_REDIRECT_CONFIGS: Record = { - notion: { - connector: "notion", - displayName: "Notion", - hostedCallbackPath: "/v1/oauth/notion/callback", - allowedRedirectUris: allowedNotionRedirectUris, - hostedCallbackValue: (env) => env.LOCALITY_NOTION_HOSTED_CALLBACK_URI - }, - "google-docs": { - connector: "google-docs", - displayName: "Google Docs", - hostedCallbackPath: "/v1/oauth/google-docs/callback", - allowedRedirectUris: allowedGoogleDocsRedirectUris, - hostedCallbackValue: (env) => env.LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI - }, - "google-calendar": { - connector: "google-calendar", - displayName: "Google Calendar", - hostedCallbackPath: "/v1/oauth/google-calendar/callback", - allowedRedirectUris: allowedGoogleCalendarRedirectUris, - hostedCallbackValue: (env) => env.LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI - }, - gmail: { - connector: "gmail", - displayName: "Gmail", - hostedCallbackPath: "/v1/oauth/gmail/callback", - allowedRedirectUris: allowedGmailRedirectUris, - hostedCallbackValue: (env) => env.LOCALITY_GMAIL_HOSTED_CALLBACK_URI - }, - slack: { - connector: "slack", - displayName: "Slack", - hostedCallbackPath: "/v1/oauth/slack/callback", - allowedRedirectUris: allowedSlackRedirectUris, - hostedCallbackValue: (env) => env.LOCALITY_SLACK_HOSTED_CALLBACK_URI - } -}; - -export function hostedConnectorCallbackUri(env: BrokerEnv, connector: ConnectorRedirectConfig["connector"]): string | undefined { - const config = CONNECTOR_REDIRECT_CONFIGS[connector]; - const value = config.hostedCallbackValue(env)?.trim(); - if (!value) { - return undefined; - } - return validateHostedConnectorCallbackUri(config, value); -} - -export function validateHostedConnectorCallbackUri(config: ConnectorRedirectConfig, callbackUri: string): string { - const hasExplicitPort = hasExplicitAuthorityPort(callbackUri); - let parsed: URL; - try { - parsed = new URL(callbackUri); - } catch { - throw badRequest("invalid_hosted_callback_uri", `hosted ${config.displayName} callback URI must be a valid URL`); - } - if ( - parsed.protocol !== "https:" || - parsed.username !== "" || - parsed.password !== "" || - parsed.hostname === "" || - hasExplicitPort || - parsed.port !== "" || - parsed.pathname !== config.hostedCallbackPath || - parsed.search !== "" || - parsed.hash !== "" - ) { - throw badRequest( - "invalid_hosted_callback_uri", - `hosted ${config.displayName} callback URI must be an HTTPS URL at ${config.hostedCallbackPath} without userinfo, port, query, or fragment` - ); - } - return parsed.toString(); -} - -export function validateConnectorRedirectUri(env: BrokerEnv, connector: ConnectorRedirectConfig["connector"], redirectUri: string): string { - const config = CONNECTOR_REDIRECT_CONFIGS[connector]; - return validateLoopbackRedirectUri(config.displayName, config.allowedRedirectUris(env), redirectUri); -} - -export function validateConnectorExchangeRedirectUri(env: BrokerEnv, connector: ConnectorRedirectConfig["connector"], redirectUri: string): string { - const hosted = hostedConnectorCallbackUri(env, connector); - if (hosted && redirectUri === hosted) { - return redirectUri; - } - return validateConnectorRedirectUri(env, connector, redirectUri); -} -``` - -Keep the existing connector-specific exports as wrappers: - -```ts -export function hostedNotionCallbackUri(env: BrokerEnv): string | undefined { - return hostedConnectorCallbackUri(env, "notion"); -} - -export function validateNotionExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "notion", redirectUri); -} - -export function validateGoogleDocsExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "google-docs", redirectUri); -} - -export function validateGoogleCalendarExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "google-calendar", redirectUri); -} - -export function validateGmailExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "gmail", redirectUri); -} - -export function validateSlackExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - return validateConnectorExchangeRedirectUri(env, "slack", redirectUri); -} -``` - -- [ ] **Step 5: Generalize Worker start/callback/exchange helpers** - -In `apps/oauth-service/src/app.ts`, replace `NotionStartRedirects` with: - -```ts -interface StartRedirects { - localRedirectUri: string; - authorizationRedirectUri: string; - exchangeRedirectUri: string; - hostedHandoff: boolean; -} - -interface OAuthConnectorRuntime { - connector: ConnectorId; - defaultLocalRedirectUri: string; - clientId(env: BrokerEnv): string | undefined; - authorizeUrl(env: BrokerEnv, redirectUri: string, state: string): string; - validateLocalRedirectUri(env: BrokerEnv, redirectUri: string): string; - validateExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string; - exchangeCode(env: BrokerEnv, code: string, redirectUri: string): Promise; - shapeTokenResponse(env: BrokerEnv, token: TokenResponse): Promise>; -} -``` - -Add runtime configs below `const app = ...`: - -```ts -const oauthConnectors = { - notion: { - connector: "notion", - defaultLocalRedirectUri: "http://localhost:8757/oauth/notion/callback", - clientId: (env: BrokerEnv) => env.LOCALITY_NOTION_CLIENT_ID, - authorizeUrl: notionAuthorizeUrl, - validateLocalRedirectUri: validateNotionRedirectUri, - validateExchangeRedirectUri: validateNotionExchangeRedirectUri, - exchangeCode: exchangeNotionCode, - shapeTokenResponse: shapeNotionTokenResponse - }, - "google-docs": { - connector: "google-docs", - defaultLocalRedirectUri: "http://localhost:8757/oauth/google-docs/callback", - clientId: googleClientId, - authorizeUrl: googleDocsAuthorizeUrl, - validateLocalRedirectUri: validateGoogleDocsRedirectUri, - validateExchangeRedirectUri: validateGoogleDocsExchangeRedirectUri, - exchangeCode: exchangeGoogleDocsCode, - shapeTokenResponse: shapeGoogleDocsTokenResponse - }, - "google-calendar": { - connector: "google-calendar", - defaultLocalRedirectUri: "http://localhost:8757/oauth/google-calendar/callback", - clientId: googleClientId, - authorizeUrl: googleCalendarAuthorizeUrl, - validateLocalRedirectUri: validateGoogleCalendarRedirectUri, - validateExchangeRedirectUri: validateGoogleCalendarExchangeRedirectUri, - exchangeCode: exchangeGoogleCalendarCode, - shapeTokenResponse: shapeGoogleCalendarTokenResponse - }, - gmail: { - connector: "gmail", - defaultLocalRedirectUri: "http://localhost:8757/oauth/gmail/callback", - clientId: googleClientId, - authorizeUrl: gmailAuthorizeUrl, - validateLocalRedirectUri: validateGmailRedirectUri, - validateExchangeRedirectUri: validateGmailExchangeRedirectUri, - exchangeCode: exchangeGmailCode, - shapeTokenResponse: shapeGmailTokenResponse - }, - slack: { - connector: "slack", - defaultLocalRedirectUri: "http://localhost:8757/oauth/slack/callback", - clientId: (env: BrokerEnv) => env.LOCALITY_SLACK_CLIENT_ID, - authorizeUrl: slackAuthorizeUrl, - validateLocalRedirectUri: validateSlackRedirectUri, - validateExchangeRedirectUri: validateSlackExchangeRedirectUri, - exchangeCode: exchangeSlackCode, - shapeTokenResponse: shapeSlackTokenResponse - } -} satisfies Record>; -``` - -Add helpers near `requiredJson`: - -```ts -async function startOAuthConnector( - env: BrokerEnv, - connector: OAuthConnectorRuntime, - body: StartRequest -) { - const redirects = startRedirects(env, connector.connector, connector.validateLocalRedirectUri, body.redirect_uri ?? connector.defaultLocalRedirectUri); - const now = nowSeconds(); - const secret = requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET"); - const state = redirects.hostedHandoff - ? await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: connector.connector, - local_redirect_uri: redirects.localRedirectUri, - provider_redirect_uri: redirects.authorizationRedirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - secret - ) - : randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: connector.connector, - state, - redirect_uri: redirects.exchangeRedirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - secret - ); - return { - connector: connector.connector, - client_id: connector.clientId(env), - authorization_url: connector.authorizeUrl(env, redirects.authorizationRedirectUri, state), - redirect_uri: redirects.localRedirectUri, - authorization_redirect_uri: redirects.authorizationRedirectUri, - exchange_redirect_uri: redirects.exchangeRedirectUri, - session, - state, - expires_in: SESSION_TTL_SECONDS - }; -} - -async function hostedCallbackResponse( - env: BrokerEnv, - connector: ConnectorId, - validateLocalRedirectUri: (env: BrokerEnv, redirectUri: string) => string, - query: HostedCallbackQuery -): Promise { - const state = callbackString(query.state, "state", 8192); - const payload = await verifyLocalHandoffState( - state, - requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); - if (payload.connector !== connector) { - throw badRequest("invalid_state", "OAuth state connector is invalid"); - } - const expectedProviderRedirectUri = hostedConnectorCallbackUri(env, connector); - if (!expectedProviderRedirectUri || payload.provider_redirect_uri !== expectedProviderRedirectUri) { - throw badRequest("invalid_state", "OAuth state provider redirect is invalid"); - } - const localRedirectUri = validateLocalRedirectUri(env, payload.local_redirect_uri); - const redirect = new URL(localRedirectUri); - redirect.searchParams.set("state", state); - const providerError = optionalCallbackString(query.error, "error", 256); - if (providerError) { - redirect.searchParams.set("error", providerError); - const description = optionalCallbackString(query.error_description, "error_description", 1024); - if (description) { - redirect.searchParams.set("error_description", description); - } - return localCallbackRedirect(redirect.toString()); - } - redirect.searchParams.set("code", callbackString(query.code, "code", 4096)); - return localCallbackRedirect(redirect.toString()); -} - -async function exchangeOAuthConnector( - env: BrokerEnv, - connector: OAuthConnectorRuntime, - body: ExchangeRequest -) { - const session = requireString(body.session, "session"); - const state = requireString(body.state, "state"); - const code = requireString(body.code, "code"); - const redirectUri = connector.validateExchangeRedirectUri(env, requireString(body.redirect_uri, "redirect_uri")); - const payload = await verifySession( - session, - requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); - if (payload.connector !== connector.connector || payload.state !== state || payload.redirect_uri !== redirectUri) { - throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); - } - const token = await connector.exchangeCode(env, code, redirectUri); - return connector.shapeTokenResponse(env, token); -} - -function startRedirects( - env: BrokerEnv, - connector: ConnectorId, - validateLocalRedirectUri: (env: BrokerEnv, redirectUri: string) => string, - requestedRedirectUri: string -): StartRedirects { - const localRedirectUri = validateLocalRedirectUri(env, requestedRedirectUri); - const hostedCallbackUri = hostedConnectorCallbackUri(env, connector); - if (!hostedCallbackUri) { - return { - localRedirectUri, - authorizationRedirectUri: localRedirectUri, - exchangeRedirectUri: localRedirectUri, - hostedHandoff: false - }; - } - return { - localRedirectUri, - authorizationRedirectUri: hostedCallbackUri, - exchangeRedirectUri: hostedCallbackUri, - hostedHandoff: true - }; -} -``` - -Replace every connector start route with the helper, for example: - -```ts -app.post("/v1/oauth/google-docs/start", async (c) => { - const body = await optionalJson(c.req.raw); - return c.json(await startOAuthConnector(c.env, oauthConnectors["google-docs"], body)); -}); -``` - -Add hosted callback routes for Google Docs, Google Calendar, Gmail, and Slack: - -```ts -app.get("/v1/oauth/google-docs/callback", async (c) => - hostedCallbackResponse(c.env, "google-docs", validateGoogleDocsRedirectUri, c.req.query() as HostedCallbackQuery) -); - -app.get("/v1/oauth/google-calendar/callback", async (c) => - hostedCallbackResponse(c.env, "google-calendar", validateGoogleCalendarRedirectUri, c.req.query() as HostedCallbackQuery) -); - -app.get("/v1/oauth/gmail/callback", async (c) => - hostedCallbackResponse(c.env, "gmail", validateGmailRedirectUri, c.req.query() as HostedCallbackQuery) -); - -app.get("/v1/oauth/slack/callback", async (c) => - hostedCallbackResponse(c.env, "slack", validateSlackRedirectUri, c.req.query() as HostedCallbackQuery) -); -``` - -Replace every connector exchange route with the helper, for example: - -```ts -app.post("/v1/oauth/slack/exchange", async (c) => { - const body = await requiredJson(c.req.raw); - return c.json(await exchangeOAuthConnector(c.env, oauthConnectors.slack, body)); -}); -``` - -- [ ] **Step 6: Run Worker tests and typecheck** - -Run: - -```bash -npm --prefix apps/oauth-service test -- app.test.ts -npm --prefix apps/oauth-service run typecheck -``` - -Expected: PASS. The hosted parameterized tests pass for all five connectors, and TypeScript compiles. - -- [ ] **Step 7: Run the full OAuth service check** - -Run: - -```bash -npm --prefix apps/oauth-service run check -``` - -Expected: PASS. - -- [ ] **Step 8: Commit Worker implementation** - -Run: - -```bash -git add apps/oauth-service/src/types.ts apps/oauth-service/src/security/redirects.ts apps/oauth-service/src/app.ts apps/oauth-service/test/app.test.ts -git commit -m "feat(oauth): host callback handoff for all connectors" -``` - -Expected: commit succeeds. - ---- - -### Task 2: Teach Shared Rust Broker Responses About Hosted Redirects - -**Files:** -- Modify: `crates/locality-connector/src/oauth_broker.rs` -- Modify: `crates/loc-cli/src/commands.rs` -- Modify: `apps/desktop/src-tauri/src/main.rs` -- Modify: `crates/loc-cli/tests/connect.rs` - -- [ ] **Step 1: Write failing shared response tests** - -In `crates/locality-connector/src/oauth_broker.rs`, update the test module import: - -```rust -use super::{OAuthBrokerStart, OAuthBrokerStartResponse, OAuthBrokerToken}; -``` - -Add tests in the test module: - -```rust -#[test] -fn start_response_defaults_hosted_redirects_to_local_redirect() { - let payload = serde_json::json!({ - "connector": "google-docs", - "client_id": "google-client-id", - "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=google-client-id", - "redirect_uri": "http://localhost:8757/oauth/google-docs/callback", - "session": "session-1", - "state": "state-1", - "expires_in": 600 - }); - - let response: OAuthBrokerStartResponse = - serde_json::from_value(payload).expect("decode start response"); - - assert_eq!( - response.local_redirect_uri(), - "http://localhost:8757/oauth/google-docs/callback" - ); - assert_eq!( - response.authorization_redirect_uri(), - "http://localhost:8757/oauth/google-docs/callback" - ); - assert_eq!( - response.exchange_redirect_uri(), - "http://localhost:8757/oauth/google-docs/callback" - ); -} - -#[test] -fn start_response_uses_hosted_redirects_when_present() { - let response = OAuthBrokerStartResponse { - connector: "gmail".to_string(), - client_id: "google-client-id".to_string(), - authorization_url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=google-client-id".to_string(), - redirect_uri: "http://localhost:8757/oauth/gmail/callback".to_string(), - authorization_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback".to_string(), - ), - exchange_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback".to_string(), - ), - session: "session-1".to_string(), - state: "state-1".to_string(), - expires_in: 600, - }; - - assert_eq!( - response.local_redirect_uri(), - "http://localhost:8757/oauth/gmail/callback" - ); - assert_eq!( - response.authorization_redirect_uri(), - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - ); - assert_eq!( - response.exchange_redirect_uri(), - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" - ); -} -``` - -- [ ] **Step 2: Run the failing shared response tests** - -Run: - -```bash -cargo test -p locality-connector start_response_ -``` - -Expected: FAIL to compile because `OAuthBrokerStartResponse` has no hosted redirect fields or helper methods. - -- [ ] **Step 3: Implement shared response fields and helpers** - -Modify `OAuthBrokerStartResponse` in `crates/locality-connector/src/oauth_broker.rs`: - -```rust -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct OAuthBrokerStartResponse { - pub connector: String, - pub client_id: String, - pub authorization_url: String, - pub redirect_uri: String, - #[serde(default)] - pub authorization_redirect_uri: Option, - #[serde(default)] - pub exchange_redirect_uri: Option, - pub session: String, - pub state: String, - pub expires_in: u64, -} - -impl OAuthBrokerStartResponse { - pub fn local_redirect_uri(&self) -> &str { - &self.redirect_uri - } - - pub fn authorization_redirect_uri(&self) -> &str { - self.authorization_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } - - pub fn exchange_redirect_uri(&self) -> &str { - self.exchange_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } -} -``` - -- [ ] **Step 4: Update CLI command flows** - -In `crates/loc-cli/src/commands.rs`, update Google Docs, Google Calendar, Gmail, and Slack broker flows. Each flow should listen on `start.local_redirect_uri()` and exchange with `start.exchange_redirect_uri()`. - -For Google Docs, replace the listener and options construction with: - -```rust -let authorization = match run_local_oauth_authorization( - "Google Docs", - &start.authorization_url, - start.local_redirect_uri(), - &start.state, - has_flag(args, "--no-browser"), - json, -) { - Ok(authorization) => authorization, - Err(error) => { - return command_error( - json, - google_docs_local_oauth_command_error(error), - EXIT_INTERNAL, - ); - } -}; -let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); -let options = GoogleDocsBrokerOAuthConnectOptions { - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - client_id: start.client_id, - session: start.session, - state: start.state, - code: authorization.code, - redirect_uri: exchange_redirect_uri, -}; -``` - -Apply the same exact pattern to Google Calendar, Gmail, and Slack with their provider names and option types. - -- [ ] **Step 5: Update desktop broker flows** - -In `apps/desktop/src-tauri/src/main.rs`, update `connect_google_docs_with_broker`, `connect_google_calendar_with_broker`, `connect_gmail_with_broker`, and `connect_slack_with_broker`. - -For Gmail, the final shape should be: - -```rust -let authorization = run_local_oauth_authorization( - "Gmail", - &start.authorization_url, - start.local_redirect_uri(), - &start.state, - !open_browser, - true, -) -.map_err(|error| error.message)?; -let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); -let options = GmailBrokerOAuthConnectOptions { - connection_id: None, - broker_url, - client_id: start.client_id, - session: start.session, - state: start.state, - code: authorization.code, - redirect_uri: exchange_redirect_uri, -}; -``` - -Apply the same exact pattern to Google Docs, Google Calendar, and Slack with their provider names and option types. - -- [ ] **Step 6: Add hosted exchange redirect tests for non-Notion connectors** - -In `crates/loc-cli/tests/connect.rs`, make the fake exchange structs configurable: - -```rust -#[derive(Clone, Debug)] -struct FakeGoogleDocsBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeGoogleDocsBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/google-docs/callback", - } - } -} -``` - -Update its assertion: - -```rust -assert_eq!(request.redirect_uri, self.expected_redirect_uri); -``` - -Apply the same pattern to: - -- `FakeGmailBrokerOAuthExchange` -- `FakeGoogleCalendarBrokerOAuthExchange` -- `FakeSlackBrokerOAuthExchange` - -Update existing test setup from unit structs to defaults, for example: - -```rust -let exchange = FakeGoogleDocsBrokerOAuthExchange::default(); -``` - -Add hosted storage tests: - -```rust -#[test] -fn connect_google_docs_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() { - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_google_docs_broker_oauth( - &mut store, - &credentials, - GoogleDocsBrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("docs-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "google-client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback".to_string(), - }, - &FakeGoogleDocsBrokerOAuthExchange { - expected_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "docs-hosted"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("docs-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:docs-hosted"); - let secret = credentials - .get("connection:docs-hosted") - .expect("credential saved"); - assert!(secret.contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"")); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} -``` - -Add equivalent tests for: - -- `connect_google_calendar_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect` with connection ID `google-calendar-hosted` and callback `/v1/oauth/google-calendar/callback` -- `connect_gmail_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect` with connection ID `gmail-hosted` and callback `/v1/oauth/gmail/callback` -- `connect_slack_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect` with connection ID `slack-hosted` and callback `/v1/oauth/slack/callback` - -- [ ] **Step 7: Run focused Rust tests** - -Run: - -```bash -cargo test -p locality-connector start_response_ -cargo test -p loc-cli hosted_exchange_redirect -``` - -Expected: PASS. If the second filter does not match because test names include more text, run: - -```bash -cargo test -p loc-cli can_store_local_credentials_after_hosted_exchange_redirect -``` - -Expected: PASS. - -- [ ] **Step 8: Run broader Rust checks** - -Run: - -```bash -cargo test -p locality-connector oauth_broker::tests -cargo test -p loc-cli connect -cargo check -p locality-desktop -``` - -Expected: PASS. Existing warnings in unrelated crates are acceptable if the commands exit 0. - -- [ ] **Step 9: Commit Rust implementation** - -Run: - -```bash -git add crates/locality-connector/src/oauth_broker.rs crates/loc-cli/src/commands.rs apps/desktop/src-tauri/src/main.rs crates/loc-cli/tests/connect.rs -git commit -m "feat(cli): use hosted callback redirects for all broker oauth" -``` - -Expected: commit succeeds. - ---- - -### Task 3: Update Config And Documentation For All Hosted Callback Paths - -**Files:** -- Modify: `apps/oauth-service/wrangler.toml` -- Modify: `apps/oauth-service/README.md` -- Modify: `apps/oauth-service/docs/deployment.md` -- Modify: `apps/oauth-service/docs/security.md` -- Modify: `docs/cli.md` -- Modify: `docs-site/cli-reference.mdx` -- Modify: `docs/notion-connector.md` - -- [ ] **Step 1: Update Worker config** - -Modify `[vars]` in `apps/oauth-service/wrangler.toml` to include every local allowlist and hosted callback: - -```toml -LOCALITY_TOKEN_MODE = "handle" -LOCALITY_NOTION_REDIRECT_URIS = "http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback" -LOCALITY_NOTION_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" -LOCALITY_GOOGLE_DOCS_REDIRECT_URIS = "http://localhost:8757/oauth/google-docs/callback,http://127.0.0.1:8757/oauth/google-docs/callback" -LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback" -LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS = "http://localhost:8757/oauth/google-calendar/callback,http://127.0.0.1:8757/oauth/google-calendar/callback" -LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback" -LOCALITY_GMAIL_REDIRECT_URIS = "http://localhost:8757/oauth/gmail/callback,http://127.0.0.1:8757/oauth/gmail/callback" -LOCALITY_GMAIL_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback" -LOCALITY_SLACK_REDIRECT_URIS = "http://localhost:8757/oauth/slack/callback,http://127.0.0.1:8757/oauth/slack/callback" -LOCALITY_SLACK_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback" -``` - -- [ ] **Step 2: Update OAuth service README** - -In `apps/oauth-service/README.md`, replace Notion-only hosted wording with connector-generic wording: - -```md -When `LOCALITY__HOSTED_CALLBACK_URI` is set, `redirect_uri` remains -the local loopback URI where the client listens. `authorization_redirect_uri` -and `exchange_redirect_uri` are the HTTPS provider callback URI registered with -the provider. The browser first returns to the broker callback, and the broker -redirects the browser to `redirect_uri` with the provider code or error. The -client then exchanges the code using `exchange_redirect_uri`, so the provider -sees the same redirect URI during authorization and token exchange. -``` - -Add a callback section covering all connector paths: - -```md -### `GET /v1/oauth//callback` - -Hosted callback routes exist for: - -- `/v1/oauth/notion/callback` -- `/v1/oauth/google-docs/callback` -- `/v1/oauth/google-calendar/callback` -- `/v1/oauth/gmail/callback` -- `/v1/oauth/slack/callback` - -These browser-facing routes are used only when the corresponding -`LOCALITY__HOSTED_CALLBACK_URI` is configured. A route accepts the -provider's `code` and `state`, verifies the signed local-handoff state, and -returns `303 See Other` to the loopback callback held inside that state. -``` - -Update Google Docs, Google Calendar, Gmail, and Slack start/exchange examples to show `authorization_redirect_uri`, `exchange_redirect_uri`, and hosted exchange redirect behavior, matching the Notion examples. - -- [ ] **Step 3: Update security and deployment docs** - -In `apps/oauth-service/docs/security.md`, replace the Redirects section with: - -```md -The broker keeps two redirect boundaries separate for every OAuth connector: - -- `LOCALITY__REDIRECT_URIS` is a loopback-only allowlist for local callbacks such as `http://localhost:8757/oauth/gmail/callback`. -- `LOCALITY__HOSTED_CALLBACK_URI` is one exact HTTPS callback served by this broker at the connector's `/v1/oauth//callback` path. - -When hosted handoff is enabled, the provider authorization request uses the -hosted callback URI. The callback route verifies a signed state payload before -redirecting to a loopback URI from the allowlist. The token exchange also uses -the hosted callback URI so the provider sees the same redirect URI in both OAuth -steps. -``` - -In `apps/oauth-service/docs/deployment.md`, document provider registration: - -~~~md -Register these hosted callback URIs before deploying the matching -`LOCALITY__HOSTED_CALLBACK_URI` values: - -```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback -``` - -The Google OAuth client must include the Google Docs, Google Calendar, and Gmail -hosted callback URIs. Slack must include the Slack hosted callback URI. Notion -must include the Notion hosted callback URI. -~~~ - -- [ ] **Step 4: Update CLI docs** - -In `docs/cli.md` and `docs-site/cli-reference.mdx`, update the Google Docs, Google Calendar, Gmail, and Slack OAuth paragraphs to mirror the Notion statement: - -```md -The default local callback is `http://localhost:8757/oauth//callback`; override it with `--redirect-uri ` or the connector-specific `LOCALITY__OAUTH_REDIRECT_URI`. In production the broker may use its own HTTPS provider callback registered on the provider app, then hand the browser back to the local callback. The command still stores the resulting OAuth credential locally. -``` - -Keep direct OAuth or provider-specific scope details unchanged. - -- [ ] **Step 5: Update Notion connector doc** - -In `docs/notion-connector.md`, replace the final Notion-only hosted wording with: - -```md -The CLI still listens on the local callback, defaulting to -`http://localhost:8757/oauth/notion/callback`. In production the Notion public -integration should register the broker's HTTPS hosted callback; the broker -receives that callback and redirects the browser back to the local listener. -For BYO/direct OAuth development, a developer-owned Notion app may still -register and use the local callback. -``` - -- [ ] **Step 6: Run docs and service checks** - -Run: - -```bash -npm --prefix apps/oauth-service run check -make docs-validate -make docs-broken-links -``` - -Expected: PASS. - -- [ ] **Step 7: Commit docs and config** - -Run: - -```bash -git add apps/oauth-service/wrangler.toml apps/oauth-service/README.md apps/oauth-service/docs/deployment.md apps/oauth-service/docs/security.md docs/cli.md docs-site/cli-reference.mdx docs/notion-connector.md -git commit -m "docs(oauth): document hosted callbacks for all connectors" -``` - -Expected: commit succeeds. - ---- - -### Task 4: Final Verification And PR Update - -**Files:** -- No source edits expected unless smoke tests reveal a concrete mismatch. - -- [ ] **Step 1: Run full affected verification** - -Run: - -```bash -npm --prefix apps/oauth-service run check -cargo test -p locality-connector oauth_broker::tests -cargo test -p locality-notion oauth::tests -cargo test -p loc-cli connect -cargo check -p locality-desktop -make check-oauth-service -make docs-validate -make docs-broken-links -``` - -Expected: every command exits 0. Existing unrelated Rust warnings are acceptable. - -- [ ] **Step 2: Run a local Worker smoke test for Google Docs and Slack** - -Prepare local Worker variables. Preserve any existing local `.dev.vars`: - -```bash -if [ -f apps/oauth-service/.dev.vars ]; then - cp apps/oauth-service/.dev.vars apps/oauth-service/.dev.vars.before-all-hosted-handoff-smoke -fi -cat > apps/oauth-service/.dev.vars <<'EOF' -LOCALITY_BROKER_SESSION_SECRET=test-session-secret-with-enough-entropy -LOCALITY_REFRESH_HANDLE_KEY=test-refresh-handle-key-with-enough-entropy -LOCALITY_TOKEN_MODE=handle -LOCALITY_NOTION_CLIENT_ID=notion-client-id -LOCALITY_NOTION_CLIENT_SECRET=notion-client-secret -LOCALITY_NOTION_REDIRECT_URIS=http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback -LOCALITY_NOTION_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -LOCALITY_GOOGLE_CLIENT_ID=google-client-id -LOCALITY_GOOGLE_CLIENT_SECRET=google-client-secret -LOCALITY_GOOGLE_DOCS_REDIRECT_URIS=http://localhost:8757/oauth/google-docs/callback,http://127.0.0.1:8757/oauth/google-docs/callback -LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS=http://localhost:8757/oauth/google-calendar/callback,http://127.0.0.1:8757/oauth/google-calendar/callback -LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -LOCALITY_GMAIL_REDIRECT_URIS=http://localhost:8757/oauth/gmail/callback,http://127.0.0.1:8757/oauth/gmail/callback -LOCALITY_GMAIL_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -LOCALITY_SLACK_CLIENT_ID=slack-client-id -LOCALITY_SLACK_CLIENT_SECRET=slack-client-secret -LOCALITY_SLACK_REDIRECT_URIS=http://localhost:8757/oauth/slack/callback,http://127.0.0.1:8757/oauth/slack/callback -LOCALITY_SLACK_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback -EOF -``` - -Start the Worker: - -```bash -npm --prefix apps/oauth-service run dev -``` - -In another shell, validate Google Docs start: - -```bash -node <<'EOF' -const expectedHosted = 'https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback'; -const expectedLocal = 'http://localhost:8757/oauth/google-docs/callback'; -const response = await fetch('http://127.0.0.1:8787/v1/oauth/google-docs/start', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ redirect_uri: expectedLocal }) -}); -if (!response.ok) throw new Error(`google-docs start failed: ${response.status} ${await response.text()}`); -const body = await response.json(); -const authorizationUrl = new URL(body.authorization_url); -if (body.redirect_uri !== expectedLocal) throw new Error('local redirect mismatch'); -if (body.authorization_redirect_uri !== expectedHosted) throw new Error('authorization redirect mismatch'); -if (body.exchange_redirect_uri !== expectedHosted) throw new Error('exchange redirect mismatch'); -if (authorizationUrl.searchParams.get('redirect_uri') !== expectedHosted) throw new Error('authorization URL redirect mismatch'); -if (authorizationUrl.searchParams.get('state') !== body.state) throw new Error('state mismatch'); -console.log('google-docs hosted start ok'); -EOF -``` - -Validate Slack start: - -```bash -node <<'EOF' -const expectedHosted = 'https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback'; -const expectedLocal = 'http://localhost:8757/oauth/slack/callback'; -const response = await fetch('http://127.0.0.1:8787/v1/oauth/slack/start', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ redirect_uri: expectedLocal }) -}); -if (!response.ok) throw new Error(`slack start failed: ${response.status} ${await response.text()}`); -const body = await response.json(); -const authorizationUrl = new URL(body.authorization_url); -if (body.redirect_uri !== expectedLocal) throw new Error('local redirect mismatch'); -if (body.authorization_redirect_uri !== expectedHosted) throw new Error('authorization redirect mismatch'); -if (body.exchange_redirect_uri !== expectedHosted) throw new Error('exchange redirect mismatch'); -if (authorizationUrl.searchParams.get('redirect_uri') !== expectedHosted) throw new Error('authorization URL redirect mismatch'); -if (authorizationUrl.searchParams.get('state') !== body.state) throw new Error('state mismatch'); -console.log('slack hosted start ok'); -EOF -``` - -Stop the Worker and restore local vars: - -```bash -rm apps/oauth-service/.dev.vars -if [ -f apps/oauth-service/.dev.vars.before-all-hosted-handoff-smoke ]; then - mv apps/oauth-service/.dev.vars.before-all-hosted-handoff-smoke apps/oauth-service/.dev.vars -fi -``` - -Expected: both smoke scripts print success and no local `.dev.vars` changes remain in `git status --short`. - -- [ ] **Step 3: Run a CLI no-browser smoke test for Google Docs** - -With the Worker running from Step 2, run: - -```bash -LOCALITY_GOOGLE_DOCS_OAUTH_BROKER_URL=http://127.0.0.1:8787 \ - cargo run -p loc-cli -- connect google-docs --name hosted-docs-smoke --no-browser -``` - -Expected: - -- The command prints a local callback at `http://localhost:8757/oauth/google-docs/callback`. -- The authorization URL contains `redirect_uri=https%3A%2F%2Fafs-oauth-broker.saurabh-b07.workers.dev%2Fv1%2Foauth%2Fgoogle-docs%2Fcallback`. -- Stop the command with Ctrl-C after verifying the URL and listener output. - -- [ ] **Step 4: Confirm external provider registration gate** - -Before deploying the Worker vars, verify in provider dashboards that the production apps include exactly these hosted callbacks: - -```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-docs/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/google-calendar/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/gmail/callback -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/slack/callback -``` - -Expected: This is a manual external gate. If not verified, report it as pending and do not claim deployment readiness. - -- [ ] **Step 5: Update the PR** - -Run: - -```bash -git status --short -git push -gh pr edit 132 --title "Support hosted OAuth callback handoff for all connectors" --body "$(cat <<'EOF' -## Summary -- Add hosted OAuth callback handoff for Notion, Google Docs, Google Calendar, Gmail, and Slack while keeping local loopback listeners and local credential storage. -- Teach CLI and desktop broker flows to listen on local `redirect_uri` and exchange with hosted `exchange_redirect_uri` when returned by the broker. -- Update Worker config and docs so loopback allowlists and hosted provider callbacks are separate for every OAuth connector. - -## Test Plan -- [x] npm --prefix apps/oauth-service run check -- [x] cargo test -p locality-connector oauth_broker::tests -- [x] cargo test -p locality-notion oauth::tests -- [x] cargo test -p loc-cli connect -- [x] cargo check -p locality-desktop -- [x] make check-oauth-service -- [x] make docs-validate -- [x] make docs-broken-links -- [x] Local Worker smoke for Google Docs and Slack hosted start responses -- [x] CLI no-browser smoke for Google Docs hosted redirect URL - -## Deployment Gate -Before deploying/enabling hosted callbacks, register the per-connector HTTPS callback URLs in the provider apps. The Google OAuth app must include the Google Docs, Google Calendar, and Gmail callback paths. -EOF -)" -``` - -Expected: branch pushes and PR title/body are updated. - ---- - -## Self-Review - -Spec coverage: - -- All OAuth connectors use hosted callback paths: Task 1 and Task 3. -- Per-connector callback paths: Task 1 tests and config/docs in Task 3. -- Local credentials and local listener remain: Task 2 CLI/desktop changes and Task 4 smoke. -- Provider exchange uses same hosted redirect URI as authorization: Task 1 exchange tests and Task 2 shared response helpers. -- No private backend changes: no private backend files are in this plan. - -Placeholder scan: - -- The plan contains no unresolved markers or copied-reference implementation instructions. -- Repeated connector work is listed with exact connector names, paths, and expected callback URIs. - -Type consistency: - -- JSON fields are consistently `authorization_redirect_uri` and `exchange_redirect_uri`. -- Rust methods are consistently `local_redirect_uri()`, `authorization_redirect_uri()`, and `exchange_redirect_uri()`. -- Worker env fields are consistently `LOCALITY__HOSTED_CALLBACK_URI`. diff --git a/docs/superpowers/plans/2026-07-29-hosted-oauth-local-handoff.md b/docs/superpowers/plans/2026-07-29-hosted-oauth-local-handoff.md deleted file mode 100644 index 2e127604..00000000 --- a/docs/superpowers/plans/2026-07-29-hosted-oauth-local-handoff.md +++ /dev/null @@ -1,1446 +0,0 @@ -# Hosted OAuth Local Handoff Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let Notion redirect to the deployed TLS OAuth service while preserving today's local `loc connect notion` outcome: the CLI receives the authorization code on localhost, exchanges through the broker, and stores local credentials in the OS credential store. - -**Architecture:** The OAuth broker remains the confidential token exchange boundary. For Notion only, `/v1/oauth/notion/start` keeps accepting a local loopback `redirect_uri`, but when `LOCALITY_NOTION_HOSTED_CALLBACK_URI` is configured it sends Notion an HTTPS broker callback URI and returns separate local, authorization, and exchange redirect URIs. The broker callback verifies a signed local-handoff state token and redirects the browser to the local loopback listener with the original code or provider error. - -**Tech Stack:** Cloudflare Worker, Hono, TypeScript, Vitest, Rust, Serde, reqwest, existing `loc` CLI OAuth flow. - ---- - -## Scope - -This plan covers Notion first. Google Docs, Gmail, Google Calendar, and Slack stay on the existing loopback redirect flow because their client/broker structs and provider registration needs are separate. - -The final behavior is: - -1. `loc connect notion` starts a local listener at `http://localhost:8757/oauth/notion/callback`. -2. The CLI calls the broker start endpoint with that local URI. -3. The broker returns an authorization URL whose `redirect_uri` is the TLS broker callback. -4. Notion redirects the browser to the TLS broker callback. -5. The broker validates signed state and redirects the browser to localhost with `code` and `state`. -6. The CLI validates `state`, receives `code`, and calls broker exchange with the hosted HTTPS exchange redirect URI. -7. The broker exchanges the code with Notion using the same hosted HTTPS redirect URI and returns the token response. -8. The CLI stores the credential locally exactly as it does today. - -## File Structure - -- Modify `apps/oauth-service/src/types.ts`: add the Notion hosted callback environment variable. -- Modify `apps/oauth-service/src/security/session.ts`: add signed local-handoff state helpers. -- Modify `apps/oauth-service/src/security/redirects.ts`: keep `LOCALITY_NOTION_REDIRECT_URIS` loopback-only and add exact HTTPS callback validation. -- Modify `apps/oauth-service/src/app.ts`: add hosted Notion start behavior, the `GET /v1/oauth/notion/callback` route, and exchange redirect validation. -- Modify `apps/oauth-service/test/app.test.ts`: add broker behavior tests for hosted handoff. -- Create `apps/oauth-service/test/local-handoff-state.test.ts`: focused tests for signed state helpers. -- Modify `apps/oauth-service/README.md`: document the hosted handoff flow and API response fields. -- Modify `apps/oauth-service/docs/security.md`: document redirect boundaries and non-persistence of callback codes. -- Modify `apps/oauth-service/wrangler.toml`: split loopback allowlist from hosted provider callback config. -- Modify `crates/locality-notion/src/oauth.rs`: extend `NotionOAuthBrokerStartResponse` with optional broker-provided authorization and exchange redirect URIs. -- Modify `crates/loc-cli/src/commands.rs`: listen on the local URI while exchanging with the broker-provided exchange URI. -- Modify `docs/cli.md` and `docs-site/cli-reference.mdx`: document that the default broker may use a hosted provider callback while the CLI still listens locally. - ---- - -### Task 1: Add Signed Local-Handoff State Helpers - -**Files:** -- Modify: `apps/oauth-service/src/security/session.ts` -- Create: `apps/oauth-service/test/local-handoff-state.test.ts` - -- [ ] **Step 1: Write the failing signed-state tests** - -Create `apps/oauth-service/test/local-handoff-state.test.ts`: - -```ts -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { signLocalHandoffState, verifyLocalHandoffState } from "../src/security/session"; - -const secret = "test-session-secret-with-enough-entropy"; - -describe("local OAuth handoff state", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-29T12:00:00Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("round-trips a Notion local handoff state without storing server state", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - - const payload = await verifyLocalHandoffState(token, secret, 1785326401); - - expect(payload).toEqual({ - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }); - }); - - it("rejects a tampered local handoff state", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - const [body, signature] = token.split("."); - const replacement = signature.endsWith("A") ? "B" : "A"; - const tampered = `${body}.${signature.slice(0, -1)}${replacement}`; - - await expect(verifyLocalHandoffState(tampered, secret, 1785326401)).rejects.toMatchObject({ - status: 401, - code: "invalid_state" - }); - }); - - it("rejects an expired local handoff state", async () => { - const token = await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: "http://localhost:8757/oauth/notion/callback", - provider_redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - iat: 1785326400, - exp: 1785327000, - nonce: "nonce-1" - }, - secret - ); - - await expect(verifyLocalHandoffState(token, secret, 1785327000)).rejects.toMatchObject({ - status: 401, - code: "expired_state" - }); - }); -}); -``` - -- [ ] **Step 2: Run the failing tests** - -Run: - -```bash -npm --prefix apps/oauth-service test -- local-handoff-state.test.ts -``` - -Expected: FAIL with TypeScript or runtime errors stating `signLocalHandoffState` and `verifyLocalHandoffState` are not exported. - -- [ ] **Step 3: Add the signed-state helpers** - -Modify `apps/oauth-service/src/security/session.ts` so the top-level exported types and functions include the following code. Keep the existing `OAuthSessionPayload`, `signSession`, `verifySession`, and `nowSeconds` exports. - -```ts -import { badRequest, unauthorized } from "../http/errors"; -import type { ConnectorId } from "../types"; -import { constantTimeEqual, hmacSha256Base64Url, parseUtf8Base64Url, utf8Base64Url } from "./crypto"; - -export interface OAuthSessionPayload { - v: 1; - connector: ConnectorId; - state: string; - redirect_uri: string; - iat: number; - exp: number; - nonce: string; -} - -export interface OAuthLocalHandoffStatePayload { - v: 1; - kind: "local_handoff"; - connector: ConnectorId; - local_redirect_uri: string; - provider_redirect_uri: string; - iat: number; - exp: number; - nonce: string; -} - -export async function signSession(payload: OAuthSessionPayload, secret: string): Promise { - return signPayload(payload, secret); -} - -export async function verifySession(token: string, secret: string, now = nowSeconds()): Promise { - const payload = await verifyPayload(token, secret, "session"); - if (!isOAuthSessionPayload(payload)) { - throw badRequest("invalid_session", "OAuth session token payload is invalid"); - } - if (payload.exp <= now) { - throw unauthorized("expired_session", "OAuth session has expired"); - } - return payload; -} - -export async function signLocalHandoffState(payload: OAuthLocalHandoffStatePayload, secret: string): Promise { - return signPayload(payload, secret); -} - -export async function verifyLocalHandoffState( - token: string, - secret: string, - now = nowSeconds() -): Promise { - const payload = await verifyPayload(token, secret, "state"); - if (!isOAuthLocalHandoffStatePayload(payload)) { - throw badRequest("invalid_state", "OAuth state payload is invalid"); - } - if (payload.exp <= now) { - throw unauthorized("expired_state", "OAuth state has expired"); - } - return payload; -} - -export function nowSeconds(): number { - return Math.floor(Date.now() / 1000); -} - -async function signPayload(payload: unknown, secret: string): Promise { - const body = utf8Base64Url(JSON.stringify(payload)); - const signature = await hmacSha256Base64Url(secret, body); - return `${body}.${signature}`; -} - -async function verifyPayload(token: string, secret: string, label: "session" | "state"): Promise { - const [body, signature] = token.split("."); - if (!body || !signature) { - throw badRequest(`invalid_${label}`, `OAuth ${label} token is malformed`); - } - const expected = await hmacSha256Base64Url(secret, body); - if (!constantTimeEqual(signature, expected)) { - throw unauthorized(`invalid_${label}`, `OAuth ${label} token signature is invalid`); - } - try { - return JSON.parse(parseUtf8Base64Url(body)) as T; - } catch { - throw badRequest(`invalid_${label}`, `OAuth ${label} token payload is invalid`); - } -} - -function isOAuthSessionPayload(value: unknown): value is OAuthSessionPayload { - if (!value || typeof value !== "object") { - return false; - } - const payload = value as Partial; - return ( - payload.v === 1 && - isConnectorId(payload.connector) && - typeof payload.state === "string" && - typeof payload.redirect_uri === "string" && - typeof payload.iat === "number" && - typeof payload.exp === "number" && - typeof payload.nonce === "string" - ); -} - -function isOAuthLocalHandoffStatePayload(value: unknown): value is OAuthLocalHandoffStatePayload { - if (!value || typeof value !== "object") { - return false; - } - const payload = value as Partial; - return ( - payload.v === 1 && - payload.kind === "local_handoff" && - isConnectorId(payload.connector) && - typeof payload.local_redirect_uri === "string" && - typeof payload.provider_redirect_uri === "string" && - typeof payload.iat === "number" && - typeof payload.exp === "number" && - typeof payload.nonce === "string" - ); -} - -function isConnectorId(value: unknown): value is ConnectorId { - return ( - value === "notion" || - value === "google-docs" || - value === "google-calendar" || - value === "gmail" || - value === "slack" - ); -} -``` - -- [ ] **Step 4: Run the signed-state tests** - -Run: - -```bash -npm --prefix apps/oauth-service test -- local-handoff-state.test.ts -``` - -Expected: PASS for all three tests. - -- [ ] **Step 5: Run broker typecheck** - -Run: - -```bash -npm --prefix apps/oauth-service run typecheck -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add apps/oauth-service/src/security/session.ts apps/oauth-service/test/local-handoff-state.test.ts -git commit -m "feat(oauth): sign local handoff state" -``` - -Expected: commit succeeds. - ---- - -### Task 2: Add Hosted Notion Callback Handoff In The OAuth Service - -**Files:** -- Modify: `apps/oauth-service/src/types.ts` -- Modify: `apps/oauth-service/src/security/redirects.ts` -- Modify: `apps/oauth-service/src/app.ts` -- Modify: `apps/oauth-service/test/app.test.ts` - -- [ ] **Step 1: Write failing broker tests for hosted Notion start, callback, and exchange** - -Append these tests inside `describe("auth broker", () => { ... })` in `apps/oauth-service/test/app.test.ts`. - -```ts - it("starts Notion OAuth with hosted provider callback and local loopback handoff", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - - const response = await app.request("/v1/oauth/notion/start", { method: "POST" }, hostedEnv); - - expect(response.status).toBe(200); - const body = (await response.json()) as StartResponse & { - authorization_redirect_uri: string; - exchange_redirect_uri: string; - }; - expect(body.redirect_uri).toBe("http://localhost:8757/oauth/notion/callback"); - expect(body.authorization_redirect_uri).toBe("https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback"); - expect(body.exchange_redirect_uri).toBe("https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback"); - const authorizationUrl = new URL(body.authorization_url); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - ); - expect(authorizationUrl.searchParams.get("state")).toBe(body.state); - expect(body.session).toBeTruthy(); - expect(body.state).toBeTruthy(); - expect(body.session).not.toBe(body.state); - }); - - it("redirects a valid hosted Notion callback to the local loopback listener", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - const startResponse = await app.request("/v1/oauth/notion/start", { method: "POST" }, hostedEnv); - const start = (await startResponse.json()) as StartResponse; - - const callback = await app.request( - `/v1/oauth/notion/callback?code=authorization-code&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(303); - expect(callback.headers.get("cache-control")).toBe("no-store"); - expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); - const location = new URL(callback.headers.get("location") ?? ""); - expect(location.origin).toBe("http://localhost:8757"); - expect(location.pathname).toBe("/oauth/notion/callback"); - expect(location.searchParams.get("code")).toBe("authorization-code"); - expect(location.searchParams.get("state")).toBe(start.state); - }); - - it("redirects hosted Notion provider denial to the local listener with state", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - const startResponse = await app.request("/v1/oauth/notion/start", { method: "POST" }, hostedEnv); - const start = (await startResponse.json()) as StartResponse; - - const callback = await app.request( - `/v1/oauth/notion/callback?error=access_denied&error_description=User%20cancelled&state=${encodeURIComponent(start.state)}`, - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(303); - const location = new URL(callback.headers.get("location") ?? ""); - expect(location.origin).toBe("http://localhost:8757"); - expect(location.pathname).toBe("/oauth/notion/callback"); - expect(location.searchParams.get("error")).toBe("access_denied"); - expect(location.searchParams.get("error_description")).toBe("User cancelled"); - expect(location.searchParams.get("state")).toBe(start.state); - expect(location.searchParams.get("code")).toBeNull(); - }); - - it("rejects hosted Notion callback state that was not signed by the broker", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - - const callback = await app.request( - "/v1/oauth/notion/callback?code=authorization-code&state=not-signed", - { method: "GET" }, - hostedEnv - ); - - expect(callback.status).toBe(400); - await expect(callback.json()).resolves.toMatchObject({ - error: { code: "invalid_state" } - }); - }); - - it("exchanges hosted Notion authorization codes with the hosted redirect URI", async () => { - const hostedEnv: BrokerEnv = { - ...env, - LOCALITY_NOTION_REDIRECT_URIS: "http://localhost:8757/oauth/notion/callback", - LOCALITY_NOTION_HOSTED_CALLBACK_URI: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }; - const startResponse = await app.request("/v1/oauth/notion/start", { method: "POST" }, hostedEnv); - const start = (await startResponse.json()) as StartResponse & { exchange_redirect_uri: string }; - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - Response.json({ - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "bearer", - expires_in: 3600, - workspace_id: "workspace-id" - }) - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const response = await app.request( - "/v1/oauth/notion/exchange", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - session: start.session, - state: start.state, - code: "authorization-code", - redirect_uri: start.exchange_redirect_uri - }) - }, - hostedEnv - ); - - expect(response.status).toBe(200); - const notionRequest = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); - expect(notionRequest).toMatchObject({ - grant_type: "authorization_code", - code: "authorization-code", - redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - }); - }); -``` - -- [ ] **Step 2: Run the hosted broker tests and verify they fail** - -Run: - -```bash -npm --prefix apps/oauth-service test -- app.test.ts -``` - -Expected: FAIL because `LOCALITY_NOTION_HOSTED_CALLBACK_URI`, `authorization_redirect_uri`, `exchange_redirect_uri`, and `GET /v1/oauth/notion/callback` are not implemented. - -- [ ] **Step 3: Add the hosted callback env type** - -Modify `apps/oauth-service/src/types.ts`: - -```ts -export interface BrokerEnv { - LOCALITY_BROKER_SESSION_SECRET: string; - LOCALITY_REFRESH_HANDLE_KEY?: string; - LOCALITY_TOKEN_MODE?: "handle" | "raw"; - LOCALITY_NOTION_CLIENT_ID: string; - LOCALITY_NOTION_CLIENT_SECRET: string; - LOCALITY_NOTION_REDIRECT_URIS?: string; - LOCALITY_NOTION_HOSTED_CALLBACK_URI?: string; - LOCALITY_NOTION_AUTH_BASE_URL?: string; - LOCALITY_NOTION_API_BASE_URL?: string; - LOCALITY_NOTION_VERSION?: string; - LOCALITY_GOOGLE_CLIENT_ID?: string; - LOCALITY_GOOGLE_CLIENT_SECRET?: string; - LOCALITY_GOOGLE_DOCS_REDIRECT_URIS?: string; - LOCALITY_GOOGLE_DOCS_AUTH_BASE_URL?: string; - LOCALITY_GOOGLE_DOCS_API_BASE_URL?: string; - LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS?: string; - LOCALITY_GOOGLE_CALENDAR_AUTH_BASE_URL?: string; - LOCALITY_GOOGLE_CALENDAR_API_BASE_URL?: string; - LOCALITY_GMAIL_REDIRECT_URIS?: string; - LOCALITY_GMAIL_AUTH_BASE_URL?: string; - LOCALITY_GMAIL_API_BASE_URL?: string; - LOCALITY_SLACK_CLIENT_ID?: string; - LOCALITY_SLACK_CLIENT_SECRET?: string; - LOCALITY_SLACK_REDIRECT_URIS?: string; - LOCALITY_SLACK_AUTH_BASE_URL?: string; - LOCALITY_SLACK_API_BASE_URL?: string; -} -``` - -- [ ] **Step 4: Add hosted callback validation without weakening loopback validation** - -Modify `apps/oauth-service/src/security/redirects.ts` by adding these exports and helpers. Keep existing `validateNotionRedirectUri` loopback-only. - -```ts -const NOTION_HOSTED_CALLBACK_PATH = "/v1/oauth/notion/callback"; - -export function hostedNotionCallbackUri(env: BrokerEnv): string | undefined { - const value = env.LOCALITY_NOTION_HOSTED_CALLBACK_URI?.trim(); - if (!value) { - return undefined; - } - return validateHostedNotionCallbackUri(value); -} - -export function validateHostedNotionCallbackUri(callbackUri: string): string { - let parsed: URL; - try { - parsed = new URL(callbackUri); - } catch { - throw badRequest("invalid_hosted_callback_uri", "hosted Notion callback URI must be a valid URL"); - } - if ( - parsed.protocol !== "https:" || - parsed.username !== "" || - parsed.password !== "" || - parsed.hostname === "" || - parsed.port !== "" || - parsed.pathname !== NOTION_HOSTED_CALLBACK_PATH || - parsed.search !== "" || - parsed.hash !== "" - ) { - throw badRequest( - "invalid_hosted_callback_uri", - "hosted Notion callback URI must be an HTTPS URL at /v1/oauth/notion/callback without userinfo, port, query, or fragment" - ); - } - return parsed.toString(); -} - -export function validateNotionExchangeRedirectUri(env: BrokerEnv, redirectUri: string): string { - const hosted = hostedNotionCallbackUri(env); - if (hosted && redirectUri === hosted) { - return redirectUri; - } - return validateNotionRedirectUri(env, redirectUri); -} -``` - -- [ ] **Step 5: Add Notion start redirect selection and callback helpers** - -Modify imports at the top of `apps/oauth-service/src/app.ts`: - -```ts -import { randomBase64Url, decryptJsonHandle, encryptJsonHandle } from "./security/crypto"; -import { - nowSeconds, - signLocalHandoffState, - signSession, - verifyLocalHandoffState, - verifySession -} from "./security/session"; -import { - hostedNotionCallbackUri, - validateGmailRedirectUri, - validateGoogleCalendarRedirectUri, - validateGoogleDocsRedirectUri, - validateNotionExchangeRedirectUri, - validateNotionRedirectUri, - validateSlackRedirectUri -} from "./security/redirects"; -``` - -Add these interfaces near the existing request interfaces: - -```ts -interface NotionStartRedirects { - localRedirectUri: string; - authorizationRedirectUri: string; - exchangeRedirectUri: string; - hostedHandoff: boolean; -} - -interface HostedCallbackQuery { - state?: string; - code?: string; - error?: string; - error_description?: string; -} -``` - -Add these helper functions near `requireString`: - -```ts -function notionStartRedirects(env: BrokerEnv, requestedRedirectUri: string): NotionStartRedirects { - const localRedirectUri = validateNotionRedirectUri(env, requestedRedirectUri); - const hostedCallbackUri = hostedNotionCallbackUri(env); - if (!hostedCallbackUri) { - return { - localRedirectUri, - authorizationRedirectUri: localRedirectUri, - exchangeRedirectUri: localRedirectUri, - hostedHandoff: false - }; - } - return { - localRedirectUri, - authorizationRedirectUri: hostedCallbackUri, - exchangeRedirectUri: hostedCallbackUri, - hostedHandoff: true - }; -} - -function callbackString(value: string | undefined, field: string, maxBytes: number): string { - if (!value || value.trim() === "" || new TextEncoder().encode(value).byteLength > maxBytes || hasControlCharacter(value)) { - throw badRequest("invalid_callback", `${field} is invalid`); - } - return value; -} - -function optionalCallbackString(value: string | undefined, field: string, maxBytes: number): string | undefined { - if (value === undefined) { - return undefined; - } - return callbackString(value, field, maxBytes); -} - -function hasControlCharacter(value: string): boolean { - for (const character of value) { - const codePoint = character.charCodeAt(0); - if (codePoint < 0x20 || codePoint === 0x7f) { - return true; - } - } - return false; -} - -function localCallbackRedirect(location: string): Response { - return new Response(null, { - status: 303, - headers: { - "Cache-Control": "no-store", - "Referrer-Policy": "no-referrer", - Location: location - } - }); -} -``` - -- [ ] **Step 6: Implement hosted Notion start behavior** - -Replace the current `/v1/oauth/notion/start` route in `apps/oauth-service/src/app.ts` with: - -```ts -app.post("/v1/oauth/notion/start", async (c) => { - const body = await optionalJson(c.req.raw); - const redirects = notionStartRedirects( - c.env, - body.redirect_uri ?? "http://localhost:8757/oauth/notion/callback" - ); - const now = nowSeconds(); - const state = redirects.hostedHandoff - ? await signLocalHandoffState( - { - v: 1, - kind: "local_handoff", - connector: "notion", - local_redirect_uri: redirects.localRedirectUri, - provider_redirect_uri: redirects.authorizationRedirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ) - : randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: "notion", - state, - redirect_uri: redirects.exchangeRedirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); - return c.json({ - connector: "notion", - client_id: c.env.LOCALITY_NOTION_CLIENT_ID, - authorization_url: notionAuthorizeUrl(c.env, redirects.authorizationRedirectUri, state), - redirect_uri: redirects.localRedirectUri, - authorization_redirect_uri: redirects.authorizationRedirectUri, - exchange_redirect_uri: redirects.exchangeRedirectUri, - session, - state, - expires_in: SESSION_TTL_SECONDS - }); -}); -``` - -- [ ] **Step 7: Implement hosted Notion callback route** - -Add this route after `/v1/oauth/notion/start` and before `/v1/oauth/notion/exchange`: - -```ts -app.get("/v1/oauth/notion/callback", async (c) => { - const query = c.req.query() as HostedCallbackQuery; - const state = callbackString(query.state, "state", 8192); - const payload = await verifyLocalHandoffState( - state, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); - if (payload.connector !== "notion") { - throw badRequest("invalid_state", "OAuth state connector is invalid"); - } - const expectedProviderRedirectUri = hostedNotionCallbackUri(c.env); - if (!expectedProviderRedirectUri || payload.provider_redirect_uri !== expectedProviderRedirectUri) { - throw badRequest("invalid_state", "OAuth state provider redirect is invalid"); - } - const localRedirectUri = validateNotionRedirectUri(c.env, payload.local_redirect_uri); - const redirect = new URL(localRedirectUri); - redirect.searchParams.set("state", state); - const providerError = optionalCallbackString(query.error, "error", 256); - if (providerError) { - redirect.searchParams.set("error", providerError); - const description = optionalCallbackString(query.error_description, "error_description", 1024); - if (description) { - redirect.searchParams.set("error_description", description); - } - return localCallbackRedirect(redirect.toString()); - } - redirect.searchParams.set("code", callbackString(query.code, "code", 4096)); - return localCallbackRedirect(redirect.toString()); -}); -``` - -- [ ] **Step 8: Allow Notion exchange to use hosted exchange redirect URI** - -Replace the redirect validation line inside `/v1/oauth/notion/exchange`: - -```ts -const redirectUri = validateNotionRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); -``` - -with: - -```ts -const redirectUri = validateNotionExchangeRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); -``` - -Keep the existing session check: - -```ts -if (payload.connector !== "notion" || payload.state !== state || payload.redirect_uri !== redirectUri) { - throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); -} -``` - -- [ ] **Step 9: Run the hosted broker tests** - -Run: - -```bash -npm --prefix apps/oauth-service test -- app.test.ts -``` - -Expected: PASS. - -- [ ] **Step 10: Run the OAuth service checks** - -Run: - -```bash -npm --prefix apps/oauth-service run check -``` - -Expected: PASS for `tsc --noEmit` and `vitest run`. - -- [ ] **Step 11: Commit** - -Run: - -```bash -git add apps/oauth-service/src/types.ts apps/oauth-service/src/security/redirects.ts apps/oauth-service/src/app.ts apps/oauth-service/test/app.test.ts -git commit -m "feat(oauth): hand hosted notion callback to localhost" -``` - -Expected: commit succeeds. - ---- - -### Task 3: Teach The Rust Notion Client About Separate Redirect URIs - -**Files:** -- Modify: `crates/locality-notion/src/oauth.rs` -- Modify: `crates/loc-cli/src/commands.rs` -- Modify: `crates/loc-cli/tests/connect.rs` - -- [ ] **Step 1: Write failing Rust tests for hosted redirect fields** - -In `crates/locality-notion/src/oauth.rs`, add this test near the existing broker start response tests: - -```rust - #[test] - fn broker_start_response_uses_hosted_authorization_redirect_when_present() { - let start = NotionOAuthBrokerStartResponse { - connector: "notion".to_string(), - client_id: "client-id".to_string(), - authorization_url: "https://api.notion.com/v1/oauth/authorize?client_id=wrong" - .to_string(), - redirect_uri: "http://localhost:8757/oauth/notion/callback".to_string(), - authorization_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - .to_string(), - ), - exchange_redirect_uri: Some( - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - .to_string(), - ), - session: "session-1".to_string(), - state: "state-1".to_string(), - expires_in: 300, - }; - - let url = Url::parse(&start.normalized_authorization_url()).expect("normalized URL"); - - assert_eq!( - query_value(&url, "redirect_uri").as_deref(), - Some("https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback") - ); - assert_eq!( - start.local_redirect_uri(), - "http://localhost:8757/oauth/notion/callback" - ); - assert_eq!( - start.exchange_redirect_uri(), - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - ); - } -``` - -In `crates/loc-cli/tests/connect.rs`, change `FakeBrokerOAuthExchange` into a configurable fake: - -```rust -#[derive(Clone, Debug)] -struct FakeBrokerOAuthExchange { - expected_redirect_uri: &'static str, -} - -impl Default for FakeBrokerOAuthExchange { - fn default() -> Self { - Self { - expected_redirect_uri: "http://localhost:8757/oauth/notion/callback", - } - } -} -``` - -Then update its assertion: - -```rust - assert_eq!(request.redirect_uri, self.expected_redirect_uri); -``` - -Update the existing broker OAuth test setup: - -```rust - let exchange = FakeBrokerOAuthExchange::default(); -``` - -Add this test next to the existing Notion broker OAuth connect tests: - -```rust -#[test] -fn connect_notion_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect() { - let mut store = InMemoryStateStore::new(); - let credentials = InMemoryCredentialStore::new(); - - let report = run_connect_notion_broker_oauth( - &mut store, - &credentials, - BrokerOAuthConnectOptions { - connection_id: Some(ConnectionId::new("notion-hosted")), - broker_url: "https://afs-oauth-broker.saurabh-b07.workers.dev".to_string(), - client_id: "client-id".to_string(), - session: "broker-session".to_string(), - state: "state-1".to_string(), - code: "oauth-code".to_string(), - redirect_uri: "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" - .to_string(), - }, - &FakeBrokerOAuthExchange { - expected_redirect_uri: - "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - }, - ) - .expect("broker OAuth connect"); - - assert_eq!(report.connection_id, "notion-hosted"); - assert_eq!(report.auth_kind, "oauth"); - let saved = store - .get_connection(&ConnectionId::new("notion-hosted")) - .expect("get connection") - .expect("saved connection"); - assert_eq!(saved.auth_kind, "oauth"); - assert_eq!(saved.secret_ref, "connection:notion-hosted"); - let secret = credentials - .get("connection:notion-hosted") - .expect("credential saved"); - assert!(secret.contains("\"oauth_broker_url\":\"https://afs-oauth-broker.saurabh-b07.workers.dev\"")); - assert!(secret.contains("\"refresh_token_handle\":\"opaque-refresh-handle\"")); -} -``` - -- [ ] **Step 2: Run the failing Rust tests** - -Run: - -```bash -cargo test -p locality-notion broker_start_response_uses_hosted_authorization_redirect_when_present -cargo test -p loc-cli connect_notion_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect -``` - -Expected: the `locality-notion` test fails because the response struct has no hosted redirect fields or accessors. The `loc-cli` test may fail to compile until the fake exchange change is made. - -- [ ] **Step 3: Extend the Notion broker start response** - -Modify `NotionOAuthBrokerStartResponse` in `crates/locality-notion/src/oauth.rs`: - -```rust -#[derive(Clone, PartialEq, Eq, Deserialize)] -pub struct NotionOAuthBrokerStartResponse { - pub connector: String, - pub client_id: String, - pub authorization_url: String, - pub redirect_uri: String, - #[serde(default)] - pub authorization_redirect_uri: Option, - #[serde(default)] - pub exchange_redirect_uri: Option, - pub session: String, - pub state: String, - pub expires_in: u64, -} -``` - -Update its `fmt::Debug` implementation: - -```rust -impl fmt::Debug for NotionOAuthBrokerStartResponse { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("NotionOAuthBrokerStartResponse") - .field("connector", &self.connector) - .field("client_id", &self.client_id) - .field("authorization_url", &REDACTED) - .field("redirect_uri", &self.redirect_uri) - .field("authorization_redirect_uri", &self.authorization_redirect_uri) - .field("exchange_redirect_uri", &self.exchange_redirect_uri) - .field("session", &REDACTED) - .field("state", &REDACTED) - .field("expires_in", &self.expires_in) - .finish() - } -} -``` - -Update its methods: - -```rust -impl NotionOAuthBrokerStartResponse { - pub fn normalized_authorization_url(&self) -> String { - normalize_notion_authorization_url( - &self.authorization_url, - &self.client_id, - self.authorization_redirect_uri(), - &self.state, - ) - } - - pub fn local_redirect_uri(&self) -> &str { - &self.redirect_uri - } - - pub fn authorization_redirect_uri(&self) -> &str { - self.authorization_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } - - pub fn exchange_redirect_uri(&self) -> &str { - self.exchange_redirect_uri - .as_deref() - .unwrap_or(&self.redirect_uri) - } -} -``` - -Update the two existing struct literals found by: - -```bash -rg -n "NotionOAuthBrokerStartResponse \\{" crates/locality-notion/src/oauth.rs -``` - -Add these two fields to each existing test literal: - -```rust - authorization_redirect_uri: None, - exchange_redirect_uri: None, -``` - -Update the exact debug-output assertion in `crates/locality-notion/src/oauth.rs` to include the two new fields: - -```rust - assert_eq!( - format!("{start:?}"), - "NotionOAuthBrokerStartResponse { connector: \"notion\", client_id: \"client-id\", authorization_url: \"\", redirect_uri: \"http://localhost/callback\", authorization_redirect_uri: None, exchange_redirect_uri: None, session: \"\", state: \"\", expires_in: 300 }" - ); -``` - -- [ ] **Step 4: Use the hosted exchange redirect while listening locally** - -Modify `crates/loc-cli/src/commands.rs` in `run_connect_notion_command`. Keep the local listener on `start.redirect_uri`; only change the exchange option. - -Replace: - -```rust - let options = BrokerOAuthConnectOptions { - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - client_id: start.client_id, - session: start.session, - state: start.state, - code: authorization.code, - redirect_uri: start.redirect_uri, - }; -``` - -with: - -```rust - let exchange_redirect_uri = start.exchange_redirect_uri().to_string(); - let options = BrokerOAuthConnectOptions { - connection_id: flag_value(args, "--name").map(ConnectionId::new), - broker_url: broker_config.broker_url, - client_id: start.client_id, - session: start.session, - state: start.state, - code: authorization.code, - redirect_uri: exchange_redirect_uri, - }; -``` - -The local listener call remains: - -```rust - let authorization = match run_local_oauth_authorization( - "Notion", - &authorization_url, - &start.redirect_uri, - &start.state, - has_flag(args, "--no-browser"), - json, - ) { -``` - -- [ ] **Step 5: Run the focused Rust tests** - -Run: - -```bash -cargo test -p locality-notion broker_start_response_uses_hosted_authorization_redirect_when_present -cargo test -p loc-cli connect_notion_broker_oauth_can_store_local_credentials_after_hosted_exchange_redirect -``` - -Expected: PASS. - -- [ ] **Step 6: Run broader affected Rust tests** - -Run: - -```bash -cargo test -p locality-notion oauth::tests -cargo test -p loc-cli connect -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add crates/locality-notion/src/oauth.rs crates/loc-cli/src/commands.rs crates/loc-cli/tests/connect.rs -git commit -m "feat(cli): support hosted notion broker callback" -``` - -Expected: commit succeeds. - ---- - -### Task 4: Update OAuth Service Deployment Config And Documentation - -**Files:** -- Modify: `apps/oauth-service/wrangler.toml` -- Modify: `apps/oauth-service/README.md` -- Modify: `apps/oauth-service/docs/security.md` -- Modify: `docs/cli.md` -- Modify: `docs-site/cli-reference.mdx` - -- [ ] **Step 1: Update Worker environment config** - -Modify `[vars]` in `apps/oauth-service/wrangler.toml`. - -Replace the Notion redirect config: - -```toml -LOCALITY_NOTION_REDIRECT_URIS = "http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback,https://api.dev.locality.dev/v1/oauth/notion/callback" -``` - -with: - -```toml -LOCALITY_NOTION_REDIRECT_URIS = "http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback" -LOCALITY_NOTION_HOSTED_CALLBACK_URI = "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback" -``` - -Register that exact `LOCALITY_NOTION_HOSTED_CALLBACK_URI` value in the Notion public integration's OAuth redirect URI settings before deploying the Worker change. - -- [ ] **Step 2: Document the hosted handoff API response** - -Update `apps/oauth-service/README.md` under `POST /v1/oauth/notion/start`. - -Replace the response example with: - -```json -{ - "connector": "notion", - "client_id": "public-client-id", - "authorization_url": "https://api.notion.com/v1/oauth/authorize?...", - "redirect_uri": "http://localhost:8757/oauth/notion/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - "session": "signed-session", - "state": "signed-local-handoff-state", - "expires_in": 600 -} -``` - -Add this paragraph immediately below the example: - -```md -When `LOCALITY_NOTION_HOSTED_CALLBACK_URI` is set, `redirect_uri` remains the local loopback URI where the CLI listens. `authorization_redirect_uri` and `exchange_redirect_uri` are the HTTPS provider callback URI registered with Notion. The browser first returns to the broker callback, and the broker redirects the browser to `redirect_uri` with the provider code or error. The CLI then exchanges the code using `exchange_redirect_uri`, so Notion sees the same redirect URI during authorization and token exchange. -``` - -Add this API section after the Notion start section: - -```md -### `GET /v1/oauth/notion/callback` - -This browser-facing route is used only when `LOCALITY_NOTION_HOSTED_CALLBACK_URI` -is configured. It accepts Notion's `code` and `state`, verifies the signed -local-handoff state, and returns `303 See Other` to the loopback callback held -inside that state. - -Success redirects to: - -```text -http://localhost:8757/oauth/notion/callback?state=...&code=... -``` - -Provider denial redirects to: - -```text -http://localhost:8757/oauth/notion/callback?state=...&error=access_denied&error_description=... -``` - -The route sets `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. -It does not persist provider codes, tokens, refresh handles, or local callback -URIs. -``` - -- [ ] **Step 3: Document redirect security boundaries** - -Update `apps/oauth-service/docs/security.md` under `## Redirects`. - -Replace the current section body with: - -```md -The broker keeps two Notion redirect boundaries separate: - -- `LOCALITY_NOTION_REDIRECT_URIS` is a loopback-only allowlist for local CLI callbacks such as `http://localhost:8757/oauth/notion/callback`. -- `LOCALITY_NOTION_HOSTED_CALLBACK_URI` is one exact HTTPS callback served by this broker at `/v1/oauth/notion/callback`. - -When hosted handoff is enabled, the Notion authorization request uses the hosted -callback URI. The callback route verifies a signed state payload before -redirecting to a loopback URI from the allowlist. The token exchange also uses -the hosted callback URI so the provider sees the same redirect URI in both OAuth -steps. - -Google Docs, Google Calendar, Gmail, and Slack continue to accept only their -configured loopback callback URLs in this implementation. -``` - -- [ ] **Step 4: Document the CLI-visible behavior** - -Update the Notion OAuth paragraph in `docs/cli.md`. - -Replace: - -```md -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_NOTION_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default callback is `http://localhost:8757/oauth/notion/callback`; override it with `--redirect-uri ` or `LOCALITY_NOTION_OAUTH_REDIRECT_URI`. The redirect URI must be registered on the Notion public integration. -``` - -with: - -```md -The default broker is `https://afs-oauth-broker.saurabh-b07.workers.dev`; override it with `--broker-url `, `LOCALITY_NOTION_OAUTH_BROKER_URL`, or `LOCALITY_AUTH_BROKER_URL`. The default local callback is `http://localhost:8757/oauth/notion/callback`; override it with `--redirect-uri ` or `LOCALITY_NOTION_OAUTH_REDIRECT_URI`. In production the broker may use its own HTTPS provider callback registered on the Notion public integration, then hand the browser back to the local callback. The command still stores the resulting OAuth credential locally. -``` - -Make the same Notion paragraph replacement in `docs-site/cli-reference.mdx`. - -- [ ] **Step 5: Run docs and service checks** - -Run: - -```bash -npm --prefix apps/oauth-service run check -cargo test -p locality-notion oauth::tests -cargo test -p loc-cli connect -``` - -Expected: all commands PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add apps/oauth-service/wrangler.toml apps/oauth-service/README.md apps/oauth-service/docs/security.md docs/cli.md docs-site/cli-reference.mdx -git commit -m "docs(oauth): describe hosted notion handoff" -``` - -Expected: commit succeeds. - ---- - -### Task 5: End-To-End Verification And Deployment Gate - -**Files:** -- No source edits expected in this task. - -- [ ] **Step 1: Run the complete OAuth service suite** - -Run: - -```bash -npm --prefix apps/oauth-service run check -``` - -Expected: PASS. - -- [ ] **Step 2: Run focused Rust suites** - -Run: - -```bash -cargo test -p locality-notion oauth::tests -cargo test -p loc-cli connect -``` - -Expected: PASS. - -- [ ] **Step 3: Run repo-level checks that include OAuth service** - -Run: - -```bash -make check-oauth-service -``` - -Expected: PASS. - -- [ ] **Step 4: Run a local Worker smoke test** - -Prepare local Worker variables. `apps/oauth-service/.dev.vars` is ignored by git. If the file already exists, preserve it first: - -```bash -if [ -f apps/oauth-service/.dev.vars ]; then - cp apps/oauth-service/.dev.vars apps/oauth-service/.dev.vars.before-hosted-handoff-smoke -fi -cat > apps/oauth-service/.dev.vars <<'EOF' -LOCALITY_BROKER_SESSION_SECRET=test-session-secret-with-enough-entropy -LOCALITY_REFRESH_HANDLE_KEY=test-refresh-handle-key-with-enough-entropy -LOCALITY_TOKEN_MODE=handle -LOCALITY_NOTION_CLIENT_ID=notion-client-id -LOCALITY_NOTION_CLIENT_SECRET=notion-client-secret -LOCALITY_NOTION_REDIRECT_URIS=http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback -LOCALITY_NOTION_HOSTED_CALLBACK_URI=https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -EOF -``` - -Start the Worker in one terminal: - -```bash -npm --prefix apps/oauth-service run dev -``` - -In another terminal, request a start session: - -```bash -curl --fail --silent --show-error \ - --request POST \ - --header 'content-type: application/json' \ - --data '{"redirect_uri":"http://localhost:8757/oauth/notion/callback"}' \ - http://127.0.0.1:8787/v1/oauth/notion/start | jq . -``` - -Expected JSON shape: - -```json -{ - "connector": "notion", - "client_id": "notion-client-id", - "authorization_url": "https://api.notion.com/v1/oauth/authorize?...", - "redirect_uri": "http://localhost:8757/oauth/notion/callback", - "authorization_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - "exchange_redirect_uri": "https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback", - "session": "...", - "state": "...", - "expires_in": 600 -} -``` - -The `authorization_url` must contain: - -```text -redirect_uri=https%3A%2F%2Fafs-oauth-broker.saurabh-b07.workers.dev%2Fv1%2Foauth%2Fnotion%2Fcallback -``` - -- [ ] **Step 5: Run a local CLI smoke test against the dev Worker** - -With the Worker still running, run: - -```bash -LOCALITY_NOTION_OAUTH_BROKER_URL=http://127.0.0.1:8787 \ - cargo run -p loc-cli -- connect notion --name hosted-handoff-smoke --no-browser -``` - -Expected: - -- The command prints an authorization URL instead of opening the browser. -- The printed authorization URL uses the hosted HTTPS callback in its `redirect_uri` query parameter. -- The command waits for `http://localhost:8757/oauth/notion/callback`. -- Stop the command with Ctrl-C after verifying the URL and listener output. - -Stop the Worker and restore any previous local variables: - -```bash -rm -f apps/oauth-service/.dev.vars -if [ -f apps/oauth-service/.dev.vars.before-hosted-handoff-smoke ]; then - mv apps/oauth-service/.dev.vars.before-hosted-handoff-smoke apps/oauth-service/.dev.vars -fi -``` - -- [ ] **Step 6: Verify provider registration before deploy** - -Open the Notion public integration settings and verify the OAuth redirect URI list contains exactly this URI for the Worker deployment in `apps/oauth-service/wrangler.toml`: - -```text -https://afs-oauth-broker.saurabh-b07.workers.dev/v1/oauth/notion/callback -``` - -The Notion redirect URI list no longer needs either loopback HTTP URI for the production public integration when hosted handoff is enabled. - -- [ ] **Step 7: Commit any verification-only documentation correction** - -If the smoke test finds that the deployed OAuth service origin differs from `https://afs-oauth-broker.saurabh-b07.workers.dev`, update these files with the exact deployed origin before deployment: - -```text -apps/oauth-service/wrangler.toml -apps/oauth-service/README.md -apps/oauth-service/docs/security.md -docs/cli.md -docs-site/cli-reference.mdx -``` - -Then run: - -```bash -npm --prefix apps/oauth-service run check -cargo test -p locality-notion oauth::tests -cargo test -p loc-cli connect -git add apps/oauth-service/wrangler.toml apps/oauth-service/README.md apps/oauth-service/docs/security.md docs/cli.md docs-site/cli-reference.mdx -git commit -m "chore(oauth): align hosted callback origin" -``` - -Expected: checks pass and commit succeeds when a correction was needed. If no correction was needed, leave the worktree unchanged. - ---- - -## Self-Review - -Spec coverage: - -- HTTPS provider callback requirement: Task 2 and Task 4. -- Keep local credentials and the current CLI flow: Task 3 and Task 5. -- OAuth service is already deployed with TLS: Task 4 uses the deployed Worker callback URI and keeps internal API out of the local-credential path. -- Provider token exchange uses the same redirect URI as authorization: Task 2 and Task 3. -- No backend-hosted credential migration: excluded in Scope and no private backend files are modified. - -Placeholder scan: - -- No placeholder markers remain in the plan. -- Every code-changing step includes concrete code or exact replacements. -- Every verification step has an exact command and expected result. - -Type consistency: - -- `authorization_redirect_uri` and `exchange_redirect_uri` are snake_case JSON fields in the Worker and Rust response struct. -- Rust accessors are `authorization_redirect_uri()`, `exchange_redirect_uri()`, and `local_redirect_uri()`. -- Worker env field is consistently `LOCALITY_NOTION_HOSTED_CALLBACK_URI`. diff --git a/docs/superpowers/specs/2026-07-29-all-oauth-hosted-handoff-design.md b/docs/superpowers/specs/2026-07-29-all-oauth-hosted-handoff-design.md deleted file mode 100644 index f7058747..00000000 --- a/docs/superpowers/specs/2026-07-29-all-oauth-hosted-handoff-design.md +++ /dev/null @@ -1,171 +0,0 @@ -# All OAuth Hosted Handoff Design - -## Goal - -Every Locality OAuth broker connector should use the same browser handoff model: -the OAuth provider redirects to the deployed TLS broker, the broker verifies a -signed local-handoff state, then the browser is redirected back to the local -loopback listener. The CLI and desktop flows continue to store OAuth credentials -locally. - -## Scope - -This extends the hosted handoff that currently exists for Notion to all OAuth -broker connectors: - -- Notion: `/v1/oauth/notion/callback` -- Google Docs: `/v1/oauth/google-docs/callback` -- Google Calendar: `/v1/oauth/google-calendar/callback` -- Gmail: `/v1/oauth/gmail/callback` -- Slack: `/v1/oauth/slack/callback` - -The internal/private backend remains out of scope. This change does not move -provider access tokens, refresh handles, or local credential state into a -backend service. - -## Configuration - -Loopback allowlists stay separate from hosted provider callbacks. - -Existing `*_REDIRECT_URIS` variables remain local-loopback allowlists for the -callback URI where the CLI or desktop listener waits: - -- `LOCALITY_NOTION_REDIRECT_URIS` -- `LOCALITY_GOOGLE_DOCS_REDIRECT_URIS` -- `LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS` -- `LOCALITY_GMAIL_REDIRECT_URIS` -- `LOCALITY_SLACK_REDIRECT_URIS` - -New hosted callback variables configure the exact HTTPS callback URI registered -with each provider app: - -- `LOCALITY_NOTION_HOSTED_CALLBACK_URI` -- `LOCALITY_GOOGLE_DOCS_HOSTED_CALLBACK_URI` -- `LOCALITY_GOOGLE_CALENDAR_HOSTED_CALLBACK_URI` -- `LOCALITY_GMAIL_HOSTED_CALLBACK_URI` -- `LOCALITY_SLACK_HOSTED_CALLBACK_URI` - -Each hosted URI must be HTTPS, contain no userinfo, port, query, or fragment, -and match the connector callback path exactly. - -## Broker API Behavior - -Each connector start endpoint continues to accept a requested local loopback -`redirect_uri`. When the connector has no hosted callback configured, behavior -falls back to the current direct-loopback broker flow. - -When the connector has a hosted callback configured, `/start` returns: - -- `redirect_uri`: the validated local loopback callback where the client listens -- `authorization_redirect_uri`: the hosted HTTPS provider callback -- `exchange_redirect_uri`: the hosted HTTPS provider callback -- `state`: a signed local-handoff state payload -- `session`: a signed broker session bound to the same state and - `exchange_redirect_uri` - -The provider authorization URL uses `authorization_redirect_uri`. - -Each connector gets a browser-facing `GET /v1/oauth//callback` -route. It accepts provider `code` or `error` fields, verifies the signed -local-handoff state, checks that the state connector and provider callback match -the route/configuration, validates the local callback against the connector -loopback allowlist, and redirects with `303 See Other` to the local callback. - -The callback route sets: - -- `Cache-Control: no-store` -- `Referrer-Policy: no-referrer` - -It does not persist provider codes, tokens, refresh handles, or local callback -URIs. - -Each connector exchange endpoint accepts the connector hosted callback URI when -it matches configuration, otherwise accepts only the configured local loopback -URI. The signed session check still requires connector, state, and redirect URI -to match before any upstream provider exchange runs. - -## Rust Client Behavior - -The shared broker start response type should understand optional -`authorization_redirect_uri` and `exchange_redirect_uri` fields. The response -should expose helpers for: - -- local listener redirect URI -- authorization redirect URI -- exchange redirect URI - -Existing broker responses without those fields remain compatible and fall back -to `redirect_uri`. - -Notion keeps its existing Notion-specific authorization URL normalization, but -the shared Google Docs, Google Calendar, Gmail, and Slack flows should use the -hosted authorization URL returned by the broker and pass the hosted -`exchange_redirect_uri` to the broker exchange when present. - -## CLI And Desktop Behavior - -For every broker-backed OAuth connector: - -1. The client calls `/start` with the local loopback callback. -2. The browser opens the returned authorization URL. -3. The local listener waits on `redirect_uri`. -4. The hosted broker callback redirects the browser back to that local listener. -5. The client exchanges the code using `exchange_redirect_uri`. -6. The returned credential is stored in the local credential store. - -This applies to CLI and desktop connection flows. - -## Provider Registration - -Production provider apps must register the hosted callback URI for their -connector paths on the deployed broker. The Google OAuth app must register the -Google Docs, Google Calendar, and Gmail hosted callback paths. Slack must -register the Slack hosted callback path. Notion must register the Notion hosted -callback path. - -Local loopback callbacks remain relevant for local listener allowlists and for -developer-owned direct OAuth apps, but they are no longer the production public -app redirect URIs when hosted handoff is enabled. - -## Error Handling And Security - -Malformed, expired, unsigned, wrong-connector, wrong-provider-callback, and -unallowlisted-local-callback states are rejected before redirecting to any local -URI. - -Hosted callback validation rejects explicit default ports such as `:443`. -Exchange requests with arbitrary redirect URIs are rejected before any upstream -provider token request. - -Provider denial is redirected back to the local listener with `error`, -optional `error_description`, and `state`, and without `code`. - -## Testing - -OAuth service tests should cover each connector: - -- hosted `/start` returns local, authorization, and exchange redirect fields -- authorization URL uses the hosted callback -- hosted `/callback` redirects successful provider codes to localhost -- hosted `/callback` redirects provider denial to localhost -- unsigned or wrong signed state is rejected -- exchange uses the hosted redirect URI upstream -- arbitrary exchange redirect URIs are rejected - -Rust tests should cover: - -- shared response fallback for older broker responses -- shared response hosted redirect helpers -- CLI connector exchange paths still store credentials locally when using hosted - exchange redirect URIs - -Verification should include the OAuth service suite, affected Rust OAuth/connect -tests, desktop check, docs checks, and a local Worker smoke test for at least two -connectors from different providers, including one Google connector. - -## Non-Goals - -- No private backend OAuth credential storage. -- No single shared `/v1/oauth/callback` route. -- No provider support beyond the existing OAuth broker connectors. -- No change to direct OAuth development flows except documentation clarity.