Files
local-cal/APPLE-OAUTH.md

6.4 KiB

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
  • The app must be served over HTTPS in production (localhost works for dev — see note in Step 2)

The five env vars

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. 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
  2. Click +App IDsApp → Continue
  3. Description: Local Cal
  4. Bundle ID (Explicit): com.yourdomain.localcal
  5. Under Capabilities scroll to Sign In with Apple → check it → ConfigureEnable 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 AppleConfigure
    • 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 or Cloudflare Tunnel.

  7. Done → Continue → Save
AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web

Step 4 — Create a private key

  1. Keys → +
  2. Key Name: Local Cal Sign In
  3. Check Sign In with AppleConfigure → 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:

# .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:

// 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);
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

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

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