Skip to content

feat(core): API-key login auth mode (module-validated) - #643

Open
seanspeaks wants to merge 2 commits into
claude/aurora-serverless-scale-to-zero-nat-freefrom
claude/api-key-login-auth-mode
Open

feat(core): API-key login auth mode (module-validated)#643
seanspeaks wants to merge 2 commits into
claude/aurora-serverless-scale-to-zero-nat-freefrom
claude/api-key-login-auth-mode

Conversation

@seanspeaks

@seanspeaks seanspeaks commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements ADR-034: API-key login auth mode (module-validated). Frigg apps that ship a browser SPA can now let an end user log in with their own product API key and land in an authenticated, tenant-scoped session — validated through the api-module itself — removing the mandatory BFF/token-broker that "log in with your product key" apps otherwise need.

Default-off and additive: apps that don't declare user.authModes.apiKey are completely unchanged, and the mode composes with the existing friggToken/sharedSecret modes.

The auth mode & flow

POST /user/login becomes polymorphic, dispatching on the credential shape in the body against the app's enabled authModes (the bodies are disjoint, so dispatch is unambiguous):

  • { username, password }friggToken — existing behavior, unchanged.
  • { apiKey } (optionally { module, apiKey } for multi-identity apps) → the new apiKey mode.

The LoginWithApiKey use case:

  1. Validate + identify via the module's Requester. Instantiates the configured identity module, seeds it with the key, and calls testAuthRequest (validity) then getEntityDetails (identity). The api-module — not a bespoke validator — is the source of truth.
  2. Derive a provider-authoritative identity. appOrgId/appUserId come only from the provider's getEntityDetails response, never from client input. A login whose module returns no stable identifier is rejected. The key is never hashed into an identity.
  3. Find-or-create the Frigg user by reusing the existing GetUserFromXFriggHeaders path. The principal is an ordinary app user, never admin.
  4. Create the Credential + Entity by reusing ProcessAuthorizationCallback(userId, module, { api_key }) — the same path /api/authorize uses.
  5. Mint a short-lived Frigg session token and return it, plus set a hardened session cookie.

Security invariants (ADR-034 normative)

  • Provider-authoritative identity — a client-supplied org/user id is ignored (impersonation guard, mutation-tested).
  • Session ≠ admin — an ordinary, tenant-scoped, short-lived app-user token (resolves back through the normal bearer path; no elevation).
  • Rate limiting — per-IP + global in-process fixed-window limiter, key-length cap before any provider work, generic errors (no enumeration).
  • Key at rest — persisted only as the encrypted Credential; never logged, never returned after login, never in a JWT claim.
  • Outage ≠ invalid — provider 5xx/timeout → 503 with no session created and no cookie cleared; only a definitive 401/403 → generic invalid-credentials (401). The 401-vs-503 split is mutation-tested.
  • Cookie hygienehttpOnly, secure (non-local stages), SameSite=Strict, plus an optional Origin/Referer allowlist (CSRF) on the cookie-bearing route.
  • Config validationauthModes.apiKey.module/modules must name registered modules; validated fail-fast at wiring time (no-op when unset).

Reuse (no reinvention)

  • ProcessAuthorizationCallback — credential/entity creation, called with { api_key }.
  • GetUserFromXFriggHeaders — find-or-create user from the provider-derived identity.
  • CreateTokenForUserId — session-token minting.

Files changed

  • docs/architecture-decisions/034-api-key-login-auth-mode.md — the ADR.
  • packages/core/user/use-cases/login-with-api-key.js — new LoginWithApiKey use case (+ classifyProviderError).
  • packages/core/user/use-cases/validate-api-key-auth-mode.js — app-definition config validation.
  • packages/core/handlers/rate-limiter.js — dependency-free per-IP + global fixed-window limiter.
  • packages/core/handlers/routers/user-router.js — side-effect-free buildUserRouter factory (polymorphic /user/login, cookie/CSRF/rate-limit).
  • packages/core/handlers/routers/user.js — thinned to production wiring that calls the factory.
  • packages/core/index.js — exports LoginWithApiKey, validateApiKeyAuthMode.

Tests (38 new, all green when run directly)

  • login-with-api-key.test.js (19) — valid key → user+credential+entity created and token returned (spy + real ProcessAuthorizationCallback over in-memory doubles); impersonation guard (hostile client org id ignored; mutation check); invalid key → generic 401, no session; provider outage → 503, no session; no stable identifier → rejected; length cap; module allowlist; session is an ordinary app-user token (resolves back to the same tenant, no admin).
  • validate-api-key-auth-mode.test.js (6) — default-off no-op; valid single/allowlist; missing/unregistered module throws.
  • rate-limiter.test.js (4) — per-key trip, key independence, global trip, window reset.
  • user-router.test.js (9, supertest) — { apiKey } dispatch + hardened cookie; allowlisted { module }; disabled-mode generic 401; 503 passthrough with no cookie; rate-limit → 429; CSRF origin allow/deny; password path unchanged with both modes enabled; missing-field 400.

Config + route contract:

user: {
  authModes: { apiKey: { module: 'reevo' } }, // or { modules: ['reevo','acme'], allowedOrigins: [...], rateLimit: {...} }
  organizationUserRequired: true,
}

POST /user/login body { apiKey }201 { token } + Set-Cookie: frigg_session=…; HttpOnly; SameSite=Strict[; Secure].

Notes for review

  • Test run: the repo's full "Frigg CI" is independently red on next (a prisma:generate step and a MongoMemoryServer global-setup that needs libcrypto.so.1.1, unrelated to this feature). The 38 new tests are pure unit/route tests with mocked module Requester and repositories — run directly they pass with no DB/provider. Two pre-existing failures in get-user-from-adopter-jwt.test.js and get-user-from-x-frigg-headers.test.js are unrelated (stub-message/validation drift; those files are untouched).
  • Rate limiting is in-process (per-container floor); pair with an infra-level limit (API Gateway/WAF) for a hard global ceiling — noted in the code.
  • Refresh route: not added in this PR. Revocation latency is bounded by the short access-token TTL (ADR-034 §5); the validateAndIdentify step is factored so a future refresh re-validates the stored key via testAuthRequest.

🤖 Generated with Claude Code


Generated by Claude Code

📦 Published PR as canary version: 2.0.0--canary.643.45b1fbc.0

✨ Test out this PR locally via:

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

@seanspeaks seanspeaks added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Aug 25, 2026 — with Claude
@netlify

netlify Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploy Preview for friggframework-org canceled.

Name Link
🔨 Latest commit 45b1fbc
🔍 Latest deploy log https://app.netlify.com/projects/friggframework-org/deploys/6a8d00b8cdef950008d57e28

@gitguardian

gitguardian Bot commented Aug 25, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36570312 Triggered Username Password 45b1fbc packages/core/logs/logger.test.js View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

claude added 2 commits August 25, 2026 02:40
Add a first-class, opt-in `apiKey` auth mode (ADR-034): let a browser end
user log in with their own product API key, validated through the api-module
itself, and land in an authenticated, tenant-scoped Frigg session — removing
the mandatory BFF/token-broker for "log in with your product key" apps.

The existing `POST /user/login` becomes polymorphic and dispatches on the
credential shape against the app's enabled authModes: `{ username, password }`
keeps the unchanged friggToken path; `{ apiKey }` runs the new mode. Both may
be enabled at once (disjoint bodies).

LoginWithApiKey use case: validate + identify via the module's Requester
(testAuthRequest, then getEntityDetails), derive a provider-authoritative
identity (never from client input), find-or-create the user via the existing
x-frigg-headers path, create the Credential + Entity by reusing
ProcessAuthorizationCallback({ api_key }), then mint an ordinary short-lived
app-user session token.

Security invariants (ADR-034 normative): provider-authoritative identity only;
ordinary app-user session (never admin); per-IP + global rate limiting with a
key-length cap and generic errors; key persisted only as the encrypted
Credential (never logged/returned/in a JWT); 401 (bad key) vs 503 (provider
outage) split, with no session/cookie side effects on outage; httpOnly +
secure (non-local) + SameSite cookie with an Origin/Referer allowlist.

Default-off and additive: apps that do not declare authModes.apiKey are
unchanged. Config is validated against registered modules at wiring time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDh45c1vm91ySYtvVv9Z65
Address confirmed security findings on the api-key login auth mode:

- logging: redact credential-bearing request-body/header fields
  (apiKey, api_key, password, token, authorization, refresh_token,
  access_token) in initDebugLog before the Lambda event is buffered, so a
  raw key/password cannot leak via the 5xx debug dump or DEBUG_VERBOSE=1
  (ADR-034 req #4). JSON bodies parsed+masked; non-JSON/oversized handled
  gracefully; logger never throws.
- rate-limit: derive the per-IP bucket from a trusted X-Forwarded-For
  position (rightmost by default, or authModes.apiKey.rateLimit.
  trustedProxyDepth hops from the right) instead of the client-controlled
  leftmost hop, so a spoofed XFF can no longer mint a fresh bucket.
- identity: namespace the find-or-create identity as `${moduleName}:${externalId}`
  so two modules in a multi-module allowlist returning the same externalId
  map to distinct Frigg tenants.
- validation: reject a bare-string allowedOrigins, non-positive/NaN
  rateLimit knobs, and align the resolver's allowlist (union of modules +
  module) with the wiring-time validator.
- login gate: require a strict boolean-true testAuthRequest pass, reject a
  non-scalar externalId instead of coercing it, and assert a credential was
  persisted before minting a session.
- cookie: align frigg_session Max-Age/Expires to the session token TTL.
- csrf: keep the Origin allowlist opt-in but warn once at wiring time when
  apiKey mode is enabled without allowedOrigins.

Adds unit + handler-level tests for each finding (mutation-verified) and
updates ADR-034 accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDh45c1vm91ySYtvVv9Z65
@seanspeaks
seanspeaks force-pushed the claude/api-key-login-auth-mode branch from 35eaec0 to 45b1fbc Compare August 25, 2026 02:40
@seanspeaks
seanspeaks changed the base branch from next to claude/aurora-serverless-scale-to-zero-nat-free August 25, 2026 02:40

Copy link
Copy Markdown
Contributor Author

Reviewer context: stacked PR + post-review hardening

This PR is stacked on #640 (base branch is claude/aurora-serverless-scale-to-zero-nat-free, not next), so the diff here is only the core/login delta (15 files). #640 was rebased current onto next first; when it merges, I'll retarget this base back to next. The two are independent code-wise (core vs devtools) — the stack exists so a single pre-release canary carries both for the downstream Reevo app.

A four-lens adversarial security pass (identity/impersonation, auth-bypass/token-scope, provider-validation fail-open, secret-handling/no-regression) ran against the original implementation. Net verdict: no auth bypass, no token-scope escalation, the password (friggToken) path is byte-identical, and the impersonation guard + 401-vs-503 split are real and mutation-tested. The confirmed findings are now fixed in the hardening commit:

  • Log redaction (was HIGH)initDebugLog buffered the whole Lambda event, so a raw key/password could leak via the 5xx debug dump / DEBUG_VERBOSE=1. Sensitive body/header keys are now masked before buffering (ADR-034 §4). Handler-level test asserts the key never reaches any console sink on the 503 path.
  • Rate-limit key — per-IP bucket now derives from a trusted X-Forwarded-For position (rightmost / trustedProxyDepth), not the client-controlled leftmost hop.
  • Tenant identity — find-or-create identity is namespaced ${moduleName}:${externalId}, so a multi-module allowlist can't collide two providers' id spaces.
  • Config validation — rejects bare-string allowedOrigins, non-positive/NaN rateLimit knobs; validator/resolver allowlist aligned.
  • Login gate — requires strict testAuthRequest === true; rejects a non-scalar externalId instead of coercing; asserts a credential was persisted before minting a session.
  • Cookiefrigg_session Max-Age aligned to token TTL; CSRF allowlist stays opt-in with a wiring-time warning when unset.

Each fix has a mutation-verified test. ADR-034 updated for the testAuthRequest contract, module-namespaced identity, log redaction, and the trusted-XFF key.

One behavior note for consumers: the module-namespaced identity means an already-live single-module instance would re-key existing users on first login after this change (invisible for a fresh deploy).

Frigg CI is independently red on next (a prisma:generate step + a MongoMemoryServer global-setup needing libcrypto.so.1.1) — unrelated to this diff; the new tests are pure unit/route tests that pass run directly.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@seanspeaks

Copy link
Copy Markdown
Contributor Author

ADR numbering — FYI (see #644)

Your number does not change034-api-key-login-auth-mode.md stays 034. It was uncontested, and #644 keeps uncontested claims as they are.

The only ask: #644 moves every unmerged ADR onto next in one commit, so this PR's copy would be an add/add conflict against it. Once #644 lands, drop the doc file here so this PR is code only.

Separately, note this PR is based on claude/aurora-serverless-scale-to-zero-nat-free (#640) rather than next, so it inherits that branch's changes too.

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