mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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
@@ -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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
Reference in new issue
Block a user