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
3 changes: 2 additions & 1 deletion .github/workflows/sdk-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ jobs:
with:
node-version: "20"
- run: npm install
- run: npm run build
- run: npm run typecheck
- run: npm test
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions scripts/prepare-build.mjs
Original file line number Diff line number Diff line change
@@ -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 })
81 changes: 81 additions & 0 deletions src/custom/auth/credentials.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthSession | null>
readonly write: (session: OAuthSession) => Promise<void>
readonly remove: () => Promise<boolean>
readonly withLifecycleLock: <T>(operation: () => Promise<T>) => Promise<T>
}

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<Record<string, string | undefined>>
readonly storedAccessToken: () => Promise<string | null>
}

export type DefaultCredentialStoreOptions = {
readonly environment?: Readonly<Record<string, string | undefined>>
readonly platform?: NodeJS.Platform
readonly credentialPath?: string
}

type KeyringEntry = {
readonly getPassword: () => Promise<string | null | undefined>
readonly setPassword: (password: string) => Promise<void>
readonly deleteCredential: () => Promise<boolean>
}

type KeyringEntryFactory = () => Promise<KeyringEntry>

const lifecycleLockOptions = {
realpath: false,
retries: { retries: 120, factor: 1, minTimeout: 250, maxTimeout: 250 },
stale: 15 * 60 * 1000,
update: 30 * 1000,
} as const
60 changes: 60 additions & 0 deletions src/custom/auth/types.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthSession>
readonly refresh: (session: OAuthSession) => Promise<OAuthSession>
readonly revoke: (session: OAuthSession) => Promise<boolean>
}

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 }),
})
11 changes: 11 additions & 0 deletions tests/auth-model.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})