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:
Marc-André Bombeck committed 2026-10-15 23:12:59 +02:00
1 parent 5dab12472f
commit 9eae352bb1
20 files changed
+1075 -208

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
/**
* v1.43 (#959) — the sealed cookie that carries a single-sign-on claim through
* the round trip to the identity provider.
*
* `POST /api/auth/claim/sso` checks the handover token and the chosen username
* and seals `{ handoverId, username, binding, exp }` into `hl_claim`. The
* token itself never leaves that request: the cookie names the link by its
* id, and it can only have been minted by a request that held the token.
*
* - AES-256-GCM with the at-rest key (`encrypt`), so it can be neither read
* nor forged, and it carries its own expiry on top of `Max-Age`.
* - `HttpOnly`, `SameSite=Lax` (the browser returns from the provider on a
* cross-site top-level GET and must carry it), `Secure` through
* `shouldEmitSecureCookie()`.
* - Scoped to `/api/auth/oidc`, the only two routes that read it.
* - `binding` is a fresh random value per start. The login copies it into
* the encrypted OIDC state, and the callback admits the claim only when
* the cookie and the state carry the same one, so a claim cookie from a
* different start (another tab, an older attempt) cannot be paired with
* this sign-in.
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { NextResponse } from "next/server";
import { decrypt, encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
export const CLAIM_COOKIE = "hl_claim";
export const CLAIM_COOKIE_PATH = "/api/auth/oidc";
export const CLAIM_COOKIE_TTL_MS = 10 * 60 * 1000;
/**
* Where `POST /api/auth/claim/sso` sends the browser next. A fixed path:
* nothing caller-supplied ever reaches a redirect on this flow.
*/
export const CLAIM_SSO_REDIRECT = "/api/auth/oidc/login?intent=claim";
/** Where every refusal on the single-sign-on claim lands, with `?error=`. */
export const CLAIM_ERROR_PATH = "/auth/claim";
export interface PendingSsoClaim {
handoverId: string;
username: string;
binding: string;
}
/** Attach a freshly sealed claim to a response. Returns what was sealed. */
export function setClaimCookie(
response: NextResponse,
claim: { handoverId: string; username: string },
now: number = Date.now(),
): PendingSsoClaim {
const binding = randomBytes(32).toString("base64url");
const sealed = encrypt(
JSON.stringify({
h: claim.handoverId,
u: claim.username,
b: binding,
e: now + CLAIM_COOKIE_TTL_MS,
}),
);
response.cookies.set(CLAIM_COOKIE, sealed, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(CLAIM_COOKIE_TTL_MS / 1000),
path: CLAIM_COOKIE_PATH,
});
return { ...claim, binding };
}
/** The claim a cookie value carries, or null when absent, forged or expired. */
export function readClaimCookie(
raw: string | undefined,
now: number = Date.now(),
): PendingSsoClaim | null {
if (!raw) return null;
try {
const parsed = JSON.parse(decrypt(raw)) as Record<string, unknown>;
if (
typeof parsed.h !== "string" ||
parsed.h.length === 0 ||
typeof parsed.u !== "string" ||
parsed.u.length === 0 ||
typeof parsed.b !== "string" ||
parsed.b.length === 0 ||
typeof parsed.e !== "number" ||
parsed.e <= now
) {
return null;
}
return { handoverId: parsed.h, username: parsed.u, binding: parsed.b };
} catch {
return null;
}
}
/** Whether a claim belongs to the sign-in whose state carries `binding`. */
export function claimMatchesBinding(
claim: PendingSsoClaim,
binding: string,
): boolean {
const a = Buffer.from(claim.binding);
const b = Buffer.from(binding);
return a.length === b.length && timingSafeEqual(a, b);
}
/** Expire the claim cookie on a response; the path must repeat the set's. */
export function clearClaimCookie(response: NextResponse): void {
response.cookies.delete({ name: CLAIM_COOKIE, path: CLAIM_COOKIE_PATH });
}
+42 -9
View File
@@ -1,11 +1,14 @@
/**
* v1.42 (#959) — the refusals both anonymous claim routes give before they
* look at a token, in one place so the preview and the claim cannot disagree.
* v1.42 (#959) — the refusals the anonymous claim routes give before they look
* at a token, in one place so the preview and the claims cannot disagree.
*
* Order matters and is the register route's order:
*
* 1. A single-sign-on-only instance admits no password, and the claim sets
* one, so the handover is refused outright in v1.42.
* 1. What this server cannot do at all. The password claim sets a password,
* so a single-sign-on-only instance refuses it outright; the
* single-sign-on claim (v1.43) needs an identity provider, and answers
* 404 `profile_claim.sso_unavailable` without one. The preview is
* refused by neither: it is what tells the page which way is open.
* 2. A signed-in caller is refused with 409. Claiming creates a session for
* the new owner, and a Guardian testing their own link would otherwise be
* signed out of their account and into the person's. Refused before the
@@ -13,12 +16,14 @@
* would lock out the person the link is for, who may share the address.
* 3. The per-address rate limit, the anonymous auth-surface pattern: a
* misconfigured proxy trust chain collapses every caller into one tight
* bucket rather than one free-for-all.
* bucket rather than one free-for-all. Both claims share one bucket, so
* switching between them buys no extra attempts.
*/
import { NextResponse } from "next/server";
import { apiError } from "@/lib/api-response";
import { isOidcOnly } from "@/lib/auth/oidc";
import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -29,9 +34,28 @@ export function claimInvalid() {
});
}
/**
* v1.43 (#959) — how a handover link can be redeemed on this server: with a
* password unless the server allows single sign-on only, and through the
* identity provider whenever one is configured. Never empty in practice
* (`OIDC_ONLY` only takes effect with a configured provider), but the Guardian
* side refuses on an empty list rather than assuming it.
*/
export function handoverMethods(): HandoverMethod[] {
const methods: HandoverMethod[] = [];
if (!isOidcOnly()) methods.push("password");
if (isOidcConfigured()) methods.push("sso");
return methods;
}
/** The provider's name for the "Continue with …" button, or null. */
export function ssoProviderName(): string | null {
return getOidcConfig()?.buttonLabel ?? null;
}
export async function claimGate(
request: Request,
surface: "preview" | "claim",
surface: "preview" | "claim" | "sso",
/**
* Whether the request carries a live session. Resolved by the ROUTE with
* `getSession()` and handed in, rather than resolved here: a session read
@@ -41,15 +65,24 @@ export async function claimGate(
*/
signedIn: boolean,
): Promise<{ refusal: Response } | { refusal: null; ip: string | null }> {
if (isOidcOnly()) {
if (surface === "claim" && isOidcOnly()) {
return {
refusal: apiError(
"Taking over a profile is not available while this server allows single sign-on only",
"This server allows single sign-on only; take the profile over through the identity provider",
403,
{ errorCode: "profile_claim.oidc_only_unsupported" },
),
};
}
if (surface === "sso" && !isOidcConfigured()) {
return {
refusal: apiError(
"Single sign-on is not configured on this server",
404,
{ errorCode: "profile_claim.sso_unavailable" },
),
};
}
if (signedIn) {
annotate({
+178
View File
@@ -0,0 +1,178 @@
/**
* v1.43 (#959) — the callback's half of claiming a managed profile through
* single sign-on: the identity provider has authenticated somebody, the
* sealed claim cookie names the link and the chosen username, and this binds
* the one to the other.
*
* The refusals, in order, each one before anything about any account changes:
*
* 1. `claim_expired` — the link is no longer usable (used, withdrawn,
* replaced, expired, its creator gone, the profile no longer managed).
* 2. `claim_sso_subject_bound` — the provider's `(issuer, sub)` already
* signs in to an account here. One subject, one account: binding it a
* second time would make the sign-in ambiguous, and re-pointing it would
* hand the existing account's sign-in to the claimed profile.
* 3. `claim_email_taken` — the provider vouches (`email_verified: true`) for
* an address another account holds. An unverified address is not used at
* all: the account is created without one rather than with a claim
* nobody checked.
* 4. `claim_username_taken` — the username chosen at the start was taken in
* the ten minutes since.
*
* Then `claimManagedProfile` — the password claim's own transaction, under
* the managed-profile lock, with every link check repeated — sets the
* username, the address, no password and the subject; applies each Guardian's
* proposed access; resets disclaimer, onboarding and AI consent. A race lost
* at the unique indexes is re-read and answered as the refusal it turned out
* to be. The session is minted only after that commit.
*/
import { auditLog } from "@/lib/auth/audit";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { createSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
claimManagedProfile,
HandoverError,
isHandoverUsable,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
export type SsoClaimRefusal =
| "claim_expired"
| "claim_sso_subject_bound"
| "claim_email_taken"
| "claim_username_taken";
export type SsoClaimOutcome =
{ ok: true; profileId: string } | { ok: false; reason: SsoClaimRefusal };
/** Where the new owner lands: the decision about each former Guardian. */
export const SSO_CLAIM_LANDING = "/onboarding/handover";
interface SsoClaimInput {
handoverId: string;
username: string;
issuer: string;
sub: string;
email: string | null;
emailVerified: boolean | undefined;
ipAddress: string;
userAgent: string | null;
}
/** Which of the three unique values is held by an account already. */
async function collision(input: {
issuer: string;
sub: string;
email: string | null;
username: string;
}): Promise<SsoClaimRefusal | null> {
const bound = await prisma.user.findFirst({
where: { oidcIssuer: input.issuer, oidcSub: input.sub },
select: { id: true },
});
if (bound) {
// Under the account that holds the subject, so its owner's history shows
// that somebody tried to bind their sign-in to a second record.
await auditLog("auth.oidc.claim_conflict", {
userId: bound.id,
actorUserId: null,
});
return "claim_sso_subject_bound";
}
if (input.email) {
const byEmail = await prisma.user.findFirst({
where: { email: { equals: input.email, mode: "insensitive" } },
select: { id: true },
});
if (byEmail) return "claim_email_taken";
}
const byName = await prisma.user.findUnique({
where: { username: input.username },
select: { id: true },
});
return byName ? "claim_username_taken" : null;
}
export async function completeSsoClaim(
input: SsoClaimInput,
): Promise<SsoClaimOutcome> {
const refuse = (reason: SsoClaimRefusal): SsoClaimOutcome => {
annotate({
action: { name: "profile_claim.sso.refused" },
meta: { reason },
});
return { ok: false, reason };
};
if (!(await isHandoverUsable(input.handoverId))) {
return refuse("claim_expired");
}
const email =
input.email && input.emailVerified === true
? input.email.toLowerCase()
: null;
const identity = {
issuer: input.issuer,
sub: input.sub,
email,
username: input.username,
};
const early = await collision(identity);
if (early) return refuse(early);
let claimed;
try {
claimed = await claimManagedProfile({
link: { handoverId: input.handoverId },
username: input.username,
email,
passwordHash: null,
oidc: { issuer: input.issuer, sub: input.sub },
ipAddress: input.ipAddress,
});
} catch (error) {
if (error instanceof HandoverError) {
if (error.code === "taken") {
// Lost a race at a unique index: say which one.
return refuse((await collision(identity)) ?? "claim_username_taken");
}
return refuse("claim_expired");
}
throw error;
}
// The new owner starts with onboarding owed, as after the password claim.
// The fifth argument stays null: single sign-on is a delegated factor and
// must never satisfy a step-up (the OIDC login's rule).
await createSession(
claimed.profileId,
true,
input.ipAddress,
input.userAgent,
null,
);
void recordSignInDevice({
userId: claimed.profileId,
ip: input.ipAddress,
userAgent: input.userAgent,
alertOnNew: false,
});
await auditLog("auth.claim", {
userId: claimed.profileId,
actorUserId: null,
ipAddress: input.ipAddress,
details: { method: "sso" },
});
notifyGuardiansOfHandover("claimed", claimed.displayName, claimed.guardians);
annotate({
action: { name: "profile_claim.complete" },
meta: { guardians: claimed.guardians.length, method: "sso" },
});
return { ok: true, profileId: claimed.profileId };
}
+92 -15
View File
@@ -444,17 +444,19 @@ export interface HandoverPreview {
}
/**
* What a link would hand over, or null for every kind of unusable link.
*
* Deliberately minimal: the profile's name, the expiry, and who keeps which
* access. No health data, no date of birth, no usernames and no grant ids —
* the person holding the link has not proved anything yet beyond holding it.
* The open link behind a stored row, or null when it can no longer be
* claimed: used, withdrawn, expired, a state this code cannot read, a profile
* no longer managed, or a creator who lost access. The one definition of
* "usable" the preview, the single-sign-on start and the callback share.
*/
export async function previewHandover(
rawToken: string,
now: Date = new Date(),
): Promise<HandoverPreview | null> {
const row = await findHandoverByToken(prisma, rawToken);
async function usableHandover(
row: Awaited<ReturnType<typeof findHandoverByToken>>,
now: Date,
): Promise<{
row: NonNullable<Awaited<ReturnType<typeof findHandoverByToken>>>;
state: HandoverState;
displayName: string | null;
} | null> {
if (!row || !isOpen(row, now)) return null;
const state = parseHandoverState(row.proposalsJson);
if (!state) return null;
@@ -467,6 +469,26 @@ export async function previewHandover(
if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) {
return null;
}
return { row, state, displayName: profile.displayName };
}
/**
* What a link would hand over, or null for every kind of unusable link.
*
* Deliberately minimal: the profile's name, the expiry, and who keeps which
* access. No health data, no date of birth, no usernames and no grant ids —
* the person holding the link has not proved anything yet beyond holding it.
*/
export async function previewHandover(
rawToken: string,
now: Date = new Date(),
): Promise<HandoverPreview | null> {
const usable = await usableHandover(
await findHandoverByToken(prisma, rawToken),
now,
);
if (!usable) return null;
const { row, state } = usable;
const guardians = await prisma.accountGrant.findMany({
where: { grantorId: row.profileId, ...activeGuardianWhere(now) },
@@ -477,7 +499,7 @@ export async function previewHandover(
orderBy: { createdAt: "asc" },
});
return {
displayName: profile.displayName,
displayName: usable.displayName,
expiresAt: row.expiresAt,
guardians: guardians.map((g) => ({
displayName: g.grantee.displayName?.trim() || null,
@@ -486,6 +508,39 @@ export async function previewHandover(
};
}
/**
* v1.43 (#959) — the usable link behind a raw token, or null. The
* single-sign-on start seals its id, never the token, into the claim cookie.
*/
export async function usableHandoverForToken(
rawToken: string,
now: Date = new Date(),
): Promise<{ handoverId: string; profileId: string } | null> {
const usable = await usableHandover(
await findHandoverByToken(prisma, rawToken),
now,
);
return usable
? { handoverId: usable.row.id, profileId: usable.row.profileId }
: null;
}
/**
* v1.43 (#959) — whether the link with this id can still be claimed. The
* single-sign-on legs re-ask on every step, so a link withdrawn or replaced
* while the person is at the identity provider is refused before any account
* changes. The claim re-checks under the lock; this decides nothing alone.
*/
export async function isHandoverUsable(
handoverId: string,
now: Date = new Date(),
): Promise<boolean> {
const row = await prisma.managedProfileHandover.findUnique({
where: { id: handoverId },
});
return (await usableHandover(row, now)) !== null;
}
export interface ClaimedProfile {
profileId: string;
username: string;
@@ -502,15 +557,33 @@ export interface ClaimedProfile {
* strength: none of that work belongs inside the lock.
*/
export async function claimManagedProfile(input: {
rawToken: string;
/**
* The link: the raw token from the password form, or (v1.43) the id the
* single-sign-on start sealed into the claim cookie after checking the
* token itself.
*/
link: { rawToken: string } | { handoverId: string };
username: string;
email: string;
passwordHash: string;
/** Null on a single-sign-on claim whose provider vouched for no address. */
email: string | null;
/** Null on a single-sign-on claim: the account signs in at the provider. */
passwordHash: string | null;
/**
* v1.43 (#959) — the identity provider's subject, bound to the profile in
* the same transaction. The `(issuer, sub)` unique index refuses a subject
* that another account holds, as `taken`.
*/
oidc?: { issuer: string; sub: string };
ipAddress?: string | null;
now?: Date;
}): Promise<ClaimedProfile> {
const now = input.now ?? new Date();
const found = await findHandoverByToken(prisma, input.rawToken);
const found =
"rawToken" in input.link
? await findHandoverByToken(prisma, input.link.rawToken)
: await prisma.managedProfileHandover.findUnique({
where: { id: input.link.handoverId },
});
if (!found) throw new HandoverError("invalid");
try {
@@ -551,6 +624,9 @@ export async function claimManagedProfile(input: {
username: input.username,
email: input.email,
passwordHash: input.passwordHash,
...(input.oidc
? { oidcIssuer: input.oidc.issuer, oidcSub: input.oidc.sub }
: {}),
disclaimerAcknowledgedAt: null,
disclaimerAcknowledgedVersion: null,
onboardingCompletedAt: null,
@@ -636,6 +712,7 @@ export async function claimManagedProfile(input: {
})),
withdrawnInvitations,
consentsWithdrawn: consent.revoked.length,
method: input.oidc ? "sso" : "password",
},
client: tx,
});
+1
View File
@@ -350,6 +350,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
profile_claim: [
"profile_claim.invalid",
"profile_claim.oidc_only_unsupported",
"profile_claim.sso_unavailable",
"profile_claim.taken",
],
record_write: ["record_write.rate_limited"],
+1 -1
View File
@@ -736,7 +736,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
"Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration never answers this.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
+9 -10
View File
@@ -43,7 +43,6 @@ import {
} from "@/lib/validations/step-up";
import {
dataEnvelope,
notImplementedResponse,
profileEmailRateLimitResponse,
stdResponses,
errorEnvelope,
@@ -768,7 +767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Claim a managed profile through single sign-on (v1.43)",
description:
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. When the callback refuses, the browser lands on `/auth/claim?error=<reason>` with reason `claim_expired` (the link was used, withdrawn, replaced or expired in the meantime, or the cookie is missing, expired or belongs to another start), `claim_sso_subject_bound`, `claim_email_taken`, `claim_username_taken` (taken during the round trip), `claim_signed_in`, or one of the sign-in reasons (`oidc_denied`, `oidc_state`, `oidc_failed`, `oidc_rate_limited`, `oidc_disabled`); on success it lands on `/onboarding/handover`, where the new owner decides each guardian's access. The cookie is cleared either way. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
requestBody: {
required: true,
content: { "application/json": { schema: claimSsoRequest } },
@@ -795,7 +794,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -807,7 +805,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Preview a managed-profile handover link",
description:
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address.",
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address. Since v1.43 answered on a single-sign-on-only instance too, with `methods` naming the ways in.",
requestBody: {
required: true,
content: { "application/json": { schema: claimPreviewRequest } },
@@ -821,11 +819,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description:
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically.",
@@ -2338,6 +2331,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.describe(
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
),
intent: z
.literal("claim")
.optional()
.describe(
"v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with `client=native` it fails with `oidc_invalid_request`. `next` is ignored.",
),
client_proto: z
.enum(["http", "https"])
.optional()
@@ -2356,7 +2355,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
"302": {
description:
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser; `/auth/claim?error=<reason>` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
},
},
},
+1
View File
@@ -15,3 +15,4 @@ export type ClaimSsoInput = z.infer<typeof claimSsoSchema>;
/** How a handover link can be redeemed on this server. */
export const HANDOVER_METHODS = ["password", "sso"] as const;
export type HandoverMethod = (typeof HANDOVER_METHODS)[number];