M4-09: Login: DPoP, refresh and step-up¶
Status: ready | Depends on: M4-02, M4-08 | Milestone: M4
Goal¶
After this ticket packages/auth is the pure Dart login core of the apps: DPoP proofs (RFC 9449) signed through a signer port with the device key, adoption of the server nonce from DPoP-Nonce and from the gateway hello, one retry on use_dpop_nonce, the access token in memory and the refresh token in a secret store port, refresh as a single flight per refresh family (a second caller waits for the running refresh; the new token is stored before it is used), the step-up flow of 08 6.2 and 14.3 with the step-up key (challenge, compact JWS over challenge, sid and iat, confirmation, repeat of the original request with the same Idempotency-Key), and the clients of the login operations the onboarding screens call (OIDC with PKCE and an app redirect, password with TOTP, passkey where offered, enrollment code, device pairing, polling a pending device, logout). Dio interceptors plug all of this into the client factory of M4-02. Tests use a fake signer and check the proof structure against RFC 9449 and that a parallel refresh runs only once. Why: every request of the apps and the gateway identify need a valid proof from the device key, and two refreshes of one family would look like theft and revoke the session (08 7.4, 15 8); this logic must exist once, without Flutter, so the app, the console parts and later the share extension's sibling logic agree.
Read first¶
docs/PLAN.md: section 3; section 10 rows M4-09, M4-10, M4-13, M4-18, M4-25, M4-35, M4-36.docs/spec/08-identity.md: 2.2 (OIDC with the server as relying party,client_challenge,app_redirect_uri,login_code), 2.4 (proxy header login), 2.5 (local accounts, password with TOTP, passkeys only with a public domain), 3.2 (enrollment code input rules), 5.1 "Registration" (fieldsdevice_id,dpop_jwk,step_up_jwk,attestation), 5.2 (pending device: tokens, pollingGET /devices/{id}), 5.3 (pairing), 6 "Device keys and step-up" (6.1, 6.2, 6.3 fresh step-up), 7 "Token model" (7.2, 7.3 DPoP with nonce rules, 7.4 refresh families and reuse), 8 "Session duration", 13 (login protection, responses), 14.1 to 14.3 (flows), 15 (ID-T14, ID-T15, ID-T17, ID-T18 as the server's view of what this client must do right).docs/spec/06-api.md: 3 "Authentication per principal kind" (3.1 DPoP, 3.2 login flows, 3.3 step-up), 5 (codesuse_dpop_nonce,step_up_requiredwithreason,session_revoked,device_pending), 7 (idempotency on repeat).docs/spec/15-client-architecture.md: 3.1 (authdepends onapi_clientanddomain; no Flutter), 8 "Network and login" (all bullets), 9 (ES256 for all clients, two keys), 21 (R-CL-04, R-CL-05, R-CL-07).docs/spec/04-sync-protocol.md: 6.3 items 2, 3, 6 and 7 (hellowithdpop_nonce; proof inidentify,resume,reauthwithhtm = GETand the HTTPS form of the gateway URL).docs/api/openapi.yaml: the operationsbeginOidcLogin,completeOidcLogin,loginWithTrustedHeader,beginPasskeyLogin,completePasskeyLogin,loginWithPassword,beginEnrollment,completeEnrollment,redeemEnrollmentCode,beginPairing,completePairing,refreshToken,logout,createStepUpChallenge,completeStepUpWithDeviceKey,getDevice,getDiscovery, and the schemedpopAuth.- RFC 9449 (DPoP) sections 4.2, 4.3, 7, 8, 11.1, 11.3; RFC 7638 (JWK thumbprint); RFC 7515 (compact JWS) and RFC 7518 3.4 (ES256 signature as
R || S, 64 bytes); RFC 7636 (PKCE S256). - ADRs:
0067-jose-profile-with-the-standard-library.md(the server's JOSE profile this client must produce),0056(extensions never refresh the app's family). - Briefs:
docs/briefs/M4-02-dart-api-client.md(factory and interceptor slot,ApiProblem),docs/briefs/M4-08-domain.md(Uuid7Generator,DeviceId,AppConfig); for the server side the M1 briefsM1-33-identity-devices-dpop.md,M1-34-identity-sessions.md,M1-35-identity-step-up.md,M1-41-identity-oidc.md,M1-38-identity-device-approval.md. docs/DECISIONS.md: D-W-100, D-W-114, D-W-115; section 10.
This ticket touches no API operation, no table, no action, no event and no configuration key.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come frompackages/l10n, never as literals in widgets. - Lifetimes and windows only through named keys: the client does not decide
auth.access_token_ttlorauth.dpop_nonce_ttl; it reacts to the server'sexp,DPoP-Nonce,use_dpop_nonceandreauth_required. Backoff for retries of the login polling usesclient.outbox_retry_baseandclient.outbox_retry_capthroughAppConfig(M4-08). No new key. - Specification first: if a flow needs a field the API lacks, stop (Boundaries).
Product requirements implemented here:
- DPoP proof per request: header
typ = dpop+jwt,alg = ES256,jwk(public part only); payloadjti(unique),htm,htu(scheme and host in lowercase, default port removed, without query and fragment),iat,ath(base64url SHA-256 of the access token) when a token is sent,noncewhen the server has issued one; the latest nonce wins (08 7.3, 06 3.1). - Gateway proofs with
htm = "GET",htu= HTTPS form of the gateway URL,noncefromhello(04 6.3, R-SY-01). - Exactly one refresh per family at a time; the new refresh token is stored before the new access token is used; a refresh needs a proof with the family's DPoP key (08 7.4, 15 8, R-CL-07).
- Two device keys:
dpopwithout user confirmation,step_upwith it; ES256 (15 9, R-CL-04, R-CL-05). - Step-up: challenge, OS confirmation, compact JWS with only
algin the header over{challenge, sid, iat}, confirmation endpoint, then the original request repeated with the sameIdempotency-Key(08 6.2, 14.3); the terminal's fresh step-up uses the same flow and is consumed by the server (08 6.3). - OIDC in the system browser with PKCE on the app leg (
client_challenge,client_verifier), redirect to the app through a port the app implements (loopback127.0.0.1on desktop, 15 8); never a web view. - A revoked session or device (
401 session_revoked, gateway4012) ends in a signed-out state that tells the app to delete local data (R-SY-14). - No token, refresh token, proof, code, password or TOTP in any log, exception message or
toString(15 16, 19 SEC-LOG rules).
Scope¶
Create in packages/auth/lib/src/:
ports.dart:DeviceKeySigner(String get alg,Map<String, String> get publicJwk,String get thumbprint,Future<Uint8List> sign(Uint8List signingInput)returning the JOSER || Sform),DeviceKeys(dpop,stepUp),SecretStore(read,write,deleteby key),BrowserLauncher,RedirectReceiver(Future<Uri> waitForRedirect(Uri expectedPrefix, Duration timeout)),Clock(Design note 1).jose.dart(base64url, compact JWS assembly, JWK thumbprint per RFC 7638),dpop.dart(DpopProofFactory),nonce_store.dart,tokens.dart(TokenState,SessionInfowithsid,deviceId,accessExpiresAt),refresh.dart(RefreshCoordinator),step_up.dart(StepUpService,StepUpPromptport),interceptors.dart(DpopInterceptor,RefreshInterceptor,StepUpInterceptor),login_flows.dart(LoginService, Design note 5),auth_events.dart(AuthEventstream:signedIn,devicePending,signedOut(reason),refreshFailed),pkce.dart.lib/auth.dartexports; tests undertest/(Tests) with a software P-256 signer intest/support/(dev dependencypointycastle, Design note 2).
Change:
packages/auth/pubspec.yaml(dependencies:api_client,domain,cryptofrom dart.dev; dev:test,pointycastle),README.md.docs/spec/15-client-architecture.md8 (one sentence each: the interceptor order, the signed-out event).docs/DECISIONS.mdsection 10: rowsD-M4-09-<n>; rootCHANGELOG.mdunder[Unreleased].
Out of scope¶
- Key generation and secure storage on the platforms: M4-10 (Apple), M4-11 (Windows), M5-01 (Android); the secret store implementation with
flutter_secure_storage: M4-12 (database key) and M4-18 (tokens). - The onboarding screens E1 to E7 and the loopback listener: M4-18 implements
RedirectReceiverandBrowserLauncher. - Passkey ceremony on native platforms (only for instances with a public domain, 15 8): this ticket implements the API calls with a
PasskeyAuthenticatorport and no platform code; M4-18 decides whether the platform offers it. - Extension child sessions (
createExtensionSession): M5-04 and M5-06. Console cookie sessions: M6-01. - Gateway connection: M4-13 calls
DpopProofFactory.forGateway.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M4-09-auth-dpop; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(client): ...,feat(sync-engine): ...,build(gates): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a Dart package (publish_to: nonein everypubspec.yaml). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only viamktemp -d(DartDirectory.systemTemp.createTemp, pytesttmp_path), removed at the end. Stop only processes you started, by PID. - Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of
make checkdo themselves. - The Flutter SDK at the version pinned in the root
pubspec.yamlmust be onPATH(and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops withVerdict: BLOCKED <tool> missinginstead of skipping. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows
branding.product_namefrom the instance, never a literal. - No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.
- If the core contract (chapters 00, 03, 04, 05) or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Ports and no I/O.
authperforms HTTP only through theapi_clientit is given and does nothing else with the outside world: browser, redirect, keys, secrets and time come through ports, so every flow is testable with fakes (15 1 principle 5). The package has no global state; oneAuthSessionobject per server address owns the token state, the nonce store and the coordinator. -
Proofs.
DpopProofFactory(DeviceKeySigner key, NonceStore nonces, Clock clock, Uuid7Generator ids);Future<String> forRequest({required String method, required Uri url, String? accessToken})andFuture<String> forGateway({required Uri gatewayUrl, required String accessToken, required String nonce}).htunormalization: lowercase scheme and host, drop the default port (443 forhttps, 80 forhttp), drop query and fragment, keep the path as sent;wssbecomeshttpsfor the gateway form.jti: a fresh UUIDv7 string.iat: whole seconds from the clock. Header members in the ordertyp,alg,jwk; JSON without whitespace. ES256 signatures areR || Swith 32 bytes each (RFC 7518 3.4); the signer port returns that form, and a test with a software signer verifies the proof with the public JWK. Ruling for docs/DECISIONS.md: the client builds JOSE by hand oncryptoand base64url, matching the server's profile of ADR 0067, because a JOSE library would add algorithms and header forms the server rejects. -
Nonce.
NonceStorekeeps the newest nonce per server; set from every response headerDPoP-Nonce(also on success) and fromhello.dpop_nonce.DpopInterceptor: addsAuthorization: DPoP <token>andDPoP: <proof>to every request that needs a person's token (all except the unauthenticated login begin calls, which still carry a proof where the API requires one:beginPairing,completePairing,completeOidcLogin); on401with problemuse_dpop_nonceit takes the new nonce and repeats the request once with a fresh proof; a seconduse_dpop_nonceis returned to the caller. -
Refresh.
RefreshCoordinator.ensureFresh()andRefreshCoordinator.refreshNow(): while a refresh runs, every caller awaits the same future (single flight). Order: callrefreshTokenwith a proof of the DPoP key; on success write the new refresh token to theSecretStoreand wait for the write to complete, then publish the new access token; on401 session_revokedor a reuse rejection emitsignedOut(reason)and clear the store; on a network error keep the old state and reportrefreshFailed(the caller retries later with backoff).RefreshInterceptorrefreshes when the access token'sexphas passed by the injected clock, and on401 token_expiredor401 unauthenticatedrefreshes once and repeats the request once; the gateway'sreauth_required(04 6.3 item 6) callsrefreshNowthrough M4-13. Ruling for docs/DECISIONS.md: the persisted write happens before the new access token is published, so a crash between rotation and storage falls into the server'sauth.refresh_reuse_grace(08 7.4) instead of a lost token. -
Login flows.
LoginServicewith one method per flow, each returningLoginOutcome(signedIn(TokenState),pending(PendingDevice with deviceId and the verification code),needsSecondFactor,failed(ApiProblem)):registerDeviceFields(): thedevice_id(UUIDv7, kept in theSecretStorefor this server),dpop_jwkandstep_up_jwkfrom the ports,platform,name,app_versionfrom the caller (08 5.1).- OIDC:
beginOidc(providerId)createsclient_verifier(32 random bytes, base64url) andclient_challenge(S256), callsbeginOidcLoginwith the redirect URI of theRedirectReceiver, opensauthorization_urlthroughBrowserLauncher, waits for the redirect withlogin_code, callscompleteOidcLoginwithclient_verifier, the proof and the device fields (08 14.1). - Password with TOTP (
loginWithPassword, second step when the server asks for the code), proxy header (loginWithTrustedHeader), passkey (beginPasskeyLogin,completePasskeyLoginthrough thePasskeyAuthenticatorport), enrollment code (redeemEnrollmentCodeorbeginEnrollment/completeEnrollmentas the API defines), pairing (beginPairingreturns the code and expiry;pollPairingcallscompletePairingwith backoff until approved, expired or rejected). pollPendingDevice(deviceId):getDevicewith backoff untilactive(emitsignedIn) orrevoked(emitsignedOut(reason)) (08 5.2).logout(): calllogout, then clear the secret store and emitsignedOut(loggedOut)even when the call fails offline. Codes are normalized as 08 3.2 says (case, spaces and hyphens do not count) before sending: remove spaces and hyphens, write a leadingen_in lower case and the rest in upper case, drop nothing else; the UI shows them grouped (M4-18).
-
Step-up.
StepUpService.confirm({required String purposeText}):createStepUpChallenge;StepUpPrompt.ask(purposeText)(the UI shows the text in its language); on consent the step-up key signs the compact JWS (header{"alg":"ES256"}only; payload{"challenge":...,"sid":...,"iat":...});completeStepUpWithDeviceKey. The OS confirmation happens inside the step-up signer of M4-10 (the key requires user presence);StepUpPromptonly explains.StepUpInterceptor: on401 step_up_required(anyreason, includingterminal_step_up_not_fresh), runconfirmonce and repeat the original request with the sameIdempotency-Keyand body, a newly generated DPoP proof (freshjtiandiat, the current nonce) and theAuthorizationof the access token that proof is bound to; a cancelled prompt returns aStepUpCancellederror to the caller; a secondstep_up_requiredafter a successful confirmation is returned, never looped. -
Secrets in errors.
TokenState.toString(), every exception and every log hook redact tokens, proofs, codes, verifiers, passwords and TOTP values (they print the kind and the length only). A test serializes each public type'stoStringand every thrown error of the flows with canary values and asserts that no canary appears. -
Rulings for docs/DECISIONS.md (
D-M4-09-<n>): hand-built JOSE (note 2); nonce retry once (note 3); persist before publish (note 4); theLoginServiceoutcome model and polling backoff keys (note 5); step-up repeat with the sameIdempotency-Keyand no loop (note 6).
Tests (write them first)¶
packages/auth/test/ (fake HttpClientAdapter for the API, fake signer from test/support/software_p256_signer.dart, fake secret store, fake clock):
dpop_proof_test.dart(RFC 9449 structure): header has exactlytyp,alg,jwk;jwkhaskty,crv,x,yand nod; payload hasjti,htm,htu,iat, andathonly with a token;athequals base64url(SHA-256(token)) for a known token;htuofHTTPS://Chat.Example.com:443/api/v1/threads?x=1#yishttps://chat.example.com/api/v1/threads; the signature is 64 bytes and verifies with the JWK; two proofs have differentjti; gateway form usesGETandhttpsfor awssURL and the given nonce.nonce_test.dart: the newestDPoP-Nonceis used in the next proof;401 use_dpop_noncerepeats once with the new nonce; a second one is returned.refresh_single_flight_test.dart: ten parallel requests that all see an expired token cause exactly onerefreshTokencall (the fake counts), and all ten complete with the new token; the new refresh token is written before the first repeated request is sent (the fake store records the order); a refresh answered with401 session_revokedemitssignedOutand clears the store; a network error keeps the old token.step_up_test.dart:401 step_up_requiredtriggers challenge, signature and confirmation, then the originalPOSTis repeated with the sameIdempotency-Key; the JWS header is exactly{"alg":"ES256"}and the payload carrieschallenge,sid,iat; a cancelled prompt returnsStepUpCancelled; a secondstep_up_requiredis returned without a loop.login_flows_test.dart: OIDC with PKCE (client_challengeis S256 of the verifier;completeOidcLogincarries the verifier and the device fields); a pending device polls untilactiveand emitssignedIn, or untilrevokedand emitssignedOut; pairing polls until approved; password with a second TOTP step; code normalization (en_ 7k2m 9qxa,EN_7K2M-9QXAanden_7K2M9QXAall senden_7K2M9QXA: spaces and hyphens removed, theen_prefix in lower case, the groups in upper case; nothing else is dropped); logout offline still clears and emits.no_secrets_test.dart(Design note 7).
Commands that must pass, from the repository root:
cd packages/auth && flutter test --coveragemake arch,make lint,make fmt-check,make coverage,make licenses,make osvmake check
Acceptance criteria¶
- Proofs match the structure of RFC 9449 and verify with the public key;
htu,ath,nonceand the gateway form are correct. - A parallel refresh is executed only once; the new refresh token is stored before use; revocation emits the signed-out event.
- Step-up signs the compact JWS with only
algin the header and repeats the original request with the sameIdempotency-Key, without loops. - The login flows (OIDC with PKCE, password with TOTP, proxy header, passkey through a port, enrollment, pairing, pending device, logout) work against the fake API.
- No secret appears in any
toString, exception or log hook. -
authimports no Flutter (make arch); rulings recorded. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why and cite the RFC section where a rule comes from.
packages/auth/README.md(English): ports to implement per platform, interceptor order, flows, events.- 15 8 updated; rulings of Design note 8 in
docs/DECISIONS.mdsection 10. - Root
CHANGELOG.mdentry under 'Unreleased'. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- Which login flows were tested against which fake responses; any API field the flows needed that the spec names differently from this brief.
- What is not proven (for example a platform test that ran only in CI, or a check done by hand).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.