Only a Logto JWT authenticates, so a second identity needs the principal's browser #24

Closed
opened 2026-08-28 01:31:15 +00:00 by jlxq0 · 4 comments
Owner

src/auth.rs validates every bearer as a Logto JWT — JWKS plus an
asymmetric-algorithm allowlist — then forwards it verbatim to Stalwart as the
JMAP credential. extract_bearer rejects Basic explicitly, with a test
asserting it, and config.rs has nothing for a static secret.

Measured against the running deployment, 2026-08-28:

garbage bearer            401 unauthorized
a Stalwart app password   401 unauthorized     40 chars, correctly provisioned

The credential is fine and the server will not take it. GET /.well-known/jmap
as 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 account
with 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 named jmap collide and the second
authentication 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:

  • what the secret authorises, given the bearer is forwarded to Stalwart verbatim
  • whether it is per-identity or shared, and where it lives
  • whether the JWT path stays the only one for Julian's own mount
  • what a leaked static credential reaches that a leaked JWT does not

hevy-mcp is the counter-example and it is why the assumption was made. It
accepts 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.

`src/auth.rs` validates every bearer as a **Logto JWT** — JWKS plus an asymmetric-algorithm allowlist — then forwards it verbatim to Stalwart as the JMAP credential. `extract_bearer` rejects `Basic` explicitly, with a test asserting it, and `config.rs` has nothing for a static secret. Measured against the running deployment, 2026-08-28: garbage bearer 401 unauthorized a Stalwart app password 401 unauthorized 40 chars, correctly provisioned **The credential is fine and the server will not take it.** `GET /.well-known/jmap` as 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 account with 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 named `jmap` collide and the second authentication 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: - what the secret authorises, given the bearer is forwarded to Stalwart verbatim - whether it is per-identity or shared, and where it lives - whether the JWT path stays the only one for Julian's own mount - what a leaked static credential reaches that a leaked JWT does not **`hevy-mcp` is the counter-example and it is why the assumption was made.** It accepts 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.
Author
Owner

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 on main (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_id derives the account entirely from the session the caller's own credential discovers:

pub async fn account_id(&self, token: &str) -> Result<String, JmapError> {
    let session = self.session_for(token).await?;
    session.mail_account_id()...
}

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 whoami returning julian@kampong.social is 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 distinct token_hash, user julian@kampong.social on all ten. Every session that mounts this server (honoka, lucy, mantis, penny) presents the same bearer. The mount says julian@ 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_ADDRESSES is a global list, not a per-caller one. In the live Deployment:

JMAP_MCP_EXTRA_FROM_ADDRESSES = julian@lindner.earth

Mcp::owned_addresses builds a caller's sendable set from three sources. Two are caller-scoped: Identity/get on the caller's account, and principal_aliases for that caller. The third is not:

for extra in self.extra_from_addresses.iter() {   // Arc<Vec<String>>, one per process
    ...
    out.push(OwnedAddress { email: extra.clone(), identity_id: None, name: None });
}

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.earth as a sendable From.

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.

## 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 on `main` (`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_id` derives the account entirely from the session the *caller's own credential* discovers: ```rust pub async fn account_id(&self, token: &str) -> Result<String, JmapError> { let session = self.session_for(token).await?; session.mail_account_id()... } ``` 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 `whoami` returning `julian@kampong.social` is 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 distinct `token_hash`, `user` `julian@kampong.social` on all ten.** Every session that mounts this server (`honoka`, `lucy`, `mantis`, `penny`) presents the same bearer. The mount says `julian@` 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_ADDRESSES` is a **global** list, not a per-caller one. In the live Deployment: ``` JMAP_MCP_EXTRA_FROM_ADDRESSES = julian@lindner.earth ``` `Mcp::owned_addresses` builds a caller's sendable set from three sources. Two are caller-scoped: `Identity/get` on the caller's account, and `principal_aliases` for that caller. The third is not: ```rust for extra in self.extra_from_addresses.iter() { // Arc<Vec<String>>, one per process ... out.push(OwnedAddress { email: extra.clone(), identity_id: None, name: None }); } ``` **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.earth` as a sendable `From`. **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.
Author
Owner

The acceptance, in terms of this server rather than of any deployment.

whoami must 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:

whoami returns the account the configured credential belongs to
a write tool present on that mount acts as that account
the same tool on a mount configured for a different account does not act as this one

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.

**The acceptance, in terms of this server rather than of any deployment.** **`whoami` must 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:** whoami returns the account the configured credential belongs to a write tool present on that mount acts as that account the same tool on a mount configured for a different account does not act as this one **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.
Author
Owner

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_id reads 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 whoami returning 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 #25 is.

What that caller may send as is a different question and is not answered by it. owned_addresses builds a sendable set from three sources, and the third is a global list appended to every authenticated caller unconditionally. So with #25 merged 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.

**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_id` reads 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 `whoami` returning 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 `#25` is. **What that caller may send as** is a different question and is not answered by it. `owned_addresses` builds a sendable set from three sources, and the third is a **global list appended to every authenticated caller unconditionally**. So with `#25` merged 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`.
Author
Owner

Closing: both halves are deployed and observed

A second identity authenticates without the principals browser

Shipped in v0.2.17: Authorization: Basic is a second path, accepted only when Stalwart returns an authenticated session, and off by default behind JMAP_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:

julian@kampong.social
lucy@kampong.social

Before this, every mounting session presented the same bearer: ten audit lines from three occasions carried one distinct token_hash and one user.

The config can express she may send as herself and not as him

Shipped in v0.2.18. JMAP_MCP_EXTRA_FROM_ADDRESSES entries carry an owner, matched against the account Stalwart reports rather than against a Logto claim. Live:

JMAP_MCP_ALLOW_STALWART_APP_PASSWORD = true
JMAP_MCP_EXTRA_FROM_ADDRESSES        = julian@kampong.social=julian@lindner.earth,
                                       lucy@kampong.social=lucy@lindner.earth

Two entries, distinct owners, and no names no owner warning 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. Bearer stays 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 garbage 401, garbage Basic 401, 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 Authorization header with 200 and a capabilities-only document that parses cleanly, so a fetch that dropped the credential would otherwise have authenticated. Recorded in AGENTS.md.

The residual, stated rather than left implicit

An app password does not expire, so AuthenticatedIdentity.exp is None on that path and /token/introspect reports 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 reports auth_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 named jmap collide and the second authentication silently becomes the first. Nothing this server does can see that.

Closing.

## Closing: both halves are deployed and observed ### A second identity authenticates without the principals browser Shipped in **v0.2.17**: `Authorization: Basic` is a second path, accepted only when Stalwart returns an **authenticated** session, and off by default behind `JMAP_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: julian@kampong.social lucy@kampong.social Before this, every mounting session presented the same bearer: ten audit lines from three occasions carried **one** distinct `token_hash` and one user. ### The config can express *she may send as herself and not as him* Shipped in **v0.2.18**. `JMAP_MCP_EXTRA_FROM_ADDRESSES` entries carry an owner, matched against the account **Stalwart** reports rather than against a Logto claim. Live: JMAP_MCP_ALLOW_STALWART_APP_PASSWORD = true JMAP_MCP_EXTRA_FROM_ADDRESSES = julian@kampong.social=julian@lindner.earth, lucy@kampong.social=lucy@lindner.earth Two entries, distinct owners, and no `names no owner` warning 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.** `Bearer` stays 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 garbage` 401, garbage `Basic` 401, 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** `Authorization` header with 200 and a capabilities-only document that parses cleanly, so a fetch that dropped the credential would otherwise have authenticated. Recorded in `AGENTS.md`. ### The residual, stated rather than left implicit **An app password does not expire**, so `AuthenticatedIdentity.exp` is `None` on that path and `/token/introspect` reports 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 reports `auth_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 named `jmap` collide and the second authentication silently becomes the first. Nothing this server does can see that. Closing.
jlxq0 closed this issue 2026-09-01 18:32:49 +00:00
Sign in to join this conversation.
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/jmap-mcp#24
No description provided.