import { cookies } from "next/headers" import { NextResponse } from "next/server" import { createPkcePair, randomString } from "@/lib/auth/pkce" import { platformUserFromToken } from "@/lib/auth/jwt-claims" import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config" import { sessionCookieOptions } from "@/lib/auth/session" const PKCE_COOKIE = "ulti_pkce_verifier" const STATE_COOKIE = "ulti_oauth_state" const INTENT_COOKIE = "ulti_auth_intent" const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub" const COOKIE_MAX_AGE = 600 function oauthCookieOptions() { return { ...sessionCookieOptions(), maxAge: COOKIE_MAX_AGE, } } export async function GET(request: Request) { let cfg try { cfg = await resolveOidcConfig() } catch (err) { const message = err instanceof Error ? err.message : "oidc_discovery_failed" return NextResponse.redirect( new URL( `/login?error=${encodeURIComponent(message)}`, getAppOrigin() ) ) } const { verifier, challenge } = await createPkcePair() const state = randomString(16) const requestUrl = new URL(request.url) const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox" const intent = requestUrl.searchParams.get("intent") const bridge = requestUrl.searchParams.get("bridge") === "1" // Embedded mode: the browser drives Authentik's flow executor (same origin) and authenticates // the session in place, then navigates to the authorize URL. We just hand back the URL + the // executor base, set the PKCE/state cookies, and never force a prompt. const embedded = requestUrl.searchParams.get("embedded") === "1" const promptParam = requestUrl.searchParams.get("prompt") const prompt = promptParam ?? (bridge || embedded ? null : intent === "add_account" ? "login select_account" : "select_account") const jar = await cookies() const existingUser = platformUserFromToken( jar.get("ulti_access_token")?.value ?? "" ) const params = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: cfg.redirectUri, response_type: "code", scope: "openid profile email offline_access", state, code_challenge: challenge, code_challenge_method: "S256", }) if (prompt) { params.set("prompt", prompt) } const authorizeUrl = `${cfg.authorizationEndpoint}?${params.toString()}` const response = embedded ? NextResponse.json(buildEmbeddedContext(authorizeUrl)) : NextResponse.redirect(authorizeUrl) const cookieOpts = oauthCookieOptions() response.cookies.set(PKCE_COOKIE, verifier, cookieOpts) response.cookies.set(STATE_COOKIE, state, cookieOpts) response.cookies.set("ulti_auth_return", returnTo, cookieOpts) if (intent === "add_account") { response.cookies.set(INTENT_COOKIE, "add_account", cookieOpts) if (existingUser?.sub) { response.cookies.set(PREVIOUS_SUB_COOKIE, existingUser.sub, cookieOpts) } } return response } /** * Build the same-origin flow executor base + `next` query from a public authorize URL. * The browser drives the flow at `${executorBase}/${slug}/?query=${flowQuery}`, then navigates * to the returned `to` (the authorize URL) to obtain the code with its authenticated session. */ function buildEmbeddedContext(authorizeUrl: string): { authorizeUrl: string flowQuery: string executorBase: string } { let executorBase = "/auth/api/v3/flows/executor" let next = authorizeUrl try { const parsed = new URL(authorizeUrl) const idx = parsed.pathname.indexOf("/application/") const prefix = idx >= 0 ? parsed.pathname.slice(0, idx) : "/auth" executorBase = `${prefix}/api/v3/flows/executor` next = `${parsed.pathname}${parsed.search}` } catch { // keep defaults } return { authorizeUrl, flowQuery: `next=${encodeURIComponent(next)}`, executorBase, } }