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_urlishttpsonapple.comor a subdomain (todayhttps://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-Controlmax-age up to one hour) is an end-entity RSA certificate of at least 2048 bits forO=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/sendwithclient_id,continuationandemail:202 {"expires_in":600,"resend_after":60}. A new send replaces the code.POST /t/{tenantId}/game-center/email/confirmwithclient_id,continuationandcode: on success the sign-in finishes with the200answer above, and the token carries the address withemail_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