@yargote/artifacts-cli 0.0.0-stage → 0.1.0-beta.2

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/src/auth.js ADDED
@@ -0,0 +1,276 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import * as oidc from 'openid-client';
5
+ import { canonicalResource, identitySchema, protectedResourceMetadataUrl, restBase } from '@yargote/artifacts-contracts/http';
6
+ import { z } from 'zod';
7
+ import { CredentialStore } from "./credentials.js";
8
+ const networkTimeout = 10_000;
9
+ const callbackTimeout = 180_000;
10
+ const scopes = 'openid profile offline_access artifacts:read artifacts:write artifacts:publish';
11
+ const resourceMetadata = z.object({ resource: z.string(), authorization_servers: z.array(z.string()).min(1) });
12
+ function endpoint(value, issuer) {
13
+ const url = new URL(value);
14
+ const loopback = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost)(?::[0-9]+)?(?:\/|$)/.test(value);
15
+ if (value.trim() !== value || url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)))
16
+ throw new Error('OAuth URLs require HTTPS or an exact loopback HTTP host, without credentials, query, or fragment');
17
+ if (issuer && url.origin !== new URL(issuer).origin)
18
+ throw new Error('OAuth endpoints must use the issuer origin');
19
+ return url;
20
+ }
21
+ async function boundedFetch(url, options = {}) {
22
+ const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(networkTimeout)]) : AbortSignal.timeout(networkTimeout);
23
+ const response = await fetch(url, { ...options, redirect: 'manual', signal });
24
+ if (response.status >= 300 && response.status < 400) {
25
+ await response.body?.cancel();
26
+ throw new Error('Authentication redirects are not allowed');
27
+ }
28
+ const reader = response.body?.getReader();
29
+ const chunks = [];
30
+ let size = 0;
31
+ if (reader) {
32
+ try {
33
+ for (;;) {
34
+ const chunk = await reader.read();
35
+ if (chunk.done)
36
+ break;
37
+ size += chunk.value.byteLength;
38
+ if (size > 1024 * 1024)
39
+ throw new Error('Authentication response is too large');
40
+ chunks.push(chunk.value);
41
+ }
42
+ }
43
+ catch (error) {
44
+ await reader.cancel().catch(() => { });
45
+ throw error;
46
+ }
47
+ finally {
48
+ reader.releaseLock();
49
+ }
50
+ }
51
+ return new Response(size ? Buffer.concat(chunks) : null, { status: response.status, statusText: response.statusText, headers: response.headers });
52
+ }
53
+ function discoveryOptions(issuer, signal) {
54
+ endpoint(issuer);
55
+ return {
56
+ timeout: networkTimeout / 1000,
57
+ execute: [...(new URL(issuer).protocol === 'http:' ? [oidc.allowInsecureRequests] : []), oidc.enableNonRepudiationChecks],
58
+ [oidc.customFetch]: (url, options) => boundedFetch(endpoint(url, issuer), { ...options, signal: signal && options.signal ? AbortSignal.any([signal, options.signal]) : signal ?? options.signal, body: options.body instanceof Uint8Array ? Uint8Array.from(options.body) : options.body }),
59
+ };
60
+ }
61
+ function validateConfiguration(config, issuer) {
62
+ const metadata = config.serverMetadata();
63
+ if (metadata.issuer !== issuer)
64
+ throw new Error('OAuth issuer metadata does not match the configured issuer');
65
+ for (const [key, value] of Object.entries(metadata)) {
66
+ if ((key.endsWith('_endpoint') || key === 'jwks_uri') && typeof value === 'string')
67
+ endpoint(value, issuer);
68
+ }
69
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint)
70
+ throw new Error('OAuth metadata is missing authorization or token endpoints');
71
+ if (config.clientMetadata().client_secret || config.clientMetadata().token_endpoint_auth_method !== 'none')
72
+ throw new Error('The CLI requires a public OAuth client without a secret');
73
+ }
74
+ async function configuration(registration, signal) {
75
+ const config = await oidc.discovery(new URL(registration.issuer), registration.clientId, { token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA' }, oidc.None(), discoveryOptions(registration.issuer, signal));
76
+ validateConfiguration(config, registration.issuer);
77
+ return config;
78
+ }
79
+ function tokenRecord(tokens, previousRefresh) {
80
+ if (typeof tokens.expires_in !== 'number' || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0)
81
+ throw new Error('OAuth token response is missing a valid expiration');
82
+ return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? previousRefresh, expiresAt: Date.now() + tokens.expires_in * 1000 };
83
+ }
84
+ async function callback() {
85
+ const controller = new AbortController();
86
+ let accept;
87
+ let reject;
88
+ let expectedState;
89
+ let expectedIssuer;
90
+ let redirectUri = '';
91
+ let settled = false;
92
+ const result = new Promise((resolve, fail) => { accept = resolve; reject = fail; });
93
+ void result.catch(() => { });
94
+ const server = createServer((request, response) => {
95
+ response.setHeader('cache-control', 'no-store');
96
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
97
+ if (request.method !== 'GET' || !request.url || request.url.length > 8192 || request.headers.host !== new URL(redirectUri).host || request.url.split('?')[0] !== '/oauth/callback') {
98
+ response.writeHead(404).end('Not found');
99
+ return;
100
+ }
101
+ const url = new URL(request.url, redirectUri);
102
+ if (settled || !expectedState || url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== expectedState || url.searchParams.getAll('iss').length !== 1 || url.searchParams.get('iss') !== expectedIssuer || url.searchParams.getAll('code').length + url.searchParams.getAll('error').length !== 1) {
103
+ response.writeHead(400).end('Invalid OAuth callback');
104
+ return;
105
+ }
106
+ settled = true;
107
+ response.end('Authorization received. Return to the terminal to check the result.');
108
+ accept(url);
109
+ });
110
+ await new Promise((resolve, fail) => {
111
+ server.once('error', fail);
112
+ server.listen(0, '127.0.0.1', () => { server.removeListener('error', fail); resolve(); });
113
+ });
114
+ const address = server.address();
115
+ if (!address || typeof address === 'string')
116
+ throw new Error('Cannot bind the OAuth loopback callback');
117
+ redirectUri = `http://127.0.0.1:${address.port}/oauth/callback`;
118
+ const fail = (error) => { controller.abort(error); reject(error); };
119
+ const timer = setTimeout(() => fail(new Error('Login timed out. Run artifacts login again')), callbackTimeout);
120
+ const cancel = () => fail(new Error('Login cancelled'));
121
+ process.once('SIGINT', cancel);
122
+ process.once('SIGTERM', cancel);
123
+ return {
124
+ redirectUri, signal: controller.signal,
125
+ wait(state, issuer) { expectedState = state; expectedIssuer = issuer; return result; },
126
+ async close() {
127
+ clearTimeout(timer);
128
+ process.removeListener('SIGINT', cancel);
129
+ process.removeListener('SIGTERM', cancel);
130
+ server.closeAllConnections();
131
+ await new Promise((resolve) => server.close(() => resolve()));
132
+ },
133
+ };
134
+ }
135
+ async function openBrowser(url) {
136
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
137
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
138
+ await new Promise((resolve, reject) => {
139
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
140
+ child.once('error', () => reject(new Error('Cannot open a browser. Run artifacts login with --no-browser')));
141
+ child.once('spawn', () => { child.unref(); resolve(); });
142
+ });
143
+ }
144
+ export async function login(options) {
145
+ const resource = canonicalResource(options.resource);
146
+ const store = await CredentialStore.open(options.stateDir);
147
+ let listener;
148
+ try {
149
+ const epoch = await store.exclusive(() => {
150
+ const profile = store.read(resource);
151
+ store.write(resource, { ...profile, epoch: profile.epoch + 1 });
152
+ return profile.epoch + 1;
153
+ });
154
+ const response = await boundedFetch(protectedResourceMetadataUrl(resource));
155
+ if (!response.ok)
156
+ throw new Error('Cannot discover the protected resource');
157
+ const metadata = resourceMetadata.parse(await response.json());
158
+ if (metadata.resource !== resource)
159
+ throw new Error('Protected resource metadata does not match the exact server URL');
160
+ const issuer = metadata.authorization_servers[0];
161
+ endpoint(issuer);
162
+ listener = await callback();
163
+ const { redirectUri, signal } = listener;
164
+ const config = await store.exclusive(async () => {
165
+ const profile = store.read(resource);
166
+ if (profile.epoch !== epoch)
167
+ throw new Error('Login was superseded by another login or logout');
168
+ if (profile.registration?.issuer === issuer)
169
+ return configuration(profile.registration, signal);
170
+ const registered = await oidc.dynamicClientRegistration(new URL(issuer), {
171
+ client_name: 'Artifacts CLI', application_type: 'native', token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA',
172
+ redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: scopes,
173
+ }, oidc.None(), discoveryOptions(issuer, signal));
174
+ validateConfiguration(registered, issuer);
175
+ store.write(resource, { ...profile, registration: { issuer, clientId: registered.clientMetadata().client_id }, tokens: null, refreshAttempt: null });
176
+ return registered;
177
+ });
178
+ const verifier = oidc.randomPKCECodeVerifier();
179
+ const state = oidc.randomState();
180
+ const nonce = oidc.randomNonce();
181
+ const authorizationUrl = oidc.buildAuthorizationUrl(config, {
182
+ redirect_uri: redirectUri, response_type: 'code', scope: scopes, resource,
183
+ code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state, nonce,
184
+ }).href;
185
+ const authorization = listener.wait(state, issuer);
186
+ options.onAuthorizationUrl(authorizationUrl);
187
+ if (!options.noBrowser)
188
+ await openBrowser(authorizationUrl);
189
+ const currentUrl = await authorization;
190
+ return await store.exclusive(async () => {
191
+ const profile = store.read(resource);
192
+ if (profile.epoch !== epoch)
193
+ throw new Error('Login was superseded by another login or logout');
194
+ const tokens = await oidc.authorizationCodeGrant(config, currentUrl, { expectedState: state, expectedNonce: nonce, pkceCodeVerifier: verifier, idTokenExpected: true }, { resource });
195
+ const credentials = tokenRecord(tokens);
196
+ const identityResponse = await boundedFetch(`${restBase(resource)}/identity`, { signal, headers: { authorization: `Bearer ${credentials.accessToken}` } });
197
+ if (!identityResponse.ok)
198
+ throw new Error('The OAuth grant cannot access this Artifacts server');
199
+ const identity = identitySchema.parse(await identityResponse.json());
200
+ const claims = tokens.claims();
201
+ if (claims.sub !== identity.userId || (claims.sid !== undefined && claims.sid !== identity.sessionId))
202
+ throw new Error('OAuth identity does not match the Artifacts session');
203
+ signal.throwIfAborted();
204
+ store.write(resource, { ...profile, tokens: credentials, refreshAttempt: null });
205
+ return identity;
206
+ });
207
+ }
208
+ finally {
209
+ await listener?.close();
210
+ store.close();
211
+ }
212
+ }
213
+ export async function accessToken(options) {
214
+ const resource = canonicalResource(options.resource);
215
+ const automationToken = process.env.ARTIFACTS_ACCESS_TOKEN;
216
+ if (automationToken !== undefined) {
217
+ if (!/^[A-Za-z0-9._~+/-]+=*$/.test(automationToken))
218
+ throw new Error('ARTIFACTS_ACCESS_TOKEN must contain a nonempty bearer token');
219
+ return automationToken;
220
+ }
221
+ const store = await CredentialStore.open(options.stateDir);
222
+ try {
223
+ return await store.exclusive(async () => {
224
+ const profile = store.read(resource);
225
+ const required = `Run artifacts login --server ${resource}`;
226
+ if (!profile.tokens || !profile.registration)
227
+ throw new Error(`You are not signed in. ${required}`);
228
+ if (profile.refreshAttempt)
229
+ throw new Error(`The previous token refresh has an uncertain result. ${required}`);
230
+ if (profile.tokens.expiresAt > Date.now() + 30_000)
231
+ return profile.tokens.accessToken;
232
+ if (!profile.tokens.refreshToken)
233
+ throw new Error(`Your session expired. ${required}`);
234
+ const config = await configuration(profile.registration);
235
+ // The separate SQLite lock survives this commit and releases automatically on process death.
236
+ store.write(resource, { ...profile, refreshAttempt: randomUUID() });
237
+ try {
238
+ const tokens = await oidc.refreshTokenGrant(config, profile.tokens.refreshToken, { resource });
239
+ const credentials = tokenRecord(tokens, profile.tokens.refreshToken);
240
+ store.write(resource, { ...profile, tokens: credentials, refreshAttempt: null });
241
+ return credentials.accessToken;
242
+ }
243
+ catch {
244
+ throw new Error(`Token refresh failed and its result is uncertain. ${required}`);
245
+ }
246
+ });
247
+ }
248
+ finally {
249
+ store.close();
250
+ }
251
+ }
252
+ export async function logout(options) {
253
+ const resource = canonicalResource(options.resource);
254
+ const store = await CredentialStore.open(options.stateDir);
255
+ let previous;
256
+ try {
257
+ previous = await store.exclusive(() => {
258
+ const profile = store.read(resource);
259
+ store.write(resource, { ...profile, epoch: profile.epoch + 1, tokens: null, refreshAttempt: null });
260
+ return profile;
261
+ });
262
+ }
263
+ finally {
264
+ store.close();
265
+ }
266
+ if (!previous.tokens?.refreshToken || !previous.registration)
267
+ return { signedOut: true, remoteRevocation: 'not-needed' };
268
+ try {
269
+ const config = await configuration(previous.registration);
270
+ await oidc.tokenRevocation(config, previous.tokens.refreshToken, { token_type_hint: 'refresh_token' });
271
+ return { signedOut: true, remoteRevocation: previous.refreshAttempt ? 'unconfirmed' : 'confirmed' };
272
+ }
273
+ catch {
274
+ return { signedOut: true, remoteRevocation: 'unconfirmed' };
275
+ }
276
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ declare const registrationSchema: z.ZodObject<{
3
+ issuer: z.ZodString;
4
+ clientId: z.ZodString;
5
+ }, z.core.$strict>;
6
+ declare const tokensSchema: z.ZodObject<{
7
+ accessToken: z.ZodString;
8
+ refreshToken: z.ZodOptional<z.ZodString>;
9
+ expiresAt: z.ZodNumber;
10
+ }, z.core.$strict>;
11
+ declare const profileSchema: z.ZodObject<{
12
+ epoch: z.ZodNumber;
13
+ registration: z.ZodNullable<z.ZodObject<{
14
+ issuer: z.ZodString;
15
+ clientId: z.ZodString;
16
+ }, z.core.$strict>>;
17
+ tokens: z.ZodNullable<z.ZodObject<{
18
+ accessToken: z.ZodString;
19
+ refreshToken: z.ZodOptional<z.ZodString>;
20
+ expiresAt: z.ZodNumber;
21
+ }, z.core.$strict>>;
22
+ refreshAttempt: z.ZodNullable<z.ZodString>;
23
+ }, z.core.$strict>;
24
+ export type Registration = z.infer<typeof registrationSchema>;
25
+ export type Tokens = z.infer<typeof tokensSchema>;
26
+ export type CredentialProfile = z.infer<typeof profileSchema>;
27
+ export declare class CredentialStore {
28
+ private readonly data;
29
+ private readonly lock;
30
+ private constructor();
31
+ static open(stateDir: string): Promise<CredentialStore>;
32
+ exclusive<T>(work: () => T | Promise<T>): Promise<T>;
33
+ read(resource: string): CredentialProfile;
34
+ write(resource: string, profile: CredentialProfile): void;
35
+ close(): void;
36
+ }
37
+ export {};
@@ -0,0 +1,94 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, lstat, mkdir, open } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { z } from 'zod';
7
+ const registrationSchema = z.strictObject({ issuer: z.string(), clientId: z.string().min(1) });
8
+ const tokensSchema = z.strictObject({ accessToken: z.string().min(1), refreshToken: z.string().min(1).optional(), expiresAt: z.number().finite() });
9
+ const profileSchema = z.strictObject({
10
+ epoch: z.number().int().nonnegative(),
11
+ registration: registrationSchema.nullable(),
12
+ tokens: tokensSchema.nullable(),
13
+ refreshAttempt: z.string().nullable(),
14
+ });
15
+ async function privateFile(path) {
16
+ const file = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
17
+ try {
18
+ const stat = await file.stat();
19
+ if (!stat.isFile() || stat.nlink !== 1 || (process.getuid && stat.uid !== process.getuid()))
20
+ throw new Error('Credential storage must be a private file owned by the current user');
21
+ await file.chmod(0o600);
22
+ }
23
+ finally {
24
+ await file.close();
25
+ }
26
+ }
27
+ export class CredentialStore {
28
+ data;
29
+ lock;
30
+ constructor(data, lock) {
31
+ this.data = data;
32
+ this.lock = lock;
33
+ }
34
+ static async open(stateDir) {
35
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
36
+ const directory = await lstat(stateDir);
37
+ if (!directory.isDirectory() || directory.isSymbolicLink() || (process.getuid && directory.uid !== process.getuid()))
38
+ throw new Error('Credential storage must be a private directory owned by the current user');
39
+ await chmod(stateDir, 0o700);
40
+ const dataPath = join(stateDir, 'credentials.sqlite');
41
+ const lockPath = join(stateDir, 'credentials-lock.sqlite');
42
+ await privateFile(dataPath);
43
+ await privateFile(lockPath);
44
+ const lock = new DatabaseSync(lockPath);
45
+ let data;
46
+ try {
47
+ data = new DatabaseSync(dataPath);
48
+ lock.exec('PRAGMA busy_timeout = 0;');
49
+ const store = new CredentialStore(data, lock);
50
+ await store.exclusive(() => {
51
+ data.exec('PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON;');
52
+ data.exec('CREATE TABLE IF NOT EXISTS profiles (resource TEXT PRIMARY KEY, profile TEXT NOT NULL) STRICT');
53
+ });
54
+ return store;
55
+ }
56
+ catch (error) {
57
+ data?.close();
58
+ lock.close();
59
+ throw error;
60
+ }
61
+ }
62
+ async exclusive(work) {
63
+ const deadline = Date.now() + 45_000;
64
+ for (;;) {
65
+ try {
66
+ this.lock.exec('BEGIN IMMEDIATE');
67
+ break;
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof Error) || !('errcode' in error) || (error.errcode !== 5 && error.errcode !== 6))
71
+ throw error;
72
+ if (Date.now() >= deadline)
73
+ throw new Error('Credential storage is busy. Try the command again');
74
+ await delay(25);
75
+ }
76
+ }
77
+ try {
78
+ return await work();
79
+ }
80
+ finally {
81
+ this.lock.exec('ROLLBACK');
82
+ }
83
+ }
84
+ read(resource) {
85
+ const row = this.data.prepare('SELECT profile FROM profiles WHERE resource = ?').get(resource);
86
+ if (!row)
87
+ return { epoch: 0, registration: null, tokens: null, refreshAttempt: null };
88
+ return profileSchema.parse(JSON.parse(String(row.profile)));
89
+ }
90
+ write(resource, profile) {
91
+ this.data.prepare('INSERT INTO profiles(resource, profile) VALUES (?, ?) ON CONFLICT(resource) DO UPDATE SET profile = excluded.profile').run(resource, JSON.stringify(profileSchema.parse(profile)));
92
+ }
93
+ close() { this.data.close(); this.lock.close(); }
94
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};