@thuyphuocthinh/mcp-auth-proxy 0.0.0-stage → 1.0.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/bin/cli.js +2 -0
  4. package/dist/src/index.d.ts +13 -0
  5. package/dist/src/index.js +183 -0
  6. package/dist/src/oauth/browser.d.ts +5 -0
  7. package/dist/src/oauth/browser.js +27 -0
  8. package/dist/src/oauth/client-registration.d.ts +13 -0
  9. package/dist/src/oauth/client-registration.js +31 -0
  10. package/dist/src/oauth/discovery.d.ts +9 -0
  11. package/dist/src/oauth/discovery.js +42 -0
  12. package/dist/src/oauth/pkce.d.ts +7 -0
  13. package/dist/src/oauth/pkce.js +17 -0
  14. package/dist/src/oauth/server.d.ts +10 -0
  15. package/dist/src/oauth/server.js +84 -0
  16. package/dist/src/proxy.d.ts +30 -0
  17. package/dist/src/proxy.js +126 -0
  18. package/dist/src/store/token-store.d.ts +29 -0
  19. package/dist/src/store/token-store.js +99 -0
  20. package/dist/tests/cli.test.d.ts +1 -0
  21. package/dist/tests/cli.test.js +50 -0
  22. package/dist/tests/oauth/browser.test.d.ts +1 -0
  23. package/dist/tests/oauth/browser.test.js +35 -0
  24. package/dist/tests/oauth/client-registration.test.d.ts +1 -0
  25. package/dist/tests/oauth/client-registration.test.js +72 -0
  26. package/dist/tests/oauth/discovery.test.d.ts +1 -0
  27. package/dist/tests/oauth/discovery.test.js +94 -0
  28. package/dist/tests/oauth/pkce.test.d.ts +1 -0
  29. package/dist/tests/oauth/pkce.test.js +40 -0
  30. package/dist/tests/oauth/server.test.d.ts +1 -0
  31. package/dist/tests/oauth/server.test.js +53 -0
  32. package/dist/tests/proxy.test.d.ts +1 -0
  33. package/dist/tests/proxy.test.js +153 -0
  34. package/dist/tests/smoke.test.d.ts +1 -0
  35. package/dist/tests/smoke.test.js +8 -0
  36. package/dist/tests/store/token-store.test.d.ts +1 -0
  37. package/dist/tests/store/token-store.test.js +160 -0
  38. package/package.json +50 -4
@@ -0,0 +1,126 @@
1
+ import readline from 'node:readline';
2
+ /**
3
+ * Extracts JSON-RPC message payloads from SSE (text/event-stream) response chunks.
4
+ * Conforms to standard Model Context Protocol (MCP) Streamable HTTP specification:
5
+ * event: message
6
+ * data: {"jsonrpc":"2.0", ...}
7
+ */
8
+ export function extractJsonRpcFromSse(responseText) {
9
+ const results = [];
10
+ const lines = responseText.split('\n');
11
+ for (const line of lines) {
12
+ const trimmed = line.trim();
13
+ if (trimmed.startsWith('data:')) {
14
+ const dataContent = trimmed.substring(5).trim();
15
+ if (dataContent) {
16
+ results.push(dataContent);
17
+ }
18
+ }
19
+ }
20
+ // Fallback if server returned raw non-SSE JSON
21
+ if (results.length === 0 && responseText.trim().startsWith('{')) {
22
+ results.push(responseText.trim());
23
+ }
24
+ return results;
25
+ }
26
+ /**
27
+ * Forwards a single JSON-RPC line from Stdio to the Remote HTTP MCP Server.
28
+ */
29
+ export async function forwardJsonRpc(options) {
30
+ const { line, remoteUrl, headers, fetchFn = fetch } = options;
31
+ let requestId = null;
32
+ try {
33
+ const parsed = JSON.parse(line);
34
+ requestId = parsed.id ?? null;
35
+ }
36
+ catch {
37
+ // Malformed JSON input from client
38
+ }
39
+ try {
40
+ const response = await fetchFn(remoteUrl, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ 'Accept': 'application/json, text/event-stream',
45
+ ...headers
46
+ },
47
+ body: line
48
+ });
49
+ if (!response.ok) {
50
+ const errorText = await response.text();
51
+ return [
52
+ JSON.stringify({
53
+ jsonrpc: '2.0',
54
+ id: requestId,
55
+ error: {
56
+ code: -32603,
57
+ message: `Remote MCP server returned HTTP ${response.status}: ${errorText}`
58
+ }
59
+ })
60
+ ];
61
+ }
62
+ const responseText = await response.text();
63
+ return extractJsonRpcFromSse(responseText);
64
+ }
65
+ catch (err) {
66
+ return [
67
+ JSON.stringify({
68
+ jsonrpc: '2.0',
69
+ id: requestId,
70
+ error: {
71
+ code: -32603,
72
+ message: `Failed to connect to remote MCP server: ${err.message}`
73
+ }
74
+ })
75
+ ];
76
+ }
77
+ }
78
+ /**
79
+ * Starts Stdio bidirectional proxy loop.
80
+ * Reads newline-delimited JSON-RPC from stdin, attaches valid token, and writes to stdout.
81
+ */
82
+ export async function startProxy(options) {
83
+ const { remoteUrl, getToken, extraHeaders = {}, input = process.stdin, output = process.stdout, fetchFn = fetch, logError = console.error } = options;
84
+ const rl = readline.createInterface({
85
+ input,
86
+ output: undefined,
87
+ terminal: false
88
+ });
89
+ for await (const rawLine of rl) {
90
+ const line = rawLine.trim();
91
+ if (!line)
92
+ continue;
93
+ try {
94
+ const token = await getToken();
95
+ const headers = {
96
+ ...extraHeaders
97
+ };
98
+ if (token) {
99
+ headers['Authorization'] = `Bearer ${token}`;
100
+ }
101
+ const results = await forwardJsonRpc({
102
+ line,
103
+ remoteUrl,
104
+ headers,
105
+ fetchFn
106
+ });
107
+ for (const res of results) {
108
+ output.write(res + '\n');
109
+ }
110
+ }
111
+ catch (err) {
112
+ logError(`[-] Proxy error: ${err.message}`);
113
+ let requestId = null;
114
+ try {
115
+ const parsed = JSON.parse(line);
116
+ requestId = parsed.id ?? null;
117
+ }
118
+ catch { }
119
+ output.write(JSON.stringify({
120
+ jsonrpc: '2.0',
121
+ id: requestId,
122
+ error: { code: -32603, message: err.message }
123
+ }) + '\n');
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,29 @@
1
+ export interface StoredToken {
2
+ accessToken: string;
3
+ refreshToken?: string;
4
+ expiresAt: number;
5
+ tokenType?: string;
6
+ scope?: string;
7
+ }
8
+ export declare class TokenStore {
9
+ private storePath;
10
+ constructor(customPath?: string);
11
+ saveToken(resource: string, token: StoredToken): void;
12
+ getToken(resource: string): StoredToken | null;
13
+ isExpiringSoon(token: StoredToken, bufferMs?: number): boolean;
14
+ refreshToken(options: {
15
+ resource: string;
16
+ tokenEndpoint: string;
17
+ clientId: string;
18
+ fetchFn?: typeof fetch;
19
+ log?: (msg: string) => void;
20
+ }): Promise<string | null>;
21
+ getOrRefreshToken(options: {
22
+ resource: string;
23
+ tokenEndpoint: string;
24
+ clientId: string;
25
+ fetchFn?: typeof fetch;
26
+ log?: (msg: string) => void;
27
+ }): Promise<string | null>;
28
+ private readAll;
29
+ }
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ export class TokenStore {
5
+ storePath;
6
+ constructor(customPath) {
7
+ this.storePath =
8
+ customPath || path.join(os.homedir(), '.mcp-oauth-proxy', 'tokens.json');
9
+ }
10
+ saveToken(resource, token) {
11
+ const data = this.readAll();
12
+ data[resource] = token;
13
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
14
+ fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf-8');
15
+ }
16
+ getToken(resource) {
17
+ const data = this.readAll();
18
+ return data[resource] || null;
19
+ }
20
+ isExpiringSoon(token, bufferMs = 300_000) {
21
+ // Default buffer: 5 minutes (300,000ms)
22
+ return Date.now() >= token.expiresAt - bufferMs;
23
+ }
24
+ async refreshToken(options) {
25
+ const { resource, tokenEndpoint, clientId, fetchFn = fetch, log = console.log } = options;
26
+ const current = this.getToken(resource);
27
+ if (!current?.refreshToken) {
28
+ log(`[-] No refresh_token available for resource: ${resource}`);
29
+ return null;
30
+ }
31
+ try {
32
+ log(`[*] Performing silent background token refresh...`);
33
+ const response = await fetchFn(tokenEndpoint, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
36
+ body: new URLSearchParams({
37
+ grant_type: 'refresh_token',
38
+ client_id: clientId,
39
+ refresh_token: current.refreshToken
40
+ })
41
+ });
42
+ if (!response.ok) {
43
+ const errorText = await response.text();
44
+ log(`[-] Silent token refresh failed (HTTP ${response.status}): ${errorText}`);
45
+ return null;
46
+ }
47
+ const data = (await response.json());
48
+ const updatedToken = {
49
+ accessToken: data.access_token,
50
+ refreshToken: data.refresh_token || current.refreshToken,
51
+ expiresAt: Date.now() + data.expires_in * 1000,
52
+ tokenType: data.token_type || current.tokenType,
53
+ scope: data.scope || current.scope
54
+ };
55
+ this.saveToken(resource, updatedToken);
56
+ log(`[+] Token refreshed successfully! Valid for ${data.expires_in}s.`);
57
+ return updatedToken.accessToken;
58
+ }
59
+ catch (err) {
60
+ log(`[-] Network error during silent token refresh: ${err.message}`);
61
+ return null;
62
+ }
63
+ }
64
+ async getOrRefreshToken(options) {
65
+ const { resource } = options;
66
+ const current = this.getToken(resource);
67
+ if (!current) {
68
+ return null;
69
+ }
70
+ // Still valid for more than buffer duration
71
+ if (!this.isExpiringSoon(current)) {
72
+ return current.accessToken;
73
+ }
74
+ // Expiring soon: silently refresh if refresh_token exists
75
+ if (current.refreshToken) {
76
+ const refreshed = await this.refreshToken(options);
77
+ if (refreshed) {
78
+ return refreshed;
79
+ }
80
+ }
81
+ // If refresh failed but token is technically not past absolute expiration timestamp
82
+ if (Date.now() < current.expiresAt) {
83
+ return current.accessToken;
84
+ }
85
+ return null;
86
+ }
87
+ readAll() {
88
+ if (!fs.existsSync(this.storePath)) {
89
+ return {};
90
+ }
91
+ try {
92
+ const content = fs.readFileSync(this.storePath, 'utf-8');
93
+ return JSON.parse(content);
94
+ }
95
+ catch {
96
+ return {};
97
+ }
98
+ }
99
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { parseArgs } from '../src/index.js';
4
+ describe('CLI Argument Parser', () => {
5
+ it('should parse positional remote URL without flags', () => {
6
+ const options = parseArgs(['https://mcp.200lab.io/mcp/learner']);
7
+ assert.strictEqual(options.remoteUrl, 'https://mcp.200lab.io/mcp/learner');
8
+ assert.strictEqual(options.noBrowser, false);
9
+ assert.strictEqual(options.loginOnly, false);
10
+ assert.strictEqual(options.help, false);
11
+ assert.strictEqual(options.version, false);
12
+ assert.deepStrictEqual(options.headers, {});
13
+ });
14
+ it('should parse --client-id flag', () => {
15
+ const options = parseArgs(['https://api.example.com', '--client-id', 'client_id_999']);
16
+ assert.strictEqual(options.clientId, 'client_id_999');
17
+ });
18
+ it('should parse single and multiple --header flags with key-value pairs', () => {
19
+ const options = parseArgs([
20
+ 'https://api.example.com',
21
+ '--header',
22
+ 'Authorization: Bearer static_token_123',
23
+ '--header',
24
+ 'X-API-Key: secret_key',
25
+ '--header',
26
+ 'User-Agent: CustomMCPClient/1.0'
27
+ ]);
28
+ assert.strictEqual(options.headers['Authorization'], 'Bearer static_token_123');
29
+ assert.strictEqual(options.headers['X-API-Key'], 'secret_key');
30
+ assert.strictEqual(options.headers['User-Agent'], 'CustomMCPClient/1.0');
31
+ });
32
+ it('should ignore malformed --header flags lacking colon separator', () => {
33
+ const options = parseArgs(['https://api.example.com', '--header', 'InvalidHeaderValueWithoutColon']);
34
+ assert.deepStrictEqual(options.headers, {});
35
+ });
36
+ it('should parse boolean flags accurately', () => {
37
+ assert.strictEqual(parseArgs(['--no-browser']).noBrowser, true);
38
+ assert.strictEqual(parseArgs(['--login-only']).loginOnly, true);
39
+ assert.strictEqual(parseArgs(['-h']).help, true);
40
+ assert.strictEqual(parseArgs(['--help']).help, true);
41
+ assert.strictEqual(parseArgs(['-v']).version, true);
42
+ assert.strictEqual(parseArgs(['--version']).version, true);
43
+ });
44
+ it('should handle URL placed after options', () => {
45
+ const options = parseArgs(['--no-browser', '--client-id', 'test_id', 'https://mcp.service.io']);
46
+ assert.strictEqual(options.remoteUrl, 'https://mcp.service.io');
47
+ assert.strictEqual(options.clientId, 'test_id');
48
+ assert.strictEqual(options.noBrowser, true);
49
+ });
50
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { getOpenBrowserCommand, openBrowser } from '../../src/oauth/browser.js';
4
+ describe('OAuth Browser Opener', () => {
5
+ const sampleUrl = 'https://api.200lab.io/oauth/authorize?client_id=test&scope=learner#section';
6
+ it('should generate valid start command on Windows (win32)', () => {
7
+ const cmd = getOpenBrowserCommand(sampleUrl, 'win32');
8
+ assert.strictEqual(cmd, `start "" "${sampleUrl}"`);
9
+ });
10
+ it('should generate valid open command on macOS (darwin)', () => {
11
+ const cmd = getOpenBrowserCommand(sampleUrl, 'darwin');
12
+ assert.strictEqual(cmd, `open "${sampleUrl}"`);
13
+ });
14
+ it('should generate valid xdg-open command on Linux and other POSIX platforms', () => {
15
+ assert.strictEqual(getOpenBrowserCommand(sampleUrl, 'linux'), `xdg-open "${sampleUrl}"`);
16
+ assert.strictEqual(getOpenBrowserCommand(sampleUrl, 'freebsd'), `xdg-open "${sampleUrl}"`);
17
+ assert.strictEqual(getOpenBrowserCommand(sampleUrl, 'openbsd'), `xdg-open "${sampleUrl}"`);
18
+ });
19
+ it('should escape URLs with query parameters properly', () => {
20
+ const complexUrl = 'https://auth.example.com/oauth/authorize?response_type=code&scope=read%20write&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback';
21
+ const cmd = getOpenBrowserCommand(complexUrl, 'win32');
22
+ assert.ok(cmd.startsWith('start "" "'));
23
+ assert.ok(cmd.endsWith('"'));
24
+ assert.ok(cmd.includes('scope=read%20write'));
25
+ });
26
+ it('openBrowser should log clear operational and fallback messages to provided logger', async () => {
27
+ const loggedMessages = [];
28
+ const mockLogger = (msg) => loggedMessages.push(msg);
29
+ await openBrowser('https://example.com/test-oauth', mockLogger);
30
+ assert.ok(loggedMessages.length >= 2);
31
+ assert.ok(loggedMessages.some((m) => m.includes('Automatically opening default browser')));
32
+ assert.ok(loggedMessages.some((m) => m.includes('https://example.com/test-oauth')));
33
+ assert.ok(loggedMessages.some((m) => m.includes('If the browser does not open automatically')));
34
+ });
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { registerDynamicClient, resolveClientId } from '../../src/oauth/client-registration.js';
4
+ describe('OAuth Client Registration (RFC 7591)', () => {
5
+ const redirectUri = 'http://localhost:8080/callback';
6
+ const registrationEndpoint = 'https://api.example.com/oauth/register';
7
+ it('should post client metadata and return issued client_id', async () => {
8
+ let capturedBody;
9
+ let capturedHeaders;
10
+ const mockFetch = async (_input, init) => {
11
+ capturedHeaders = init?.headers;
12
+ capturedBody = JSON.parse(init?.body);
13
+ return new Response(JSON.stringify({
14
+ client_id: 'client_registered_777',
15
+ client_name: capturedBody.client_name,
16
+ redirect_uris: capturedBody.redirect_uris
17
+ }), { status: 201, headers: { 'Content-Type': 'application/json' } });
18
+ };
19
+ const clientId = await registerDynamicClient(registrationEndpoint, redirectUri, 'custom-proxy', mockFetch);
20
+ assert.strictEqual(clientId, 'client_registered_777');
21
+ assert.strictEqual(capturedBody.client_name, 'custom-proxy');
22
+ assert.deepStrictEqual(capturedBody.redirect_uris, [redirectUri]);
23
+ assert.strictEqual(capturedHeaders['Content-Type'], 'application/json');
24
+ });
25
+ it('should throw an informative error when registration server returns non-200', async () => {
26
+ const mockFetch = async () => {
27
+ return new Response('Invalid client metadata', { status: 400 });
28
+ };
29
+ await assert.rejects(async () => {
30
+ await registerDynamicClient(registrationEndpoint, redirectUri, 'proxy', mockFetch);
31
+ }, /Dynamic Client Registration failed \(HTTP 400\): Invalid client metadata/);
32
+ });
33
+ it('should throw an error when server response is missing client_id', async () => {
34
+ const mockFetch = async () => {
35
+ return new Response(JSON.stringify({ status: 'ok' }), { status: 200 });
36
+ };
37
+ await assert.rejects(async () => {
38
+ await registerDynamicClient(registrationEndpoint, redirectUri, 'proxy', mockFetch);
39
+ }, /Registration response missing client_id/);
40
+ });
41
+ it('resolveClientId should prioritize explicitClientId when provided by user', async () => {
42
+ const logs = [];
43
+ const clientId = await resolveClientId({
44
+ registrationEndpoint,
45
+ redirectUri,
46
+ explicitClientId: 'user_provided_fixed_id',
47
+ log: (msg) => logs.push(msg)
48
+ });
49
+ assert.strictEqual(clientId, 'user_provided_fixed_id');
50
+ assert.ok(logs.some((l) => l.includes('Using configured Client ID: user_provided_fixed_id')));
51
+ });
52
+ it('resolveClientId should dynamically register when no explicit ID provided', async () => {
53
+ const mockFetch = async () => {
54
+ return new Response(JSON.stringify({ client_id: 'auto_gen_123' }), { status: 200 });
55
+ };
56
+ const clientId = await resolveClientId({
57
+ registrationEndpoint,
58
+ redirectUri,
59
+ fetchFn: mockFetch,
60
+ log: () => { }
61
+ });
62
+ assert.strictEqual(clientId, 'auto_gen_123');
63
+ });
64
+ it('resolveClientId should throw an error if registration endpoint is unavailable and no explicit ID given', async () => {
65
+ await assert.rejects(async () => {
66
+ await resolveClientId({
67
+ redirectUri,
68
+ log: () => { }
69
+ });
70
+ }, /Server does not support Dynamic Client Registration \(RFC 7591\)\. Please provide an explicit --client-id <id>/);
71
+ });
72
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { extractResourceMetadataUrl, discoverOAuth } from '../../src/oauth/discovery.js';
4
+ describe('OAuth Metadata Discovery', () => {
5
+ it('should extract resource_metadata URL from valid WWW-Authenticate Bearer header', () => {
6
+ const header = 'Bearer resource_metadata="https://mcp.200lab.io/.well-known/oauth-protected-resource/mcp/learner"';
7
+ const extracted = extractResourceMetadataUrl(header);
8
+ assert.strictEqual(extracted, 'https://mcp.200lab.io/.well-known/oauth-protected-resource/mcp/learner');
9
+ });
10
+ it('should be case-insensitive for Bearer and resource_metadata tokens', () => {
11
+ const header = 'bearer RESOURCE_METADATA="https://mcp.example.com/meta"';
12
+ const extracted = extractResourceMetadataUrl(header);
13
+ assert.strictEqual(extracted, 'https://mcp.example.com/meta');
14
+ });
15
+ it('should return null for headers lacking resource_metadata', () => {
16
+ assert.strictEqual(extractResourceMetadataUrl(null), null);
17
+ assert.strictEqual(extractResourceMetadataUrl(''), null);
18
+ assert.strictEqual(extractResourceMetadataUrl('Bearer realm="OAuth"'), null);
19
+ assert.strictEqual(extractResourceMetadataUrl('Basic realm="Restricted"'), null);
20
+ });
21
+ it('should execute full discovery handshake and return all resolved endpoints', async () => {
22
+ const mockFetch = async (input) => {
23
+ const url = input.toString();
24
+ if (url === 'https://mcp.company.com/service') {
25
+ return new Response('Unauthorized', {
26
+ status: 401,
27
+ headers: {
28
+ 'WWW-Authenticate': 'Bearer resource_metadata="https://mcp.company.com/.well-known/resource"'
29
+ }
30
+ });
31
+ }
32
+ if (url === 'https://mcp.company.com/.well-known/resource') {
33
+ return new Response(JSON.stringify({
34
+ resource: 'https://mcp.company.com/service',
35
+ authorization_servers: ['https://auth.company.com/'],
36
+ scopes_supported: ['read', 'write']
37
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
38
+ }
39
+ if (url === 'https://auth.company.com/.well-known/oauth-authorization-server') {
40
+ return new Response(JSON.stringify({
41
+ issuer: 'https://auth.company.com',
42
+ authorization_endpoint: 'https://auth.company.com/oauth/authorize',
43
+ token_endpoint: 'https://auth.company.com/oauth/token',
44
+ registration_endpoint: 'https://auth.company.com/oauth/register'
45
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
46
+ }
47
+ return new Response('Not Found', { status: 404 });
48
+ };
49
+ const metadata = await discoverOAuth('https://mcp.company.com/service', mockFetch);
50
+ assert.strictEqual(metadata.resource, 'https://mcp.company.com/service');
51
+ assert.strictEqual(metadata.authorizationEndpoint, 'https://auth.company.com/oauth/authorize');
52
+ assert.strictEqual(metadata.tokenEndpoint, 'https://auth.company.com/oauth/token');
53
+ assert.strictEqual(metadata.registrationEndpoint, 'https://auth.company.com/oauth/register');
54
+ assert.deepStrictEqual(metadata.scopes, ['read', 'write']);
55
+ });
56
+ it('should throw an informative error when remote server does not provide 401 WWW-Authenticate header', async () => {
57
+ const mockFetch = async () => new Response('OK', { status: 200 });
58
+ await assert.rejects(async () => {
59
+ await discoverOAuth('https://mcp.example.com', mockFetch);
60
+ }, /Missing 'WWW-Authenticate: Bearer resource_metadata="\.\.\."' header/);
61
+ });
62
+ it('should throw an error when Resource Metadata returns HTTP error', async () => {
63
+ const mockFetch = async (input) => {
64
+ const url = input.toString();
65
+ if (url.includes('probe')) {
66
+ return new Response('Unauthorized', {
67
+ status: 401,
68
+ headers: {
69
+ 'WWW-Authenticate': 'Bearer resource_metadata="https://mcp.example.com/bad-meta"'
70
+ }
71
+ });
72
+ }
73
+ return new Response('Not Found', { status: 404 });
74
+ };
75
+ await assert.rejects(async () => {
76
+ await discoverOAuth('https://mcp.example.com/probe', mockFetch);
77
+ }, /Failed to fetch Resource Metadata/);
78
+ });
79
+ it('should throw an error when Resource Metadata lacks authorization_servers', async () => {
80
+ const mockFetch = async (input) => {
81
+ const url = input.toString();
82
+ if (url.includes('probe')) {
83
+ return new Response('Unauthorized', {
84
+ status: 401,
85
+ headers: { 'WWW-Authenticate': 'Bearer resource_metadata="https://mcp.example.com/meta"' }
86
+ });
87
+ }
88
+ return new Response(JSON.stringify({ resource: 'test' }), { status: 200 });
89
+ };
90
+ await assert.rejects(async () => {
91
+ await discoverOAuth('https://mcp.example.com/probe', mockFetch);
92
+ }, /Resource Metadata does not contain valid authorization_servers/);
93
+ });
94
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import crypto from 'node:crypto';
4
+ import { generatePKCE, base64url } from '../../src/oauth/pkce.js';
5
+ describe('PKCE Generator (RFC 7636)', () => {
6
+ it('should generate valid base64url strings without standard base64 padding or symbols', () => {
7
+ const { verifier, challenge, method } = generatePKCE();
8
+ assert.strictEqual(method, 'S256');
9
+ assert.ok(verifier.length >= 43 && verifier.length <= 128, 'Verifier length must be between 43 and 128 characters');
10
+ assert.match(verifier, /^[A-Za-z0-9_-]+$/, 'Verifier must only contain base64url characters');
11
+ assert.match(challenge, /^[A-Za-z0-9_-]+$/, 'Challenge must only contain base64url characters');
12
+ assert.ok(!verifier.includes('+') && !verifier.includes('/') && !verifier.includes('='));
13
+ assert.ok(!challenge.includes('+') && !challenge.includes('/') && !challenge.includes('='));
14
+ });
15
+ it('should compute the exact SHA-256 hash challenge corresponding to the verifier', () => {
16
+ const { verifier, challenge } = generatePKCE();
17
+ const expectedChallenge = base64url(crypto.createHash('sha256').update(verifier).digest());
18
+ assert.strictEqual(challenge, expectedChallenge, 'Challenge must strictly equal base64url(sha256(verifier))');
19
+ });
20
+ it('should produce high entropy unique pairs across multiple invocations', () => {
21
+ const verifiers = new Set();
22
+ const challenges = new Set();
23
+ const iterations = 50;
24
+ for (let i = 0; i < iterations; i++) {
25
+ const pair = generatePKCE();
26
+ verifiers.add(pair.verifier);
27
+ challenges.add(pair.challenge);
28
+ }
29
+ assert.strictEqual(verifiers.size, iterations, 'All generated verifiers must be distinct');
30
+ assert.strictEqual(challenges.size, iterations, 'All generated challenges must be distinct');
31
+ });
32
+ it('should correctly format known RFC 7636 test vectors', () => {
33
+ // RFC 7636 Appendix B test vector
34
+ const testVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
35
+ const expectedChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
36
+ const hash = crypto.createHash('sha256').update(testVerifier).digest();
37
+ const computedChallenge = base64url(hash);
38
+ assert.strictEqual(computedChallenge, expectedChallenge, 'base64url must match standard RFC 7636 test vector');
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import net from 'node:net';
4
+ import { getAvailablePort, startCallbackServer } from '../../src/oauth/server.js';
5
+ describe('OAuth Callback Server & Dynamic Port Selection', () => {
6
+ it('should find next available port when the initial port is busy', async () => {
7
+ const testPort = 19123;
8
+ const blockerServer = net.createServer();
9
+ await new Promise((resolve) => blockerServer.listen(testPort, () => resolve()));
10
+ const nextPort = await getAvailablePort(testPort);
11
+ assert.strictEqual(nextPort, testPort + 1, 'Should increment to next available port');
12
+ await new Promise((resolve) => blockerServer.close(() => resolve()));
13
+ });
14
+ it('should receive authorization code and serve success HTML page on valid callback', async () => {
15
+ const callbackServer = await startCallbackServer();
16
+ const expectedCode = 'auth_code_secret_xyz_987';
17
+ const codePromise = callbackServer.waitForCode();
18
+ const res = await fetch(`${callbackServer.redirectUri}?code=${expectedCode}`);
19
+ assert.strictEqual(res.status, 200);
20
+ const htmlBody = await res.text();
21
+ assert.ok(htmlBody.includes('Authentication Successful!'));
22
+ const receivedCode = await codePromise;
23
+ assert.strictEqual(receivedCode, expectedCode);
24
+ await callbackServer.close();
25
+ });
26
+ it('should handle OAuth error redirect with 400 and reject promise with error description', async () => {
27
+ const callbackServer = await startCallbackServer();
28
+ const codePromise = callbackServer.waitForCode();
29
+ const res = await fetch(`${callbackServer.redirectUri}?error=access_denied&error_description=User+declined+authorization`);
30
+ assert.strictEqual(res.status, 400);
31
+ const htmlBody = await res.text();
32
+ assert.ok(htmlBody.includes('Authentication Failed'));
33
+ assert.ok(htmlBody.includes('User declined authorization'));
34
+ await assert.rejects(codePromise, /User declined authorization/);
35
+ await callbackServer.close();
36
+ });
37
+ it('should reject when callback is invoked without authorization code or error parameters', async () => {
38
+ const callbackServer = await startCallbackServer();
39
+ const codePromise = callbackServer.waitForCode();
40
+ const res = await fetch(`${callbackServer.redirectUri}`);
41
+ assert.strictEqual(res.status, 400);
42
+ const htmlBody = await res.text();
43
+ assert.ok(htmlBody.includes('Authorization Code Missing'));
44
+ await assert.rejects(codePromise, /Missing authorization code in callback/);
45
+ await callbackServer.close();
46
+ });
47
+ it('should return 404 for unhandled routes', async () => {
48
+ const callbackServer = await startCallbackServer();
49
+ const res = await fetch(`http://localhost:${callbackServer.port}/non-existent-route`);
50
+ assert.strictEqual(res.status, 404);
51
+ await callbackServer.close();
52
+ });
53
+ });
@@ -0,0 +1 @@
1
+ export {};