flipstream 0.4.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/LICENSE +202 -0
- package/README.md +659 -0
- package/bin/dev.js +9 -0
- package/bin/run.js +7 -0
- package/dist/commands/auth/clear-all.d.ts +10 -0
- package/dist/commands/auth/clear-all.js +50 -0
- package/dist/commands/auth/login.d.ts +11 -0
- package/dist/commands/auth/login.js +49 -0
- package/dist/commands/auth/logout.d.ts +10 -0
- package/dist/commands/auth/logout.js +51 -0
- package/dist/commands/auth/status.d.ts +8 -0
- package/dist/commands/auth/status.js +47 -0
- package/dist/commands/connections/list.d.ts +19 -0
- package/dist/commands/connections/list.js +43 -0
- package/dist/commands/query.d.ts +13 -0
- package/dist/commands/query.js +76 -0
- package/dist/commands/version.d.ts +15 -0
- package/dist/commands/version.js +17 -0
- package/dist/commands/workspaces/connections.d.ts +21 -0
- package/dist/commands/workspaces/connections.js +41 -0
- package/dist/commands/workspaces/get.d.ts +15 -0
- package/dist/commands/workspaces/get.js +65 -0
- package/dist/commands/workspaces/list.d.ts +20 -0
- package/dist/commands/workspaces/list.js +87 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/api/admin-client.d.ts +19 -0
- package/dist/lib/api/admin-client.js +44 -0
- package/dist/lib/api/client.d.ts +13 -0
- package/dist/lib/api/client.js +21 -0
- package/dist/lib/api/connections.d.ts +14 -0
- package/dist/lib/api/connections.js +44 -0
- package/dist/lib/api/errors.d.ts +7 -0
- package/dist/lib/api/errors.js +68 -0
- package/dist/lib/api/http.d.ts +9 -0
- package/dist/lib/api/http.js +55 -0
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +8 -0
- package/dist/lib/api/list.d.ts +12 -0
- package/dist/lib/api/list.js +31 -0
- package/dist/lib/api/projections.d.ts +2 -0
- package/dist/lib/api/projections.js +34 -0
- package/dist/lib/api/retry.d.ts +13 -0
- package/dist/lib/api/retry.js +37 -0
- package/dist/lib/auth/claims.d.ts +3 -0
- package/dist/lib/auth/claims.js +20 -0
- package/dist/lib/auth/discovery.d.ts +13 -0
- package/dist/lib/auth/discovery.js +62 -0
- package/dist/lib/auth/flow.d.ts +22 -0
- package/dist/lib/auth/flow.js +135 -0
- package/dist/lib/auth/headless.d.ts +12 -0
- package/dist/lib/auth/headless.js +61 -0
- package/dist/lib/auth/loopback.d.ts +15 -0
- package/dist/lib/auth/loopback.js +198 -0
- package/dist/lib/auth/pkce.d.ts +6 -0
- package/dist/lib/auth/pkce.js +17 -0
- package/dist/lib/auth/provider.d.ts +15 -0
- package/dist/lib/auth/provider.js +23 -0
- package/dist/lib/auth/refresh.d.ts +6 -0
- package/dist/lib/auth/refresh.js +69 -0
- package/dist/lib/auth/register.d.ts +4 -0
- package/dist/lib/auth/register.js +43 -0
- package/dist/lib/auth/revoke.d.ts +8 -0
- package/dist/lib/auth/revoke.js +28 -0
- package/dist/lib/auth/session.d.ts +6 -0
- package/dist/lib/auth/session.js +13 -0
- package/dist/lib/command/admin.d.ts +4 -0
- package/dist/lib/command/admin.js +30 -0
- package/dist/lib/command/base.d.ts +28 -0
- package/dist/lib/command/base.js +75 -0
- package/dist/lib/command/crash.d.ts +2 -0
- package/dist/lib/command/crash.js +37 -0
- package/dist/lib/command/flags.d.ts +7 -0
- package/dist/lib/command/flags.js +10 -0
- package/dist/lib/config/constants.d.ts +5 -0
- package/dist/lib/config/constants.js +20 -0
- package/dist/lib/config/xdg.d.ts +21 -0
- package/dist/lib/config/xdg.js +97 -0
- package/dist/lib/errors.d.ts +38 -0
- package/dist/lib/errors.js +122 -0
- package/dist/lib/exit-codes.d.ts +10 -0
- package/dist/lib/exit-codes.js +13 -0
- package/dist/lib/help.d.ts +4 -0
- package/dist/lib/help.js +14 -0
- package/dist/lib/output/ndjson.d.ts +1 -0
- package/dist/lib/output/ndjson.js +16 -0
- package/dist/lib/output/redact.d.ts +1 -0
- package/dist/lib/output/redact.js +47 -0
- package/dist/lib/output/render.d.ts +2 -0
- package/dist/lib/output/render.js +8 -0
- package/dist/lib/output/table.d.ts +3 -0
- package/dist/lib/output/table.js +19 -0
- package/dist/lib/store/credentials.d.ts +12 -0
- package/dist/lib/store/credentials.js +40 -0
- package/dist/lib/store/index.d.ts +5 -0
- package/dist/lib/store/index.js +21 -0
- package/dist/lib/store/keyring.d.ts +16 -0
- package/dist/lib/store/keyring.js +45 -0
- package/dist/lib/store/memory-store.d.ts +10 -0
- package/dist/lib/store/memory-store.js +21 -0
- package/oclif.manifest.json +851 -0
- package/package.json +79 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { decodeJwtClaimsUnverified } from './claims.js';
|
|
2
|
+
export interface LoginOptions {
|
|
3
|
+
clientId?: string;
|
|
4
|
+
host?: string;
|
|
5
|
+
open?: (url: string) => boolean | void;
|
|
6
|
+
openBrowser?: boolean;
|
|
7
|
+
promptRedirect?: (authorizeUrl: string) => Promise<string>;
|
|
8
|
+
redirectTimeoutMs?: number;
|
|
9
|
+
scopes?: string[];
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface LoginResult {
|
|
13
|
+
accessToken: string;
|
|
14
|
+
claims: null | Record<string, unknown>;
|
|
15
|
+
clientId: string;
|
|
16
|
+
expiresIn: number;
|
|
17
|
+
host: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
refreshToken: string;
|
|
20
|
+
scopes: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function login(options?: LoginOptions): Promise<LoginResult>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { DEFAULT_HOST, OAUTH_CLI_CLIENT_ID } from '../config/constants.js';
|
|
3
|
+
import { AuthFailedError } from '../errors.js';
|
|
4
|
+
import { decodeJwtClaimsUnverified } from './claims.js';
|
|
5
|
+
import { fetchMetadata } from './discovery.js';
|
|
6
|
+
import { defaultPromptRedirect, detectHeadless, parseRedirect } from './headless.js';
|
|
7
|
+
import { DEFAULT_REDIRECT_TIMEOUT_MS, startLoopback } from './loopback.js';
|
|
8
|
+
import { generatePair } from './pkce.js';
|
|
9
|
+
import { buildAuthorizeUrl } from './provider.js';
|
|
10
|
+
export { decodeJwtClaimsUnverified } from './claims.js';
|
|
11
|
+
const DEFAULT_SCOPES = ['read', 'write'];
|
|
12
|
+
function tokenErrorMessage(status, body) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(body);
|
|
15
|
+
if (parsed.error) {
|
|
16
|
+
return parsed.error_description ? `${parsed.error}: ${parsed.error_description}` : parsed.error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Not JSON — fall through to the generic message.
|
|
21
|
+
}
|
|
22
|
+
return `Token request failed: HTTP ${status}`;
|
|
23
|
+
}
|
|
24
|
+
async function exchangeCode(params) {
|
|
25
|
+
const body = new URLSearchParams({
|
|
26
|
+
client_id: params.clientId,
|
|
27
|
+
code: params.code,
|
|
28
|
+
code_verifier: params.verifier,
|
|
29
|
+
grant_type: 'authorization_code',
|
|
30
|
+
redirect_uri: params.redirectUri,
|
|
31
|
+
});
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(params.meta.tokenEndpoint, {
|
|
37
|
+
body: body.toString(),
|
|
38
|
+
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
|
|
39
|
+
method: 'POST',
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const reason = controller.signal.aborted ? `timed out after ${params.timeoutMs}ms` : error.message;
|
|
45
|
+
throw new AuthFailedError(`Token request failed: ${reason}`, 'token_exchange_failed');
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
if (response.status >= 400) {
|
|
52
|
+
throw new AuthFailedError(tokenErrorMessage(response.status, text), 'token_exchange_failed');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new AuthFailedError(`Token request failed: HTTP ${response.status}`, 'token_exchange_failed');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function defaultOpen(url) {
|
|
62
|
+
void import('open').then(({ default: open }) => open(url)).catch(() => { });
|
|
63
|
+
}
|
|
64
|
+
// Full login: discovery -> loopback -> authorize (browser) -> code -> token
|
|
65
|
+
// exchange, using the fixed first-party client_id (no per-login DCR). Tokens
|
|
66
|
+
// stay in memory (keyring persistence is E2). The raw authorization code and
|
|
67
|
+
// tokens are never logged here.
|
|
68
|
+
export async function login(options = {}) {
|
|
69
|
+
const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, '');
|
|
70
|
+
const scopes = options.scopes && options.scopes.length > 0 ? options.scopes : DEFAULT_SCOPES;
|
|
71
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
72
|
+
const state = randomBytes(24).toString('base64url');
|
|
73
|
+
const { challenge, verifier } = generatePair();
|
|
74
|
+
const meta = await fetchMetadata(host, { timeoutMs });
|
|
75
|
+
const loopback = await startLoopback({
|
|
76
|
+
expectedIss: meta.issuer,
|
|
77
|
+
expectedState: state,
|
|
78
|
+
requireIss: meta.issParameterSupported === true,
|
|
79
|
+
timeoutMs: options.redirectTimeoutMs ?? DEFAULT_REDIRECT_TIMEOUT_MS,
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
// Fixed first-party public client_id (non-secret). No per-login DCR.
|
|
83
|
+
const clientId = options.clientId ?? process.env.FLIPSTREAM_OAUTH_CLIENT_ID ?? OAUTH_CLI_CLIENT_ID;
|
|
84
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
85
|
+
authorizationEndpoint: meta.authorizationEndpoint,
|
|
86
|
+
clientId,
|
|
87
|
+
codeChallenge: challenge,
|
|
88
|
+
redirectUri: loopback.redirectUri,
|
|
89
|
+
scopes,
|
|
90
|
+
state,
|
|
91
|
+
});
|
|
92
|
+
// Manual paste-back flow for headless/SSH/CI or --no-browser. An explicitly
|
|
93
|
+
// injected driver (open/promptRedirect) opts a test out of env auto-detection.
|
|
94
|
+
const explicitDriver = typeof options.open === 'function' || typeof options.promptRedirect === 'function';
|
|
95
|
+
const manual = options.openBrowser === false || (!explicitDriver && detectHeadless());
|
|
96
|
+
// In manual mode the redirect lands on the user's other machine, never this
|
|
97
|
+
// loopback; swallow its eventual timeout so it cannot surface as an unhandled
|
|
98
|
+
// rejection. close() (in finally) clears the timer on the normal fast path.
|
|
99
|
+
const runManual = async () => {
|
|
100
|
+
void loopback.waitForCode().catch(() => { });
|
|
101
|
+
const pasted = await (options.promptRedirect ?? defaultPromptRedirect)(authorizeUrl);
|
|
102
|
+
if (!pasted || !pasted.trim()) {
|
|
103
|
+
throw new AuthFailedError('No redirected URL was provided. Browser login is not possible in a non-interactive ' +
|
|
104
|
+
'environment; use a service-account / client_credentials flow instead (PLANNED).', 'headless_no_input');
|
|
105
|
+
}
|
|
106
|
+
return parseRedirect(pasted, state).code;
|
|
107
|
+
};
|
|
108
|
+
let code;
|
|
109
|
+
if (manual) {
|
|
110
|
+
code = await runManual();
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
process.stderr.write(`Opening your browser to sign in. If it does not open, visit:\n ${authorizeUrl}\n`);
|
|
114
|
+
const opened = (options.open ?? defaultOpen)(authorizeUrl);
|
|
115
|
+
code = opened === false ? await runManual() : await loopback.waitForCode();
|
|
116
|
+
}
|
|
117
|
+
const token = await exchangeCode({ clientId, code, meta, redirectUri: loopback.redirectUri, timeoutMs, verifier });
|
|
118
|
+
if (!token.access_token) {
|
|
119
|
+
throw new AuthFailedError('Token response did not include an access_token', 'token_exchange_failed');
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
accessToken: token.access_token,
|
|
123
|
+
claims: decodeJwtClaimsUnverified(token.access_token),
|
|
124
|
+
clientId,
|
|
125
|
+
expiresIn: token.expires_in ?? 0,
|
|
126
|
+
host,
|
|
127
|
+
redirectUri: loopback.redirectUri,
|
|
128
|
+
refreshToken: token.refresh_token ?? '',
|
|
129
|
+
scopes,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await loopback.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface HeadlessProbe {
|
|
2
|
+
browserOpened?: boolean;
|
|
3
|
+
env?: NodeJS.ProcessEnv;
|
|
4
|
+
platform?: NodeJS.Platform;
|
|
5
|
+
}
|
|
6
|
+
export declare function detectHeadless(probe?: HeadlessProbe): boolean;
|
|
7
|
+
export interface ParsedRedirect {
|
|
8
|
+
code: string;
|
|
9
|
+
state: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseRedirect(pasted: string, expectedState: string): ParsedRedirect;
|
|
12
|
+
export declare function defaultPromptRedirect(authorizeUrl: string): Promise<string>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { LoopbackError } from '../errors.js';
|
|
3
|
+
// True when there is no interactive browser to drive the OAuth redirect: an
|
|
4
|
+
// explicit open() failure, a CI run, an SSH session, or Linux without a display.
|
|
5
|
+
export function detectHeadless(probe = {}) {
|
|
6
|
+
const env = probe.env ?? process.env;
|
|
7
|
+
const platform = probe.platform ?? process.platform;
|
|
8
|
+
if (probe.browserOpened === false)
|
|
9
|
+
return true;
|
|
10
|
+
if (env.CI)
|
|
11
|
+
return true;
|
|
12
|
+
if (env.SSH_CONNECTION || env.SSH_TTY)
|
|
13
|
+
return true;
|
|
14
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
15
|
+
return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// Parse a pasted redirected loopback URL (the http://127.0.0.1:<port>/callback?...
|
|
19
|
+
// the browser failed to load on the headless box). Binds the response to our
|
|
20
|
+
// request by validating state first, then surfaces any provider error, then the
|
|
21
|
+
// code — mirroring the loopback server's validation order.
|
|
22
|
+
export function parseRedirect(pasted, expectedState) {
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(pasted.trim());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new LoopbackError('Pasted value is not a valid URL.');
|
|
29
|
+
}
|
|
30
|
+
const params = url.searchParams;
|
|
31
|
+
const state = params.get('state');
|
|
32
|
+
if (state !== expectedState) {
|
|
33
|
+
throw new LoopbackError('State mismatch in the pasted redirect URL.');
|
|
34
|
+
}
|
|
35
|
+
const error = params.get('error');
|
|
36
|
+
if (error !== null) {
|
|
37
|
+
const description = params.get('error_description');
|
|
38
|
+
throw new LoopbackError(description ? `${error}: ${description}` : error);
|
|
39
|
+
}
|
|
40
|
+
const code = params.get('code');
|
|
41
|
+
if (code === null || code === '') {
|
|
42
|
+
throw new LoopbackError('The pasted redirect URL is missing the authorization code.');
|
|
43
|
+
}
|
|
44
|
+
return { code, state };
|
|
45
|
+
}
|
|
46
|
+
// Default manual-mode reader: print the authorize URL + instructions to STDERR
|
|
47
|
+
// (stdout stays clean for the eventual JSON) and read the pasted redirect from stdin.
|
|
48
|
+
export function defaultPromptRedirect(authorizeUrl) {
|
|
49
|
+
process.stderr.write('\nNo browser is available here (headless / SSH / CI).\n' +
|
|
50
|
+
'Open this URL in a browser on any machine and approve access:\n\n' +
|
|
51
|
+
` ${authorizeUrl}\n\n` +
|
|
52
|
+
'Your browser will then try to load a http://127.0.0.1/... address that fails to\n' +
|
|
53
|
+
'connect. Copy that full URL from the address bar and paste it below.\n\n');
|
|
54
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
rl.question('Paste the redirected URL here: ', (answer) => {
|
|
57
|
+
rl.close();
|
|
58
|
+
resolve(answer);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { LoopbackError } from '../errors.js';
|
|
2
|
+
export declare const DEFAULT_REDIRECT_TIMEOUT_MS = 300000;
|
|
3
|
+
export interface LoopbackOptions {
|
|
4
|
+
expectedIss?: string;
|
|
5
|
+
expectedState: string;
|
|
6
|
+
requireIss?: boolean;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface LoopbackServer {
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
port: number;
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
waitForCode(): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare function startLoopback(options: LoopbackOptions): Promise<LoopbackServer>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { LoopbackError } from '../errors.js';
|
|
3
|
+
import { ExitCode } from '../exit-codes.js';
|
|
4
|
+
export { LoopbackError } from '../errors.js';
|
|
5
|
+
export const DEFAULT_REDIRECT_TIMEOUT_MS = 300_000;
|
|
6
|
+
// Inlined Flipstream wordmark (assets/flipstream-text.svg) so the loopback page
|
|
7
|
+
// is fully self-contained / offline — no external asset fetch, no phone-home.
|
|
8
|
+
const LOGO_SVG = `<svg class="logo" viewBox="0 0 154 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flipstream">
|
|
9
|
+
<path d="M0 12.8C0 8.31958 0 6.07937 0.871948 4.36808C1.63893 2.86278 2.86278 1.63893 4.36808 0.871948C6.07937 0 8.31958 0 12.8 0H19.2C23.6804 0 25.9206 0 27.6319 0.871948C29.1372 1.63893 30.3611 2.86278 31.1281 4.36808C32 6.07937 32 8.31958 32 12.8V19.2C32 23.6804 32 25.9206 31.1281 27.6319C30.3611 29.1372 29.1372 30.3611 27.6319 31.1281C25.9206 32 23.6804 32 19.2 32H12.8C8.31958 32 6.07937 32 4.36808 31.1281C2.86278 30.3611 1.63893 29.1372 0.871948 27.6319C0 25.9206 0 23.6804 0 19.2V12.8Z" fill="white"/>
|
|
10
|
+
<path d="M12.7998 0.0996094H19.2002C21.442 0.0996094 23.1173 0.100404 24.4463 0.208984C25.7741 0.317475 26.7477 0.533352 27.5869 0.960938C29.0732 1.7183 30.2817 2.92681 31.0391 4.41309C31.4666 5.25227 31.6825 6.22592 31.791 7.55371C31.8996 8.88267 31.9004 10.558 31.9004 12.7998V19.2002C31.9004 21.442 31.8996 23.1173 31.791 24.4463C31.6825 25.7741 31.4666 26.7477 31.0391 27.5869C30.2817 29.0732 29.0732 30.2817 27.5869 31.0391C26.7477 31.4666 25.7741 31.6825 24.4463 31.791C23.1173 31.8996 21.442 31.9004 19.2002 31.9004H12.7998C10.558 31.9004 8.88267 31.8996 7.55371 31.791C6.22592 31.6825 5.25227 31.4666 4.41309 31.0391C2.92681 30.2817 1.7183 29.0732 0.960938 27.5869C0.533352 26.7477 0.317475 25.7741 0.208984 24.4463C0.100404 23.1173 0.0996094 21.442 0.0996094 19.2002V12.7998C0.0996094 10.558 0.100404 8.88267 0.208984 7.55371C0.317475 6.22592 0.533352 5.25227 0.960938 4.41309C1.7183 2.92681 2.92681 1.7183 4.41309 0.960938C5.25227 0.533352 6.22592 0.317475 7.55371 0.208984C8.88267 0.100404 10.558 0.0996094 12.7998 0.0996094Z" stroke="#0A0D12" stroke-opacity="0.12" stroke-width="0.2"/>
|
|
11
|
+
<path d="M7.75 8.63462C7.75 6.76534 9.27223 5.25 11.15 5.25H23.9C24.3694 5.25 24.75 5.62884 24.75 6.09615V8.63462C24.75 10.5039 23.2278 12.0192 21.35 12.0192H8.6C8.13056 12.0192 7.75 11.6404 7.75 11.1731V8.63462Z" fill="#7F41BD"/>
|
|
12
|
+
<path d="M7.75 16.25C7.75 14.3807 9.27223 12.8654 11.15 12.8654H18.8C19.2694 12.8654 19.65 13.2442 19.65 13.7115V16.25C19.65 18.1193 18.1278 19.6346 16.25 19.6346H8.6C8.13056 19.6346 7.75 19.2558 7.75 18.7885V16.25Z" fill="#A57AD1"/>
|
|
13
|
+
<path d="M7.75 23.8654C7.75 21.9961 9.27223 20.4808 11.15 20.4808C13.0278 20.4808 14.55 21.9961 14.55 23.8654C14.55 25.7347 13.0278 27.25 11.15 27.25C9.27223 27.25 7.75 25.7347 7.75 23.8654Z" fill="#D2BCE8"/>
|
|
14
|
+
<path d="M51.0477 8.59532V10.9675H44.9389V14.7251H49.6202V17.0552H44.9389V23.248H42V8.59532H51.0477Z" fill="#4B2B73"/>
|
|
15
|
+
<path d="M56.0706 7.71364V23.248H53.1317V7.71364H56.0706Z" fill="#4B2B73"/>
|
|
16
|
+
<path d="M60.4648 10.2327C59.9469 10.2327 59.5131 10.0718 59.1632 9.7499C58.8273 9.41402 58.6594 9.00117 58.6594 8.51135C58.6594 8.02153 58.8273 7.61568 59.1632 7.29379C59.5131 6.95792 59.9469 6.78998 60.4648 6.78998C60.9826 6.78998 61.4094 6.95792 61.7453 7.29379C62.0952 7.61568 62.2701 8.02153 62.2701 8.51135C62.2701 9.00117 62.0952 9.41402 61.7453 9.7499C61.4094 10.0718 60.9826 10.2327 60.4648 10.2327ZM61.9132 11.6182V23.248H58.9743V11.6182H61.9132Z" fill="#4B2B73"/>
|
|
17
|
+
<path d="M67.7558 13.2976C68.1337 12.7658 68.6515 12.325 69.3093 11.9751C69.981 11.6112 70.7437 11.4293 71.5974 11.4293C72.5911 11.4293 73.4867 11.6742 74.2845 12.164C75.0962 12.6538 75.7329 13.3536 76.1948 14.2633C76.6706 15.1589 76.9085 16.2016 76.9085 17.3911C76.9085 18.5807 76.6706 19.6373 76.1948 20.561C75.7329 21.4706 75.0962 22.1774 74.2845 22.6812C73.4867 23.185 72.5911 23.4369 71.5974 23.4369C70.7437 23.4369 69.988 23.262 69.3303 22.9121C68.6865 22.5622 68.1617 22.1214 67.7558 21.5896V28.79H64.8169V11.6182H67.7558V13.2976ZM73.9066 17.3911C73.9066 16.6914 73.7596 16.0896 73.4658 15.5858C73.1859 15.068 72.808 14.6761 72.3322 14.4102C71.8703 14.1443 71.3665 14.0114 70.8207 14.0114C70.2889 14.0114 69.7851 14.1513 69.3093 14.4312C68.8474 14.6971 68.4696 15.089 68.1757 15.6068C67.8958 16.1246 67.7558 16.7334 67.7558 17.4331C67.7558 18.1329 67.8958 18.7416 68.1757 19.2594C68.4696 19.7773 68.8474 20.1761 69.3093 20.456C69.7851 20.7219 70.2889 20.8549 70.8207 20.8549C71.3665 20.8549 71.8703 20.7149 72.3322 20.435C72.808 20.1551 73.1859 19.7563 73.4658 19.2385C73.7596 18.7206 73.9066 18.1049 73.9066 17.3911Z" fill="#4B2B73"/>
|
|
18
|
+
<path d="M83.4945 23.4369C82.5429 23.4369 81.6892 23.269 80.9335 22.9331C80.1777 22.5832 79.576 22.1144 79.1281 21.5266C78.6943 20.9388 78.4564 20.2881 78.4144 19.5743H81.3743C81.4303 20.0222 81.6472 20.393 82.0251 20.6869C82.4169 20.9808 82.8997 21.1278 83.4735 21.1278C84.0333 21.1278 84.4672 21.0158 84.7751 20.7919C85.0969 20.568 85.2579 20.2811 85.2579 19.9312C85.2579 19.5533 85.062 19.2734 84.6701 19.0915C84.2922 18.8956 83.6835 18.6857 82.8438 18.4617C81.9761 18.2518 81.2623 18.0349 80.7025 17.811C80.1567 17.5871 79.6809 17.2442 79.2751 16.7823C78.8832 16.3205 78.6873 15.6977 78.6873 14.914C78.6873 14.2703 78.8692 13.6825 79.2331 13.1507C79.6109 12.6189 80.1427 12.199 80.8285 11.8911C81.5282 11.5832 82.3469 11.4293 83.2846 11.4293C84.6701 11.4293 85.7757 11.7792 86.6014 12.4789C87.4271 13.1647 87.8819 14.0953 87.9659 15.2709H85.1529C85.1109 14.8091 84.915 14.4452 84.5651 14.1793C84.2293 13.8994 83.7744 13.7594 83.2006 13.7594C82.6688 13.7594 82.256 13.8574 81.9621 14.0533C81.6822 14.2493 81.5422 14.5222 81.5422 14.872C81.5422 15.2639 81.7382 15.5648 82.13 15.7747C82.5219 15.9706 83.1307 16.1736 83.9564 16.3835C84.7961 16.5934 85.4888 16.8103 86.0346 17.0343C86.5804 17.2582 87.0492 17.608 87.4411 18.0839C87.8469 18.5457 88.0569 19.1615 88.0709 19.9312C88.0709 20.603 87.8819 21.2047 87.5041 21.7365C87.1402 22.2683 86.6084 22.6882 85.9086 22.9961C85.2229 23.29 84.4182 23.4369 83.4945 23.4369Z" fill="#4B2B73"/>
|
|
19
|
+
<path d="M93.9051 14.0323V19.6583C93.9051 20.0502 93.9961 20.3371 94.178 20.519C94.3739 20.6869 94.6958 20.7709 95.1436 20.7709H96.5081V23.248H94.6608C92.1837 23.248 90.9452 22.0444 90.9452 19.6373V14.0323H89.5597V11.6182H90.9452V8.74227H93.9051V11.6182H96.5081V14.0323H93.9051Z" fill="#4B2B73"/>
|
|
20
|
+
<path d="M101.561 13.4236C101.939 12.8078 102.429 12.325 103.03 11.9751C103.646 11.6252 104.346 11.4503 105.13 11.4503V14.5362H104.353C103.429 14.5362 102.729 14.7531 102.254 15.1869C101.792 15.6208 101.561 16.3765 101.561 17.4541V23.248H98.622V11.6182H101.561V13.4236Z" fill="#4B2B73"/>
|
|
21
|
+
<path d="M117.92 17.1812C117.92 17.601 117.892 17.9789 117.836 18.3148H109.334C109.404 19.1545 109.698 19.8122 110.216 20.2881C110.734 20.7639 111.371 21.0018 112.126 21.0018C113.218 21.0018 113.995 20.533 114.456 19.5953H117.626C117.29 20.7149 116.647 21.6386 115.695 22.3663C114.743 23.0801 113.575 23.4369 112.189 23.4369C111.07 23.4369 110.062 23.192 109.166 22.7022C108.285 22.1984 107.592 21.4916 107.088 20.582C106.598 19.6723 106.353 18.6227 106.353 17.4331C106.353 16.2295 106.598 15.1729 107.088 14.2633C107.578 13.3536 108.264 12.6538 109.145 12.164C110.027 11.6742 111.042 11.4293 112.189 11.4293C113.295 11.4293 114.282 11.6672 115.149 12.143C116.031 12.6189 116.71 13.2976 117.185 14.1793C117.675 15.047 117.92 16.0476 117.92 17.1812ZM114.876 16.3415C114.862 15.5858 114.589 14.984 114.058 14.5362C113.526 14.0743 112.875 13.8434 112.105 13.8434C111.378 13.8434 110.762 14.0673 110.258 14.5152C109.768 14.949 109.467 15.5578 109.355 16.3415H114.876Z" fill="#4B2B73"/>
|
|
22
|
+
<path d="M119.31 17.3911C119.31 16.2156 119.541 15.1729 120.002 14.2633C120.478 13.3536 121.115 12.6538 121.913 12.164C122.724 11.6742 123.627 11.4293 124.621 11.4293C125.488 11.4293 126.244 11.6042 126.888 11.9541C127.546 12.304 128.07 12.7448 128.462 13.2766V11.6182H131.422V23.248H128.462V21.5476C128.084 22.0934 127.56 22.5482 126.888 22.9121C126.23 23.262 125.467 23.4369 124.6 23.4369C123.62 23.4369 122.724 23.185 121.913 22.6812C121.115 22.1774 120.478 21.4706 120.002 20.561C119.541 19.6373 119.31 18.5807 119.31 17.3911ZM128.462 17.4331C128.462 16.7194 128.322 16.1106 128.042 15.6068C127.763 15.089 127.385 14.6971 126.909 14.4312C126.433 14.1513 125.922 14.0114 125.376 14.0114C124.831 14.0114 124.327 14.1443 123.865 14.4102C123.403 14.6761 123.025 15.068 122.731 15.5858C122.451 16.0896 122.312 16.6914 122.312 17.3911C122.312 18.0909 122.451 18.7066 122.731 19.2385C123.025 19.7563 123.403 20.1551 123.865 20.435C124.341 20.7149 124.845 20.8549 125.376 20.8549C125.922 20.8549 126.433 20.7219 126.909 20.456C127.385 20.1761 127.763 19.7843 128.042 19.2804C128.322 18.7626 128.462 18.1468 128.462 17.4331Z" fill="#4B2B73"/>
|
|
23
|
+
<path d="M148.735 11.4503C150.163 11.4503 151.31 11.8911 152.178 12.7728C153.06 13.6405 153.501 14.858 153.501 16.4255V23.248H150.562V16.8243C150.562 15.9147 150.331 15.2219 149.869 14.7461C149.407 14.2563 148.777 14.0114 147.98 14.0114C147.182 14.0114 146.545 14.2563 146.069 14.7461C145.607 15.2219 145.377 15.9147 145.377 16.8243V23.248H142.438V16.8243C142.438 15.9147 142.207 15.2219 141.745 14.7461C141.283 14.2563 140.653 14.0114 139.856 14.0114C139.044 14.0114 138.4 14.2563 137.924 14.7461C137.462 15.2219 137.232 15.9147 137.232 16.8243V23.248H134.293V11.6182H137.232V13.0247C137.609 12.5349 138.092 12.15 138.68 11.8701C139.282 11.5902 139.94 11.4503 140.653 11.4503C141.563 11.4503 142.375 11.6462 143.088 12.0381C143.802 12.4159 144.355 12.9617 144.747 13.6755C145.125 13.0037 145.67 12.4649 146.384 12.0591C147.112 11.6532 147.896 11.4503 148.735 11.4503Z" fill="#4B2B73"/>
|
|
24
|
+
</svg>`;
|
|
25
|
+
// Branded loopback page that mirrors the provider consent screen (pulse-fe
|
|
26
|
+
// LandingViewV2): a soft brand-gradient backdrop, the Flipstream lockup, and a
|
|
27
|
+
// glassy card. Deliberately simple — no actions, just a status + a close hint.
|
|
28
|
+
// A guarded inline script strips the ?code=... from the address bar after load.
|
|
29
|
+
function renderLoopbackPage(opts) {
|
|
30
|
+
return `<!doctype html>
|
|
31
|
+
<html lang="en">
|
|
32
|
+
<head>
|
|
33
|
+
<meta charset="utf-8">
|
|
34
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
35
|
+
<title>${opts.title}</title>
|
|
36
|
+
<style>
|
|
37
|
+
*{box-sizing:border-box}
|
|
38
|
+
html,body{height:100%}
|
|
39
|
+
body{
|
|
40
|
+
margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
|
41
|
+
padding:24px; color:#181d27;
|
|
42
|
+
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
43
|
+
-webkit-font-smoothing:antialiased;
|
|
44
|
+
background:
|
|
45
|
+
radial-gradient(ellipse 80% 60% at 30% 10%, color-mix(in srgb,#b69ddc 18%,transparent) 0%, transparent 70%),
|
|
46
|
+
radial-gradient(ellipse 70% 50% at 70% 5%, color-mix(in srgb,#7f41bd 14%,transparent) 0%, transparent 70%),
|
|
47
|
+
radial-gradient(ellipse 90% 40% at 50% 0%, color-mix(in srgb,#e9def3 40%,transparent) 0%, transparent 60%),
|
|
48
|
+
linear-gradient(to bottom,#f9f7fc 0%,#f4eff9 100%);
|
|
49
|
+
}
|
|
50
|
+
.wrap{width:100%; max-width:480px; display:flex; flex-direction:column; align-items:center}
|
|
51
|
+
.logo{width:200px; height:auto; margin-bottom:28px}
|
|
52
|
+
.card{
|
|
53
|
+
width:100%; border-radius:16px; padding:32px; text-align:center;
|
|
54
|
+
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(255,255,255,.4) 100%);
|
|
55
|
+
border:1px solid rgba(255,255,255,.7);
|
|
56
|
+
box-shadow:0 8px 32px color-mix(in srgb,#7f41bd 8%,transparent);
|
|
57
|
+
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
|
|
58
|
+
}
|
|
59
|
+
h1{font-size:26px; line-height:1.25; font-weight:600; letter-spacing:-.01em; margin:0 0 8px}
|
|
60
|
+
p{font-size:15px; font-weight:400; line-height:1.55; color:#535862; margin:0}
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<main class="wrap">
|
|
65
|
+
${LOGO_SVG}
|
|
66
|
+
<section class="card">
|
|
67
|
+
<h1>${opts.heading}</h1>
|
|
68
|
+
<p>${opts.subtitle}</p>
|
|
69
|
+
</section>
|
|
70
|
+
</main>
|
|
71
|
+
<script>try{history.replaceState(null,"",location.pathname)}catch(e){}</script>
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
const SUCCESS_HTML = renderLoopbackPage({
|
|
77
|
+
heading: "You're signed in",
|
|
78
|
+
subtitle: 'You can close this tab and return to your terminal.',
|
|
79
|
+
title: 'Signed in · Flipstream',
|
|
80
|
+
});
|
|
81
|
+
const ERROR_HTML = renderLoopbackPage({
|
|
82
|
+
heading: 'Sign-in failed',
|
|
83
|
+
subtitle: 'Return to your terminal for details.',
|
|
84
|
+
title: 'Sign-in failed · Flipstream',
|
|
85
|
+
});
|
|
86
|
+
function rstripSlash(value) {
|
|
87
|
+
return value.replace(/\/+$/, '');
|
|
88
|
+
}
|
|
89
|
+
function sendHtml(res, status, body) {
|
|
90
|
+
res.writeHead(status, {
|
|
91
|
+
connection: 'close',
|
|
92
|
+
'content-length': Buffer.byteLength(body),
|
|
93
|
+
'content-type': 'text/html; charset=utf-8',
|
|
94
|
+
});
|
|
95
|
+
res.end(body);
|
|
96
|
+
}
|
|
97
|
+
// Start an ephemeral loopback callback server on 127.0.0.1 ONLY (RFC 8252).
|
|
98
|
+
// waitForCode() resolves to the authorization code or rejects with a typed
|
|
99
|
+
// LoopbackError; it settles exactly once. The caller MUST close() (e.g. in a
|
|
100
|
+
// finally) to release the port on every path.
|
|
101
|
+
export function startLoopback(options) {
|
|
102
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_REDIRECT_TIMEOUT_MS;
|
|
103
|
+
const requireIss = options.requireIss ?? false;
|
|
104
|
+
const expectedIss = options.expectedIss ? rstripSlash(options.expectedIss) : undefined;
|
|
105
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
106
|
+
let settled = false;
|
|
107
|
+
let timer;
|
|
108
|
+
let resolveCode;
|
|
109
|
+
let rejectCode;
|
|
110
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
111
|
+
resolveCode = resolve;
|
|
112
|
+
rejectCode = reject;
|
|
113
|
+
});
|
|
114
|
+
const sockets = new Set();
|
|
115
|
+
const settle = (action) => {
|
|
116
|
+
if (settled)
|
|
117
|
+
return;
|
|
118
|
+
settled = true;
|
|
119
|
+
if (timer)
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
action();
|
|
122
|
+
};
|
|
123
|
+
function onSigint() {
|
|
124
|
+
settle(() => rejectCode(new LoopbackError('Sign-in interrupted.', 'loopback_interrupted', ExitCode.GENERIC)));
|
|
125
|
+
}
|
|
126
|
+
const server = createServer((req, res) => {
|
|
127
|
+
const { port } = server.address();
|
|
128
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
129
|
+
if (url.pathname !== '/callback') {
|
|
130
|
+
sendHtml(res, 404, ERROR_HTML);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const params = url.searchParams;
|
|
134
|
+
// Validation order is exact (state, error, iss, missing code, success).
|
|
135
|
+
if (params.get('state') !== options.expectedState) {
|
|
136
|
+
sendHtml(res, 400, ERROR_HTML);
|
|
137
|
+
settle(() => rejectCode(new LoopbackError('state mismatch')));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const error = params.get('error');
|
|
141
|
+
if (error !== null) {
|
|
142
|
+
const description = params.get('error_description');
|
|
143
|
+
sendHtml(res, 400, ERROR_HTML);
|
|
144
|
+
settle(() => rejectCode(new LoopbackError(description ? `${error}: ${description}` : error)));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const iss = params.get('iss');
|
|
148
|
+
if (iss === null) {
|
|
149
|
+
if (requireIss) {
|
|
150
|
+
sendHtml(res, 400, ERROR_HTML);
|
|
151
|
+
settle(() => rejectCode(new LoopbackError('missing issuer')));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (expectedIss !== undefined && rstripSlash(iss) !== expectedIss) {
|
|
156
|
+
sendHtml(res, 400, ERROR_HTML);
|
|
157
|
+
settle(() => rejectCode(new LoopbackError('issuer mismatch')));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const code = params.get('code');
|
|
161
|
+
if (code === null || code === '') {
|
|
162
|
+
sendHtml(res, 400, ERROR_HTML);
|
|
163
|
+
settle(() => rejectCode(new LoopbackError('missing authorization code')));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
sendHtml(res, 200, SUCCESS_HTML);
|
|
167
|
+
settle(() => resolveCode(code));
|
|
168
|
+
});
|
|
169
|
+
server.on('connection', (socket) => {
|
|
170
|
+
sockets.add(socket);
|
|
171
|
+
socket.on('close', () => sockets.delete(socket));
|
|
172
|
+
});
|
|
173
|
+
server.on('error', (error) => {
|
|
174
|
+
rejectStart(new LoopbackError(`Could not start the loopback server: ${error.message}`, 'loopback_listen', ExitCode.GENERIC));
|
|
175
|
+
});
|
|
176
|
+
server.listen(0, '127.0.0.1', () => {
|
|
177
|
+
const { port } = server.address();
|
|
178
|
+
const minutes = Math.round(timeoutMs / 60_000);
|
|
179
|
+
timer = setTimeout(() => {
|
|
180
|
+
settle(() => rejectCode(LoopbackError.timeout(`No response within ${minutes} minutes; re-run \`flipstream auth login\`.`)));
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
process.once('SIGINT', onSigint);
|
|
183
|
+
resolveStart({
|
|
184
|
+
close: () => new Promise((resolve) => {
|
|
185
|
+
if (timer)
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
process.removeListener('SIGINT', onSigint);
|
|
188
|
+
for (const socket of sockets)
|
|
189
|
+
socket.destroy();
|
|
190
|
+
server.close(() => resolve());
|
|
191
|
+
}),
|
|
192
|
+
port,
|
|
193
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
194
|
+
waitForCode: () => codePromise,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
// base64url with padding stripped (Node's 'base64url' encoding already does this).
|
|
3
|
+
function base64url(buffer) {
|
|
4
|
+
return buffer.toString('base64url');
|
|
5
|
+
}
|
|
6
|
+
// Derive the S256 challenge from a verifier: base64url(sha256(ascii(verifier))).
|
|
7
|
+
// Hashing is over the ASCII bytes of the verifier STRING (not raw entropy).
|
|
8
|
+
export function deriveChallenge(verifier) {
|
|
9
|
+
return base64url(createHash('sha256').update(verifier, 'ascii').digest());
|
|
10
|
+
}
|
|
11
|
+
// Generate a PKCE (verifier, challenge) pair using S256 only. The verifier is
|
|
12
|
+
// base64url(64 CSPRNG bytes) = 86 chars. Uses crypto.randomBytes (a CSPRNG) —
|
|
13
|
+
// never a non-cryptographic generator; this is the public client's security boundary.
|
|
14
|
+
export function generatePair() {
|
|
15
|
+
const verifier = base64url(randomBytes(64));
|
|
16
|
+
return { challenge: deriveChallenge(verifier), verifier };
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Metadata } from './discovery.js';
|
|
2
|
+
export { DEFAULT_HOST as DEFAULT_AUTH_HOST, DATA_HOST as DEFAULT_DATA_HOST } from '../config/constants.js';
|
|
3
|
+
export interface AuthorizeUrlParams {
|
|
4
|
+
authorizationEndpoint: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
codeChallenge: string;
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
resource?: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
state: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveProvider(host: string, options?: {
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<Metadata>;
|
|
15
|
+
export declare function buildAuthorizeUrl(params: AuthorizeUrlParams): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fetchMetadata } from './discovery.js';
|
|
2
|
+
export { DEFAULT_HOST as DEFAULT_AUTH_HOST, DATA_HOST as DEFAULT_DATA_HOST } from '../config/constants.js';
|
|
3
|
+
// Resolve provider metadata for a host (discovery + conventional fallback).
|
|
4
|
+
export function resolveProvider(host, options = {}) {
|
|
5
|
+
return fetchMetadata(host, options);
|
|
6
|
+
}
|
|
7
|
+
// Build the /oauth/authorize URL. code_challenge_method is ALWAYS 'S256'
|
|
8
|
+
// (hardcoded; never the weaker challenge method). scope is space-joined.
|
|
9
|
+
export function buildAuthorizeUrl(params) {
|
|
10
|
+
const url = new URL(params.authorizationEndpoint);
|
|
11
|
+
const query = url.searchParams;
|
|
12
|
+
query.set('response_type', 'code');
|
|
13
|
+
query.set('client_id', params.clientId);
|
|
14
|
+
query.set('redirect_uri', params.redirectUri);
|
|
15
|
+
query.set('scope', params.scopes.join(' '));
|
|
16
|
+
query.set('state', params.state);
|
|
17
|
+
query.set('code_challenge', params.codeChallenge);
|
|
18
|
+
query.set('code_challenge_method', 'S256');
|
|
19
|
+
if (params.resource) {
|
|
20
|
+
query.set('resource', params.resource);
|
|
21
|
+
}
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createStore } from '../store/index.js';
|
|
2
|
+
import { fetchMetadata } from './discovery.js';
|
|
3
|
+
// Silent refresh via the refresh_token grant. Returns the new access token, or
|
|
4
|
+
// null when there is nothing to refresh or the refresh failed. The body is
|
|
5
|
+
// application/x-www-form-urlencoded and carries no client_secret (public client).
|
|
6
|
+
//
|
|
7
|
+
// 4xx (e.g. invalid_grant from refresh-token reuse) => clear creds + null,
|
|
8
|
+
// forcing a fresh interactive login.
|
|
9
|
+
// network / discovery / 5xx error => null WITHOUT clearing (transient).
|
|
10
|
+
export async function refresh(host, options = {}) {
|
|
11
|
+
const store = options.store ?? createStore();
|
|
12
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
13
|
+
const creds = store.load(host);
|
|
14
|
+
if (!creds || !creds.refreshToken)
|
|
15
|
+
return null;
|
|
16
|
+
let tokenEndpoint;
|
|
17
|
+
try {
|
|
18
|
+
tokenEndpoint = (await fetchMetadata(host, { timeoutMs })).tokenEndpoint;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null; // discovery/network error — keep creds for a later retry.
|
|
22
|
+
}
|
|
23
|
+
const body = new URLSearchParams({
|
|
24
|
+
client_id: creds.clientId,
|
|
25
|
+
grant_type: 'refresh_token',
|
|
26
|
+
refresh_token: creds.refreshToken,
|
|
27
|
+
});
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(tokenEndpoint, {
|
|
33
|
+
body: body.toString(),
|
|
34
|
+
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
|
|
35
|
+
method: 'POST',
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null; // network error — keep creds.
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
if (response.status >= 400 && response.status < 500) {
|
|
46
|
+
store.clear(host); // invalid_grant / revoked session — force re-login.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok)
|
|
50
|
+
return null; // 5xx — transient, keep creds.
|
|
51
|
+
let token;
|
|
52
|
+
try {
|
|
53
|
+
token = (await response.json());
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (!token.access_token)
|
|
59
|
+
return null;
|
|
60
|
+
const updated = {
|
|
61
|
+
...creds,
|
|
62
|
+
accessToken: token.access_token,
|
|
63
|
+
expiresAt: Math.floor(Date.now() / 1000) + (token.expires_in ?? 0),
|
|
64
|
+
// Rotate the refresh token ONLY when the server issued a new one.
|
|
65
|
+
refreshToken: token.refresh_token ?? creds.refreshToken,
|
|
66
|
+
};
|
|
67
|
+
store.save(host, updated);
|
|
68
|
+
return token.access_token;
|
|
69
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CLIENT_NAME } from '../config/constants.js';
|
|
2
|
+
import { AuthFailedError } from '../errors.js';
|
|
3
|
+
// Dynamically register a PUBLIC OAuth client (RFC 7591) and return its
|
|
4
|
+
// client_id. The CLI never requests, parses, or stores a client secret — the
|
|
5
|
+
// return type is the bare client_id string so a refactor cannot leak a secret.
|
|
6
|
+
export async function registerClient(meta, redirectUri, scopes, options = {}) {
|
|
7
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
10
|
+
const body = {
|
|
11
|
+
client_name: CLIENT_NAME,
|
|
12
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
13
|
+
redirect_uris: [redirectUri],
|
|
14
|
+
response_types: ['code'],
|
|
15
|
+
scope: scopes.join(' '),
|
|
16
|
+
token_endpoint_auth_method: 'none',
|
|
17
|
+
};
|
|
18
|
+
let response;
|
|
19
|
+
try {
|
|
20
|
+
response = await fetch(meta.registrationEndpoint, {
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
23
|
+
method: 'POST',
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const reason = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error.message;
|
|
29
|
+
throw new AuthFailedError(`Client registration failed: ${reason}`, 'registration_failed');
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
if (response.status >= 400) {
|
|
35
|
+
const text = await response.text().catch(() => '');
|
|
36
|
+
throw new AuthFailedError(`Client registration failed: HTTP ${response.status} ${text}`.trim(), 'registration_failed');
|
|
37
|
+
}
|
|
38
|
+
const json = (await response.json().catch(() => ({})));
|
|
39
|
+
if (typeof json.client_id !== 'string' || json.client_id.length === 0) {
|
|
40
|
+
throw new AuthFailedError('Client registration response did not include a client_id', 'registration_failed');
|
|
41
|
+
}
|
|
42
|
+
return json.client_id;
|
|
43
|
+
}
|