feat(auth): accept a Stalwart app password over Basic, opt-in #25
No reviewers
Labels
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/jmap-mcp!25
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/stalwart-app-password"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the case in #24: a second identity needed a mount and only a Logto JWT authenticated, so every new identity cost the principal a browser OAuth.
The shape
The two paths are distinguished by the request's own scheme, not by a fallback.
Authorization: Bearerstays Logto-JWT-only, so a garbage bearer takes exactly the path it took before this existed and cannot reach Stalwart by failing the JWT check first.Authorization: Basicis the static path. There is no if the JWT fails, try something else branch, which is how a validated credential and an unvalidated one become the same object.Acceptance is Stalwart's answer, never a shape check, which is what keeps this from becoming
hevy-mcp. Nothing is forwarded on the strength of being non-empty.Off by default behind
JMAP_MCP_ALLOW_STALWART_APP_PASSWORD. An app password is long-lived and validated by the mail server rather than by Logto, so enabling it is a deployment decision about that mailbox.The server says which credential it holds rather than leaving it inferred:
AccessTokencarries its scheme,/token/introspectreportsauth_method, and every outbound request builds its header fromheader_value()so no call site can pick a scheme.The predicate, and why it is not
200Stalwart answers a request with no
Authorizationheader with 200, and that body parses cleanly intoJmapSession:primaryAccounts{},username"". So a fetch that dropped the credential returnsOkand the authentication step succeeds. The predicate isis_authenticated: 200 and a non-emptyusernameor a real entry inprimaryAccounts. Recorded inAGENTS.md, because it is the first predicate anyone reaches for.Mutations, each red on its own alone
is_authenticatedalwaystruebasic_is_ignored_unless_the_deployment_opted_inheader_valuedrops the schemescheme_travels_with_the_secretBearerto the static patha_garbage_bearer_still_goes_to_the_jwt_pathOne methodology note. My first attempt at the
is_authenticatedmutation reported all 177 green, which reads exactly like a test that pins nothing. It had matched nothing: rustfmt had reflowed the expression. Applying it through a script that asserts its own anchor showed two tests red. An unapplied mutation and a worthless test produce the same output.rejects_basic_schemeis kept rather than deleted. The contract it records did not change, only what happens to aBasicheader elsewhere.Gates and review
Five on 1.93.0:
fmt,clippy -D warnings,test(177),audit,deny.Codex, asked whether any request can authenticate that would not have before, other than by presenting an app password Stalwart accepts:
None.Acceptance
The half that is mine, a garbage bearer still returning 401, runs after deploy. The positive half needs
lucy@lindner.earth's app password, which I do not hold and will not extract.Refs #24
Merging on the code. fmt 0, test 0 with 177 passed, CI green on
cargo,dockerandtag-ancestry.The one red is a clippy that
origin/mainfails identically:So local and CI disagree about the same commits, and the disagreement is the toolchain.
ci.ymlusesdtolnay/rust-toolchain@<sha> # stable, which is a moving target: the runner's cached stable and mine are the same word and not the same compiler. A local gate can neither confirm nor contradict CI while that holds.withings-mcphit this lint two days ago:#[allow(clippy::unused_async_trait_impl)]above#[tool_handler], because it fires on macro-generated methods and there is nothing to rewrite. An#[allow]naming a lint the running clippy does not have is itself an error under-D warnings, so adding it sets a floor andci.ymlmust then name a version rather thanstable. Its own change, with the floor stated, rather than smuggled into this one.What this unblocks and what it does not
Lucy gets a route to a mount that authenticates as herself, which she has not had, and without which she has no legitimate access to that mailbox at all.
The positive half of the acceptance is unrun. She can authenticate as herself is a claim about code until her app password runs against a deployment with the flag on. That has been outstanding since 2026-08-28 and it is mine to have run rather than yours.
And this does not close the requirement it looks like it closes.
owned_addressesappends a globalArc<Vec<String>>to every authenticated caller, so with this merged she authenticates as herself and is still handed another account's address as a sendableFrom. Scoping that to an owning account is a narrowing of what is deployed today, it is the next change, and it wants to land before any send tool is granted again.Correcting my comment above:
ci.ymlpins1.93.0and I read a comment as the toolchain.The
# stableis a comment on the action's sha. The toolchain is two lines below it and I stopped at the line that looked like an answer, which is this fleet's own rule about reading the field name and then what follows it, arriving in my own hands.Verified on the pinned toolchain here:
So there was never a disagreement between local and CI. There was a disagreement between two compilers, one of which gates nothing.
And the fix I proposed would have broken the build
#[allow(clippy::unused_async_trait_impl)]is an unknown lint to 1.93.0, and-D unknown-lintsis implied by-D warnings, so adding it turns a green CI red.AGENTS.mdalready carries that rule and it would need#[allow(unknown_lints)]above it to be safe, which today buys nothing because the compiler that emits the lint is not the one that gates.withings-mcp's answer does not transfer and I said it did. That repository pins1.98.0for exactly this lint and documents the two-floor reasoning; here the floor is 1.93.0 and the lint does not exist.What my conclusion should have been
A local gate cannot contradict CI here, not because CI moves but because my local was not the pinned toolchain.
RUSTUP_TOOLCHAINunset and a shim resolvingstablemakes a local result read as authoritative on the wrong compiler, which is inAGENTS.mdand has now caught two of us.cargo +1.93.0 clippyis the command that means something, and it is what I should have run before writing anything about CI.The merge stands. Nothing was waved through: the only red came from a compiler nothing runs.