feat(auth): let a single-sign-on account confirm it is you at its provider

An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 08:56:51 +01:00
1 parent 35a0ceb12c
commit bbef4eea99
35 files changed
+1654 -187

No files matched your search

+7 -1
View File
@@ -22,19 +22,21 @@ import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
import { recentProofMethods } from "@/lib/auth/second-factor";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
const [recoveryCodesRemaining, webauthn, proofMethods] = await Promise.all([
countRemainingRecoveryCodes(user.id),
prisma.webauthnMfaCredential.findMany({
where: { userId: user.id },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
orderBy: { createdAt: "desc" },
}),
recentProofMethods(user),
]);
annotate({ action: { name: "auth.mfa.status" } });
@@ -46,5 +48,9 @@ export const GET = apiHandler(async () => {
// The security hub also drives the passkey-upgrade nudge from this one
// status read, so it knows whether the user has already dismissed it.
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
// The ways this account can confirm it is you, the list the recent-proof
// refusal names. The passkey card offers exactly these before adding one,
// so an account without a password is not handed a password field.
proofMethods,
});
});
+116 -33
View File
@@ -47,6 +47,11 @@ import {
} from "@/lib/managed-profiles/claim-sso";
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
import {
isReproofSession,
recordOidcReproof,
reproofReturnUrl,
} from "@/lib/auth/oidc-reproof";
const LOGIN_ERROR_URL = "/auth/login";
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
return response;
}
/**
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
* the person is still signed in, and the session they came from is the one
* thing that leg may touch. `reason` null is the success.
*/
function reproofRedirect(next: string, reason: string | null): NextResponse {
if (reason) annotate({ meta: { reason } });
const response = NextResponse.redirect(
reproofReturnUrl(next, reason ? "failed" : "done"),
);
deleteStateCookie(response);
return response;
}
/**
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
const isNative = stored?.native === true;
const isClaim = stored?.intent === "claim";
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
const isReproof = stored?.intent === "reproof";
const reproofBack = (reason: string | null) =>
reproofRedirect(stored?.next ?? "/", reason);
// Native-aware error redirect: the custom scheme for a decrypted native flow,
// the web login page otherwise. Deletes the single-use state cookie either
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
};
const failRedirect = (reason: string): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
// and there is nothing safe to hand the app (spec §1 web-fallback).
if (!code || !state) {
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
if (!stored) return stateMissing(req);
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
// v1.43 (#959) — a claim is never finished over a live session (the claim
// routes' rule: a Guardian finishing their own link would otherwise be
// signed out of their account and into the person's), and is admitted only
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
return failRedirect("oidc_failed");
}
});
/**
* #1296 — the re-proof leg. The browser must still carry the session the
* start bound into the state, checked before the code is redeemed; then the
* provider's answer must name this account's linked identity and an
* authentication inside the gate's window. Only then is `reproofAt` stamped.
* No session is created, and every refusal lands back on the page it left.
*/
async function finishReproof(
req: NextRequest,
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
config: NonNullable<ReturnType<typeof getOidcConfig>>,
ip: string,
back: (reason: string | null) => NextResponse,
): Promise<NextResponse> {
const live = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (!isReproofSession(stored, live)) return back("session_mismatch");
try {
const metadata = await discoverOidcMetadata(config);
const tokens = await exchangeCodeForTokens({
metadata,
config,
code: req.nextUrl.searchParams.get("code") as string,
codeVerifier: stored.codeVerifier,
redirectUri: getOidcRedirectUri(),
});
if (!tokens.id_token) return back("oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
const outcome = await recordOidcReproof({
userId: stored.reproofUserId as string,
sessionId: stored.reproofSessionId as string,
issuer: metadata.issuer,
identity,
ipAddress: ip,
});
return back(outcome.ok ? null : outcome.reason);
} catch (err) {
getEvent()?.setError(err);
return back("exchange_or_verify_failed");
}
}
/**
* A callback that arrives without its state blob: the duplicate of a sign-in
* that already completed, or a forged, replayed or expired one.
*/
async function stateMissing(req: NextRequest): Promise<NextResponse> {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
+118
View File
@@ -0,0 +1,118 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import { recentProofMethods } from "@/lib/auth/second-factor";
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
export const dynamic = "force-dynamic";
/**
* Start confirming it is you at the identity provider (#1296).
*
* A top-level navigation from the re-proof dialog, cookie session only: a
* Bearer token never re-proves this way, because a stamp on a session row
* means nothing to a token (`requireCookieAuth` reads the session cookie and
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
* is an account without a second factor whose identity is linked while a
* provider is configured; anything else goes straight back with
* `reproof=failed`.
*
* The round trip asks for a fresh authentication (`prompt=login`,
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
* same single-use cookie the sign-in uses, together with the account and the
* session it confirms. The callback stamps that session's `reproofAt` and
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
* a session.
*
* Draws on the account's shared re-proof budget, like every other proof.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.reproof.start" } });
const { user, session } = await requireCookieAuth();
const next = sanitizeOidcNextPath(
req.nextUrl.searchParams.get("next"),
req.url,
);
const back = (reason: string): NextResponse => {
annotate({ meta: { reason } });
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
};
const config = getOidcConfig();
if (!config) return back("oidc_disabled");
const methods = await recentProofMethods(user);
if (!methods.includes("oidc")) return back("not_offered");
const limited = await throttleReproof(
user.id,
getClientIp(req),
OIDC_REPROOF_STAGE,
);
if (limited) return back("rate_limited");
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return back("oidc_failed");
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const response = NextResponse.redirect(
buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge: s256Challenge(codeVerifier),
redirectUri: getOidcRedirectUri(),
reauthenticate: true,
}),
);
response.cookies.set(
OIDC_STATE_COOKIE,
encrypt(
JSON.stringify({
state,
nonce,
codeVerifier,
next,
intent: "reproof" as const,
reproofUserId: user.id,
reproofSessionId: session.id,
}),
),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
path: OIDC_STATE_COOKIE_PATH,
},
);
return response;
});
@@ -5,6 +5,8 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
RECENT_PROOF_MAX_AGE_MS,
REPROOF_REQUIRED_CODE,
isSecondFactorProof,
recordReproofFailure,
refundReproof,
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ ...result, reauth: true });
}
// #1296 — an account whose only credential is its identity provider proves
// itself there (`GET /api/auth/oidc/reproof`), which stamps
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
// stamp as the proof: only where `oidc` is on the account's list (no second
// factor, a linked identity, a configured provider) and only inside the
// five-minute window. Like a password, it clears the MFA stamp rather than
// setting it.
if (Object.keys(body).length === 1 && body.method === "oidc") {
if (
!(await recentProofMethods(user)).includes("oidc") ||
!(await hasRecentReproof(session.id))
) {
annotate({
action: { name: "auth.reproof.required" },
meta: { stage: "passkey_enroll", method: "oidc" },
});
return apiError("Confirm it is you before adding a passkey", 401, {
errorCode: REPROOF_REQUIRED_CODE,
methods: await recentProofMethods(user),
});
}
return issueRegistrationOptions(user, session.id, null);
}
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
await refundReproof(user.id);
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
// session-bound challenge but cannot upgrade password-only authentication.
return issueRegistrationOptions(
user,
session.id,
parsed.data.method === "password" ? null : new Date(),
);
});
/** A password or provider re-proof on this session inside the window. */
async function hasRecentReproof(sessionId: string): Promise<boolean> {
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { reproofAt: true },
});
return (
row?.reproofAt != null &&
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
);
}
/**
* A strong existing factor refreshes the session's MFA stamp. A password or
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
* is authorized by this single-use, session-bound challenge but cannot upgrade
* first-factor authentication.
*/
async function issueRegistrationOptions(
user: { id: string; email: string | null; username: string },
sessionId: string,
mfaVerifiedAt: Date | null,
): Promise<Response> {
await prisma.session.update({
where: { id: session.id },
data: {
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
},
where: { id: sessionId },
data: { mfaVerifiedAt },
});
const { options, challengeId } = await createRegistrationOptions(
user.id,
user.email ?? user.username,
session.id,
sessionId,
);
annotate({ action: { name: "auth.passkey.register-options" } });
return apiSuccess({ options, challengeId });
});
}