@moxxy/plugin-oauth 0.0.33
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/LICENSE +21 -0
- package/dist/adapters/openai-device-flow.d.ts +34 -0
- package/dist/adapters/openai-device-flow.d.ts.map +1 -0
- package/dist/adapters/openai-device-flow.js +136 -0
- package/dist/adapters/openai-device-flow.js.map +1 -0
- package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
- package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
- package/dist/adapters/rfc8628-device-flow.js +54 -0
- package/dist/adapters/rfc8628-device-flow.js.map +1 -0
- package/dist/credential-lock.d.ts +60 -0
- package/dist/credential-lock.d.ts.map +1 -0
- package/dist/credential-lock.js +149 -0
- package/dist/credential-lock.js.map +1 -0
- package/dist/ensure-fresh.d.ts +63 -0
- package/dist/ensure-fresh.d.ts.map +1 -0
- package/dist/ensure-fresh.js +122 -0
- package/dist/ensure-fresh.js.map +1 -0
- package/dist/flow.d.ts +6 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +5 -0
- package/dist/flow.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/browser-flow.d.ts +23 -0
- package/dist/oauth/browser-flow.d.ts.map +1 -0
- package/dist/oauth/browser-flow.js +82 -0
- package/dist/oauth/browser-flow.js.map +1 -0
- package/dist/oauth/callback-server.d.ts +10 -0
- package/dist/oauth/callback-server.d.ts.map +1 -0
- package/dist/oauth/callback-server.js +158 -0
- package/dist/oauth/callback-server.js.map +1 -0
- package/dist/oauth/device-flow-shared.d.ts +39 -0
- package/dist/oauth/device-flow-shared.d.ts.map +1 -0
- package/dist/oauth/device-flow-shared.js +101 -0
- package/dist/oauth/device-flow-shared.js.map +1 -0
- package/dist/oauth/device-flow.d.ts +29 -0
- package/dist/oauth/device-flow.d.ts.map +1 -0
- package/dist/oauth/device-flow.js +74 -0
- package/dist/oauth/device-flow.js.map +1 -0
- package/dist/oauth/poll-until.d.ts +41 -0
- package/dist/oauth/poll-until.d.ts.map +1 -0
- package/dist/oauth/poll-until.js +70 -0
- package/dist/oauth/poll-until.js.map +1 -0
- package/dist/oauth/token-exchange.d.ts +28 -0
- package/dist/oauth/token-exchange.d.ts.map +1 -0
- package/dist/oauth/token-exchange.js +142 -0
- package/dist/oauth/token-exchange.js.map +1 -0
- package/dist/oauth/types.d.ts +90 -0
- package/dist/oauth/types.d.ts.map +1 -0
- package/dist/oauth/types.js +2 -0
- package/dist/oauth/types.js.map +1 -0
- package/dist/open-browser.d.ts +32 -0
- package/dist/open-browser.d.ts.map +1 -0
- package/dist/open-browser.js +83 -0
- package/dist/open-browser.js.map +1 -0
- package/dist/pkce.d.ts +20 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +34 -0
- package/dist/pkce.js.map +1 -0
- package/dist/profile.d.ts +129 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +11 -0
- package/dist/profile.js.map +1 -0
- package/dist/run-login.d.ts +9 -0
- package/dist/run-login.d.ts.map +1 -0
- package/dist/run-login.js +93 -0
- package/dist/run-login.js.map +1 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +167 -0
- package/dist/storage.js.map +1 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +283 -0
- package/dist/tools.js.map +1 -0
- package/package.json +73 -0
- package/skills/google-oauth.md +313 -0
- package/skills/oauth-flow.md +132 -0
- package/src/adapters/openai-device-flow.test.ts +191 -0
- package/src/adapters/openai-device-flow.ts +179 -0
- package/src/adapters/rfc8628-device-flow.ts +75 -0
- package/src/credential-lock.ts +183 -0
- package/src/discovery.test.ts +35 -0
- package/src/ensure-fresh.test.ts +279 -0
- package/src/ensure-fresh.ts +178 -0
- package/src/flow.ts +9 -0
- package/src/index.ts +153 -0
- package/src/oauth/browser-flow.ts +96 -0
- package/src/oauth/callback-server.test.ts +90 -0
- package/src/oauth/callback-server.ts +208 -0
- package/src/oauth/device-flow-shared.test.ts +142 -0
- package/src/oauth/device-flow-shared.ts +129 -0
- package/src/oauth/device-flow.test.ts +102 -0
- package/src/oauth/device-flow.ts +82 -0
- package/src/oauth/poll-until.test.ts +63 -0
- package/src/oauth/poll-until.ts +100 -0
- package/src/oauth/token-exchange.test.ts +224 -0
- package/src/oauth/token-exchange.ts +169 -0
- package/src/oauth/types.ts +92 -0
- package/src/oauth.test.ts +185 -0
- package/src/open-browser-spawn.test.ts +69 -0
- package/src/open-browser.test.ts +30 -0
- package/src/open-browser.ts +84 -0
- package/src/pkce.ts +37 -0
- package/src/profile.test.ts +331 -0
- package/src/profile.ts +135 -0
- package/src/run-login.ts +123 -0
- package/src/storage.test.ts +191 -0
- package/src/storage.ts +208 -0
- package/src/tools.test.ts +239 -0
- package/src/tools.ts +332 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { classifyHttpStatus, MoxxyError } from '@moxxy/sdk';
|
|
2
|
+
import type { TokenSet } from './types.js';
|
|
3
|
+
|
|
4
|
+
interface ExchangeCodeInput {
|
|
5
|
+
readonly tokenUrl: string;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly redirectUri: string;
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
readonly clientSecret?: string;
|
|
10
|
+
readonly codeVerifier: string;
|
|
11
|
+
/** Abort the exchange fetch when the caller's flow is cancelled. */
|
|
12
|
+
readonly signal?: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function exchangeCodeForToken(
|
|
16
|
+
input: ExchangeCodeInput,
|
|
17
|
+
fetchImpl: typeof fetch = fetch,
|
|
18
|
+
): Promise<TokenSet> {
|
|
19
|
+
const body = new URLSearchParams();
|
|
20
|
+
body.set('grant_type', 'authorization_code');
|
|
21
|
+
body.set('code', input.code);
|
|
22
|
+
body.set('redirect_uri', input.redirectUri);
|
|
23
|
+
body.set('client_id', input.clientId);
|
|
24
|
+
body.set('code_verifier', input.codeVerifier);
|
|
25
|
+
if (input.clientSecret) body.set('client_secret', input.clientSecret);
|
|
26
|
+
const res = await fetchImpl(input.tokenUrl, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
29
|
+
body: body.toString(),
|
|
30
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const text = await res.text().catch(() => '');
|
|
34
|
+
throw (
|
|
35
|
+
classifyHttpStatus(res.status, { url: input.tokenUrl, body: text }) ??
|
|
36
|
+
new MoxxyError({
|
|
37
|
+
code: 'AUTH_INVALID',
|
|
38
|
+
message: `Token exchange failed (HTTP ${res.status})${errorSummary(text)}`,
|
|
39
|
+
context: { status: res.status, url: input.tokenUrl, ...bodyContext(text) },
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const json = await parseJsonBody(res, input.tokenUrl);
|
|
44
|
+
return parseTokenResponse(json);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a human-message suffix from a token-endpoint error body. Token
|
|
49
|
+
* endpoints return `{error, error_description}` (RFC 6749 §5.2); prefer those
|
|
50
|
+
* structured fields verbatim. For an opaque/HTML body (proxy/captive-portal
|
|
51
|
+
* error page) — provider/attacker-influenced text that flows to logs and can
|
|
52
|
+
* reach the model — emit nothing in the human message; the bounded raw body
|
|
53
|
+
* still lands in `context` via {@link bodyContext}.
|
|
54
|
+
*/
|
|
55
|
+
function errorSummary(body: string): string {
|
|
56
|
+
const fields = parseOauthError(body);
|
|
57
|
+
if (!fields) return '';
|
|
58
|
+
const { error, error_description } = fields;
|
|
59
|
+
const detail = error_description ?? error;
|
|
60
|
+
return detail ? `: ${detail.slice(0, 300)}` : '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Truncated raw body for the error context only (never the human message). */
|
|
64
|
+
function bodyContext(body: string): Record<string, string> {
|
|
65
|
+
return body ? { body: body.slice(0, 200) } : {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseOauthError(body: string): { error?: string; error_description?: string } | null {
|
|
69
|
+
if (!body) return null;
|
|
70
|
+
try {
|
|
71
|
+
const parsed: unknown = JSON.parse(body);
|
|
72
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
73
|
+
const obj = parsed as Record<string, unknown>;
|
|
74
|
+
const error = typeof obj.error === 'string' ? obj.error : undefined;
|
|
75
|
+
const error_description =
|
|
76
|
+
typeof obj.error_description === 'string' ? obj.error_description : undefined;
|
|
77
|
+
if (!error && !error_description) return null;
|
|
78
|
+
return {
|
|
79
|
+
...(error !== undefined ? { error } : {}),
|
|
80
|
+
...(error_description !== undefined ? { error_description } : {}),
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return null; // opaque / HTML body — don't reflect it into the message
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a 2xx token-endpoint body as JSON. A provider (or an intercepting
|
|
89
|
+
* proxy / captive portal / load-balancer error page) can return HTTP 200 with
|
|
90
|
+
* a non-JSON or truncated body; an unguarded `res.json()` would reject with a
|
|
91
|
+
* native SyntaxError that escapes the MoxxyError boundary (no code/hint, and
|
|
92
|
+
* `isAuthRejection` can't classify it). Convert it to a typed error instead.
|
|
93
|
+
*/
|
|
94
|
+
async function parseJsonBody(res: { json(): Promise<unknown> }, url: string): Promise<Record<string, unknown>> {
|
|
95
|
+
return (await res.json().catch(() => {
|
|
96
|
+
throw new MoxxyError({
|
|
97
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
98
|
+
message: 'token endpoint returned a non-JSON success body',
|
|
99
|
+
context: { url },
|
|
100
|
+
});
|
|
101
|
+
})) as Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Refresh an access token using a stored refresh_token. Same token
|
|
106
|
+
* endpoint, different grant_type. Returns a new TokenSet — note that
|
|
107
|
+
* providers MAY or MAY NOT rotate the refresh_token (Google does
|
|
108
|
+
* not; Auth0 with rotation does). Caller should preserve the prior
|
|
109
|
+
* refresh_token if the response omits one.
|
|
110
|
+
*/
|
|
111
|
+
export async function refreshAccessToken(
|
|
112
|
+
input: {
|
|
113
|
+
readonly tokenUrl: string;
|
|
114
|
+
readonly clientId: string;
|
|
115
|
+
readonly clientSecret?: string;
|
|
116
|
+
readonly refreshToken: string;
|
|
117
|
+
},
|
|
118
|
+
fetchImpl: typeof fetch = fetch,
|
|
119
|
+
): Promise<TokenSet> {
|
|
120
|
+
const body = new URLSearchParams();
|
|
121
|
+
body.set('grant_type', 'refresh_token');
|
|
122
|
+
body.set('refresh_token', input.refreshToken);
|
|
123
|
+
body.set('client_id', input.clientId);
|
|
124
|
+
if (input.clientSecret) body.set('client_secret', input.clientSecret);
|
|
125
|
+
const res = await fetchImpl(input.tokenUrl, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
128
|
+
body: body.toString(),
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const text = await res.text().catch(() => '');
|
|
132
|
+
// 401/403 here typically means the refresh_token was revoked/expired —
|
|
133
|
+
// classifyHttpStatus maps those to AUTH_INVALID/AUTH_DENIED. Surface a
|
|
134
|
+
// refresh-specific fallback for the unmapped statuses.
|
|
135
|
+
throw (
|
|
136
|
+
classifyHttpStatus(res.status, { url: input.tokenUrl, body: text }) ??
|
|
137
|
+
new MoxxyError({
|
|
138
|
+
code: 'AUTH_EXPIRED',
|
|
139
|
+
message: `Token refresh failed (HTTP ${res.status})${errorSummary(text)}`,
|
|
140
|
+
context: { status: res.status, url: input.tokenUrl, ...bodyContext(text) },
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const json = await parseJsonBody(res, input.tokenUrl);
|
|
145
|
+
return parseTokenResponse(json);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseTokenResponse(json: Record<string, unknown>): TokenSet {
|
|
149
|
+
const access = typeof json.access_token === 'string' ? json.access_token : null;
|
|
150
|
+
if (!access) {
|
|
151
|
+
throw new MoxxyError({
|
|
152
|
+
code: 'PROVIDER_UNKNOWN_RESPONSE',
|
|
153
|
+
message: `token response missing access_token: ${JSON.stringify(json).slice(0, 200)}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const refresh = typeof json.refresh_token === 'string' ? json.refresh_token : undefined;
|
|
157
|
+
const expiresIn = typeof json.expires_in === 'number' ? json.expires_in : null;
|
|
158
|
+
const scope = typeof json.scope === 'string' ? json.scope : undefined;
|
|
159
|
+
const tokenType = typeof json.token_type === 'string' ? json.token_type : 'Bearer';
|
|
160
|
+
const idToken = typeof json.id_token === 'string' ? json.id_token : undefined;
|
|
161
|
+
return {
|
|
162
|
+
accessToken: access,
|
|
163
|
+
...(refresh !== undefined ? { refreshToken: refresh } : {}),
|
|
164
|
+
...(expiresIn != null ? { expiresAt: Date.now() + expiresIn * 1000 } : {}),
|
|
165
|
+
...(scope !== undefined ? { scope } : {}),
|
|
166
|
+
tokenType,
|
|
167
|
+
...(idToken !== undefined ? { idToken } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface OAuthFlowOptions {
|
|
2
|
+
/** Provider's authorization endpoint, e.g. https://accounts.google.com/o/oauth2/v2/auth */
|
|
3
|
+
readonly authUrl: string;
|
|
4
|
+
/** Provider's token endpoint, e.g. https://oauth2.googleapis.com/token */
|
|
5
|
+
readonly tokenUrl: string;
|
|
6
|
+
readonly clientId: string;
|
|
7
|
+
/** Confidential clients only; loopback flows usually omit. */
|
|
8
|
+
readonly clientSecret?: string;
|
|
9
|
+
readonly scopes: ReadonlyArray<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Loopback redirect port. The redirect URI MUST be registered with
|
|
12
|
+
* the provider exactly (most providers require an exact match) — pick
|
|
13
|
+
* a value and tell the user to register it. Default 8765 to keep the
|
|
14
|
+
* Google Cloud Console setup deterministic across sessions.
|
|
15
|
+
*/
|
|
16
|
+
readonly redirectPort?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Loopback redirect path. Default `/callback`. Some providers reject
|
|
19
|
+
* paths other than `/`; tweak per provider. The full registered
|
|
20
|
+
* redirect URI must be `http://localhost:<port><path>` exactly.
|
|
21
|
+
*/
|
|
22
|
+
readonly redirectPath?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Provider-specific extra query parameters on the auth URL. Common
|
|
25
|
+
* cases: `access_type=offline` + `prompt=consent` for Google (forces
|
|
26
|
+
* the issuance of a refresh_token); `audience=...` for Auth0.
|
|
27
|
+
*/
|
|
28
|
+
readonly extraAuthParams?: Readonly<Record<string, string>>;
|
|
29
|
+
/** How long to wait for the callback before giving up. Default 5min. */
|
|
30
|
+
readonly timeoutMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Abort signal — when fired, shuts the local server and rejects the
|
|
33
|
+
* flow. Wire from `ctx.signal` so a turn cancel kills a pending auth.
|
|
34
|
+
*/
|
|
35
|
+
readonly signal?: AbortSignal;
|
|
36
|
+
/**
|
|
37
|
+
* When true, do NOT auto-open the browser. The auth URL still
|
|
38
|
+
* gets handed to `onAuthUrl`; the caller is expected to print it
|
|
39
|
+
* for the user to visit on a host where the loopback callback is
|
|
40
|
+
* reachable (same machine, SSH tunnel, port-forward).
|
|
41
|
+
*/
|
|
42
|
+
readonly noOpen?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Fires with the built auth URL just before the browser-open step.
|
|
45
|
+
* Use to log / display the URL so the user sees it even when
|
|
46
|
+
* auto-open fails or `noOpen` is set.
|
|
47
|
+
*/
|
|
48
|
+
readonly onAuthUrl?: (url: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeviceFlowOptions {
|
|
52
|
+
/** Provider's device-authorization endpoint (RFC 8628 §3.1). */
|
|
53
|
+
readonly deviceUrl: string;
|
|
54
|
+
readonly tokenUrl: string;
|
|
55
|
+
readonly clientId: string;
|
|
56
|
+
readonly clientSecret?: string;
|
|
57
|
+
readonly scopes: ReadonlyArray<string>;
|
|
58
|
+
/** Hard cap; the device-code's own expires_in usually drives the timer. */
|
|
59
|
+
readonly timeoutMs?: number;
|
|
60
|
+
readonly signal?: AbortSignal;
|
|
61
|
+
/**
|
|
62
|
+
* Fired ONCE with the user-facing prompt info as soon as the
|
|
63
|
+
* device endpoint returns. Channels should surface this prominently
|
|
64
|
+
* — the whole flow stalls until the user finishes on the URL.
|
|
65
|
+
*/
|
|
66
|
+
readonly onPrompt: (info: DevicePrompt) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DevicePrompt {
|
|
70
|
+
/** Short code the user types into the verification page. */
|
|
71
|
+
readonly userCode: string;
|
|
72
|
+
/** URL the user opens on any device. */
|
|
73
|
+
readonly verificationUri: string;
|
|
74
|
+
/** Some providers return a URL that already includes the user_code. */
|
|
75
|
+
readonly verificationUriComplete?: string;
|
|
76
|
+
/** Seconds until the device_code expires (from the provider). */
|
|
77
|
+
readonly expiresIn: number;
|
|
78
|
+
/** Poll interval the provider wants us to use, in seconds. */
|
|
79
|
+
readonly interval: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TokenSet {
|
|
83
|
+
readonly accessToken: string;
|
|
84
|
+
readonly refreshToken?: string;
|
|
85
|
+
/** Epoch ms when the access_token expires. */
|
|
86
|
+
readonly expiresAt?: number;
|
|
87
|
+
/** Granted scopes — provider may grant less than requested. */
|
|
88
|
+
readonly scope?: string;
|
|
89
|
+
readonly tokenType: string;
|
|
90
|
+
/** OIDC id_token if the provider returned one (Google does for `openid` scope). */
|
|
91
|
+
readonly idToken?: string;
|
|
92
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
3
|
+
import {
|
|
4
|
+
buildAuthUrl,
|
|
5
|
+
buildOauthAuthorizeTool,
|
|
6
|
+
buildOauthClearTool,
|
|
7
|
+
buildOauthGetTokenTool,
|
|
8
|
+
computeCodeChallenge,
|
|
9
|
+
generateCodeVerifier,
|
|
10
|
+
generateState,
|
|
11
|
+
validateProvider,
|
|
12
|
+
} from './index.js';
|
|
13
|
+
|
|
14
|
+
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
15
|
+
const fakeCtx = {
|
|
16
|
+
signal: new AbortController().signal,
|
|
17
|
+
logger: noopLogger,
|
|
18
|
+
} as never;
|
|
19
|
+
|
|
20
|
+
describe('@moxxy/plugin-oauth', () => {
|
|
21
|
+
describe('PKCE primitives', () => {
|
|
22
|
+
it('verifier is 43-128 chars and url-safe (RFC 7636 §4.1)', () => {
|
|
23
|
+
for (let i = 0; i < 20; i += 1) {
|
|
24
|
+
const v = generateCodeVerifier();
|
|
25
|
+
expect(v.length).toBeGreaterThanOrEqual(43);
|
|
26
|
+
expect(v.length).toBeLessThanOrEqual(128);
|
|
27
|
+
expect(v).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('challenge is S256(verifier) base64url-encoded', () => {
|
|
32
|
+
// RFC 7636 §B.1 worked example
|
|
33
|
+
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
34
|
+
const challenge = computeCodeChallenge(verifier);
|
|
35
|
+
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('state is high-entropy and url-safe', () => {
|
|
39
|
+
const a = generateState();
|
|
40
|
+
const b = generateState();
|
|
41
|
+
expect(a).not.toBe(b);
|
|
42
|
+
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
43
|
+
expect(a.length).toBeGreaterThanOrEqual(20);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('buildAuthUrl', () => {
|
|
48
|
+
it('includes all required OAuth + PKCE params', () => {
|
|
49
|
+
const url = buildAuthUrl({
|
|
50
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
51
|
+
clientId: 'ABC.apps.googleusercontent.com',
|
|
52
|
+
redirectUri: 'http://localhost:8765/callback',
|
|
53
|
+
scopes: ['openid', 'email', 'profile'],
|
|
54
|
+
codeChallenge: 'XYZ',
|
|
55
|
+
state: 'STATE',
|
|
56
|
+
});
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
expect(parsed.searchParams.get('client_id')).toBe('ABC.apps.googleusercontent.com');
|
|
59
|
+
expect(parsed.searchParams.get('redirect_uri')).toBe('http://localhost:8765/callback');
|
|
60
|
+
expect(parsed.searchParams.get('response_type')).toBe('code');
|
|
61
|
+
expect(parsed.searchParams.get('scope')).toBe('openid email profile');
|
|
62
|
+
expect(parsed.searchParams.get('code_challenge')).toBe('XYZ');
|
|
63
|
+
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
|
64
|
+
expect(parsed.searchParams.get('state')).toBe('STATE');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('merges extraAuthParams (Google offline access)', () => {
|
|
68
|
+
const url = buildAuthUrl({
|
|
69
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
70
|
+
clientId: 'X',
|
|
71
|
+
redirectUri: 'http://localhost:8765/callback',
|
|
72
|
+
scopes: ['email'],
|
|
73
|
+
codeChallenge: 'C',
|
|
74
|
+
state: 'S',
|
|
75
|
+
extraAuthParams: { access_type: 'offline', prompt: 'consent' },
|
|
76
|
+
});
|
|
77
|
+
const parsed = new URL(url);
|
|
78
|
+
expect(parsed.searchParams.get('access_type')).toBe('offline');
|
|
79
|
+
expect(parsed.searchParams.get('prompt')).toBe('consent');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('provider validation', () => {
|
|
84
|
+
it('accepts lowercase ids with . _ -', () => {
|
|
85
|
+
expect(() => validateProvider('google')).not.toThrow();
|
|
86
|
+
expect(() => validateProvider('google.workspace')).not.toThrow();
|
|
87
|
+
expect(() => validateProvider('github-enterprise')).not.toThrow();
|
|
88
|
+
expect(() => validateProvider('my_provider_42')).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects uppercase / spaces / slashes', () => {
|
|
92
|
+
expect(() => validateProvider('Google')).toThrow();
|
|
93
|
+
expect(() => validateProvider('google workspace')).toThrow();
|
|
94
|
+
expect(() => validateProvider('google/workspace')).toThrow();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('tool schemas', () => {
|
|
99
|
+
const noopDeps = { vault: {} as never };
|
|
100
|
+
|
|
101
|
+
it('authorize requires tokenUrl, scopes, clientId, provider', () => {
|
|
102
|
+
const tool = buildOauthAuthorizeTool(noopDeps);
|
|
103
|
+
expect(
|
|
104
|
+
tool.inputSchema.safeParse({
|
|
105
|
+
provider: 'google',
|
|
106
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
107
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
108
|
+
clientId: 'X',
|
|
109
|
+
scopes: ['email'],
|
|
110
|
+
}).success,
|
|
111
|
+
).toBe(true);
|
|
112
|
+
expect(tool.inputSchema.safeParse({ provider: 'google' }).success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('authorize accepts mode=device', () => {
|
|
116
|
+
const tool = buildOauthAuthorizeTool(noopDeps);
|
|
117
|
+
expect(
|
|
118
|
+
tool.inputSchema.safeParse({
|
|
119
|
+
provider: 'google',
|
|
120
|
+
deviceUrl: 'https://oauth2.googleapis.com/device/code',
|
|
121
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
122
|
+
clientId: 'X',
|
|
123
|
+
scopes: ['email'],
|
|
124
|
+
mode: 'device',
|
|
125
|
+
}).success,
|
|
126
|
+
).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('get_token has includeRefresh opt-in', () => {
|
|
130
|
+
const tool = buildOauthGetTokenTool(noopDeps);
|
|
131
|
+
expect(
|
|
132
|
+
tool.inputSchema.safeParse({ provider: 'google', includeRefresh: true }).success,
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('clear requires provider', () => {
|
|
137
|
+
const tool = buildOauthClearTool(noopDeps);
|
|
138
|
+
expect(tool.inputSchema.safeParse({ provider: 'google' }).success).toBe(true);
|
|
139
|
+
expect(tool.inputSchema.safeParse({}).success).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('authorize input validation', () => {
|
|
144
|
+
const deps = { vault: {} as never };
|
|
145
|
+
|
|
146
|
+
it('throws a TOOL_ERROR MoxxyError when mode=device is missing deviceUrl', async () => {
|
|
147
|
+
const tool = buildOauthAuthorizeTool(deps);
|
|
148
|
+
const err = await Promise.resolve()
|
|
149
|
+
.then(() => tool.handler({ provider: 'google', clientId: 'X', scopes: ['email'], tokenUrl: 'https://t', mode: 'device' }, fakeCtx))
|
|
150
|
+
.catch((e) => e);
|
|
151
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
152
|
+
expect((err as MoxxyError).code).toBe('TOOL_ERROR');
|
|
153
|
+
expect((err as MoxxyError).message).toMatch(/deviceUrl/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('throws a TOOL_ERROR MoxxyError when mode=loopback is missing authUrl', async () => {
|
|
157
|
+
const tool = buildOauthAuthorizeTool(deps);
|
|
158
|
+
const err = await Promise.resolve()
|
|
159
|
+
.then(() => tool.handler({ provider: 'google', clientId: 'X', scopes: ['email'], tokenUrl: 'https://t' }, fakeCtx))
|
|
160
|
+
.catch((e) => e);
|
|
161
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
162
|
+
expect((err as MoxxyError).code).toBe('TOOL_ERROR');
|
|
163
|
+
expect((err as MoxxyError).message).toMatch(/authUrl/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('end-to-end chain', () => {
|
|
168
|
+
it('exposes the three tools needed for OAuth → MCP wiring', () => {
|
|
169
|
+
const deps = { vault: {} as never };
|
|
170
|
+
const tools = [
|
|
171
|
+
buildOauthAuthorizeTool(deps),
|
|
172
|
+
buildOauthGetTokenTool(deps),
|
|
173
|
+
buildOauthClearTool(deps),
|
|
174
|
+
];
|
|
175
|
+
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
176
|
+
'oauth_authorize',
|
|
177
|
+
'oauth_clear_token',
|
|
178
|
+
'oauth_get_token',
|
|
179
|
+
]);
|
|
180
|
+
for (const tool of tools) {
|
|
181
|
+
expect(tool.permission?.action).toBe('prompt');
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Mock node:child_process so we can drive spawn's error/success timing
|
|
5
|
+
// deterministically without launching a real browser opener.
|
|
6
|
+
const spawnMock = vi.fn();
|
|
7
|
+
vi.mock('node:child_process', () => ({
|
|
8
|
+
spawn: (...args: unknown[]) => spawnMock(...args),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Imported AFTER the mock is registered.
|
|
12
|
+
const { openInBrowser } = await import('./open-browser.js');
|
|
13
|
+
|
|
14
|
+
interface FakeChild extends EventEmitter {
|
|
15
|
+
unref: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fakeChild(): FakeChild {
|
|
19
|
+
const ee = new EventEmitter() as FakeChild;
|
|
20
|
+
ee.unref = vi.fn();
|
|
21
|
+
return ee;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
spawnMock.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('openInBrowser — failure path degrades, never hangs or leaks', () => {
|
|
29
|
+
it('rejects when the opener process emits a spawn error (does NOT swallow it into a resolve)', async () => {
|
|
30
|
+
const child = fakeChild();
|
|
31
|
+
spawnMock.mockReturnValue(child);
|
|
32
|
+
const p = openInBrowser('https://example.test/auth?a=1&b=2');
|
|
33
|
+
// Fire the spawn error synchronously after the call returns.
|
|
34
|
+
queueMicrotask(() => child.emit('error', new Error('ENOENT')));
|
|
35
|
+
await expect(p).rejects.toThrow(/ENOENT/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('a LATE error after settle is swallowed, never an unhandled (fatal) error event', async () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
try {
|
|
41
|
+
const child = fakeChild();
|
|
42
|
+
spawnMock.mockReturnValue(child);
|
|
43
|
+
const p = openInBrowser('https://example.test/auth');
|
|
44
|
+
// Advance past the 50ms settle so the success path resolves first.
|
|
45
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
46
|
+
await expect(p).resolves.toBeUndefined();
|
|
47
|
+
// A late async spawn failure must not throw. The error listener stays
|
|
48
|
+
// attached (a listenerless 'error' event crashes the process in Node), so
|
|
49
|
+
// the late error is handled and harmlessly ignored.
|
|
50
|
+
expect(() => child.emit('error', new Error('late'))).not.toThrow();
|
|
51
|
+
} finally {
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resolves after the spawn tick on the happy path and unrefs the child', async () => {
|
|
57
|
+
vi.useFakeTimers();
|
|
58
|
+
try {
|
|
59
|
+
const child = fakeChild();
|
|
60
|
+
spawnMock.mockReturnValue(child);
|
|
61
|
+
const p = openInBrowser('https://example.test/auth');
|
|
62
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
63
|
+
await expect(p).resolves.toBeUndefined();
|
|
64
|
+
expect(child.unref).toHaveBeenCalled();
|
|
65
|
+
} finally {
|
|
66
|
+
vi.useRealTimers();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { browserOpenCommand } from './open-browser';
|
|
3
|
+
|
|
4
|
+
// A realistic OAuth authorize URL: many `&`-separated params, which cmd.exe
|
|
5
|
+
// treats as command separators unless the whole URL is quoted.
|
|
6
|
+
const AUTH_URL =
|
|
7
|
+
'https://auth.openai.com/oauth/authorize?client_id=app_x&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&response_type=code&scope=openid+profile&code_challenge=abc123&state=deadbeef';
|
|
8
|
+
|
|
9
|
+
describe('browserOpenCommand', () => {
|
|
10
|
+
it('macOS uses `open` with the URL as a single arg', () => {
|
|
11
|
+
expect(browserOpenCommand(AUTH_URL, 'darwin')).toEqual({ cmd: 'open', args: [AUTH_URL] });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('Linux uses `xdg-open` with the URL as a single arg', () => {
|
|
15
|
+
expect(browserOpenCommand(AUTH_URL, 'linux')).toEqual({ cmd: 'xdg-open', args: [AUTH_URL] });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('Windows DOUBLE-QUOTES the URL so cmd.exe does not split it on `&`', () => {
|
|
19
|
+
const { cmd, args, verbatim } = browserOpenCommand(AUTH_URL, 'win32');
|
|
20
|
+
expect(cmd).toMatch(/cmd(\.exe)?$/i);
|
|
21
|
+
expect(verbatim).toBe(true);
|
|
22
|
+
expect(args.slice(0, 3)).toEqual(['/c', 'start', '""']);
|
|
23
|
+
// The whole URL — every `&` included — is inside one double-quoted token.
|
|
24
|
+
expect(args[3]).toBe(`"${AUTH_URL}"`);
|
|
25
|
+
// Sanity: the quoted arg still contains all the params after the first `&`.
|
|
26
|
+
expect(args[3]).toContain('redirect_uri');
|
|
27
|
+
expect(args[3]).toContain('code_challenge');
|
|
28
|
+
expect(args[3]).toContain('state=deadbeef');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open a URL in the user's default browser. Platform-specific:
|
|
5
|
+
* macOS → `open <url>`
|
|
6
|
+
* Linux → `xdg-open <url>`
|
|
7
|
+
* Win32 → `cmd /c start "" "<url>"` (the URL DOUBLE-QUOTED + args passed
|
|
8
|
+
* verbatim — see {@link browserOpenCommand})
|
|
9
|
+
*
|
|
10
|
+
* Returns once the helper process is spawned — it does NOT wait for the
|
|
11
|
+
* browser itself, since the user's flow continues asynchronously via
|
|
12
|
+
* the OAuth callback. Failures fall back to returning the URL string
|
|
13
|
+
* so the caller can print it for the user to paste manually.
|
|
14
|
+
*/
|
|
15
|
+
export async function openInBrowser(url: string): Promise<void> {
|
|
16
|
+
const { cmd, args, verbatim } = browserOpenCommand(url);
|
|
17
|
+
await new Promise<void>((resolve, reject) => {
|
|
18
|
+
const child = spawn(cmd, args, {
|
|
19
|
+
stdio: 'ignore',
|
|
20
|
+
detached: true,
|
|
21
|
+
// Windows only: keep cmd.exe from re-quoting our already-quoted URL,
|
|
22
|
+
// which would otherwise break the `&` escaping (see below).
|
|
23
|
+
...(verbatim ? { windowsVerbatimArguments: true } : {}),
|
|
24
|
+
});
|
|
25
|
+
// Settle exactly once. An early spawn `error` rejects AND clears the pending
|
|
26
|
+
// 50ms timer (else it would fire a stray resolve later and hold a live timer
|
|
27
|
+
// handle on the event loop). After we've resolved, a LATE async spawn error
|
|
28
|
+
// must NOT be left unhandled: an 'error' event with no listener is fatal in
|
|
29
|
+
// Node (it re-throws as an uncaught exception). So we keep an error listener
|
|
30
|
+
// attached for the child's whole lifetime — it rejects before settle, and
|
|
31
|
+
// harmlessly swallows after.
|
|
32
|
+
let settled = false;
|
|
33
|
+
const onError = (err: Error): void => {
|
|
34
|
+
if (settled) return; // post-resolve late error: swallow, never crash
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
reject(err);
|
|
38
|
+
};
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
resolve();
|
|
43
|
+
}, 50);
|
|
44
|
+
// Don't let the 50ms settle-timer keep the process alive on its own.
|
|
45
|
+
timer.unref?.();
|
|
46
|
+
// `on` (not `once`): the listener stays for the child's lifetime so any late
|
|
47
|
+
// 'error' is always handled (a listenerless 'error' would crash the process).
|
|
48
|
+
child.on('error', onError);
|
|
49
|
+
// `unref` so a misbehaving opener process can't keep the moxxy
|
|
50
|
+
// process alive past its own lifetime.
|
|
51
|
+
child.unref();
|
|
52
|
+
// Resolve after spawn — we never want to wait for the browser to
|
|
53
|
+
// close. A 50ms tick is enough to surface spawn-time errors.
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the platform's "open this URL in the default browser" command.
|
|
59
|
+
* Exported + `platform`-parameterized so the Windows quoting can be unit-tested
|
|
60
|
+
* without actually spawning a browser.
|
|
61
|
+
*
|
|
62
|
+
* Windows is the tricky one: `&` (and `|`, `<`, `>`, `^`) in a URL are cmd.exe
|
|
63
|
+
* command separators. `cmd /c start "" http://a?x=1&y=2` truncates the URL at
|
|
64
|
+
* the first `&`, so an OAuth authorize URL loses redirect_uri / code_challenge /
|
|
65
|
+
* state and the provider rejects the request ("authorization error"). Wrapping
|
|
66
|
+
* the URL in double quotes makes cmd treat it as one literal token; passing the
|
|
67
|
+
* args verbatim (`windowsVerbatimArguments`) stops Node from re-quoting and
|
|
68
|
+
* undoing that. The empty `""` is the required `start` window-title placeholder.
|
|
69
|
+
*/
|
|
70
|
+
export function browserOpenCommand(
|
|
71
|
+
url: string,
|
|
72
|
+
platform: NodeJS.Platform = process.platform,
|
|
73
|
+
): { cmd: string; args: string[]; verbatim?: boolean } {
|
|
74
|
+
if (platform === 'darwin') return { cmd: 'open', args: [url] };
|
|
75
|
+
if (platform === 'win32') {
|
|
76
|
+
return {
|
|
77
|
+
cmd: process.env.ComSpec || 'cmd.exe',
|
|
78
|
+
args: ['/c', 'start', '""', `"${url}"`],
|
|
79
|
+
verbatim: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Linux + everything else assumes a Freedesktop-compliant `xdg-open`.
|
|
83
|
+
return { cmd: 'xdg-open', args: [url] };
|
|
84
|
+
}
|
package/src/pkce.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a PKCE code verifier per RFC 7636 §4.1.
|
|
5
|
+
* Spec range: 43–128 chars from the unreserved set [A-Z a-z 0-9 - . _ ~].
|
|
6
|
+
* We use 96 bytes of entropy → 128 base64url chars (full length).
|
|
7
|
+
*/
|
|
8
|
+
export function generateCodeVerifier(): string {
|
|
9
|
+
return base64urlEncode(randomBytes(96));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* RFC 7636 §4.2 — SHA-256(verifier) then base64url-encode. The
|
|
14
|
+
* challenge_method is implied: callers MUST send `code_challenge_method=S256`
|
|
15
|
+
* to the authorization endpoint when using this helper.
|
|
16
|
+
*/
|
|
17
|
+
export function computeCodeChallenge(codeVerifier: string): string {
|
|
18
|
+
return base64urlEncode(createHash('sha256').update(codeVerifier).digest());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Opaque CSRF token included in the `state` query param. The callback
|
|
23
|
+
* server verifies that the returned state matches what we sent, so a
|
|
24
|
+
* cross-site forgery can't redirect the user back into our local
|
|
25
|
+
* server with attacker-controlled code.
|
|
26
|
+
*/
|
|
27
|
+
export function generateState(): string {
|
|
28
|
+
return base64urlEncode(randomBytes(24));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function base64urlEncode(buf: Buffer): string {
|
|
32
|
+
return buf
|
|
33
|
+
.toString('base64')
|
|
34
|
+
.replace(/\+/g, '-')
|
|
35
|
+
.replace(/\//g, '_')
|
|
36
|
+
.replace(/=+$/, '');
|
|
37
|
+
}
|