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:
- Resolves or creates the email user (subject to the tenant’s signup policy), as for hosted login.
- Consumes the sign-in flow. A flow signs in once.
- Checks again that the user code is still pending for the same tenant and client.
- 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 ondecision: "login_required"by showing the email form inline (AUTH4D-60 (repository filedocs/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