AUTH4D-120 Discord sign-in during device approval

A player who opens the device page without a browser session can now sign in with Discord as well as by email code (AUTH4D-59 (repository file docs/api-spec/auth4d-59.md)). As with email, the sign-in only creates a browser session on the auth origin. The player then approves or denies the same user code, and tokens reach the device only through the device-code grant (AUTH4D-16 (repository file docs/api-spec/auth4d-16.md)).

The existing Discord connector (AUTH4D-14 (repository file docs/api-spec/auth4d-14.md)) carries the round trip. It uses the same state, callback route and one-time identity proof. Only the return target and the final step differ. The user code is never put in a redirect URL.

Verification data

For decision: "login_required", login_methods in GET /device/verify gains discord, the tenant’s Discord provider setting:

{ "decision": "login_required", "login_methods": { "email": true, "discord": true } }

discord is optional in DeviceVerifyResponseSchema, so responses from before this change still parse. The device sign-in flow now also stores the user code in the tenant’s Durable Object. That lets the page read it back after Discord (see Resume). Flows created before this change have no stored code and cannot resume after Discord. Email sign-in is unaffected.

Start Discord

The page navigates to the existing start route with the device application’s client_id and the browser_flow_id from the verify response:

GET /t/{tenantId}/identity/discord/start?client_id=cli_…&browser_flow_id=flw_…
Cookie: __Host-auth4_flow_{flowId}=…

When browser_flow_id names no OAuth authorization flow, start accepts a live device sign-in flow instead. The flow cookie must match. The flow must belong to the same tenant and native client, and its code must still be pending. Otherwise start returns 403 access_denied. A wrong cookie does not consume the flow. If Discord is off or not configured for the project, start returns 503 temporarily_unavailable, as for hosted login. The Discord state records deviceApproval: true.

Before it navigates, the page removes a user_code from its own address (from verification_uri_complete). That way no Referer carries the code.

Callback

GET /t/{tenantId}/identity/discord/callback is unchanged in its checks: one-time state, the flow cookie, expiry, the provider exchange and tenant-scoped identity resolution. A native client is accepted only for guest-linking (AUTH4D-67 (repository file docs/api-spec/auth4d-67.md)) or device sign-in states. For a device sign-in state it redirects to the hosted device page instead of /login:

HTTP/1.1 302 Found
Location: https://auth.auth4.dev/login/device?tenant_id=ten_…&client_id=cli_…&flow_id=flw_…&proof_id=prf_…&proof_purpose=discord_login
Cache-Control: no-store, max-age=0
Referrer-Policy: no-referrer

If Discord was denied or failed, proof_id and proof_purpose are replaced by error=access_denied or error=temporarily_unavailable. The proof is the usual discord_login identity proof: one-time, short-lived and bound to the tenant, client and browser_flow_id. The callback creates no session and does not touch the code.

Resume

The page removes the return values from its address, then calls:

POST /device/sign-in/resume?tenant_id={tenantId}
Origin: https://auth.auth4.dev
Cookie: __Host-auth4_flow_{flowId}=…
Content-Type: application/json

{ "client_id": "cli_…", "browser_flow_id": "flw_…", "proof_id": "prf_…" }

The body is DeviceSignInResumeRequestSchema, and proof_id is optional. With a proof, the endpoint:

  1. Takes the proof (one-time). It must be a live discord_login proof for the same tenant, client and browser_flow_id.
  2. Consumes the device sign-in flow through the AUTH4D-59 completion path. It checks the cookie and that the code is still pending, then creates a browser session for the proof’s user. No token is signed and the code’s expiry is unchanged.
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", "user_code": "7K2Q9ZP4" }

Without a proof (Discord was cancelled or failed), the endpoint only reads the pending code back. The flow stays live, so the player can choose email or Discord again:

{ "status": "login_required", "user_code": "7K2Q9ZP4" }

The response is DeviceSignInResumeResponseSchema. The page then calls GET /device/verify for that code and continues as in AUTH4D-59. After a successful resume it gets decision: "approvable".

Response When
400 invalid_request Missing tenant, or a malformed body
403 access_denied Origin is not the auth origin, the flow cookie is missing, the client is not an active device client, or the proof is missing, spent, expired or for another flow
400 expired_token The flow, its cookie binding or the code no longer match: expired, decided elsewhere, or another tenant or client
429 slow_down More than 5 resume calls for one flow in an hour
503 temporarily_unavailable Storage or sessions are unavailable

The device-verification rate limits and the 8–12 character user-code rules (AUTH4D-84) still apply to the verify calls.

Hosted page

If login_methods.discord is true, /login/device shows a choice: Continue with email (when email is on) and Continue with Discord. With Discord off, the email form appears directly, as before. When the page returns from Discord:

  • after success, it shows the approval step for the same code with “Signed in with Discord.”;
  • after a cancel or failure, it shows the sign-in choice again with an alert;
  • if the code expired or the flow ended, it shows the expired-code page;
  • a malformed return link shows the invalid-link page and makes no call.

Tests

  • tests/integration/auth4d-120.device-discord.test.ts: success through approval and token issue for the Discord identity, Discord cancel and retry, denial after Discord, code expiry during the round trip, the code decided elsewhere, Discord disabled, and binding to the cookie, flow, client, tenant and Origin. It also covers the one-time proof and the resume rate limit. Discord is a deterministic double over fetch.
  • tests/e2e/auth4d-120.device-discord.spec.ts (pnpm exec playwright test -c tests/e2e/auth4d-120.playwright.config.ts, port 5221): the page with the AUTH4D-67 Discord double. It covers keyboard sign-in through approval, the complete verification link (no code in the start URL or Referer), cancel, expiry, Discord disabled, Discord only, a tampered return link, and 320px and 1280px layouts.

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