mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
35a0ceb12c
commit
bbef4eea99
35 files changed
+1654
-187
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user