Skip to content

Add OAuth 2.1 resource server for external provider auth#95

Merged
cmeans merged 10 commits into
mainfrom
feature/oauth-resource-server
Mar 30, 2026
Merged

Add OAuth 2.1 resource server for external provider auth#95
cmeans merged 10 commits into
mainfrom
feature/oauth-resource-server

Conversation

@cmeans
Copy link
Copy Markdown
Owner

@cmeans cmeans commented Mar 30, 2026

Summary

  • OAuth 2.1 resource server: provider-agnostic JWKS-based token validation for external OAuth providers (WorkOS AuthKit, Auth0, Cloudflare Access, Keycloak, etc.)
  • Dual auth: self-signed JWTs (via CLI) and OAuth provider tokens both accepted — OAuth for interactive clients (Claude Desktop, Claude Code), self-signed for edge providers/scripts
  • User auto-provisioning: auto-create user record on first valid OAuth login (AWARENESS_OAUTH_AUTO_PROVISION, default: false — tighter control during early access)
  • Well-known metadata: /.well-known/oauth-protected-resource (RFC 9728) for MCP client OAuth discovery
  • WWW-Authenticate: 401 responses include resource_metadata URL per spec
  • OAuth identity columns: oauth_subject and oauth_issuer on users table with unique index, Alembic migration included

New env vars

Variable Default Description
AWARENESS_OAUTH_ISSUER (required for OAuth) OIDC issuer URL
AWARENESS_OAUTH_AUDIENCE (optional) Expected aud claim
AWARENESS_OAUTH_JWKS_URI {issuer}/.well-known/jwks.json Override JWKS endpoint
AWARENESS_OAUTH_USER_CLAIM sub JWT claim for owner_id
AWARENESS_OAUTH_AUTO_PROVISION false Auto-create users on first login (set to true for open self-service signups)

QA

Prerequisites

  • pip install -e ".[dev]"
  • Deploy to test instance on alternate port (AWARENESS_PORT=8421)
  • WorkOS AuthKit staging domain: thoughtful-saga-02-staging.authkit.app

Manual tests (via MCP tools)

    • Self-signed JWT still works
    AWARENESS_AUTH_REQUIRED=true AWARENESS_JWT_SECRET=<secret> AWARENESS_OAUTH_ISSUER=https://thoughtful-saga-02-staging.authkit.app
    mcp-awareness-token --user test-user --expires 1h
    # Use token as Bearer header — verify MCP tools respond normally
    

    Expected: existing JWT auth continues to work alongside OAuth

    • Well-known metadata served
    curl https://<host>/.well-known/oauth-protected-resource
    

    Expected: JSON with authorization_servers, token_methods: ["Bearer"], resource URL

    • 401 includes WWW-Authenticate header
    curl -H "Authorization: Bearer bad-token" https://<host>/mcp
    

    Expected: 401 with WWW-Authenticate: Bearer resource_metadata="..." header

    • OAuth flow [QA: requires live provider] with Claude Desktop
      Configure connector with WorkOS OAuth Client ID/Secret, connect to awareness MCP URL. Verify login flow completes and MCP tools work.
      Expected: successful authentication, tools return user-scoped data
    • Auto-provisioning [QA: requires live provider; unit tests cover path] creates user (requires AWARENESS_OAUTH_AUTO_PROVISION=true)
      After first OAuth login, verify user exists:
    mcp-awareness-user list
    

    Expected: OAuth user appears in user list

🤖 Generated with Claude Code

Provider-agnostic JWKS-based token validation — works with any OIDC-
compliant provider (WorkOS, Auth0, Cloudflare Access, Keycloak, etc.).
Enables Claude Desktop and Claude Code to authenticate via OAuth.

- Dual auth: self-signed JWTs (CLI) + OAuth provider tokens both accepted
- Auto-provision users on first valid OAuth login (configurable)
- Well-known metadata (RFC 9728) for MCP client discovery
- WWW-Authenticate headers on 401 responses
- 17 new tests (474 total), lint/mypy clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cmeans cmeans added the Dev Active Developer is actively working on this PR; QA should not start label Mar 30, 2026
cmeans and others added 4 commits March 29, 2026 20:44
Remove loop_scope="function" parameter from @pytest.mark.asyncio
markers — not supported on older pytest-asyncio versions in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Alembic migration: oauth_subject, oauth_issuer columns on users table
- Unique index on (oauth_issuer, oauth_subject) for provider-agnostic lookup
- Auto-provisioning stores OAuth identity on first login
- create_tables.sql includes ALTER TABLE fallback for existing schemas
- get_user_by_oauth.sql for fast OAuth identity lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI has anyio (via mcp[cli]) but not pytest-asyncio as a direct
dependency. Existing middleware tests use @pytest.mark.anyio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per design decision: tighter control during early access / tester phase.
Set to true when ready for open self-service signups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 30, 2026

Codecov Report

❌ Patch coverage is 98.21429% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/mcp_awareness/server.py 90.32% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

cmeans and others added 3 commits March 29, 2026 21:00
…rver wiring

8 new tests covering: OAuth identity columns in store, get_user_by_oauth
lookup, explicit jwks_uri, JWKS cache refresh, auto-provision in middleware
flow, _build_oauth_validator server wiring. oauth.py now at 100% coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…wiring

Covers _ensure_user with real store and _wrap_with_auth with OAuth issuer.
484 tests total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test verifies auto-provisioning failure is swallowed gracefully and
doesn't block the authenticated request. 485 tests total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cmeans cmeans removed the Dev Active Developer is actively working on this PR; QA should not start label Mar 30, 2026
@github-actions github-actions Bot added the Ready for QA Dev work complete — QA can begin review label Mar 30, 2026
@cmeans
Copy link
Copy Markdown
Owner Author

cmeans commented Mar 30, 2026

Codecov note: 3 lines remain uncovered in server.py (lines 400, 419-421) — these are WellKnownMiddleware instantiation inside _run(), only exercised when the server starts with uvicorn + AWARENESS_OAUTH_ISSUER set. Cannot be unit tested without booting the full server. Accepted by project owner.

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label Mar 30, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label Mar 30, 2026
Copy link
Copy Markdown
Owner Author

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA Review — PR #95: Add OAuth 2.1 resource server

Code Review

OAuthTokenValidator (oauth.py)

  • Provider-agnostic JWKS validation via PyJWKClient — supports RS256, ES256
  • JWKS cache with configurable TTL (default 3600s), client re-created on refresh
  • Audience verification: required if set, skipped if not (verify_aud: False)
  • Extracts owner_id from configurable claim (default sub), plus email, name, oauth_subject, oauth_issuer for auto-provisioning

AuthMiddleware dual auth (middleware.py)

  • Self-signed tried first → fallback to OAuth if configured
  • _try_self_signed() returns (owner_id, error) tuple — clean error propagation
  • _try_oauth() calls validator, auto-provisions user if enabled
  • _ensure_user() catches all exceptions — auth doesn't fail if provisioning fails
  • _unauthorized() includes WWW-Authenticate header with resource_metadata URL per RFC 9728
  • /.well-known/ paths bypass auth (correct)

WellKnownMiddleware (middleware.py)

  • Serves /.well-known/oauth-protected-resource with authorization_servers, token_methods, resource URL
  • RFC 9728 compliant

Migration (i4d5e6f7g8h9)

  • Adds oauth_subject TEXT, oauth_issuer TEXT to users table
  • Unique index on (oauth_issuer, oauth_subject) where not null
  • Index on oauth_subject for fast lookup
  • Revision chain correct: depends on h3c4d5e6f7g8

PostgresStore (postgres_store.py)

  • create_user_if_not_exists(): INSERT ... ON CONFLICT (id) DO NOTHING — safe upsert
  • get_user(), get_user_by_oauth(): clean lookups with SQL files

Server wiring (server.py)

  • _build_oauth_validator(): creates validator if OAUTH_ISSUER set
  • _wrap_with_auth(): extracted from inline code — cleaner, handles both auth modes
  • Auth required now accepts either JWT_SECRET or OAUTH_ISSUER (or both) — was previously JWT-only
  • WellKnownMiddleware wraps inside auth (correct — metadata must be public)

Dependencies

  • PyJWT[crypto]>=2.8[crypto] extra adds cryptography for RS256/ES256 support

Observations

  1. PR body says AWARENESS_OAUTH_AUTO_PROVISION default: true but code defaults to "false". CHANGELOG correctly says default: false. PR body table is stale.
  2. Tests #4-5 (OAuth flow with Claude Desktop, auto-provisioning): require live OAuth provider — not testable in QA. The auto-provisioning path is well-covered by unit tests (test_oauth.py has 653 lines of tests).
  3. create_tables.sql has both CREATE TABLE with columns AND ALTER TABLE ADD COLUMN IF NOT EXISTS for OAuth columns — belt-and-suspenders for fresh vs migrated installs. Works but slightly unusual.

CI Gate Check

Check Conclusion
lint ✅ SUCCESS
typecheck ✅ SUCCESS
test (3.10) ✅ SUCCESS
test (3.11) ✅ SUCCESS
test (3.12) ✅ SUCCESS
codecov/patch SUCCESS

Zero non-SUCCESS/non-SKIPPED checks.

Test Results

Check Result
pytest (485 tests) ✅ 485/485 pass
ruff (src/, alembic/) ✅ Clean
mypy ✅ Clean
CI ✅ All green
Manual #1: Self-signed JWT alongside OAuth ✅ Accepted, MCP session created
Manual #2: Well-known metadata ✅ RFC 9728 JSON with authorization_servers, token_methods
Manual #3: 401 WWW-Authenticate header Bearer resource_metadata="..."
Manual #4: OAuth flow with Claude Desktop ⏸️ Requires live OAuth provider
Manual #5: Auto-provisioning ⏸️ Requires live OAuth provider (unit tests cover this path)

Verdict

Zero findings (3 observations). All CI green. 3/5 manual tests verified; 2/5 require live OAuth provider (well-covered by 485 unit tests). Ready for QA Signoff.

@cmeans
Copy link
Copy Markdown
Owner Author

cmeans commented Mar 30, 2026

Adding Ready for QA Signoff — OAuth resource server reviewed. Self-signed JWT, well-known metadata, WWW-Authenticate all verified manually. OAuth flow tests require live provider (covered by unit tests). CI fully green, 485/485 pytest.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge and removed QA Active QA is actively reviewing; Dev should not push changes labels Mar 30, 2026
When a user is pre-provisioned via CLI (mcp-awareness-user add) and
later authenticates via OAuth, the server links their OAuth identity
(sub + issuer) to their existing account by matching on email.

Resolution order: OAuth lookup → email link → auto-provision → fallback.
Prevents duplicate accounts when CLI and OAuth auth paths converge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed Ready for QA Signoff QA passed — ready for maintainer final review and merge labels Mar 30, 2026
…tests

Two new tests exercise _resolve_user through the real store:
- Already-linked user found via get_user_by_oauth
- Pre-provisioned user linked by email on first OAuth login
middleware.py now at 100% coverage. 490 tests total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cmeans cmeans added Ready for QA Dev work complete — QA can begin review QA Active QA is actively reviewing; Dev should not push changes and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels Mar 30, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label Mar 30, 2026
Copy link
Copy Markdown
Owner Author

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA Re-Review — PR #95 (Round 2)

New changes (commits `6176b63`, `bba3bf8`)

OAuth identity linking — smart resolution order:

  1. Look up by OAuth identity (issuer + subject) — already linked
  2. Email match → link pre-provisioned user on first OAuth login (`link_oauth_identity.sql`: `WHERE email = %s AND oauth_subject IS NULL`)
  3. Auto-provision new user (if enabled)
  4. Fallback to token `sub` claim as-is
  • `link_oauth_identity.sql`: idempotent — only updates when `oauth_subject IS NULL` (first-time link)
  • `_resolve_user()` returns resolved ID or None (caller falls back to `owner_id`)
  • Exception handling: resolution failures don't break auth

PR body fixed: `AWARENESS_OAUTH_AUTO_PROVISION` default now correctly shows `false`.

Results

  • pytest: 490/490 pass (+5 new identity linking/email match tests)
  • CI: all green, codecov/patch SUCCESS
  • Prior manual test results still valid (no changes to self-signed JWT, well-known, or WWW-Authenticate paths)

Ready for QA Signoff.

@cmeans
Copy link
Copy Markdown
Owner Author

cmeans commented Mar 30, 2026

Adding Ready for QA Signoff — OAuth identity linking verified (3-step resolution: OAuth lookup → email link → auto-provision). PR body default fixed. CI green, 490/490 pytest.

@cmeans cmeans removed the QA Active QA is actively reviewing; Dev should not push changes label Mar 30, 2026
@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Signoff QA passed — ready for maintainer final review and merge labels Mar 30, 2026
@cmeans
Copy link
Copy Markdown
Owner Author

cmeans commented Mar 30, 2026

Adding Ready for QA Signoff — full manual re-test on fresh Docker instance. 3/5 checkboxes verified, 2/5 require live OAuth provider (annotated). CI green, 490/490 pytest.

Copy link
Copy Markdown
Owner Author

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA Re-Review — PR #95 (Round 3 — full manual re-test)

Re-ran all manual tests on fresh QA Docker instance.

Manual Test Results

# Test Result
1 Self-signed JWT still works alongside OAuth ✅ Session created, `get_stats` returned total=17
2 Well-known metadata served ✅ RFC 9728: authorization_servers, token_methods=Bearer, resource URL
3 401 includes WWW-Authenticate header ✅ `Bearer resource_metadata="/.well-known/oauth-protected-resource"`
4 OAuth flow with Claude Desktop ⏸️ Requires live WorkOS AuthKit provider
5 Auto-provisioning creates user ⏸️ Requires live OAuth (schema verified, unit tests cover path)

PR checkboxes updated: 3/5 checked, 2/5 annotated as provider-dependent.

Automated Tests

  • pytest: 490/490 pass
  • CI: all green, codecov/patch SUCCESS

Ready for QA Signoff.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge and removed QA Active QA is actively reviewing; Dev should not push changes labels Mar 30, 2026
Copy link
Copy Markdown
Owner Author

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

LGTM

@cmeans cmeans added QA Approved Manual QA testing completed and passed and removed Ready for QA Signoff QA passed — ready for maintainer final review and merge labels Mar 30, 2026
@cmeans cmeans merged commit 52ed457 into main Mar 30, 2026
67 checks passed
@cmeans cmeans deleted the feature/oauth-resource-server branch March 30, 2026 03:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant