Skip to content

M1-34 Sessions, access tokens, refresh families and logout

No Date Ruling Reason
M1-34/1 2026-10-13 Base int/m1-wave8 at f6c34de, branch feat/M1-34-identity-sessions in its own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. Text this ticket adds is English, also in German chapters and READMEs (their German parts stay). Instructions of the launcher, which override the brief's merge step and the brief's language.
M1-34/2 2026-10-13 Session, device and key state are read per request, not cached: the Authenticator reads session, device and the device's active dpop key in one indexed query and reports SessionActive, DeviceState and DeviceKeyActive in the actor; authz takes these facts through the optional port authorize.CredentialObserver in check_active instead of the facts it cached for the rights version. A principal without observed facts (API tokens until M1-44, test principals) keeps the credential source. Logout and refresh reuse do not raise permission_version. 04 section 8.5 ("a cache that is tied to the permission version") holds for grants; for sessions and devices of people this ruling applies. The brief's ruling: revocation then works across processes at once (AZ-P9 for sessions, TestAuthenticate_RevocationAcrossInstances) without a rights change for every logout; the cost is one indexed query per authenticated request. A session never comes back, so the cached flag could only be stale in the allowing direction.
M1-34/3 2026-10-13 listUserSessions authorizes session.list on the resource user (the catalogue's only resource kind of the action) and answers the decision forbidden with reason not_self for another person with 404 not_found; an unknown person is 404 after authorize as well. 05 rule 2: the sessions of another person are not readable for the caller, so the answer is 404, as the brief asks. With the kind user authz sees a visible person (the directory) and refuses at the requirement; the catalogue lists no owned kind for session.list (unlike device.list, M1-33/2), and changing 05 is a core contract change outside this ticket.
M1-34/4 2026-10-13 A refresh of a person in status pending succeeds like one from a pending device; disabled and deleted are 401 session_revoked. The brief says "principal not active", but 08 R-ID-14 gives provisioned people tokens with the rights of a pending device, and 05 section 2.5 lets them call auth.token.refresh; refusing would log them out before the owner approves them.
M1-34/5 2026-10-13 A missing required DPoP header is answered by the request validator of platform/httpserver with 401 invalid_dpop_proof and the challenge DPoP error="invalid_dpop_proof", algs="ES256 EdDSA", without DPoP-Nonce. The validator stage runs before the handler, so the generated decoder never sees the request; the chain has no nonce source, and the next proof failure answers with the nonce. The rule holds for every operation with the parameter (refresh and the login operations).
M1-34/6 2026-10-13 The chain passes every value of the DPoP header (actor.Credentials.DPoPProofs), puts the DPoP facts of the request (reqmeta.Proof: method, expected htu, values) and a setter of response headers (reqmeta.SetResponseHeader) into the context. The Authenticator keeps the signature of M1-18 (Authenticate(ctx, actor.Credentials)), not the brief's Authenticate(r). Two DPoP headers must fail (RFC 9449 section 4.3), the refresh handler needs method and htu without the request, and DPoP-Nonce after a stale nonce has to reach the response from code that has no response writer.
M1-34/7 2026-10-13 The session part of identity starts in its own step sessions right after signing_keys (Identity.BindSessions); identity stays right after authz (INT-W7/3). Until then the chain would authenticate with actor.AnonymousOnly. Access tokens are signed with the keys of instance, which exist only after signing_keys; the routes need the Authenticator.
M1-34/8 2026-10-13 The port identity.Auth has one method beyond the brief's list: CreateExtensionSession(ctx, actor, component), the use case of createExtensionSession (authorize session.extension.create, StartChildSession, audit). A replaced child ends with logout, without actor in its session.revoked. StartChildSession takes no actor (the brief's signature), and the handler must stay free of authorization and audit (adapters only convert).
M1-34/9 2026-10-13 Refresh denials carry the reasons of the brief and the cause codes unknown_token, revoked_state, refresh_expired, dpop_key_mismatch and refresh_reuse (with family_id); an expired refresh token is audited too. Reuse writes its deny entry inside the unit of work that commits the revocation; every other denial goes to RecordDenial after the rollback. SEC-AUD-5 allows codes only; a cause per denial makes the audit log readable without tokens or hashes.
M1-34/10 2026-10-13 The migration 00011_identity_sessions.sql creates all four table checks of 03 auth_sessions (the brief counts three; 03 also lists step_up_consumed_at IS NULL OR step_up_at IS NOT NULL), defers the foreign key identity_id -> user_identities(id) to M1-36, and grants bothy_app SELECT, INSERT, UPDATE on the three tables, no DELETE. 03 is normative; rows end by revoked_at and used_at and are security history, deleted only by the cleanup and retention jobs, which grant their delete.
M1-34/11 2026-10-13 Activity is written by one statement (TouchSession, a CTE over auth_sessions and devices) on the pool, outside the request's unit of work, each row only when its last_seen_at is unset or older than identity.last_active_granularity; a failure is logged at warn level with the error kind and the correlation id. Brief "Authenticator" item 8; the guard in the statement also holds when two processes see the same old value.
M1-34/12 2026-10-13 kernel/jose gets SignerFunc; the access token issuer wraps instance.Signer.Sign(ctx, ...) in it. The deep scan of go-arch-lint refuses a type of identity/app handed into kernel/jose (a kernel component calling a method of a module); a function value of a kernel type keeps the direction.
M1-34/13 2026-10-13 Lint exclusions: gosec G117 for api/rest/rest.gen.go (the contract's TokenResponse marshals access_token) and G101 for identity/adapters/postgres/sqlcgen/refresh.sql.go (query names containing "token"). Generated code that must look like this; the tokens leave the server only through TokenResponse, and the queries bind HMACs only.
M1-34/14 2026-10-13 Access tokens are verified with the algorithm allowlist EdDSA (the only algorithm of the server keys, ADR 0066); a key in state next is refused like an unknown one (instance.Keys.VerificationKey also returns next keys). The Bearer branch answers invalid_dpop_proof when the token parses with header typ at+jwt and names a key of purpose access_token in state active or retiring, without a signature check. 08 section 7.2 (kid among active or retiring); the Bearer answer is 401 either way, so a forged token learns nothing.
M1-34/15 2026-10-13 ID-T27 for math/rand: the depguard rule crypto-rand-only-in-identity in services/server/.golangci.yml, held by test_identity_bans_math_rand (tools/ci) and proven once on a probe file in a temporary copy of the module (golangci-lint reported import 'math/rand/v2' is not allowed). The brief allows a golangci fixture or a probe in a temporary copy; a test that runs golangci-lint inside go test would need the pinned binary in every test environment.
M1-34/16 2026-10-13 Not built: the refresh without rotation in maintenance mode (08 section 7.4, ADR 0065). server.maintenance_mode and the stage StageMaintenance arrive with M7-08; the brief does not list it.
M1-34/17 2026-10-13 refreshToken is an operation without security (security: []): a client sends no Authorization header with it, only the DPoP proof. A request that carries one goes through the Authenticator like any other (an expired access token is 401 token_expired, a valid one consumes the proof's jti before the handler). RFC 9449 section 5: the refresh request proves the key with the proof alone; the contract declares the operation public.
M1-34/18 2026-10-13 Red team round 1 (JA), minors fixed with a test each: the session tree is locked in two statements, the session row first and then its children, so a child inserted by the holder of the parent lock is revoked with the parent (TestRevokeParent_ChildCreatedConcurrently); a refresh whose family key is no longer the device's active dpop key is 401 session_revoked (TestRefresh_DeviceKeyRevoked); an activity write without a valid client IP keeps the stored last_ip (TestTouch_KeepsLastIP). Round 2 (JA): the concurrency test now waits until the logout waits on the lock before the child commits. One READ COMMITTED statement re-checks only the rows it found after waiting for a lock, so it missed the new child; a refresh would otherwise issue tokens check_active refuses.
M1-34/19 2026-10-13 Port onto main: the migration keeps 00011_identity_sessions.sql with a checksum over the English comments, .golangci.yml enables depguard with only the rule crypto-rand-only-in-identity (the S3 rule of the integration belongs to M3-02), and the chapter references name the English files and section titles. The specification is English on main and M3-02 is not on main yet; no behaviour changes.