fix(auth): carry purgeAt on the native OIDC deletion-pending redirect

The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.

Refs #1341
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 21:18:30 +01:00
1 parent a812fb699f
commit 66533497de
5 files changed
+78 -15

No files matched your search

+4
View File
@@ -16,6 +16,10 @@
country was only read for some languages. The region in the language tag
now picks the country's lines whatever the language; a bare language
code keeps the combined sets as before. (#1338)
- **SSO sign-in in the app names the deletion date.** Signing in with SSO
on the phone to an account pending deletion now carries the date the
account is erased, as the web sign-in already did, so the app can say
until when an admin can still undo it. (#1341)
### Changed
+7 -5
View File
@@ -4058,11 +4058,13 @@ paths:
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.
`oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `account_deletion_pending`,
`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. `account_deletion_pending` means the account is locked pending deletion; when the purge date is
known it rides beside the reason as `purgeAt=<ISO 8601 instant>` on both legs, and nothing else about the
account does. 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.
/api/auth/passkey/register-options:
post:
tags:
@@ -653,6 +653,9 @@ describe("GET /api/auth/oidc/callback — duplicate-callback idempotency", () =>
});
});
/** The two columns `pendingDeletionOf` reads off the user row. */
type PendingRow = Awaited<ReturnType<typeof prisma.user.findUnique>>;
describe("GET /api/auth/oidc/callback — native branch", () => {
beforeEach(() => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
@@ -751,6 +754,40 @@ describe("GET /api/auth/oidc/callback — native branch", () => {
);
});
it("carries purgeAt, and nothing else, on a native sign-in to an account pending deletion", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
deletionRequestedAt: new Date("2026-11-01T10:00:00.000Z"),
deletionPurgeAt: new Date("2026-12-01T10:00:00.000Z"),
} as PendingRow);
const res = await GET(nativeRequest());
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=account_deletion_pending&purgeAt=2026-12-01T10%3A00%3A00.000Z",
);
expect(mintNativeHandoff).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
});
it("omits purgeAt on the native leg when the purge date is unknown", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
deletionRequestedAt: new Date("2026-11-01T10:00:00.000Z"),
deletionPurgeAt: null,
} as PendingRow);
const res = await GET(nativeRequest());
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=account_deletion_pending",
);
});
it("delivers an IdP denial to the custom scheme", async () => {
const res = await GET(
makeRequest({
+29 -9
View File
@@ -95,6 +95,26 @@ function secondFactorRedirect(
return response;
}
/**
* A failure on the native leg: the custom scheme with `error` and, at most,
* a non-secret detail beside it. An error redirect carries no code, ticket,
* or session.
*/
function nativeErrorRedirect(params: Record<string, string>): NextResponse {
const response = NextResponse.redirect(buildNativeCallbackUrl(params));
deleteStateCookie(response);
return response;
}
/**
* The purge date as the `purgeAt` parameter the native leg carries beside
* `account_deletion_pending`, mirroring `deletionPendingRedirect` (#1341).
* Only the date: nothing else about the account rides the URL.
*/
function purgeAtParam(purgeAt: Date | null): Record<string, string> {
return purgeAt ? { purgeAt: purgeAt.toISOString() } : {};
}
/**
* The web sign-in page for an account pending deletion, with the date it is
* erased, so the page can say until when an admin can undo it.
@@ -180,16 +200,13 @@ export const GET = apiHandler(async (req: NextRequest) => {
return response;
};
const failRedirect = (reason: string): NextResponse => {
const failRedirect = (
reason: string,
nativeExtra: Record<string, string> = {},
): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({ error: reason }),
);
deleteStateCookie(response);
return response;
}
if (isNative) return nativeErrorRedirect({ error: reason, ...nativeExtra });
return errorRedirect(reason);
};
@@ -349,7 +366,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
// the caller is this account, so the sign-in page may name the date.
const pendingDeletion = await pendingDeletionOf(user.id);
if (pendingDeletion) {
if (isNative || isClaim) return failRedirect("account_deletion_pending");
if (isNative || isClaim) {
const extra = purgeAtParam(pendingDeletion.purgeAt);
return failRedirect("account_deletion_pending", extra);
}
return deletionPendingRedirect(pendingDeletion.purgeAt);
}
+1 -1
View File
@@ -2455,7 +2455,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; `/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.",
"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`, `account_deletion_pending`, `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. `account_deletion_pending` means the account is locked pending deletion; when the purge date is known it rides beside the reason as `purgeAt=<ISO 8601 instant>` on both legs, and nothing else about the account does. 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.",
},
},
},