Skip to content

feat(core): adopter-token (JWT) authentication - #678

Open
seanspeaks wants to merge 2 commits into
feat/management-api-v2from
claude/internal-mcp-02-adopter-jwt
Open

seanspeaks wants to merge 2 commits into
feat/management-api-v2from
claude/internal-mcp-02-adopter-jwt

Conversation

@seanspeaks

@seanspeaks seanspeaks commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This implements the adopter-token auth mode from ADR-056 §2, which until now was a stub that returned 501. Frigg can now verify tokens issued by an adopter's own identity provider directly, so a caller needs no shared secret and no relay. The first consumer is Left Hook's internal MCP server: admin.lefthook.com issues ES256 tokens, and frigg-internal verifies them against admin's JWKS. See lefthook.com ADR-0016 D6 and the build plan in lefthook.com PR #758.

This PR is stacked on #671 (feat/management-api-v2). Canaries are published from this branch for development use until it merges down.

What it does

  • user/adopter-token-verifier.js is a new adapter that owns the config, the JWKS and the mapping from failures to reason codes. It is built once per router or dependency set, not once per request.
    • Algorithms come from an allow-list. Defaults are ES256/RS256 with jwksUri, and HS256 with secret. The jku/jwk headers are never followed.
    • exp is required. iss and aud are checked, and clockTolerance is bounded.
    • Only known token failures return 401. Anything else, including connection refused, a DNS failure, a timeout or a bad JWKS body, returns 503 jwks_unavailable, so a key-server outage does not log users out.
  • GetUserFromAdopterJwt takes the user from userIdClaim (default sub) and the active org from orgIdClaim, and exposes the role through User#getOrgRole().
    • A person is never silently re-linked. If an individual already belongs to org A and the token names org B, the request gets a 403 multi_org_unsupported. This check runs before anything is created. It is interim until OrganizationMembership lands in F4 (ADR-056 §3.6).
  • Config is validated at app-definition load (adopter-jwt-config.js):
    • Exactly one of secret or jwksUri may be set.
    • HS* algorithms are only allowed with a secret, and asymmetric ones only with a JWKS.
    • jwksUri must be https; http is allowed only to loopback.
    • audience is required when jwksUri is set, and a warning is logged when issuer is unset.
    • The JSON schema mirrors these rules with oneOf, a dependencies rule and a not rule.
  • Shared code. user/app-user-helpers.js holds the find-or-create and buildUser logic that both this path and GetUserFromXFriggHeaders use. Each keeps its own linking policy.
  • Typings. types/user/index.d.ts adds UserConfig, AdopterJwtConfig and User#getOrgRole/setOrgRole.
  • Dependency. jose@^5.10.0.

Known, not fixed here

  • GetUserFromXFriggHeaders compares individual.organizationUser, but the repositories return organizationId, so its mismatch check is always true. That will be tracked separately.
  • Two concurrent first logins can race on create or link, and the first org link is one-way. Both are documented in code; F4 replaces this path.

Test plan

  • Adopter suites: 97 tests across 5 files. One of them is the remote-JWKS suite, which runs a real local JWKS server and covers caching, an upstream 500, a closed port, an unresolvable host and a hang.
  • Full packages/core run, with the no-op global setup because Mongo binaries can't run in CI-less containers: 15 suites and 63 tests fail. The failing tests match base by name, minus the 2 from the deleted stub suite. 2931 pass.
  • packages/schemas: 154 pass.
  • ESLint on changed files: 0 errors.
  • Two independent reviews (spec/test and clean code). The implementer killed 64 mutations across two rounds. Reviewers ran 30 more against the first round: 28 killed, and the 2 survivors now have tests. Live jose probes were rejected: alg: none, HS256 signed with the public key, forged jku and jwk headers, issuer mismatch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6


Generated by Claude Code

📦 Published PR as canary version: 2.0.0--canary.678.58db4b2.0

✨ Test out this PR locally via:

npm install @friggframework/admin-scripts@2.0.0--canary.678.58db4b2.0
npm install @friggframework/core@2.0.0--canary.678.58db4b2.0
npm install @friggframework/devtools@2.0.0--canary.678.58db4b2.0
npm install @friggframework/eslint-config@2.0.0--canary.678.58db4b2.0
npm install @friggframework/prettier-config@2.0.0--canary.678.58db4b2.0
npm install @friggframework/schemas@2.0.0--canary.678.58db4b2.0
npm install @friggframework/serverless-plugin@2.0.0--canary.678.58db4b2.0
npm install @friggframework/test@2.0.0--canary.678.58db4b2.0
npm install @friggframework/ui@2.0.0--canary.678.58db4b2.0
# or 
yarn add @friggframework/admin-scripts@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/core@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/devtools@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/eslint-config@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/prettier-config@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/schemas@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/serverless-plugin@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/test@2.0.0--canary.678.58db4b2.0
yarn add @friggframework/ui@2.0.0--canary.678.58db4b2.0

Implement GetUserFromAdopterJwt with jose: JWKS (remote, cached once per
process) or HMAC key, iss/aud checks, exp/nbf with tolerance, algorithm
allow-list, and stable 401 reason codes. Find or create the individual and
organization users; link only when the individual has no organization, and
refuse a second organization with 403 multi_org_unsupported (ADR-056 §3.6).

loadAppDefinition now fails at startup when authModes.adopterJwt is on
without a usable jwtConfig. The schema's jwtConfig gains jwksUri, issuer,
audience, algorithms, clockToleranceSec and roleClaim, and requires one of
secret or jwksUri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
… stricter jwtConfig

Classify verification failures by inversion: only known token failures are
401; anything else (refused connection, DNS failure, non-200 JWKS, generic
JOSE error) is 503 jwks_unavailable. Validate jwksUri at load: https, or http
only for loopback. Require audience with jwksUri and warn when issuer is unset.

Extract AdopterTokenVerifier (config resolved once, JWKS held per verifier;
module-global cache removed), a frozen error table with one fail() helper,
assertSingleOrganization, and a find-or-create helper shared with
GetUserFromXFriggHeaders. Add setOrgRole, UserConfig/AdopterJwtConfig types,
schema oneOf and dependencies, and consolidate the tests under
user/tests/adopter-jwt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@seanspeaks seanspeaks added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Oct 15, 2026 — with Claude
@sonarqubecloud

Copy link
Copy Markdown

@seanspeaks

Copy link
Copy Markdown
Contributor Author

ADR reconciliation needed after Stack A/B — drafted with Claude Code at Sean's request

Severity: must-reconcile (text only; the code looks consistent with the intent of ADR-056 §2).

Merge order: #671 → this PR. #644's ADR-034 edit is independent but should land before #643's code.

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

Labels

prerelease This change is available in a prerelease. release Create a release when this pr is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants