Runbook:Sign in with Apple 設定(Apple Developer → Cortex)
本文件說明加入 Apple Developer Program 之後,要在 Apple Developer 後台建立哪些物件、 拿到哪些值、填進 Cortex 的哪個環境變數,以及如何驗證登入流程真的通了。
後端程式碼已內建兩條 Apple 登入路徑,本文只涉及設定,不需要改程式:
| 路徑 | 適用 | 後端端點 | Apple 端 audience |
|---|---|---|---|
| Redirect flow(OAuth 2.0 + form_post) | Web、Android、任何瀏覽器 | POST /api/auth/login/apple → POST /api/auth/callback/apple → POST /api/auth/exchange |
Services ID(APPLE_CLIENT_ID) |
| Native flow(AuthenticationServices SDK) | iOS / macOS 原生 App | POST /api/auth/apple/verify-token |
App Bundle ID(APPLE_APP_BUNDLE_ID) |
Android 沒有 Apple 原生 SDK,一律走 redirect flow,
POST /api/auth/login/apple時帶{"platform": "android"}即可讓 callback 導回FRONTEND_URL_ANDROID。
0. 前提
- 已加入 Apple Developer Program(個人或組織皆可)。
- 已知道 API 對外的 HTTPS 網域(即
OAUTH_CALLBACK_URL,例如https://sit-api.example.com)。 Apple 只接受 HTTPS 的公開網域 作為 Return URL,不接受localhost或 IP。本機開發請用 Cloudflare Tunnel / ngrok 之類的工具把本機 API 暴露成 HTTPS 網域,再把該網域填進 Apple。 - 若有 iOS App,已決定 Bundle ID(例如
com.aoshiken.cortex)。
以下步驟都在 https://developer.apple.com/account/resources/ 完成。
1. 建立 App ID(原生 App;沒有 iOS App 也要建,Services ID 需要掛在它底下)
- Certificates, Identifiers & Profiles → Identifiers → + → App IDs → App。
- Description 隨意(如
Cortex),Bundle ID 選 Explicit,填 App 的 Bundle ID。 - Capabilities 勾選 Sign in with Apple(維持預設 Enable as a primary App ID)。
- Continue → Register。
📋 記下 Bundle ID → APPLE_APP_BUNDLE_ID(沒有原生 App 可留空;但仍需要這個 App ID 給步驟 2 用)。
2. 建立 Services ID(Web / Android redirect flow 的 client_id)
- Identifiers → + → Services IDs。
- Description 如
Cortex Web,Identifier 建議用反向網域且不能與 App ID 相同,例如com.aoshiken.cortex.web。 - Register 後點進該 Services ID,勾選 Sign in with Apple → Configure:
- Primary App ID:選步驟 1 的 App ID。
- Domains and Subdomains:API 對外網域(不含
https://),例如sit-api.example.com。 - Return URLs:
https://<API 網域>/api/auth/callback/apple,例如https://sit-api.example.com/api/auth/callback/apple。SIT 與正式環境各加一條。 - Next → Done → Continue → Save。
📋 記下 Identifier → APPLE_CLIENT_ID。
Return URL 必須與後端組出來的 callback 完全一致(scheme、host、path),後端固定用
${OAUTH_CALLBACK_URL}/api/auth/callback/apple。若之後換網域,這裡要同步改。
3. 建立 Sign in with Apple 私鑰(.p8)
- Keys → +,Key Name 如
Cortex Sign in with Apple。 - 勾選 Sign in with Apple → Configure → Primary App ID 選步驟 1 的 App ID → Save。
- Continue → Register → Download,得到
AuthKey_XXXXXXXXXX.p8。
⚠️ 這個檔案只能下載一次,Apple 不會再給。請立刻放進密碼管理工具/GitHub Secrets;遺失只能 Revoke 後重建。
📋 記下 Key ID(頁面上顯示的 10 碼)→ APPLE_KEY_ID;.p8 內容 → APPLE_PRIVATE_KEY。
4. Team ID
Membership details(帳號右上角 → Membership)頁面上的 10 碼 Team ID。
📋 → APPLE_TEAM_ID。
5. 填入 Cortex 環境變數
| 環境變數 | 來源 | 範例 | 必填 |
|---|---|---|---|
APPLE_CLIENT_ID |
步驟 2 Services ID | com.aoshiken.cortex.web |
Web/Android 登入必填 |
APPLE_APP_BUNDLE_ID |
步驟 1 App ID 的 Bundle ID | com.aoshiken.cortex |
iOS 原生登入必填 |
APPLE_TEAM_ID |
步驟 4 | A1B2C3D4E5 |
必填 |
APPLE_KEY_ID |
步驟 3 Key ID | X9Y8Z7W6V5 |
必填 |
APPLE_PRIVATE_KEY |
步驟 3 .p8 內容 |
見下方 | 必填 |
OAUTH_CALLBACK_URL |
既有 | https://sit-api.example.com |
必填(需與步驟 2 Return URL 一致) |
APPLE_PRIVATE_KEY 接受四種形式,後端啟動時會自動辨識並正規化(auth/config.py):
- 多行 PEM 原文(GitHub Actions Secret 可直接貼多行);
- 單行 PEM,換行寫成字面
\n(許多.env工具的做法); - 整段 PEM 的 base64(
base64 -w0 AuthKey_XXXXXXXXXX.p8); - 容器內可讀到的
.p8絕對路徑。
填了值但四種都對不上,API 啟動時就會失敗(fail-fast),不會等到第一個使用者登入才爆。
解析出來的 PEM 還會再真的載入一次,必須是未加密的 P-256(ES256)私鑰,也就是 Apple 下載給你的 .p8 原樣。截斷的 key、RSA key、其他曲線的 EC key、有密碼保護的 key,都會在啟動時被擋下,而不是等到第一次登入簽 client_secret 時才失敗。錯誤訊息只會說哪個設定有問題,不會把 key 或其他密鑰的內容印進日誌。
可選的調校項(有預設值,通常不用動):
| 環境變數 | 預設 | 說明 |
|---|---|---|
APPLE_CLIENT_SECRET_TTL_SECONDS |
15552000(180 天) |
後端自簽 client_secret JWT 的效期;Apple 上限 15,777,000 秒(6 個月) |
APPLE_ID_TOKEN_CLOCK_SKEW_SECONDS |
10 |
驗證 id_token exp/iat 的容許時鐘偏差 |
APPLE_JWKS_CACHE_TTL_SECONDS |
3600 |
Apple 公鑰快取時間;遇到未知 kid(金鑰輪替)會自動重抓一次 |
各環境放哪裡
- 本機 dev:
investment-agent/.env(gitignored)。docker-compose.yml已把上述變數全部透傳進 API 容器。 - SIT:GitHub → Settings → Secrets and variables → Actions。
- Secrets:
APPLE_CLIENT_ID、APPLE_TEAM_ID、APPLE_KEY_ID、APPLE_PRIVATE_KEY - Variables:
APPLE_APP_BUNDLE_ID(Bundle ID 不是秘密) - 完整清單見
sit-deploy.md§1;deploy-sit.yml會在部署時注入。 - 正式環境:同 SIT 的方式,但 Services ID 的 Return URL 要另外加正式網域那一條。
6. 驗證
6.1 Redirect flow(Web / Android)
API=https://sit-api.example.com
# 1) 取得 Apple 授權頁 URL(回應含 auth_url / flow_id / flow_verifier)
curl -s -X POST "$API/api/auth/login/apple" -H 'Content-Type: application/json' -d '{"platform":"web"}'
- 用瀏覽器開
auth_url,以 Apple ID 登入並選擇是否隱藏 email。 - Apple 會以 HTTP POST(form_post) 打回
/api/auth/callback/apple,後端驗證後302導到FRONTEND_URL_WEB?auth_code=...&auth_flow=<flow_id>。 - 前端拿
auth_code+ 步驟 1 的flow_verifier呼叫POST /api/auth/exchange換 token。
curl -s -X POST "$API/api/auth/exchange" -H 'Content-Type: application/json' \
-d '{"code":"<auth_code>","flow_verifier":"<flow_verifier>"}'
成功會拿到 access_token / refresh_token / member。
6.2 Native flow(iOS)
App 端用 ASAuthorizationAppleIDProvider 取得 credential 後,把 identityToken(JWT 字串)POST 給後端。
建議產生一個隨機 nonce,流程是:把它的 SHA-256 放進 ASAuthorizationAppleIDRequest.nonce,
然後把原始值(沒有雜湊過的那個)連同 token 送給後端。Apple 會把收到的雜湊原樣寫回 id_token 的
nonce claim,後端則對你送來的原始值做一次 SHA-256 再比對,藉此確認這張 token 屬於這次登入、不是被重放的。
⚠️ 送錯方向是最常見的錯:如果你把雜湊後的值送給後端,後端會再雜湊一次,比對必然失敗,一律回 401。
⚠️ 有設 nonce 就一定要送:只要這次登入的 request 設了 nonce,Apple 發的 token 就會帶 nonce claim,這時後端要求你附上原始 nonce,省略或送 null 都回 401。這是刻意的:否則別人只要拿到一張identityToken,省略 nonce 就能跳過綁定換到 session。
curl -s -X POST "$API/api/auth/apple/verify-token" -H 'Content-Type: application/json' -d '{
"id_token": "<credential.identityToken>",
"nonce": "<raw nonce>",
"full_name": {"given_name": "小明", "family_name": "王"}
}'
full_name 只有第一次授權 Apple 才會給 App,之後都是 nil;請在第一次就送上,否則會員的顯示名稱會是空的。
6.3 在 SIT 檢查
- API log 出現
Apple token exchange failed status=...或Apple id_token verification failed ...即為 Apple 端設定問題,見下方常見錯誤。後端刻意不記錄 token 與 Apple 回應內容。 GET /api/admin/members的provider_records會出現provider = apple,is_private_relay標示是否為 Apple 隱藏信箱。
7. 行為上要知道的事
- 姓名只給一次:Apple 只在使用者第一次授權時提供姓名(redirect flow 在 callback 的
user表單欄位;native flow 在fullName)。後端會把它存成display_name;之後的登入拿不到姓名,但不會覆蓋既有值。 - Email 只給一次、且可能是 private relay:
xxx@privaterelay.appleid.com是 Apple 代轉信箱。後端把它當成合法身份,但絕不用它做帳號合併(auth/service.py),避免被人以 relay 信箱撞到別人的帳號。 - 要重測「第一次授權」:到 https://appleid.apple.com → 登入與安全性 → 使用 Apple 登入 → 找到 Cortex → 停止使用 Apple ID。下次登入 Apple 會再次視為首次授權並重新給姓名/email。
- client_secret 是後端自簽的 JWT:每次 token exchange 都用
.p8現簽一份(效期由APPLE_CLIENT_SECRET_TTL_SECONDS決定),不需要人工定期更換。 - App Store 審核:iOS App 若提供 Google 登入等第三方登入,依 App Store 審核指南 4.8 也必須提供 Sign in with Apple——這正是原生流程存在的原因。
8. 常見錯誤
| 現象 | 通常原因 | 檢查 |
|---|---|---|
Apple 授權頁顯示 invalid_client |
APPLE_CLIENT_ID 不是 Services ID、或 Services ID 沒開 Sign in with Apple、或 Return URL 沒登記 |
步驟 2 |
Apple 授權頁顯示 invalid_request / redirect_uri 錯誤 |
Return URL 與 ${OAUTH_CALLBACK_URL}/api/auth/callback/apple 不一致(含 http/https、尾斜線) |
步驟 2、OAUTH_CALLBACK_URL |
前端收到 ?error=provider_error,log Apple token exchange failed status=400 |
client_secret 簽章錯:APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY 三者不匹配,或 Key 未綁到正確的 Primary App ID;也可能是授權碼被重複使用(Apple 授權碼 5 分鐘、單次) |
步驟 3、4 |
API 啟動失敗 APPLE_PRIVATE_KEY must be PEM content... |
私鑰值不是上述四種形式(常見:貼到 .cer 而非 .p8、路徑在容器內不存在) |
§5 |
API 啟動失敗 APPLE_PRIVATE_KEY could not be parsed... |
key 內容不完整。常見是複製貼上時少了幾行,或 Secret 的換行被吃掉 | §5,改用 base64 |
API 啟動失敗 APPLE_PRIVATE_KEY must be a P-256 EC private key |
放錯 key:例如 APNs 或其他服務的 key,或自己產的 RSA key | 步驟 3,重新下載 Sign in with Apple 的 .p8 |
API 啟動失敗 APPLE_PRIVATE_KEY is passphrase-protected |
key 被加了密碼。Apple 給的 .p8 本身沒有密碼 |
用原始下載的 .p8 |
帶了 token 但沒帶 nonce 就 401 |
App 這次登入有設 nonce,token 因此帶 nonce claim,後端要求附上原始 nonce | §6.2 |
POST /api/auth/apple/verify-token 回 401 INVALID_ID_TOKEN |
identityToken 的 aud 不在 APPLE_APP_BUNDLE_ID / APPLE_CLIENT_ID 內、token 過期(Apple 給的 id_token 效期約 10 分鐘,App 拿到要立刻送)、或 nonce 不符 |
APPLE_APP_BUNDLE_ID、App 端 nonce 流程 |
帶 nonce 就 401,不帶就成功 |
App 送了雜湊後的 nonce。後端收原始值並自行雜湊,送雜湊值等於被雜湊兩次 | §6.2 |
POST /api/auth/apple/verify-token 回 500 |
後端連 APPLE_CLIENT_ID 和 APPLE_APP_BUNDLE_ID 都沒設 |
§5 |
502 PROVIDER_ERROR(Failed to fetch Apple public keys) |
API 容器連不到 https://appleid.apple.com/auth/keys |
出站網路/proxy |
登入回 403 VERIFIED_EMAIL_REQUIRED |
新帳號但 Apple 沒回 email(使用者先前授權過又撤銷、或 Services ID 沒要 email scope) | §7「重測第一次授權」 |