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.

@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Frigg 2.0.1 review (Stack A) — COMMENT only

Verdict (would): changes needed

Reviewed against the adopter-JWT checklist (signature, issuer, audience, expiry, algorithm allow-list; no alg: none; no HS/RS confusion; user/org mapping). Crypto core is strong: algorithm allow-list, mutually exclusive JWKS vs secret, HS* forbidden with JWKS, exp required, audience required for JWKS, 503 on JWKS outage. Unit suite for adopter JWT passed in this environment.

Blocking

None that are clear auth bypasses on their own — gaps below are checklist / production foot-guns.

Should-fix

  1. adopter-jwt-config.js (~142, 160–170) — JWKS mode does not require issuer; startup only warns. jwtVerify gets issuer: undefined → jose skips iss.
    Failure: Shared JWKS / multi-audience tokens for another API with the same aud verify and map to Frigg users. Checklist asks to verify issuer; mirror the audience rule and fail startup when jwksUri is set without issuer.
  2. HMAC mode — audience / issuer optional when only secret is set. Shared HMAC secrets across services allow tokens minted for service A to authenticate here. Prefer requiring audience whenever adopterJwt is on.
  3. get-user-from-x-frigg-headers.js (~57–58) — Reads organizationUser but records expose organizationId (JWT path is correct). Under strictUserValidation, correctly linked users always look mismatched → 400; under auto-link, the short-circuit never runs. Align field name with the JWT path (note: later memberships PR may rewrite this file — still broken on this branch).

Nits

  • No minimum HMAC secret length in config/schema.
  • Find-or-create race on appUserId is documented; ensure uniqueness at the DB layer.

No formal approve / request-changes on this review.

function assertAdopterJwtConfig(userConfig) {
if (userConfig?.authModes?.adopterJwt !== true) return;
const config = resolveAdopterJwtConfig(userConfig.jwtConfig);
if (config.jwksUri && !config.issuer) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should-fix — require issuer when jwksUri is set

Checklist: verify signature, issuer, audience, expiry, algorithm allow-list. Audience is already required for JWKS; issuer is only warned here, and adopter-token-verifier passes issuer: undefined through to jose (claim not checked).

Scenario: App enables adopterJwt with jwksUri + audience: "frigg-api" and no issuer. IdP also mints tokens for another API that share this JWKS and include the same aud → those tokens authenticate as Frigg users.

Please throw from resolveAdopterJwtConfig / assertAdopterJwtConfig when jwksUri is set and issuer is missing (same posture as audience).

}
let { individual: individualUserData, organization: organizationUserData } =
await findAppUsers(this.userRepository, this.userConfig, {
appUserId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should-fix — organizationUser vs organizationId (unchanged lines just below)

This refactor correctly centralizes find/create, but the mismatch check a few lines down still reads individualUserData.organizationUser?.toString(). Persisted individuals expose organizationId (see JWT hasOrganization / assertSingleOrganization on this branch).

Scenario: strictUserValidation: true + correctly linked (appUserId, appOrgId) → always looks mismatched → 400. Default auto-link never short-circuits and re-enters linkIndividualToOrganization every time.

Please read organizationId here (same as the adopter-JWT path). Later memberships work may rewrite this file; on this branch the field name is still wrong.

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