The Own Lab The Own Lab

Apple Login 實作

Sign in with Apple 的 Developer Portal 設定、JWT 簽名與常見陷阱

Overview##

Sign in with Apple 是 Apple 的 OAuth 2.0 / OIDC 實作。相比 Google Login,Apple 的設定明顯更複雜,且有幾個獨特的限制需要特別注意。

面向GoogleApple
Client Secret固定字串動態 JWT(.p8 私鑰簽署)
回調方式GET redirectPOST form_post
Email真實 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你的開發者帳號 IDMembership 頁面
App ID / Bundle ID應用程式識別碼Certificates → Identifiers
Service IDWeb 登入的 client_idCertificates → 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 欄位說明
algES256Apple 要求的簽名演算法
issTeam ID你的 Apple Developer Team ID
subService IDWeb 用的 client_id
audhttps://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 的回調有兩個重要特性:

  1. 使用 POST(不是 GET)——response_mode: 'form_post'
  2. 使用者的名字只在第一次授權時提供
// 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
使用者分享真實 emailjohn@gmail.comfalse
使用者選擇隱藏abc123@privaterelay.appleid.comtrue

你發送到 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 需要:

  1. 處理 POST /auth/apple/callback(不是 GET)
  2. 解析 application/x-www-form-urlencoded body
  3. 確保 CORS 設定允許 appleid.apple.com 的請求

Tip

開發時常見的錯誤是只設定了 GET handler。如果收到 405 Method Not Allowed,檢查是否用了 POST handler。

Quiz##

Single Choice

Apple Login 的 client_secret 和 Google 有什麼不同?

Single Choice

Apple Login 的 user info(使用者名稱)有什麼特殊限制?

Single Choice

Apple Private Relay Email 的作用是什麼?

Single Choice

Apple Login 的回調方式和 Google 有什麼不同?

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-Agent header
  • iOS App 若有第三方登入必須提供 Sign in with Apple(App Store 審核要求)

留言 (0)

登入後即可留言