@prmichaelsen/remember-mcp 3.18.1 → 3.19.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/AGENT.md +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -1
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-create.md +5 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/design/local.api-token-oauth-access.md +286 -0
- package/agent/milestones/milestone-21-api-token-oauth-access.md +53 -0
- package/agent/progress.yaml +73 -2
- package/agent/scripts/acp.install.sh +50 -27
- package/agent/scripts/acp.package-validate.sh +55 -27
- package/agent/tasks/milestone-21-api-token-oauth-access/task-515-add-auth-scheme-config.md +56 -0
- package/agent/tasks/milestone-21-api-token-oauth-access/task-516-implement-oauth-token-exchange.md +56 -0
- package/agent/tasks/milestone-21-api-token-oauth-access/task-517-implement-local-config-resolution.md +59 -0
- package/agent/tasks/milestone-21-api-token-oauth-access/task-518-wire-oauth-into-server-factory.md +56 -0
- package/agent/tasks/milestone-21-api-token-oauth-access/task-519-tests-and-documentation.md +54 -0
- package/agent/tasks/unassigned/task-514-remember-mcp-consume-core-space.md +71 -0
- package/dist/auth/config-resolver.d.ts +28 -0
- package/dist/auth/config-resolver.spec.d.ts +2 -0
- package/dist/auth/oauth-bootstrap.d.ts +22 -0
- package/dist/auth/oauth-bootstrap.spec.d.ts +2 -0
- package/dist/auth/oauth-exchange.d.ts +32 -0
- package/dist/auth/oauth-exchange.spec.d.ts +2 -0
- package/dist/config.d.ts +24 -0
- package/dist/server.js +1454 -78
- package/package.json +1 -1
- package/src/auth/config-resolver.spec.ts +101 -0
- package/src/auth/config-resolver.ts +127 -0
- package/src/auth/oauth-bootstrap.spec.ts +72 -0
- package/src/auth/oauth-bootstrap.ts +45 -0
- package/src/auth/oauth-exchange.spec.ts +126 -0
- package/src/auth/oauth-exchange.ts +128 -0
- package/src/config.ts +46 -0
- package/src/server.ts +17 -3
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { resolveAuthConfig } from './config-resolver.js';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
describe('resolveAuthConfig', () => {
|
|
6
|
+
const projectDir = join(process.cwd(), '.remember');
|
|
7
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Save env vars
|
|
11
|
+
savedEnv.REMEMBER_OAUTH_ENDPOINT = process.env.REMEMBER_OAUTH_ENDPOINT;
|
|
12
|
+
savedEnv.REMEMBER_API_TOKEN = process.env.REMEMBER_API_TOKEN;
|
|
13
|
+
// Clear env vars
|
|
14
|
+
delete process.env.REMEMBER_OAUTH_ENDPOINT;
|
|
15
|
+
delete process.env.REMEMBER_API_TOKEN;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
// Restore env vars
|
|
20
|
+
if (savedEnv.REMEMBER_OAUTH_ENDPOINT !== undefined) {
|
|
21
|
+
process.env.REMEMBER_OAUTH_ENDPOINT = savedEnv.REMEMBER_OAUTH_ENDPOINT;
|
|
22
|
+
} else {
|
|
23
|
+
delete process.env.REMEMBER_OAUTH_ENDPOINT;
|
|
24
|
+
}
|
|
25
|
+
if (savedEnv.REMEMBER_API_TOKEN !== undefined) {
|
|
26
|
+
process.env.REMEMBER_API_TOKEN = savedEnv.REMEMBER_API_TOKEN;
|
|
27
|
+
} else {
|
|
28
|
+
delete process.env.REMEMBER_API_TOKEN;
|
|
29
|
+
}
|
|
30
|
+
// Cleanup project config
|
|
31
|
+
if (existsSync(projectDir)) {
|
|
32
|
+
rmSync(projectDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('resolves from project config file', () => {
|
|
37
|
+
mkdirSync(projectDir, { recursive: true });
|
|
38
|
+
writeFileSync(join(projectDir, 'config'), 'oauth_endpoint: https://proj.example.com/oauth\napi_token: ab_live-sk_proj\n');
|
|
39
|
+
|
|
40
|
+
const config = resolveAuthConfig();
|
|
41
|
+
expect(config.oauthEndpoint).toBe('https://proj.example.com/oauth');
|
|
42
|
+
expect(config.apiToken).toBe('ab_live-sk_proj');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('env vars override file values', () => {
|
|
46
|
+
mkdirSync(projectDir, { recursive: true });
|
|
47
|
+
writeFileSync(join(projectDir, 'config'), 'oauth_endpoint: https://file.com/oauth\napi_token: ab_live-sk_file\n');
|
|
48
|
+
process.env.REMEMBER_API_TOKEN = 'ab_live-sk_env';
|
|
49
|
+
|
|
50
|
+
const config = resolveAuthConfig();
|
|
51
|
+
expect(config.oauthEndpoint).toBe('https://file.com/oauth');
|
|
52
|
+
expect(config.apiToken).toBe('ab_live-sk_env');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('works with env vars only (no config file)', () => {
|
|
56
|
+
process.env.REMEMBER_OAUTH_ENDPOINT = 'https://env.com/oauth';
|
|
57
|
+
process.env.REMEMBER_API_TOKEN = 'ab_live-sk_envonly';
|
|
58
|
+
|
|
59
|
+
const config = resolveAuthConfig();
|
|
60
|
+
expect(config.oauthEndpoint).toBe('https://env.com/oauth');
|
|
61
|
+
expect(config.apiToken).toBe('ab_live-sk_envonly');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('throws when oauth_endpoint is missing', () => {
|
|
65
|
+
process.env.REMEMBER_API_TOKEN = 'ab_live-sk_tok';
|
|
66
|
+
|
|
67
|
+
expect(() => resolveAuthConfig()).toThrow('Could not resolve OAuth endpoint');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws when api_token is missing', () => {
|
|
71
|
+
process.env.REMEMBER_OAUTH_ENDPOINT = 'https://x.com/oauth';
|
|
72
|
+
|
|
73
|
+
expect(() => resolveAuthConfig()).toThrow('Could not resolve API token');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles comments and empty lines in config file', () => {
|
|
77
|
+
mkdirSync(projectDir, { recursive: true });
|
|
78
|
+
writeFileSync(join(projectDir, 'config'), [
|
|
79
|
+
'# This is a comment',
|
|
80
|
+
'',
|
|
81
|
+
'oauth_endpoint: https://comments.com/oauth',
|
|
82
|
+
'# Another comment',
|
|
83
|
+
'api_token: ab_live-sk_comments',
|
|
84
|
+
'',
|
|
85
|
+
].join('\n'));
|
|
86
|
+
|
|
87
|
+
const config = resolveAuthConfig();
|
|
88
|
+
expect(config.oauthEndpoint).toBe('https://comments.com/oauth');
|
|
89
|
+
expect(config.apiToken).toBe('ab_live-sk_comments');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('partial file + env var fills gaps', () => {
|
|
93
|
+
mkdirSync(projectDir, { recursive: true });
|
|
94
|
+
writeFileSync(join(projectDir, 'config'), 'api_token: ab_live-sk_fromfile\n');
|
|
95
|
+
process.env.REMEMBER_OAUTH_ENDPOINT = 'https://mixed.com/oauth';
|
|
96
|
+
|
|
97
|
+
const config = resolveAuthConfig();
|
|
98
|
+
expect(config.oauthEndpoint).toBe('https://mixed.com/oauth');
|
|
99
|
+
expect(config.apiToken).toBe('ab_live-sk_fromfile');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Config Resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves API token and OAuth endpoint from .remember/config files.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order (first wins per field):
|
|
7
|
+
* 1. ./.remember/config (project-level)
|
|
8
|
+
* 2. ~/.remember/config (global)
|
|
9
|
+
* 3. REMEMBER_API_TOKEN / REMEMBER_OAUTH_ENDPOINT env vars
|
|
10
|
+
*
|
|
11
|
+
* Env vars override file values when both are present.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { logger } from '../utils/logger.js';
|
|
18
|
+
|
|
19
|
+
export interface ResolvedAuthConfig {
|
|
20
|
+
oauthEndpoint: string;
|
|
21
|
+
apiToken: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ConfigFileValues {
|
|
25
|
+
oauth_endpoint?: string;
|
|
26
|
+
api_token?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a .remember/config file.
|
|
31
|
+
* Format: simple key: value pairs, one per line. Lines starting with # are comments.
|
|
32
|
+
*/
|
|
33
|
+
function parseConfigFile(filePath: string): ConfigFileValues {
|
|
34
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
35
|
+
const result: ConfigFileValues = {};
|
|
36
|
+
|
|
37
|
+
for (const line of content.split('\n')) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
40
|
+
|
|
41
|
+
const colonIndex = trimmed.indexOf(':');
|
|
42
|
+
if (colonIndex === -1) continue;
|
|
43
|
+
|
|
44
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
45
|
+
const value = trimmed.slice(colonIndex + 1).trim();
|
|
46
|
+
|
|
47
|
+
if (key === 'oauth_endpoint' && value) {
|
|
48
|
+
result.oauth_endpoint = value;
|
|
49
|
+
} else if (key === 'api_token' && value) {
|
|
50
|
+
result.api_token = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Try to read a config file, returning null if it doesn't exist.
|
|
59
|
+
*/
|
|
60
|
+
function tryReadConfigFile(filePath: string): ConfigFileValues | null {
|
|
61
|
+
if (!existsSync(filePath)) return null;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const values = parseConfigFile(filePath);
|
|
65
|
+
logger.debug('Read config file', { filePath, keys: Object.keys(values) });
|
|
66
|
+
return values;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.warn('Failed to parse config file', {
|
|
69
|
+
filePath,
|
|
70
|
+
error: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve auth config from .remember/config files and env vars.
|
|
78
|
+
*
|
|
79
|
+
* Merge strategy:
|
|
80
|
+
* - Project config fills in values
|
|
81
|
+
* - Global config fills in remaining gaps
|
|
82
|
+
* - Env vars override any file values
|
|
83
|
+
*
|
|
84
|
+
* @throws Error if required values are missing after resolution
|
|
85
|
+
*/
|
|
86
|
+
export function resolveAuthConfig(): ResolvedAuthConfig {
|
|
87
|
+
const projectConfigPath = join(process.cwd(), '.remember', 'config');
|
|
88
|
+
const globalConfigPath = join(homedir(), '.remember', 'config');
|
|
89
|
+
|
|
90
|
+
// Read config files (project takes precedence)
|
|
91
|
+
const projectConfig = tryReadConfigFile(projectConfigPath);
|
|
92
|
+
const globalConfig = tryReadConfigFile(globalConfigPath);
|
|
93
|
+
|
|
94
|
+
// Start with file values (project > global)
|
|
95
|
+
let oauthEndpoint = projectConfig?.oauth_endpoint ?? globalConfig?.oauth_endpoint ?? '';
|
|
96
|
+
let apiToken = projectConfig?.api_token ?? globalConfig?.api_token ?? '';
|
|
97
|
+
|
|
98
|
+
// Env vars override file values
|
|
99
|
+
if (process.env.REMEMBER_OAUTH_ENDPOINT) {
|
|
100
|
+
oauthEndpoint = process.env.REMEMBER_OAUTH_ENDPOINT;
|
|
101
|
+
}
|
|
102
|
+
if (process.env.REMEMBER_API_TOKEN) {
|
|
103
|
+
apiToken = process.env.REMEMBER_API_TOKEN;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validate
|
|
107
|
+
const searched = [projectConfigPath, globalConfigPath, 'env vars'].join(', ');
|
|
108
|
+
|
|
109
|
+
if (!oauthEndpoint) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Could not resolve OAuth endpoint. Set REMEMBER_OAUTH_ENDPOINT env var or add oauth_endpoint to .remember/config. Searched: ${searched}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!apiToken) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Could not resolve API token. Set REMEMBER_API_TOKEN env var or add api_token to .remember/config. Searched: ${searched}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
logger.info('Auth config resolved', {
|
|
122
|
+
oauthEndpoint,
|
|
123
|
+
tokenSource: process.env.REMEMBER_API_TOKEN ? 'env' : projectConfig?.api_token ? 'project' : 'global',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { oauthEndpoint, apiToken };
|
|
127
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { bootstrapOAuth } from './oauth-bootstrap.js';
|
|
2
|
+
import * as configResolver from './config-resolver.js';
|
|
3
|
+
import * as oauthExchange from './oauth-exchange.js';
|
|
4
|
+
|
|
5
|
+
jest.mock('./config-resolver.js');
|
|
6
|
+
jest.mock('./oauth-exchange.js');
|
|
7
|
+
|
|
8
|
+
const mockResolveAuthConfig = configResolver.resolveAuthConfig as jest.MockedFunction<typeof configResolver.resolveAuthConfig>;
|
|
9
|
+
const mockExchangeApiToken = oauthExchange.exchangeApiToken as jest.MockedFunction<typeof oauthExchange.exchangeApiToken>;
|
|
10
|
+
const mockExtractUserId = oauthExchange.extractUserId as jest.MockedFunction<typeof oauthExchange.extractUserId>;
|
|
11
|
+
|
|
12
|
+
describe('bootstrapOAuth', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('completes full flow: resolve → exchange → extract', async () => {
|
|
18
|
+
mockResolveAuthConfig.mockReturnValue({
|
|
19
|
+
oauthEndpoint: 'https://example.com/oauth',
|
|
20
|
+
apiToken: 'ab_live-sk_test',
|
|
21
|
+
});
|
|
22
|
+
mockExchangeApiToken.mockResolvedValue({
|
|
23
|
+
access_token: 'jwt.payload.sig',
|
|
24
|
+
token_type: 'Bearer',
|
|
25
|
+
expires_in: 3600,
|
|
26
|
+
});
|
|
27
|
+
mockExtractUserId.mockReturnValue('user456');
|
|
28
|
+
|
|
29
|
+
const result = await bootstrapOAuth();
|
|
30
|
+
|
|
31
|
+
expect(result.accessToken).toBe('jwt.payload.sig');
|
|
32
|
+
expect(result.userId).toBe('user456');
|
|
33
|
+
expect(mockResolveAuthConfig).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(mockExchangeApiToken).toHaveBeenCalledWith('https://example.com/oauth', 'ab_live-sk_test');
|
|
35
|
+
expect(mockExtractUserId).toHaveBeenCalledWith('jwt.payload.sig');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('propagates config resolution errors', async () => {
|
|
39
|
+
mockResolveAuthConfig.mockImplementation(() => {
|
|
40
|
+
throw new Error('Could not resolve API token');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await expect(bootstrapOAuth()).rejects.toThrow('Could not resolve API token');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('propagates exchange errors', async () => {
|
|
47
|
+
mockResolveAuthConfig.mockReturnValue({
|
|
48
|
+
oauthEndpoint: 'https://x.com/oauth',
|
|
49
|
+
apiToken: 'ab_live-sk_bad',
|
|
50
|
+
});
|
|
51
|
+
mockExchangeApiToken.mockRejectedValue(new Error('invalid or expired API token'));
|
|
52
|
+
|
|
53
|
+
await expect(bootstrapOAuth()).rejects.toThrow('invalid or expired API token');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('propagates userId extraction errors', async () => {
|
|
57
|
+
mockResolveAuthConfig.mockReturnValue({
|
|
58
|
+
oauthEndpoint: 'https://x.com/oauth',
|
|
59
|
+
apiToken: 'ab_live-sk_ok',
|
|
60
|
+
});
|
|
61
|
+
mockExchangeApiToken.mockResolvedValue({
|
|
62
|
+
access_token: 'bad.jwt',
|
|
63
|
+
token_type: 'Bearer',
|
|
64
|
+
expires_in: 3600,
|
|
65
|
+
});
|
|
66
|
+
mockExtractUserId.mockImplementation(() => {
|
|
67
|
+
throw new Error('Invalid JWT: expected 3 parts');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(bootstrapOAuth()).rejects.toThrow('expected 3 parts');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Bootstrap
|
|
3
|
+
*
|
|
4
|
+
* Single entry point for the OAuth authentication flow.
|
|
5
|
+
* Resolves config → exchanges API token → extracts userId.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
import { resolveAuthConfig } from './config-resolver.js';
|
|
10
|
+
import { exchangeApiToken, extractUserId } from './oauth-exchange.js';
|
|
11
|
+
|
|
12
|
+
export interface OAuthBootstrapResult {
|
|
13
|
+
accessToken: string; // JWT
|
|
14
|
+
userId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bootstrap OAuth authentication.
|
|
19
|
+
*
|
|
20
|
+
* 1. Resolves config from .remember/config files and env vars
|
|
21
|
+
* 2. Exchanges API token for JWT via OAuth endpoint
|
|
22
|
+
* 3. Extracts userId from JWT claims
|
|
23
|
+
*
|
|
24
|
+
* @returns The JWT access token and userId
|
|
25
|
+
* @throws Error with descriptive message at any step
|
|
26
|
+
*/
|
|
27
|
+
export async function bootstrapOAuth(): Promise<OAuthBootstrapResult> {
|
|
28
|
+
logger.info('Bootstrapping OAuth authentication...');
|
|
29
|
+
|
|
30
|
+
// Step 1: Resolve config
|
|
31
|
+
const { oauthEndpoint, apiToken } = resolveAuthConfig();
|
|
32
|
+
|
|
33
|
+
// Step 2: Exchange API token for JWT
|
|
34
|
+
const tokenResponse = await exchangeApiToken(oauthEndpoint, apiToken);
|
|
35
|
+
|
|
36
|
+
// Step 3: Extract userId from JWT
|
|
37
|
+
const userId = extractUserId(tokenResponse.access_token);
|
|
38
|
+
|
|
39
|
+
logger.info('OAuth bootstrap complete', { userId, oauthEndpoint });
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
accessToken: tokenResponse.access_token,
|
|
43
|
+
userId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { exchangeApiToken, extractUserId } from './oauth-exchange.js';
|
|
2
|
+
|
|
3
|
+
// Helper to create a JWT with given payload
|
|
4
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
5
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
6
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
7
|
+
return `${header}.${body}.fakesig`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('extractUserId', () => {
|
|
11
|
+
it('extracts sub from valid JWT', () => {
|
|
12
|
+
const jwt = makeJwt({ sub: 'user123', iss: 'agentbase.me' });
|
|
13
|
+
expect(extractUserId(jwt)).toBe('user123');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('throws on missing sub claim', () => {
|
|
17
|
+
const jwt = makeJwt({ iss: 'agentbase.me' });
|
|
18
|
+
expect(() => extractUserId(jwt)).toThrow('missing or empty "sub" claim');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws on empty sub claim', () => {
|
|
22
|
+
const jwt = makeJwt({ sub: '' });
|
|
23
|
+
expect(() => extractUserId(jwt)).toThrow('missing or empty "sub" claim');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws on malformed JWT (wrong part count)', () => {
|
|
27
|
+
expect(() => extractUserId('onlyone')).toThrow('expected 3 parts');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws on malformed JWT (bad base64)', () => {
|
|
31
|
+
expect(() => extractUserId('a.!!!invalid!!!.c')).toThrow('unable to decode payload');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('exchangeApiToken', () => {
|
|
36
|
+
const originalFetch = global.fetch;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
global.fetch = originalFetch;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns token response on success', async () => {
|
|
43
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
44
|
+
ok: true,
|
|
45
|
+
status: 200,
|
|
46
|
+
json: () => Promise.resolve({
|
|
47
|
+
access_token: 'jwt123',
|
|
48
|
+
token_type: 'Bearer',
|
|
49
|
+
expires_in: 3600,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = await exchangeApiToken('https://example.com/oauth', 'ab_live-sk_test');
|
|
54
|
+
expect(result.access_token).toBe('jwt123');
|
|
55
|
+
expect(result.token_type).toBe('Bearer');
|
|
56
|
+
expect(result.expires_in).toBe(3600);
|
|
57
|
+
|
|
58
|
+
expect(global.fetch).toHaveBeenCalledWith('https://example.com/oauth', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ grant_type: 'api_token', api_token: 'ab_live-sk_test' }),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws descriptive error on 401', async () => {
|
|
66
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
67
|
+
ok: false,
|
|
68
|
+
status: 401,
|
|
69
|
+
text: () => Promise.resolve('unauthorized'),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await expect(exchangeApiToken('https://x.com/oauth', 'bad'))
|
|
73
|
+
.rejects.toThrow('invalid or expired API token');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws descriptive error on 403', async () => {
|
|
77
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
78
|
+
ok: false,
|
|
79
|
+
status: 403,
|
|
80
|
+
text: () => Promise.resolve('disabled'),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await expect(exchangeApiToken('https://x.com/oauth', 'disabled'))
|
|
84
|
+
.rejects.toThrow('API token has been disabled');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws descriptive error on network failure', async () => {
|
|
88
|
+
global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
89
|
+
|
|
90
|
+
await expect(exchangeApiToken('https://x.com/oauth', 'tok'))
|
|
91
|
+
.rejects.toThrow('network error');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws on invalid JSON response', async () => {
|
|
95
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 200,
|
|
98
|
+
json: () => Promise.reject(new Error('invalid json')),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await expect(exchangeApiToken('https://x.com/oauth', 'tok'))
|
|
102
|
+
.rejects.toThrow('invalid JSON response');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('throws on missing access_token in response', async () => {
|
|
106
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
107
|
+
ok: true,
|
|
108
|
+
status: 200,
|
|
109
|
+
json: () => Promise.resolve({ token_type: 'Bearer' }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(exchangeApiToken('https://x.com/oauth', 'tok'))
|
|
113
|
+
.rejects.toThrow('response missing access_token');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('throws descriptive error on other HTTP errors', async () => {
|
|
117
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
118
|
+
ok: false,
|
|
119
|
+
status: 500,
|
|
120
|
+
text: () => Promise.resolve('internal server error'),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(exchangeApiToken('https://x.com/oauth', 'tok'))
|
|
124
|
+
.rejects.toThrow('HTTP 500');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Exchange Client
|
|
3
|
+
*
|
|
4
|
+
* Exchanges an API token for a JWT by calling the configured OAuth endpoint.
|
|
5
|
+
* Uses native fetch (Node 18+) — no new dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
export interface OAuthTokenResponse {
|
|
11
|
+
access_token: string;
|
|
12
|
+
token_type: 'Bearer';
|
|
13
|
+
expires_in: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Exchange an API token for a JWT via the OAuth endpoint.
|
|
18
|
+
*
|
|
19
|
+
* @param oauthEndpoint - The OAuth token exchange URL
|
|
20
|
+
* @param apiToken - The opaque API token to exchange
|
|
21
|
+
* @returns The OAuth token response containing the JWT
|
|
22
|
+
* @throws Error with descriptive message on failure
|
|
23
|
+
*/
|
|
24
|
+
export async function exchangeApiToken(
|
|
25
|
+
oauthEndpoint: string,
|
|
26
|
+
apiToken: string
|
|
27
|
+
): Promise<OAuthTokenResponse> {
|
|
28
|
+
logger.info('Exchanging API token for JWT', { oauthEndpoint });
|
|
29
|
+
|
|
30
|
+
let response: Response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(oauthEndpoint, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
grant_type: 'api_token',
|
|
37
|
+
api_token: apiToken,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
throw new Error(
|
|
43
|
+
`OAuth token exchange failed: network error connecting to ${oauthEndpoint} — ${message}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (response.status === 401) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'OAuth token exchange failed: invalid or expired API token'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (response.status === 403) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'OAuth token exchange failed: API token has been disabled'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const body = await response.text().catch(() => '');
|
|
61
|
+
throw new Error(
|
|
62
|
+
`OAuth token exchange failed: HTTP ${response.status} from ${oauthEndpoint}${body ? ` — ${body}` : ''}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let data: unknown;
|
|
67
|
+
try {
|
|
68
|
+
data = await response.json();
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'OAuth token exchange failed: invalid JSON response from endpoint'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
typeof data !== 'object' ||
|
|
77
|
+
data === null ||
|
|
78
|
+
!('access_token' in data) ||
|
|
79
|
+
typeof (data as any).access_token !== 'string'
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'OAuth token exchange failed: response missing access_token field'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result: OAuthTokenResponse = {
|
|
87
|
+
access_token: (data as any).access_token,
|
|
88
|
+
token_type: (data as any).token_type ?? 'Bearer',
|
|
89
|
+
expires_in: (data as any).expires_in ?? 3600,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
logger.info('API token exchanged successfully', {
|
|
93
|
+
expiresIn: result.expires_in,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract userId from a JWT access token.
|
|
101
|
+
*
|
|
102
|
+
* Decodes the JWT payload without verification — the OAuth endpoint
|
|
103
|
+
* already validated the token, so we just need the claims.
|
|
104
|
+
*
|
|
105
|
+
* @param jwt - The JWT access token
|
|
106
|
+
* @returns The userId from the `sub` claim
|
|
107
|
+
* @throws Error if JWT is malformed or missing `sub`
|
|
108
|
+
*/
|
|
109
|
+
export function extractUserId(jwt: string): string {
|
|
110
|
+
const parts = jwt.split('.');
|
|
111
|
+
if (parts.length !== 3) {
|
|
112
|
+
throw new Error('Invalid JWT: expected 3 parts (header.payload.signature)');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let payload: Record<string, unknown>;
|
|
116
|
+
try {
|
|
117
|
+
const decoded = Buffer.from(parts[1], 'base64url').toString('utf-8');
|
|
118
|
+
payload = JSON.parse(decoded);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error('Invalid JWT: unable to decode payload');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof payload.sub !== 'string' || !payload.sub) {
|
|
124
|
+
throw new Error('Invalid JWT: missing or empty "sub" claim');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return payload.sub;
|
|
128
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -67,6 +67,52 @@ export const config = {
|
|
|
67
67
|
},
|
|
68
68
|
} as const;
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Auth scheme configuration — discriminated union.
|
|
72
|
+
*
|
|
73
|
+
* - 'service': Current behavior, JWT via mcp-auth (deployed behind remember-mcp-server)
|
|
74
|
+
* - 'oauth': Local mode, exchanges API token for JWT via OAuth endpoint
|
|
75
|
+
*/
|
|
76
|
+
type ServiceAuthConfig = { scheme: 'service' };
|
|
77
|
+
type OAuthAuthConfig = {
|
|
78
|
+
scheme: 'oauth';
|
|
79
|
+
oauthEndpoint: string;
|
|
80
|
+
apiToken: string;
|
|
81
|
+
};
|
|
82
|
+
export type AuthSchemeConfig = ServiceAuthConfig | OAuthAuthConfig;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load auth scheme config from environment variables.
|
|
86
|
+
* Validates at startup — fails fast with descriptive errors.
|
|
87
|
+
*
|
|
88
|
+
* Note: apiToken may be empty here when scheme=oauth.
|
|
89
|
+
* The config-resolver (T517) fills it from .remember/config if not set via env var.
|
|
90
|
+
*/
|
|
91
|
+
export function loadAuthSchemeConfig(): AuthSchemeConfig {
|
|
92
|
+
const scheme = process.env.REMEMBER_AUTH_SCHEME ?? 'service';
|
|
93
|
+
|
|
94
|
+
if (scheme === 'service') {
|
|
95
|
+
return { scheme };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (scheme !== 'oauth') {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Invalid REMEMBER_AUTH_SCHEME: "${scheme}". Valid values: "service", "oauth"`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const oauthEndpoint = process.env.REMEMBER_OAUTH_ENDPOINT;
|
|
105
|
+
if (!oauthEndpoint) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
'REMEMBER_OAUTH_ENDPOINT is required when REMEMBER_AUTH_SCHEME=oauth'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const apiToken = process.env.REMEMBER_API_TOKEN ?? '';
|
|
112
|
+
|
|
113
|
+
return { scheme, oauthEndpoint, apiToken };
|
|
114
|
+
}
|
|
115
|
+
|
|
70
116
|
/**
|
|
71
117
|
* Validate required configuration
|
|
72
118
|
*/
|