129 lines
3.7 KiB
TypeScript
129 lines
3.7 KiB
TypeScript
import { cookies } from "next/headers"
|
|
import { NextResponse } from "next/server"
|
|
import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
|
|
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
|
|
import {
|
|
applySessionCookies,
|
|
type TokenResponse,
|
|
} 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"
|
|
|
|
export async function GET(request: Request) {
|
|
const url = new URL(request.url)
|
|
const appOrigin = getAppOrigin()
|
|
const code = url.searchParams.get("code")
|
|
const state = url.searchParams.get("state")
|
|
const oauthError = url.searchParams.get("error")
|
|
|
|
if (oauthError) {
|
|
const desc = url.searchParams.get("error_description") ?? oauthError
|
|
return NextResponse.redirect(
|
|
new URL(`/login?error=${encodeURIComponent(desc)}`, appOrigin)
|
|
)
|
|
}
|
|
|
|
if (!code || !state) {
|
|
return NextResponse.redirect(
|
|
new URL("/login?error=missing_code", appOrigin)
|
|
)
|
|
}
|
|
|
|
const jar = await cookies()
|
|
const expectedState = jar.get(STATE_COOKIE)?.value
|
|
const verifier = jar.get(PKCE_COOKIE)?.value
|
|
const returnTo = jar.get("ulti_auth_return")?.value ?? "/mail/inbox"
|
|
const authIntent = jar.get(INTENT_COOKIE)?.value
|
|
const previousSub = jar.get(PREVIOUS_SUB_COOKIE)?.value
|
|
|
|
if (!expectedState || state !== expectedState || !verifier) {
|
|
return NextResponse.redirect(
|
|
new URL(
|
|
`/login?error=${encodeURIComponent(
|
|
!expectedState || !verifier
|
|
? "invalid_state:missing_oauth_cookies"
|
|
: "invalid_state:state_mismatch"
|
|
)}`,
|
|
appOrigin
|
|
)
|
|
)
|
|
}
|
|
|
|
let cfg
|
|
try {
|
|
cfg = await resolveServerOidcConfig()
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "oidc_discovery_failed"
|
|
return NextResponse.redirect(
|
|
new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin)
|
|
)
|
|
}
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: cfg.clientId,
|
|
client_secret: cfg.clientSecret,
|
|
code,
|
|
redirect_uri: cfg.redirectUri,
|
|
code_verifier: verifier,
|
|
})
|
|
|
|
let tokens: TokenResponse
|
|
try {
|
|
const res = await fetch(cfg.tokenEndpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
})
|
|
if (!res.ok) {
|
|
const text = await res.text()
|
|
return NextResponse.redirect(
|
|
new URL(
|
|
`/login?error=${encodeURIComponent(`token_exchange_failed:${text.slice(0, 120)}`)}`,
|
|
appOrigin
|
|
)
|
|
)
|
|
}
|
|
tokens = (await res.json()) as TokenResponse
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "token_exchange_failed"
|
|
return NextResponse.redirect(
|
|
new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin)
|
|
)
|
|
}
|
|
|
|
if (!tokens.id_token) {
|
|
return NextResponse.redirect(
|
|
new URL("/login?error=no_id_token", appOrigin)
|
|
)
|
|
}
|
|
|
|
const bearer = tokens.id_token
|
|
|
|
const newUser = platformUserFromToken(bearer)
|
|
const completeUrl = new URL("/auth/complete", appOrigin)
|
|
completeUrl.searchParams.set("returnTo", returnTo)
|
|
if (
|
|
authIntent === "add_account" &&
|
|
previousSub &&
|
|
newUser?.sub === previousSub
|
|
) {
|
|
completeUrl.searchParams.set("accountNotice", "same")
|
|
}
|
|
|
|
const response = NextResponse.redirect(completeUrl)
|
|
|
|
response.cookies.delete(PKCE_COOKIE)
|
|
response.cookies.delete(STATE_COOKIE)
|
|
response.cookies.delete("ulti_auth_return")
|
|
response.cookies.delete(INTENT_COOKIE)
|
|
response.cookies.delete(PREVIOUS_SUB_COOKIE)
|
|
|
|
applySessionCookies(response, tokens, bearer)
|
|
|
|
return response
|
|
}
|