From 8bb17133f2753b58dd63e63695b6b97efa43db79 Mon Sep 17 00:00:00 2001 From: Shengming Liang <231182611+AgentWings@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:26:24 -0700 Subject: [PATCH] feat(auth): add device-code login --- README.md | 15 ++++- man/dedalus-login.1 | 8 ++- package.json | 2 +- src/cli/login.ts | 46 +++++++++++++--- src/commands/index.ts | 12 ++++ tests/device-login.test.mjs | 106 ++++++++++++++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 tests/device-login.test.mjs diff --git a/README.md b/README.md index 2ee823b..f65a79f 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,27 @@ and a registered `dedalus-cli` public client supporting loopback callbacks. Until that server support is deployed, use an API key. Local protocol tests do not establish that hosted account sign-in is available. +Run `dedalus login --flow device` when the terminal cannot open a browser or +receive a local callback. Open the displayed link on any browser-capable device, +confirm the code, and approve access. The CLI saves the resulting tokens and +refreshes them automatically on later commands, including after a restart. +If the refresh token expires or is revoked, sign in again. + +Device sign-in requires the public OAuth discovery document at +`https://dcs.dedaluslabs.ai/.well-known/oauth-authorization-server` to advertise +the device grant and its endpoints, with that grant enabled for the public client. +It is bound to the same API host as browser sign-in. Until this server support is +available, use an API key; adding the command does not enable hosted device login. + `dedalus logout` removes local credentials; it does not revoke tokens on the server or sign out your browser. It does not read or remove sessions from the older `dedalus auth login` implementation. -`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..d6313bc 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 +Approve the displayed code in a browser on any device. Requires server support for device authorization. Saves tokens and refreshes them on later commands. +.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 9e8319d..58bdfd2 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -53,12 +53,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 kind: 'oauth'; readonly grant: 'openIdConnect'; @@ -461,8 +462,12 @@ 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.'); + } + const endpoints = 'discoveryUrl' in method ? await discoverDeviceAuthorization(method, baseUrl) : method; const device = await requestDeviceAuthorization( - method.deviceAuthorizationUrl, + endpoints.deviceAuthorizationUrl, baseUrl, clientId, method.scopes, @@ -474,9 +479,36 @@ 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, + }; +}; + +/** 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 }> => { + 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']; + 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, }; }; diff --git a/src/commands/index.ts b/src/commands/index.ts index 462c2eb..50726f6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -871,6 +871,18 @@ export const auth = { scopes: ['dedalus:cli', 'offline_access'], redirectPort: 0, }, + { + name: 'device', + label: 'Sign in with a device code', + kind: 'oauth', + grant: 'deviceAuthorization', + clientKey: 'apiKey', + clientId: 'dedalus-cli', + discoveryUrl: 'https://dcs.dedaluslabs.ai/.well-known/oauth-authorization-server', + issuer: 'https://dcs.dedaluslabs.ai', + resource: 'https://dcs.dedaluslabs.ai', + scopes: ['dedalus:cli', 'offline_access'], + }, { 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..b390992 --- /dev/null +++ b/tests/device-login.test.mjs @@ -0,0 +1,106 @@ +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/); +}); + +for (const scenario of ['approved', 'denied', 'expired', 'wrong-issuer', 'unsupported', 'missing-endpoint', 'insecure-endpoint']) { + 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 = []; + 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', + 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'); + 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 { + 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')); + 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.equal(JSON.parse(readFileSync(store, 'utf8')).profiles[origin], undefined); + }); +}