196 lines
6.4 KiB
Markdown
196 lines
6.4 KiB
Markdown
# Apple OAuth Setup
|
|
|
|
Reference for when the Apple Developer account is ready.
|
|
No code changes needed — just fill in the five env vars and restart.
|
|
|
|
## What you need
|
|
|
|
- An **Apple Developer Program** membership ($99/yr) — [enroll here](https://developer.apple.com/programs/enroll/)
|
|
- The app must be served over **HTTPS** in production (localhost works for dev — see note in Step 2)
|
|
|
|
## The five env vars
|
|
|
|
```bash
|
|
AUTH_APPLE_CLIENT_ID= # Services ID identifier (Step 2)
|
|
AUTH_APPLE_CLIENT_SECRET= # Pre-generated JWT (Step 4)
|
|
AUTH_APPLE_TEAM_ID= # 10-char team identifier (Step 1)
|
|
AUTH_APPLE_KEY_ID= # 10-char key identifier (Step 3)
|
|
AUTH_APPLE_PRIVATE_KEY= # Contents of the .p8 file (Step 3)
|
|
```
|
|
|
|
All five must be non-empty or the Apple button won't appear.
|
|
Set any subset to empty to disable the provider without touching code.
|
|
|
|
---
|
|
|
|
## Step 1 — Note your Team ID
|
|
|
|
Sign in to [developer.apple.com](https://developer.apple.com/account).
|
|
Your **Team ID** is the 10-character string shown in the top-right corner
|
|
next to your name (e.g. `ABCD123456`).
|
|
|
|
```
|
|
AUTH_APPLE_TEAM_ID=ABCD123456
|
|
```
|
|
|
|
---
|
|
|
|
## Step 2 — Create an App ID
|
|
|
|
1. [Certificates, IDs & Profiles → Identifiers](https://developer.apple.com/account/resources/identifiers/list)
|
|
2. Click **+** → **App IDs** → **App** → Continue
|
|
3. **Description**: `Local Cal`
|
|
4. **Bundle ID** (Explicit): `com.yourdomain.localcal`
|
|
5. Under **Capabilities** scroll to **Sign In with Apple** → check it →
|
|
**Configure** → **Enable as primary App ID** → Done
|
|
6. Continue → **Register**
|
|
|
|
---
|
|
|
|
## Step 3 — Create a Services ID (the OAuth client ID)
|
|
|
|
This is what identifies the *web* sign-in flow — separate from the App ID above.
|
|
|
|
1. **Identifiers → +** → **Services IDs** → Continue
|
|
2. **Description**: `Local Cal Web`
|
|
3. **Identifier**: `com.yourdomain.localcal.web`
|
|
*(must differ from the Bundle ID)*
|
|
4. Continue → **Register**
|
|
5. Click the Services ID you just created
|
|
6. Enable **Sign In with Apple** → **Configure**
|
|
- **Primary App ID**: select `com.yourdomain.localcal` (Step 2)
|
|
- **Domains and Subdomains**: `yourdomain.com`
|
|
*(no `https://`, no trailing slash, no path)*
|
|
- **Return URLs**:
|
|
```
|
|
https://yourdomain.com/api/auth/callback/apple
|
|
```
|
|
> **localhost dev**: add `localhost` as a domain and
|
|
> `http://localhost:3000/api/auth/callback/apple` as a return URL.
|
|
> Apple permits plain HTTP for localhost only.
|
|
> Alternatively use an HTTPS tunnel:
|
|
> [ngrok](https://ngrok.com/) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
|
|
7. Done → Continue → **Save**
|
|
|
|
```
|
|
AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web
|
|
```
|
|
|
|
---
|
|
|
|
## Step 4 — Create a private key
|
|
|
|
1. [Keys → +](https://developer.apple.com/account/resources/authkeys/list)
|
|
2. **Key Name**: `Local Cal Sign In`
|
|
3. Check **Sign In with Apple** → **Configure** → select the App ID from Step 2 → Save
|
|
4. Continue → **Register**
|
|
5. **Download the `.p8` file immediately** — Apple only lets you download it once.
|
|
Store it somewhere safe (password manager, secrets vault).
|
|
The filename will be `AuthKey_XXXXXXXXXX.p8`.
|
|
|
|
The **Key ID** (`AUTH_APPLE_KEY_ID`) is shown on the key detail page.
|
|
|
|
```
|
|
AUTH_APPLE_KEY_ID=ABC1234567
|
|
```
|
|
|
|
Paste the full contents of the `.p8` file into `AUTH_APPLE_PRIVATE_KEY`:
|
|
|
|
```bash
|
|
# .env.local — wrap multi-line value in double quotes
|
|
AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...
|
|
-----END PRIVATE KEY-----"
|
|
```
|
|
|
|
On deployment platforms (Vercel, Railway, Fly.io) paste the value as-is —
|
|
they handle multi-line secrets natively without quotes.
|
|
|
|
---
|
|
|
|
## Step 5 — Generate the client secret JWT
|
|
|
|
Apple does not use a static client secret. Instead you sign a short-lived JWT
|
|
with the private key. It is valid for up to **180 days**.
|
|
|
|
Run this once with Bun:
|
|
|
|
```ts
|
|
// scripts/gen-apple-secret.ts
|
|
import { importPKCS8, SignJWT } from "jose";
|
|
import { readFileSync } from "fs";
|
|
|
|
const TEAM_ID = "ABCD123456"; // Step 1
|
|
const CLIENT_ID = "com.yourdomain.localcal.web"; // Step 3
|
|
const KEY_ID = "ABC1234567"; // Step 4
|
|
const PRIVATE_KEY = readFileSync("./AuthKey_ABC1234567.p8", "utf8");
|
|
|
|
const key = await importPKCS8(PRIVATE_KEY, "ES256");
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const jwt = await new SignJWT({})
|
|
.setProtectedHeader({ alg: "ES256", kid: KEY_ID })
|
|
.setIssuer(TEAM_ID)
|
|
.setSubject(CLIENT_ID)
|
|
.setAudience("https://appleid.apple.com")
|
|
.setIssuedAt(now)
|
|
.setExpirationTime(now + 180 * 24 * 60 * 60)
|
|
.sign(key);
|
|
|
|
console.log(jwt);
|
|
```
|
|
|
|
```bash
|
|
bun run scripts/gen-apple-secret.ts
|
|
```
|
|
|
|
Copy the printed JWT:
|
|
|
|
```
|
|
AUTH_APPLE_CLIENT_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6Ii...
|
|
```
|
|
|
|
> **Set a reminder** to regenerate ~5 months from now.
|
|
> When the JWT expires the Apple button will silently fail at sign-in.
|
|
|
|
---
|
|
|
|
## Completed `.env.local` block
|
|
|
|
```bash
|
|
AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web
|
|
AUTH_APPLE_CLIENT_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6Ii...
|
|
AUTH_APPLE_TEAM_ID=ABCD123456
|
|
AUTH_APPLE_KEY_ID=ABC1234567
|
|
AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...
|
|
-----END PRIVATE KEY-----"
|
|
```
|
|
|
|
Restart the dev server — the Apple button appears automatically.
|
|
|
|
---
|
|
|
|
## Verifying without a device
|
|
|
|
```bash
|
|
bun test tests/auth.test.ts
|
|
```
|
|
|
|
The tests mock env vars and confirm the Apple provider registers correctly.
|
|
A passing suite means the wiring is correct before you touch the portal.
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Cause | Fix |
|
|
|---|---|---|
|
|
| Apple button missing | Any of the 5 vars is empty | All five must be non-empty |
|
|
| `invalid_client` | JWT expired or signed with wrong key | Regenerate `AUTH_APPLE_CLIENT_SECRET` (Step 5) |
|
|
| `invalid_grant` | `CLIENT_ID` is the Bundle ID, not the Services ID | Use `com.yourdomain.localcal.web` (Step 3) |
|
|
| Redirect fails in dev | Localhost not registered as a domain | Add `localhost` + `http://localhost:3000/api/auth/callback/apple` in Step 3 |
|
|
| Redirect fails in prod | Domain/return URL not registered | Re-check the Services ID configuration in Step 3 |
|
|
| Name is blank after sign-in | Apple only sends the user's name on the **first** sign-in | Expected — store the name on that first callback |
|
|
| Email is a relay address | User chose "Hide My Email" | Expected — treat it as canonical, it routes to their real inbox |
|