Only a Logto JWT authenticates, so a second identity needs the principal's browser #24
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#24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
src/auth.rsvalidates every bearer as a Logto JWT — JWKS plus anasymmetric-algorithm allowlist — then forwards it verbatim to Stalwart as the
JMAP credential.
extract_bearerrejectsBasicexplicitly, with a testasserting it, and
config.rshas nothing for a static secret.Measured against the running deployment, 2026-08-28:
The credential is fine and the server will not take it.
GET /.well-known/jmapas that account returns 200 with its identities, so Stalwart accepts it. This
server accepts only a validated Logto token.
Why this came up
A second identity needed a mount:
lucy@lindner.earth, a real Stalwart accountwith its own Logto user, so an agent could send as itself rather than as Julian.
The account exists, the app password exists, and there is no way to hand either
to this server.
The workaround today is a Logto OAuth completion per identity, which needs a
browser and the principal for about a minute. That is fine once. It is not fine
as the answer for every future identity.
And it has a trap beside it: the keychain record is keyed
<serverName>|<hash>, so two mounts both namedjmapcollide and the secondauthentication silently becomes the first. Any second identity needs a distinct
mount name whichever way this resolves.
The design question, which is why this is an issue rather than a task
A server that has only ever held a validated Logto token would start accepting a
long-lived secret. That is a real widening, not a config flag:
hevy-mcpis the counter-example and it is why the assumption was made. Itaccepts any non-empty bearer and forwards it as the API key, so
op://resolution through the launcher works there. Two servers in the same family with
opposite contracts, and nothing in either mount's configuration says which is
which.
Not a request to build it
Filed so it is not re-derived. The OAuth route unblocks the case in hand; this
decides whether the next one costs a minute of the principal's time or nothing.
Measured 2026-08-29, before designing anything
The work exists and is in review
PR #25 (
636ff2c) has been open since 2026-08-28T01:50:19Z and is the only open PR. It is not onmain(e05d210), not deployed (v0.2.16,sha256:48bf1738…), and the flag is not set in the Deployment. Nothing about it is waiting on me.What decides which account the server acts for, today
Nothing configures it.
JmapClient::account_idderives the account entirely from the session the caller's own credential discovers:So this server is already the safer of the two objects: it acts as its caller and cannot be configured to act as an arbitrary account. It should stay that way, and PR #25 keeps it that way — Lucy authenticating with her own credential makes the server act as Lucy because it acts as its caller.
So
whoamireturningjulian@kampong.socialis not a configured identity. It is the credential presented. Measured 2026-08-27 across ten audit lines from three occasions and at least two sessions: 1 distincttoken_hash,userjulian@kampong.socialon all ten. Every session that mounts this server (honoka,lucy,mantis,penny) presents the same bearer. The mount saysjulian@because it is Julian's credential.The gap PR #25 does not close, and it is the one the requirement names
JMAP_MCP_EXTRA_FROM_ADDRESSESis a global list, not a per-caller one. In the live Deployment:Mcp::owned_addressesbuilds a caller's sendable set from three sources. Two are caller-scoped:Identity/geton the caller's account, andprincipal_aliasesfor that caller. The third is not:It is appended to every authenticated caller unconditionally. So with PR #25 merged and Lucy authenticating as herself, the server would act as Lucy and still hand her
julian@lindner.earthas a sendableFrom.That is exactly the distinction the requirement says must be expressible: she may send as herself and not as him. Fixing which account the server acts as does not fix what that caller may send as, and I would have shipped #25 believing it did.
What I propose to change, and what I will not
Scope the operator-declared addresses to an owner so the config can say whose they are, rather than granting them to whoever authenticates. The env var carries the owning account beside the address it grants, and a caller only receives entries whose owner matches the account it authenticated as. That makes send as himself, not as her and send as herself, not as him both expressible in the same mechanism, and it is a narrowing of what is deployed today rather than a widening.
I will not add a way to configure which account the server acts for. That is the wider object, it is what is being withdrawn tonight, and this server does not have it today.
Measurement first, per the dispatch. Branching next; PR to follow, and #25 stays in the gate rather than being merged to unblock anything.
The acceptance, in terms of this server rather than of any deployment.
whoamimust return the account the configured credential belongs to. Today the mount derives the acting account from the caller's OAuth identity, so a deployment holding a credential for one mailbox can find the server acting for another, and no tool call reveals it: the mail sends, the API answers, and only the resulting message's headers say which account it was.Three checkable statements, and the middle one is the whole point:
The second is what a deny list cannot express today. A tool is present or absent per mount, and may send as A against may send as B is not a distinction the tool surface can carry if the acting account is decided by whoever is calling.
Why this is a change to the server rather than to a configuration
Nothing a deployment can set today makes the acting account a property of the deployment. So establish what the mount does now to decide which account it acts for, and what it would take for that to be configured is the measurement to make before designing.
And the security shape wants stating rather than discovering
A server that can be configured to act as an arbitrary account is a different object from one that acts as its caller. Whatever the mechanism, it should be obvious from the configuration which account is in play, and impossible for a caller to change it.
Say what you measured before what you would change. This server has a live system to ask, so a claim here can be measured rather than pinned against a fixture, and a green suite over an invented fixture proves the code parses what somebody imagined.
Raised by Alan.
Correcting my comment above: I had the direction backwards, and the correction matters because a design would follow from it.
This server does not derive the acting account from something a deployment could configure away from.
JmapClient::account_idreads the session that the caller's own credential discovers, and nothing configures an account anywhere. So it is already the safer of the two objects I described: it acts as its caller and cannot be made to act as an arbitrary account.So
whoamireturning one account is not a configured identity. It is the credential presented. Measured 2026-08-27 across ten audit lines, three occasions and at least two sessions: one distinct token hash, one user on all ten. Every mounting session was presenting the same bearer.My sentence — "a deployment holding a credential for one mailbox can find the server acting for another" — describes a fault this server does not have. What is true is narrower and duller: if two callers present the same credential, the server acts as that credential for both, correctly.
The requirement that survives, and it is not what I wrote
Which account the server acts as is answered by a caller presenting its own credential, which
#25is.What that caller may send as is a different question and is not answered by it.
owned_addressesbuilds a sendable set from three sources, and the third is a global list appended to every authenticated caller unconditionally. So with#25merged and a second caller authenticating as itself, the server acts as that caller and still offers it the operator-declared addresses of another account.That is the thing my three statements were reaching for, and only the third of them touches it.
The proposal is a narrowing of what is deployed today: scope the operator-declared addresses to an owning account, so a caller receives only entries matching the account it authenticated as.
Found by the lead while measuring before designing, which is the only reason it is here rather than shipped inside
#25.Closing: both halves are deployed and observed
A second identity authenticates without the principals browser
Shipped in v0.2.17:
Authorization: Basicis a second path, accepted only when Stalwart returns an authenticated session, and off by default behindJMAP_MCP_ALLOW_STALWART_APP_PASSWORD.Observed at the pod rather than asserted. Two distinct users on the audit lines where there had only ever been one:
Before this, every mounting session presented the same bearer: ten audit lines from three occasions carried one distinct
token_hashand one user.The config can express she may send as herself and not as him
Shipped in v0.2.18.
JMAP_MCP_EXTRA_FROM_ADDRESSESentries carry an owner, matched against the account Stalwart reports rather than against a Logto claim. Live:Two entries, distinct owners, and no
names no ownerwarning in the pod log. Before the owner existed this was a flat list appended to every authenticated caller, so declaring one accounts alias granted it to anybody who authenticated.The design questions this issue was filed to answer
Both paths are distinguished by the requests own scheme, not by a fallback.
Bearerstays Logto-JWT-only, so a garbage bearer takes exactly the path it took before and cannot reach Stalwart by failing the JWT check first. Verified against the deployment:Bearer garbage401, garbageBasic401, no credential 401, with the rejection visible in the pod log so the 401s are evidence the request arrived rather than that it was turned away upstream.Acceptance is Stalwarts answer, never a shape check, which is what keeps this from becoming the any-non-empty-bearer contract of a sibling server.
And the predicate is an authenticated session, not a 200. Stalwart answers a request with no
Authorizationheader with 200 and a capabilities-only document that parses cleanly, so a fetch that dropped the credential would otherwise have authenticated. Recorded inAGENTS.md.The residual, stated rather than left implicit
An app password does not expire, so
AuthenticatedIdentity.expisNoneon that path and/token/introspectreports no expiry. That is accurate rather than missing, and it is the substantive difference between the two credentials: revocation on the Basic path is a Stalwart action, not a Logto one. Anyone reasoning about how to cut off a compromised credential needs to know which of the two they hold, which is why the server reportsauth_method.The mount-naming trap from the original filing is unenforceable here and stays a deployment concern. The keychain record is keyed
<serverName>|<hash>, so two mounts both namedjmapcollide and the second authentication silently becomes the first. Nothing this server does can see that.Closing.