Skip to content

feat(oauth): add OAuth 2.1 authorization flow for MCP clients - #318

Draft
dash0-dev[bot] wants to merge 24 commits into
mainfrom
feat/mcp-oauth-authorization
Draft

feat(oauth): add OAuth 2.1 authorization flow for MCP clients#318
dash0-dev[bot] wants to merge 24 commits into
mainfrom
feat/mcp-oauth-authorization

Conversation

@dash0-dev

@dash0-dev dash0-dev Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the OAuth 2.1 authorization-code + PKCE flow that an MCP host's Authorize button drives, so Claude Code / Cursor / ChatGPT / VS Code can attach to LoreKit without generating and pasting an lk_* token. The consent screen lets the user choose the access level and which organizations the connection may reach.

The authorization server is the Next.js dashboard, not an Edge Function: the consent screen needs the Supabase-Auth session, listMyOrgs() and the api_tokens write path, all of which already live in packages/web and none of which the self-contained Deno edge tree can reach. Both discovery documents are served from there too — RFC 9728 §3.1 lets the WWW-Authenticate challenge carry an absolute resource_metadata URL, so the protected-resource document does not have to be same-origin with the resource, and one owner beats a pure module mirrored verbatim into the Deno tree for two string constants. The MCP Edge Function stays the resource server.

Changes

Migration 00055_oauth.sql

  • oauth_clients (RFC 7591 dynamic registration, public clients only — token_endpoint_auth_method = 'none' is CHECK-enforced).
  • oauth_authorization_codes (hashed code, S256-only challenge, consumed_at for replay detection).
  • api_tokens gains kind / expires_at / org_ids / client_id, all nullable-or-defaulted so every existing row keeps its exact meaning with no backfill.
  • lorekit_purge_expired_oauth(), service-role only.

Authorization server (packages/web)

  • /.well-known/oauth-protected-resource (RFC 9728), /.well-known/oauth-authorization-server (RFC 8414), /api/oauth/register, /api/oauth/token, /api/oauth/revoke (RFC 7009).
  • /oauth/authorize consent screen — outside the (dashboard) group (no sidebar competing with the decision) but reusing its getUser()/login?next=… gate verbatim.
  • Pure, unit-tested modules: pkce.ts (RFC 7636 Appendix B vector, S256-only, timing-safe compare), redirect-uri.ts, client-registration.ts, metadata.ts, errors.ts. Impure shell in store.ts.
  • middleware.ts matcher excludes api/oauth and .well-known — those are reached by a client process with no cookie to refresh.

Resource server (supabase/functions/mcp)

  • Answers /.well-known/oauth-protected-resource with a 308 to the dashboard copy, so a client that derives the URL from the resource identifier (RFC 9728 §3.1 path insertion) instead of reading the header still resolves.
  • resolveAuth rejects an expired token and carries the org allow-list.
  • memory.* reads intersect the allow-list with lorekit_member_org_ids; explicit-org writes/deletes are pre-checked against it.

Notable decisions (all argued in CLAUDE.md)

  • The "no 401" invariant is narrowed, not repealed. A request with no credential gets 401 + WWW-Authenticate — the only discovery trigger an Authorize button can follow. A request with an invalid/expired token still gets the in-band JSON-RPC error at HTTP 200. The hang the original rule prevents (mcp-remote reading a 401 as a session failure and retrying silently) requires a configured client with a pending, correlated tools/call, which a credential-less request does not have. mcp-authz-status.spec.ts now pins both halves.
  • An OAuth token IS an api_tokens row, not a second credential type — revocation, the dashboard list and the audit trail all keep working. Re-authorizing replaces the client's previous token. The 20-token cap is the one thing that does not carry over: MAX_TOKENS_PER_USER is checked only in generateToken, and issueAccessToken never counts rows, so authorizing enough distinct clients takes a user past 20 (re-authorizing an existing client is net-zero, which bounds the drift to the client count). Those rows still count against the dashboard's own cap, so a user over the line can no longer mint a dashboard token. Enforcing it belongs at consent time, where a browser session exists to tell the user to revoke something — rejecting at the token endpoint would burn the authorization code with no recoverable RFC 6749 §5.2 error code. Tracked as an open item on this PR, not claimed as done.
  • org_ids is narrowing only. It is intersected with lorekit_member_org_ids via one shared pair (intersectTokenOrgIds / tokenAllowsOrgId, mirrored + parity-guarded), never substituted for it, so leaving an org revokes access immediately. Role authorization stays inside lorekit_org_can. The intersection is applied after the per-request membership cache so one credential's restriction cannot leak into another call.
  • The token endpoint collapses every rejection into one opaque invalid_grant — distinguishing "expired" from "wrong verifier" for an unauthenticated caller is an oracle. The real reason goes to telemetry only; the route spec asserts the collapse.
  • redirect_uri validation is its own rule, deliberately not safeNextPath (an MCP redirect target is legitimately an off-origin loopback URL or a dotted private-use scheme). The single exception to exact matching is the loopback port, per RFC 8252 §7.3.
  • No refresh-token grant is issued or advertised: re-running the one-click flow is a smaller surface than a second long-lived credential.
  • No new audit action. Consent is recorded as api_key.create with metadata.via = 'oauth', reusing the existing vocabulary rather than widening the audit_log CHECK.
  • packages/mcp-server (Fly.io Node variant) is out of scope, as it already is for API-token auth.
  • Both discovery values are resolved per deployment, not pinned to production. The edge builds its WWW-Authenticate challenge and its 308 metadata redirect through authorizationServerIssuer() (supabase/functions/mcp/oauth-metadata.ts), which honours the existing LOREKIT_APP_URL secret; the dashboard's protected-resource document resolves its resource through resolveMcpUrl() (packages/web/src/lib/mcp-url.ts, the one derivation from NEXT_PUBLIC_SUPABASE_URL). Pinned constants would have had a staging Supabase project challenge clients toward production lorekit.io and mint a token for the wrong resource, and would have made the flow unexercisable outside production — which is how that bug would have survived. resolveMcpUrl() was also fixed to stop mangling a non-supabase.co origin (a local http://127.0.0.1:54321 became https://http://127.0.0.1:54321.supabase.co/...), harmless while it only fed copy-paste snippets and not harmless once clients compare the value. This is the deploy-tooling carve-out to the static-URL rule, not a <ref> placeholder in user-facing copy; oauth-discovery.spec.ts pins both the overridability and the concrete production literal.
  • LOREKIT_APP_URL is now load-bearing for OAuth on every non-production project. It previously only shaped cap / rate-limit message links, so leaving it unset was cosmetic. On a preview or self-hosted Supabase project it must now be set to that deployments dashboard origin, or the edge will challenge clients toward lorekit.ioand the Authorize button will complete against the wrong authorization server. Production is unaffected — the default already ishttps://lorekit.io`.

Verification

  • nx run-many -t typecheck --projects=web,mcp-core,mcp-server — clean
  • nx run-many -t test --projects=mcp-core,web — 836 + 542 passing, including the updated mcp-authz-status guard and the new cross-package oauth-discovery guard (issuer, metadata URL and challenge shape agree across packages/web and the Deno edge tree)
  • nx lint web / nx lint mcp-core — clean
  • scripts/check-migration-order.mjs main — ok (00055 sorts after the base max, 00054)

Follow-ups (deliberately not in this PR)

  • SQL assertions for the new tables as a numbered section of supabase/tests/migrations.test.sql.
  • A "Connected applications" panel in Settings → API keys that groups kind='oauth' rows by client and shows the granted orgs.
  • Scheduling lorekit_purge_expired_oauth().
  • The REST surface (_shared/api/auth.ts) has its own resolveAuth; it does not yet honour expires_at / org_ids. Worth landing in lockstep before OAuth tokens are advertised for REST.

MCP hosts (Claude Code, Cursor, ChatGPT, VS Code) expose an "Authorize"
action that drives RFC 9728 discovery -> RFC 8414 metadata -> RFC 7591
dynamic client registration -> consent -> authorization-code exchange with
PKCE. LoreKit had none of it, so the only way to attach a client was to mint
an lk_* token in the dashboard and paste it into a config file.

The authorization server is the Next.js dashboard: the consent screen needs
the Supabase-Auth session, the caller's org list and the api_tokens write
path, all of which already live in packages/web and none of which the
self-contained Deno edge tree can reach. The MCP Edge Function stays the
resource server.

An OAuth access token IS an api_tokens row (same lk_ format, same SHA-256
lookup), so revocation, the per-user cap, the dashboard list and the audit
trail keep working unchanged; what is new is kind='oauth', a 30-day
expires_at enforced at auth time, client_id, and the org allow-list the user
picked on the consent screen.

The "no 401 from the MCP endpoint" invariant is narrowed rather than
repealed: a request with NO credential now gets 401 + WWW-Authenticate (the
only discovery trigger an Authorize button can follow), while a request with
an invalid or expired token still gets an in-band JSON-RPC error at HTTP
200 -- the hang that rule prevents needs a configured client with a pending
tools/call, which a credential-less request does not have.
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lorekit Ready Ready Preview Aug 3, 2026 3:12pm

The RFC 9728 protected-resource document was served by the MCP Edge
Function and the RFC 8414 document by the Next.js app, which meant a pure
module mirrored verbatim into the self-contained Deno tree (plus an
edge-parity entry) to carry two string constants.

RFC 9728 3.1 lets the WWW-Authenticate challenge carry an absolute
resource_metadata URL, and the MCP spec requires clients to follow it, so
the document does not have to be same-origin with the resource. Both now
live in packages/web with one pair of constants and one owner.

The edge function keeps exactly two pieces of OAuth knowledge: the
challenge string, and a 308 on the path-constructed metadata URL so a
client that derives it from the resource identifier instead of reading the
header does not hit a 404. oauth-discovery.spec.ts source-scans both sides
to keep the issuer, metadata URL and challenge shape agreeing.
@mthines

mthines commented Aug 2, 2026

Copy link
Copy Markdown
Owner

@dash0 review

@dash0-dev

dash0-dev Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

⚡ Running PR Review & Implement
View PR Review & Implement in Agent0

@dash0-dev dash0-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 gate(s) that need attention before human review.

Gate Status Details
Description vs. code "the 20-token cap keeps working" — issueAccessToken never checks MAX_TOKENS_PER_USER
Prior bot feedback
Documentation
Self-review signals
Code review See inline comments

Reviewed for commit 58d08799b2a154b3c313d06afa541e807fe2e73e. CI status is shown in the checks section above.

Review diagnostics

Run mode: full — 3284 lines changed across 37 files
Integrations checked: RFC 6749 §4.1.3/§5.2, RFC 7591 §3.2.1, RFC 7636 §4.1, RFC 7009 §2.2, RFC 8252 §7.1/§7.3, RFC 8414, RFC 9728 §3.1; PostgREST/supabase-js filter + RPC surface; Fetch/CORS Access-Control-Expose-Headers semantics. No dependency manifest or lock file changed.

Quality Gate: produced 14, carried forward 0, relevance-memory drops 0, dedupe drops 2,
grounding drops 0, confidence drops 4, shape drops 0, cleared 8, deferred over inline cap 0, posted inline 8.

Optimality review (2.4c): ran — 6 unit(s) judged, 6 optimal, 0 proposal(s), 0 withheld.

Skipped files: none

Comment thread supabase/migrations/00049_oauth.sql Outdated
Comment thread supabase/migrations/00095_oauth.sql
Comment thread packages/web/src/lib/oauth/store.ts Outdated
permissions: grant.permissions,
kind: 'oauth',
client_id: grant.clientId,
org_ids: grant.orgIds,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: MAX_TOKENS_PER_USER (tokens.ts:44) is never checked on this path, so authorizing enough clients pushes a user past the 20-token cap the PR says keeps working. (non-blocking)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half fixed. The PR body and CLAUDE.md:532 no longer claim the cap holds here (2885cc1) — the code change is not applied: consumeAuthorizationCode has already burned the code by the time issueAccessToken runs, so rejecting there hands the client a dead code and there is no fitting RFC 6749 §5.2 error, leaving every retry to fail identically. Consent time is the seam that has a browser session to point at "revoke a token"; leaving this thread open for that call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not applied and the thread stays open, as agreed: a consent-time check needs MAX_TOKENS_PER_USER out of a 'use server' file plus a new consent-screen failure path that ConsentResult has no channel for. The prose half is already correct in the body, CLAUDE.md and now the migration header (481c7b6).

headers: {
'Content-Type': 'application/json',
'WWW-Authenticate': wwwAuthenticateChallenge(),
'Access-Control-Expose-Headers': 'WWW-Authenticate',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Access-Control-Expose-Headers is inert without Access-Control-Allow-Origin, so a browser-based MCP client still cannot read this challenge. This function sets no CORS headers on any other response. (non-blocking)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed inert — line 132 is the only Access-Control-* header in the whole function and there is no OPTIONS handler, so a browser client never completes preflight either. Not applied: the only remedy that makes the challenge readable is Access-Control-Allow-Origin plus a preflight on the MCP resource endpoint, which opens it to every browser origin — an authz-surface decision beyond this suggestion. Leaving the thread open.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed inert and left as-is: Access-Control-Allow-Origin plus an OPTIONS preflight on the MCP resource endpoint is an authz-surface decision, not a suggestion this run can land. Thread stays open for that call.

Comment thread supabase/functions/mcp/tools.ts Outdated
Comment thread packages/web/src/app/api/oauth/register/route.ts
Comment thread packages/web/src/app/oauth/authorize/AuthorizeConsent.tsx
Both discovery values were pinned to production constants, which had two
consequences. A staging Supabase project would have challenged clients
toward production lorekit.io and had a token minted for the wrong resource;
and the flow could not be exercised on a local stack at all, which is how
that bug would have survived to production.

- The edge challenge and the protected-resource redirect now go through
  authorizationServerIssuer(), honouring the LOREKIT_APP_URL secret that
  already existed for cap/rate-limit message links.
- The protected-resource document resolves its resource through
  resolveMcpUrl() -- the one derivation from NEXT_PUBLIC_SUPABASE_URL --
  rather than holding a second copy of the production URL.
- resolveMcpUrls() no longer mangles a non-supabase.co origin. It split the
  host on the ref, so a local http://127.0.0.1:54321 became
  https://http://127.0.0.1:54321.supabase.co/functions/v1/mcp. Harmless
  while it only fed a copy-paste onboarding snippet; wrong now that the same
  URL is the OAuth resource identifier clients compare against.

oauth-discovery.spec.ts gains assertions that neither side is re-pinned to a
production literal, and deployment.md documents LOREKIT_APP_URL as
load-bearing for OAuth on non-production projects.
Agent0 added 7 commits August 2, 2026 07:27
`auth.role()` is NULL whenever there is no PostgREST request context — pg_cron,
psql, the dashboard SQL editor — so `is distinct from 'service_role'` was true
for exactly the callers the sweep is meant for and the function raised LK002
instead of purging. The guard now only checks a caller that actually presented
a JWT; every other caller stays gated by the REVOKE/GRANT below, which is what
00004's and 00034's reapers rely on.

Addresses @dash0-dev[bot]
#318 (comment)
The comment on `oauth_authorization_codes.scope` described `state` — the
parameter echoed back on the authorize redirect for CSRF protection, which this
table never stores. It now describes the requested scope string and says
plainly that the granted access is `permissions` + `org_ids`.

Addresses @dash0-dev[bot]
#318 (comment)
`randomToken(24)` base64url-encodes to exactly 32 characters, so stripping `-`
and `_` before `slice(0, 32)` produced a suffix of 26-32 characters (measured
over 20k samples) instead of the `lk_{rw|ro|wo}_<32>` the dashboard mints. Use
the same alphanumeric generator `lib/tokens.ts` uses; it is copied rather than
imported because that module is `'use server'` and exporting the helper there
would make it a callable action endpoint.

Addresses @dash0-dev[bot]
#318 (comment)
`MAX_TOKENS_PER_USER` is checked only in `generateToken`; `issueAccessToken`
inserts without counting, so the cap does not hold on the OAuth path. Say so,
name the bound (re-authorizing a client is net-zero, so drift is the number of
distinct clients) and record why enforcing it at the token endpoint is the
wrong seam.

Addresses @dash0-dev[bot]
#318 (comment)
`toolArchive`, `toolRestore`, `toolPurge` and `toolPurgeExpired` never read
`tokenOrgIds` — they scope by `user_id` alone, which is already narrower than
any org allow-list. The parameter is there because mcp-handler.ts dispatches
every tool through one uniform signature; the docblocks now say that instead of
implying an intersection that does not happen. The six handlers that do consult
it are unchanged.

Addresses @dash0-dev[bot]
#318 (comment)
The `0 = the registration does not expire` note sat above
`client_id_issued_at`, which is a timestamp; no expiry field is emitted at all.
Describe the field that is actually there and state the omission explicitly.

Addresses @dash0-dev[bot]
#318 (comment)
`permissions` initialises to `['read', 'write']`, the widest of the three
tiers, so the "least access that is still useful" claim was wrong. State the
real default and the reason for it, and keep the part of the claim that is
true: no org is granted unless the user ticks it.

Addresses @dash0-dev[bot]
#318 (comment)

@dash0-dev dash0-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 gate(s) that need attention before human review.

Gate Status Details
Description vs. code Omits the per-deployment issuer/resource resolution and the now load-bearing LOREKIT_APP_URL
Prior bot feedback
Documentation
Self-review signals
Code review See inline comments

Reviewed for commit 1a77f85d4eec24226dbc0122f20eec337aeeb8d5. CI status is shown in the checks section above.

Review diagnostics

Run mode: full — 284 lines in delta since 58d0879 (upgraded from incremental: delta > 100 lines and a migrations/ path)
Integrations checked: RFC 6749 §4.1.1/§4.1.3/§5.1, RFC 7009 §2.2, RFC 7591 §3.2.1, RFC 7636 §4.1, RFC 8252 §7.1/§7.3, RFC 8414, RFC 9728 §3.1; Fetch/CORS simple-response semantics (Access-Control-Allow-Origin required on the actual response, not only the preflight); PostgREST filter + RPC surface. No dependency manifest or lock file changed.

Quality Gate: produced 14, carried forward 0, relevance-memory drops 0, dedupe drops 4,
grounding drops 0, confidence drops 6, shape drops 0, cleared 3, deferred over inline cap 0, posted inline 3.

Memories applied: (2 LoreKit memories influenced this review)

Optimality review (2.4c): ran — 6 unit(s) judged, 6 optimal, 0 proposal(s), 0 withheld.

Skipped files: none

Comment thread packages/web/src/app/api/oauth/register/route.ts Outdated
Comment thread packages/web/src/lib/oauth/metadata.ts Outdated
Comment thread packages/mcp-core/src/oauth-discovery.spec.ts
Agent0 added 3 commits August 2, 2026 07:44
The 201 was the only response from POST /api/oauth/register without an
Access-Control-Allow-Origin header, so a browser-based MCP client -- the
very caller the OPTIONS preflight below it exists for -- could complete
the registration but never read the client_id it was issued.

Declare the header once as CORS_HEADERS and spread it into all three
paths (201, error, preflight) so they cannot drift apart again. The
response carries no secret (public clients only, no client_secret) and
registration is already open, so '*' grants nothing new.

Addresses #318 (comment)
The protectedResourceMetadata docblock said `resource` must be the
concrete production URL, which contradicts both the MCP_RESOURCE_URL
note above it (which calls that value a default) and the route, which
passes resolveMcpUrl() so a preview stack advertises its own endpoint.

Reworded to say "this deployment's endpoint" and to name the
derivation. The no-<ref>-placeholder rule is kept -- resolveMcpUrl()
yields a concrete origin -- so the CLAUDE.md key decision it encodes is
not lost.

Addresses #318 (comment)
edgeBuilt and METADATA_URL were both interpolated from the same local
ISSUER const, so the assertion was equal by construction -- no edit to
either source could ever break it, and the belt-and-braces guard the
comment promised never fired.

Each side is now rebuilt from literals extracted out of that side's own
source (edge AUTHORIZATION_SERVER_ISSUER + its path string; web
DEFAULT_ISSUER + its path template) and the two are compared with each
other and with METADATA_URL. Explicit not-toBeNull assertions on all
four matches close the undefined === undefined hole a stale regex would
otherwise open.

Mutation-verified: repointing the edge issuer at staging.lorekit.io
makes the comparison fail; the previous assertion stayed green.

Addresses #318 (comment)

@dash0-dev dash0-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed your changes and found no issues ready for human review.

Gate Status
Description vs. code
Prior bot feedback
Documentation
Self-review signals
Code review

Incremental review for commit df3170ab2ec0f39c67e34047f717ae75b55dc1bb (delta since 1a77f85). CI status is shown in the checks section above.

Review diagnostics

Run mode: incremental — 55 lines in delta across 3 files (oauth-discovery.spec.ts, api/oauth/register/route.ts, lib/oauth/metadata.ts); 0 new files, 0 high-stakes paths

Integrations checked: RFC 7591 §3.2.1 (registration response — client_id_issued_at optional, no client_id_expires_at field exists; emitted shape conforms), RFC 8414 / RFC 9728 §3.1 (issuer + metadata URL agreement across the web/edge split), Fetch CORS preflight semantics (Content-Type: application/json is non-safelisted, so the OPTIONS handler and its Access-Control-Allow-Headers are required and correct). No dependency manifest or lock file changed.

Quality Gate: produced 5, carried forward 0, relevance-memory drops 0, dedupe drops 1,
grounding drops 1, confidence drops 3, shape drops 0, cleared 0, deferred over inline cap 0, posted inline 0.

Optimality review (2.4c): ran — 3 unit(s) judged, 3 optimal, 0 proposal(s), 0 withheld.

Skipped files: none

@mthines
mthines marked this pull request as ready for review August 2, 2026 08:06
@mthines
mthines self-requested a review as a code owner August 2, 2026 08:06

@dash0-dev dash0-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed your changes and found no issues ready for human review.

Gate Status
Description vs. code
Prior bot feedback
Documentation
Self-review signals
Code review

No code changes since df3170a — gate checks only for commit df3170ab2ec0f39c67e34047f717ae75b55dc1bb. CI status is shown in the checks section above.

Review diagnostics

Run mode: incremental-quick — no code changes (compare df3170a...df3170a is identical; the prior PR_REVIEWER_REPORT review was already submitted at this commit). Inline review skipped per the zero-delta short-circuit; all gates re-evaluated against the full PR state.

Integrations checked: skipped (incremental-quick)

Quality Gate: produced 0, carried forward 0, relevance-memory drops 0, dedupe drops 0,
grounding drops 0, confidence drops 0, shape drops 0, cleared 0, deferred over inline cap 0, posted inline 0.

Optimality review (2.4c): skipped (incremental-quick) — 0 unit(s) judged, 0 optimal, 0 proposal(s), 0 withheld.

Skipped files: none

main shipped its own 00049 (memory_scopes_last_activity) plus 00050-00052
while this branch was open, so merging main in left two files claiming
00049. supabase db push applies in numeric order, so a duplicate that sorts
before migrations already live cannot be applied without --include-all.

Renames the file and updates every reference to it in the code comments,
the drift guards, docs/architecture.md and CLAUDE.md.

@dash0-dev dash0-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 gate(s) that need attention before human review.

Gate Status Details
Description vs. code Body still names 00049_oauth.sql; HEAD ships 00053_oauth.sql (renumbered in b8ff173)
Prior bot feedback
Documentation
Self-review signals
Code review See inline comments

Reviewed for commit b8ff173dadd4a0cc8f84a7a8e33a50bb89351bfc. CI status is shown in the checks section above.

Review diagnostics

Run mode: full — upgraded from incremental (delta since df3170a is 17143 lines / 63 new files, dominated by the origin/main merge in d6524d7)

Integrations checked: RFC 6749 §4.1.1/§4.1.2.1/§4.1.3/§5.1/§5.2, RFC 7009 §2.2, RFC 7591 §3.2.1, RFC 7636 §4.1 + Appendix B, RFC 8252 §7.1/§7.3, RFC 8414, RFC 9728 §3.1; PostgREST filter/RPC surface; Fetch CORS preflight semantics. No dependency manifest or lock file changed.

Quality Gate: produced 14, carried forward 0, relevance-memory drops 0, dedupe drops 3,
grounding drops 1, confidence drops 5, shape drops 0, cleared 5, deferred over inline cap 0, posted inline 5.

Optimality review (2.4c): ran — 6 unit(s) judged, 6 optimal, 0 proposal(s), 0 withheld.

Skipped files: none

Comment thread docs/api-tokens.md

### What the consent choices mean

- **Organizations are narrowing, never widening.** Ticking an org lets the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: resolveRestAuth (supabase/functions/_shared/api/auth.ts:51) selects only user_id,permissions, so the same token reaches every org over /memories and outlives expires_at. This narrowing holds on MCP only. (blocking)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half applied: resolveRestAuth now selects expires_at and rejects an expired token, mirroring mcp/auth.ts (ce131ac). The org_ids intersection is not applied and this thread stays open — memories/handlers/{facets,scopes,tags}.ts resolve visibility inside SECURITY DEFINER RPCs that compose lorekit_member_org_ids server-side, so a client-side intersection cannot narrow them and a partial fix would leave two surfaces wide.

Comment thread docs/api-tokens.md Outdated
Comment thread supabase/migrations/00055_oauth.sql Outdated
Comment thread supabase/migrations/00055_oauth.sql Outdated
Comment thread supabase/functions/mcp/tools.ts Outdated
@mthines

mthines commented Aug 3, 2026

Copy link
Copy Markdown
Owner

@dash0 implement

@dash0-dev

dash0-dev Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

⚡ Running PR Loop · Implement Only
View PR Loop · Implement Only in Agent0

@mthines

mthines commented Aug 3, 2026

Copy link
Copy Markdown
Owner

/preview

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

❌ Preview deploy failed

Branch: feat/mcp-oauth-authorization @ b8ff173

🔗 Open preview → https://lorekit-d5fhmroqv-mads-thines-projects.vercel.app

Step Result
API (Supabase edge functions + migrations) ❌ failure
Web (Vercel preview) ✅ success
Smoke tests ⏭️ skipped

Endpoints:

View workflow run

mthines added 2 commits August 3, 2026 15:04
…w deploy

The discovery documents took their issuer from NEXT_PUBLIC_APP_URL, which
Vercel pulls into preview builds still holding the PRODUCTION value. A
preview deployment therefore advertised production's /authorize and /token
endpoints, so authorizing against a preview would have minted a token for
the wrong deployment. The /preview workflow is worse still: it deploys
prebuilt via the Vercel CLI, where neither VERCEL_BRANCH_URL nor VERCEL_URL
is populated at build time, so nothing build-time knows the origin at all.

resolveIssuer() is a ladder: the stable branch alias on a preview, the
configured canonical origin elsewhere, and the request origin as a last
resort -- which is also the semantically right answer for a document that
describes the server you just fetched it from. It never honours
NEXT_PUBLIC_APP_URL on a preview.

Caching is coupled to the decision. A request-derived origin comes from a
caller-controlled header, so a shared CDN cache would be a poisoning
primitive; those responses are no-store, configured ones stay cacheable.
The /preview run failed at `supabase db push` with 'Remote migration
versions not found in local migrations directory' naming 00054. That is not
a workflow fault: main shipped 00053_read_activity and 00054_usage_event_client
while this branch was open, both are already applied to the shared preview
project, and this branch's highest was 00053 -- so the remote had a version
the branch did not know about, and its own 00053 collided besides.

Merges main and renumbers to 00055, updating every reference (code comments,
the drift guards, docs/architecture.md, CLAUDE.md).
Agent0 added 5 commits August 3, 2026 15:10
`resolveRestAuth` selected only `user_id,permissions`, so an OAuth-issued
token kept working over /memories and /orgs after its `expires_at` had
passed — `mcp/auth.ts` rejects the same token at that instant. Mirror the
check here. Personal dashboard tokens carry a NULL `expires_at` and are
unaffected.

The org-allow-list half of the same finding is not addressed here and the
thread stays open: three REST read paths resolve visibility inside
SECURITY DEFINER RPCs, so `api_tokens.org_ids` cannot be intersected
client-side.

Addresses review comment by @dash0-dev[bot]
#318 (comment)
The OAuth section added directly below issues tokens that expire in 30 days
and that `MAX_TOKENS_PER_USER` never counts, so "Maximum 20 tokens" and
"No expiry" both read as false for half the `lk_*` population. Say which
population each bullet describes, and name the asymmetry: oauth rows do not
get counted at issue time but do block dashboard minting.

Addresses review comment by @dash0-dev[bot]
#318 (comment)
`packages/web/src/lib/tokens.ts` has no `resolveAuth` — it exports only
`generateToken`, `listTokens` and `revokeToken`, and
`packages/mcp-server/src/auth.ts` does no `api_tokens` lookup at all. There
are two SHA-256 verification sites, not three.

Addresses review comment by @dash0-dev[bot]
#318 (comment)
CLAUDE.md and the PR body already record that the cap is the one thing that
does not survive the OAuth path; this header comment was the last place still
saying it keeps working unchanged.

Addresses review comment by @dash0-dev[bot]
#318 (comment)
`./org-permissions.ts` was imported twice — once for
`translateOrgPermissionError` and again six lines later for
`OrgPermissionError`. One specifier list.

Addresses review comment by @dash0-dev[bot]
#318 (comment)
@mthines

mthines commented Aug 4, 2026

Copy link
Copy Markdown
Owner

/preview

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

❌ Preview deploy failed

Branch: feat/mcp-oauth-authorization @ a241a09

🔗 Open preview → https://lorekit-jp64dlhah-mads-thines-projects.vercel.app

Step Result
API (Supabase edge functions + migrations) ✅ success
Web (Vercel preview) ✅ success
Smoke tests ❌ failure

Endpoints:

View workflow run

@mthines
mthines marked this pull request as draft August 18, 2026 18:25
@mthines

mthines commented Aug 30, 2026

Copy link
Copy Markdown
Owner

@dash0 > this PR is fairly old. Please rebase it and update any conflicts and update so it's ready to be merged

@dash0-dev

dash0-dev Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

⚡ Running Agent0 | Execute
View Agent0 | Execute in Agent0

@dash0-dev

dash0-dev Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Looked into rebasing/updating this branch against main. This PR is 23 commits ahead but 1,281 commits behind main, and the conflicts are not mechanical — they need a human security-design decision rather than a best-effort auto-resolution:

  • main has since landed feat(auth): enforce api token scoping across MCP and REST (05f112d), a substantial security-critical refactor of tenant scoping and API token enforcement that touches the exact same files this PR's OAuth 2.1 flow touches: supabase/functions/mcp/auth.ts, mcp-handler.ts, tools.ts, _shared/api/auth.ts, and the tenant-scope.ts family.
  • This PR's own supabase/functions/mcp/tenant-scope.ts (added here) was independently superseded on main by an expanded supabase/functions/_shared/tenant-scope.ts plus new enforcement wiring in mcp-handler.ts/tools.ts.
  • A trial merge produces conflicts across 7 files totaling roughly 2,100 inserted / 550 deleted lines in auth-critical code (largest single hunks: ~1,164 lines in tools.ts, ~412 in mcp-handler.ts, ~220 in auth.ts), plus modify/delete conflicts on tenant-scope.ts/tenant-scope.spec.ts/tenant-scope-usage.spec.ts where this branch's version and main's restructured version diverge in both location and logic.

Reconciling OAuth 2.1 bearer-token authorization with the newly-enforced API-token scoping model requires deciding how the two authorization mechanisms compose (e.g. does an OAuth-issued token need to pass through the same scope-narrowing/refusal logic as API tokens, or is it a separate code path?) — that's a design call, not something I can resolve confidently by auto-merging without risking an auth bypass or scope-check regression.

Could you clarify: should this be resolved by layering OAuth token validation on top of main's current API-token scoping/tenant-scope enforcement (keeping main's versions of tenant-scope.ts/auth.ts/mcp-handler.ts/tools.ts as the base and re-adding only the OAuth-specific additions), or is a different reconciliation intended? Once that's confirmed I can carry out the rebase.

…odel

main landed feat(auth): enforce api token scoping across MCP and REST
(05f112d) after this branch forked, restructuring tenant-scope
enforcement into a generic KeyRestriction (scopes/org_access/org_ids)
that superseded this PR's own narrower org_ids-only scoping.

Reconciliation, following main's security model rather than layering a
second mechanism on top:

- Deleted this branch's packages/mcp-core/src/tenant-scope.ts (+specs)
  and supabase/functions/mcp/tenant-scope.ts: both superseded by main's
  packages/mcp-core/src/auth/tenant-scope.ts /
  supabase/functions/_shared/auth/tenant-scope.ts, which already cover
  everything intersectTokenOrgIds/tokenAllowsOrgId did, generically.
- Accepted main's supabase/functions/mcp/tools.ts and mcp-handler.ts
  unchanged: every memory.* tool already threads keyScoping through
  applyTenantScope and the memory_write/memory_delete RPCs
  (p_key_org_access/p_key_org_ids), so OAuth-issued tokens are narrowed
  with zero OAuth-specific code once they populate the same columns a
  scoped dashboard token does.
- Kept this PR's own additions (mcp/auth.ts and _shared/api/auth.ts
  expiry checks; mcp/index.ts's 401 + WWW-Authenticate discovery
  challenge; oauth-metadata.ts; the web app's OAuth consent/token
  routes), rebased onto main's keyScoping-based AuthContext.
- Renumbered the OAuth migration 00055 -> 00095 (main independently
  used 00055 for an unrelated migration) and dropped its own
  api_tokens.org_ids column add: that column already exists from
  00068_api_token_scoping.sql with different (tri-state) semantics.
  issueAccessToken now maps the consent screen's ticked orgs onto
  org_access='selected'/org_ids=<ticked>, or 'personal' when none are
  ticked -- the consent screen never offers 'all', so an OAuth token is
  always the least-privileged of the three states.
- Hand-updated the generated database.types.ts (both copies) to add
  api_tokens.kind/expires_at/client_id and the two new oauth_* tables,
  since supabase gen types needs a live project this sandbox has no
  access to.
- Resolved CLAUDE.md/docs/api-tokens.md doc conflicts by keeping main's
  restructured content and re-adding the OAuth sections in its style.

Verified: deno check (all edge functions, 0 regressions), nx affected
typecheck (mcp-core/web/feature-flags/schemas/smoke-tests), nx affected
test (mcp-core 1492/1493 + web 1346/1346 passing; the one mcp-core
failure is a 5s test-timeout flake in a sandbox-slow codegen spec,
reproduced green in isolation), nx affected lint (0 errors, pre-existing
warning baseline only).
@dash0-dev

dash0-dev Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased and resolved conflicts, following main's newer security model rather than layering a second scoping mechanism on top, per your direction.

What changed

Main had landed feat(auth): enforce api token scoping across MCP and REST (05f112d) after this branch forked — a security-critical refactor that generalized per-key tenant scoping into a KeyRestriction (scopes/org_access/org_ids) enforced in exactly one place (applyTenantScope + the memory_write/memory_delete RPCs). This PR's own narrower api_tokens.org_ids-only scoping (intersectTokenOrgIds/tokenAllowsOrgId) was superseded by it. Reconciliation:

  • Dropped this branch's own tenant-scope duplicationpackages/mcp-core/src/tenant-scope.ts (+specs) and supabase/functions/mcp/tenant-scope.ts are deleted; main's packages/mcp-core/src/auth/tenant-scope.ts / supabase/functions/_shared/auth/tenant-scope.ts already cover the same narrowing generically.
  • Accepted main's mcp/tools.ts and mcp/mcp-handler.ts unchanged — every memory.* tool call already threads keyScoping through applyTenantScope and into the RPCs' p_key_org_access/p_key_org_ids parameters, so an OAuth-issued token is narrowed with zero OAuth-specific code once it populates the same columns a scoped dashboard token does.
  • Kept this PR's own OAuth-specific pieces, rebased onto main's keyScoping-based AuthContext: the expires_at check in mcp/auth.ts and _shared/api/auth.ts, the 401 + WWW-Authenticate discovery challenge in mcp/index.ts, oauth-metadata.ts, and the dashboard's consent/token/registration routes.
  • Renumbered the migration 00055 → 00095 (main independently used 00055 for an unrelated migration) and dropped its own api_tokens.org_ids column add — that column already exists from 00068_api_token_scoping.sql with different (tri-state) semantics. issueAccessToken now maps the consent screen's ticked orgs onto org_access='selected' + org_ids=<ticked>, or 'personal' when none are ticked — the consent screen has no "all orgs" affordance, so an OAuth token is always the least-privileged of the three states.
  • Hand-updated the generated database.types.ts (both the edge and mcp-core copies) to add api_tokens.kind/expires_at/client_id and the two new oauth_clients/oauth_authorization_codes tables, since regenerating from a live project isn't possible in this environment.
  • Resolved the CLAUDE.md/docs/api-tokens.md doc conflicts by keeping main's restructured content and re-adding the OAuth sections in its style/format.

Verification

  • deno check on every edge function: 0 regressions against the committed baseline.
  • nx affected -t typecheck (mcp-core, web, feature-flags, schemas, smoke-tests): clean.
  • nx affected -t test: mcp-core 1492/1493 and web 1346/1346 passing — the one mcp-core failure was a 5s test-timeout in a sandbox-slow codegen spec, confirmed green when re-run in isolation.
  • nx affected -t lint: 0 errors, only the pre-existing warning baseline.

GitHub now reports the PR as MERGEABLE (previously CONFLICTING); mergeStateStatus is BLOCKED pending CI/review, not conflicts.

@github-actions

Copy link
Copy Markdown
Contributor

The dashboard preview for this PR — redeployed on each push that changes the web app.

Name Status Preview Deployment (this commit) Updated (UTC)
lorekit ✅ Ready Visit Preview 4a610a5 2026-08-30 18:45

Preview always points at the PR's latest commit; Deployment is this exact commit. · Comment /web-preview to force a redeploy. · Workflow logs

@dash0-dev

dash0-dev Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed your changes — 1 error, 2 warnings need attention before human review. Blocking: 1 unanswered blocking review thread.
Updated 2026-08-30 18:53 UTC

Fix all with Agent0

Review details — 3 open review threads (1 blocking)

Reviewed for commit 4a610a5.

Gate Status Details
Description vs. code ⚠️ Follow-ups says REST lacks expires_at, but this diff adds it; body also cites migration 00055 (now 00095).
Prior review feedback 3 unresolved review thread(s) — see the thread list below
Documentation docs/api-tokens.md, architecture.md, remote.mdx and the PR body thoroughly cover the new flow.
Self-review signals No debug logs, leftover TODOs, secrets, or unreviewed stubs on added lines.
Code review No new blocking issues this pass; existing concerns tracked in the open threads above.

Open review threads (3)

CIIntegration smoke (local Supabase) is pending; typecheck, lint, migration-order, Storybook and Vercel-preview checks are all green. Non-blocking on this draft PR.

Verified — Findings grounded via direct file reads (Tier 1); no local execution/typecheck run this pass — no new behavioral issue: finding required a receipt.

Run mode — full · 3711 lines in delta · PR-state record unavailable on this dispatch (no MCP tool access) — sticky footer scan found no prior report; treated as first full review.

Memories — 304 indexed · 2 used

  • reviewer-comment-relevance::issue-migration-numbering-collision-with-main — this repo renumbers migrations as main advances — the 00055→00095 drift is expected churn, not treated as a fresh defect on its own.
  • reviewer-comment-relevance::mcp-pr-description-rule3-stale-after-fallback-fix — precedent for flagging PR-body prose left stale after the code it describes changed.

Quality — produced 0 → posted inline 0 · cleared 0 · carried forward 0 · deferred 0 · below-bar 0

Integrations — OAuth 2.1 (RFC 6749/7636/7591/7009/8252/8414/9728) — checked against cited spec sections; no version-drift found.

Optimality (2.4c) — skipped (skill not installed)

Standards (2.4d) — skipped (skill not installed)

Skipped files — none

Reviewed by the pr-reviewer agent — open it to read how these gates and findings are produced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant