|
| 1 | +import 'server-only' |
| 2 | + |
| 3 | +import { type NextRequest, NextResponse } from 'next/server' |
| 4 | +import { PROTECTED_URLS } from '@/configs/urls' |
| 5 | +import { ensureOryUserBootstrapped } from '@/core/server/auth/ory/dashboard-bootstrap' |
| 6 | +import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client' |
| 7 | +import { |
| 8 | + E2B_OAUTH_FLOW_COOKIE, |
| 9 | + OAUTH_CALLBACK_PATH, |
| 10 | + parseOryFlowState, |
| 11 | +} from '@/core/server/auth/ory/oauth-flow' |
| 12 | +import { |
| 13 | + E2B_SESSION_COOKIE, |
| 14 | + orySessionCookieOptions, |
| 15 | + sealOrySession, |
| 16 | +} from '@/core/server/auth/ory/session-cookie' |
| 17 | +import { |
| 18 | + buildOryLogoutUrl, |
| 19 | + ORY_POST_LOGOUT_PATH, |
| 20 | +} from '@/core/server/auth/ory/signout' |
| 21 | +import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' |
| 22 | +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' |
| 23 | + |
| 24 | +// Failures land on the recover route, whose one-shot guard retries the flow |
| 25 | +// once (via /sign-in → /start, which mints a fresh flow cookie) before bailing |
| 26 | +// to home — so a stale/invalid callback can't loop. |
| 27 | +const ORY_RECOVER_PATH = '/api/auth/oauth/recover' |
| 28 | + |
| 29 | +// Hydra redirects here with ?code after Kratos created the session. We exchange |
| 30 | +// the code (validating state/nonce/PKCE), provision the dashboard user from the |
| 31 | +// id_token, then seal the OIDC tokens into e2b_session. Kratos already owns the |
| 32 | +// session at this point — this cookie only carries tokens for API access. |
| 33 | +export async function GET(request: NextRequest) { |
| 34 | + const origin = request.nextUrl.origin |
| 35 | + const flow = parseOryFlowState( |
| 36 | + request.cookies.get(E2B_OAUTH_FLOW_COOKIE)?.value |
| 37 | + ) |
| 38 | + |
| 39 | + if (!flow) { |
| 40 | + l.warn( |
| 41 | + { key: 'oauth_callback:missing_flow_state' }, |
| 42 | + 'Ory callback hit without a valid flow-state cookie' |
| 43 | + ) |
| 44 | + return finalize(NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin))) |
| 45 | + } |
| 46 | + |
| 47 | + let tokens: Awaited<ReturnType<typeof exchangeOryCallback>> |
| 48 | + try { |
| 49 | + tokens = await exchangeOryCallback({ |
| 50 | + // A genuine global URL — oauth4webapi rejects NextURL (not `instanceof URL`). |
| 51 | + currentUrl: new URL(request.url), |
| 52 | + expectedState: flow.state, |
| 53 | + expectedNonce: flow.nonce, |
| 54 | + codeVerifier: flow.codeVerifier, |
| 55 | + redirectUri: new URL(OAUTH_CALLBACK_PATH, origin).toString(), |
| 56 | + }) |
| 57 | + } catch (error) { |
| 58 | + l.error( |
| 59 | + { |
| 60 | + key: 'oauth_callback:exchange_failed', |
| 61 | + error: serializeErrorForLog(error), |
| 62 | + }, |
| 63 | + 'Ory authorization code exchange failed' |
| 64 | + ) |
| 65 | + return finalize(NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin))) |
| 66 | + } |
| 67 | + |
| 68 | + const bootstrapped = await ensureOryUserBootstrapped({ |
| 69 | + accessToken: tokens.accessToken, |
| 70 | + idToken: tokens.idToken, |
| 71 | + provider: 'ory', |
| 72 | + }) |
| 73 | + |
| 74 | + if (!bootstrapped) { |
| 75 | + l.error( |
| 76 | + { key: 'oauth_callback:bootstrap_failed' }, |
| 77 | + 'dashboard bootstrap failed; ending the Ory session without a dashboard cookie' |
| 78 | + ) |
| 79 | + // Don't strand the user with a half-provisioned login: end the Ory + Kratos |
| 80 | + // session via RP-logout (falling back to home if no id_token is available). |
| 81 | + const logoutUrl = tokens.idToken |
| 82 | + ? buildOryLogoutUrl({ idToken: tokens.idToken, origin }) |
| 83 | + : null |
| 84 | + return finalize( |
| 85 | + NextResponse.redirect(logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)) |
| 86 | + ) |
| 87 | + } |
| 88 | + |
| 89 | + const sealed = await sealOrySession({ |
| 90 | + accessToken: tokens.accessToken, |
| 91 | + refreshToken: tokens.refreshToken, |
| 92 | + idToken: tokens.idToken, |
| 93 | + expiresAt: tokens.expiresAt, |
| 94 | + }) |
| 95 | + |
| 96 | + const destination = flow.returnTo ?? PROTECTED_URLS.DASHBOARD |
| 97 | + const response = finalize(NextResponse.redirect(new URL(destination, origin))) |
| 98 | + response.cookies.set(E2B_SESSION_COOKIE, sealed, orySessionCookieOptions()) |
| 99 | + return response |
| 100 | +} |
| 101 | + |
| 102 | +// Clears the one-shot transient cookies on every exit path. |
| 103 | +function finalize(response: NextResponse): NextResponse { |
| 104 | + response.cookies.delete(E2B_OAUTH_FLOW_COOKIE) |
| 105 | + response.cookies.delete(ORY_SIGNUP_METADATA_COOKIE) |
| 106 | + return response |
| 107 | +} |
0 commit comments