Apple Login 實作
Sign in with Apple 的 Developer Portal 設定、JWT 簽名與常見陷阱
Overview##
Sign in with Apple 是 Apple 的 OAuth 2.0 / OIDC 實作。相比 Google Login,Apple 的設定明顯更複雜,且有幾個獨特的限制需要特別注意。
| 面向 | Apple | |
|---|---|---|
| Client Secret | 固定字串 | 動態 JWT(.p8 私鑰簽署) |
| 回調方式 | GET redirect | POST form_post |
| 真實 email | 可選 Private Relay | |
| 使用者名稱 | 每次都提供 | 只有第一次 |
| 頭像 | 提供 URL | 不提供 |
| App Store | 無要求 | iOS App 若有第三方登入必須提供 |
Warning
如果你的 iOS App 提供任何第三方登入(Google、Facebook 等),Apple 要求你必須同時提供 Sign in with Apple,否則會被 App Store 拒絕。
Setup##
Apple Developer Portal 設定###
需要設定三個元件:
Apple Developer Portal
├── App ID
│ └── Enable "Sign in with Apple" capability
├── Service ID(Web 用)
│ ├── Identifier: com.yourapp.web
│ └── Return URLs: https://yourapp.com/auth/apple/callback
└── Key(.p8 檔案)
├── Key ID: ABC123DEFG
└── Enable "Sign in with Apple"
| 元件 | 用途 | 取得方式 |
|---|---|---|
| Team ID | 你的開發者帳號 ID | Membership 頁面 |
| App ID / Bundle ID | 應用程式識別碼 | Certificates → Identifiers |
| Service ID | Web 登入的 client_id | Certificates → Identifiers → Services IDs |
| Key ID + .p8 file | 產生 client secret 用 | Certificates → Keys |
產生 Client Secret(JWT)###
Apple 不使用固定的 client secret——你需要用 .p8 私鑰簽署 JWT,每次都動態產生:
import jwt from 'jsonwebtoken';
import fs from 'fs';
function generateAppleClientSecret(): string {
const privateKey = fs.readFileSync('AuthKey_ABC123DEFG.p8', 'utf8');
const token = jwt.sign({}, privateKey, {
algorithm: 'ES256',
expiresIn: '180d', // Max 6 months
issuer: process.env.APPLE_TEAM_ID!, // 10-char Team ID
subject: process.env.APPLE_SERVICE_ID!, // Service ID
audience: 'https://appleid.apple.com',
keyid: process.env.APPLE_KEY_ID!, // 10-char Key ID
});
return token;
}
| JWT 欄位 | 值 | 說明 |
|---|---|---|
alg | ES256 | Apple 要求的簽名演算法 |
iss | Team ID | 你的 Apple Developer Team ID |
sub | Service ID | Web 用的 client_id |
aud | https://appleid.apple.com | 固定值 |
exp | 最多 180 天 | Client secret 有效期 |
Warning
.p8 私鑰只能下載一次。下載後務必安全儲存,不要放進版控。如果遺失,需要重新建立 Key。
Usage##
Step 1 — 導向授權頁面###
// GET /auth/apple
import crypto from 'crypto';
function getAppleAuthUrl(): string {
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');
// Store state and nonce in session
const params = new URLSearchParams({
client_id: process.env.APPLE_SERVICE_ID!,
redirect_uri: 'https://yourapp.com/auth/apple/callback',
response_type: 'code id_token',
scope: 'name email',
state,
nonce,
response_mode: 'form_post', // Apple uses POST, not GET
});
return `https://appleid.apple.com/auth/authorize?${params}`;
}
Step 2 — 處理回調###
Apple 的回調有兩個重要特性:
- 使用 POST(不是 GET)——
response_mode: 'form_post' - 使用者的名字只在第一次授權時提供
// POST /auth/apple/callback
async function handleAppleCallback(body: {
code: string;
id_token: string;
state: string;
user?: string; // Only on first authorization!
}) {
// 1. Verify state
// 2. Exchange code for tokens
const clientSecret = generateAppleClientSecret();
const tokenRes = await fetch('https://appleid.apple.com/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'YourApp/1.0', // Required by Apple
},
body: new URLSearchParams({
code: body.code,
client_id: process.env.APPLE_SERVICE_ID!,
client_secret: clientSecret,
redirect_uri: 'https://yourapp.com/auth/apple/callback',
grant_type: 'authorization_code',
}),
});
const tokens = await tokenRes.json();
// 3. Decode and verify ID token
const decoded = jwt.decode(tokens.id_token) as {
sub: string;
email?: string;
email_verified?: boolean;
is_private_email?: boolean;
};
// 4. Parse user info (only available on first auth!)
let name: string | undefined;
if (body.user) {
const userInfo = JSON.parse(body.user);
name = `${userInfo.name?.firstName} ${userInfo.name?.lastName}`.trim();
}
// 5. Find or create user
const user = await findOrCreateUser({
provider: 'apple',
providerId: decoded.sub,
email: decoded.email,
name, // May be undefined on subsequent logins
isPrivateEmail: decoded.is_private_email,
});
return user;
}
Caution
Apple 只在使用者第一次授權時提供 name。之後的登入不會再提供。你必須在第一次收到時就存入資料庫。如果漏存了,使用者需要在 Apple ID 設定中撤銷你的 app 授權,重新授權才會再次提供。
Pitfalls##
Private Relay Email###
使用者可以選擇隱藏真實 email,Apple 會提供一個 relay 地址:
真實 email: john@gmail.com
Relay email: abc123@privaterelay.appleid.com
| 情況 | email 欄位 | is_private_email |
|---|---|---|
| 使用者分享真實 email | john@gmail.com | false |
| 使用者選擇隱藏 | abc123@privaterelay.appleid.com | true |
你發送到 relay 地址的 email 會被 Apple 轉發到使用者的真實信箱——但你需要在 Apple Developer Portal 註冊你的發信 domain。
Name 只給一次###
第一次授權:
body.user = '{"name":{"firstName":"John","lastName":"Doe"},"email":"john@gmail.com"}'
第二次之後:
body.user = undefined ← 沒了
處理策略:
// First login: save name immediately
if (body.user) {
const userInfo = JSON.parse(body.user);
await db.user.update({
where: { id: user.id },
data: {
name: `${userInfo.name.firstName} ${userInfo.name.lastName}`,
},
});
}
// Subsequent logins: name is already in database, skip
Token Request 需要 User-Agent###
Apple 的 /auth/token endpoint 要求 User-Agent header,否則會驗證失敗。這是 Apple 特有的要求,Google 不需要。
POST 回調與 CORS###
Apple 用 POST 回調,你的 server 需要:
- 處理
POST /auth/apple/callback(不是 GET) - 解析
application/x-www-form-urlencodedbody - 確保 CORS 設定允許
appleid.apple.com的請求
Tip
開發時常見的錯誤是只設定了 GET handler。如果收到 405 Method Not Allowed,檢查是否用了 POST handler。
Quiz##
Summary##
- Apple Login 的 client_secret 是動態 JWT——用
.p8私鑰(ES256)簽署,最長有效期 180 天 - 設定需要 Team ID + Service ID + Key ID + .p8 檔案四個元件
- 回調使用 POST form_post,不是 GET redirect
- 使用者名稱只在第一次授權時提供——必須立即存入資料庫
- Private Relay 讓使用者隱藏真實 email,需要註冊發信 domain 才能寄信
- Token request 必須包含
User-Agentheader - iOS App 若有第三方登入必須提供 Sign in with Apple(App Store 審核要求)
留言 (0)
登入後即可留言