diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml index 2418049..ff3ffee 100644 --- a/.github/workflows/sdk-ci.yml +++ b/.github/workflows/sdk-ci.yml @@ -13,4 +13,5 @@ jobs: with: node-version: "20" - run: npm install - - run: npm run build + - run: npm run typecheck + - run: npm test diff --git a/package.json b/package.json index 8baf425..ca93068 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,20 @@ "./man/dedalus-completion.1" ], "scripts": { - "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finalize-build.mjs", + "build": "node scripts/prepare-build.mjs && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finalize-build.mjs", + "test": "npm run build && node --test tests/*.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.cjs.json --noEmit" }, "dependencies": { + "@napi-rs/keyring": "^1.3.0", "ansis": "^4.3.0", "commander": "^14.0.3", + "proper-lockfile": "^4.1.2", "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "^20.17.0", + "@types/proper-lockfile": "^4.1.4", "typescript": "^6.0.0" }, "license": "Apache-2.0" diff --git a/scripts/prepare-build.mjs b/scripts/prepare-build.mjs new file mode 100644 index 0000000..8d2c062 --- /dev/null +++ b/scripts/prepare-build.mjs @@ -0,0 +1,7 @@ +import { rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +await rm(resolve(root, 'dist'), { recursive: true, force: true }) diff --git a/src/custom/auth/credentials.ts b/src/custom/auth/credentials.ts new file mode 100644 index 0000000..db64c1d --- /dev/null +++ b/src/custom/auth/credentials.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'node:crypto' +import { constants, type Stats } from 'node:fs' +import { type FileHandle, lstat, mkdir, open, rename, unlink } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import lockfile from 'proper-lockfile' + +import type { OAuthSession } from './types.js' + +const credentialService = 'com.dedalus.cli' +const credentialAccount = 'default' +const maxCredentialFileBytes = 512 * 1024 +const maxCredentialMetadataLength = 4 * 1024 +const maxCredentialScopeCount = 32 +const maxCredentialScopeLength = 256 +const maxCredentialTokenLength = 128 * 1024 + +export type CredentialStorageErrorCode = + | 'ambiguous_credential' + | 'insecure_permissions' + | 'environment_mismatch' + | 'invalid_configuration' + | 'invalid_credential' + | 'not_logged_in' + | 'storage_unavailable' + | 'unsupported_credential' + +export class CredentialStorageError extends Error { + readonly code: CredentialStorageErrorCode + + constructor(code: CredentialStorageErrorCode, options?: ErrorOptions) { + super(code, options) + this.name = 'CredentialStorageError' + this.code = code + } +} + +export type CredentialStore = { + readonly backend: 'file' | 'keyring' + readonly read: () => Promise + readonly write: (session: OAuthSession) => Promise + readonly remove: () => Promise + readonly withLifecycleLock: (operation: () => Promise) => Promise +} + +export type ResolvedCredential = { + readonly value: string + readonly source: 'environment' | 'flag' | 'oauth_session' + readonly transport: 'bearer' | 'x-api-key' +} + +export type CredentialResolutionOptions = { + readonly flags: { + readonly apiKey?: string + readonly bearerAuth?: string + readonly xApiKey?: string + } + readonly environment?: Readonly> + readonly storedAccessToken: () => Promise +} + +export type DefaultCredentialStoreOptions = { + readonly environment?: Readonly> + readonly platform?: NodeJS.Platform + readonly credentialPath?: string +} + +type KeyringEntry = { + readonly getPassword: () => Promise + readonly setPassword: (password: string) => Promise + readonly deleteCredential: () => Promise +} + +type KeyringEntryFactory = () => Promise + +const lifecycleLockOptions = { + realpath: false, + retries: { retries: 120, factor: 1, minTimeout: 250, maxTimeout: 250 }, + stale: 15 * 60 * 1000, + update: 30 * 1000, +} as const diff --git a/src/custom/auth/types.ts b/src/custom/auth/types.ts new file mode 100644 index 0000000..3110669 --- /dev/null +++ b/src/custom/auth/types.ts @@ -0,0 +1,60 @@ +export type OAuthSession = { + readonly version: 1 + readonly issuer: string + readonly clientId: string + readonly accessToken: string + /** Unix epoch milliseconds, capped at JavaScript's year 275760 date limit. */ + readonly accessTokenExpiresAt: number + readonly refreshToken: string + readonly userId: string + readonly organizationId: string + readonly organizationName?: string + readonly grantedScopes: readonly string[] + readonly providerSessionId?: string +} + +export type AuthProviderErrorStage = 'local' | 'network' | 'provider' + +export class AuthProviderError extends Error { + readonly code: string + readonly stage: AuthProviderErrorStage + readonly status: number | undefined + + constructor( + code: string, + options: ErrorOptions & { + readonly stage?: AuthProviderErrorStage + readonly status?: number + } = {}, + ) { + super(code, options) + this.name = 'AuthProviderError' + this.code = code + this.stage = options.stage ?? 'local' + this.status = options.status + } +} + +export type AuthProvider = { + readonly issuer: string + readonly clientId: string + readonly login: () => Promise + readonly refresh: (session: OAuthSession) => Promise + readonly revoke: (session: OAuthSession) => Promise +} + +export type OAuthSessionMetadata = Omit< + OAuthSession, + 'accessToken' | 'refreshToken' | 'version' +> + +export const oauthSessionMetadata = (session: OAuthSession): OAuthSessionMetadata => ({ + issuer: session.issuer, + clientId: session.clientId, + accessTokenExpiresAt: session.accessTokenExpiresAt, + userId: session.userId, + organizationId: session.organizationId, + ...(session.organizationName === undefined ? {} : { organizationName: session.organizationName }), + grantedScopes: session.grantedScopes, + ...(session.providerSessionId === undefined ? {} : { providerSessionId: session.providerSessionId }), +}) diff --git a/tests/auth-model.test.mjs b/tests/auth-model.test.mjs new file mode 100644 index 0000000..893069b --- /dev/null +++ b/tests/auth-model.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { CredentialStorageError } from '../dist/esm/custom/auth/credentials.js' + +test('credential errors retain stable machine-readable codes', () => { + const error = new CredentialStorageError('invalid_credential') + + assert.equal(error.name, 'CredentialStorageError') + assert.equal(error.code, 'invalid_credential') +})