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
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Credentials are resolved via a chain (first match wins):
1. Explicit `apiKey` option
2. Explicit `accessToken` option (string or `() => string | Promise<string>`)
3. `SECLAI_API_KEY` environment variable
4. SSO profile from `~/.seclai/config` with cached tokens in `~/.seclai/sso/cache/`
4. SSO cached tokens from `~/.seclai/sso/cache/` (requires a prior `seclai auth login`)

```ts
// API key
Expand Down Expand Up @@ -81,13 +81,25 @@ const client = new Seclai({ profile: "my-profile" });
const client = new Seclai();
```

To set up SSO authentication, install the [Seclai CLI](https://www.npmjs.com/package/seclai) and run:
#### SSO authentication

SSO is the default fallback when no explicit credentials are provided. The SDK
includes built-in production SSO defaults, so `seclai configure sso` is not
required. You only need to log in once to populate the token cache:

```bash
seclai configure sso # set up an SSO profile
seclai auth login # authenticate via browser
npx @seclai/cli auth login # authenticate via browser — no prior setup needed
```

To customize SSO settings (e.g. for a staging environment), use `seclai configure sso`
or set environment variables:

| Variable | Description | Default |
|---|---|---|
| `SECLAI_SSO_DOMAIN` | Cognito domain | `auth.seclai.com` |
| `SECLAI_SSO_CLIENT_ID` | Cognito app client ID | `4bgf8v9qmc5puivbaqon9n5lmr` |
| `SECLAI_SSO_REGION` | AWS region | `us-west-2` |

## API documentation

Online API documentation (latest):
Expand Down
246 changes: 160 additions & 86 deletions openapi/seclai.openapi.json

Large diffs are not rendered by default.

71 changes: 40 additions & 31 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { FetchLike } from "./client";

/** Resolved SSO profile settings. */
export interface SsoProfile {
ssoAccountId: string;
ssoAccountId?: string | undefined;
ssoRegion: string;
ssoClientId: string;
ssoDomain: string;
Expand All @@ -37,6 +37,13 @@ const CONFIG_FILE = "config";
const EXPIRY_BUFFER_MS = 30_000; // 30 seconds
const DEFAULT_API_KEY_HEADER = "x-api-key";

/** Default SSO domain (production Cognito). Override with `SECLAI_SSO_DOMAIN` or config file. */
export const DEFAULT_SSO_DOMAIN = "auth.seclai.com";
/** Default SSO client ID (production public client). Override with `SECLAI_SSO_CLIENT_ID` or config file. */
export const DEFAULT_SSO_CLIENT_ID = "4bgf8v9qmc5puivbaqon9n5lmr";
/** Default SSO region. Override with `SECLAI_SSO_REGION` or config file. */
export const DEFAULT_SSO_REGION = "us-west-2";

// ─── Environment helpers ─────────────────────────────────────────────────────

function getEnv(name: string): string | undefined {
Expand Down Expand Up @@ -186,38 +193,42 @@ export async function resolveConfigDir(override?: string): Promise<string> {
/**
* Load and resolve an SSO profile from the config file.
* Non-default profiles inherit unset keys from `[default]`.
* All profiles fall back to built-in defaults and environment variable overrides.
*
* **Node.js only** — this function uses `node:fs` and `node:path` internally
* and will throw in browser/edge-worker runtimes.
*
* @param configDir - Resolved config directory path.
* @param profileName - Profile name to look up (`"default"` or a named profile).
* @returns The resolved profile, or `null` if not found or incomplete.
* @returns The resolved profile. Always returns a valid profile using built-in defaults.
*/
export async function loadSsoProfile(
configDir: string,
profileName: string,
): Promise<SsoProfile | null> {
): Promise<SsoProfile> {
const fs = await getFs();
const pathMod = await getPath();

const configPath = pathMod.join(configDir, CONFIG_FILE);
if (!fs.existsSync(configPath)) return null;

const content = fs.readFileSync(configPath, "utf-8");
const sections = parseIni(content);

const defaultSection = sections["default"] ?? {};
const profileSection = profileName === "default" ? defaultSection : sections[profileName];
let merged: Record<string, string> = {};

if (!profileSection) return null;
const configPath = pathMod.join(configDir, CONFIG_FILE);
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const sections = parseIni(content);

// Non-default profiles inherit from [default]
const merged = profileName === "default" ? profileSection : { ...defaultSection, ...profileSection };
const defaultSection = sections["default"] ?? {};
const profileSection = profileName === "default" ? defaultSection : sections[profileName];

const ssoAccountId = merged["sso_account_id"];
const ssoRegion = merged["sso_region"];
const ssoClientId = merged["sso_client_id"];
const ssoDomain = merged["sso_domain"];
if (profileSection) {
merged = profileName === "default" ? profileSection : { ...defaultSection, ...profileSection };
}
}

if (!ssoAccountId || !ssoRegion || !ssoClientId || !ssoDomain) return null;
// Environment variables override config file values
const ssoDomain = getEnv("SECLAI_SSO_DOMAIN") ?? merged["sso_domain"] ?? DEFAULT_SSO_DOMAIN;
const ssoClientId = getEnv("SECLAI_SSO_CLIENT_ID") ?? merged["sso_client_id"] ?? DEFAULT_SSO_CLIENT_ID;
const ssoRegion = getEnv("SECLAI_SSO_REGION") ?? merged["sso_region"] ?? DEFAULT_SSO_REGION;
const ssoAccountId = merged["sso_account_id"] || undefined;

return { ssoAccountId, ssoRegion, ssoClientId, ssoDomain };
}
Comment thread
burgaard marked this conversation as resolved.
Expand Down Expand Up @@ -491,19 +502,17 @@ export async function resolveCredentialChain(
const profileName = opts.profile ?? getEnv("SECLAI_PROFILE") ?? "default";
const ssoProfile = await loadSsoProfile(configDir, profileName);

if (ssoProfile) {
return {
mode: "sso",
apiKeyHeader,
accountId: opts.accountId ?? ssoProfile.ssoAccountId,
ssoProfile,
configDir,
autoRefresh: opts.autoRefresh !== false,
fetcher: opts.fetch,
};
}
return {
mode: "sso",
apiKeyHeader,
accountId: opts.accountId ?? ssoProfile.ssoAccountId,
ssoProfile,
configDir,
autoRefresh: opts.autoRefresh !== false,
fetcher: opts.fetch,
};
} catch {
// Config dir not found or profile not configured — fall through
// Config dir not found — fall through
}

// 6. Nothing found
Expand Down
6 changes: 5 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ function getEnv(name: string): string | undefined {
}

function buildURL(baseUrl: string, path: string, query?: Record<string, unknown>): URL {
const url = new URL(path, baseUrl);
// Ensure baseUrl ends with "/" so new URL() preserves its path component.
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
// Strip leading "/" from path so it's treated as relative to base.
const relative = path.startsWith("/") ? path.slice(1) : path;
const url = new URL(relative, base);
Comment thread
burgaard marked this conversation as resolved.
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
Expand Down
24 changes: 24 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ export {
type SsoCacheEntry,
type AuthState,
type CredentialChainOptions,
DEFAULT_SSO_DOMAIN,
DEFAULT_SSO_CLIENT_ID,
DEFAULT_SSO_REGION,
/**
* Load an SSO profile from the config file.
* **Node.js only** — uses `node:fs` and `node:path`.
*/
loadSsoProfile,
/**
* Read a cached SSO token from disk.
* **Node.js only** — uses `node:fs` and `node:path`.
*/
readSsoCache,
/**
* Write a cached SSO token to disk atomically.
* **Node.js only** — uses `node:fs` and `node:path`.
*/
writeSsoCache,
/**
* Delete a cached SSO token file.
* **Node.js only** — uses `node:fs` and `node:path`.
*/
deleteSsoCache,
Comment thread
burgaard marked this conversation as resolved.
isTokenValid,
} from "./auth";

export {
Expand Down
Loading
Loading