From 46469b1245da4e1cd9d8842e6972cda74300716a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:15:28 +0000 Subject: [PATCH] Granular OAuth scope picker (replaces transition:generic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-step sign-in flow: after entering a handle, the user picks which write-side permissions to grant (create / update / delete / blob upload) — all checked by default. The runtime scope passed to client.signIn() is built from the selection, replacing the broad transition:generic legacy scope. The metadata endpoint advertises the superset of granular scopes so any subset the user picks is valid under PAR. Reads stay implicit since records in the user's own repo are public. Modeled on pdsls.dev's flow. https://claude.ai/code/session_017YxfAzFqAcWiafJkQ5gAfk --- src/app/oauth-client-metadata.json/route.ts | 4 +- src/components/AtprotoSessionProvider.tsx | 14 +- src/components/SessionMenu.tsx | 57 ++++-- src/components/account/AccountPage.tsx | 129 +++++++----- src/components/explore/SignInPanel.tsx | 68 +++++-- src/components/oauth/ScopeSelector.tsx | 205 ++++++++++++++++++++ src/lib/oauth/client.ts | 5 +- src/lib/oauth/scopes.ts | 68 +++++++ 8 files changed, 455 insertions(+), 95 deletions(-) create mode 100644 src/components/oauth/ScopeSelector.tsx create mode 100644 src/lib/oauth/scopes.ts diff --git a/src/app/oauth-client-metadata.json/route.ts b/src/app/oauth-client-metadata.json/route.ts index 7aeee93..42f0d0a 100644 --- a/src/app/oauth-client-metadata.json/route.ts +++ b/src/app/oauth-client-metadata.json/route.ts @@ -12,11 +12,11 @@ */ import { NextResponse } from 'next/server'; +import { METADATA_SCOPE } from '@/lib/oauth/scopes'; export const runtime = 'edge'; export const dynamic = 'force-dynamic'; -const OAUTH_SCOPE = 'atproto transition:generic'; const REDIRECT_PATH = '/oauth/callback'; function isAllowedHost(hostname: string): boolean { @@ -46,7 +46,7 @@ export async function GET(request: Request): Promise { tos_uri: `${origin}/terms`, policy_uri: `${origin}/terms`, redirect_uris: [`${origin}${REDIRECT_PATH}`], - scope: OAUTH_SCOPE, + scope: METADATA_SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', diff --git a/src/components/AtprotoSessionProvider.tsx b/src/components/AtprotoSessionProvider.tsx index 8856e63..6389c4e 100644 --- a/src/components/AtprotoSessionProvider.tsx +++ b/src/components/AtprotoSessionProvider.tsx @@ -11,7 +11,8 @@ import { } from 'react'; import type { Agent } from '@atproto/api'; import type { OAuthSession } from '@atproto/oauth-client-browser'; -import { getOauthClient, getOauthEvents, OAUTH_SCOPE } from '@/lib/oauth/client'; +import { getOauthClient, getOauthEvents } from '@/lib/oauth/client'; +import { METADATA_SCOPE } from '@/lib/oauth/scopes'; type SessionContextValue = { session: OAuthSession | null; @@ -19,7 +20,12 @@ type SessionContextValue = { did: string | null; loading: boolean; error: Error | null; - signIn: (input: string) => Promise; + /** + * Kick off the OAuth flow. `scope` is the runtime-requested scope string + * (must be a subset of METADATA_SCOPE); defaults to the full superset + * if the caller doesn't pass one (e.g. legacy entry points). + */ + signIn: (input: string, scope?: string) => Promise; signOut: () => Promise; }; @@ -91,9 +97,9 @@ export function AtprotoSessionProvider({ children }: { children: ReactNode }) { }; }, [session]); - const signIn = useCallback(async (input: string) => { + const signIn = useCallback(async (input: string, scope?: string) => { const client = await getOauthClient(); - await client.signIn(input, { scope: OAUTH_SCOPE }); + await client.signIn(input, { scope: scope ?? METADATA_SCOPE }); }, []); const signOut = useCallback(async () => { diff --git a/src/components/SessionMenu.tsx b/src/components/SessionMenu.tsx index 28142e3..4f979e1 100644 --- a/src/components/SessionMenu.tsx +++ b/src/components/SessionMenu.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { ChevronDown, LogIn, LogOut, Settings, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from './AtprotoSessionProvider'; +import ScopeSelector from './oauth/ScopeSelector'; import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; import { encodeRepo } from '@/utils/atproto/urls'; @@ -22,17 +23,27 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant const { session, did, signIn, signOut, loading } = useAtprotoSession(); const [open, setOpen] = useState(false); const [signInInput, setSignInInput] = useState(''); + const [step, setStep] = useState<'handle' | 'scopes'>('handle'); + const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [profile, setProfile] = useState(null); const rootRef = useRef(null); + function closePopover() { + setOpen(false); + setStep('handle'); + setPendingAccount(''); + setBusy(false); + setError(null); + } + // Close on outside click. useEffect(() => { if (!open) return; function onClick(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { - setOpen(false); + closePopover(); } } document.addEventListener('mousedown', onClick); @@ -62,7 +73,7 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant
- {open && ( + {open && step === 'handle' && ( { + onSubmit={() => { const v = signInInput.trim(); if (!v) return; - setBusy(true); setError(null); - try { - await signIn(v); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } + setPendingAccount(v); + setStep('scopes'); }} /> )} + {open && step === 'scopes' && ( +
+
+ { + setStep('handle'); + setError(null); + }} + onContinue={async (scopeString) => { + setBusy(true); + setError(null); + try { + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> +
+
+ )}
); } @@ -215,7 +246,7 @@ function SignInPopover({ Sign in with your atproto handle
- We’ll redirect you to your PDS to authorize Aturi. + We’ll ask which permissions to grant before redirecting.
- {busy ? 'Redirecting…' : 'Continue →'} + {busy ? 'Redirecting…' : 'Next →'} {error && (

diff --git a/src/components/account/AccountPage.tsx b/src/components/account/AccountPage.tsx index 5cc3edd..f94664e 100644 --- a/src/components/account/AccountPage.tsx +++ b/src/components/account/AccountPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { CheckCircle2, CircleAlert, Loader2, LogOut, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; +import ScopeSelector from '@/components/oauth/ScopeSelector'; import { usePreferences } from '@/components/PreferencesProvider'; import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; import { encodeRepo } from '@/utils/atproto/urls'; @@ -14,6 +15,8 @@ export default function AccountPage() { const { session, did, signIn, signOut, loading } = useAtprotoSession(); const { pdsSync } = usePreferences(); const [input, setInput] = useState(''); + const [step, setStep] = useState<'handle' | 'scopes'>('handle'); + const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [profile, setProfile] = useState(null); const [error, setError] = useState(null); @@ -52,62 +55,81 @@ export default function AccountPage() { Reorder waypoints, hide ones you don’t use, or add your own. Your preferences sync to your PDS so they follow you across devices.

- { - e.preventDefault(); - const v = input.trim(); - if (!v) return; - setBusy(true); - setError(null); - try { - await signIn(v); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } - }} - style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }} - > - setInput(e.target.value)} - disabled={busy} + {step === 'handle' ? ( + { + e.preventDefault(); + const v = input.trim(); + if (!v) return; + setError(null); + setPendingAccount(v); + setStep('scopes'); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }} + > + setInput(e.target.value)} + style={{ + padding: '0.75rem 0.875rem', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-medium)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.9rem', + outline: 'none', + }} + /> + +
+ ) : ( +
- - {error && ( -

- {error} -

- )} - + { + setStep('handle'); + setError(null); + }} + onContinue={async (scopeString) => { + setBusy(true); + setError(null); + try { + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> +
+ )}

- You’ll be redirected to your PDS to authorize Aturi. We only request the - permissions needed to read and write your preferences record. + You’ll be redirected to your PDS to authorize Aturi. Uncheck any + permission above you don’t want to grant — reads always work + since your repo is public.

); diff --git a/src/components/explore/SignInPanel.tsx b/src/components/explore/SignInPanel.tsx index 05efc94..4894fc7 100644 --- a/src/components/explore/SignInPanel.tsx +++ b/src/components/explore/SignInPanel.tsx @@ -3,35 +3,64 @@ import { useState } from 'react'; import { LogIn } from 'lucide-react'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; +import ScopeSelector from '@/components/oauth/ScopeSelector'; /** * Compact sign-in form used inside the record view's action row. Accepts a - * handle / DID; redirects out to the user's OAuth provider. + * handle / DID; redirects out to the user's OAuth provider via a two-step + * flow that lets the user pick which permissions to grant. */ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) { const { signIn } = useAtprotoSession(); const [value, setValue] = useState(defaultInput || ''); + const [step, setStep] = useState<'handle' | 'scopes'>('handle'); + const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const v = value.trim(); - if (!v) return; - setBusy(true); - setError(null); - try { - await signIn(v); - // signIn redirects; this is unreachable in normal flow. - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } + if (step === 'scopes') { + return ( +
+ { + setStep('handle'); + setError(null); + }} + onContinue={async (scopeString) => { + setBusy(true); + setError(null); + try { + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> +
+ ); } return (
{ + e.preventDefault(); + const v = value.trim(); + if (!v) return; + setError(null); + setPendingAccount(v); + setStep('scopes'); + }} style={{ display: 'flex', flexWrap: 'wrap', @@ -46,7 +75,6 @@ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) placeholder="handle or DID" value={value} onChange={(e) => setValue(e.target.value)} - disabled={busy} style={{ flex: '1 1 220px', minWidth: 0, @@ -61,7 +89,7 @@ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) /> {error && (

void; + onContinue: (scopeString: string) => void | Promise; +} + +/** + * Step 2 of the sign-in flow: granular permission picker. + * + * All four write-side actions default to checked; users can opt out of + * individual ones to grant a narrower scope than `repo:*`. The atproto + * OAuth consent screen at the user's PDS will then only show / authorize + * the subset they actually requested. + */ +export default function ScopeSelector({ + account, + busy, + error, + onBack, + onContinue, +}: Props) { + const [selected, setSelected] = useState>( + new Set(ALL_SCOPE_IDS), + ); + + function toggle(id: ScopeId) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( + { + e.preventDefault(); + void onContinue(buildScopeString(selected)); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }} + > +

+ +
+
+ Select permissions +
+
+ for {account} +
+
+
+ +
    + {GRANULAR_SCOPES.map((scope) => { + const checked = selected.has(scope.id); + return ( +
  • + +
  • + ); + })} +
+ + + +

+ Reading records is always allowed — your repo is public. +

+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function backButtonStyle(): React.CSSProperties { + return { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-subtle)', + color: 'var(--text-secondary)', + cursor: 'pointer', + flexShrink: 0, + }; +} + +function rowStyle(checked: boolean, busy?: boolean): React.CSSProperties { + return { + display: 'flex', + alignItems: 'flex-start', + gap: '0.625rem', + padding: '0.5rem 0.625rem', + background: checked ? 'var(--bg-tertiary)' : 'transparent', + border: '1px solid', + borderColor: checked ? 'var(--border-medium)' : 'var(--border-subtle)', + cursor: busy ? 'wait' : 'pointer', + }; +} diff --git a/src/lib/oauth/client.ts b/src/lib/oauth/client.ts index 11df0f8..f50b9f1 100644 --- a/src/lib/oauth/client.ts +++ b/src/lib/oauth/client.ts @@ -17,6 +17,7 @@ */ import type { BrowserOAuthClient } from '@atproto/oauth-client-browser'; +import { METADATA_SCOPE } from './scopes'; let client: BrowserOAuthClient | null = null; let pending: Promise | null = null; @@ -27,8 +28,6 @@ let pending: Promise | null = null; // EventTarget is a global in Node 18+ too, so this is safe SSR-side. const events = new EventTarget(); -export const OAUTH_SCOPE = 'atproto transition:generic'; - export function getOauthEvents(): EventTarget { return events; } @@ -90,7 +89,7 @@ export async function getOauthClient(): Promise { tos_uri: `${origin}/terms`, policy_uri: `${origin}/terms`, redirect_uris: [`${origin}${redirectPath}`], - scope: OAUTH_SCOPE, + scope: METADATA_SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', diff --git a/src/lib/oauth/scopes.ts b/src/lib/oauth/scopes.ts new file mode 100644 index 0000000..20e8d25 --- /dev/null +++ b/src/lib/oauth/scopes.ts @@ -0,0 +1,68 @@ +/** + * OAuth scope definitions and helpers. + * + * Aturi advertises a superset of granular per-action scopes in its OAuth + * client metadata and then lets users opt out of individual permissions + * at sign-in time. Per the atproto OAuth PAR spec, the runtime-requested + * scope must be a subset of what the metadata advertises — so the + * metadata string is the union of every granular scope below. + * + * Reads aren't gated by a scope (records in the user's own repo are + * public), so the picker only exposes write-side actions. + */ + +export type ScopeId = 'create' | 'update' | 'delete' | 'blob'; + +export type GranularScope = { + id: ScopeId; + scope: string; + label: string; + hint: string; +}; + +export const GRANULAR_SCOPES: GranularScope[] = [ + { + id: 'create', + scope: 'repo:*?action=create', + label: 'Create records', + hint: 'Add new records to any collection in your repo.', + }, + { + id: 'update', + scope: 'repo:*?action=update', + label: 'Update records', + hint: 'Edit existing records (Record Editor, preferences).', + }, + { + id: 'delete', + scope: 'repo:*?action=delete', + label: 'Delete records', + hint: 'Remove records from your repo.', + }, + { + id: 'blob', + scope: 'blob:*/*', + label: 'Upload blobs', + hint: 'Upload images and other media attachments.', + }, +]; + +export const BASE_SCOPE = 'atproto'; + +/** Superset string baked into oauth-client-metadata.json. */ +export const METADATA_SCOPE = [ + BASE_SCOPE, + ...GRANULAR_SCOPES.map((s) => s.scope), +].join(' '); + +export const ALL_SCOPE_IDS: ReadonlySet = new Set( + GRANULAR_SCOPES.map((s) => s.id), +); + +/** Build the runtime scope string from a set of selected granular IDs. */ +export function buildScopeString(selected: Set): string { + const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map( + (s) => s.scope, + ); + return [BASE_SCOPE, ...granular].join(' '); +} -- 2.51.2