From 31e1b75abde3ee371a3a4361e0959c826460d3a4 Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Wed, 12 Aug 2026 18:17:07 -0400 Subject: [PATCH] Gate the OAuth scope behind a wrangler var OAUTH_SCOPE was a hardcoded constant; it is now an optional env var falling back to 'atproto transition:generic'. The granular value ('atproto include:page.mooring.authSite blob:image/*') sits commented in wrangler.jsonc: once the lexicons are published and _lexicon.mooring.page resolves, the flip is a config deploy, and a PDS that can't resolve the permission set is handled by reverting the var rather than a code rollback. Co-Authored-By: Claude Fable 5 --- apps/web/src/app.d.ts | 2 ++ apps/web/src/lib/server/oauth/client.ts | 25 +++++++++++++++------ apps/web/src/lib/server/oauth/oauth.test.ts | 17 ++++++++++++++ apps/web/src/routes/login/+page.server.ts | 8 ++++--- apps/web/wrangler.jsonc | 5 +++++ 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 42dc4e5..e9777bb 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -22,6 +22,8 @@ declare global { CLOUDFLARE_ZONE_ID?: string; CLOUDFLARE_API_TOKEN?: string; OAUTH_PRIVATE_KEY_JWK?: string; + /** OAuth scope override; unset requests the default scope. */ + OAUTH_SCOPE?: string; SESSION_SECRET?: string; }; } diff --git a/apps/web/src/lib/server/oauth/client.ts b/apps/web/src/lib/server/oauth/client.ts index 08edc03..f8c8795 100644 --- a/apps/web/src/lib/server/oauth/client.ts +++ b/apps/web/src/lib/server/oauth/client.ts @@ -4,10 +4,20 @@ import { importablePrivateKeyJwk } from './private-key'; import { D1SimpleStore } from './stores'; import { HANDLE_RESOLVER_URL, workersDidResolver, workersFetch } from './workers-compat'; -// TODO: switch to 'atproto include:page.mooring.authSite blob:image/*' once -// the page.mooring.* lexicons are published on-network. blob:image/* must be -// requested separately — permission sets cannot carry blob scopes. -export const OAUTH_SCOPE = 'atproto transition:generic'; +/** + * Scope used when the OAUTH_SCOPE var is unset. The granular scope + * ('atproto include:page.mooring.authSite blob:image/*' — blob scopes must be + * requested separately, permission sets cannot carry them) only works on + * PDSes that can resolve the published permission set, so the flip to it is + * a config change (wrangler.jsonc vars), deployable and revertable without + * touching code. + */ +export const DEFAULT_OAUTH_SCOPE = 'atproto transition:generic'; + +/** The OAuth scope this deployment requests. */ +export function oauthScope(env: App.Platform['env']): string { + return env.OAUTH_SCOPE ?? DEFAULT_OAUTH_SCOPE; +} /** * Build the OAuth client for this deployment. @@ -23,6 +33,7 @@ export async function createOAuthClient( env: App.Platform['env'], origin: string ): Promise { + const scope = oauthScope(env); let clientMetadata: NodeOAuthClientOptions['clientMetadata']; let keyset: NodeOAuthClientOptions['keyset']; @@ -43,7 +54,7 @@ export async function createOAuthClient( redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], - scope: OAUTH_SCOPE, + scope, application_type: 'web', token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', @@ -57,10 +68,10 @@ export async function createOAuthClient( clientMetadata = { client_id: `http://localhost?${new URLSearchParams({ redirect_uri: redirectUri, - scope: OAUTH_SCOPE + scope })}`, redirect_uris: [redirectUri], - scope: OAUTH_SCOPE, + scope, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', diff --git a/apps/web/src/lib/server/oauth/oauth.test.ts b/apps/web/src/lib/server/oauth/oauth.test.ts index bb910e8..668c449 100644 --- a/apps/web/src/lib/server/oauth/oauth.test.ts +++ b/apps/web/src/lib/server/oauth/oauth.test.ts @@ -74,4 +74,21 @@ describe('createOAuthClient', () => { 'http://127.0.0.1:5173/oauth/callback' ); }); + + it('requests the default scope when OAUTH_SCOPE is unset', async () => { + const client = await createOAuthClient({ DB: {} as D1Database }, 'http://localhost:5173'); + expect(client.clientMetadata.scope).toBe('atproto transition:generic'); + }); + + it('requests the OAUTH_SCOPE var when set', async () => { + const granular = 'atproto include:page.mooring.authSite blob:image/*'; + const client = await createOAuthClient( + { DB: {} as D1Database, OAUTH_SCOPE: granular }, + 'http://localhost:5173' + ); + expect(client.clientMetadata.scope).toBe(granular); + expect(client.clientMetadata.client_id).toContain( + new URLSearchParams({ scope: granular }).toString() + ); + }); }); diff --git a/apps/web/src/routes/login/+page.server.ts b/apps/web/src/routes/login/+page.server.ts index afccf54..1849cc4 100644 --- a/apps/web/src/routes/login/+page.server.ts +++ b/apps/web/src/routes/login/+page.server.ts @@ -1,9 +1,9 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions } from './$types'; -import { OAUTH_SCOPE } from '$lib/server/oauth/client'; +import { oauthScope } from '$lib/server/oauth/client'; export const actions: Actions = { - default: async ({ request, locals }) => { + default: async ({ request, locals, platform }) => { const form = await request.formData(); const handle = String(form.get('handle') ?? '').trim(); if (!handle) { @@ -12,7 +12,9 @@ export const actions: Actions = { let authorizationUrl: URL; try { - authorizationUrl = await locals.oauth.authorize(handle, { scope: OAUTH_SCOPE }); + authorizationUrl = await locals.oauth.authorize(handle, { + scope: oauthScope(platform!.env) + }); } catch (err) { console.error('authorize failed', err); return fail(400, { diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index ab858d0..b7cb7e0 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -20,6 +20,11 @@ // set here as a var. // Both unset = provisioning skipped; domains still verify and serve. "CLOUDFLARE_ZONE_ID": "59558dba16a1fbf920d86f627d1d5822" + // OAUTH_SCOPE — unset requests 'atproto transition:generic'. Uncomment + // once the page.mooring.* lexicons are published on-network and + // _lexicon.mooring.page points at the authority DID; revert to roll + // back if a PDS can't resolve the permission set. + // "OAUTH_SCOPE": "atproto include:page.mooring.authSite blob:image/*" }, // The apex and www are Workers custom domains (exact hostnames, DNS and // certs managed by Cloudflare). Everything else binds through this route: -- 2.51.2