AUTH4D-59 email sign-in during device approval

A player who opens the device page without a browser session can now sign in by email code on that page and then approve or deny the same user code. The sign-in only creates a browser session on the auth origin. It never completes an OAuth authorization, issues an authorization code or identity proof, or mints tokens. Tokens still reach the device only through the device-code grant (AUTH4D-16 (repository file docs/api-spec/auth4d-16.md)) after an explicit approval.

Verification data

GET /device/verify?tenant_id={tenantId}&user_code={userCode}[&client_id={clientId}] with Accept: application/json returns DeviceVerifyResponseSchema as before (AUTH4D-58 (repository file docs/api-spec/auth4d-58.md)), plus a decision:

decision When Extra cookies and fields
approvable The request carries a valid __Host-auth4_session cookie for this tenant none
login_required No session cookie, or it is unknown, expired, revoked or for another tenant login_methods, and a sign-in flow cookie
{
  "tenant": { "tenant_id": "ten_…", "display_name": "Studio" },
  "application": { "client_id": "cli_…", "display_name": "My game" },
  "branding": {},
  "user_code": "7K2Q9ZP4",
  "scopes": ["openid", "offline_access"],
  "browser_flow_id": "flw_…",
  "csrf_token": "…",
  "decision": "login_required",
  "login_methods": { "email": true }
}

With login_required, browser_flow_id also names a device sign-in flow, and the response sets __Host-auth4_flow_{browser_flow_id} (HttpOnly; Secure; SameSite=Lax). login_methods.email is the tenant’s email provider setting. The CSRF cookie and token are issued in both cases, and the response keeps Cache-Control: no-store.

The sign-in flow is stored in the tenant’s Durable Object and is bound to:

  • the tenant and the device client that issued the code;
  • the user code (by hash) and its scopes;
  • the flow cookie (by hash).

It expires after 600 seconds or when the code expires, whichever is first. The flow cookie’s Max-Age matches. Neither the flow nor the sign-in changes the code’s expiry.

Sign in with an email code

The page uses the existing endpoints from AUTH4D-13 (repository file docs/api-spec/auth4d-13.md), with the device application’s client_id and the browser_flow_id from the verify response:

POST /t/{tenantId}/email/verification/start
Origin: https://auth.auth4.dev
Cookie: __Host-auth4_flow_{flowId}=…
Content-Type: application/json

{ "client_id": "cli_…", "browser_flow_id": "flw_…", "email": "player@example.com" }

Start accepts a native client only through a live device sign-in flow. The cookie must match, and the code must still be pending for the same tenant and client. Otherwise start returns 403 access_denied, which also covers another tenant, another client, a reused flow or an expired code. All email send limits apply unchanged: the resend cooldown, the per-address, per-IP, per-tenant and global budgets. Responses are the same enumeration-resistant 202.

POST /t/{tenantId}/email/verification/confirm takes the same body plus code, with the same attempt limits. For a device sign-in flow, a correct code:

  1. Resolves or creates the email user (subject to the tenant’s signup policy), as for hosted login.
  2. Consumes the sign-in flow. A flow signs in once.
  3. Checks again that the user code is still pending for the same tenant and client.
  4. Creates a browser session with SessionService.createBrowserSession. This signs no token and creates no refresh family.
HTTP/1.1 200 OK
Cache-Control: no-store, max-age=0
Set-Cookie: __Host-auth4_session=…; Path=/; Max-Age=43200; HttpOnly; Secure; SameSite=Lax
Set-Cookie: __Host-auth4_flow_{flowId}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax

{ "status": "signed_in" }

The body is DeviceSignInConfirmResponseSchema. If the code expired or was approved or denied elsewhere before confirmation, confirm returns 400 {"error":"expired_token"} and sets no session cookie. Wrong codes and other failures return 400 invalid_code, as before.

Approve or deny

The page calls GET /device/verify again. With the new session cookie it returns decision: "approvable" for the same user code. The page then uses the new browser_flow_id and csrf_token with POST /device/approval or /device/denial. Those endpoints are unchanged: they still require the same-origin Origin, the device CSRF cookie and the X-CSRF-Token header, and a valid browser session.

Notes

  • The browser session belongs to the tenant, like sessions from hosted login. Later hosted logins and device approvals in the same tenant reuse it until it expires or the user logs out.
  • Discord sign-in from the device page was left out here. AUTH4D-120 (repository file docs/api-spec/auth4d-120.md) adds it with a separate completion path, POST /device/sign-in/resume.
  • The hosted page (apps/login) acts on decision: "login_required" by showing the email form inline (AUTH4D-60 (repository file docs/api-spec/auth4d-23.md)).

Tests

tests/integration/auth4d-59.device-signin.test.ts covers:

  • The full path from no session to approval, and to denial.
  • An expired code and a code used elsewhere.
  • A flow capped at the code’s remaining lifetime.
  • Binding to tenant, client, cookie and single use.
  • Email send limits and an unknown session cookie.

packages/sessions/src/index.test.ts covers token-free browser sessions.

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