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

@@ -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);
}