mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(claim): hand over a managed profile through single sign-on
POST /api/auth/claim/sso checks the handover link and the username and seals the link id, the username and a per-start binding into an hl_claim cookie. GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted OIDC state, and the callback binds the provider subject to the profile in the claim transaction, refusing a subject or verified email another account holds, a link that is no longer usable, and a live session. The handover status and the claim preview name the redemption methods; a single-sign-on-only server hands over through the identity provider, and only the password claim stays refused there.
This commit is contained in:
1 parent
5dab12472f
commit
9eae352bb1
20 files changed
+1075
-208
No files matched your search
@@ -38,6 +38,17 @@ import {
|
||||
mintNativeHandoff,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link";
|
||||
import {
|
||||
CLAIM_COOKIE,
|
||||
CLAIM_ERROR_PATH,
|
||||
claimMatchesBinding,
|
||||
clearClaimCookie,
|
||||
readClaimCookie,
|
||||
} from "@/lib/managed-profiles/claim-cookie";
|
||||
import {
|
||||
completeSsoClaim,
|
||||
SSO_CLAIM_LANDING,
|
||||
} from "@/lib/managed-profiles/claim-sso";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -102,6 +113,14 @@ interface StoredOidcState {
|
||||
native?: boolean;
|
||||
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
|
||||
appCodeChallenge?: string;
|
||||
/**
|
||||
* v1.43 (#959) — set only when the login carried `intent=claim` and a valid
|
||||
* `hl_claim` cookie. The callback then claims the managed profile the
|
||||
* cookie names instead of signing in, and only when the cookie still
|
||||
* carries this exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
claimBinding?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
@@ -122,6 +141,13 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
if (v.native !== true) return false;
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
if (v.intent !== undefined) {
|
||||
if (v.intent !== "claim") return false;
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,12 +179,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
}
|
||||
}
|
||||
const isNative = stored?.native === true;
|
||||
const isClaim = stored?.intent === "claim";
|
||||
|
||||
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
||||
// the web login page otherwise. Deletes the single-use state cookie either
|
||||
// way. An error redirect carries no code, ticket, or session, so routing it
|
||||
// to the scheme leaks nothing (spec §1).
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isClaim) {
|
||||
// A refused claim returns to the claim page, never the sign-in page,
|
||||
// and takes the claim cookie with it: the person starts again from the
|
||||
// link, which is still theirs to use if the refusal left it open.
|
||||
const response = NextResponse.redirect(
|
||||
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
clearClaimCookie(response);
|
||||
return response;
|
||||
}
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ error: reason }),
|
||||
@@ -198,7 +236,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all
|
||||
// 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) return errorRedirect("oidc_state");
|
||||
if (!code || !state) {
|
||||
return isClaim ? failRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
@@ -240,7 +280,32 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
return errorRedirect("oidc_state");
|
||||
return isClaim ? failRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
// v1.43 (#959) — a claim is admitted only with its own sealed cookie, still
|
||||
// bound to this sign-in, and never 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). Both before the code is
|
||||
// redeemed, so a refused claim spends nothing at the provider.
|
||||
const pendingClaim = isClaim
|
||||
? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value)
|
||||
: null;
|
||||
if (isClaim) {
|
||||
if (
|
||||
!pendingClaim ||
|
||||
!claimMatchesBinding(pendingClaim, stored.claimBinding as string)
|
||||
) {
|
||||
annotate({ meta: { reason: "claim_cookie" } });
|
||||
return failRedirect("claim_expired");
|
||||
}
|
||||
const liveSession = await validateSessionFromCookieValue(
|
||||
req.cookies.get(SESSION_COOKIE_NAME)?.value,
|
||||
);
|
||||
if (liveSession) {
|
||||
annotate({ meta: { reason: "claim_signed_in" } });
|
||||
return failRedirect("claim_signed_in");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -274,6 +339,28 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
emailVerified = userinfo.emailVerified;
|
||||
}
|
||||
|
||||
// v1.43 (#959) — the claim. The provider has authenticated somebody; bind
|
||||
// that subject to the profile the cookie names, or refuse. Never falls
|
||||
// through to the sign-in below: a refused claim signs nobody in, and
|
||||
// provisions and links nothing.
|
||||
if (pendingClaim) {
|
||||
const outcome = await completeSsoClaim({
|
||||
handoverId: pendingClaim.handoverId,
|
||||
username: pendingClaim.username,
|
||||
issuer: metadata.issuer,
|
||||
sub: identity.sub,
|
||||
email,
|
||||
emailVerified,
|
||||
ipAddress: ip,
|
||||
userAgent: req.headers.get("user-agent"),
|
||||
});
|
||||
if (!outcome.ok) return failRedirect(outcome.reason);
|
||||
const response = NextResponse.redirect(oidcAppUrl(SSO_CLAIM_LANDING));
|
||||
deleteStateCookie(response);
|
||||
clearClaimCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// (1) Durable identity match — the (issuer, sub) pair stamped on a
|
||||
// previous login, link, or provision. Email is deliberately NOT
|
||||
// consulted here: after the stamp it is a display field, so an
|
||||
|
||||
@@ -214,3 +214,50 @@ describe("GET /api/auth/oidc/login", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a claim on the native leg with oidc_invalid_request", async () => {
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?intent=claim&client=native&code_challenge=${"a".repeat(43)}`,
|
||||
),
|
||||
);
|
||||
expect(res.headers.get("location")).toMatch(
|
||||
/^healthlog:\/\/oidc-callback\?error=oidc_invalid_request$/,
|
||||
);
|
||||
expect(discoverOidcMetadata).not.toHaveBeenCalled();
|
||||
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends a claim without its cookie back to the claim page, before the provider", async () => {
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://healthlog.example.com/auth/claim?error=claim_expired",
|
||||
);
|
||||
expect(discoverOidcMetadata).not.toHaveBeenCalled();
|
||||
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes the claim's other failures to the claim page too", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://healthlog.example.com/auth/claim?error=oidc_rate_limited",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
OIDC_STATE_TTL_MS,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import {
|
||||
CLAIM_COOKIE,
|
||||
CLAIM_ERROR_PATH,
|
||||
readClaimCookie,
|
||||
} from "@/lib/managed-profiles/claim-cookie";
|
||||
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
||||
|
||||
/**
|
||||
* Redirects the browser to the configured OIDC provider's authorization
|
||||
@@ -40,16 +46,34 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// nothing — an error redirect carries no code, ticket, or session (spec §1).
|
||||
const isNative = req.nextUrl.searchParams.get("client") === "native";
|
||||
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
|
||||
// v1.43 (#959) — `intent=claim`: the sign-in claims a managed profile. The
|
||||
// link and the username ride the sealed `hl_claim` cookie set by
|
||||
// `POST /api/auth/claim/sso`, never this URL; the intent only says to look
|
||||
// for it. Web only.
|
||||
const isClaim = req.nextUrl.searchParams.get("intent") === "claim";
|
||||
|
||||
// Native-aware error redirect: the scheme for a native start, the web login
|
||||
// page otherwise. Machine-readable `error=<reason>` in both.
|
||||
// page otherwise, and the claim page for a claim. Machine-readable
|
||||
// `error=<reason>` in all three.
|
||||
const loginError = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
return NextResponse.redirect(buildNativeCallbackUrl({ error: reason }));
|
||||
}
|
||||
if (isClaim) {
|
||||
return NextResponse.redirect(
|
||||
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`));
|
||||
};
|
||||
|
||||
// The app never claims a profile: the claim runs in a browser, and the new
|
||||
// owner signs in to the app afterwards through the ordinary native leg.
|
||||
if (isClaim && isNative) {
|
||||
annotate({ meta: { reason: "claim_on_native" } });
|
||||
return loginError("oidc_invalid_request");
|
||||
}
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) {
|
||||
return loginError("oidc_disabled");
|
||||
@@ -90,6 +114,19 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return loginError("oidc_invalid_request");
|
||||
}
|
||||
|
||||
// A claim needs its sealed cookie and a link that is still usable. Checked
|
||||
// here, after the rate limit, so a dead link is told before the round trip
|
||||
// to the provider; the callback checks both again.
|
||||
let claimBinding: string | null = null;
|
||||
if (isClaim) {
|
||||
const claim = readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value);
|
||||
if (!claim || !(await isHandoverUsable(claim.handoverId))) {
|
||||
annotate({ meta: { reason: "claim_expired" } });
|
||||
return loginError("claim_expired");
|
||||
}
|
||||
claimBinding = claim.binding;
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
@@ -104,10 +141,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const codeChallenge = s256Challenge(codeVerifier);
|
||||
|
||||
// `next` is a post-login WEB path, meaningless to the app — pin it to "/" on
|
||||
// the native branch (one less input to sanitize).
|
||||
const safeNext = isNative
|
||||
? "/"
|
||||
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
|
||||
// the native branch (one less input to sanitize). A claim lands on a fixed
|
||||
// page the callback names, so its `next` is pinned too.
|
||||
const safeNext =
|
||||
isNative || isClaim
|
||||
? "/"
|
||||
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
|
||||
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
@@ -133,6 +172,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
...(isNative
|
||||
? { native: true as const, appCodeChallenge: appCodeChallenge! }
|
||||
: {}),
|
||||
// The claim's binding, inside the same tamper-authenticated blob: the
|
||||
// callback admits the claim only when the cookie it carries back holds
|
||||
// this exact value.
|
||||
...(claimBinding !== null
|
||||
? { intent: "claim" as const, claimBinding }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
|
||||
|
||||
Reference in new issue
Block a user