跳轉到

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 需要掛在它底下)

  1. Certificates, Identifiers & Profiles → Identifiers → + → App IDs → App。
  2. Description 隨意(如 Cortex),Bundle ID 選 Explicit,填 App 的 Bundle ID。
  3. Capabilities 勾選 Sign in with Apple(維持預設 Enable as a primary App ID)。
  4. Continue → Register。

📋 記下 Bundle ID → APPLE_APP_BUNDLE_ID(沒有原生 App 可留空;但仍需要這個 App ID 給步驟 2 用)。

2. 建立 Services ID(Web / Android redirect flow 的 client_id)

  1. Identifiers → + → Services IDs。
  2. Description 如 Cortex Web,Identifier 建議用反向網域且不能與 App ID 相同,例如 com.aoshiken.cortex.web。
  3. Register 後點進該 Services ID,勾選 Sign in with Apple → Configure:
  4. Primary App ID:選步驟 1 的 App ID。
  5. Domains and Subdomains:API 對外網域(不含 https://),例如 sit-api.example.com。
  6. Return URLs:https://<API 網域>/api/auth/callback/apple,例如 https://sit-api.example.com/api/auth/callback/apple。SIT 與正式環境各加一條。
  7. 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)

  1. Keys → +,Key Name 如 Cortex Sign in with Apple。
  2. 勾選 Sign in with Apple → Configure → Primary App ID 選步驟 1 的 App ID → Save。
  3. 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):

  1. 多行 PEM 原文(GitHub Actions Secret 可直接貼多行);
  2. 單行 PEM,換行寫成字面 \n(許多 .env 工具的做法);
  3. 整段 PEM 的 base64(base64 -w0 AuthKey_XXXXXXXXXX.p8);
  4. 容器內可讀到的 .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"}'
  1. 用瀏覽器開 auth_url,以 Apple ID 登入並選擇是否隱藏 email。
  2. Apple 會以 HTTP POST(form_post) 打回 /api/auth/callback/apple,後端驗證後 302 導到 FRONTEND_URL_WEB?auth_code=...&auth_flow=<flow_id>。
  3. 前端拿 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「重測第一次授權」