From 8fd5c006e461233ef051ee1d448d2867d26bce00 Mon Sep 17 00:00:00 2001 From: MehrshadFb Date: Wed, 16 Sep 2026 22:55:23 -0400 Subject: [PATCH] feat(api): revoke Sign in with Apple tokens during account deletion --- api/.env.example | 11 ++ api/docs/account-deletion.md | 19 ++- api/docs/authentication.md | 51 +++++++ api/src/app.module.ts | 5 +- api/src/config/apple.config.ts | 19 +++ api/src/sdk/apple/apple-client-secret.spec.ts | 63 ++++++++ api/src/sdk/apple/apple-client-secret.ts | 59 ++++++++ api/src/sdk/apple/apple-siwa.constants.ts | 18 +++ api/src/sdk/apple/apple-siwa.module.ts | 9 ++ api/src/sdk/apple/apple-siwa.service.spec.ts | 131 ++++++++++++++++ api/src/sdk/apple/apple-siwa.service.ts | 111 ++++++++++++++ .../sdk/auth0/auth0-management.constants.ts | 1 + .../auth0/auth0-management.service.spec.ts | 53 ++++++- api/src/sdk/auth0/auth0-management.service.ts | 26 ++++ .../apple-identity-revocation.service.spec.ts | 142 ++++++++++++++++++ .../apple-identity-revocation.service.ts | 105 +++++++++++++ api/src/users/users.constants.ts | 9 ++ api/src/users/users.module.ts | 2 + api/src/users/users.service.spec.ts | 56 +++++++ api/src/users/users.service.ts | 8 + .../integration/users.integration.spec.ts | 88 +++++++++++ 21 files changed, 980 insertions(+), 6 deletions(-) create mode 100644 api/src/config/apple.config.ts create mode 100644 api/src/sdk/apple/apple-client-secret.spec.ts create mode 100644 api/src/sdk/apple/apple-client-secret.ts create mode 100644 api/src/sdk/apple/apple-siwa.constants.ts create mode 100644 api/src/sdk/apple/apple-siwa.module.ts create mode 100644 api/src/sdk/apple/apple-siwa.service.spec.ts create mode 100644 api/src/sdk/apple/apple-siwa.service.ts create mode 100644 api/src/users/apple-identity-revocation.service.spec.ts create mode 100644 api/src/users/apple-identity-revocation.service.ts diff --git a/api/.env.example b/api/.env.example index 6ca9058..08515ba 100644 --- a/api/.env.example +++ b/api/.env.example @@ -26,6 +26,17 @@ AUTH0_AUDIENCE=https://your-api-identifier AUTH0_MANAGEMENT_CLIENT_ID=your-management-m2m-client-id AUTH0_MANAGEMENT_CLIENT_SECRET=your-management-m2m-client-secret +# Sign in with Apple token revocation on account deletion (docs/authentication.md). +# Same Team ID, Key ID and .p8 key as the Auth0 Apple connection. CLIENT_ID is the +# identifier Auth0 presents to Apple for the login flow the app uses: the iOS +# bundle identifier (App ID) for the native flow. Unset: Apple users are still +# deleted, and each deletion logs an error because their Apple token was not revoked. +# The Auth0 management app also needs read:users and read:user_idp_tokens. +# APPLE_SIWA_TEAM_ID=ABCDE12345 +# APPLE_SIWA_KEY_ID=ABCDE12345 +# APPLE_SIWA_CLIENT_ID=com.example.everglow +# APPLE_SIWA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" + # Account deletion reconciler: finishes sagas where Auth0 is gone (or deletion # started) but the Postgres user row remains. # OFF unless set to exactly "true". Enable it ONLY where this DATABASE_URL is the diff --git a/api/docs/account-deletion.md b/api/docs/account-deletion.md index 066f385..cb11486 100644 --- a/api/docs/account-deletion.md +++ b/api/docs/account-deletion.md @@ -120,12 +120,16 @@ This is a small **saga / state machine** for dual-store delete. Durable intent l 2. Mark deletion intent: set deletionStartedAt 3. Prep related database data so user.delete is likely to succeed (events, memberships, photos, etc. per product rules) -4. Delete Auth0 user (idempotent: already-gone / 404 counts as success) -5. Set auth0DeletedAt -6. In one transaction: upsert DeletedProviderSub tombstone, then delete the User row -7. Best-effort S3 / other side cleanup (same spirit as event photo purge) +4. Sign in with Apple only: revoke the Apple token Auth0 holds for the user + (idempotent: Apple answers 200 for an already-revoked token) +5. Delete Auth0 user (idempotent: already-gone / 404 counts as success) +6. Set auth0DeletedAt +7. In one transaction: upsert DeletedProviderSub tombstone, then delete the User row +8. Best-effort S3 / other side cleanup (same spirit as event photo purge) ``` +Step 4 exists because Apple treats the app as still authorised until the token is revoked, and that token lives on the Auth0 user, so it must be revoked before the user is deleted. It repeats on every pass that still has an Auth0 user, which is safe. See [authentication.md](./authentication.md#10-sign-in-with-apple) for the rule and the ops setup. + Derived state from the two nullable timestamps (no separate status enum): | `deletionStartedAt` | `auth0DeletedAt` | Meaning | @@ -134,6 +138,13 @@ Derived state from the two nullable timestamps (no separate status enum): | set | null | Deletion in progress; Auth0 not confirmed cleared | | set | set | Auth0 cleared; database teardown still owed (reconciler) | +### If Apple revocation fails (after the flag) + +Two cases, decided by whether trying again could help: + +- **Apple unreachable** (transport error, 5xx): the saga stops before the Auth0 delete and returns an error. `deletionStartedAt` stays set, the Auth0 user and its token stay put, and the next pass (client retry or reconciler) revokes again. Same shape as an Auth0 failure. +- **Apple refuses** (400: wrong client id, bad key) or **no token to revoke** (Apple credentials not configured, management client lacks `read:user_idp_tokens`, connection stores no token): logged at `error` with `audit: true`, and the saga carries on to delete the Auth0 user. Apple's own guidance is that the deletion must still be honoured; the person then has to unlink the app under Settings → Apple ID → Sign in with Apple themselves. Retrying would only delay a deletion the person asked for, and the token is lost with the Auth0 user either way. + ### If Auth0 fails (after the flag) Leave `deletionStartedAt` set and return an error. Do not delete the database user. diff --git a/api/docs/authentication.md b/api/docs/authentication.md index 2d33c73..584674a 100644 --- a/api/docs/authentication.md +++ b/api/docs/authentication.md @@ -189,3 +189,54 @@ Account **deletion** is closer in spirit to a saga across two stores (flags, Aut 4. **Re-join via new identity**: a new Auth0 `sub` after a real re-signup can provision a new app user; the old `sub` stays blocked. Implementation details (table or column names, exact status codes, retention job) live with the users / auth code and can evolve. This document is the why. + +--- + +## 10. Sign in with Apple + +Apple login goes through the Auth0 **Apple social connection**; the API never talks to Apple to authenticate anyone. What reaches the API is still an Auth0 access token, so the request path in §2 is unchanged. Three things are specific to Apple. + +### 10.1 What the API sees + +Auth0 derives the subject from Apple's stable per-team user identifier, so the `sub` (and our `providerSub`) looks like `apple|001234.abcdef0123456789abcdef.0123`. Nothing else about the token differs: same issuer, audience and signing keys. JIT provisioning (§4) creates the `User` row on first request exactly as for any other connection, and `isAppleProviderSub` in `users.constants.ts` is the only place the prefix is inspected. + +The access token carries no email or name. Onboarding stays the client's job: `POST /users/me/onboarding` receives the email the person chooses to give us. With **Hide My Email**, that may be an `@privaterelay.appleid.com` address. It is a valid, unique address for that person and app, so nothing on the API needs to know it is a relay; `UserDetails.email` stores it like any other. It cascades away with the `User` row on deletion, so a later re-signup that produces a different relay address cannot collide with it. + +### 10.2 Re-signup after delete + +Apple's identifier is stable for the same Apple ID and developer team, so after deletion the same `apple|…` `sub` comes back. That is the social-connection case §6 already handles: a token minted after the tombstone's `deletedAt` provisions a fresh account. No Apple-specific rule is needed. + +What Apple _does_ need is the token revocation below. Without it the app stays listed under the person's Apple ID as authorised, so their next sign-in skips Apple's consent screen and Apple never re-sends their email or name to Auth0. The Auth0 user is then created without an email, and any email-dependent step in the tenant fails. Revocation resets that, and is also what Apple's App Store review checks. + +### 10.3 Token revocation on account deletion + +Apple requires apps that offer Sign in with Apple to revoke the user's Apple tokens when the account is deleted (App Store Review Guideline 5.1.1(v); Apple technote TN3194). Auth0 obtained those tokens when it exchanged the authorization code, keeps them on the user's `identities[]` entry for the Apple connection, and **does not revoke them when the user is deleted**. Deleting the Auth0 user simply discards them. So the API revokes first, then deletes: + +```text +intent → prep → revoke Apple token (if apple|) → Auth0 delete → tombstone + row delete +``` + +Implementation: + +- `AppleIdentityRevocationService` (users module) decides whether the step applies and what to log. It reads the Apple identity's tokens through `Auth0ManagementService.getIdentityProviderTokens`, prefers the refresh token (revoking only the access token leaves the authorisation in place) and calls `AppleSiwaService.revokeToken`. +- `AppleSiwaService` (`src/sdk/apple`) posts to `https://appleid.apple.com/auth/revoke` with a per-request `client_secret`: an ES256 JWT signed with the Sign in with Apple private key (`signAppleClientSecret`). Apple answers `200` for a token that is already revoked, so the step is idempotent and a resumed saga repeats it safely. +- Failure handling is in [account-deletion.md](./account-deletion.md): Apple unreachable → the saga stops and retries later with the token still in Auth0; Apple refuses or there is no token → logged, deletion continues. + +The client id sent to Apple must be the one Auth0 presented when the person authorised. For the native iOS flow that is the app's bundle identifier (the App ID on the connection's iOS settings), not the Services ID used by browser-based Universal Login. A mismatch is a `400 invalid_client`, logged and not retried. + +Nothing here is stored in our database: no Apple tokens, no new columns. Apple's own user identifier only ever appears inside `providerSub`. + +### 10.4 Tenant and portal setup (outside this repository) + +Auth0 Dashboard: + +1. **Authentication → Social → Apple**: Client ID (Services ID), Team ID, Key ID and the .p8 signing key; under iOS settings the app's **App ID / bundle identifier** for the native flow. Enable the connection for the mobile application. +2. Same connection: turn on storing the Apple refresh token if the setting is offered (Auth0 staff refer to it as "Fetch Refresh Token"). Without it only the access token is available and revocation does not fully unlink the app; the API logs `tokenType: "access_token"` when that happens. +3. **Applications → APIs → Auth0 Management API → Machine to Machine Applications**: the API's management client needs `read:users` and `read:user_idp_tokens` in addition to `delete:users`. + +Apple Developer portal: + +4. The Sign in with Apple key (Team ID, Key ID, .p8) and the App ID must match what the connection uses. The API gets the same values as `APPLE_SIWA_TEAM_ID`, `APPLE_SIWA_KEY_ID`, `APPLE_SIWA_PRIVATE_KEY` and `APPLE_SIWA_CLIENT_ID` (see `.env.example`). +5. Optional, not required for review: register a server-to-server notification endpoint so Apple's `consent-revoked` and `account-delete` events can start the deletion saga when the person unlinks the app from their Apple ID settings instead of from within the app. Not implemented yet. + +Verifying a deployment: delete an Apple-signed-in test account, then check the device's Settings → Apple ID → Sign in with Apple. The app must no longer be listed, and the next sign-in must show Apple's full consent screen again. diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 7e69dcb..4201b55 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -7,6 +7,7 @@ import { AppService } from "./app.service"; import { AuthModule } from "./auth/auth.module"; import { CaslModule } from "./casl/casl.module"; import { buildLoggerConfig } from "./common/logging/logging.config"; +import appleConfig from "./config/apple.config"; import auth0Config from "./config/auth0.config"; import awsConfig from "./config/aws.config"; import encryptionConfig from "./config/encryption.config"; @@ -15,6 +16,7 @@ import usersConfig from "./config/users.config"; import { EventsModule } from "./events/events.module"; import { PhotosModule } from "./photos/photos.module"; import { PrismaModule } from "./prisma/prisma.module"; +import { AppleSiwaModule } from "./sdk/apple/apple-siwa.module"; import { Auth0ManagementModule } from "./sdk/auth0/auth0-management.module"; import { S3Module } from "./sdk/aws/s3/s3.module"; import { UsersModule } from "./users/users.module"; @@ -23,7 +25,7 @@ import { UsersModule } from "./users/users.module"; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [auth0Config, awsConfig, encryptionConfig, photosConfig, usersConfig], + load: [appleConfig, auth0Config, awsConfig, encryptionConfig, photosConfig, usersConfig], envFilePath: ".env", }), ScheduleModule.forRoot(), @@ -37,6 +39,7 @@ import { UsersModule } from "./users/users.module"; }), EventsModule, S3Module, + AppleSiwaModule, Auth0ManagementModule, PhotosModule, ], diff --git a/api/src/config/apple.config.ts b/api/src/config/apple.config.ts new file mode 100644 index 0000000..a5d75fa --- /dev/null +++ b/api/src/config/apple.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from "@nestjs/config"; + +/** + * Sign in with Apple credentials the API uses to revoke a user's Apple tokens + * during account deletion (see docs/authentication.md, "Sign in with Apple"). + * + * These are the same Team ID, Key ID and .p8 key the Auth0 Apple connection is + * configured with. `clientId` must be the identifier Auth0 presented to Apple + * when the user authorised, which for the native iOS flow is the app's bundle + * identifier (App ID), not the Services ID. + */ +export default registerAs("apple", () => ({ + siwaTeamId: process.env.APPLE_SIWA_TEAM_ID, + siwaKeyId: process.env.APPLE_SIWA_KEY_ID, + siwaClientId: process.env.APPLE_SIWA_CLIENT_ID, + // The .p8 contents. Env files cannot hold newlines, so an escaped "\n" is + // accepted and unescaped here. + siwaPrivateKey: process.env.APPLE_SIWA_PRIVATE_KEY?.replace(/\\n/g, "\n"), +})); diff --git a/api/src/sdk/apple/apple-client-secret.spec.ts b/api/src/sdk/apple/apple-client-secret.spec.ts new file mode 100644 index 0000000..87aeec9 --- /dev/null +++ b/api/src/sdk/apple/apple-client-secret.spec.ts @@ -0,0 +1,63 @@ +import { generateKeyPairSync, verify } from "node:crypto"; +import { signAppleClientSecret } from "./apple-client-secret"; +import { APPLE_CLIENT_SECRET_MAX_TTL_SECONDS, APPLE_SIWA_ERRORS, APPLE_SIWA_ISSUER } from "./apple-siwa.constants"; + +const decodeSegment = (segment: string): Record => + JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record; + +describe("signAppleClientSecret", () => { + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + + const input = { + teamId: "TEAM123456", + keyId: "KEYID12345", + clientId: "com.example.everglow", + privateKey: privateKeyPem, + issuedAt: 1_760_000_000, + }; + + it("produces an ES256 JWT with the header and claims Apple specifies", () => { + const jwt = signAppleClientSecret(input); + const [header, payload] = jwt.split("."); + + expect(decodeSegment(header)).toEqual({ alg: "ES256", kid: "KEYID12345" }); + expect(decodeSegment(payload)).toEqual({ + iss: "TEAM123456", + iat: 1_760_000_000, + exp: 1_760_000_300, + aud: APPLE_SIWA_ISSUER, + sub: "com.example.everglow", + }); + }); + + it("signs with the private key in the raw r||s form JWS requires", () => { + const jwt = signAppleClientSecret(input); + const [header, payload, signature] = jwt.split("."); + + const valid = verify( + "sha256", + Buffer.from(`${header}.${payload}`), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(signature, "base64url"), + ); + expect(valid).toBe(true); + expect(Buffer.from(signature, "base64url")).toHaveLength(64); + }); + + it("honours a custom TTL", () => { + const jwt = signAppleClientSecret({ ...input, ttlSeconds: 60 }); + expect(decodeSegment(jwt.split(".")[1]).exp).toBe(1_760_000_060); + }); + + it("refuses a TTL beyond Apple's six-month ceiling", () => { + const ttlSeconds = APPLE_CLIENT_SECRET_MAX_TTL_SECONDS + 1; + expect(() => signAppleClientSecret({ ...input, ttlSeconds })).toThrow( + APPLE_SIWA_ERRORS.CLIENT_SECRET_TTL_TOO_LONG(ttlSeconds), + ); + }); + + it("rejects a key that is not a valid PEM private key", () => { + expect(() => signAppleClientSecret({ ...input, privateKey: "not a key" })).toThrow(); + }); +}); diff --git a/api/src/sdk/apple/apple-client-secret.ts b/api/src/sdk/apple/apple-client-secret.ts new file mode 100644 index 0000000..1eceaeb --- /dev/null +++ b/api/src/sdk/apple/apple-client-secret.ts @@ -0,0 +1,59 @@ +import { createPrivateKey, sign } from "node:crypto"; +import { + APPLE_CLIENT_SECRET_MAX_TTL_SECONDS, + APPLE_CLIENT_SECRET_TTL_SECONDS, + APPLE_SIWA_ERRORS, + APPLE_SIWA_ISSUER, +} from "./apple-siwa.constants"; + +export interface AppleClientSecretInput { + /** 10-character Apple Developer Team ID; becomes `iss`. */ + teamId: string; + /** 10-character Key ID of the Sign in with Apple private key; becomes the `kid` header. */ + keyId: string; + /** App ID (bundle identifier) or Services ID the user authorised against; becomes `sub`. */ + clientId: string; + /** PEM contents of the .p8 key. */ + privateKey: string; + /** Unix seconds; defaults to now. */ + issuedAt?: number; + ttlSeconds?: number; +} + +const base64url = (input: string | Buffer): string => Buffer.from(input).toString("base64url"); + +/** + * Mints the `client_secret` Apple's REST endpoints expect: an ES256 JWT signed + * with the Sign in with Apple private key. Small enough that a JWT library is + * not worth a dependency; the shape is fixed by Apple and covered by tests. + */ +export const signAppleClientSecret = ({ + teamId, + keyId, + clientId, + privateKey, + issuedAt = Math.floor(Date.now() / 1000), + ttlSeconds = APPLE_CLIENT_SECRET_TTL_SECONDS, +}: AppleClientSecretInput): string => { + if (ttlSeconds > APPLE_CLIENT_SECRET_MAX_TTL_SECONDS) { + throw new Error(APPLE_SIWA_ERRORS.CLIENT_SECRET_TTL_TOO_LONG(ttlSeconds)); + } + + const header = { alg: "ES256", kid: keyId }; + const payload = { + iss: teamId, + iat: issuedAt, + exp: issuedAt + ttlSeconds, + aud: APPLE_SIWA_ISSUER, + sub: clientId, + }; + const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`; + + // JWS wants the raw r||s signature, not the DER encoding node produces by default. + const signature = sign("sha256", Buffer.from(signingInput), { + key: createPrivateKey(privateKey), + dsaEncoding: "ieee-p1363", + }); + + return `${signingInput}.${signature.toString("base64url")}`; +}; diff --git a/api/src/sdk/apple/apple-siwa.constants.ts b/api/src/sdk/apple/apple-siwa.constants.ts new file mode 100644 index 0000000..b4acf3d --- /dev/null +++ b/api/src/sdk/apple/apple-siwa.constants.ts @@ -0,0 +1,18 @@ +export const APPLE_SIWA_ISSUER = "https://appleid.apple.com"; +export const APPLE_SIWA_REVOKE_URL = `${APPLE_SIWA_ISSUER}/auth/revoke`; + +// Apple accepts a client secret valid for up to six months; ours is minted per +// request, so a few minutes covers clock skew and nothing more. +export const APPLE_CLIENT_SECRET_TTL_SECONDS = 300; +export const APPLE_CLIENT_SECRET_MAX_TTL_SECONDS = 15_777_000; + +export const APPLE_SIWA_REQUEST_TIMEOUT_MS = 10_000; + +export const APPLE_SIWA_ERRORS = { + CREDENTIALS_NOT_CONFIGURED: () => "Sign in with Apple revocation credentials are not configured", + CLIENT_SECRET_TTL_TOO_LONG: (ttl: number) => + `Apple client secret TTL of ${ttl}s exceeds Apple's maximum of ${APPLE_CLIENT_SECRET_MAX_TTL_SECONDS}s`, + REVOKE_REJECTED: (code: string) => `Apple rejected the token revocation request (${code})`, + REVOKE_UNAVAILABLE: (status: number) => `Apple token revocation endpoint responded with HTTP ${status}`, + REVOKE_TRANSPORT_FAILED: () => "Apple token revocation request failed before a response was received", +}; diff --git a/api/src/sdk/apple/apple-siwa.module.ts b/api/src/sdk/apple/apple-siwa.module.ts new file mode 100644 index 0000000..1986823 --- /dev/null +++ b/api/src/sdk/apple/apple-siwa.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { AppleSiwaService } from "./apple-siwa.service"; + +@Global() +@Module({ + providers: [AppleSiwaService], + exports: [AppleSiwaService], +}) +export class AppleSiwaModule {} diff --git a/api/src/sdk/apple/apple-siwa.service.spec.ts b/api/src/sdk/apple/apple-siwa.service.spec.ts new file mode 100644 index 0000000..be0bf3a --- /dev/null +++ b/api/src/sdk/apple/apple-siwa.service.spec.ts @@ -0,0 +1,131 @@ +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import { generateKeyPairSync } from "node:crypto"; +import { PinoLogger } from "nestjs-pino"; +import { APPLE_SIWA_ERRORS, APPLE_SIWA_REVOKE_URL } from "./apple-siwa.constants"; +import { AppleSiwaService, AppleTokenRevocationError } from "./apple-siwa.service"; + +describe("AppleSiwaService", () => { + const privateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) + .privateKey.export({ type: "pkcs8", format: "pem" }) + .toString(); + + const configured: Record = { + "apple.siwaTeamId": "TEAM123456", + "apple.siwaKeyId": "KEYID12345", + "apple.siwaClientId": "com.example.everglow", + "apple.siwaPrivateKey": privateKey, + }; + + const fetchMock = jest.fn, Parameters>(); + + const buildService = async (config: Record): Promise => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AppleSiwaService, + { provide: ConfigService, useValue: { get: jest.fn((key: string) => config[key]) } }, + { + provide: PinoLogger, + useValue: { setContext: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + }, + ], + }).compile(); + + return module.get(AppleSiwaService); + }; + + const jsonResponse = (status: number, body?: unknown): Response => + new Response(body === undefined ? null : JSON.stringify(body), { status }); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + describe("isRevocationConfigured", () => { + it("is true only when every Apple credential is present", async () => { + await expect(buildService(configured).then((s) => s.isRevocationConfigured())).resolves.toBe(true); + await expect( + buildService({ ...configured, "apple.siwaClientId": undefined }).then((s) => s.isRevocationConfigured()), + ).resolves.toBe(false); + await expect(buildService({}).then((s) => s.isRevocationConfigured())).resolves.toBe(false); + }); + }); + + describe("revokeToken", () => { + it("posts the token with a freshly signed client secret to Apple's revoke endpoint", async () => { + fetchMock.mockResolvedValue(jsonResponse(200)); + const service = await buildService(configured); + + await expect(service.revokeToken("refresh-abc", "refresh_token")).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(APPLE_SIWA_REVOKE_URL); + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ "content-type": "application/x-www-form-urlencoded" }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + + const body = init?.body as URLSearchParams; + expect(body.get("client_id")).toBe("com.example.everglow"); + expect(body.get("token")).toBe("refresh-abc"); + expect(body.get("token_type_hint")).toBe("refresh_token"); + expect(body.get("client_secret")?.split(".")).toHaveLength(3); + }); + + it("treats Apple's 200 for an already-revoked token as success", async () => { + fetchMock.mockResolvedValue(jsonResponse(200)); + const service = await buildService(configured); + + await expect(service.revokeToken("already-gone", "access_token")).resolves.toBeUndefined(); + }); + + it("fails without retry on a 400, carrying Apple's error code", async () => { + fetchMock.mockResolvedValue(jsonResponse(400, { error: "invalid_client" })); + const service = await buildService(configured); + + const failure = await service.revokeToken("refresh-abc", "refresh_token").catch((e: unknown) => e); + expect(failure).toBeInstanceOf(AppleTokenRevocationError); + expect((failure as AppleTokenRevocationError).retryable).toBe(false); + expect((failure as Error).message).toBe(APPLE_SIWA_ERRORS.REVOKE_REJECTED("invalid_client")); + }); + + it("fails without retry on a 400 whose body is not JSON", async () => { + fetchMock.mockResolvedValue(new Response("nope", { status: 400 })); + const service = await buildService(configured); + + const failure = await service.revokeToken("refresh-abc", "refresh_token").catch((e: unknown) => e); + expect((failure as AppleTokenRevocationError).retryable).toBe(false); + expect((failure as Error).message).toBe(APPLE_SIWA_ERRORS.REVOKE_REJECTED("unknown_error")); + }); + + it("fails with retry on a 5xx", async () => { + fetchMock.mockResolvedValue(jsonResponse(503)); + const service = await buildService(configured); + + const failure = await service.revokeToken("refresh-abc", "refresh_token").catch((e: unknown) => e); + expect(failure).toBeInstanceOf(AppleTokenRevocationError); + expect((failure as AppleTokenRevocationError).retryable).toBe(true); + expect((failure as Error).message).toBe(APPLE_SIWA_ERRORS.REVOKE_UNAVAILABLE(503)); + }); + + it("fails with retry when the request never gets a response", async () => { + const cause = new Error("socket hang up"); + fetchMock.mockRejectedValue(cause); + const service = await buildService(configured); + + const failure = await service.revokeToken("refresh-abc", "refresh_token").catch((e: unknown) => e); + expect((failure as AppleTokenRevocationError).retryable).toBe(true); + expect((failure as AppleTokenRevocationError).cause).toBe(cause); + }); + + it("fails without retry, and without calling Apple, when credentials are missing", async () => { + const service = await buildService({}); + + const failure = await service.revokeToken("refresh-abc", "refresh_token").catch((e: unknown) => e); + expect((failure as AppleTokenRevocationError).retryable).toBe(false); + expect((failure as Error).message).toBe(APPLE_SIWA_ERRORS.CREDENTIALS_NOT_CONFIGURED()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/src/sdk/apple/apple-siwa.service.ts b/api/src/sdk/apple/apple-siwa.service.ts new file mode 100644 index 0000000..bec60f3 --- /dev/null +++ b/api/src/sdk/apple/apple-siwa.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PinoLogger } from "nestjs-pino"; +import { signAppleClientSecret } from "./apple-client-secret"; +import { APPLE_SIWA_ERRORS, APPLE_SIWA_REQUEST_TIMEOUT_MS, APPLE_SIWA_REVOKE_URL } from "./apple-siwa.constants"; + +export type AppleTokenTypeHint = "refresh_token" | "access_token"; + +/** + * A revocation that did not go through. `retryable` separates an outage + * (try again later, the token is still there to revoke) from a request Apple + * will keep rejecting (wrong client id, bad key), where retrying only delays + * the account deletion the person asked for. + */ +export class AppleTokenRevocationError extends Error { + constructor( + message: string, + readonly retryable: boolean, + readonly cause?: unknown, + ) { + super(message); + this.name = "AppleTokenRevocationError"; + } +} + +interface AppleSiwaCredentials { + teamId: string; + keyId: string; + clientId: string; + privateKey: string; +} + +/** + * Thin client for Apple's Sign in with Apple REST API. Only revocation is + * needed: Auth0 handles the login, but Apple keeps the app authorised for the + * user until the token Auth0 obtained is revoked, and Auth0 does not do that + * when the user is deleted (see docs/authentication.md, "Sign in with Apple"). + */ +@Injectable() +export class AppleSiwaService { + constructor( + private readonly configService: ConfigService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(this.constructor.name); + } + + isRevocationConfigured(): boolean { + return this.getCredentials() !== null; + } + + /** + * Revokes one Apple token. Apple answers 200 for a token that is already + * revoked or was never valid, so this is idempotent and safe for a resumed + * deletion saga to repeat. + */ + async revokeToken(token: string, tokenTypeHint: AppleTokenTypeHint): Promise { + const credentials = this.getCredentials(); + if (!credentials) { + throw new AppleTokenRevocationError(APPLE_SIWA_ERRORS.CREDENTIALS_NOT_CONFIGURED(), false); + } + + const body = new URLSearchParams({ + client_id: credentials.clientId, + client_secret: signAppleClientSecret(credentials), + token, + token_type_hint: tokenTypeHint, + }); + + let response: Response; + try { + response = await fetch(APPLE_SIWA_REVOKE_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(APPLE_SIWA_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new AppleTokenRevocationError(APPLE_SIWA_ERRORS.REVOKE_TRANSPORT_FAILED(), true, error); + } + + if (response.ok) return; + + // 400 is the only error Apple documents: a malformed request or wrong + // client credentials, which no retry fixes. Anything else is treated as + // an outage. + if (response.status === 400) { + throw new AppleTokenRevocationError(APPLE_SIWA_ERRORS.REVOKE_REJECTED(await readErrorCode(response)), false); + } + throw new AppleTokenRevocationError(APPLE_SIWA_ERRORS.REVOKE_UNAVAILABLE(response.status), true); + } + + private getCredentials(): AppleSiwaCredentials | null { + const teamId = this.configService.get("apple.siwaTeamId"); + const keyId = this.configService.get("apple.siwaKeyId"); + const clientId = this.configService.get("apple.siwaClientId"); + const privateKey = this.configService.get("apple.siwaPrivateKey"); + if (!teamId || !keyId || !clientId || !privateKey) return null; + + return { teamId, keyId, clientId, privateKey }; + } +} + +const readErrorCode = async (response: Response): Promise => { + try { + const parsed = (await response.json()) as { error?: unknown }; + return typeof parsed.error === "string" ? parsed.error : "unknown_error"; + } catch { + return "unknown_error"; + } +}; diff --git a/api/src/sdk/auth0/auth0-management.constants.ts b/api/src/sdk/auth0/auth0-management.constants.ts index bc747de..b72d60e 100644 --- a/api/src/sdk/auth0/auth0-management.constants.ts +++ b/api/src/sdk/auth0/auth0-management.constants.ts @@ -1,4 +1,5 @@ export const AUTH0_MANAGEMENT_ERRORS = { CREDENTIALS_NOT_CONFIGURED: () => "Auth0 Management API credentials are not configured", DELETE_USER_FAILED: (providerSub: string) => `Failed to delete Auth0 user "${providerSub}"`, + GET_USER_FAILED: (providerSub: string) => `Failed to read Auth0 user "${providerSub}"`, }; diff --git a/api/src/sdk/auth0/auth0-management.service.spec.ts b/api/src/sdk/auth0/auth0-management.service.spec.ts index 8d79766..35b4d76 100644 --- a/api/src/sdk/auth0/auth0-management.service.spec.ts +++ b/api/src/sdk/auth0/auth0-management.service.spec.ts @@ -7,6 +7,7 @@ import { AUTH0_MANAGEMENT_ERRORS } from "./auth0-management.constants"; import { Auth0ManagementService } from "./auth0-management.service"; const mockDeleteUser = jest.fn(); +const mockGetUser = jest.fn(); jest.mock("auth0", () => { class MockManagementError extends Error { @@ -19,7 +20,7 @@ jest.mock("auth0", () => { } return { ManagementClient: jest.fn().mockImplementation(() => ({ - users: { delete: mockDeleteUser }, + users: { delete: mockDeleteUser, get: mockGetUser }, })), ManagementError: MockManagementError, }; @@ -54,6 +55,7 @@ describe("Auth0ManagementService", () => { beforeEach(async () => { mockDeleteUser.mockReset(); + mockGetUser.mockReset(); jest.mocked(ManagementClient).mockClear(); const module: TestingModule = await Test.createTestingModule({ @@ -108,6 +110,55 @@ describe("Auth0ManagementService", () => { await expect(service.deleteUser("auth0|abc123")).rejects.toBeInstanceOf(InternalServerErrorException); }); + describe("getIdentityProviderTokens", () => { + it("returns the tokens of the identity from the requested provider", async () => { + mockGetUser.mockResolvedValue({ + identities: [ + { provider: "auth0", connection: "Username-Password-Authentication", user_id: "abc" }, + { provider: "apple", connection: "apple", user_id: "001.abc", access_token: "a-1", refresh_token: "r-1" }, + ], + }); + + await expect(service.getIdentityProviderTokens("apple|001.abc", "apple")).resolves.toEqual({ + accessToken: "a-1", + refreshToken: "r-1", + }); + expect(mockGetUser).toHaveBeenCalledWith("apple|001.abc", { fields: "identities", include_fields: true }); + }); + + it("returns empty tokens when the identity has none exposed (missing read:user_idp_tokens)", async () => { + mockGetUser.mockResolvedValue({ identities: [{ provider: "apple", connection: "apple", user_id: "001.abc" }] }); + + await expect(service.getIdentityProviderTokens("apple|001.abc", "apple")).resolves.toEqual({ + accessToken: undefined, + refreshToken: undefined, + }); + }); + + it("returns empty tokens when no identity matches the provider", async () => { + mockGetUser.mockResolvedValue({ identities: [{ provider: "auth0", connection: "db", user_id: "abc" }] }); + + await expect(service.getIdentityProviderTokens("auth0|abc", "apple")).resolves.toEqual({ + accessToken: undefined, + refreshToken: undefined, + }); + }); + + it("returns null when the Auth0 user no longer exists", async () => { + mockGetUser.mockRejectedValue(new ManagementError({ message: "not found", statusCode: 404 })); + + await expect(service.getIdentityProviderTokens("apple|001.abc", "apple")).resolves.toBeNull(); + }); + + it("maps unexpected Auth0 errors to InternalServerErrorException", async () => { + mockGetUser.mockRejectedValue(new ManagementError({ message: "boom", statusCode: 500 })); + + await expect(service.getIdentityProviderTokens("apple|001.abc", "apple")).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + }); + }); + describe("credentials", () => { // Account deletion is the only caller, so a missing secret must fail that // one endpoint rather than stop the whole API from starting. diff --git a/api/src/sdk/auth0/auth0-management.service.ts b/api/src/sdk/auth0/auth0-management.service.ts index 43b3908..9bb2207 100644 --- a/api/src/sdk/auth0/auth0-management.service.ts +++ b/api/src/sdk/auth0/auth0-management.service.ts @@ -4,6 +4,12 @@ import { ManagementClient, ManagementError } from "auth0"; import { PinoLogger } from "nestjs-pino"; import { AUTH0_MANAGEMENT_ERRORS } from "./auth0-management.constants"; +/** Tokens the upstream identity provider issued to Auth0 for one of a user's identities. */ +export interface IdentityProviderTokens { + accessToken?: string; + refreshToken?: string; +} + @Injectable() export class Auth0ManagementService { private client?: ManagementClient; @@ -36,6 +42,26 @@ export class Auth0ManagementService { return this.client; } + /** + * The tokens Auth0 holds from `provider` for this user, or null when the + * Auth0 user no longer exists. Auth0 only returns these fields when the + * management client has the `read:user_idp_tokens` scope; without it the + * result is an empty object, not an error. + */ + async getIdentityProviderTokens(providerSub: string, provider: string): Promise { + const client = this.getClient(); + try { + const user = await client.users.get(providerSub, { fields: "identities", include_fields: true }); + const identity = user.identities?.find((candidate) => candidate.provider === provider); + return { accessToken: identity?.access_token, refreshToken: identity?.refresh_token }; + } catch (error) { + if (isAuth0NotFound(error)) return null; + + this.logger.error({ err: error as Error, providerSub, provider }, "auth0 getUser failed"); + throw new InternalServerErrorException(AUTH0_MANAGEMENT_ERRORS.GET_USER_FAILED(providerSub)); + } + } + async deleteUser(providerSub: string): Promise { const client = this.getClient(); try { diff --git a/api/src/users/apple-identity-revocation.service.spec.ts b/api/src/users/apple-identity-revocation.service.spec.ts new file mode 100644 index 0000000..29bbbfd --- /dev/null +++ b/api/src/users/apple-identity-revocation.service.spec.ts @@ -0,0 +1,142 @@ +import { InternalServerErrorException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DeepMockProxy, mockDeep } from "jest-mock-extended"; +import { PinoLogger } from "nestjs-pino"; +import { AppleSiwaService, AppleTokenRevocationError } from "src/sdk/apple/apple-siwa.service"; +import { Auth0ManagementService } from "src/sdk/auth0/auth0-management.service"; +import { AppleIdentityRevocationService } from "./apple-identity-revocation.service"; + +describe("AppleIdentityRevocationService", () => { + let service: AppleIdentityRevocationService; + let auth0Management: DeepMockProxy; + let appleSiwa: DeepMockProxy; + let logger: { setContext: jest.Mock; info: jest.Mock; warn: jest.Mock; error: jest.Mock; debug: jest.Mock }; + + const userId = "11111111-1111-1111-1111-111111111111"; + const appleSub = "apple|001234.abcdef0123456789.0987"; + + beforeEach(async () => { + auth0Management = mockDeep(); + appleSiwa = mockDeep(); + appleSiwa.isRevocationConfigured.mockReturnValue(true); + logger = { setContext: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AppleIdentityRevocationService, + { provide: Auth0ManagementService, useValue: auth0Management }, + { provide: AppleSiwaService, useValue: appleSiwa }, + { provide: PinoLogger, useValue: logger }, + ], + }).compile(); + + service = module.get(AppleIdentityRevocationService); + }); + + it("does nothing for a non-Apple identity", async () => { + await expect(service.revokeBeforeAuth0Delete(userId, "auth0|abc123")).resolves.toBe("not_apple"); + await expect(service.revokeBeforeAuth0Delete(userId, "google-oauth2|123")).resolves.toBe("not_apple"); + + expect(auth0Management.getIdentityProviderTokens).not.toHaveBeenCalled(); + expect(appleSiwa.revokeToken).not.toHaveBeenCalled(); + }); + + it("revokes the refresh token Auth0 holds for the Apple identity", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "r-1", accessToken: "a-1" }); + appleSiwa.revokeToken.mockResolvedValue(undefined); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).resolves.toBe("revoked"); + + expect(auth0Management.getIdentityProviderTokens).toHaveBeenCalledWith(appleSub, "apple"); + expect(appleSiwa.revokeToken).toHaveBeenCalledWith("r-1", "refresh_token"); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ event: "user.account.apple_token_revoked", userId, tokenType: "refresh_token" }), + expect.any(String), + ); + }); + + it("falls back to the access token when Auth0 stored no refresh token", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ accessToken: "a-1" }); + appleSiwa.revokeToken.mockResolvedValue(undefined); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).resolves.toBe("revoked"); + + expect(appleSiwa.revokeToken).toHaveBeenCalledWith("a-1", "access_token"); + }); + + it("never logs the token values", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "r-secret", accessToken: "a-secret" }); + appleSiwa.revokeToken.mockResolvedValue(undefined); + + await service.revokeBeforeAuth0Delete(userId, appleSub); + + const calls: unknown[] = [logger.info.mock.calls, logger.warn.mock.calls, logger.error.mock.calls]; + const logged = JSON.stringify(calls); + expect(logged).not.toContain("r-secret"); + expect(logged).not.toContain("a-secret"); + }); + + it("skips with an error log when Apple credentials are not configured", async () => { + appleSiwa.isRevocationConfigured.mockReturnValue(false); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).resolves.toBe("skipped_unconfigured"); + + expect(auth0Management.getIdentityProviderTokens).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ event: "user.account.apple_revocation_skipped", reason: "unconfigured", userId }), + expect.any(String), + ); + }); + + it("skips quietly when the Auth0 user is already gone (resumed saga)", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue(null); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).resolves.toBe("skipped_identity_gone"); + + expect(appleSiwa.revokeToken).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "user.account.apple_revocation_skipped", reason: "identity_gone" }), + expect.any(String), + ); + }); + + it("skips with an error log when Auth0 returned no token for the identity", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({}); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).resolves.toBe("skipped_no_token"); + + expect(appleSiwa.revokeToken).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ event: "user.account.apple_revocation_skipped", reason: "no_token" }), + expect.any(String), + ); + }); + + it("logs and continues when Apple rejects the request for good", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "r-1" }); + appleSiwa.revokeToken.mockRejectedValue(new AppleTokenRevocationError("invalid_client", false)); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).resolves.toBe("failed"); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ event: "user.account.apple_revocation_failed", userId, retryable: false }), + expect.any(String), + ); + }); + + it("propagates a retryable Apple failure so the saga retries while the token still exists", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "r-1" }); + const outage = new AppleTokenRevocationError("503", true); + appleSiwa.revokeToken.mockRejectedValue(outage); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).rejects.toBe(outage); + }); + + it("propagates an Auth0 read failure", async () => { + const auth0Error = new InternalServerErrorException("auth0 down"); + auth0Management.getIdentityProviderTokens.mockRejectedValue(auth0Error); + + await expect(service.revokeBeforeAuth0Delete(userId, appleSub)).rejects.toBe(auth0Error); + expect(appleSiwa.revokeToken).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/users/apple-identity-revocation.service.ts b/api/src/users/apple-identity-revocation.service.ts new file mode 100644 index 0000000..da91401 --- /dev/null +++ b/api/src/users/apple-identity-revocation.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; +import { AppleSiwaService, AppleTokenRevocationError, AppleTokenTypeHint } from "src/sdk/apple/apple-siwa.service"; +import { Auth0ManagementService } from "src/sdk/auth0/auth0-management.service"; +import { APPLE_PROVIDER, isAppleProviderSub } from "./users.constants"; + +export type AppleRevocationOutcome = + | "not_apple" + | "revoked" + | "skipped_unconfigured" + | "skipped_identity_gone" + | "skipped_no_token" + | "failed"; + +/** + * Apple's account-deletion rule for Sign in with Apple: when the account goes, + * the app must revoke the Apple tokens it holds, or the person stays + * "authorised" in their Apple ID settings and a later sign-in skips Apple's + * consent screen and never re-sends their email. Auth0 obtained those tokens + * and keeps them on the user's identity, and deleting the Auth0 user throws + * them away without revoking anything, so this runs first. + * + * Deletion is never blocked for good by this step. A retryable failure + * (Apple unreachable) propagates so the saga retries while the token is still + * there; anything else is logged for ops and the deletion carries on, which is + * what Apple asks for when a token cannot be revoked. + */ +@Injectable() +export class AppleIdentityRevocationService { + constructor( + private readonly auth0Management: Auth0ManagementService, + private readonly appleSiwa: AppleSiwaService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(this.constructor.name); + } + + /** Must run while the Auth0 user still exists: the token lives on it. */ + async revokeBeforeAuth0Delete(userId: string, providerSub: string): Promise { + if (!isAppleProviderSub(providerSub)) return "not_apple"; + + if (!this.appleSiwa.isRevocationConfigured()) { + this.logger.error( + { event: "user.account.apple_revocation_skipped", userId, reason: "unconfigured", audit: true }, + "Apple user deleted without revoking their Sign in with Apple token: APPLE_SIWA_* is not configured", + ); + return "skipped_unconfigured"; + } + + const tokens = await this.auth0Management.getIdentityProviderTokens(providerSub, APPLE_PROVIDER); + if (tokens === null) { + // Auth0 already deleted the user (a resumed saga). The token went with it; + // nothing is left to revoke on our side. + this.logger.warn( + { event: "user.account.apple_revocation_skipped", userId, reason: "identity_gone", audit: true }, + "Auth0 user already gone before Apple token revocation; nothing left to revoke", + ); + return "skipped_identity_gone"; + } + + const selected = selectToken(tokens); + if (!selected) { + this.logger.error( + { event: "user.account.apple_revocation_skipped", userId, reason: "no_token", audit: true }, + "Auth0 returned no Apple token for this user: check the management client has read:user_idp_tokens " + + "and the Apple connection stores tokens", + ); + return "skipped_no_token"; + } + + try { + await this.appleSiwa.revokeToken(selected.token, selected.tokenType); + } catch (error) { + if (error instanceof AppleTokenRevocationError && !error.retryable) { + this.logger.error( + { err: error, event: "user.account.apple_revocation_failed", userId, retryable: false, audit: true }, + "Apple refused the token revocation; continuing account deletion, the person must revoke manually", + ); + return "failed"; + } + throw error; + } + + this.logger.info( + { event: "user.account.apple_token_revoked", userId, tokenType: selected.tokenType, audit: true }, + "Sign in with Apple token revoked for account deletion saga", + ); + return "revoked"; + } +} + +/** + * The refresh token is what unlinks the app from the Apple ID; revoking only + * the access token leaves the authorisation in place. Fall back to it anyway + * rather than do nothing, and say which one was used so a missing refresh + * token shows up in the logs. + */ +const selectToken = (tokens: { + accessToken?: string; + refreshToken?: string; +}): { token: string; tokenType: AppleTokenTypeHint } | null => { + if (tokens.refreshToken) return { token: tokens.refreshToken, tokenType: "refresh_token" }; + if (tokens.accessToken) return { token: tokens.accessToken, tokenType: "access_token" }; + return null; +}; diff --git a/api/src/users/users.constants.ts b/api/src/users/users.constants.ts index 2217ff1..cfba8d2 100644 --- a/api/src/users/users.constants.ts +++ b/api/src/users/users.constants.ts @@ -11,6 +11,15 @@ export const USER_SERVICE_ERRORS = { ACCOUNT_DELETED: "This account has been deleted. Sign in again to start a new one.", }; +/** + * Auth0 derives the subject for its Apple social connection from Apple's + * stable user identifier: `apple|001234.abcd…`. The prefix is the provider + * name, which is how the API tells an Apple identity from any other without + * a second lookup. + */ +export const APPLE_PROVIDER = "apple"; +export const isAppleProviderSub = (providerSub: string): boolean => providerSub.startsWith(`${APPLE_PROVIDER}|`); + export const DEFAULT_ACCOUNT_DELETION_RECONCILER_BATCH_SIZE = 50; export const DEFAULT_ACCOUNT_DELETION_RECONCILER_STUCK_AFTER_HOURS = 1; diff --git a/api/src/users/users.module.ts b/api/src/users/users.module.ts index 8c6de29..e6c5f44 100644 --- a/api/src/users/users.module.ts +++ b/api/src/users/users.module.ts @@ -3,6 +3,7 @@ import { PhotosModule } from "src/photos/photos.module"; import { AccountDeletionPrepService } from "./account-deletion-prep.service"; import { AccountDeletionReconcilerScheduler } from "./account-deletion-reconciler.scheduler"; import { AccountDeletionReconcilerService } from "./account-deletion-reconciler.service"; +import { AppleIdentityRevocationService } from "./apple-identity-revocation.service"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; @@ -12,6 +13,7 @@ import { UsersService } from "./users.service"; providers: [ UsersService, AccountDeletionPrepService, + AppleIdentityRevocationService, AccountDeletionReconcilerService, AccountDeletionReconcilerScheduler, ], diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index 5df8715..25f417d 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -13,6 +13,7 @@ import { FREE_TIER_STORAGE_LIMIT_BYTES } from "src/photos/photos.constants"; import { PrismaService } from "src/prisma/prisma.service"; import { Auth0ManagementService } from "src/sdk/auth0/auth0-management.service"; import { AccountDeletionPrepService } from "./account-deletion-prep.service"; +import { AppleIdentityRevocationService } from "./apple-identity-revocation.service"; import { hashProviderSub } from "./deleted-provider-sub"; import { CreateUserDetailsDto } from "./dto/create-user-details.dto"; import { UpdateUserDto } from "./dto/update-user.dto"; @@ -39,6 +40,7 @@ describe("UsersService", () => { let prisma: DeepMockProxy; let auth0Management: DeepMockProxy; let deletionPrep: { prepareRelatedData: jest.Mock }; + let appleRevocation: { revokeBeforeAuth0Delete: jest.Mock }; let photoPurge: { purgeObjects: jest.Mock }; const providerSubHash = hashProviderSub("auth0|abc123"); @@ -100,6 +102,7 @@ describe("UsersService", () => { s3Keys: [], }), }; + appleRevocation = { revokeBeforeAuth0Delete: jest.fn().mockResolvedValue("not_apple") }; photoPurge = { purgeObjects: jest.fn().mockResolvedValue({ requested: 0, deleted: 0, failed: 0 }) }; prisma.$transaction.mockImplementation(async (fn) => (fn as (tx: unknown) => Promise)(prisma)); @@ -118,6 +121,10 @@ describe("UsersService", () => { provide: AccountDeletionPrepService, useValue: deletionPrep, }, + { + provide: AppleIdentityRevocationService, + useValue: appleRevocation, + }, { provide: PhotoPurgeService, useValue: photoPurge, @@ -410,6 +417,55 @@ describe("UsersService", () => { ); }); + it("revokes the Apple token before the Auth0 user is deleted, on every pass that still has one", async () => { + prisma.user.update + .mockResolvedValueOnce({ deletionStartedAt } as never) + .mockResolvedValueOnce({ auth0DeletedAt } as never); + appleRevocation.revokeBeforeAuth0Delete.mockResolvedValue("revoked"); + auth0Management.deleteUser.mockResolvedValue(undefined); + prisma.user.delete.mockResolvedValue(userWithDetails); + + await service.completeAccountDeletion(freshDeletionUser()); + + expect(appleRevocation.revokeBeforeAuth0Delete).toHaveBeenCalledWith(userId, providerSub); + // The token lives on the Auth0 user; once that is gone there is nothing left to revoke. + expect(appleRevocation.revokeBeforeAuth0Delete.mock.invocationCallOrder[0]).toBeLessThan( + auth0Management.deleteUser.mock.invocationCallOrder[0], + ); + // And nothing irreversible happens before prep has settled the data. + expect(deletionPrep.prepareRelatedData.mock.invocationCallOrder[0]).toBeLessThan( + appleRevocation.revokeBeforeAuth0Delete.mock.invocationCallOrder[0], + ); + }); + + it("skips Apple revocation once Auth0 is already cleared", async () => { + const auth0ClearedUser: AccountDeletionUser = { + id: userId, + providerSub, + deletionStartedAt, + auth0DeletedAt, + deletionPhotoPolicy: AccountDeletionPhotoPolicy.KEEP, + }; + prisma.user.delete.mockResolvedValue(userWithDetails); + + await service.completeAccountDeletion(auth0ClearedUser); + + expect(appleRevocation.revokeBeforeAuth0Delete).not.toHaveBeenCalled(); + }); + + it("leaves the Auth0 user in place when Apple revocation fails in a retryable way", async () => { + prisma.user.update.mockResolvedValueOnce({ deletionStartedAt } as never); + const outage = new Error("Apple unreachable"); + appleRevocation.revokeBeforeAuth0Delete.mockRejectedValue(outage); + + await expect(service.completeAccountDeletion(freshDeletionUser())).rejects.toBe(outage); + + expect(auth0Management.deleteUser).not.toHaveBeenCalled(); + expect(prisma.user.delete).not.toHaveBeenCalled(); + // Intent stays stamped so the reconciler retries with the token still in Auth0. + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); + it("resumes mid-saga: skips re-stamping intent, retries Auth0, then tombstones and deletes", async () => { const midSagaUser: AccountDeletionUser = { id: userId, diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 6f002c7..a9d6809 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -12,6 +12,7 @@ import { isUniqueConstraintViolation } from "src/prisma/prisma.errors"; import { PrismaService } from "src/prisma/prisma.service"; import { Auth0ManagementService } from "src/sdk/auth0/auth0-management.service"; import { AccountDeletionPrepService } from "./account-deletion-prep.service"; +import { AppleIdentityRevocationService } from "./apple-identity-revocation.service"; import { hashProviderSub, isIssuedAfterDeletion } from "./deleted-provider-sub"; import { CreateUserDetailsDto } from "./dto/create-user-details.dto"; import { UpdateUserDto } from "./dto/update-user.dto"; @@ -30,6 +31,7 @@ export class UsersService { private readonly prisma: PrismaService, private readonly auth0Management: Auth0ManagementService, private readonly deletionPrep: AccountDeletionPrepService, + private readonly appleRevocation: AppleIdentityRevocationService, private readonly photoPurge: PhotoPurgeService, private readonly logger: PinoLogger, ) { @@ -136,6 +138,12 @@ export class UsersService { const { s3Keys } = await this.deletionPrep.prepareRelatedData(id, policy ?? ACCOUNT_DELETION_PHOTO_POLICY_FALLBACK); if (!auth0DeletedAt) { + // Apple keeps the app authorised until the token Auth0 obtained is + // revoked, and that token lives on the Auth0 user, so it has to go + // before the user does. Idempotent: Apple answers 200 for a token that + // is already revoked, so a resumed saga repeats it harmlessly. + await this.appleRevocation.revokeBeforeAuth0Delete(id, providerSub); + // Leave deletionStartedAt set if Auth0 fails so a lost success response // cannot drop the durable marker; retries treat Auth0 404 as success. await this.auth0Management.deleteUser(providerSub); diff --git a/api/test/integration/users.integration.spec.ts b/api/test/integration/users.integration.spec.ts index afce3ff..efe9b09 100644 --- a/api/test/integration/users.integration.spec.ts +++ b/api/test/integration/users.integration.spec.ts @@ -2,6 +2,7 @@ import { INestApplication, InternalServerErrorException, UnauthorizedException } import { AccountDeletionPhotoPolicy, PhotoStatus, PrismaClient } from "generated/prisma/client"; import { Server } from "http"; import { DeepMockProxy, mockDeep, mockReset } from "jest-mock-extended"; +import { AppleSiwaService, AppleTokenRevocationError } from "src/sdk/apple/apple-siwa.service"; import { S3Service } from "src/sdk/aws/s3/s3.service"; import { Auth0ManagementService } from "src/sdk/auth0/auth0-management.service"; import { API_GLOBAL_PREFIX } from "src/swagger/swagger.config"; @@ -45,6 +46,7 @@ describe("UsersController (integration)", () => { let app: INestApplication; let prisma: DeepMockProxy; let auth0Management: DeepMockProxy; + let appleSiwa: DeepMockProxy; let usersService: UsersService; let httpServer: Server; @@ -52,10 +54,13 @@ describe("UsersController (integration)", () => { beforeAll(async () => { auth0Management = mockDeep(); + appleSiwa = mockDeep(); const context = await createTestApp((builder) => builder .overrideProvider(Auth0ManagementService) .useValue(auth0Management) + .overrideProvider(AppleSiwaService) + .useValue(appleSiwa) .overrideProvider(S3Service) .useValue(s3Service), ); @@ -74,6 +79,7 @@ describe("UsersController (integration)", () => { // implementations, preventing stubs from leaking between tests mockReset(prisma); mockReset(auth0Management); + mockReset(appleSiwa); prisma.$transaction.mockImplementation(async (fn) => (fn as (tx: unknown) => Promise)(prisma)); // Prep sweeps events and photos before the Auth0 call; an account with // nothing to settle is the default for these cases. @@ -563,6 +569,88 @@ describe("UsersController (integration)", () => { expect(prisma.user.delete).not.toHaveBeenCalled(); }); + it("does not touch Apple for an identity from another connection", async () => { + const deletionStartedAt = new Date("2026-06-10T12:01:00.000Z"); + const auth0DeletedAt = new Date("2026-06-10T12:02:00.000Z"); + prisma.user.findUnique.mockResolvedValue(buildUserWithDetails()); + prisma.user.update + .mockResolvedValueOnce({ deletionStartedAt } as never) + .mockResolvedValueOnce({ auth0DeletedAt } as never); + auth0Management.deleteUser.mockResolvedValue(undefined); + prisma.user.delete.mockResolvedValue(buildUserWithDetails()); + + await request(httpServer).delete(keepPath).set(authHeader()).expect(204); + + expect(auth0Management.getIdentityProviderTokens).not.toHaveBeenCalled(); + expect(appleSiwa.revokeToken).not.toHaveBeenCalled(); + }); + + describe("Sign in with Apple", () => { + const appleSub = "apple|001234.abcdef0123456789.0123"; + const deletionStartedAt = new Date("2026-06-10T12:01:00.000Z"); + const auth0DeletedAt = new Date("2026-06-10T12:02:00.000Z"); + + beforeEach(() => { + prisma.user.findUnique.mockResolvedValue(buildUserWithDetails({ providerSub: appleSub })); + prisma.user.update + .mockResolvedValueOnce({ deletionStartedAt } as never) + .mockResolvedValueOnce({ auth0DeletedAt } as never); + prisma.user.delete.mockResolvedValue(buildUserWithDetails({ providerSub: appleSub })); + appleSiwa.isRevocationConfigured.mockReturnValue(true); + }); + + it("revokes the Apple refresh token before deleting the Auth0 user", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "apple-refresh" }); + appleSiwa.revokeToken.mockResolvedValue(undefined); + auth0Management.deleteUser.mockResolvedValue(undefined); + + await request(httpServer).delete(keepPath).set(authHeader()).expect(204); + + expect(auth0Management.getIdentityProviderTokens).toHaveBeenCalledWith(appleSub, "apple"); + expect(appleSiwa.revokeToken).toHaveBeenCalledWith("apple-refresh", "refresh_token"); + expect(appleSiwa.revokeToken.mock.invocationCallOrder[0]).toBeLessThan( + auth0Management.deleteUser.mock.invocationCallOrder[0], + ); + expect(auth0Management.deleteUser).toHaveBeenCalledWith(appleSub); + expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: TEST_USER_ID } }); + }); + + it("returns 500 and keeps the Auth0 user when Apple is unreachable, so a retry can still revoke", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "apple-refresh" }); + appleSiwa.revokeToken.mockRejectedValue(new AppleTokenRevocationError("Apple down", true)); + + await request(httpServer).delete(keepPath).set(authHeader()).expect(500); + + expect(auth0Management.deleteUser).not.toHaveBeenCalled(); + expect(prisma.deletedProviderSub.upsert).not.toHaveBeenCalled(); + expect(prisma.user.delete).not.toHaveBeenCalled(); + // Intent stays stamped for the reconciler. + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); + + it("still deletes the account when Apple refuses the revocation outright", async () => { + auth0Management.getIdentityProviderTokens.mockResolvedValue({ refreshToken: "apple-refresh" }); + appleSiwa.revokeToken.mockRejectedValue(new AppleTokenRevocationError("invalid_client", false)); + auth0Management.deleteUser.mockResolvedValue(undefined); + + await request(httpServer).delete(keepPath).set(authHeader()).expect(204); + + expect(auth0Management.deleteUser).toHaveBeenCalledWith(appleSub); + expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: TEST_USER_ID } }); + }); + + it("still deletes the account when Apple credentials are not configured", async () => { + appleSiwa.isRevocationConfigured.mockReturnValue(false); + auth0Management.deleteUser.mockResolvedValue(undefined); + + await request(httpServer).delete(keepPath).set(authHeader()).expect(204); + + expect(auth0Management.getIdentityProviderTokens).not.toHaveBeenCalled(); + expect(appleSiwa.revokeToken).not.toHaveBeenCalled(); + expect(auth0Management.deleteUser).toHaveBeenCalledWith(appleSub); + }); + }); + it("returns 401 when the access token is missing", async () => { const response = await request(httpServer).delete(path).expect(401);