import { NextResponse } from 'next/server' import { Agent } from '@atproto/api' import { getOAuthClient } from '@/lib/oauth' import { createBridgeToken } from '@/lib/auth-bridge' import { parseAppState, normalizeReturnTo } from '@/lib/oauth-state' import { isAllowedUser } from '@/lib/beta' import { APPVIEW_URL } from '@/lib/appview' export async function GET(request: Request) { const { searchParams } = new URL(request.url) const appUrl = process.env.NEXT_PUBLIC_APP_URL! try { const client = await getOAuthClient() const { session, state } = await client.callback(searchParams) // Scope-upgrade flows carry typed app state with the DID that initiated // the upgrade. If the user switched accounts on the PDS consent screen, // refuse to swap the app session — that would be a confused-account bug. // Note: client.callback() has already exchanged the code, revoked any // prior stored session for the returned DID, and stored the new grant — // that cannot be undone here. On mismatch we only refuse to bind this // browser's iron session to the returned DID. Do not revoke the new grant // either: the returned DID may be a legitimate user of this app, and // revoking would be more destructive than leaving the stored grant alone. const upgradeState = parseAppState(state) if (upgradeState && session.did !== upgradeState.did) { console.error( `[oauth/callback] scope-upgrade DID mismatch: expected ${upgradeState.did}, got ${session.did}` ) const loginUrl = new URL('/login', appUrl) loginUrl.searchParams.set( 'error', 'Permission upgrade was completed with a different account. Please try again with the account you are signed in as.' ) return NextResponse.redirect(loginUrl.toString(), { status: 302 }) } // Beta allowlist check — if BETA_LIST_URI is set, only list members can log in if (!(await isAllowedUser(session.did))) { const loginUrl = new URL('/login', appUrl) loginUrl.searchParams.set('error', 'access_restricted') return NextResponse.redirect(loginUrl.toString(), { status: 302 }) } // Fetch handle + avatar via the public AppView. // Using the public agent (not the OAuth agent) is more reliable here — the // OAuth agent requires the session to be fully set up and the PDS to proxy // back to the AppView, which can fail. getProfile is public data. let handle: string | undefined let avatar: string | undefined try { const publicAgent = new Agent(APPVIEW_URL) const profile = await publicAgent.getProfile({ actor: session.did }) handle = profile.data.handle avatar = profile.data.avatar } catch { // Best-effort — DID alone is sufficient to establish a session } // Upgrades land back where the user was; fresh logins land on the homepage const returnTo = upgradeState ? normalizeReturnTo(upgradeState.returnTo) : '/' const token = createBridgeToken(session.did, handle, avatar, returnTo) const bridgeUrl = new URL('/api/oauth/bridge', appUrl) bridgeUrl.searchParams.set('token', token) return NextResponse.redirect(bridgeUrl.toString(), { status: 302 }) } catch (err) { console.error('[oauth/callback] failed:', err) const loginUrl = new URL('/login', appUrl) loginUrl.searchParams.set('error', 'Authentication failed. Please try again.') return NextResponse.redirect(loginUrl.toString(), { status: 302 }) } }