feat: add Google and Apple OAuth via better-auth socialProviders

This commit is contained in:
2026-04-08 09:12:50 -04:00
parent e59476dea9
commit 9dfd4ef326
9 changed files with 777 additions and 120 deletions

195
APPLE-OAUTH.md Normal file
View File

@@ -0,0 +1,195 @@
# 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 |