AUTH4D-66 browser SDK guest sign-in and guest upgrade

@auth4/sdk-browser adds signInAsGuest() and upgradeGuest(). This is an SDK change only. It uses the existing POST /t/{tenantId}/guest endpoint (auth4d-15.md) and the hosted /login/upgrade window protocol (auth4d-23.md), and adds no endpoints.

Guest sign-in

signInAsGuest({ signal? }) replaces any current session and sends:

POST {issuer}/guest
Content-Type: application/json
Accept: application/json

{"client_id":"cli_…","browser_flow_id":"flw_<43 random base64url characters>"}

The request uses credentials: "omit", so the browser stores none of the API’s cookies, and a fresh browser_flow_id per call. The response must carry a user_id and a valid browser token set (a bearer access token that lives at most five minutes). The access token’s sub must equal user_id. The resolved BrowserSession is { user: { sub: user_id }, expiresAt, isGuest }. isGuest is true only when the access token carries auth4_guest: true (auth4d-64.md). The SDK reads that claim without verifying the signature, because the token came straight from the issuer over TLS. Resource servers still verify access tokens with the server SDK. The response’s id_token is not kept. Its refresh_token is kept in memory only, and only with the refresh: true option (auth4d-65.md). Every session, not just guest sessions, now reports isGuest, and the value is re-read on each renewal.

The guest endpoint is not a client-CORS path. A cross-origin page needs its origin in the Worker’s CLIENT_ORIGINS variable (comma-separated), which feeds the boundary’s auth CORS allow-list. Without that, the boundary returns 403 origin_denied and the SDK rejects with guest_sign_in_failed.

Required API addition (not implemented by this ticket)

Customer origins should not need a deployment variable. POST /t/{tenantId}/guest should get per-client CORS like /oauth/token and /oauth/revoke (AUTH4D-63). The boundary would leave the path to the guest feature. The feature would then accept an Origin only when it is one of the named browser client’s registered origins, and send Access-Control-Allow-Origin without credentials.

Failures reject with guest_sign_in_failed, including HTTP errors such as 429 slow_down, invalid JSON, a missing user_id, a subject mismatch, or an invalid token set. The client then has no session.

Guest upgrade (opener side)

upgradeGuest({ signal? }) requires a live session with isGuest: true. Without one it rejects with invalid_session or not_guest and opens nothing. Otherwise it synchronously calls:

window.open(
  "{authOrigin}/login/upgrade?tenant_id={tenantId}&client_id={clientId}",
  "auth4-guest-upgrade",
  "popup,width=480,height=720",
);

{authOrigin} is the issuer’s origin. The window is opened without noopener, and the URL carries no token. The method must be called directly from a user gesture. A null window rejects with popup_blocked. While an upgrade is pending, another call focuses the window and returns the same promise.

The SDK ignores every message event unless event.origin equals the auth origin exactly and event.source is the upgrade window. It then handles these messages:

Message from the window SDK behaviour
{type:"auth4:guest-upgrade:ready", tenant_id, client_id} If the tenant and client match, it posts {type:"auth4:guest-upgrade:token", access_token} to the window with target origin set to the auth origin. It answers each ready, which covers the Discord round trip.
{type:"auth4:guest-upgrade:complete", result} Validates result (see below), replaces the token set and resolves.
{type:"auth4:guest-upgrade:error", error:"identity_in_use"} Rejects with identity_in_use and keeps listening (see below).
{type:"auth4:guest-upgrade:error", error:"invalid_session"} Rejects with invalid_session.
{type:"auth4:guest-upgrade:cancelled"} Rejects with cancelled.

The token sent for a ready message comes from getAccessTokenSilently(), so it can be renewed first. It is sent only while the session that started the upgrade is still live and still a guest. After a logout or a session change, the SDK rejects with invalid_session and sends nothing. The SDK polls window.closed every 500 ms, and a closed window rejects with window_closed. Aborting the signal closes the window and rejects with cancelled.

Completion

result is the POST /t/{tenantId}/guest/upgrade response: user_id, identity and the replacement token set. user_id and the access token’s sub must both equal the guest’s user ID, and identity.identity_id and identity.provider must be strings. Otherwise the SDK rejects with upgrade_failed and keeps the guest token set. A valid result replaces the access token, expiry, isGuest and (with refresh: true) refresh token in one synchronous step. That step also advances the session generation, so a renewal still in flight for the revoked guest session discards its result. The promise resolves with:

{
  session: { user: { sub }, expiresAt, isGuest: false },
  identity: { identityId, provider },
}

The tokens stay inside the client. Read them with getAccessToken() or getAccessTokenSilently(). The SDK also emits a session event { type: "upgraded", session }, which carries no token values.

After identity_in_use the hosted window offers to link a different credential, so the SDK keeps answering that window until it closes or sends another final message. A later completion still replaces the token set and emits upgraded, even though the promise has already rejected.

Errors

New Auth4BrowserError codes: guest_sign_in_failed, not_guest, invalid_session, identity_in_use, popup_blocked, window_closed and upgrade_failed. cancelled also covers “Not now”.

Tests

  • pnpm exec vitest run packages/sdk-browser runs packages/sdk-browser/src/guest.test.ts, which uses a mocked window, popup and postMessage.
  • pnpm exec playwright test -c tests/e2e/auth4d-66.playwright.config.ts builds the login page into apps/login/.wrangler/auth4d-66-dist and serves it on port 5199. A routed game page on http://localhost:5299 runs the transpiled SDK, and the API is mocked. The suite covers the full email upgrade, identity_in_use, “Not now”, invalid_session and a closed window.

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