serve-upstream cannot refresh an OAuth credential, so an authorization-code-only upstream expires the pod #82

Open
opened 2026-08-18 02:45:54 +00:00 by coilyco-ops · 4 comments
Member

--upstream-header (62474ac) presents a static value resolved from env, file, or literal on 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:

{"issuer":"https://<workspace>.moxn.dev",
 "authorization_endpoint":"https://<workspace>.moxn.dev/api/oauth/authorize",
 "token_endpoint":"https://<workspace>.moxn.dev/api/oauth/token",
 "registration_endpoint":"https://<workspace>.moxn.dev/api/oauth/register",
 "jwks_uri":"https://clerk.moxn.dev/.well-known/jwks.json",
 "scopes_supported":["profile","email","offline_access"],
 "grant_types_supported":["authorization_code","refresh_token"],
 "token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post"],
 "code_challenge_methods_supported":["S256"]}

grant_types_supported has no client_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_access is 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-upstream proxy 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:

  • A refresh-token grant against a configured token_endpoint, with the refresh token resolved through the existing valuesource registry so it lands from a Secret exactly as {env:VAR} does now.
  • An in-memory access token cached to its own expires_in, renewed ahead of expiry rather than after a 401, so no call pays for the refresh.
  • Renewal serialized across concurrent calls: the proxy holds one long-lived session and forwards every tool call through it, so a naive implementation would stampede the token endpoint.
  • A 401 from the upstream as a secondary trigger, since an access token can be revoked before its stated expiry. Bounded to one retry, matching how reconnect deliberately refuses to replay a call that may already have reached the upstream.
  • Failure stays loud. A refresh that cannot succeed should surface as a clear tool error naming the token endpoint, never a silent fallback to an unauthenticated request.

Open questions worth settling before building

  • Does this belong here at all? The current split says umbra owns guarded HTTP execution and its auth node 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.
  • Where does the refresh token live, and what happens when it rotates? Some issuers return a new refresh token on every exchange. A pod holding a rotating credential in SSM needs write access back to SSM, which is a materially larger blast radius than the read-only header this replaces.
  • Is an offline_access refresh 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.
  • Dynamic client registration. The document advertises a registration_endpoint and token_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

`--upstream-header` (62474ac) presents a **static** value resolved from `env`, `file`, or `literal` on 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: ```json {"issuer":"https://<workspace>.moxn.dev", "authorization_endpoint":"https://<workspace>.moxn.dev/api/oauth/authorize", "token_endpoint":"https://<workspace>.moxn.dev/api/oauth/token", "registration_endpoint":"https://<workspace>.moxn.dev/api/oauth/register", "jwks_uri":"https://clerk.moxn.dev/.well-known/jwks.json", "scopes_supported":["profile","email","offline_access"], "grant_types_supported":["authorization_code","refresh_token"], "token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post"], "code_challenge_methods_supported":["S256"]} ``` **`grant_types_supported` has no `client_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_access` is 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-upstream` proxy 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: * A refresh-token grant against a configured `token_endpoint`, with the refresh token resolved through the existing `valuesource` registry so it lands from a Secret exactly as `{env:VAR}` does now. * An in-memory access token cached to its own `expires_in`, renewed ahead of expiry rather than after a 401, so no call pays for the refresh. * Renewal serialized across concurrent calls: the proxy holds one long-lived session and forwards every tool call through it, so a naive implementation would stampede the token endpoint. * A 401 from the upstream as a secondary trigger, since an access token can be revoked before its stated expiry. Bounded to one retry, matching how `reconnect` deliberately refuses to replay a call that may already have reached the upstream. * Failure stays loud. A refresh that cannot succeed should surface as a clear tool error naming the token endpoint, never a silent fallback to an unauthenticated request. ## Open questions worth settling before building * **Does this belong here at all?** The current split says umbra owns guarded HTTP execution and its `auth` node 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. * **Where does the refresh token live, and what happens when it rotates?** Some issuers return a new refresh token on every exchange. A pod holding a rotating credential in SSM needs write access back to SSM, which is a materially larger blast radius than the read-only header this replaces. * **Is an `offline_access` refresh 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. * **Dynamic client registration.** The document advertises a `registration_endpoint` and `token_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
Author
Member

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_uri in the discovery document. That is wrong. The token the flow actually mints is opaque:

  • access token - 36 chars, prefix oat_, no dots
  • refresh token - 48 chars, no prefix
  • the value in /coilysiren/moxn/api-token - 48 chars, prefix moxn_

So the resource server introspects opaque tokens rather than verifying a signed JWT, and jwks_uri describes 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. Whatever moxn_ keys are for, the MCP resource server refuses them.

The number that sizes this issue

expiresAt: 2026-08-19T02:47:17.703Z    (issued ~2026-08-18T02:47Z)

A 24-hour access token. So a --upstream-header deployment 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 tokenEndpoint and a clientId (a public client, consistent with token_endpoint_auth_methods_supported including none). So a refresh exchange needs no client secret, which removes one secret from the pod but does not remove the offline_access refresh 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.

**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_uri` in the discovery document. **That is wrong.** The token the flow actually mints is opaque: * **access token** - 36 chars, prefix `oat_`, no dots * **refresh token** - 48 chars, no prefix * the value in `/coilysiren/moxn/api-token` - 48 chars, prefix `moxn_` So the resource server introspects opaque tokens rather than verifying a signed JWT, and `jwks_uri` describes 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. Whatever `moxn_` keys are for, the MCP resource server refuses them. ## The number that sizes this issue ``` expiresAt: 2026-08-19T02:47:17.703Z (issued ~2026-08-18T02:47Z) ``` **A 24-hour access token.** So a `--upstream-header` deployment 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 `tokenEndpoint` and a `clientId` (a public client, consistent with `token_endpoint_auth_methods_supported` including `none`). So a refresh exchange needs no client secret, which removes one secret from the pod but does not remove the `offline_access` refresh 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.
Author
Member

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.0 and @moxn/mcp-kb@0.4.1 from 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 clientId silently revokes the human

@moxn/auth dist/credentials.d.ts states this as a load-bearing contract rather than an implementation note:

Every Moxn client process on a machine shares one credentials file per workspace. That sharing is a load-bearing contract: Clerk keeps only the newest access token alive per (clientId, user), so any client that minted its own private token would silently revoke every sibling's.

planAuthAction exists 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 clientId from 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:

GET https://clerk.moxn.dev/.well-known/oauth-authorization-server
  "registration_endpoint":"https://clerk.moxn.dev/oauth/register",
  "token_endpoint_auth_methods_supported":["client_secret_basic","none","client_secret_post"]

The vendor client also already honours MOXN_CLIENT_ID and MOXN_CLIENT_SECRET from the environment, so a pre-registered per-deployment client is the shape its own author anticipated. Registering once out of band and carrying the resulting client_id as 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 all

This 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.js builds the scope from the protected-resource document, not the authorization-server document quoted in the issue body:

const scope = options.oauthScope?.trim() || scopesSupported?.join(' ') || 'profile email'

with scopesSupported = resourceMeta.scopes_supported. Live and unauthenticated:

GET https://owl-glass.moxn.dev/.well-known/oauth-protected-resource/api/mcp/http
{"resource":"https://owl-glass.moxn.dev/api/mcp/http",
 "authorization_servers":["https://owl-glass.moxn.dev"],
 "scopes_supported":["profile","email"],
 "bearer_methods_supported":["header"]}

No offline_access, and @moxn/mcp-kb exposes no scope flag, so an MCP session always authorizes for profile email. @moxn/auth has an oauthScope option but only the moxn CLI can be reaching it.

planAuthAction's fallthrough is then the whole story:

if (stored.refreshToken && stored.tokenEndpoint) return { action: 'refresh' }
return { action: 'reauth' }

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 npx session produces, because that session has no refresh token to hand over. The refresh loop would need a purpose-built client that registers itself, requests profile email offline_access explicitly, and completes one browser flow whose refresh token is then the durable artifact. The AS advertises offline_access in scopes_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-header stays 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_access refresh 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.

**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.0` and `@moxn/mcp-kb@0.4.1` from 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 `clientId` silently revokes the human `@moxn/auth` `dist/credentials.d.ts` states this as a load-bearing contract rather than an implementation note: > Every Moxn client process on a machine shares one credentials file per workspace. That sharing is a load-bearing contract: **Clerk keeps only the newest access token alive per (clientId, user)**, so any client that minted its own private token would silently revoke every sibling's. `planAuthAction` exists 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 `clientId` from 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: ``` GET https://clerk.moxn.dev/.well-known/oauth-authorization-server "registration_endpoint":"https://clerk.moxn.dev/oauth/register", "token_endpoint_auth_methods_supported":["client_secret_basic","none","client_secret_post"] ``` The vendor client also already honours `MOXN_CLIENT_ID` and `MOXN_CLIENT_SECRET` from the environment, so a pre-registered per-deployment client is the shape its own author anticipated. Registering once out of band and carrying the resulting `client_id` as 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 all This 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.js` builds the scope from the **protected-resource** document, not the authorization-server document quoted in the issue body: ```js const scope = options.oauthScope?.trim() || scopesSupported?.join(' ') || 'profile email' ``` with `scopesSupported = resourceMeta.scopes_supported`. Live and unauthenticated: ``` GET https://owl-glass.moxn.dev/.well-known/oauth-protected-resource/api/mcp/http {"resource":"https://owl-glass.moxn.dev/api/mcp/http", "authorization_servers":["https://owl-glass.moxn.dev"], "scopes_supported":["profile","email"], "bearer_methods_supported":["header"]} ``` No `offline_access`, and `@moxn/mcp-kb` exposes no scope flag, so an MCP session always authorizes for `profile email`. `@moxn/auth` has an `oauthScope` option but only the `moxn` CLI can be reaching it. `planAuthAction`'s fallthrough is then the whole story: ```js if (stored.refreshToken && stored.tokenEndpoint) return { action: 'refresh' } return { action: 'reauth' } ``` **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 `npx` session produces, because that session has no refresh token to hand over. The refresh loop would need a purpose-built client that registers itself, requests `profile email offline_access` explicitly, and completes one browser flow whose refresh token is then the durable artifact. The AS advertises `offline_access` in `scopes_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-header` stays 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_access` refresh 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.
Author
Member

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-header to 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:

  • #83, client_credentials as 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.
  • This issue, authorization_code plus refresh. Everything below.

What this actually costs, as ops requirements

  1. An attended seeding tool. Nothing here runs a browser, so a refresh token has to arrive from somewhere. That tool is unscoped and is where most of the mess lives. It is a prerequisite, not a follow-up.
  2. Refresh-token rotation. If the issuer rotates on every exchange, the pod must persist the new token or lose the grant at restart. Writing back to SSM gives a pod write access to the parameter store, which is a much larger blast radius than the read-only header this replaces. Redis is already on the cluster and is the less-bad store.
  3. Its own registered clientId. Not optional. @moxn/auth dist/credentials.d.ts states 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.
  4. A long-lived grant on a person's identity in a pod, because the upstream exposes no service identity. That is a deploying-repo judgement, not a runtime default.

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/auth dist/oauth.js takes its authorization scope from that document rather than the AS document. No offline_access is requested, so a session may hold nothing to refresh.

This is answerable in seconds by reading expiresAt and 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-header already carries it, and that path needs nothing from this issue.

Refs #83, coilyco-bridge/deploy#647

**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-header` to 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: * **#83, `client_credentials` as 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. * **This issue, `authorization_code` plus refresh.** Everything below. ## What this actually costs, as ops requirements 1. **An attended seeding tool.** Nothing here runs a browser, so a refresh token has to arrive from somewhere. That tool is unscoped and is where most of the mess lives. It is a prerequisite, not a follow-up. 2. **Refresh-token rotation.** If the issuer rotates on every exchange, the pod must persist the new token or lose the grant at restart. Writing back to SSM gives a pod write access to the parameter store, which is a much larger blast radius than the read-only header this replaces. Redis is already on the cluster and is the less-bad store. 3. **Its own registered `clientId`.** Not optional. `@moxn/auth` `dist/credentials.d.ts` states 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. 4. **A long-lived grant on a person's identity in a pod**, because the upstream exposes no service identity. That is a deploying-repo judgement, not a runtime default. ## 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/auth` `dist/oauth.js` takes its authorization scope from that document rather than the AS document. No `offline_access` is requested, so a session may hold nothing to refresh. This is answerable in seconds by reading `expiresAt` and 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-header` already carries it, and that path needs nothing from this issue. Refs #83, coilyco-bridge/deploy#647
Author
Member

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-mcp reads /coilysiren/moxn/access-token, the 24-hour browser-minted OAuth token this issue is about.
  • hugo-caddy, the publish plane on vibes.coilysiren.me, reads /coilysiren/moxn/api-token as MOXN_API_KEY and drives the context CLI 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

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-mcp` reads `/coilysiren/moxn/access-token`, the 24-hour browser-minted OAuth token this issue is about. * `hugo-caddy`, the publish plane on `vibes.coilysiren.me`, reads `/coilysiren/moxn/api-token` as `MOXN_API_KEY` and drives the `context` CLI 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
Sign in to join this conversation.
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
coilyco-flight-deck/mcp-beaver#82
No description provided.