mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
a812fb699f
commit
66533497de
5 files changed
+78
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user