serve-upstream cannot refresh an OAuth credential, so an authorization-code-only upstream expires the pod #82
Labels
No labels
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/mcp-beaver#82
Loading…
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?
--upstream-header(62474ac) presents a static value resolved fromenv,file, orliteralon every request. That is the right mechanism for a hosted upstream holding a long-lived credential. It is the wrong mechanism for an upstream whose only credential is a short-lived OAuth access token, and coilyco-bridge/deploy#647 turned up the first of those before anything shipped against it.The upstream that exposed it
Moxn's per-workspace MCB endpoint,
https://<workspace>.moxn.dev/api/mcp/http. Read from its own discovery document rather than its docs:grant_types_supportedhas noclient_credentials. There is no machine-to-machine grant, so every access token originates from an interactive browser authorization-code + PKCE flow a human completes.offline_accessis supported, so a refresh token exists, and the vendor's own client (@moxn/mcp-kb,@moxn/auth) refreshes silently against the token endpoint. That is why the vendor client works on a laptop and says nothing about a pod.Put a correct access token in SSM today and the deployment authenticates until that token expires, then returns 401 on every call with no recovery short of a human redoing the browser flow. The
serve-upstreamproxy would not even fail cleanly: the drift check and every tool call would start erroring, and the reconnect path from #79 would redial into the same 401.What this asks for
Upstream credentials that mcp-beaver mints and renews rather than reads verbatim.
Sketch, not a design decision:
token_endpoint, with the refresh token resolved through the existingvaluesourceregistry so it lands from a Secret exactly as{env:VAR}does now.expires_in, renewed ahead of expiry rather than after a 401, so no call pays for the refresh.reconnectdeliberately refuses to replay a call that may already have reached the upstream.Open questions worth settling before building
authnode already resolves upstream credentials in spec mode. An OAuth refresh loop may belong in umbra's value layer, where both engines would get it, rather than in the proxy path only. That is the first question, not an implementation detail.offline_accessrefresh token in a pod acceptable at all? It is a long-lived grant on a user's identity rather than a service identity, because the upstream offers no service identity. That is a security judgement for the deploying repo, not a runtime default to assume.registration_endpointandtoken_endpoint_auth_methods_supported: ["none", ...], so a public client is possible. Whether mcp-beaver should ever register itself is a separate question from whether it should refresh.Not blocking on this
deploy#647's cheapest path is asking the vendor for a long-lived service token, which needs no code here. This issue should not be built until that answer comes back negative, and it is filed now so the finding is not lost if it does.
The static-header flag stays correct and shipped either way. Every upstream on the fleet today is an unauthenticated loopback sidecar, and a hosted upstream with a durable token is served fine by what already exists.
Refs coilyco-bridge/deploy#647
The OAuth flow succeeded, which puts real numbers on this and corrects one claim in the issue body. Angie (engineer seat), 2026-08-17.
Kai completed the browser flow against
owl-glass. That confirms the slug is real and she has access, and it produced a working credential to measure.Correction: the access token is opaque, not a JWT
The issue body says the resource "expects a Clerk-issued JWT access token", reasoning from
jwks_uriin the discovery document. That is wrong. The token the flow actually mints is opaque:oat_, no dots/coilysiren/moxn/api-token- 48 chars, prefixmoxn_So the resource server introspects opaque tokens rather than verifying a signed JWT, and
jwks_uridescribes something else in the Clerk setup. I inferred a token format from a discovery field that does not describe the token format, which is the same mistake this issue's parent made reading the README instead of the package.The conclusion still holds, and now on direct evidence rather than inference. The stored
moxn_value is a third credential class: it is neither the access token nor the refresh token, matching neither by value nor by shape. Whatevermoxn_keys are for, the MCP resource server refuses them.The number that sizes this issue
A 24-hour access token. So a
--upstream-headerdeployment holding a correct access token in SSM serves for one day and then returns 401 on every call until a human redoes the browser flow. Not an edge case and not a slow leak - a daily outage.That is the strongest argument for this issue and equally the strongest argument for the cheaper path: if Mark can issue a long-lived service token accepted at
/api/mcp/http, none of this needs building.One detail for whoever implements it
The stored credential names its own
tokenEndpointand aclientId(a public client, consistent withtoken_endpoint_auth_methods_supportedincludingnone). So a refresh exchange needs no client secret, which removes one secret from the pod but does not remove theoffline_accessrefresh token itself. The open question in the body about refresh-token rotation stands, and it is worth checking empirically whether Moxn returns a new refresh token on exchange before assuming either way.Everything above is metadata read from
~/.moxn/credentials-owl-glass.json. No credential value is recorded in this issue.Two constraints from the vendor's auth package that change this issue's shape. Still not started, and still parked behind the vendor question. Olaf (ops seat), 2026-08-18.
Read
@moxn/auth@0.1.0and@moxn/mcp-kb@0.4.1from the tarballs on disk, plus the live discovery documents. Full context on coilyco-bridge/deploy#647, where the slug and the credential class are now settled. Two findings land here rather than there, because they are about what a refresh loop would have to do.1. Refreshing on a borrowed
clientIdsilently revokes the human@moxn/authdist/credentials.d.tsstates this as a load-bearing contract rather than an implementation note:planAuthActionexists entirely to honour it: on a 401 it re-reads the shared store and adopts a sibling's newer token rather than minting, because minting is what kills the others.A pod cannot join that store. So if mcp-beaver refreshes using the
clientIdfrom a credential file a human produced, every renewal in the pod invalidates that human's live session, and every renewal on her laptop invalidates the pod's. Neither side sees an error until the next call 401s. That is a worse failure than the expiry this issue was filed for, because it is silent, mutual, and looks like flakiness.This moves the fourth open question ("whether mcp-beaver should ever register itself") from an aside to a precondition. The pod needs its own
(clientId, user)slot. Registration is live and admits a public client:The vendor client also already honours
MOXN_CLIENT_IDandMOXN_CLIENT_SECRETfrom the environment, so a pre-registered per-deployment client is the shape its own author anticipated. Registering once out of band and carrying the resultingclient_idas ordinary config is materially smaller than teaching a proxy RFC 7591, and it keeps registration out of the hot path.2. The vendor client never requests
offline_access, so it may hold no refresh token at allThis issue says the vendor's client "refreshes silently against the token endpoint". That is conditional on a refresh token existing, and the scope it authorizes with argues against one.
dist/oauth.jsbuilds the scope from the protected-resource document, not the authorization-server document quoted in the issue body:with
scopesSupported = resourceMeta.scopes_supported. Live and unauthenticated:No
offline_access, and@moxn/mcp-kbexposes no scope flag, so an MCP session always authorizes forprofile email.@moxn/authhas anoauthScopeoption but only themoxnCLI can be reaching it.planAuthAction's fallthrough is then the whole story:Marked as inference. Confirming it means reading the key names of a human's credential store, which is not mine to open and which the sandbox correctly refused. deploy#647 carries the one-line, no-secret check.
If it holds, this issue's sketch is not buildable against the credential a human's
npxsession produces, because that session has no refresh token to hand over. The refresh loop would need a purpose-built client that registers itself, requestsprofile email offline_accessexplicitly, and completes one browser flow whose refresh token is then the durable artifact. The AS advertisesoffline_accessinscopes_supported, so it is requestable. Nothing has tested whether it is granted.What this does not change
Parked behind the vendor question, exactly as filed. The cheapest path is still a long-lived service token accepted at
/api/mcp/http, which needs no code in either repo. deploy#647 now asks that question in a sharper form and adds a second one, whether Moxn objects to a second OAuth client registering itself for an unattended agent. The second answer is the one that decides whether this gets built, because both findings above are only reachable through that door.--upstream-headerstays correct. Nothing here argues against the static flag. It argues that Moxn is further from being the upstream it fits than this issue first recorded.One consequence worth stating for whoever picks this up: an
offline_accessrefresh token minted by a self-registered client is still a grant on a person's identity, not a service identity. The isolation in finding 1 stops the pod from stepping on her session. It does not make the grant smaller.Scoping this as the expensive half. The cheap half is now #83 and does not block on it. Olaf (ops seat), 2026-08-18.
Settled since the last comment: Moxn's UI states that MCP connections use OAuth and do not require an API key, so the 401 is intended behaviour rather than a defect. There is no static credential for
--upstream-headerto carry, which makes this issue the only durable path for that upstream rather than a contingency behind a vendor request.Splitting the work, because these were one issue and should not have been:
client_credentialsas an umbra value provider. No browser, no rotation, no personal grant, no client contention. Buildable today for any upstream that offers the grant. Moxn does not.authorization_codeplus refresh. Everything below.What this actually costs, as ops requirements
clientId. Not optional.@moxn/authdist/credentials.d.tsstates that Clerk keeps only the newest access token alive per(clientId, user), and solves it with a credentials file shared between local processes that a pod cannot join. Without a separate client the pod silently revokes the human's session on every refresh, and she revokes the pod's.The precondition that decides whether this is buildable at all
Is a refresh token ever issued? Moxn's protected-resource metadata advertises
scopes_supported: ["profile","email"], and@moxn/authdist/oauth.jstakes its authorization scope from that document rather than the AS document. Nooffline_accessis requested, so a session may hold nothing to refresh.This is answerable in seconds by reading
expiresAtand the key set of an existing credentials file, and it should be answered before any design work starts. If there is no refresh token, this issue is moot for Moxn and only #83 remains useful.Still not started
Correct. An attended access token seeded into SSM covers a demo,
--upstream-headeralready carries it, and that path needs nothing from this issue.Refs #83, coilyco-bridge/deploy#647
A data point from the same Moxn account that may narrow this, found while checking something else.
Moxn does issue a non-expiring API key. It just does not accept one on the MCP endpoint. Two different credentials for one account are now deployed side by side in coilyco-bridge/deploy:
sirens-dowel-moxn-mcpreads/coilysiren/moxn/access-token, the 24-hour browser-minted OAuth token this issue is about.hugo-caddy, the publish plane onvibes.coilysiren.me, reads/coilysiren/moxn/api-tokenasMOXN_API_KEYand drives thecontextCLI with it on a 3-second loop.So the split is per-surface rather than per-account: the CLI path takes a durable key, the MCP path is authorization-code OAuth only, which is what Moxn's own UI says when it states that MCP connections use OAuth and do not require an API key.
Two things follow.
The blast radius is smaller than it reads. Only the MCP wrap expires. The publish plane keeps compiling and serving through an expiry, so a dead token on this path is a quiet tool surface rather than a dead demo.
There may be a shortcut worth asking about before building the general fix. If the constraint is which credential the MCP endpoint accepts rather than which credentials exist, then the durable fix here is a vendor question first and a token-refresh implementation second. Mark Weiss is reachable, and "will the hosted MCP endpoint accept a scoped API key" is a cheaper thing to learn than a refresh flow is to build.
Refs coilyco-bridge/deploy#674, coilyco-bridge/deploy#672