mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Bind OAuth token grants to their approved resource (#37122)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e8161043d
commit
380788fd9a
3
docs/generated/postgres-schema/README.md
generated
3
docs/generated/postgres-schema/README.md
generated
|
|
@ -93,7 +93,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
|||
| [public.oauth_access_tokens](public.oauth_access_tokens.md) | 3 | | BASE TABLE |
|
||||
| [public.oauth_authorization_codes](public.oauth_authorization_codes.md) | 13 | | BASE TABLE |
|
||||
| [public.oauth_clients](public.oauth_clients.md) | 10 | | BASE TABLE |
|
||||
| [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) | 7 | | BASE TABLE |
|
||||
| [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) | 8 | | BASE TABLE |
|
||||
| [public.oauth_user_consents](public.oauth_user_consents.md) | 5 | | BASE TABLE |
|
||||
| [public.poller_state](public.poller_state.md) | 7 | | BASE TABLE |
|
||||
| [public.processed_data](public.processed_data.md) | 5 | | BASE TABLE |
|
||||
|
|
@ -1213,6 +1213,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
timestamp_3__with_time_zone createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
json scope
|
||||
varchar_255_ token
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
timestamp_3__with_time_zone createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
json scope
|
||||
varchar_255_ token
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
| clientId | varchar | | false | | [public.oauth_clients](public.oauth_clients.md) | |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| expiresAt | bigint | | false | | | Unix timestamp in milliseconds |
|
||||
| resource | varchar | | false | | | RFC 8707 resource indicator the grant was approved for (e.g. https://n8n.example.com/mcp-server/http); the audience of every access token minted from this refresh token. |
|
||||
| scope | json | '["tool:listWorkflows","tool:getWorkflowDetails"]'::json | false | | | OAuth scopes granted for this refresh token |
|
||||
| token | varchar(255) | | false | | | |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
| oauth_refresh_tokens_clientId_not_null | n | NOT NULL "clientId" |
|
||||
| oauth_refresh_tokens_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| oauth_refresh_tokens_expiresAt_not_null | n | NOT NULL "expiresAt" |
|
||||
| oauth_refresh_tokens_resource_not_null | n | NOT NULL resource |
|
||||
| oauth_refresh_tokens_scope_not_null | n | NOT NULL scope |
|
||||
| oauth_refresh_tokens_token_not_null | n | NOT NULL token |
|
||||
| oauth_refresh_tokens_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
|
|
@ -45,6 +47,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
timestamp_3__with_time_zone createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
json scope
|
||||
varchar_255_ token
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
|
|
|
|||
1
docs/generated/postgres-schema/public.user.md
generated
1
docs/generated/postgres-schema/public.user.md
generated
|
|
@ -266,6 +266,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
timestamp_3__with_time_zone createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
json scope
|
||||
varchar_255_ token
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
|
|
|
|||
5
docs/generated/sqlite-schema/README.md
generated
5
docs/generated/sqlite-schema/README.md
generated
|
|
@ -93,7 +93,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [oauth_access_tokens](oauth_access_tokens.md) | 3 | | table |
|
||||
| [oauth_authorization_codes](oauth_authorization_codes.md) | 13 | | table |
|
||||
| [oauth_clients](oauth_clients.md) | 10 | | table |
|
||||
| [oauth_refresh_tokens](oauth_refresh_tokens.md) | 7 | | table |
|
||||
| [oauth_refresh_tokens](oauth_refresh_tokens.md) | 8 | | table |
|
||||
| [oauth_user_consents](oauth_user_consents.md) | 5 | | table |
|
||||
| [poller_state](poller_state.md) | 7 | | table |
|
||||
| [processed_data](processed_data.md) | 5 | | table |
|
||||
|
|
@ -266,8 +266,8 @@ erDiagram
|
|||
"oauth_access_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_authorization_codes" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_authorization_codes" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_refresh_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_refresh_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_refresh_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_user_consents" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
|
@ -1200,6 +1200,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
datetime_3_ createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
TEXT scope
|
||||
varchar_255_ token PK
|
||||
datetime_3_ updatedAt
|
||||
|
|
|
|||
1
docs/generated/sqlite-schema/oauth_clients.md
generated
1
docs/generated/sqlite-schema/oauth_clients.md
generated
|
|
@ -85,6 +85,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
datetime_3_ createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
TEXT scope
|
||||
varchar_255_ token PK
|
||||
datetime_3_ updatedAt
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "oauth_refresh_tokens" ("token" varchar(255) PRIMARY KEY NOT NULL, "clientId" varchar NOT NULL, "userId" varchar NOT NULL, "expiresAt" bigint NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "scope" text NOT NULL DEFAULT ('["tool:listWorkflows","tool:getWorkflowDetails"]'), CONSTRAINT "FK_a699f3ed9fd0c1b19bc2608ac53" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_b388696ce4d8be7ffbe8d3e4b69" FOREIGN KEY ("clientId") REFERENCES "oauth_clients" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
|
||||
CREATE TABLE "oauth_refresh_tokens" ("token" varchar(255) PRIMARY KEY NOT NULL, "clientId" varchar NOT NULL, "userId" varchar NOT NULL, "expiresAt" bigint NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "scope" text NOT NULL DEFAULT ('["tool:listWorkflows","tool:getWorkflowDetails"]'), "resource" varchar NOT NULL, CONSTRAINT "FK_b388696ce4d8be7ffbe8d3e4b69" FOREIGN KEY ("clientId") REFERENCES "oauth_clients" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a699f3ed9fd0c1b19bc2608ac53" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -18,6 +18,7 @@ CREATE TABLE "oauth_refresh_tokens" ("token" varchar(255) PRIMARY KEY NOT NULL,
|
|||
| clientId | varchar | | false | | [oauth_clients](oauth_clients.md) | |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| expiresAt | bigint | | false | | | |
|
||||
| resource | varchar | | false | | | |
|
||||
| scope | TEXT | '["tool:listWorkflows","tool:getWorkflowDetails"]' | false | | | |
|
||||
| token | varchar(255) | | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
|
|
@ -27,8 +28,8 @@ CREATE TABLE "oauth_refresh_tokens" ("token" varchar(255) PRIMARY KEY NOT NULL,
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| sqlite_autoindex_oauth_refresh_tokens_1 | PRIMARY KEY | PRIMARY KEY (token) |
|
||||
| token | PRIMARY KEY | PRIMARY KEY (token) |
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
datetime_3_ createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
TEXT scope
|
||||
varchar_255_ token PK
|
||||
datetime_3_ updatedAt
|
||||
|
|
|
|||
1
docs/generated/sqlite-schema/user.md
generated
1
docs/generated/sqlite-schema/user.md
generated
|
|
@ -272,6 +272,7 @@ erDiagram
|
|||
varchar clientId FK
|
||||
datetime_3_ createdAt
|
||||
bigint expiresAt
|
||||
varchar resource
|
||||
TEXT scope
|
||||
varchar_255_ token PK
|
||||
datetime_3_ updatedAt
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const TABLE_NAME = 'oauth_refresh_tokens';
|
||||
const COLUMN_NAME = 'resource';
|
||||
|
||||
/**
|
||||
* Binds every refresh token to the protected resource its grant was approved for,
|
||||
* so rotation reissues the same audience instead of taking one from the request.
|
||||
*
|
||||
* Existing rows are cleared first: they predate the column and carry no record of
|
||||
* the resource they were granted for, so there is nothing to backfill them with.
|
||||
* Defaulting them to the instance MCP resource would widen those grants, so they
|
||||
* are dropped instead — clients re-run the authorization flow on their next
|
||||
* refresh. Access tokens are left alone; they are already audience-bound and
|
||||
* expire within the hour, so live sessions are unaffected.
|
||||
*
|
||||
* Reversible on purpose, following `DisallowOrphanExecutions1693554410387`: the column
|
||||
* is NOT NULL with no default, so a downgrade needs it dropped for the older code to
|
||||
* insert tokens again. Reversal excludes restoring deleted rows.
|
||||
*/
|
||||
export class AddResourceToOAuthRefreshTokens1787739515257 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column }, escape, runQuery }: MigrationContext) {
|
||||
await runQuery(`DELETE FROM ${escape.tableName(TABLE_NAME)}`);
|
||||
|
||||
// No default on purpose: an insert that forgets the resource must fail instead
|
||||
// of silently binding the grant to something the user never approved.
|
||||
await addColumns(
|
||||
TABLE_NAME,
|
||||
[
|
||||
column(COLUMN_NAME)
|
||||
.varchar()
|
||||
.notNull.comment(
|
||||
'RFC 8707 resource indicator the grant was approved for (e.g. https://n8n.example.com/mcp-server/http); ' +
|
||||
'the audience of every access token minted from this refresh token.',
|
||||
),
|
||||
],
|
||||
{ recreatesOnSqlite: true },
|
||||
);
|
||||
}
|
||||
|
||||
/** Reversal excludes restoring the cleared refresh tokens. */
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns(TABLE_NAME, [COLUMN_NAME], { recreatesOnSqlite: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -369,7 +369,14 @@ describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () =>
|
|||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
userId,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -313,7 +313,14 @@ describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () =>
|
|||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
userId,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
@ -383,7 +390,14 @@ describe('test vs production chat resources', () => {
|
|||
|
||||
const mint = async (resourceUrl: string) => {
|
||||
const pair = tokenService.generateTokenPair(owner.id, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -315,7 +315,14 @@ describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () =>
|
|||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
userId,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -323,7 +323,14 @@ describe('test vs production form resources', () => {
|
|||
|
||||
const mint = async (resourceUrl: string) => {
|
||||
const pair = tokenService.generateTokenPair(owner.id, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -873,6 +873,7 @@ describe('OAuthServerService', () => {
|
|||
tokenService.generateTokenPair.mockReturnValue({
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
audience: TEST_RESOURCE_URL,
|
||||
});
|
||||
tokenService.saveTokenPair.mockResolvedValue();
|
||||
tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);
|
||||
|
|
@ -904,6 +905,7 @@ describe('OAuthServerService', () => {
|
|||
'client-123',
|
||||
'user-456',
|
||||
['workflow:read'],
|
||||
TEST_RESOURCE_URL,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
access_token: 'access-token-123',
|
||||
|
|
@ -978,6 +980,7 @@ describe('OAuthServerService', () => {
|
|||
tokenService.generateTokenPair.mockReturnValue({
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
audience: formResourceUrl,
|
||||
});
|
||||
tokenService.saveTokenPair.mockResolvedValue();
|
||||
tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);
|
||||
|
|
@ -1016,6 +1019,7 @@ describe('OAuthServerService', () => {
|
|||
tokenService.generateTokenPair.mockReturnValue({
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
audience: TEST_RESOURCE_URL,
|
||||
});
|
||||
|
||||
await service.exchangeAuthorizationCode(client, 'auth-code-123', 'verifier-123');
|
||||
|
|
@ -1028,14 +1032,133 @@ describe('OAuthServerService', () => {
|
|||
expect(authorizationCodeService.markAuthorizationCodeAsUsed).toHaveBeenCalledWith(
|
||||
'auth-code-123',
|
||||
);
|
||||
// A code carrying no resource was approved against the default resource,
|
||||
// so that is what the token is minted for.
|
||||
expect(tokenService.generateTokenPair).toHaveBeenCalledWith(
|
||||
'user-456',
|
||||
'client-123',
|
||||
undefined,
|
||||
TEST_RESOURCE_URL,
|
||||
['workflow:read'],
|
||||
);
|
||||
});
|
||||
|
||||
describe('resource binding', () => {
|
||||
const client = {
|
||||
client_id: 'client-123',
|
||||
client_name: 'Test Client',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
response_types: ['code'],
|
||||
scope: 'read',
|
||||
logo_uri: undefined,
|
||||
tos_uri: undefined,
|
||||
};
|
||||
|
||||
// A second registered resource the code was never approved for.
|
||||
const otherResourceUrl = 'https://n8n.example.com/mcp/other';
|
||||
let boundService: OAuthServerService;
|
||||
|
||||
beforeEach(() => {
|
||||
const registry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
registry.register({
|
||||
id: 'instance-mcp',
|
||||
getResourceUrl: () => TEST_RESOURCE_URL,
|
||||
getAudiences: () => [TEST_RESOURCE_URL],
|
||||
scopes: SUPPORTED_SCOPES,
|
||||
isDefault: true,
|
||||
authorize: async () => true,
|
||||
});
|
||||
registry.register({
|
||||
id: 'other-resource',
|
||||
getResourceUrl: () => otherResourceUrl,
|
||||
getAudiences: () => [otherResourceUrl],
|
||||
scopes: [],
|
||||
authorize: async () => true,
|
||||
});
|
||||
|
||||
boundService = new OAuthServerService(
|
||||
logger,
|
||||
mockInstance(GlobalConfig),
|
||||
oauthSessionService,
|
||||
oauthClientRepository,
|
||||
tokenService,
|
||||
authorizationCodeService,
|
||||
userConsentRepository,
|
||||
registry,
|
||||
mailer,
|
||||
urlServiceMock,
|
||||
eventService,
|
||||
mock<AuthService>(),
|
||||
mock<OAuthConsentService>(),
|
||||
);
|
||||
|
||||
tokenService.generateTokenPair.mockReturnValue({
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
audience: TEST_RESOURCE_URL,
|
||||
});
|
||||
tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);
|
||||
});
|
||||
|
||||
const mockCode = (resource: string | null) =>
|
||||
authorizationCodeService.findAuthorizationCode.mockResolvedValue({
|
||||
userId: 'user-456',
|
||||
clientId: 'client-123',
|
||||
resource,
|
||||
scope: [] as string[],
|
||||
} as AuthorizationCode);
|
||||
|
||||
it('should reject a token request naming a resource the code was not approved for', async () => {
|
||||
mockCode(TEST_RESOURCE_URL);
|
||||
|
||||
await expect(
|
||||
boundService.exchangeAuthorizationCode(
|
||||
client,
|
||||
'auth-code-123',
|
||||
'verifier-123',
|
||||
'https://example.com/callback',
|
||||
new URL(otherResourceUrl),
|
||||
),
|
||||
).rejects.toThrow(InvalidTargetError);
|
||||
|
||||
expect(authorizationCodeService.markAuthorizationCodeAsUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a resource introduced at token time when the code carries none', async () => {
|
||||
mockCode(null);
|
||||
|
||||
await expect(
|
||||
boundService.exchangeAuthorizationCode(
|
||||
client,
|
||||
'auth-code-123',
|
||||
'verifier-123',
|
||||
'https://example.com/callback',
|
||||
new URL(otherResourceUrl),
|
||||
),
|
||||
).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
|
||||
it('should accept the default resource at token time when the code carries none', async () => {
|
||||
mockCode(null);
|
||||
|
||||
await boundService.exchangeAuthorizationCode(
|
||||
client,
|
||||
'auth-code-123',
|
||||
'verifier-123',
|
||||
'https://example.com/callback',
|
||||
new URL(TEST_RESOURCE_URL),
|
||||
);
|
||||
|
||||
expect(tokenService.generateTokenPair).toHaveBeenCalledWith(
|
||||
'user-456',
|
||||
'client-123',
|
||||
TEST_RESOURCE_URL,
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate InvalidGrantError when markAuthorizationCodeAsUsed detects concurrent consumption', async () => {
|
||||
const client = {
|
||||
client_id: 'client-123',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { InvalidTargetError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
|
|
@ -35,6 +36,7 @@ const workflowFinderService = mock<WorkflowFinderService>();
|
|||
const TEST_BASE_URL = 'https://n8n.example.com';
|
||||
const TEST_RESOURCE_URL = `${TEST_BASE_URL}/mcp-server/http`;
|
||||
const LEGACY_AUDIENCE = 'mcp-server-api';
|
||||
const OTHER_RESOURCE_URL = `${TEST_BASE_URL}/mcp/workflow-b`;
|
||||
|
||||
const registry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
registry.register({
|
||||
|
|
@ -132,6 +134,21 @@ describe('OAuthTokenService', () => {
|
|||
expect(decoded.scope).toBe('');
|
||||
});
|
||||
|
||||
it('should return the resolved audience so the grant can be persisted', () => {
|
||||
const withResource = service.generateTokenPair(
|
||||
'user-123',
|
||||
'client-456',
|
||||
OTHER_RESOURCE_URL,
|
||||
[],
|
||||
);
|
||||
expect(withResource.audience).toBe(OTHER_RESOURCE_URL);
|
||||
|
||||
// Resource-less grants resolve to the default resource, so the returned
|
||||
// audience is always a concrete URL.
|
||||
const withoutResource = service.generateTokenPair('user-123', 'client-456', undefined, []);
|
||||
expect(withoutResource.audience).toBe(TEST_RESOURCE_URL);
|
||||
});
|
||||
|
||||
it('should generate different tokens on each call', () => {
|
||||
const userId = 'user-123';
|
||||
const clientId = 'client-456';
|
||||
|
|
@ -151,7 +168,14 @@ describe('OAuthTokenService', () => {
|
|||
const clientId = 'client-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
await service.saveTokenPair(accessToken, refreshToken, clientId, userId, ['workflow:read']);
|
||||
await service.saveTokenPair(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
clientId,
|
||||
userId,
|
||||
['workflow:read'],
|
||||
TEST_RESOURCE_URL,
|
||||
);
|
||||
|
||||
expect(txRunner.run).toHaveBeenCalled();
|
||||
|
||||
|
|
@ -167,6 +191,7 @@ describe('OAuthTokenService', () => {
|
|||
userId,
|
||||
expiresAt: expect.any(Number),
|
||||
scope: ['workflow:read'],
|
||||
resource: TEST_RESOURCE_URL,
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
|
|
@ -183,6 +208,7 @@ describe('OAuthTokenService', () => {
|
|||
userId: 'user-456',
|
||||
expiresAt: Date.now() + 1000000, // Valid
|
||||
scope: [],
|
||||
resource: TEST_RESOURCE_URL,
|
||||
});
|
||||
|
||||
refreshTokenRepository.findByToken.mockResolvedValue(refreshTokenRecord);
|
||||
|
|
@ -215,6 +241,7 @@ describe('OAuthTokenService', () => {
|
|||
userId: 'user-456',
|
||||
expiresAt: Date.now() + 1000000,
|
||||
scope: [],
|
||||
resource: TEST_RESOURCE_URL,
|
||||
});
|
||||
|
||||
refreshTokenRepository.findByToken.mockResolvedValue(refreshTokenRecord);
|
||||
|
|
@ -241,6 +268,7 @@ describe('OAuthTokenService', () => {
|
|||
userId: 'user-456',
|
||||
expiresAt: Date.now() + 1000000,
|
||||
scope: ['workflow:read', 'execution:read'],
|
||||
resource: TEST_RESOURCE_URL,
|
||||
} as RefreshToken;
|
||||
|
||||
refreshTokenRepository.findByToken.mockResolvedValue(refreshTokenRecord);
|
||||
|
|
@ -281,6 +309,120 @@ describe('OAuthTokenService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('refresh-token resource binding', () => {
|
||||
const GRANTED_URL = `${TEST_BASE_URL}/mcp/granted`;
|
||||
// Second URL the same resource is reachable by, e.g. through the instance hostname.
|
||||
const GRANTED_ALIAS_URL = 'https://alias.example.com/mcp/granted';
|
||||
const ANOTHER_URL = `${TEST_BASE_URL}/mcp/another`;
|
||||
const REFRESH_TOKEN = 'stored-refresh-token';
|
||||
const CLIENT_ID = 'client-123';
|
||||
|
||||
let boundService: OAuthTokenService;
|
||||
|
||||
beforeAll(() => {
|
||||
const boundRegistry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
boundRegistry.register({
|
||||
id: 'instance-mcp',
|
||||
getResourceUrl: () => TEST_RESOURCE_URL,
|
||||
getAudiences: () => [TEST_RESOURCE_URL, LEGACY_AUDIENCE],
|
||||
scopes: [],
|
||||
isDefault: true,
|
||||
authorize: async () => true,
|
||||
});
|
||||
boundRegistry.register({
|
||||
id: 'granted-resource',
|
||||
getResourceUrl: () => GRANTED_URL,
|
||||
getResourceUrls: () => [GRANTED_URL, GRANTED_ALIAS_URL],
|
||||
getAudiences: () => [GRANTED_URL, GRANTED_ALIAS_URL],
|
||||
scopes: [],
|
||||
authorize: async () => true,
|
||||
});
|
||||
boundRegistry.register({
|
||||
id: 'another-resource',
|
||||
getResourceUrl: () => ANOTHER_URL,
|
||||
getAudiences: () => [ANOTHER_URL],
|
||||
scopes: [],
|
||||
authorize: async () => true,
|
||||
});
|
||||
|
||||
boundService = new OAuthTokenService(
|
||||
logger,
|
||||
jwtService,
|
||||
userRepository,
|
||||
accessTokenRepository,
|
||||
refreshTokenRepository,
|
||||
boundRegistry,
|
||||
txRunner,
|
||||
workflowFinderService,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
refreshTokenRepository.findByToken.mockResolvedValue({
|
||||
token: REFRESH_TOKEN,
|
||||
clientId: CLIENT_ID,
|
||||
userId: 'user-456',
|
||||
expiresAt: Date.now() + 1000000,
|
||||
scope: [] as string[],
|
||||
resource: GRANTED_URL,
|
||||
} as RefreshToken);
|
||||
refreshTokenRepository.deleteValidByToken.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('reuses the granted resource when the request names none', async () => {
|
||||
const result = await boundService.validateAndRotateRefreshToken(REFRESH_TOKEN, CLIENT_ID);
|
||||
|
||||
// Not the default resource, which is what a resource-less mint would fall back to.
|
||||
expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL);
|
||||
});
|
||||
|
||||
it('accepts a request naming the granted resource', async () => {
|
||||
const result = await boundService.validateAndRotateRefreshToken(
|
||||
REFRESH_TOKEN,
|
||||
CLIENT_ID,
|
||||
GRANTED_URL,
|
||||
);
|
||||
|
||||
expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL);
|
||||
});
|
||||
|
||||
it('accepts an equivalent spelling of the granted resource', async () => {
|
||||
const result = await boundService.validateAndRotateRefreshToken(
|
||||
REFRESH_TOKEN,
|
||||
CLIENT_ID,
|
||||
GRANTED_ALIAS_URL,
|
||||
);
|
||||
|
||||
// Minted from the stored spelling, which the resource also accepts.
|
||||
expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL);
|
||||
});
|
||||
|
||||
it('rejects a request naming a different resource, leaving the token usable', async () => {
|
||||
await expect(
|
||||
boundService.validateAndRotateRefreshToken(REFRESH_TOKEN, CLIENT_ID, ANOTHER_URL),
|
||||
).rejects.toThrow(InvalidTargetError);
|
||||
|
||||
expect(refreshTokenRepository.deleteValidByToken).not.toHaveBeenCalled();
|
||||
expect(refreshTokenRepository.insertToken).not.toHaveBeenCalled();
|
||||
expect(accessTokenRepository.insertToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a request naming the default resource for a grant made elsewhere', async () => {
|
||||
await expect(
|
||||
boundService.validateAndRotateRefreshToken(REFRESH_TOKEN, CLIENT_ID, TEST_RESOURCE_URL),
|
||||
).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
|
||||
it('carries the granted resource onto the rotated refresh token', async () => {
|
||||
await boundService.validateAndRotateRefreshToken(REFRESH_TOKEN, CLIENT_ID, GRANTED_ALIAS_URL);
|
||||
|
||||
expect(refreshTokenRepository.insertToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resource: GRANTED_URL }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyAccessToken', () => {
|
||||
it('should verify valid access token and return auth info', async () => {
|
||||
const userId = 'user-123';
|
||||
|
|
@ -728,6 +870,7 @@ describe('OAuthTokenService', () => {
|
|||
userId: 'user-456',
|
||||
expiresAt: Date.now() + 1000000,
|
||||
scope: ['workflow:read'],
|
||||
resource: TEST_RESOURCE_URL,
|
||||
} as RefreshToken;
|
||||
|
||||
refreshTokenRepository.findByToken.mockResolvedValue(refreshTokenRecord);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,14 @@ const registerTestWebhook = async (
|
|||
const mintTokenFor = async (resourceUrl: string, user: User) => {
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
const pair = tokenService.generateTokenPair(user.id, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, user.id, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
user.id,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ describe('test vs production resources', () => {
|
|||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
testToken.audience,
|
||||
);
|
||||
const productionToken = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
|
|
@ -266,6 +267,7 @@ describe('test vs production resources', () => {
|
|||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
productionToken.audience,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -82,13 +82,13 @@ const createProtectedWorkflow = async (workflowName: string, requireExecuteAcces
|
|||
return { workflow, resourceUrl: productionResourceUrl(webhookPath) };
|
||||
};
|
||||
|
||||
const registerClient = async () =>
|
||||
const registerClient = async (grantTypes: string[] = ['authorization_code']) =>
|
||||
(
|
||||
await oauthClientRepository.save({
|
||||
id: `client-${randomUUID()}`,
|
||||
name: 'Test OAuth Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
grantTypes,
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
})
|
||||
).id;
|
||||
|
|
@ -96,7 +96,14 @@ const registerClient = async () =>
|
|||
const mintAccessToken = async (userId: string, resourceUrl: string) => {
|
||||
const clientId = await registerClient();
|
||||
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
userId,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
@ -222,3 +229,76 @@ describe('consent gate: workflow name and authorization code require execute acc
|
|||
expect(String(response.body.data?.redirectUrl ?? '')).not.toContain('code=');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A grant is approved for one protected resource, so every token the token endpoint
|
||||
* mints from it — including after rotation — carries that resource as its audience.
|
||||
*/
|
||||
describe('token endpoint: refresh grants stay on their approved resource', () => {
|
||||
const mintGrantFor = async (resourceUrl: string) => {
|
||||
const clientId = await registerClient(['authorization_code', 'refresh_token']);
|
||||
const pair = tokenService.generateTokenPair(owner.id, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return { clientId, refreshToken: pair.refreshToken };
|
||||
};
|
||||
|
||||
const refresh = async (clientId: string, refreshToken: string, resource?: string) =>
|
||||
await testServer.restlessAgent
|
||||
.post('/oauth/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
...(resource ? { resource } : {}),
|
||||
});
|
||||
|
||||
test('refuses a resource the grant was not approved for, and keeps the token usable', async () => {
|
||||
const { resourceUrl: resourceA } = await createProtectedWorkflow('Workflow A');
|
||||
const { resourceUrl: resourceB } = await createProtectedWorkflow('Workflow B');
|
||||
const { clientId, refreshToken } = await mintGrantFor(resourceA);
|
||||
|
||||
const denied = await refresh(clientId, refreshToken, resourceB);
|
||||
|
||||
expect(denied.statusCode).toBe(400);
|
||||
expect(denied.body.error).toBe('invalid_target');
|
||||
|
||||
// The refused request must not have consumed the refresh token.
|
||||
const retry = await refresh(clientId, refreshToken, resourceA);
|
||||
expect(retry.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('reuses the approved resource when the request names none', async () => {
|
||||
const { resourceUrl } = await createProtectedWorkflow('Workflow A');
|
||||
const { clientId, refreshToken } = await mintGrantFor(resourceUrl);
|
||||
|
||||
const response = await refresh(clientId, refreshToken);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(jwtService.decode(response.body.access_token).aud).toBe(resourceUrl);
|
||||
});
|
||||
|
||||
test('keeps the resource on the rotated refresh token', async () => {
|
||||
const { resourceUrl: resourceA } = await createProtectedWorkflow('Workflow A');
|
||||
const { resourceUrl: resourceB } = await createProtectedWorkflow('Workflow B');
|
||||
const { clientId, refreshToken } = await mintGrantFor(resourceA);
|
||||
|
||||
const rotated = await refresh(clientId, refreshToken, resourceA);
|
||||
expect(rotated.statusCode).toBe(200);
|
||||
|
||||
const crossResource = await refresh(clientId, rotated.body.refresh_token, resourceB);
|
||||
expect(crossResource.statusCode).toBe(400);
|
||||
expect(crossResource.body.error).toBe('invalid_target');
|
||||
|
||||
const reused = await refresh(clientId, rotated.body.refresh_token);
|
||||
expect(reused.statusCode).toBe(200);
|
||||
expect(jwtService.decode(reused.body.access_token).aud).toBe(resourceA);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -303,13 +303,13 @@ describe('token audience', () => {
|
|||
const clientId = await registerOAuthClient();
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
resourceUrlFor(pathA),
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).toBe(resourceUrlFor(pathA));
|
||||
|
||||
|
|
@ -330,13 +330,13 @@ describe('token audience', () => {
|
|||
const tokenService = Container.get(OAuthTokenService);
|
||||
|
||||
// no resource indicator -> falls back to the default resource (instance MCP)
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).not.toBe(resourceUrlFor(webhookPath));
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -395,6 +395,7 @@ describe('test vs production resources', () => {
|
|||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
testToken.audience,
|
||||
);
|
||||
const productionToken = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
|
|
@ -408,6 +409,7 @@ describe('test vs production resources', () => {
|
|||
clientId,
|
||||
owner.id,
|
||||
[],
|
||||
productionToken.audience,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -452,13 +452,13 @@ describe('protected resource metadata for webhook triggers', () => {
|
|||
const clientId = await registerOAuthClient();
|
||||
|
||||
const resourceA = resourceUrlFor(`${webhookIdA}/user/:id`);
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
resourceA,
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).toBe(resourceA);
|
||||
|
||||
|
|
@ -549,13 +549,13 @@ describe('token audience', () => {
|
|||
const tokenService = Container.get(OAuthTokenService);
|
||||
const clientId = await registerOAuthClient();
|
||||
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
resourceUrlFor(pathA),
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).toBe(resourceUrlFor(pathA));
|
||||
|
||||
|
|
@ -583,13 +583,13 @@ describe('token audience', () => {
|
|||
const clientId = await registerOAuthClient();
|
||||
|
||||
const getResource = resourceUrlFor(sharedPath, 'GET');
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
getResource,
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).toBe(getResource);
|
||||
|
||||
|
|
@ -611,13 +611,13 @@ describe('token audience', () => {
|
|||
const tokenService = Container.get(OAuthTokenService);
|
||||
const clientId = await registerOAuthClient();
|
||||
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
resourceUrlFor(webhookPath, 'GET'),
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(accessToken, resourceUrlFor(webhookPath, 'POST')),
|
||||
|
|
@ -653,13 +653,13 @@ describe('token audience', () => {
|
|||
const clientId = await registerOAuthClient();
|
||||
|
||||
const getResource = resourceUrlFor(webhookPath, 'GET');
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
getResource,
|
||||
[],
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, []);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id, [], audience);
|
||||
|
||||
// the user adds POST to the node; the GET-scoped token must survive
|
||||
await insertWebhookRow(workflow.id, webhookPath, 'Webhook', 'POST');
|
||||
|
|
|
|||
|
|
@ -33,4 +33,12 @@ export class RefreshToken extends WithTimestamps {
|
|||
/** OAuth scopes of the originating grant, reissued on every rotation. */
|
||||
@JsonColumn()
|
||||
scope: string[];
|
||||
|
||||
/**
|
||||
* RFC 8707 resource the grant was approved for — the audience of every access
|
||||
* token minted from it, carried unchanged across rotations. Never null: the
|
||||
* mint path resolves the default resource before the row is written.
|
||||
*/
|
||||
@Column({ type: 'varchar' })
|
||||
resource: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type NewRefreshToken = {
|
|||
userId: string;
|
||||
expiresAt: number;
|
||||
scope: string[];
|
||||
resource: string;
|
||||
};
|
||||
|
||||
@Service()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { OAuthConsentService } from './oauth-consent.service';
|
|||
import { OAuthSessionPayload, OAuthSessionService } from './oauth-session.service';
|
||||
import { OAuthTokenService } from './oauth-token.service';
|
||||
import { OAuthClientLimitReachedError } from './oauth.errors';
|
||||
import { isSameProtectedResource } from './resource-identity';
|
||||
|
||||
/** Maximum number of redirect URIs per client */
|
||||
const MAX_REDIRECT_URIS = 10;
|
||||
|
|
@ -434,29 +435,31 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
const resourceStr = resource?.toString();
|
||||
const tokenResource = await this.resolveAndValidateResourceIndicator(resourceStr);
|
||||
|
||||
// RFC 8707: if both the token request and the auth code specify a resource, they must match
|
||||
// (token substitution defense). Otherwise either supplies the other, falling back to the
|
||||
// registry's default resource.
|
||||
let finalResource: string | undefined;
|
||||
const codeResource = authRecord.resource ?? undefined;
|
||||
// RFC 8707 §2.2: the authorization request's resource applies to the whole grant, so
|
||||
// the token request may only name the resource the user approved. A code carrying no
|
||||
// resource was approved against the registry's default resource — that is what the
|
||||
// consent screen showed — so the token request may only name that one.
|
||||
const approvedResource =
|
||||
authRecord.resource ?? this.resourceRegistry.getDefaultResource()?.getResourceUrl();
|
||||
|
||||
if (tokenResource && codeResource) {
|
||||
if (tokenResource !== codeResource) {
|
||||
throw new InvalidResourceIndicatorError(tokenResource, codeResource);
|
||||
}
|
||||
finalResource = tokenResource;
|
||||
} else {
|
||||
finalResource = tokenResource ?? codeResource;
|
||||
if (
|
||||
tokenResource &&
|
||||
!(
|
||||
approvedResource &&
|
||||
(await isSameProtectedResource(this.resourceRegistry, tokenResource, approvedResource))
|
||||
)
|
||||
) {
|
||||
throw new InvalidResourceIndicatorError(tokenResource, approvedResource ?? 'none');
|
||||
}
|
||||
|
||||
await this.authorizationCodeService.markAuthorizationCodeAsUsed(authorizationCode);
|
||||
|
||||
const grantedScopes = authRecord.scope;
|
||||
|
||||
const { accessToken, refreshToken } = this.tokenService.generateTokenPair(
|
||||
const { accessToken, refreshToken, audience } = this.tokenService.generateTokenPair(
|
||||
authRecord.userId,
|
||||
client.client_id,
|
||||
finalResource,
|
||||
approvedResource,
|
||||
grantedScopes,
|
||||
);
|
||||
|
||||
|
|
@ -466,14 +469,15 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
client.client_id,
|
||||
authRecord.userId,
|
||||
grantedScopes,
|
||||
audience,
|
||||
);
|
||||
|
||||
// Completion of the authorization-code grant is the point at which the user
|
||||
// has finished the OAuth flow for this client. The authorization server is
|
||||
// shared by every protected resource on the instance (MCP, forms, ...), so
|
||||
// only grants targeting the instance MCP server count as MCP usage.
|
||||
const grantedResource = finalResource
|
||||
? await this.resourceRegistry.getByResourceUrl(finalResource)
|
||||
const grantedResource = approvedResource
|
||||
? await this.resourceRegistry.getByResourceUrl(approvedResource)
|
||||
: this.resourceRegistry.getDefaultResource();
|
||||
if (grantedResource?.id === INSTANCE_MCP_RESOURCE_ID) {
|
||||
this.eventService.emit('mcp-oauth-completed', {
|
||||
|
|
@ -494,9 +498,10 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
};
|
||||
}
|
||||
|
||||
// `resource` (when present) is normalized and validated before rotation; if omitted,
|
||||
// the token service falls back to the default protected resource. `_scopes` is part of
|
||||
// the SDK contract but unused — OAuth 2.1 refresh tokens reuse the original grant's scopes.
|
||||
// `resource` (when present) is normalized and validated against the registry here, then
|
||||
// against the grant's own resource by the token service; if omitted, the token service
|
||||
// reuses the grant's resource. `_scopes` is part of the SDK contract but unused — OAuth
|
||||
// 2.1 refresh tokens reuse the original grant's scopes.
|
||||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
refreshToken: string,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import {
|
||||
InvalidGrantError,
|
||||
InvalidTargetError,
|
||||
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
|
|
@ -23,6 +26,7 @@ import { AccessTokenRepository } from './database/repositories/oauth-access-toke
|
|||
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
|
||||
import { AccessTokenNotFoundError, JWTVerificationError } from './oauth.errors';
|
||||
import { authorizeAgainstGrant } from './resource-gate';
|
||||
import { isSameProtectedResource } from './resource-identity';
|
||||
|
||||
/**
|
||||
* Manages the OAuth 2.1 token lifecycle for the shared OAuth server.
|
||||
|
|
@ -52,12 +56,17 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
return this.ACCESS_TOKEN_EXPIRY_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an access/refresh pair. Returns the `audience` it resolved, so the
|
||||
* caller can persist the grant's resource alongside the refresh token — the
|
||||
* resolved value, not the (optional) request one, is what the token is bound to.
|
||||
*/
|
||||
generateTokenPair(
|
||||
userId: string,
|
||||
clientId: string,
|
||||
resource: string | undefined,
|
||||
scopes: string[],
|
||||
): { accessToken: string; refreshToken: string } {
|
||||
): { accessToken: string; refreshToken: string; audience: string } {
|
||||
// Pre-RFC-8707 clients omit the resource indicator; fall back to the
|
||||
// registry's default resource (the instance MCP server).
|
||||
const audience = resource ?? this.resourceRegistry.getDefaultResource()?.getResourceUrl();
|
||||
|
|
@ -85,7 +94,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
|
||||
const refreshToken = randomBytes(32).toString('hex');
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
return { accessToken, refreshToken, audience };
|
||||
}
|
||||
|
||||
async saveTokenPair(
|
||||
|
|
@ -94,6 +103,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
clientId: string,
|
||||
userId: string,
|
||||
scopes: string[],
|
||||
resource: string,
|
||||
): Promise<void> {
|
||||
await this.txRunner.run({}, async (ctx) => {
|
||||
await this.accessTokenRepository.insertToken({ token: accessToken, clientId, userId }, ctx);
|
||||
|
|
@ -104,12 +114,18 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
userId,
|
||||
expiresAt: Date.now() + this.REFRESH_TOKEN_EXPIRY_MS,
|
||||
scope: scopes,
|
||||
resource,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a refresh token into a fresh pair, reissuing the grant's scopes and
|
||||
* its resource. `resource`, when the client sends one, must name the resource
|
||||
* the grant was approved for (RFC 8707 §2.2).
|
||||
*/
|
||||
async validateAndRotateRefreshToken(
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
|
|
@ -130,6 +146,23 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
throw new InvalidGrantError('Invalid refresh token');
|
||||
}
|
||||
|
||||
// The resource approved at authorization bounds every later token request on
|
||||
// the grant (RFC 8707 §2.2), so a request naming another one is refused and a
|
||||
// request naming none reuses the grant's own — never the default resource.
|
||||
// Checked before the row is consumed, so a refused request leaves the refresh
|
||||
// token usable.
|
||||
const grantedResource = refreshTokenRecord.resource;
|
||||
if (
|
||||
resource &&
|
||||
!(await isSameProtectedResource(this.resourceRegistry, resource, grantedResource))
|
||||
) {
|
||||
this.logger.warn('Refresh token request denied: resource is outside the grant', {
|
||||
clientId,
|
||||
userId: refreshTokenRecord.userId,
|
||||
});
|
||||
throw new InvalidTargetError('Requested resource is not part of this grant');
|
||||
}
|
||||
|
||||
const numAffected = await this.refreshTokenRepository.deleteValidByToken(
|
||||
refreshToken,
|
||||
clientId,
|
||||
|
|
@ -142,10 +175,12 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
|
||||
const scopes = refreshTokenRecord.scope;
|
||||
|
||||
// Minted from the stored resource, not the requested spelling, so the binding
|
||||
// cannot drift across rotations.
|
||||
const { accessToken, refreshToken: newRefreshToken } = this.generateTokenPair(
|
||||
refreshTokenRecord.userId,
|
||||
clientId,
|
||||
resource,
|
||||
grantedResource,
|
||||
scopes,
|
||||
);
|
||||
|
||||
|
|
@ -161,6 +196,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
userId: refreshTokenRecord.userId,
|
||||
expiresAt: now + this.REFRESH_TOKEN_EXPIRY_MS,
|
||||
scope: scopes,
|
||||
resource: grantedResource,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
|
|
|||
31
packages/cli/src/modules/oauth-server/resource-identity.ts
Normal file
31
packages/cli/src/modules/oauth-server/resource-identity.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
|
||||
/**
|
||||
* Whether two RFC 8707 resource indicators name the same protected resource.
|
||||
*
|
||||
* String equality alone is too strict: the token endpoint deliberately echoes back
|
||||
* the caller's spelling when it names one of a resource's declared URLs (the
|
||||
* instance MCP server publishes several), so one grant can legitimately be referred
|
||||
* to by more than one URL.
|
||||
*
|
||||
* Fails closed — a URL that no longer resolves cannot be shown to name the resource
|
||||
* that was approved, so a deleted or unavailable resource never matches.
|
||||
*/
|
||||
export async function isSameProtectedResource(
|
||||
registry: ProtectedResourceRegistry,
|
||||
requested: string,
|
||||
granted: string,
|
||||
): Promise<boolean> {
|
||||
if (requested === granted) return true;
|
||||
|
||||
const [requestedResource, grantedResource] = await Promise.all([
|
||||
registry.getByResourceUrl(requested),
|
||||
registry.getByResourceUrl(granted),
|
||||
]);
|
||||
|
||||
return (
|
||||
requestedResource !== undefined &&
|
||||
grantedResource !== undefined &&
|
||||
requestedResource.id === grantedResource.id
|
||||
);
|
||||
}
|
||||
|
|
@ -102,7 +102,14 @@ const mintAccessToken = async (userId: string, resourceUrl: string) => {
|
|||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
|
||||
await tokenService.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
clientId,
|
||||
userId,
|
||||
[],
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user