AUTH4D-219 Godot web exports

Godot 4 web exports can now sign players in and upgrade guests. This is an addon change only and adds no endpoints. In a web export, Auth4Client.sign_in_with_browser() runs the authorization code flow with S256 PKCE in a popup, and Auth4Client.upgrade_guest() uses the hosted /login/upgrade window protocol (auth4d-23.md), as the browser SDK (auth4d-66.md) and Unity WebGL builds (auth4d-69.md) do. Native exports keep using device authorization and the upgrade intent (auth4d-67.md, auth4d-68.md).

Routing

Auth4ClientOptions.web_bridge (an Auth4WebBridge) sits alongside browser_launcher. The default Auth4WebBridge reports is_supported() true only when OS.has_feature("web"), and runs the JavaScript in Auth4WebBridge.SOURCE through JavaScriptBridge. When it is supported:

  • sign_in_with_browser() opens the popup. Outside the web it fails with unsupported_platform and sends nothing.
  • upgrade_guest() opens the upgrade popup instead of calling the browser launcher. No upgrade intent is created. A signed-out client fails with login_required and a non-guest session with invalid_state; nothing opens in either case.
  • begin_guest_upgrade() fails with unsupported_platform, because the in-game email steps need cookie and Origin headers that the browser controls.

Only one popup is open at a time; a second call while one is pending fails with invalid_state. Both popups open before the call first awaits, so a browser user gesture still applies. If navigator.userActivation.isActive is false by the time Godot handles the input, the bridge waits for the next pointerup or touchend on the page, in the capture phase, and opens the popup inside that event. A null window ends the call with popup_blocked. The game page must not send Cross-Origin-Opener-Policy: same-origin, which Godot’s threaded web exports need, because it severs the link to the popup; the verified export uses the default non-threaded template. Popups are opened without noopener, with the features popup,width=480,height=720.

Browser sign-in

Auth4ClientOptions.redirect_uri is required. It must be an absolute https URL without a fragment (http only for loopback hosts) and must be registered as a redirect URI of a browser application. The bridge refuses it (invalid_configuration) unless it is on the game page’s own origin and equals the authorization request’s redirect_uri.

The popup opens:

{authOrigin}/oauth/authorize?tenant_id={tenantId}&response_type=code&client_id={clientId}
  &redirect_uri={redirectUri}&scope={scope}&state={state}&code_challenge={challenge}
  &code_challenge_method=S256[&provider_hint={provider}]

state and the verifier are each 32 random bytes, base64url-encoded. The challenge is BASE64URL(SHA-256(verifier)). The scope defaults to default_scope and must include openid. The bridge refuses an authorization URL that is not /oauth/authorize on the exact auth origin.

The bridge reads the popup’s location every 200 ms. While the popup is on the auth origin the read fails and is ignored. Once the popup is on the redirect URI’s origin and path, the bridge passes only the query string to GDScript and closes the popup. A popup closed earlier ends the call with window_closed. The client then:

  • requires exactly one state, equal to the one it sent, and an iss, if present, equal to {authOrigin}/t/{tenantId}. Otherwise it fails with invalid_response;
  • maps error=access_denied to reason DENIED, and other error values to that error;
  • posts grant_type=authorization_code with code, redirect_uri, client_id and code_verifier to POST /oauth/token?tenant_id={tenantId} and adopts the token set as a new session. The ID token is not used.

Guest upgrade window (opener side)

The popup opens {authOrigin}/login/upgrade?tenant_id={tenantId}&client_id={clientId}, plus &provider=discord or &provider=steam when a provider is passed. The URL carries no token. The bridge refuses any upgrade URL that is not /login/upgrade on the exact auth origin.

The bridge ignores every message event unless event.origin equals the auth origin exactly and event.source is the popup it opened. It relays these messages to GDScript:

Message from the window Client behaviour
{type:"auth4:guest-upgrade:ready", tenant_id, client_id} If the tenant and client match, the client gets a fresh guest access token and the bridge posts {type:"auth4:guest-upgrade:token", access_token} to the auth origin.
{type:"auth4:guest-upgrade:complete", result} Validates result and replaces the token set (see below).
{type:"auth4:guest-upgrade:error", error:"identity_in_use"} Returns reason CONFLICT and keeps listening.
{type:"auth4:guest-upgrade:error", error:"invalid_session"} Returns invalid_session.
{type:"auth4:guest-upgrade:cancelled"} Returns access_denied with reason DENIED (“Not now”).

The bridge checks popup.closed every 500 ms; a closed window ends the upgrade with window_closed. A token is sent for a ready only while the guest session that started the upgrade is still live and still a guest, and the sign-out generation hasn’t changed. Otherwise the upgrade ends with invalid_session and nothing is sent. A transient refresh failure is returned as is. Cancelling the Auth4CancelToken closes the popup and returns reason CANCELLED. After CONFLICT the client keeps answering the window until it closes or sends another final message; a later complete still replaces the token set and emits session_changed. Starting another popup stops the old listener.

result is the POST /t/{tenantId}/guest/upgrade response. It goes through the same commit as the native upgrade: the token set must be valid, user_id must equal the guest’s and identity must be present. Otherwise the returned credentials are revoked, the local session is cleared and the client returns invalid_response. If the guest signed out meanwhile, the credentials are revoked and the client returns invalid_state. Progress is reported as OPENING_BROWSER, WAITING_FOR_PLAYER once the window opens, then COMPLETED, with expires_at set to INF and interval_seconds 0.

Token handling

  • No token appears in a URL or in localStorage, sessionStorage or cookies. Sessions are kept in memory; web exports should not set secure_storage.
  • The sign-in popup’s one-time code reaches GDScript only as the redirect’s query string, and the popup is closed at once. The verifier never leaves GDScript except in the token request body.
  • The game must use a browser client whose allowed origins include the page origin: the token, revocation and guest endpoints answer CORS only for those origins (auth4d-85.md, auth4d-122.md), and the hosted upgrade page posts only to them. Device authorization does not accept game origins, so device sign-in is for native builds.

Tests

  • GODOT_BIN=… node packages/sdk-godot/tests/run.mjs: tests/test_client.gd drives real clients with a fake Auth4WebBridge against tests/mock-server.mjs, which now redeems authorization codes against the PKCE challenge. It covers the authorization URL, the code exchange, state and issuer mismatches, access_denied, closed and blocked popups, cancellation, a refused redirect URI, the full window upgrade, a foreign client’s ready, CONFLICT followed by success, invalid_session, a result for another user, sign-out during the upgrade and the refused in-game email steps.
  • pnpm exec playwright test -c tests/e2e/auth4d-219.playwright.config.ts: a harness page (packages/sdk-godot/tests/web/harness.html) installs Auth4WebBridge.SOURCE exactly as the addon does. It runs against the built hosted page on port 5419, with a mocked API, a routed authorization endpoint and a routed game origin http://localhost:5619. It covers sign-in through the popup, a declined and a closed sign-in, refused sign-in URLs, a full email upgrade with no token in URLs or storage, the pointer-up fallback and cancel-to-close, forged messages, identity_in_use followed by a closed window, a blocked popup and refused upgrade URLs.
  • tests/integration/auth4d-219.godot-web.test.ts checks statically that the bridge speaks the same window protocol as the hosted page and the Unity bridge, and keeps tokens out of storage.

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