@vfarcic/dot-ai 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/schema.js +27 -0
- package/dist/interfaces/error-response.d.ts +2 -1
- package/dist/interfaces/error-response.d.ts.map +1 -1
- package/dist/interfaces/error-response.js +6 -2
- package/dist/interfaces/mcp.d.ts +32 -12
- package/dist/interfaces/mcp.d.ts.map +1 -1
- package/dist/interfaces/mcp.js +327 -230
- package/dist/interfaces/oauth/dex-api.proto +304 -0
- package/dist/interfaces/oauth/dex-client.d.ts +41 -0
- package/dist/interfaces/oauth/dex-client.d.ts.map +1 -0
- package/dist/interfaces/oauth/dex-client.js +145 -0
- package/dist/interfaces/oauth/index.d.ts +6 -0
- package/dist/interfaces/oauth/index.d.ts.map +1 -0
- package/dist/interfaces/oauth/index.js +16 -0
- package/dist/interfaces/oauth/jwt.d.ts +37 -0
- package/dist/interfaces/oauth/jwt.d.ts.map +1 -0
- package/dist/interfaces/oauth/jwt.js +106 -0
- package/dist/interfaces/oauth/middleware.d.ts +27 -0
- package/dist/interfaces/oauth/middleware.d.ts.map +1 -0
- package/dist/interfaces/oauth/middleware.js +106 -0
- package/dist/interfaces/oauth/provider.d.ts +94 -0
- package/dist/interfaces/oauth/provider.d.ts.map +1 -0
- package/dist/interfaces/oauth/provider.js +373 -0
- package/dist/interfaces/oauth/types.d.ts +98 -0
- package/dist/interfaces/oauth/types.d.ts.map +1 -0
- package/dist/interfaces/oauth/types.js +8 -0
- package/dist/interfaces/oauth/user-management.d.ts +36 -0
- package/dist/interfaces/oauth/user-management.d.ts.map +1 -0
- package/dist/interfaces/oauth/user-management.js +156 -0
- package/dist/interfaces/request-context.d.ts +19 -0
- package/dist/interfaces/request-context.d.ts.map +1 -0
- package/dist/interfaces/request-context.js +20 -0
- package/dist/interfaces/rest-api.d.ts +12 -0
- package/dist/interfaces/rest-api.d.ts.map +1 -1
- package/dist/interfaces/rest-api.js +107 -0
- package/dist/interfaces/routes/index.d.ts.map +1 -1
- package/dist/interfaces/routes/index.js +38 -0
- package/dist/interfaces/schemas/common.d.ts +1 -0
- package/dist/interfaces/schemas/common.d.ts.map +1 -1
- package/dist/interfaces/schemas/common.js +1 -0
- package/dist/interfaces/schemas/index.d.ts +1 -0
- package/dist/interfaces/schemas/index.d.ts.map +1 -1
- package/dist/interfaces/schemas/index.js +16 -1
- package/dist/interfaces/schemas/users.d.ts +128 -0
- package/dist/interfaces/schemas/users.d.ts.map +1 -0
- package/dist/interfaces/schemas/users.js +64 -0
- package/dist/tools/remediate.d.ts +1 -1
- package/dist/tools/remediate.d.ts.map +1 -1
- package/dist/tools/remediate.js +38 -26
- package/dist/tools/version.d.ts.map +1 -1
- package/dist/tools/version.js +4 -0
- package/package.json +7 -2
- package/shared-prompts/prd-start.md +13 -0
- package/dist/interfaces/auth.d.ts +0 -26
- package/dist/interfaces/auth.d.ts.map +0 -1
- package/dist/interfaces/auth.js +0 -82
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-mode authentication middleware for PRD #380.
|
|
3
|
+
*
|
|
4
|
+
* Validates Bearer tokens in two modes:
|
|
5
|
+
* 1. JWT (HMAC-SHA256) — returns UserIdentity when valid
|
|
6
|
+
* 2. Legacy DOT_AI_AUTH_TOKEN — constant-time comparison fallback
|
|
7
|
+
*
|
|
8
|
+
* All existing tests continue to pass because the legacy path is preserved.
|
|
9
|
+
*/
|
|
10
|
+
import { IncomingMessage } from 'node:http';
|
|
11
|
+
import type { AuthResult } from './types';
|
|
12
|
+
/**
|
|
13
|
+
* Check Bearer token authentication (dual-mode: JWT + legacy token).
|
|
14
|
+
*
|
|
15
|
+
* Order: JWT verification first, legacy DOT_AI_AUTH_TOKEN fallback second.
|
|
16
|
+
* When neither auth method is configured, authentication is disabled (backward compatible).
|
|
17
|
+
*
|
|
18
|
+
* @param req - The incoming HTTP request
|
|
19
|
+
* @returns AuthResult with optional UserIdentity when JWT auth succeeds
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkBearerAuth(req: IncomingMessage): AuthResult;
|
|
22
|
+
/**
|
|
23
|
+
* Check if authentication is enabled.
|
|
24
|
+
* True when either legacy token or JWT secret is configured.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isAuthEnabled(): boolean;
|
|
27
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../../src/interfaces/oauth/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAG1C;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,eAAe,GAAG,UAAU,CAyFhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Dual-mode authentication middleware for PRD #380.
|
|
4
|
+
*
|
|
5
|
+
* Validates Bearer tokens in two modes:
|
|
6
|
+
* 1. JWT (HMAC-SHA256) — returns UserIdentity when valid
|
|
7
|
+
* 2. Legacy DOT_AI_AUTH_TOKEN — constant-time comparison fallback
|
|
8
|
+
*
|
|
9
|
+
* All existing tests continue to pass because the legacy path is preserved.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.checkBearerAuth = checkBearerAuth;
|
|
13
|
+
exports.isAuthEnabled = isAuthEnabled;
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const jwt_1 = require("./jwt");
|
|
16
|
+
/**
|
|
17
|
+
* Check Bearer token authentication (dual-mode: JWT + legacy token).
|
|
18
|
+
*
|
|
19
|
+
* Order: JWT verification first, legacy DOT_AI_AUTH_TOKEN fallback second.
|
|
20
|
+
* When neither auth method is configured, authentication is disabled (backward compatible).
|
|
21
|
+
*
|
|
22
|
+
* @param req - The incoming HTTP request
|
|
23
|
+
* @returns AuthResult with optional UserIdentity when JWT auth succeeds
|
|
24
|
+
*/
|
|
25
|
+
function checkBearerAuth(req) {
|
|
26
|
+
const legacyToken = process.env.DOT_AI_AUTH_TOKEN;
|
|
27
|
+
const jwtSecret = process.env.DOT_AI_JWT_SECRET;
|
|
28
|
+
// If no auth is configured, reject — DOT_AI_AUTH_TOKEN is required
|
|
29
|
+
if (!legacyToken && !jwtSecret) {
|
|
30
|
+
return {
|
|
31
|
+
authorized: false,
|
|
32
|
+
message: 'Authentication is not configured. Set DOT_AI_AUTH_TOKEN in your deployment.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Extract Authorization header
|
|
36
|
+
const rawAuthHeader = req.headers['authorization'];
|
|
37
|
+
if (!rawAuthHeader) {
|
|
38
|
+
return {
|
|
39
|
+
authorized: false,
|
|
40
|
+
message: 'Authentication required. Provide Authorization: Bearer <token> header.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Normalize header to string (handle array case)
|
|
44
|
+
const authHeader = Array.isArray(rawAuthHeader)
|
|
45
|
+
? (rawAuthHeader[0] ?? '')
|
|
46
|
+
: rawAuthHeader;
|
|
47
|
+
// Parse Bearer token (ReDoS-safe: indexOf instead of regex)
|
|
48
|
+
const trimmedHeader = authHeader.trim();
|
|
49
|
+
const spaceIndex = trimmedHeader.indexOf(' ');
|
|
50
|
+
if (spaceIndex === -1) {
|
|
51
|
+
return {
|
|
52
|
+
authorized: false,
|
|
53
|
+
message: 'Invalid authorization format. Expected: Bearer <token>',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const scheme = trimmedHeader.slice(0, spaceIndex);
|
|
57
|
+
const providedToken = trimmedHeader.slice(spaceIndex + 1).trim();
|
|
58
|
+
// Validate Bearer scheme (case-insensitive per RFC 7235)
|
|
59
|
+
if (scheme.toLowerCase() !== 'bearer') {
|
|
60
|
+
return {
|
|
61
|
+
authorized: false,
|
|
62
|
+
message: 'Invalid authorization format. Expected: Bearer <token>',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!providedToken) {
|
|
66
|
+
return { authorized: false, message: 'Bearer token is empty.' };
|
|
67
|
+
}
|
|
68
|
+
// Mode 1: Try JWT verification
|
|
69
|
+
const secret = jwtSecret || (0, jwt_1.getJwtSecret)();
|
|
70
|
+
const claims = (0, jwt_1.verifyJwt)(providedToken, secret);
|
|
71
|
+
if (claims) {
|
|
72
|
+
return {
|
|
73
|
+
authorized: true,
|
|
74
|
+
identity: {
|
|
75
|
+
userId: claims.sub,
|
|
76
|
+
email: claims.email,
|
|
77
|
+
groups: claims.groups ?? [],
|
|
78
|
+
source: 'oauth',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Mode 2: Fall back to legacy DOT_AI_AUTH_TOKEN comparison
|
|
83
|
+
if (legacyToken) {
|
|
84
|
+
const configuredBuffer = Buffer.from(legacyToken, 'utf8');
|
|
85
|
+
const providedBuffer = Buffer.from(providedToken, 'utf8');
|
|
86
|
+
if (configuredBuffer.length !== providedBuffer.length) {
|
|
87
|
+
// Dummy comparison to maintain constant time
|
|
88
|
+
(0, node_crypto_1.timingSafeEqual)(configuredBuffer, configuredBuffer);
|
|
89
|
+
return { authorized: false, message: 'Invalid authentication token.' };
|
|
90
|
+
}
|
|
91
|
+
if ((0, node_crypto_1.timingSafeEqual)(configuredBuffer, providedBuffer)) {
|
|
92
|
+
return {
|
|
93
|
+
authorized: true,
|
|
94
|
+
identity: { userId: 'anonymous', groups: [], source: 'token' },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { authorized: false, message: 'Invalid authentication token.' };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check if authentication is enabled.
|
|
102
|
+
* True when either legacy token or JWT secret is configured.
|
|
103
|
+
*/
|
|
104
|
+
function isAuthEnabled() {
|
|
105
|
+
return !!process.env.DOT_AI_AUTH_TOKEN || !!process.env.DOT_AI_JWT_SECRET;
|
|
106
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP SDK OAuth Server Provider for PRD #380.
|
|
3
|
+
*
|
|
4
|
+
* Implements the SDK's OAuthServerProvider interface with:
|
|
5
|
+
* - In-memory client store (clients re-register on restart per MCP spec)
|
|
6
|
+
* - Dual-mode token verification (JWT + legacy DOT_AI_AUTH_TOKEN)
|
|
7
|
+
* - Dex OIDC integration for authorize/callback/token flow (Task 2.3)
|
|
8
|
+
*/
|
|
9
|
+
import type { Request, Response } from 'express';
|
|
10
|
+
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
|
|
11
|
+
import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
|
12
|
+
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
|
13
|
+
import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
14
|
+
/**
|
|
15
|
+
* In-memory client store for OAuth registered clients.
|
|
16
|
+
* Clients re-register on server restart per the MCP Authorization spec.
|
|
17
|
+
*/
|
|
18
|
+
export declare class DotAIClientsStore implements OAuthRegisteredClientsStore {
|
|
19
|
+
private clients;
|
|
20
|
+
getClient(clientId: string): OAuthClientInformationFull | undefined;
|
|
21
|
+
registerClient(client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>): OAuthClientInformationFull;
|
|
22
|
+
/** Clear all registered clients. For testing only. @internal */
|
|
23
|
+
_clearClients(): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* OAuth Server Provider for dot-ai.
|
|
27
|
+
*
|
|
28
|
+
* Acts as the OAuth Authorization Server for MCP clients. On authorize,
|
|
29
|
+
* redirects the browser to Dex for authentication, then exchanges the
|
|
30
|
+
* Dex code for an ID token and issues a dot-ai JWT.
|
|
31
|
+
*
|
|
32
|
+
* Token verification supports dual-mode: JWT first, legacy token fallback.
|
|
33
|
+
*/
|
|
34
|
+
export declare class DotAIOAuthProvider implements OAuthServerProvider {
|
|
35
|
+
readonly clientsStore: DotAIClientsStore;
|
|
36
|
+
private pendingRequests;
|
|
37
|
+
private authCodes;
|
|
38
|
+
private dexConfig;
|
|
39
|
+
private dotAiExternalUrl;
|
|
40
|
+
private pruneTimer;
|
|
41
|
+
constructor();
|
|
42
|
+
/**
|
|
43
|
+
* Periodically remove expired entries from pendingRequests and authCodes
|
|
44
|
+
* to prevent unbounded memory growth from abandoned OAuth flows.
|
|
45
|
+
*/
|
|
46
|
+
private startPruning;
|
|
47
|
+
/** Remove expired pending requests and authorization codes. */
|
|
48
|
+
private pruneExpired;
|
|
49
|
+
/** Stop the pruning timer. For testing only. @internal */
|
|
50
|
+
_stopPruning(): void;
|
|
51
|
+
private loadDexConfig;
|
|
52
|
+
/**
|
|
53
|
+
* Start the authorization flow by redirecting the browser to Dex.
|
|
54
|
+
*
|
|
55
|
+
* Stores the pending auth request (PKCE challenge, redirect URI, state)
|
|
56
|
+
* keyed by a random session ID, then encodes sessionId|originalState
|
|
57
|
+
* in the Dex state param so the callback can recover the pending request.
|
|
58
|
+
*/
|
|
59
|
+
authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Return the PKCE code challenge for a given authorization code.
|
|
62
|
+
*
|
|
63
|
+
* Called by the SDK's tokenHandler BEFORE exchangeAuthorizationCode.
|
|
64
|
+
* Do NOT delete the code here — it is consumed in exchangeAuthorizationCode.
|
|
65
|
+
*/
|
|
66
|
+
challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Exchange a dot-ai authorization code for a JWT access token.
|
|
69
|
+
*
|
|
70
|
+
* Called by the SDK's tokenHandler AFTER PKCE verification passes.
|
|
71
|
+
* Consumes the authorization code (one-time use) and signs a JWT
|
|
72
|
+
* containing the user's identity from the Dex ID token.
|
|
73
|
+
*/
|
|
74
|
+
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, _resource?: URL): Promise<OAuthTokens>;
|
|
75
|
+
exchangeRefreshToken(_client: OAuthClientInformationFull, _refreshToken: string, _scopes?: string[], _resource?: URL): Promise<OAuthTokens>;
|
|
76
|
+
/**
|
|
77
|
+
* Handle the Dex OIDC callback after user authenticates.
|
|
78
|
+
*
|
|
79
|
+
* Receives the redirect from Dex with ?code=DEX_CODE&state=sessionId|originalState.
|
|
80
|
+
* Exchanges the Dex code for an ID token, extracts user identity,
|
|
81
|
+
* creates a dot-ai authorization code, and redirects to the MCP client.
|
|
82
|
+
*/
|
|
83
|
+
handleCallback(req: Request, res: Response): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Verify an access token (dual-mode: JWT + legacy token).
|
|
86
|
+
*
|
|
87
|
+
* 1. If no auth configured → anonymous access (backward compatible)
|
|
88
|
+
* 2. Try JWT verification → returns AuthInfo with identity in `extra`
|
|
89
|
+
* 3. Fall back to legacy DOT_AI_AUTH_TOKEN → returns AuthInfo without identity
|
|
90
|
+
* 4. Throw InvalidTokenError on failure
|
|
91
|
+
*/
|
|
92
|
+
verifyAccessToken(token: string): Promise<AuthInfo>;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../../src/interfaces/oauth/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEjD,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,kDAAkD,CAAC;AACpG,OAAO,KAAK,EACV,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,mDAAmD,CAAC;AAC3D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gDAAgD,CAAC;AAM/E,OAAO,KAAK,EACV,0BAA0B,EAC1B,WAAW,EACZ,MAAM,0CAA0C,CAAC;AAelD;;;GAGG;AACH,qBAAa,iBAAkB,YAAW,2BAA2B;IACnE,OAAO,CAAC,OAAO,CAAiD;IAEhE,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,0BAA0B,GAAG,SAAS;IAInE,cAAc,CACZ,MAAM,EAAE,IAAI,CAAC,0BAA0B,EAAE,WAAW,GAAG,qBAAqB,CAAC,GAC5E,0BAA0B;IAQ7B,gEAAgE;IAChE,aAAa,IAAI,IAAI;CAGtB;AAWD;;;;;;;;GAQG;AACH,qBAAa,kBAAmB,YAAW,mBAAmB;IAC5D,QAAQ,CAAC,YAAY,EAAE,iBAAiB,CAAC;IACzC,OAAO,CAAC,eAAe,CAAyC;IAChE,OAAO,CAAC,SAAS,CAAwC;IACzD,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,UAAU,CAA+C;;IASjE;;;OAGG;IACH,OAAO,CAAC,YAAY;IAMpB,+DAA+D;IAC/D,OAAO,CAAC,YAAY;IAcpB,0DAA0D;IAC1D,YAAY,IAAI,IAAI;IAOpB,OAAO,CAAC,aAAa;IAYrB;;;;;;OAMG;IACG,SAAS,CACb,MAAM,EAAE,0BAA0B,EAClC,MAAM,EAAE,mBAAmB,EAC3B,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,IAAI,CAAC;IAgChB;;;;;OAKG;IACG,6BAA6B,CACjC,MAAM,EAAE,0BAA0B,EAClC,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,MAAM,CAAC;IAoBlB;;;;;;OAMG;IACG,yBAAyB,CAC7B,MAAM,EAAE,0BAA0B,EAClC,iBAAiB,EAAE,MAAM,EACzB,aAAa,CAAC,EAAE,MAAM,EACtB,WAAW,CAAC,EAAE,MAAM,EACpB,SAAS,CAAC,EAAE,GAAG,GACd,OAAO,CAAC,WAAW,CAAC;IA6CjB,oBAAoB,CACxB,OAAO,EAAE,0BAA0B,EACnC,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,MAAM,EAAE,EAClB,SAAS,CAAC,EAAE,GAAG,GACd,OAAO,CAAC,WAAW,CAAC;IAIvB;;;;;;OAMG;IACG,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IA6FhE;;;;;;;OAOG;IACG,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;CA2D1D"}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MCP SDK OAuth Server Provider for PRD #380.
|
|
4
|
+
*
|
|
5
|
+
* Implements the SDK's OAuthServerProvider interface with:
|
|
6
|
+
* - In-memory client store (clients re-register on restart per MCP spec)
|
|
7
|
+
* - Dual-mode token verification (JWT + legacy DOT_AI_AUTH_TOKEN)
|
|
8
|
+
* - Dex OIDC integration for authorize/callback/token flow (Task 2.3)
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.DotAIOAuthProvider = exports.DotAIClientsStore = void 0;
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
const errors_js_1 = require("@modelcontextprotocol/sdk/server/auth/errors.js");
|
|
14
|
+
const jwt_1 = require("./jwt");
|
|
15
|
+
const dex_client_1 = require("./dex-client");
|
|
16
|
+
/**
|
|
17
|
+
* Safely extract a single string from an Express query parameter.
|
|
18
|
+
* Query params can be string, string[], or undefined when tampered.
|
|
19
|
+
*/
|
|
20
|
+
function extractQueryParam(value) {
|
|
21
|
+
if (typeof value === 'string')
|
|
22
|
+
return value;
|
|
23
|
+
if (Array.isArray(value) && typeof value[0] === 'string')
|
|
24
|
+
return value[0];
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* In-memory client store for OAuth registered clients.
|
|
29
|
+
* Clients re-register on server restart per the MCP Authorization spec.
|
|
30
|
+
*/
|
|
31
|
+
class DotAIClientsStore {
|
|
32
|
+
clients = new Map();
|
|
33
|
+
getClient(clientId) {
|
|
34
|
+
return this.clients.get(clientId);
|
|
35
|
+
}
|
|
36
|
+
registerClient(client) {
|
|
37
|
+
// SDK pre-populates client_id, client_secret, timestamps before calling this.
|
|
38
|
+
// The Omit type is the interface contract, but the actual object has all fields.
|
|
39
|
+
const fullClient = client;
|
|
40
|
+
this.clients.set(fullClient.client_id, fullClient);
|
|
41
|
+
return fullClient;
|
|
42
|
+
}
|
|
43
|
+
/** Clear all registered clients. For testing only. @internal */
|
|
44
|
+
_clearClients() {
|
|
45
|
+
this.clients.clear();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.DotAIClientsStore = DotAIClientsStore;
|
|
49
|
+
/** Max age for pending auth requests (10 minutes). */
|
|
50
|
+
const PENDING_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
51
|
+
/** Max age for authorization codes (5 minutes). */
|
|
52
|
+
const AUTH_CODE_TTL_MS = 5 * 60 * 1000;
|
|
53
|
+
/** Separator between session ID and original state in the Dex state param. */
|
|
54
|
+
const STATE_SEPARATOR = '|';
|
|
55
|
+
/**
|
|
56
|
+
* OAuth Server Provider for dot-ai.
|
|
57
|
+
*
|
|
58
|
+
* Acts as the OAuth Authorization Server for MCP clients. On authorize,
|
|
59
|
+
* redirects the browser to Dex for authentication, then exchanges the
|
|
60
|
+
* Dex code for an ID token and issues a dot-ai JWT.
|
|
61
|
+
*
|
|
62
|
+
* Token verification supports dual-mode: JWT first, legacy token fallback.
|
|
63
|
+
*/
|
|
64
|
+
class DotAIOAuthProvider {
|
|
65
|
+
clientsStore;
|
|
66
|
+
pendingRequests = new Map();
|
|
67
|
+
authCodes = new Map();
|
|
68
|
+
dexConfig;
|
|
69
|
+
dotAiExternalUrl;
|
|
70
|
+
pruneTimer = null;
|
|
71
|
+
constructor() {
|
|
72
|
+
this.clientsStore = new DotAIClientsStore();
|
|
73
|
+
this.dexConfig = this.loadDexConfig();
|
|
74
|
+
this.dotAiExternalUrl = (process.env.DOT_AI_EXTERNAL_URL || '').replace(/\/$/, '');
|
|
75
|
+
this.startPruning();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Periodically remove expired entries from pendingRequests and authCodes
|
|
79
|
+
* to prevent unbounded memory growth from abandoned OAuth flows.
|
|
80
|
+
*/
|
|
81
|
+
startPruning() {
|
|
82
|
+
// Run every 60 seconds — lightweight scan of small maps
|
|
83
|
+
this.pruneTimer = setInterval(() => this.pruneExpired(), 60_000);
|
|
84
|
+
this.pruneTimer.unref(); // Don't prevent Node.js from exiting
|
|
85
|
+
}
|
|
86
|
+
/** Remove expired pending requests and authorization codes. */
|
|
87
|
+
pruneExpired() {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
for (const [key, req] of this.pendingRequests) {
|
|
90
|
+
if (now - req.createdAt > PENDING_REQUEST_TTL_MS) {
|
|
91
|
+
this.pendingRequests.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [key, code] of this.authCodes) {
|
|
95
|
+
if (now > code.expiresAt) {
|
|
96
|
+
this.authCodes.delete(key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Stop the pruning timer. For testing only. @internal */
|
|
101
|
+
_stopPruning() {
|
|
102
|
+
if (this.pruneTimer) {
|
|
103
|
+
clearInterval(this.pruneTimer);
|
|
104
|
+
this.pruneTimer = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
loadDexConfig() {
|
|
108
|
+
const issuerUrl = process.env.DEX_ISSUER_URL;
|
|
109
|
+
const clientId = process.env.DEX_CLIENT_ID;
|
|
110
|
+
const clientSecret = process.env.DEX_CLIENT_SECRET;
|
|
111
|
+
if (!issuerUrl || !clientId || !clientSecret) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const tokenEndpoint = process.env.DEX_TOKEN_ENDPOINT
|
|
115
|
+
|| `${issuerUrl.replace(/\/$/, '')}/token`;
|
|
116
|
+
return { issuerUrl, tokenEndpoint, clientId, clientSecret };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Start the authorization flow by redirecting the browser to Dex.
|
|
120
|
+
*
|
|
121
|
+
* Stores the pending auth request (PKCE challenge, redirect URI, state)
|
|
122
|
+
* keyed by a random session ID, then encodes sessionId|originalState
|
|
123
|
+
* in the Dex state param so the callback can recover the pending request.
|
|
124
|
+
*/
|
|
125
|
+
async authorize(client, params, res) {
|
|
126
|
+
if (!this.dexConfig) {
|
|
127
|
+
throw new errors_js_1.ServerError('Dex not configured (set DEX_ISSUER_URL, DEX_CLIENT_ID, DEX_CLIENT_SECRET)');
|
|
128
|
+
}
|
|
129
|
+
if (!this.dotAiExternalUrl) {
|
|
130
|
+
throw new errors_js_1.ServerError('DOT_AI_EXTERNAL_URL is required for OAuth. Set it to the external URL of the dot-ai server.');
|
|
131
|
+
}
|
|
132
|
+
const sessionId = (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
133
|
+
const pending = {
|
|
134
|
+
clientId: client.client_id,
|
|
135
|
+
redirectUri: params.redirectUri,
|
|
136
|
+
codeChallenge: params.codeChallenge,
|
|
137
|
+
codeChallengeMethod: 'S256',
|
|
138
|
+
state: params.state ?? '',
|
|
139
|
+
createdAt: Date.now(),
|
|
140
|
+
};
|
|
141
|
+
this.pendingRequests.set(sessionId, pending);
|
|
142
|
+
const dexState = `${sessionId}${STATE_SEPARATOR}${params.state ?? ''}`;
|
|
143
|
+
const callbackUrl = `${this.dotAiExternalUrl}/callback`;
|
|
144
|
+
const dexAuthUrl = (0, dex_client_1.buildAuthorizeUrl)(this.dexConfig, {
|
|
145
|
+
redirectUri: callbackUrl,
|
|
146
|
+
state: dexState,
|
|
147
|
+
});
|
|
148
|
+
res.redirect(302, dexAuthUrl);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Return the PKCE code challenge for a given authorization code.
|
|
152
|
+
*
|
|
153
|
+
* Called by the SDK's tokenHandler BEFORE exchangeAuthorizationCode.
|
|
154
|
+
* Do NOT delete the code here — it is consumed in exchangeAuthorizationCode.
|
|
155
|
+
*/
|
|
156
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
157
|
+
const record = this.authCodes.get(authorizationCode);
|
|
158
|
+
if (!record) {
|
|
159
|
+
throw new errors_js_1.InvalidGrantError('Authorization code not found or expired');
|
|
160
|
+
}
|
|
161
|
+
if (Date.now() > record.expiresAt) {
|
|
162
|
+
this.authCodes.delete(authorizationCode);
|
|
163
|
+
throw new errors_js_1.InvalidGrantError('Authorization code expired');
|
|
164
|
+
}
|
|
165
|
+
// Verify the code was issued to the requesting client (RFC 6749 §4.1.3)
|
|
166
|
+
if (record.clientId !== client.client_id) {
|
|
167
|
+
this.authCodes.delete(authorizationCode);
|
|
168
|
+
throw new errors_js_1.InvalidGrantError('Authorization code was not issued to this client');
|
|
169
|
+
}
|
|
170
|
+
return record.codeChallenge;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Exchange a dot-ai authorization code for a JWT access token.
|
|
174
|
+
*
|
|
175
|
+
* Called by the SDK's tokenHandler AFTER PKCE verification passes.
|
|
176
|
+
* Consumes the authorization code (one-time use) and signs a JWT
|
|
177
|
+
* containing the user's identity from the Dex ID token.
|
|
178
|
+
*/
|
|
179
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, _resource) {
|
|
180
|
+
const record = this.authCodes.get(authorizationCode);
|
|
181
|
+
if (!record) {
|
|
182
|
+
throw new errors_js_1.InvalidGrantError('Authorization code not found or expired');
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() > record.expiresAt) {
|
|
185
|
+
this.authCodes.delete(authorizationCode);
|
|
186
|
+
throw new errors_js_1.InvalidGrantError('Authorization code expired');
|
|
187
|
+
}
|
|
188
|
+
// Verify client_id matches (RFC 6749 §4.1.3)
|
|
189
|
+
if (record.clientId !== client.client_id) {
|
|
190
|
+
this.authCodes.delete(authorizationCode);
|
|
191
|
+
throw new errors_js_1.InvalidGrantError('Authorization code was not issued to this client');
|
|
192
|
+
}
|
|
193
|
+
// Verify redirect_uri matches the original request (RFC 6749 §4.1.3)
|
|
194
|
+
if (redirectUri && redirectUri !== record.redirectUri) {
|
|
195
|
+
this.authCodes.delete(authorizationCode);
|
|
196
|
+
throw new errors_js_1.InvalidGrantError('redirect_uri does not match the original authorization request');
|
|
197
|
+
}
|
|
198
|
+
// Consume the authorization code (one-time use)
|
|
199
|
+
this.authCodes.delete(authorizationCode);
|
|
200
|
+
const now = Math.floor(Date.now() / 1000);
|
|
201
|
+
const expiresIn = 3600; // 1 hour
|
|
202
|
+
const secret = (0, jwt_1.getJwtSecret)();
|
|
203
|
+
const accessToken = (0, jwt_1.signJwt)({
|
|
204
|
+
sub: record.userIdentity.userId,
|
|
205
|
+
email: record.userIdentity.email,
|
|
206
|
+
groups: record.userIdentity.groups,
|
|
207
|
+
iat: now,
|
|
208
|
+
exp: now + expiresIn,
|
|
209
|
+
}, secret);
|
|
210
|
+
return {
|
|
211
|
+
access_token: accessToken,
|
|
212
|
+
token_type: 'bearer',
|
|
213
|
+
expires_in: expiresIn,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async exchangeRefreshToken(_client, _refreshToken, _scopes, _resource) {
|
|
217
|
+
throw new errors_js_1.ServerError('Refresh tokens not supported');
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Handle the Dex OIDC callback after user authenticates.
|
|
221
|
+
*
|
|
222
|
+
* Receives the redirect from Dex with ?code=DEX_CODE&state=sessionId|originalState.
|
|
223
|
+
* Exchanges the Dex code for an ID token, extracts user identity,
|
|
224
|
+
* creates a dot-ai authorization code, and redirects to the MCP client.
|
|
225
|
+
*/
|
|
226
|
+
async handleCallback(req, res) {
|
|
227
|
+
const dexCode = extractQueryParam(req.query.code);
|
|
228
|
+
const encodedState = extractQueryParam(req.query.state);
|
|
229
|
+
const error = extractQueryParam(req.query.error);
|
|
230
|
+
if (error) {
|
|
231
|
+
const sessionId = encodedState?.split(STATE_SEPARATOR)[0];
|
|
232
|
+
const pending = sessionId ? this.pendingRequests.get(sessionId) : undefined;
|
|
233
|
+
if (pending) {
|
|
234
|
+
this.pendingRequests.delete(sessionId);
|
|
235
|
+
const errUrl = new URL(pending.redirectUri);
|
|
236
|
+
errUrl.searchParams.set('error', 'access_denied');
|
|
237
|
+
errUrl.searchParams.set('error_description', extractQueryParam(req.query.error_description) ?? 'Authentication failed');
|
|
238
|
+
if (pending.state)
|
|
239
|
+
errUrl.searchParams.set('state', pending.state);
|
|
240
|
+
res.redirect(302, errUrl.toString());
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
res.status(400).send('Authentication failed and no pending session found');
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!dexCode || !encodedState) {
|
|
248
|
+
res.status(400).send('Missing code or state parameter');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const separatorIndex = encodedState.indexOf(STATE_SEPARATOR);
|
|
252
|
+
if (separatorIndex === -1) {
|
|
253
|
+
res.status(400).send('Invalid state parameter');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const sessionId = encodedState.slice(0, separatorIndex);
|
|
257
|
+
const originalState = encodedState.slice(separatorIndex + 1);
|
|
258
|
+
const pending = this.pendingRequests.get(sessionId);
|
|
259
|
+
if (!pending) {
|
|
260
|
+
res.status(400).send('No pending auth request for this session (expired or invalid)');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (Date.now() - pending.createdAt > PENDING_REQUEST_TTL_MS) {
|
|
264
|
+
this.pendingRequests.delete(sessionId);
|
|
265
|
+
res.status(400).send('Auth request expired');
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.pendingRequests.delete(sessionId);
|
|
269
|
+
if (!this.dexConfig) {
|
|
270
|
+
res.status(500).send('Dex not configured');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const callbackUrl = `${this.dotAiExternalUrl}/callback`;
|
|
275
|
+
const { idToken } = await (0, dex_client_1.exchangeDexCode)(this.dexConfig, dexCode, callbackUrl);
|
|
276
|
+
const claims = (0, dex_client_1.parseIdToken)(idToken);
|
|
277
|
+
const userIdentity = {
|
|
278
|
+
userId: claims.sub,
|
|
279
|
+
email: claims.email,
|
|
280
|
+
groups: claims.groups ?? [],
|
|
281
|
+
source: 'oauth',
|
|
282
|
+
};
|
|
283
|
+
const dotAiCode = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
284
|
+
const authCode = {
|
|
285
|
+
code: dotAiCode,
|
|
286
|
+
clientId: pending.clientId,
|
|
287
|
+
redirectUri: pending.redirectUri,
|
|
288
|
+
codeChallenge: pending.codeChallenge,
|
|
289
|
+
codeChallengeMethod: 'S256',
|
|
290
|
+
userIdentity,
|
|
291
|
+
createdAt: Date.now(),
|
|
292
|
+
expiresAt: Date.now() + AUTH_CODE_TTL_MS,
|
|
293
|
+
};
|
|
294
|
+
this.authCodes.set(dotAiCode, authCode);
|
|
295
|
+
const redirectUrl = new URL(pending.redirectUri);
|
|
296
|
+
redirectUrl.searchParams.set('code', dotAiCode);
|
|
297
|
+
if (originalState)
|
|
298
|
+
redirectUrl.searchParams.set('state', originalState);
|
|
299
|
+
res.redirect(302, redirectUrl.toString());
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
const errUrl = new URL(pending.redirectUri);
|
|
303
|
+
errUrl.searchParams.set('error', 'server_error');
|
|
304
|
+
errUrl.searchParams.set('error_description', 'Failed to exchange code with identity provider');
|
|
305
|
+
if (originalState)
|
|
306
|
+
errUrl.searchParams.set('state', originalState);
|
|
307
|
+
res.redirect(302, errUrl.toString());
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Verify an access token (dual-mode: JWT + legacy token).
|
|
312
|
+
*
|
|
313
|
+
* 1. If no auth configured → anonymous access (backward compatible)
|
|
314
|
+
* 2. Try JWT verification → returns AuthInfo with identity in `extra`
|
|
315
|
+
* 3. Fall back to legacy DOT_AI_AUTH_TOKEN → returns AuthInfo without identity
|
|
316
|
+
* 4. Throw InvalidTokenError on failure
|
|
317
|
+
*/
|
|
318
|
+
async verifyAccessToken(token) {
|
|
319
|
+
const legacyToken = process.env.DOT_AI_AUTH_TOKEN;
|
|
320
|
+
const jwtSecretEnv = process.env.DOT_AI_JWT_SECRET;
|
|
321
|
+
// No auth configured → allow all (backward compatible)
|
|
322
|
+
if (!legacyToken && !jwtSecretEnv) {
|
|
323
|
+
return {
|
|
324
|
+
token,
|
|
325
|
+
clientId: 'anonymous',
|
|
326
|
+
scopes: [],
|
|
327
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// Mode 1: JWT verification
|
|
331
|
+
const secret = jwtSecretEnv || (0, jwt_1.getJwtSecret)();
|
|
332
|
+
const claims = (0, jwt_1.verifyJwt)(token, secret);
|
|
333
|
+
if (claims) {
|
|
334
|
+
return {
|
|
335
|
+
token,
|
|
336
|
+
clientId: claims.sub,
|
|
337
|
+
scopes: [],
|
|
338
|
+
expiresAt: claims.exp,
|
|
339
|
+
extra: {
|
|
340
|
+
identity: {
|
|
341
|
+
userId: claims.sub,
|
|
342
|
+
email: claims.email,
|
|
343
|
+
groups: claims.groups ?? [],
|
|
344
|
+
source: 'oauth',
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// Mode 2: Legacy DOT_AI_AUTH_TOKEN comparison
|
|
350
|
+
if (legacyToken) {
|
|
351
|
+
const configuredBuffer = Buffer.from(legacyToken, 'utf8');
|
|
352
|
+
const providedBuffer = Buffer.from(token, 'utf8');
|
|
353
|
+
let isMatch = false;
|
|
354
|
+
if (configuredBuffer.length === providedBuffer.length) {
|
|
355
|
+
isMatch = (0, node_crypto_1.timingSafeEqual)(configuredBuffer, providedBuffer);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Dummy comparison to maintain constant time
|
|
359
|
+
(0, node_crypto_1.timingSafeEqual)(configuredBuffer, configuredBuffer);
|
|
360
|
+
}
|
|
361
|
+
if (isMatch) {
|
|
362
|
+
return {
|
|
363
|
+
token,
|
|
364
|
+
clientId: 'legacy',
|
|
365
|
+
scopes: [],
|
|
366
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
throw new errors_js_1.InvalidTokenError('Invalid authentication token.');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
exports.DotAIOAuthProvider = DotAIOAuthProvider;
|