Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- @custom end -->

`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 <name>` 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 <name>` 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
Expand Down
8 changes: 7 additions & 1 deletion man/dedalus-login.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
68 changes: 57 additions & 11 deletions src/cli/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -450,7 +451,7 @@ const runOauthFlow = async (
): Promise<OAuthFlowResult> => {
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);
Expand Down Expand Up @@ -494,6 +495,7 @@ type OAuthFlowResult = {
readonly token: TokenResponse;
readonly clientId: string | undefined;
readonly refreshUrl: string;
readonly revocationUrl?: string;
};

/**
Expand All @@ -510,11 +512,19 @@ const deviceAuthorizationFlow = async (
): Promise<OAuthFlowResult> => {
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 ' +
Expand All @@ -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(),
};
};

Expand Down Expand Up @@ -565,9 +608,12 @@ const requestDeviceAuthorization = async (
baseUrl: string,
clientId: string,
scopes: readonly string[],
resource: string | undefined,
): Promise<DeviceAuthorization> => {
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(
Expand Down
15 changes: 15 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
121 changes: 121 additions & 0 deletions tests/device-login.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}