fix: let deployments advertise offline_access so MCP sessions can refresh #20

Merged
jknapp merged 1 commits from fix/oauth-offline-access into main 2026-08-11 22:21:52 +00:00
Owner

Fixes MCP clients being kicked back to an interactive login on a short cycle with requires re-authorization (token expired).

Cause

/.well-known/oauth-protected-resource advertised only openid, profile, email, and the audience scope. Confirmed against the live deployment:

$ curl -s https://memory.dnspegasus.net/.well-known/oauth-protected-resource | jq .scopes_supported
["openid","profile","email","aud-shared-memory"]

$ curl -s https://auth.dnspegasus.net/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .scopes_supported
["profile","aud-shared-memory","openid","email"]

Neither list contains offline_access. A client requests exactly the scopes it discovers in the resource metadata, and Authentik issues a refresh token only when offline_access is among them. So the client received an access token and nothing to renew it with — once that token aged out, manual re-auth was the only way forward.

Note that Authentik does support the grant (grant_types_supported includes refresh_token); it simply never issues one because nobody asks.

This is the same trap the audience scope already documents one comment further up in the same file:

"Authentik only evaluates a scope mapping when the client requests that scope by name, and the client only learns scope names from this document."

A scope missing from this document is a scope the client will never request, however the IdP is configured.

Change

Adds OIDC_OFFLINE_ACCESS (default false). When enabled, offline_access is appended to the advertised scopes.

Why opt-in rather than always-on

It is only half the fix. The IdP also needs an offline_access scope mapping on the provider, and advertising a scope the IdP doesn't offer risks an invalid_scope rejection that breaks authentication outright. Defaulting it on would be a live-fire change for every other deployment of this server, including ones whose admins never read the release note. A deployment turns it on after configuring its IdP.

Deploying this (both steps required)

  1. Authentikshared-memory-mcp provider → add the built-in authentik default OAuth Mapping: offline_access to its Scopes. Verify:
    curl -s https://auth.example.com/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .scopes_supported
    
  2. .envOIDC_OFFLINE_ACCESS=true, redeploy. Verify:
    curl -s https://memory.example.com/.well-known/oauth-protected-resource | jq .scopes_supported
    

Then reconnect one client and confirm it survives past the previous drop-out window.

While you're in the provider, the Access Token validity setting is worth a look — refresh makes expiry survivable, but a very short access-token lifetime still means frequent refresh round-trips.

Tests

4 tests on the metadata endpoint, written before the implementation and confirmed failing against it:

  • omits offline_access by default (protects existing deployments)
  • advertises it when opted in
  • still advertises the audience scope when enabled — regression guard; dropping it would break auth outright
  • honours a custom OIDC_AUDIENCE_SCOPE name alongside it

typecheck also caught that buildPhaseStub() needed the new field — worth noting since that stub is what keeps next build working without real secrets.

Verification

  • 39/39 tests passing
  • pnpm -r typecheck clean
  • pnpm lint clean
  • pnpm build succeeds

Note

The default stays false in this PR, so merging alone changes nothing — the fix lands when the two deploy steps above are done.

Fixes MCP clients being kicked back to an interactive login on a short cycle with `requires re-authorization (token expired)`. ## Cause `/.well-known/oauth-protected-resource` advertised only `openid`, `profile`, `email`, and the audience scope. Confirmed against the live deployment: ``` $ curl -s https://memory.dnspegasus.net/.well-known/oauth-protected-resource | jq .scopes_supported ["openid","profile","email","aud-shared-memory"] $ curl -s https://auth.dnspegasus.net/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .scopes_supported ["profile","aud-shared-memory","openid","email"] ``` Neither list contains `offline_access`. A client requests exactly the scopes it discovers in the resource metadata, and Authentik issues a refresh token only when `offline_access` is among them. So the client received an access token and **nothing to renew it with** — once that token aged out, manual re-auth was the only way forward. Note that Authentik *does* support the grant (`grant_types_supported` includes `refresh_token`); it simply never issues one because nobody asks. This is the same trap the audience scope already documents one comment further up in the same file: > *"Authentik only evaluates a scope mapping when the client requests that scope by name, and the client only learns scope names from this document."* A scope missing from this document is a scope the client will never request, however the IdP is configured. ## Change Adds `OIDC_OFFLINE_ACCESS` (default `false`). When enabled, `offline_access` is appended to the advertised scopes. ## Why opt-in rather than always-on It is only half the fix. The IdP also needs an `offline_access` scope mapping on the provider, and **advertising a scope the IdP doesn't offer risks an `invalid_scope` rejection that breaks authentication outright.** Defaulting it on would be a live-fire change for every other deployment of this server, including ones whose admins never read the release note. A deployment turns it on *after* configuring its IdP. ## Deploying this (both steps required) 1. **Authentik** → `shared-memory-mcp` provider → add the built-in `authentik default OAuth Mapping: offline_access` to its **Scopes**. Verify: ```bash curl -s https://auth.example.com/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .scopes_supported ``` 2. **`.env`** → `OIDC_OFFLINE_ACCESS=true`, redeploy. Verify: ```bash curl -s https://memory.example.com/.well-known/oauth-protected-resource | jq .scopes_supported ``` Then reconnect one client and confirm it survives past the previous drop-out window. While you're in the provider, the **Access Token validity** setting is worth a look — refresh makes expiry survivable, but a very short access-token lifetime still means frequent refresh round-trips. ## Tests 4 tests on the metadata endpoint, written before the implementation and confirmed failing against it: - omits `offline_access` by default (protects existing deployments) - advertises it when opted in - **still advertises the audience scope when enabled** — regression guard; dropping it would break auth outright - honours a custom `OIDC_AUDIENCE_SCOPE` name alongside it `typecheck` also caught that `buildPhaseStub()` needed the new field — worth noting since that stub is what keeps `next build` working without real secrets. ## Verification - 39/39 tests passing - `pnpm -r typecheck` clean - `pnpm lint` clean - `pnpm build` succeeds ## Note The default stays `false` in this PR, so **merging alone changes nothing** — the fix lands when the two deploy steps above are done.
jknapp added 1 commit 2026-08-11 22:19:42 +00:00
MCP clients were being kicked back to an interactive login on a short
cycle, reporting "requires re-authorization (token expired)".

Cause: /.well-known/oauth-protected-resource advertised only
openid/profile/email plus the audience scope. A client requests exactly
the scopes it finds there, and Authentik issues a refresh token only when
offline_access is among them — so the client received an access token
with nothing to renew it with. Once that token aged out, re-authenticating
by hand was the only path forward.

This is the same trap the audience scope already documents one comment
further up: a scope missing from this document is a scope the client will
never ask for, however the IdP is configured.

Adds OIDC_OFFLINE_ACCESS (default false). Enabling it appends
offline_access to the advertised scopes.

Left opt-in rather than always-on because it is only half the fix — the
IdP also needs an offline_access scope mapping on the provider, and
advertising a scope the IdP doesn't offer risks an invalid_scope
rejection that would break authentication outright. A deployment turns
this on after configuring its IdP; README documents both halves and how
to verify each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp merged commit e0d0d3b2b3 into main 2026-08-11 22:21:52 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cybercove-labs/shared-memory#20