chore: Add scoped JWT strategy for public API (no-changelog)#28333
chore: Add scoped JWT strategy for public API (no-changelog)#28333phyllis-noester merged 7 commits intomasterfrom
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Performance ComparisonComparing current → latest master → 14-day baseline Idle baseline with Instance AI module loaded
docker-stats
Memory consumption baseline with starter plan resources
How to read this table
|
afitzek
left a comment
There was a problem hiding this comment.
Code looks good, there are just 2 test problems that we need to fix.
| type: apiKey | ||
| in: header | ||
| name: X-N8N-API-KEY | ||
| BearerAuth: |
There was a problem hiding this comment.
This could be confusing for users that use an api key, because the api key is not accepted in this format.
| }); | ||
|
|
||
| it('returns false when subject user does not exist in DB', async () => { | ||
| const token = makeScopedJwt('non-existent-id'); |
There was a problem hiding this comment.
The user id should be a non existing uuid.
|
|
||
| it('continues without actor and uses subject scopes when actor ID is not in DB', async () => { | ||
| const subject = await createOwner(); | ||
| const token = makeScopedJwt(subject.id, 'non-existent-actor'); |
There was a problem hiding this comment.
The actor id should be a non existing uuid.
There was a problem hiding this comment.
5 issues found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/cli/src/public-api/index.ts">
<violation number="1" location="packages/cli/src/public-api/index.ts:137">
P1: `ApiKeyAuth` should not delegate to the full auth registry; it now lets scoped Bearer tokens satisfy operations declared as API-key-only. According to linked Linear issue IAM-469, existing API key auth should remain unaffected.</violation>
<violation number="2" location="packages/cli/src/public-api/index.ts:138">
P0: Custom agent: **Security Review**
According to linked Linear issue IAM-469, bearer-authenticated scoped JWTs must carry resource constraints into `tokenGrant`. Enabling `BearerAuth` here without propagating or enforcing `resource` turns resource-scoped tokens into effectively account-wide scope tokens on the public API.</violation>
</file>
<file name="packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts">
<violation number="1" location="packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts:63">
P0: Custom agent: **Security Review**
According to linked Linear issue IAM-469, this must preserve the exchanged JWT's scope/resource when building `req.tokenGrant`. Mapping `tokenGrant.scopes` from `actingUser.role.scopes` and dropping `payload.resource` turns a scoped JWT into a full-role token, so a token exchanged for `workflow:read` can call any public API endpoint allowed by the actor's role.</violation>
</file>
<file name="packages/@n8n/db/src/entities/types-db.ts">
<violation number="1" location="packages/@n8n/db/src/entities/types-db.ts:424">
P1: TokenGrant drops the `roles`/`resource` fields required for scoped JWTs. According to linked Linear issue IAM-469, those claims still need to be carried on `req.tokenGrant`.</violation>
</file>
<file name="packages/cli/src/modules/token-exchange/token-exchange.module.ts">
<violation number="1" location="packages/cli/src/modules/token-exchange/token-exchange.module.ts:43">
P1: According to linked Linear issue IAM-469, this registers `ScopedJwtStrategy` too early: because API key auth is added later during public API setup, the scoped strategy becomes first in the chain and can reject requests before a valid API key is tried.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client
participant API as Public API (Express)
participant Registry as AuthStrategyRegistry
participant KeyAuth as ApiKeyAuthStrategy
participant ScopedAuth as ScopedJwtStrategy
participant JWT as JwtService
participant DB as UserRepository
Note over Client,ScopedAuth: Authentication Flow for Scoped JWTs
Client->>API: Request (Header: Authorization or x-n8n-api-key)
API->>Registry: authenticate(req)
Registry->>KeyAuth: authenticate(req)
KeyAuth->>JWT: decode(token)
alt CHANGED: Token is JWT but Issuer != API_KEY_ISSUER
KeyAuth-->>Registry: return null (Abstain)
else Invalid JWT
KeyAuth-->>Registry: return false (Fail)
end
Registry->>ScopedAuth: NEW: authenticate(req)
ScopedAuth->>ScopedAuth: extractToken() from headers
ScopedAuth->>JWT: decode(token)
alt NEW: Issuer == 'n8n-token-exchange'
ScopedAuth->>JWT: verify(token) signature & expiry
ScopedAuth->>DB: findUser(sub)
DB-->>ScopedAuth: subject user + roles
opt JWT has 'act' claim (Delegation)
ScopedAuth->>DB: findUser(act.sub)
DB-->>ScopedAuth: actor user + roles
end
ScopedAuth->>ScopedAuth: Map scopes from DB Role (not JWT)
ScopedAuth->>API: Set req.user & req.tokenGrant
ScopedAuth-->>Registry: return true
else Not a Scoped JWT
ScopedAuth-->>Registry: return null
end
alt Authentication Success
Registry-->>API: success
API->>API: NEW: Update LastActiveAt & Emit Event
API-->>Client: 200 OK + Data
else Authentication Failed
Registry-->>API: failure
API-->>Client: 401 Unauthorized
end
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| return authenticated; | ||
| }, | ||
| ApiKeyAuth: authenticate, | ||
| BearerAuth: authenticate, |
There was a problem hiding this comment.
P0: Custom agent: Security Review
According to linked Linear issue IAM-469, bearer-authenticated scoped JWTs must carry resource constraints into tokenGrant. Enabling BearerAuth here without propagating or enforcing resource turns resource-scoped tokens into effectively account-wide scope tokens on the public API.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/public-api/index.ts, line 138:
<comment>According to linked Linear issue IAM-469, bearer-authenticated scoped JWTs must carry resource constraints into `tokenGrant`. Enabling `BearerAuth` here without propagating or enforcing `resource` turns resource-scoped tokens into effectively account-wide scope tokens on the public API.</comment>
<file context>
@@ -110,28 +134,8 @@ function createLazyValidatorMiddleware(
- return authenticated;
- },
+ ApiKeyAuth: authenticate,
+ BearerAuth: authenticate,
},
},
</file context>
|
|
||
| // 7. Scopes come from the acting user's role (role.scopes is eager: true) | ||
| req.tokenGrant = { | ||
| scopes: actingUser.role.scopes.map((s) => s.slug), |
There was a problem hiding this comment.
P0: Custom agent: Security Review
According to linked Linear issue IAM-469, this must preserve the exchanged JWT's scope/resource when building req.tokenGrant. Mapping tokenGrant.scopes from actingUser.role.scopes and dropping payload.resource turns a scoped JWT into a full-role token, so a token exchanged for workflow:read can call any public API endpoint allowed by the actor's role.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts, line 63:
<comment>According to linked Linear issue IAM-469, this must preserve the exchanged JWT's scope/resource when building `req.tokenGrant`. Mapping `tokenGrant.scopes` from `actingUser.role.scopes` and dropping `payload.resource` turns a scoped JWT into a full-role token, so a token exchanged for `workflow:read` can call any public API endpoint allowed by the actor's role.</comment>
<file context>
@@ -0,0 +1,94 @@
+
+ // 7. Scopes come from the acting user's role (role.scopes is eager: true)
+ req.tokenGrant = {
+ scopes: actingUser.role.scopes.map((s) => s.slug),
+ subject,
+ ...(actor && { actor }),
</file context>
| scopes: string[]; | ||
| actor?: { userId: string }; | ||
| actor?: User; | ||
| subject: User; |
There was a problem hiding this comment.
P1: TokenGrant drops the roles/resource fields required for scoped JWTs. According to linked Linear issue IAM-469, those claims still need to be carried on req.tokenGrant.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/@n8n/db/src/entities/types-db.ts, line 424:
<comment>TokenGrant drops the `roles`/`resource` fields required for scoped JWTs. According to linked Linear issue IAM-469, those claims still need to be carried on `req.tokenGrant`.</comment>
<file context>
@@ -419,9 +419,9 @@ export type AuthenticationInformation = {
scopes: string[];
- actor?: { userId: string };
+ actor?: User;
+ subject: User;
}
</file context>
|
|
||
| return authenticated; | ||
| }, | ||
| ApiKeyAuth: authenticate, |
There was a problem hiding this comment.
P1: ApiKeyAuth should not delegate to the full auth registry; it now lets scoped Bearer tokens satisfy operations declared as API-key-only. According to linked Linear issue IAM-469, existing API key auth should remain unaffected.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/public-api/index.ts, line 137:
<comment>`ApiKeyAuth` should not delegate to the full auth registry; it now lets scoped Bearer tokens satisfy operations declared as API-key-only. According to linked Linear issue IAM-469, existing API key auth should remain unaffected.</comment>
<file context>
@@ -110,28 +134,8 @@ function createLazyValidatorMiddleware(
-
- return authenticated;
- },
+ ApiKeyAuth: authenticate,
+ BearerAuth: authenticate,
},
</file context>
| // ScopedJwtStrategy runs after ApiKeyAuthStrategy (which abstains for token-exchange JWTs). | ||
| const { ScopedJwtStrategy } = await import('./services/scoped-jwt.strategy'); | ||
| const { AuthStrategyRegistry } = await import('@/services/auth-strategy.registry'); | ||
| Container.get(AuthStrategyRegistry).register(Container.get(ScopedJwtStrategy)); |
There was a problem hiding this comment.
P1: According to linked Linear issue IAM-469, this registers ScopedJwtStrategy too early: because API key auth is added later during public API setup, the scoped strategy becomes first in the chain and can reject requests before a valid API key is tried.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/modules/token-exchange/token-exchange.module.ts, line 43:
<comment>According to linked Linear issue IAM-469, this registers `ScopedJwtStrategy` too early: because API key auth is added later during public API setup, the scoped strategy becomes first in the chain and can reject requests before a valid API key is tried.</comment>
<file context>
@@ -35,5 +35,11 @@ export class TokenExchangeModule implements ModuleInterface {
+ // ScopedJwtStrategy runs after ApiKeyAuthStrategy (which abstains for token-exchange JWTs).
+ const { ScopedJwtStrategy } = await import('./services/scoped-jwt.strategy');
+ const { AuthStrategyRegistry } = await import('@/services/auth-strategy.registry');
+ Container.get(AuthStrategyRegistry).register(Container.get(ScopedJwtStrategy));
}
}
</file context>
|
Got released with |
PR Summary
https://linear.app/n8n/issue/IAM-469
What this PR does
Implements
ScopedJwtStrategy— the auth strategy that validates scoped JWTs issued by the token exchange endpoint and authenticates public API requests with them.Scoped JWTs can be presented in either
Authorization: Bearer <token>orx-n8n-api-keyheaders. The strategy identifies them by their issuer (n8n-token-exchange) and is registered intoAuthStrategyRegistrybyTokenExchangeModuleon startup, making it the second strategy afterApiKeyAuthStrategy.Changes
ScopedJwtStrategy(new)Authorization: Bearerandx-n8n-api-keyheadersdecode()+ issuer check before the expensiveverify()call, so non-token-exchange JWTs pass through asnullwithout signature overheadsub→subjectand optionallyact.sub→actorfrom the DBactor ?? subject) — not from the JWT payload — so permission changes take effect immediately without re-issuing tokensreq.userto the acting principal (actor if delegation is present, subject otherwise)req.tokenGrant = { scopes, subject, actor? }ApiKeyAuthStrategy(updated)subject: apiKeyRecord.usertoreq.tokenGrant—TokenGrant.subjectis now requiredx-n8n-api-keyis a JWT with an issuer other thanAPI_KEY_ISSUER, the strategy returnsnull(abstain) instead offalse(fail-fast). Without this, a token-exchange JWT inx-n8n-api-keywould short-circuit the auth chain beforeScopedJwtStrategyrunsTokenExchangeModule(updated)ScopedJwtStrategyintoAuthStrategyRegistryat the end ofinit(), behind the existing feature flag guardKey decisions
Scopes from role, not JWT payload. Scopes are resolved at request time from the acting user's
role.scopesrather than from thescopeclaim embedded in the JWT. This means permission changes take effect immediately and the JWT itself only needs to carry identity claims (sub,act).Actor is optional. If the JWT carries an
actclaim but the actor user ID is not found in the DB (e.g. the account was deleted after the token was issued), authentication continues with the subject acting as principal. Only if the actor is found and disabled does authentication fail. This keeps token-exchange tokens usable across user lifecycle events.ApiKeyAuthStrategyabstains on non-API-key JWTs. The issuer check (decoded.iss !== API_KEY_ISSUER) is placed before the DB lookup so a token-exchange JWT inx-n8n-api-keygetsnull(pass through) rather thanfalse(block). A null decode (non-JWT string) still returnsfalseto preserve existing rejection behaviour for garbage values.Module self-registration (Option B).
ScopedJwtStrategyis registered byTokenExchangeModule.init()rather than in the public API bootstrap. This keeps the token-exchange module self-contained and ensures the strategy is only active when the module is enabled.Review / Merge checklist
Backport to Beta,Backport to Stable, orBackport to v1(if the PR is an urgent fix that needs to be backported)