| D-M6-01-0 |
2026-10-16 |
Base int/m1-wave31 at f63a8aa, branch feat/M6-01-console-session in its own worktree; not merged locally and not pushed, the PR lane ports it (the brief's landing steps follow the task). The core contract change R-DM-49 and R-AZ-32 of main (57b1553) enters first as its own commit, with the generated catalogue code, the requirement table and the ERD. |
The base predates that commit; the brief needs exactly that change and no other. |
| D-M6-01-1 |
2026-10-16 |
Console form of the login operations: member client (app default, console) on completeOidcLogin, completePasskeyLogin, loginWithPassword, loginWithTrustedHeader and beginOidcLogin; the console form sends no device and no DPoP proof (the parameter DpopProofLogin is optional, the handler requires device for the app form); the login completion registers a web device {platform: web, state: active, name: "Web console"} without keys per login and starts a console session, no token and no refresh family; answer 200 (response ConsoleLogin). A pending person does not log in to the console. An OIDC console flow needs /console/login/callback on the origin of server.public_url (skipping the provider's app_redirect_uris) and completes only in the console form, an app flow only in the app form. Enrollment codes and pairing refuse the form (400). The allow entry of auth.login.complete carries client_component = console. |
One login implementation per method (brief note 1); the redirect binding keeps tokens of an app login out of the browser. |
| D-M6-01-2 |
2026-10-16 |
Cookie __Host-console_session, 32 bytes of crypto/rand as base64url without padding, stored as HMAC-SHA-256 with the token pepper; attributes Path=/; Max-Age=<seconds to expires_at>; Secure; HttpOnly; SameSite=Strict, no Domain. A new hash at login and in the transaction of every successful step-up of a console session; logout and every 401 of an unknown, revoked, expired or idle cookie answer with the clearing cookie. Cache-Control: no-store comes from SEC-HDR-2, which the chain sets on every API answer, so the contract does not repeat it per response. Plain HTTP never carries the cookie in production, because the transport protection refuses plaintext from untrusted peers (SEC-TLS-1) and __Host- requires Secure. |
Brief note 2; session fixation. |
| D-M6-01-3 |
2026-10-16 |
Idle rule: an accepted request writes last_seen_at (session and device) only when it is older than auth.console_session_idle_timeout / 100; a session is idle when now - last_seen_at >= idle_timeout + idle_timeout/100. The request that finds an idle or expired session and the job identity.auth_cleanup revoke it with expired (transition guarded by revoked_at IS NULL) and write one allow entry of the system action session.revoke with revoke_reason = expired and cause idle or absolute. The key minimum is 1m, so the write bound is never zero. |
Brief note 3; bounded writes without losing activity just before the deadline. |
| D-M6-01-4 |
2026-10-16 |
The cookie is the second credential kind of kernel/actor.Authenticator (Credentials.ConsoleCookie, a redacting type; Present counts it, so failed cookies count in rl.public_auth). Cookie and Authorization together are 400 validation_failed. The console actor has ClientComponent = console, DeviceKeyActive = true (no key binding exists) and the stored view; a trusted_header console session is bound to its header login on every request like an app session. |
Brief note 4; one credential per request. |
| D-M6-01-5 |
2026-10-16 |
CSRF header X-Console-Request: 1 (parameter ConsoleRequest, referenced on the state-changing operations of this ticket; later console tickets reference it on theirs) with an Origin equal to the origin of server.public_url; ConsoleGuard runs as the last step of StageAuthenticate, only for requests the cookie authenticated, so the stage list and the golden order of 02 stay as they are. The gateway refuses an upgrade with the cookie and no Origin. No CORS header is added. |
Brief note 5; a cross-site form or fetch cannot set the header. |
| D-M6-01-6 |
2026-10-16 |
Step-up operations stepUpWithPassword, beginStepUpOidcReauth, completeStepUpOidcReauth (body of the completion: the existing schema IdentityLinkComplete). The OIDC re-login is a link-mode flow of the person whose stored redirect is /console/step-up/callback?challenge_id=<id>: the callback hands the challenge back with the login_code, completeIdentityLink refuses such a flow, and the completion needs the person's identity (same issuer and subject) and an auth_time not before the second of the challenge's creation (auth_time has whole seconds). Wrong confirmations fail the challenge and count. Both methods answer 403 outside a console session. |
Brief note 6; the core contract has no column for the binding, and the redirect is the flow's own data. |
| D-M6-01-7 |
2026-10-16 |
"View as person" on the console session: viewAsUser from a console session stores target and start through the authz port ConsoleViews in the unit of work of its allow entry (attributes target_user_id, started_at, expires_at); the authenticator sets actor.ViewAs from it; at or after authz.view_as_max_duration the next request clears it in its own unit of work with the entry access.view_as.end (ended_reason = expired, actor the owner) and answers 401 step_up_required; endViewAs writes ended_reason = ended, also idempotently without a view. domain.ViewAsExempt makes authorize decide access.view_as.end, auth.logout, auth.step_up on the own session and self.read on oneself without the view. |
Brief note 7, R-DM-49, R-AZ-32. |
| D-M6-01-8 |
2026-10-16 |
Pre-step-up gate consolesession.PassesGate, called by the authenticator after the session checks: without step_up_at only self.read, auth.step_up, auth.logout, instance.discovery.read and the public login actions pass; everything else is 401 step_up_required with reason console_step_up_required, which never counts as a failed credential. |
Brief note 8; the login actions let a tab with an old valid cookie log in again. |
| D-M6-01-9 |
2026-10-16 |
docs/api/openapi.yaml stays below repo.max_file_kb (1 MiB) with the console additions: two shared responses (ConsoleLogin, StepUpDone), the view inline in ConsoleSession, no descriptions or examples on the new operations and only 401, 403 and default on them. The margin left is a few hundred bytes. The route generator splits the strict response objects into objects.gen.go and objects2.gen.go (cut at the operation group nearest the middle). |
The base file had 8 KB below the limit and objects.gen.go 200 bytes; the gate must not be loosened. The next contract change needs a split of openapi.yaml, an owner decision (report). |
| D-M6-01-10 |
2026-10-16 |
Tests follow the merged harness: the integration tests run in the session world of identity/adapters/httpapi (PostgreSQL through pgtest, the full HTTP chain, a fake clock, the fake IdP and virtualauthn) instead of a process of internal/wire, because only that world has a fake clock; the WebSocket case runs against the gateway handler on a real listener; the write during a view is updateMyProfile instead of createMessage and DMs stay with M1-49, because messaging is not in that world. |
Brief "Tests"; D-W-100 differences in names and places, same behaviour. |
| D-M6-01-11 |
2026-10-16 |
The generated clients follow the contract: the Dart and Python clients type the four login operations with the 200 ConsoleSession of the console form, because openapi-generator takes the lowest success code. packages/auth therefore sends the app form of completeOidcLogin, loginWithPassword, loginWithTrustedHeader and completePasskeyLogin through the same Dio, interceptors and serializers itself and reads the 201 TokenResponse (_appLogin); the console (M6-02) uses the generated methods. |
Without it the apps would decode their token response as a console session and every app login would fail; the generator pipeline and the contract stay unchanged. |
| D-M6-01-12 |
2026-10-20 |
Port onto main on top of M1-64: the ticket range (f63a8aa..aed86cd) as one commit plus the integration fix INT-W33/2 (D-M6-01-13: the request validator refuses an empty header outside its enum, so an empty X-Console-Request is no longer treated as missing). The spec additions of the German chapters go into the English chapters (07, 08, 19, the idempotency table of 06); the core rules R-DM-49 and R-AZ-32 were already on main. The rows of the new operations in the idempotency and route tables of 06 are added. The configuration keys auth.console_session_max_age and auth.console_session_idle_timeout leave tools/configdoc/planned.yaml. D-M6-01-9 describes the contract before PL/9 gave it its own limit api.contract_max_file_kb. trustedheader_internal_test.go of M1-43 passes the now optional device as a pointer. The fixes INT-W34/1 and INT-W34/2 belong to M8-10 and are not part of this port. |
The PR lane ports only the ticket's own commits and the integration fixes it needs; the English spec on main wins. |
| D-M6-01-13 |
2026-10-16 |
The request validator checks a header parameter that arrives with only an empty value against the enum of its schema: when the enum does not list "", the answer is 400 validation_failed with #header/<name> invalid_enum, the same as for any other value outside the enum. |
M6-01 added the optional header X-Console-Request (enum "1") to seven operations, and G7 of M1-71 sends X-Console-Request: with an empty value under a bearer token: the validator library treats an empty header as absent and the console guard skips requests with Authorization, so the request got 204 instead of a 4xx (reproduced with the seed of the failing run). A value such as "2" was already refused; the empty value now is too. Test: TestValidate_EmptyEnumHeaderRejected. |
| D-M6-01-14 |
2026-10-21 |
After M8-10 on main: the regenerated Go client api/restclient gives logout and createStepUpChallenge a params argument for the optional header X-Console-Request; bothyapi.Logout of the import tool and its real-server test test/importtool pass empty params, so the DPoP logout of a device sends no console header. The split helpers of the route generator (splitAll, halveObjects, groupNearest) use api/internal/gosplit of M8-10, as in the integration (INT-W34/1 and /2). |
The server did not compile with both tickets; a device logout is not a console request, and the header stays unsent as before. |
| D-M6-01-15 |
2026-10-21 |
After M1-69 on main: the canary scenario serves the console (consoleServed = true in test/canary/drive.go, as M1-69/10 asks of this ticket), so it logs the owner in with the console form of loginWithPassword and drives getConsoleSession, stepUpWithPassword, viewAsUser with endViewAs, beginStepUpOidcReauth with completeStepUpOidcReauth and the console logout with canary inputs. In StageAuthenticate the span annotation of the principal (DP-T09) stays before ConsoleGuard, so every authenticated request keeps it. |
TestSEC_T10_ScenarioCoversEveryEnabledOperation requires every enabled operation in the scenario; the canary steps for the console were written with M1-69 and only waited for the switch. |