Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions api/docs/account-deletion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions api/docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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(),
Expand All @@ -37,6 +39,7 @@ import { UsersModule } from "./users/users.module";
}),
EventsModule,
S3Module,
AppleSiwaModule,
Auth0ManagementModule,
PhotosModule,
],
Expand Down
19 changes: 19 additions & 0 deletions api/src/config/apple.config.ts
Original file line number Diff line number Diff line change
@@ -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"),
}));
63 changes: 63 additions & 0 deletions api/src/sdk/apple/apple-client-secret.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record<string, unknown>;

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();
});
});
59 changes: 59 additions & 0 deletions api/src/sdk/apple/apple-client-secret.ts
Original file line number Diff line number Diff line change
@@ -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")}`;
};
18 changes: 18 additions & 0 deletions api/src/sdk/apple/apple-siwa.constants.ts
Original file line number Diff line number Diff line change
@@ -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",
};
9 changes: 9 additions & 0 deletions api/src/sdk/apple/apple-siwa.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading