AUTH4D-311 Apple Game Center sign-in

Game (native) applications on iOS and macOS can sign players in with Apple Game Center. The game calls GameKit’s fetchItems(forIdentityVerificationSignature:) and sends what it returned to the token endpoint below, which checks Apple’s signature and answers an auth4.dev token set. There is no hosted page: the endpoint is for native games only. The design is in docs/architecture/game-center-sign-in.md.

Settings

GET /management/tenants/{tenantId}/clients/{clientId}/game-center (scope client:read) and PUT the same path (scope client:write, owners and admins) read and replace the bundle ids an application accepts:

{ "allowed_bundle_ids": ["com.example.arcade", "com.example.arcade.mac"] }
{
  "game_center": {
    "enabled": true,
    "allowed_bundle_ids": ["com.example.arcade", "com.example.arcade.mac"]
  }
}

Game Center is on exactly when at least one bundle id is allowed. Up to 20 unique bundle ids (letters, digits and hyphens in dot-separated labels) are accepted, and only for game applications: a browser or server application gets 400 validation_failed. An empty list turns Game Center off. A change writes the audit event management.client.game_center.updated with status enabled or disabled; the bundle ids themselves are not audited. The order of checks and the error envelope are those of the AUTH4D-144 password setting (401, 404, 429, 403, then insufficient_role and project_suspended). The setting is stored in clients.game_center_bundle_ids (migration 0042_game_center.sql); before that migration it reads as off and writes answer 503 not_ready.

Sign in

POST /t/{tenantId}/game-center/token with a JSON body:

Field Type Value
client_id string An enabled game application of the tenant.
player_id string GKPlayer.teamPlayerID, or gamePlayerID for Apple Arcade games. Printable ASCII, ≤ 128.
bundle_id string The app’s CFBundleIdentifier. Must be one of the application’s allowed bundle ids.
public_key_url string The publicKeyURL GameKit returned.
signature string The signature data, standard base64.
salt string The salt data, standard base64.
timestamp integer The timestamp (UInt64 milliseconds since the Unix epoch) as a JSON number.

Requests that carry an Origin header are refused with 403 access_denied, and the endpoint sets no cookies. Unknown fields are 400 invalid_request. A browser or server application, or an unknown or disabled one, is 401 unauthorized_client; an application with no allowed bundle id is 403 access_denied.

The Worker accepts the signature only when all of these hold, and answers 400 invalid_grant for every failure, without saying which:

  • the bundle id is allowed for the application (checked before any download);
  • the timestamp is at most five minutes old and at most one minute in the future;
  • public_key_url is https on apple.com or a subdomain (today https://static.gc.apple.com/public-key/gc-prod-<n>.cer), with no port, credentials, query or fragment, and a path ending in .cer;
  • the downloaded key (at most 16 KiB, redirects refused, 5-second timeout, cached for its Cache-Control max-age up to one hour) is an end-entity RSA certificate of at least 2048 bits for O=Apple Inc. with the code-signing extended key usage, valid now, issued by the bundled DigiCert Trusted G4 Code Signing RSA4096 SHA384 2021 CA1, which chains to the bundled DigiCert Trusted Root G4. Nothing in a request or in configuration can add a trust anchor;
  • the RSASSA-PKCS1-v1_5 SHA-256 signature verifies, with WebCrypto, over player_id (UTF-8), bundle_id (UTF-8), the timestamp as a big-endian UInt64 and the salt, in that order;
  • the signature was not accepted before. Its SHA-256 is recorded with an atomic put-if-absent in the tenant’s Durable Object until the timestamp could no longer pass the age check, so a replay is invalid_grant.

Limits: 60 requests per minute per IP address and 20 per player and application in ten minutes; more answer 429 slow_down with retry_after. Apple’s key server being unreachable is invalid_grant too; the game can retry with a new signature.

The verified player resolves to the tenant’s game_center identity (provider subject = the player id) or a new user with one. Flows run pre_registration for a new user and post_login, and publish user.created and user.signed_in with method: "game_center". The answer is 200:

{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "…",
  "id_token": "…",
  "scope": "openid profile offline_access",
  "user_id": "usr_…",
  "session_id": "ses_…",
  "is_new_user": true,
  "linked_guest": false
}

The scopes are openid, profile and offline_access as far as the application allows them (offline_access only with the refresh-token grant). The ID token carries idp: "game_center", and refreshes keep it. sub is the auth4.dev user ID; the player id is never put in a token. Game Center gives no email address, so email and email_verified appear only when the user has an address verified by auth4.dev.

Linking to a guest

With Authorization: Bearer <guest access token> of a live guest session of the same application, the endpoint links the player to that guest instead. Flows run pre_guest_upgrade. The guest keeps its user ID, the answer has linked_guest: true, and the guest session is revoked once the replacement exists; guest.upgraded is audited (metadata client_id, provider: game_center) and published once. A player who already belongs to another user is 409 identity_in_use, and the guest is unchanged; accounts are never merged. A bearer that is not a live guest session is 401 invalid_session.

Verified email step

When the application’s requireVerifiedEmail (AUTH4D-259) applies, Game Center counts as a social sign-in with no upstream address, like Steam: under when_missing (the default) and always, a user without an address verified by auth4.dev must add one. The token endpoint then answers 403 and issues no token:

{
  "error": "email_verification_required",
  "continuation": "gcc_…",
  "reason": "no_email",
  "expires_in": 600
}

The continuation is a one-time bearer secret (only its SHA-256 is stored, in the tenant’s Durable Object, for ten minutes). The game asks the player for an address and calls:

  • POST /t/{tenantId}/game-center/email/send with client_id, continuation and email: 202 {"expires_in":600,"resend_after":60}. A new send replaces the code.
  • POST /t/{tenantId}/game-center/email/confirm with client_id, continuation and code: on success the sign-in finishes with the 200 answer above, and the token carries the address with email_verified: true.

The code store, its rules (6 digits, HMAC with the privacy key, 10 minutes, single use, 5 wrong attempts, constant-time comparison, 60-second resend cooldown) and the per-user, per-address and per-IP budgets are the hosted step’s (AUTH4D-263). Errors are 400 invalid_code, code_expired or too_many_attempts, 409 email_unavailable (another account keeps the address; the step stays open for another address), 410 step_expired and 429 slow_down. Linking under the project’s linkVerifiedEmail: after_code follows AUTH4D-264: only a Game Center identity created in this sign-in may join the address’s owner, and a guest link never does. Calls on one continuation run one at a time. The audit events are email_step.code_sent, email_step.verified, email_step.linked and email_step.failed, with idp: game_center and a masked address only.

Source: docs/api-spec/auth4d-311.md