Skip to content
Merged
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
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,34 @@ See the [API reference](./api.md) for every available operation.

## Signing In

`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: api-key, x-api-key. Pass `--flow <name>` to pick one without being asked.
<!-- @custom start -->
Browser sign-in is not enabled in the public package until its provider and
client registration are verified. `dedalus login --flow browser` fails before
opening a browser when that configuration is absent. Use an API key meanwhile.
A configured provider must supply its exact callback issuer, authorization,
token, refresh, and revocation endpoints, client ID, scopes, and API resource.
The API resource must match the command's base URL.

The configured browser flow uses PKCE, verifies callback state and issuer, and
saves access and refresh tokens. Token refresh retains the revocation endpoint.
`dedalus logout` attempts to revoke the refresh token, or the access token when
no refresh token exists, with a 10-second timeout per request. It then removes
local credentials even if revocation fails or the device is offline. A warning
means the server session may still be active; local deletion does not confirm
server-side revocation. Local credential deletion failures still fail logout.
`--all` applies this to all saved profiles. Manually entered credentials are
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.

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.

```sh
dedalus login
dedalus login --flow browser
dedalus login --flow api-key
dedalus logout
dedalus logout --all
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",
"test": "node --test tests/feedback-*.test.mjs tests/browser-login.test.mjs",
"ci:generate": "hollywood generate 'ci/**/*.ts' --source-root ci --output .",
"test:package": "node scripts/verify-staging-package.mjs"
},
Expand Down
4 changes: 4 additions & 0 deletions src/cli/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export type CredentialStoreLocation = {

/** Refresh metadata kept beside a stored OAuth access token. */
export type StoredOAuth = {
// scalar-sdk-generator:custom-code revocation-metadata:start
/** Provider endpoint used for best-effort revocation on logout. */
readonly revocationUrl?: string;
// scalar-sdk-generator:custom-code revocation-metadata:end
readonly refreshToken?: string;
/** Unix epoch milliseconds the access token stops being usable, when the server said so. */
readonly expiresAt?: number;
Expand Down
104 changes: 90 additions & 14 deletions src/cli/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import {
type CredentialStoreLocation,
type StoredOAuth,
type StoredProfile,
clearAll,
deleteProfile,
profileKey,
readProfile,
// scalar-sdk-generator:custom-code logout-imports:start
clearAll,
readStore,
// scalar-sdk-generator:custom-code logout-imports:end
refreshProfile,
storeDescription,
writeProfile,
Expand All @@ -30,6 +33,12 @@ export type CliAuthMethodDefinition = {
| {
readonly kind: 'oauth';
readonly grant: 'authorizationCode';
// scalar-sdk-generator:custom-code oauth-provider-contract:start
// resource-bound provider configuration
readonly resource?: string;
readonly issuer?: string;
readonly revocationUrl?: string;
// scalar-sdk-generator:custom-code oauth-provider-contract:end
readonly clientKey: string;
readonly tokenUrl: string;
readonly refreshUrl: string;
Expand Down Expand Up @@ -236,18 +245,50 @@ export const runLogin = async (
return outstanding ? line + '\n' + outstanding : line;
};

/** Forgets stored credentials, returning the line to print. */
export const runLogout = (auth: CliAuthDefinition, baseUrl: string, all: boolean): string => {
// scalar-sdk-generator:custom-code oauth-logout:start

/** Attempts provider revocation, then clears local credentials even when offline. */
export const runLogout = async (auth: CliAuthDefinition, baseUrl: string, all: boolean): Promise<string> => {
const location = storeLocation(auth);
const keys = all ? Object.keys(readStore(location).profiles) : [profileKey(baseUrl)];
const warnRevocation = () => processStderr.write(
'Warning: OAuth revocation could not be confirmed. Clearing local credentials; the server session may remain active.\n',
);
for (const key of keys) {
let profile: StoredProfile;
try {
profile = readProfile(location, key);
} catch {
warnRevocation();
continue;
}
for (const [clientKey, meta] of Object.entries(profile.oauth ?? {})) {
try {
// Match Codex: prefer refresh revocation; use access only when no refresh token exists.
const token = meta?.refreshToken || profile.credentials?.[clientKey];
if (!token) continue;
if (!meta?.revocationUrl) throw new Error('Missing revocation endpoint.');
const url = requireSecureUrl(meta.revocationUrl, '', 'revocation endpoint');
const { response } = await postForToken(url, {
token,
token_type_hint: meta.refreshToken ? 'refresh_token' : 'access_token',
}, 10_000);
if (!response.ok) throw new Error('Revocation failed.');
} catch {
// Never print provider bodies or request errors: they can contain credentials.
warnRevocation();
}
}
}
// Local deletion is independent of the network result; deletion errors still propagate.
if (all) {
clearAll(location);
return 'Signed out everywhere.';
return 'Signed out of all saved profiles.';
}
const key = profileKey(baseUrl);
const outcome = deleteProfile(location, key);
if (outcome === 'removed') return 'Signed out of ' + safeText(baseUrl || 'the API') + '.';
return 'No stored credentials for ' + safeText(baseUrl || 'the API') + '.';
const removed = deleteProfile(location, profileKey(baseUrl)) === 'removed';
return (removed ? 'Signed out of ' : 'No stored credentials for ') + safeText(baseUrl || 'the API') + '.';
};
// scalar-sdk-generator:custom-code oauth-logout:end

/**
* Stored credentials for one base URL, keyed by client-option name.
Expand Down Expand Up @@ -297,7 +338,11 @@ export const storedCredentials = async (
// must not be undone by the token it produced. See `refreshProfile`.
refreshProfile(location, key, {
credentials: { [clientKey]: token.accessToken },
oauth: { [clientKey]: oauthMetadata(token, refreshUrl, meta.clientId, meta.refreshToken) },
// scalar-sdk-generator:custom-code refresh-revocation-metadata:start
// refresh retains revocation configuration
oauth: { [clientKey]: { ...oauthMetadata(token, refreshUrl, meta.clientId, meta.refreshToken),
...(meta.revocationUrl ? { revocationUrl: meta.revocationUrl } : {}) } },
// scalar-sdk-generator:custom-code refresh-revocation-metadata:end
});
} catch {
// Deliberately silent; see this function's doc comment.
Expand Down Expand Up @@ -358,7 +403,13 @@ const captureCredentials = async (
const { token, clientId, refreshUrl } = await runOauthFlow(method, baseUrl);
return {
credentials: { [method.clientKey]: token.accessToken },
oauth: { [method.clientKey]: oauthMetadata(token, refreshUrl, clientId, undefined) },
// 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 } : {}),
} },
// scalar-sdk-generator:custom-code login-revocation-metadata:end
};
};

Expand Down Expand Up @@ -682,11 +733,26 @@ const authorizationCodeFlow = async (
baseUrl: string,
): Promise<{ readonly token: TokenResponse; readonly clientId: string | undefined }> => {
const clientId = method.clientId;
if (!clientId || !method.authorizationUrl) throw new Error('This flow needs a configured OAuth client id.');
// scalar-sdk-generator:custom-code configured-browser-login:start
// public provider settings must be verified before enabling login
if (method.resource && profileKey(baseUrl) !== profileKey(method.resource)) {
throw new UsageError('Browser sign-in is unavailable for this API base URL.');
}
if (!clientId || !method.authorizationUrl || !method.tokenUrl || (method.resource && (!method.issuer || !method.revocationUrl))) {
throw new UsageError('Browser sign-in is not configured for this release. Use an API key until the provider rollout is complete.');
}
// scalar-sdk-generator:custom-code configured-browser-login:end
const authorizeUrl = requireSecureUrl(method.authorizationUrl, baseUrl, 'authorization endpoint');
// scalar-sdk-generator:custom-code oauth-resource:start
// RFC 8707 resource binding
if (method.resource) authorizeUrl.searchParams.set('resource', method.resource);
// scalar-sdk-generator:custom-code oauth-resource:end
const verifier = base64Url(randomBytes(32));
const challenge = base64Url(createHash('sha256').update(verifier).digest());
const state = base64Url(randomBytes(16));
// scalar-sdk-generator:custom-code oauth-state:start
// 256-bit callback state
const state = base64Url(randomBytes(32));
// scalar-sdk-generator:custom-code oauth-state:end

const server = createServer();
// Bound first, and only then given its request handler. Registering the handler earlier meant a
Expand All @@ -708,7 +774,10 @@ const authorizationCodeFlow = async (
}
throw error;
});
const redirect = awaitRedirect(server, state, REDIRECT_PATH);
// scalar-sdk-generator:custom-code callback-issuer:start
// RFC 9207 issuer binding
const redirect = awaitRedirect(server, state, REDIRECT_PATH, method.issuer);
// scalar-sdk-generator:custom-code callback-issuer:end
const redirectUri = 'http://127.0.0.1:' + String(port) + REDIRECT_PATH;
try {
authorizeUrl.searchParams.set('response_type', 'code');
Expand Down Expand Up @@ -866,6 +935,10 @@ const awaitRedirect = (
server: ReturnType<typeof createServer>,
state: string,
path: string,
// scalar-sdk-generator:custom-code callback-issuer-parameter:start
// RFC 9207 issuer binding
issuer?: string,
// scalar-sdk-generator:custom-code callback-issuer-parameter:end
): Promise<string> =>
new Promise<string>((resolve, reject) => {
server.on('request', (request, response) => {
Expand All @@ -890,7 +963,9 @@ const awaitRedirect = (
send(404, 'text/plain', 'Not found.\n');
return;
}
if (!sameToken(params.get('state') ?? '', state)) {
// scalar-sdk-generator:custom-code callback-verification:start
if (params.getAll('state').length !== 1 || !sameToken(params.get('state') ?? '', state) ||
(issuer !== undefined && (params.getAll('iss').length !== 1 || params.get('iss') !== issuer))) {
// Ends the sign-in rather than waiting for a better redirect. A request that reaches here is
// on the registered path and carries a `code` or an `error`, which makes it the provider's
// redirect rather than a stray probe — the path check above is what turns those away — so a
Expand All @@ -906,6 +981,7 @@ const awaitRedirect = (
reject(new Error('The browser redirect did not match this sign-in attempt.'));
return;
}
// scalar-sdk-generator:custom-code callback-verification:end
const failure = params.get('error');
if (failure) {
finish(
Expand Down
21 changes: 20 additions & 1 deletion src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ const commands = [

const groups = [] as const satisfies readonly CliCommandGroup[];

const auth = {
export const auth = {
loginPath: ['login'],
logoutPath: ['logout'],
loginCommand: 'dedalus login',
Expand All @@ -856,6 +856,25 @@ const auth = {
xAPIKey: 'DEDALUS_X_API_KEY',
},
methods: [
// scalar-sdk-generator:custom-code configured-browser-method:start
// configured browser login pending the public provider rollout
{
name: 'browser',
label: 'Sign in with your browser',
kind: 'oauth',
grant: 'authorizationCode',
clientKey: 'apiKey',
clientId: 'dedalus-cli',
authorizationUrl: '',
tokenUrl: '',
refreshUrl: '',
resource: 'https://dcs.dedaluslabs.ai',
issuer: '',
revocationUrl: '',
scopes: ['dedalus:cli', 'offline_access'],
redirectPort: 0,
},
// scalar-sdk-generator:custom-code configured-browser-method:end
{
name: 'api-key',
label: 'Enter your access token',
Expand Down
Loading