AUTH4D-146 Google, Apple and Microsoft sign-in (OpenID Connect)

auth4.dev can now act as an OpenID Connect relying party for three fixed presets: Google, Apple and Microsoft personal accounts. Each project registers its own application at the provider and stores the credentials with auth4.dev. There is no arbitrary, developer-defined OIDC provider; only these presets exist.

The Microsoft preset uses the /consumers endpoints, so it accepts personal Microsoft accounts only. That is the account Xbox players sign in with. auth4.dev does not call Xbox services and does not read gamertags or any other Xbox profile data.

Preset Issuer accepted in the ID token Scopes Response mode
google https://accounts.google.com or accounts.google.com openid email query
apple https://appleid.apple.com email form_post
microsoft https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 (consumer) openid email query

Routes

  • GET /t/{tenantId}/identity/google/start
  • GET /t/{tenantId}/identity/google/callback
  • GET /t/{tenantId}/identity/apple/start
  • POST /t/{tenantId}/identity/apple/callback
  • GET /t/{tenantId}/identity/apple/callback
  • GET /t/{tenantId}/identity/microsoft/start
  • GET /t/{tenantId}/identity/microsoft/callback

Start

GET /t/{tenantId}/identity/{google|apple|microsoft}/start?client_id={clientId}&browser_flow_id={flowId} is opened by the hosted pages. It accepts exactly those two parameters, once each, and requires the __Host-auth4_flow_{flowId} HttpOnly browser cookie. As for Discord and Steam, the API consumes and restores the tenant-scoped flow record and checks its tenant, client and cookie binding. It accepts an OAuth authorization flow, a guest-linking flow, or a live device sign-in flow (AUTH4D-124 (repository file docs/api-spec/auth4d-124.md)); native clients only in the last two. A missing cookie or a flow that does not match is 403 access_denied. The tenant must be active and the client enabled, otherwise 400 unauthorized_client.

The provider must be enabled and fully configured for the project, and its stored secret must decrypt. Otherwise start answers 403 access_denied, so a half-configured provider is never offered.

The API creates three random 256-bit values and keeps them server-side in the tenant’s challenge Durable Object, keyed by the hash of state:

  • state, sent to the provider and consumed on return, so every callback works once;
  • an S256 PKCE code_verifier, of which only the challenge leaves the server;
  • a nonce, which the returned ID token must carry.

The record also binds the tenant, preset, client, browser flow, browser-cookie hash, the exact redirect URI and the provider client ID used. It expires with the flow, at most ten minutes. The redirect to the provider carries response_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge and code_challenge_method=S256. Google and Microsoft also get prompt=select_account, so a player signed in to several accounts can choose. Apple gets response_mode=form_post. Apple’s documentation does not clearly say whether it enforces PKCE; auth4.dev sends it anyway, and the nonce check protects the exchange either way.

The redirect URI is always {AUTH_ORIGIN}/t/{tenantId}/identity/{preset}/callback. Developers register exactly that URL at the provider; the console shows it with a copy button.

Callback (Google and Microsoft)

GET /t/{tenantId}/identity/{google|microsoft}/callback accepts state plus exactly one of code or error. error_description, error_uri, iss and scope are tolerated once each and ignored. The provider’s error_description is never shown or echoed. Any other or repeated parameter is 400 invalid_request.

The state record is taken first. A missing, expired, tenant-mismatched or preset-mismatched state, or a missing or different flow cookie, fails closed with 400 or 403 access_denied and no redirect.

On a code, the Worker posts to the provider’s token endpoint with grant_type=authorization_code, the code, the redirect URI, the client ID and secret and the PKCE code_verifier (5-second timeout). It fetches the provider’s JWKS in parallel. The ID token is accepted only when all of these hold:

  • the signature verifies against the provider’s JWKS with RS256; no other algorithm is accepted;
  • iss is one of the preset’s issuers and aud is the project’s client ID;
  • exp, iat and sub are present, and the token is within its lifetime, with 60 seconds of clock tolerance;
  • nonce equals the one stored with the state.

The provider’s access token is discarded. If the project changed the provider’s client ID while the player was away, or switched the provider off, the callback fails with temporarily_unavailable.

Callback (Apple form_post)

Apple sends the result as a cross-site form post from appleid.apple.com, so the SameSite=Lax flow cookie is not present on that request. The API splits the callback in two:

  1. POST /t/{tenantId}/identity/apple/callback takes application/x-www-form-urlencoded with state and exactly one of code or error. Apple’s optional user field (the player’s name, sent on the first sign-in only) is accepted and dropped. Anything else is 400 invalid_request. The API consumes the one-time state, holds the code or error server-side for 120 seconds, and answers 303 to GET /t/{tenantId}/identity/apple/callback?state={state}&resume=1. This path accepts form posts from any origin and never sends CORS headers.
  2. The browser follows the redirect as a top-level navigation, so the flow cookie is sent. The GET takes the held result, which makes the resume URL single-use, and checks the cookie binding before redeeming anything. Exchange and ID token checks are then as above.

The authorization code never appears in a URL. A code sent to the Apple GET callback is refused with 400 invalid_request, and resume is refused for Google and Microsoft.

Result

The identity key is the ID token sub, stored per tenant as (tenant_id, provider, sub). An existing identity signs in to its user; a new one creates a user. A disabled user is refused. Accounts are never merged by email, so the same address at two providers gives two players.

The email address is stored lowercased when the provider sends one. It is marked verified only when the provider says so: Google and Apple send email_verified. Microsoft sends no such claim, so a Microsoft email is never marked verified. Apple may send a private relay address (…@privaterelay.appleid.com) instead of the player’s own.

On success the callback redirects (302, Cache-Control: no-store, Referrer-Policy: no-referrer) to /login, or to /login/device for a device sign-in flow, with tenant_id, client_id, flow_id, a one-time proof_id and proof_purpose set to google_login, apple_login or microsoft_login. On failure it carries error instead:

Situation error
Player cancelled (access_denied, or Apple’s user_cancelled_authorize) access_denied
Bad signature, issuer, audience, lifetime or nonce; disabled user access_denied
Other provider error, timeout, unexpected response, provider switched off temporarily_unavailable

POST /oauth/authorize/continue, the guest upgrade endpoints and POST /device/sign-in/resume accept the three new proof purposes exactly like discord_login and steam_login. In a guest-linking flow, only an identity created inside that flow moves to the guest, which keeps its user ID. An account that already has its own user gives 409 identity_in_use.

Login methods and provider hint

login_methods in GET /t/{tenantId}/login/context and in the login_required answer of GET /device/verify gains optional google, apple and microsoft keys. Each is present, and true, only when that provider is enabled and configured:

{ "email": true, "discord": false, "steam": true, "google": true, "microsoft": true }

provider_hint on GET /oauth/authorize (AUTH4D-125 (repository file docs/api-spec/auth4d-125.md)) and provider on /login/upgrade now also accept google, apple and microsoft. The browser SDK and Unity package do not send these values yet; players pick the provider from the hosted list.

Project settings

PATCH /management/tenants/{tenantId} accepts three more objects under providers. Every field is optional; unknown fields are 400.

Object Fields
google enabled, clientId ({digits}-{id}.apps.googleusercontent.com), clientSecret
microsoft enabled, clientId (the lowercase Application (client) ID GUID), clientSecret
apple enabled, clientId (the Services ID, reverse-DNS), teamId and keyId (10 upper-case letters or digits), privateKey (the .p8 PKCS #8 PEM)

Client secrets are 8 to 512 printable ASCII characters. The rules:

  • A first configuration needs every credential: client ID and secret, or for Apple the Services ID, team ID, key ID and private key. Otherwise 400 provider_credentials_required.
  • Changing the client ID (or Apple’s team or key ID) requires the secret or key again, also 400 provider_credentials_required.
  • Once configured, {"enabled": true} or false alone is enough.
{
  "providers": {
    "google": {
      "enabled": true,
      "clientId": "123456789012-abc123.apps.googleusercontent.com",
      "clientSecret": "…"
    }
  }
}

Secrets and the Apple key are write-only. They are encrypted with the deployment’s AUTH_ENCRYPTION_KEY_RING (packages/crypto envelopes bound to the tenant and to the purpose provider:google:client_secret, provider:microsoft:client_secret or provider:apple:signing_key) and stored in oidc_provider_settings. If the deployment has no key ring the whole update is refused with 503 temporarily_unavailable. The project view returns identifiers only:

{
  "google": {
    "enabled": true,
    "clientId": "123456789012-abc123.apps.googleusercontent.com",
    "clientSecretConfigured": true,
    "clientSecretUpdatedAt": "2026-10-06T09:00:00.000Z"
  },
  "apple": {
    "enabled": false,
    "clientId": "com.example.game.signin",
    "teamId": "ABCDE12345",
    "keyId": "XYZ9876543",
    "privateKeyConfigured": true,
    "privateKeyUpdatedAt": "2026-10-06T09:00:00.000Z"
  },
  "microsoft": {
    "enabled": false,
    "clientId": null,
    "clientSecretConfigured": false,
    "clientSecretUpdatedAt": null
  }
}

Apple’s client secret is not stored at all. For each token request the Worker signs a fresh ES256 JWT with the stored key: iss the team ID, sub the Services ID, aud https://appleid.apple.com, a five-minute lifetime and kid the key ID.

Only project owners can change these settings (tenant:write). Each change writes a management.provider.updated audit event with provider and status (enabled or disabled), never the secret.

Storage

Migration 0017_oidc_providers.sql rebuilds identities to allow provider values google, apple and microsoft, as 0011 did for Steam. Columns, keys, indexes and the Steam rule are unchanged. It adds oidc_provider_settings, keyed by (tenant_id, provider), which cascades on tenant deletion. See docs/operations/d1-schema.md.

Local development and tests

OIDC_LOCAL_MOCK_ORIGIN, honoured only when RUNTIME_ENV is local and only for a loopback origin, sends all three presets to one stand-in. The preset name is the first path segment, for example http://127.0.0.1:9100/google/token. Tests never contact the real providers.

  • packages/provider-oidc/src/index.test.ts: authorization URLs, PKCE, the Apple client-secret JWT, and ID token checks (algorithm, issuer, audience, lifetime, nonce, email verification).
  • tests/integration/auth4d-146.oidc.test.ts: a local mock provider that enforces PKCE and client authentication. Covers sign-in for all three presets, Apple’s form_post and resume, replayed and mismatched state, project settings and secrets, guest upgrade and identity_in_use, and device sign-in.
pnpm --filter @auth4/provider-oidc test
cd tests/integration && pnpm exec vitest run auth4d-146.oidc.test.ts

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