diff --git a/README.md b/README.md index 817837e..603dac3 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,29 @@ removed locally without OAuth revocation. Older sessions without a saved revocation endpoint are cleared locally with a warning; the CLI does not guess their provider endpoint. +Device sign-in is also disabled in the public package. +`dedalus login --flow device` fails before any network request until a verified +issuer and discovery URL are configured. Enabling it requires a provider that supports the device +grant for this client, binds tokens to the requested API resource, and records +the approved organization. Discovery alone does not establish that contract. + +With a configured provider, use `dedalus login --flow device` when the terminal +cannot open a browser or receive a local callback. Open the displayed link, +confirm the code, and approve access. The CLI sends the configured API resource, +saves the resulting tokens and discovered revocation endpoint, and refreshes +tokens on later commands. Discovery must advertise the device grant, device +authorization, token, and revocation endpoints. Until hosted support is verified, +use an API key. + Logout does not sign out the browser or read sessions from the older `dedalus auth login` implementation. Local tests do not prove hosted readiness. -`dedalus login` signs you in and saves the credential for later commands, so it does not have to be passed every time. It goes into your operating system's credential store — the system keyring on Linux, Credential Manager on Windows — and falls back to a file in your state directory, readable only by you, when no such store is available. On macOS it is always that file, because the system's own tool accepts a password only on its command line, where other processes could read it. Either way it is filed under the base URL it was captured for, so a credential saved for one host is never sent to another. `dedalus logout` forgets it. A credential passed with a flag, or set in the environment, still takes precedence over a saved one. Sign-in methods: browser, api-key, x-api-key. Pass `--flow ` to pick one without being asked. +`dedalus login` signs you in and saves the credential for later commands, so it does not have to be passed every time. It goes into your operating system's credential store — the system keyring on Linux, Credential Manager on Windows — and falls back to a file in your state directory, readable only by you, when no such store is available. On macOS it is always that file, because the system's own tool accepts a password only on its command line, where other processes could read it. Either way it is filed under the base URL it was captured for, so a credential saved for one host is never sent to another. `dedalus logout` forgets it. A credential passed with a flag, or set in the environment, still takes precedence over a saved one. Sign-in methods: browser, device, api-key, x-api-key. Pass `--flow ` to pick one without being asked. ```sh dedalus login --flow browser +dedalus login --flow device dedalus login --flow api-key dedalus logout dedalus logout --all diff --git a/man/dedalus-login.1 b/man/dedalus-login.1 index 1858106..335316e 100644 --- a/man/dedalus-login.1 +++ b/man/dedalus-login.1 @@ -10,12 +10,18 @@ Obtains a credential and saves it so later commands do not have to carry one. It .SH "OPTIONS" .TP \fB\-\-flow\fR \fIname\fR -Sign\-in flow to use, skipping the prompt. One of: api\-key, x\-api\-key. +Sign\-in flow to use, skipping the prompt. One of: browser, device, api\-key, x\-api\-key. .TP \fB\-\-base\-url\fR \fIurl\fR Sign in against this base URL instead of the default. .SH "SIGN\-IN FLOWS" .TP +\fBbrowser\fR +Sign in using a browser and a local callback. +.TP +\fBdevice\fR +Disabled in the public package until provider configuration and server support are verified. With a configured provider, approve the displayed code in a browser on any device. Saves tokens for refresh and logout revocation. +.TP \fBapi\-key\fR Enter your access token. .TP diff --git a/package.json b/package.json index 9d568e0..697ca8f 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "scripts": { "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finalize-build.mjs", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.cjs.json --noEmit", - "test": "node --test tests/feedback-*.test.mjs tests/browser-login.test.mjs", + "test": "node --test tests/feedback-*.test.mjs tests/browser-login.test.mjs tests/device-login.test.mjs", "ci:generate": "hollywood generate 'ci/**/*.ts' --source-root ci --output .", "test:package": "node scripts/verify-staging-package.mjs" }, diff --git a/src/cli/login.ts b/src/cli/login.ts index 92a8200..2458278 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -60,12 +60,13 @@ export type CliAuthMethodDefinition = { readonly kind: 'oauth'; readonly grant: 'deviceAuthorization'; readonly clientKey: string; - readonly deviceAuthorizationUrl: string; - readonly tokenUrl: string; - readonly refreshUrl: string; + readonly resource?: string; readonly scopes: readonly string[]; readonly clientId?: string; - } + } & ( + | { readonly discoveryUrl: string; readonly issuer: string } + | { readonly deviceAuthorizationUrl: string; readonly tokenUrl: string; readonly refreshUrl: string; readonly revocationUrl?: string } + ) | { readonly kind: 'oauth'; readonly grant: 'openIdConnect'; @@ -400,14 +401,14 @@ const captureCredentials = async ( if (!password) throw new UsageError('No password entered; nothing was saved.'); return { credentials: { [method.usernameKey]: username, [method.passwordKey]: password } }; } - const { token, clientId, refreshUrl } = await runOauthFlow(method, baseUrl); + const { token, clientId, refreshUrl, revocationUrl } = await runOauthFlow(method, baseUrl); return { credentials: { [method.clientKey]: token.accessToken }, // scalar-sdk-generator:custom-code login-revocation-metadata:start // retain the provider revocation endpoint with the grant oauth: { [method.clientKey]: { ...oauthMetadata(token, refreshUrl, clientId, undefined), - ...(method.grant === 'authorizationCode' && method.revocationUrl ? { revocationUrl: method.revocationUrl } : {}), + ...(revocationUrl ? { revocationUrl } : {}), } }, // scalar-sdk-generator:custom-code login-revocation-metadata:end }; @@ -450,7 +451,7 @@ const runOauthFlow = async ( ): Promise => { if (method.grant === 'authorizationCode') { const result = await authorizationCodeFlow(method, baseUrl); - return { ...result, refreshUrl: method.refreshUrl }; + return { ...result, refreshUrl: method.refreshUrl, ...(method.revocationUrl ? { revocationUrl: method.revocationUrl } : {}) }; } if (method.grant === 'openIdConnect') return openIdConnectFlow(method, baseUrl); if (method.grant === 'deviceAuthorization') return deviceAuthorizationFlow(method, baseUrl); @@ -494,6 +495,7 @@ type OAuthFlowResult = { readonly token: TokenResponse; readonly clientId: string | undefined; readonly refreshUrl: string; + readonly revocationUrl?: string; }; /** @@ -510,11 +512,19 @@ const deviceAuthorizationFlow = async ( ): Promise => { const clientId = method.clientId ?? (await promptLine('Client id: ')); if (!clientId) throw new UsageError('No client id entered; nothing was saved.'); + if (method.resource && profileKey(baseUrl) !== profileKey(method.resource)) { + throw new UsageError('Device sign-in is unavailable for this API base URL.'); + } + if ('discoveryUrl' in method && (!method.discoveryUrl || !method.issuer)) { + throw new UsageError('Device sign-in is not configured for this release. Use an API key until the provider rollout is complete.'); + } + const endpoints = 'discoveryUrl' in method ? await discoverDeviceAuthorization(method, baseUrl) : method; const device = await requestDeviceAuthorization( - method.deviceAuthorizationUrl, + endpoints.deviceAuthorizationUrl, baseUrl, clientId, method.scopes, + method.resource, ); processStderr.write( 'To sign in, visit:\n\n ' + @@ -523,9 +533,42 @@ const deviceAuthorizationFlow = async ( '\n\nWaiting for authorization...\n', ); return { - token: await pollDeviceToken(method.tokenUrl, baseUrl, clientId, device), + token: await pollDeviceToken(endpoints.tokenUrl, baseUrl, clientId, device), clientId, - refreshUrl: method.refreshUrl, + refreshUrl: endpoints.refreshUrl, + ...(endpoints.revocationUrl ? { revocationUrl: endpoints.revocationUrl } : {}), + }; +}; + +/** Use only device endpoints advertised by the configured issuer. */ +const discoverDeviceAuthorization = async ( + method: { readonly discoveryUrl: string; readonly issuer: string }, + baseUrl: string, +): Promise<{ deviceAuthorizationUrl: string; tokenUrl: string; refreshUrl: string; revocationUrl: string }> => { + const url = requireSecureUrl(method.discoveryUrl, baseUrl, 'OAuth discovery endpoint'); + const { response, text } = await getJson(url, 'OAuth discovery document'); + const payload = parseJson(text); + if (!response.ok) throw new Error('Device sign-in discovery is unavailable.'); + if (payload?.['issuer'] !== method.issuer) throw new Error('Device sign-in issuer does not match.'); + const grants = payload['grant_types_supported']; + if (!Array.isArray(grants) || !grants.includes('urn:ietf:params:oauth:grant-type:device_code')) { + throw new Error('The provider does not advertise device sign-in.'); + } + const deviceUrl = payload['device_authorization_endpoint']; + const tokenUrl = payload['token_endpoint']; + const revocationUrl = payload['revocation_endpoint']; + if (typeof revocationUrl !== 'string' || !revocationUrl) { + throw new Error('The provider returned no device sign-in revocation endpoint.'); + } + if (typeof deviceUrl !== 'string' || !deviceUrl || typeof tokenUrl !== 'string' || !tokenUrl) { + throw new Error('The provider returned incomplete device sign-in endpoints.'); + } + const token = requireSecureUrl(tokenUrl, method.issuer, 'token endpoint').toString(); + return { + deviceAuthorizationUrl: requireSecureUrl(deviceUrl, method.issuer, 'device authorization endpoint').toString(), + tokenUrl: token, + refreshUrl: token, + revocationUrl: requireSecureUrl(revocationUrl, method.issuer, 'revocation endpoint').toString(), }; }; @@ -565,9 +608,12 @@ const requestDeviceAuthorization = async ( baseUrl: string, clientId: string, scopes: readonly string[], + resource: string | undefined, ): Promise => { const url = requireSecureUrl(endpoint, baseUrl, 'device authorization endpoint'); - const { response, text } = await postForToken(url, { client_id: clientId, ...scopeParam(scopes) }); + const { response, text } = await postForToken(url, { + client_id: clientId, ...scopeParam(scopes), ...(resource ? { resource } : {}), + }); const payload = parseJson(text); if (!response.ok) { throw new Error( diff --git a/src/commands/index.ts b/src/commands/index.ts index 529303d..093564d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -875,6 +875,21 @@ export const auth = { redirectPort: 0, }, // scalar-sdk-generator:custom-code configured-browser-method:end + // scalar-sdk-generator:custom-code configured-device-method:start + // Device registration must support resource and organization binding before rollout. + { + name: 'device', + label: 'Sign in with a device code', + kind: 'oauth', + grant: 'deviceAuthorization', + clientKey: 'apiKey', + clientId: 'dedalus-cli', + discoveryUrl: '', + issuer: '', + resource: 'https://dcs.dedaluslabs.ai', + scopes: ['dedalus:cli', 'offline_access'], + }, + // scalar-sdk-generator:custom-code configured-device-method:end { name: 'api-key', label: 'Enter your access token', diff --git a/tests/device-login.test.mjs b/tests/device-login.test.mjs new file mode 100644 index 0000000..0c0687a --- /dev/null +++ b/tests/device-login.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { auth } from '../dist/esm/commands/index.js'; +import { runLogin } from '../dist/esm/cli/login.js'; + +test('invariant_device_session_is_bound_to_its_api', async () => { + await assert.rejects(runLogin(auth, 'https://other.invalid', 'device'), /unavailable for this API/); +}); + +test('invariant_unconfigured_device_login_makes_no_network_request', async (t) => { + const fetch = t.mock.method(globalThis, 'fetch', () => { throw new Error('unexpected network request'); }); + await assert.rejects(runLogin(auth, auth.defaultBaseUrl, 'device'), /not configured/); + assert.equal(fetch.mock.callCount(), 0); +}); + +for (const scenario of ['approved', 'denied', 'expired', 'wrong-issuer', 'unsupported', 'missing-endpoint', 'insecure-endpoint', 'missing-revocation', 'insecure-revocation']) { + test(`invariant_device_authorization_${scenario}`, { timeout: 15000 }, async (t) => { + const directory = mkdtempSync(join(tmpdir(), 'dedalus-device-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const store = join(directory, 'credentials.json'); + let polls = 0, deviceRequests = 0; + const grants = [], requests = [], revocations = []; + const server = createServer(async (req, res) => { + const reply = (status, body) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + if (req.url === '/.well-known/oauth-authorization-server') { + reply(200, { issuer: scenario === 'wrong-issuer' ? 'https://other.invalid' : origin, + grant_types_supported: scenario === 'unsupported' ? [] : ['urn:ietf:params:oauth:grant-type:device_code'], + device_authorization_endpoint: scenario === 'missing-endpoint' ? undefined : origin + '/device', + revocation_endpoint: scenario === 'missing-revocation' ? undefined : scenario === 'insecure-revocation' ? 'http://other.invalid/revoke' : origin + '/revoke', + token_endpoint: scenario === 'insecure-endpoint' ? 'http://other.invalid/token' : origin + '/token' }); + } else if (req.url === '/device' || req.url === '/token') { + let body = ''; + for await (const chunk of req) body += chunk; + const form = new URLSearchParams(body); + assert.equal(form.get('client_id'), 'dedalus-cli'); + assert.equal(form.has('client_secret'), false); + if (req.url === '/device') { + deviceRequests++; + assert.equal(form.get('scope'), 'dedalus:cli offline_access'); + assert.equal(form.get('resource'), origin); + reply(200, { device_code: 'private-device-code', user_code: 'TEST-CODE', + verification_uri: origin + '/verify', expires_in: 600, interval: 0.2 }); + } else { + grants.push(form.get('grant_type')); + if (form.get('grant_type') === 'refresh_token') { + assert.equal(form.get('refresh_token'), 'saved-refresh'); + reply(200, { access_token: 'renewed-access', refresh_token: 'rotated-refresh', expires_in: 3600 }); + } else { + assert.equal(form.get('device_code'), 'private-device-code'); + polls++; + if (scenario === 'denied' || scenario === 'expired') { + reply(400, { error: scenario === 'denied' ? 'access_denied' : 'expired_token' }); + } else if (polls === 1) reply(400, { error: 'authorization_pending' }); + else reply(200, { access_token: 'saved-access', refresh_token: 'saved-refresh', expires_in: 3600 }); + } + } + } else if (req.url === '/revoke') { + let body = ''; + for await (const chunk of req) body += chunk; + revocations.push(Object.fromEntries(new URLSearchParams(body))); + res.writeHead(200).end(); + } else { + requests.push(req.headers.authorization); + reply(200, { items: [], next_cursor: null }); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const origin = 'http://127.0.0.1:' + server.address().port; + const env = { ...process.env, DEDALUS_CREDENTIALS_FILE: store, TEST_API_ORIGIN: origin }; + for (const key of ['DEDALUS_API_KEY', 'DEDALUS_X_API_KEY', 'DEDALUS_BASE_URL', 'DEDALUS_CUSTOM_HEADERS']) delete env[key]; + const run = (args) => new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import { auth, getProgram } from './dist/esm/commands/index.js'; + const origin = process.env.TEST_API_ORIGIN; + auth.backend = 'file'; + auth.defaultBaseUrl = origin; + Object.assign(auth.methods.find(method => method.name === 'device'), { + resource: origin, issuer: origin, discoveryUrl: origin + '/.well-known/oauth-authorization-server' + }); + await getProgram().parseAsync(JSON.parse(process.env.TEST_ARGS), { from: 'user' }); + `], { env: { ...env, TEST_ARGS: JSON.stringify([...args, '--base-url', origin]) }, stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill(); }); + let stdout = '', stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('exit', code => resolve({ code, stdout, stderr })); + }); + const login = await run(['login', '--flow', 'device']); + assert.equal((login.stdout + login.stderr).includes('private-device-code'), false); + if (scenario !== 'approved') { + assert.notEqual(login.code, 0); + assert.throws(() => readFileSync(store), { code: 'ENOENT' }); + assert.equal(deviceRequests, ['denied', 'expired'].includes(scenario) ? 1 : 0); + return; + } + assert.equal(login.code, 0, login.stderr); + assert.match(login.stderr, /TEST-CODE/); + assert.ok(login.stderr.includes(origin + '/verify')); + assert.equal(polls, 2); + assert.equal(grants[0], 'urn:ietf:params:oauth:grant-type:device_code'); + assert.equal((await run(['machines', 'list'])).code, 0); + assert.equal(requests.at(-1), 'Bearer saved-access'); + const saved = JSON.parse(readFileSync(store, 'utf8')); + assert.equal(saved.profiles[origin].oauth.apiKey.revocationUrl, origin + '/revoke'); + saved.profiles[origin].oauth.apiKey.expiresAt = 0; + writeFileSync(store, JSON.stringify(saved)); + assert.equal((await run(['machines', 'list'])).code, 0); + assert.equal(requests.at(-1), 'Bearer renewed-access'); + assert.equal(JSON.parse(readFileSync(store, 'utf8')).profiles[origin].oauth.apiKey.refreshToken, 'rotated-refresh'); + assert.equal((await run(['logout'])).code, 0); + assert.deepEqual(revocations, [{ token: 'rotated-refresh', token_type_hint: 'refresh_token' }]); + assert.equal(JSON.parse(readFileSync(store, 'utf8')).profiles[origin], undefined); + }); +}