diff --git a/CHANGELOG.md b/CHANGELOG.md index 778a927c4..fac8da7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b9712d115..943986f2c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4058,11 +4058,13 @@ paths: drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, 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=` 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: diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts index 161f0e687..2ae3f8081 100644 --- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -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>; + 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({ diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index ae12a38b8..c23f81e47 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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): 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 { + 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 = {}, + ): 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); } diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 05a213b7e..d42f61ea1 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -2455,7 +2455,7 @@ export const authPaths: NonNullable = { responses: { "302": { description: - "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser; `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (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=`, with `mfa_ticket=&methods=`, or with `error=`, 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=` (browser; `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (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=`, with `mfa_ticket=&methods=`, or with `error=`, 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=` 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.", }, }, },