diff --git a/.kiro/specs/oauth-login/.config.kiro b/.kiro/specs/oauth-login/.config.kiro new file mode 100644 index 000000000..4d7e72ac8 --- /dev/null +++ b/.kiro/specs/oauth-login/.config.kiro @@ -0,0 +1 @@ +{"specId": "1a7f04d1-c120-4907-9ae8-e53b357dfa91", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/oauth-login/design.md b/.kiro/specs/oauth-login/design.md new file mode 100644 index 000000000..1a2746834 --- /dev/null +++ b/.kiro/specs/oauth-login/design.md @@ -0,0 +1,414 @@ +# Design Document + +> cz-cli OAuth2 登录接入 — 技术设计 + +## Overview + +本设计在 `@clickzetta/sdk`(`packages/clickzetta-sdk`)的认证层引入 OAuth2 授权码 + PKCE 流程,使 `loginWithPassword` / `loginWithPat` 在 OAuth 模式下获取 `access_token` / `refresh_token`,并让 token 缓存层(`token.ts`)在过期时优先使用 refresh token 轮换续期。改造遵循以下原则: + +- **最小侵入、向后兼容**:保持 `loginWithPat` / `loginWithPassword` 的现有签名与传统登录行为;OAuth 仅在服务端返回 `authorizationCode` 时生效。 +- **复用既有重试链路**:登录请求继续走 `postLogin` 的重试 / 退避 / 实例错误识别逻辑,OAuth 只是在登录成功之后追加“换取 token”步骤。 +- **本期手动粘贴优先**:默认通过登录响应中直接返回的 `authorizationCode`(免浏览器路径)或手动粘贴获取授权码;本地回调监听作为独立模块实现但默认禁用。 +- **TDD 对齐**:已有的 `packages/clickzetta-sdk/test/login-oauth.test.ts` 定义了目标行为(发送 `oauthLoginParam`、换取授权码、`AuthToken.refreshToken`),本设计需让该测试通过。 + +设计覆盖需求 1–8。 + +## Architecture + +OAuth 登录链路分为四个职责模块,全部位于 SDK 认证层: + +```mermaid +flowchart TD + Caller["调用方 (token.ts: getToken / forceRefreshToken)"] + Login["login.ts: loginWithPassword / loginWithPat"] + PKCE["pkce.ts: 生成 code_verifier / code_challenge"] + Portal["POST /clickzetta-portal/user/loginSingle (+oauthLoginParam)"] + OAuth["oauth.ts: exchangeAuthorizationCode / refreshAccessToken / fetchUserInfo"] + TokenEP["POST /oauth2/token"] + UserInfoEP["GET /oauth2/userinfo"] + Callback["callback-server.ts (本地回调监听, 默认禁用)"] + + Caller --> Login + Login --> PKCE + Login --> Portal + Portal -->|"data.authorizationCode 非空"| OAuth + Portal -->|"无 authorizationCode"| Caller + OAuth --> TokenEP + OAuth --> UserInfoEP + Login -.->|"启用本地回调时"| Callback + OAuth -->|"AuthToken{token,refreshToken,expireTimeMs}"| Caller +``` + +登录时序(手动粘贴 / 免浏览器默认路径): + +```mermaid +sequenceDiagram + participant T as token.ts + participant L as login.ts + participant P as PKCE + participant S as Portal /user/loginSingle + participant O as /oauth2/token + + T->>L: loginWithPassword(baseUrl, user, pass, instance) + L->>P: generatePkce() + P-->>L: {codeVerifier, codeChallenge} + L->>S: POST {username,password,instanceName, oauthLoginParam{...,codeChallenge,S256}} + S-->>L: {data:{token(legacy), authorizationCode, ...}} + alt authorizationCode 非空 + L->>O: POST grant_type=authorization_code, code, code_verifier, client_id, redirect_uri + O-->>L: {access_token, refresh_token, expires_in} + L-->>T: AuthToken{token=access_token, refreshToken, expireTimeMs=expires_in*1000} + else 无 authorizationCode (传统/兼容) + L-->>T: AuthToken{token=legacy token} + end +``` + +## Components and Interfaces + +### 1. `src/auth/oauth-constants.ts`(新增) + +集中 OAuth 客户端常量,避免散落字符串。 + +```ts +export const OAUTH_CLIENT_ID = "official-cli" +export const OAUTH_REDIRECT_URI = "http://127.0.0.1/callback" +export const OAUTH_SCOPE = "openid profile offline_access" +export const OAUTH_CODE_CHALLENGE_METHOD = "S256" +``` + +> 设计说明:`redirectUri` 固定为 `http://127.0.0.1/callback`(与服务端 client 白名单一致)。本地回调监听启用时仍使用该 loopback 地址(端口由监听模块决定,见模块 5)。 + +### 2. `src/auth/pkce.ts`(新增) + +负责生成符合 RFC 7636 的 PKCE 参数(需求 2)。 + +```ts +export interface Pkce { + codeVerifier: string + codeChallenge: string // base64url(SHA-256(codeVerifier)), 无 padding +} + +// 生成 43-128 字符的高熵 code_verifier,并计算 S256 challenge +export function generatePkce(): Pkce +``` + +实现要点: +- `codeVerifier`:使用 `crypto.getRandomValues` 生成随机字节并 base64url 编码,截取到 RFC 7636 合法长度(43–128,unreserved 字符集)。 +- `codeChallenge`:`base64url(sha256(codeVerifier))`,去除 `=` padding,`+`→`-`、`/`→`_`。 + +### 3. `src/auth/oauth.ts`(新增) + +封装与 `/oauth2/*` 端点的交互(需求 4、5、6、7)。 + +```ts +export interface OAuthTokenResult { + accessToken: string + refreshToken?: string + expiresInMs: number + tokenType: string +} + +// grant_type=authorization_code +export function exchangeAuthorizationCode( + baseUrl: string, + code: string, + codeVerifier: string, +): Promise + +// grant_type=refresh_token +export function refreshAccessToken( + baseUrl: string, + refreshToken: string, +): Promise + +// GET /oauth2/userinfo (Authorization: Bearer) +export function fetchUserInfo( + baseUrl: string, + accessToken: string, +): Promise> +``` + +实现要点: +- 请求体为 `application/x-www-form-urlencoded`,用 `URLSearchParams` 构造。 +- 端点路径:`${baseUrl}/oauth2/token`、`${baseUrl}/oauth2/userinfo`(与 portal 路径前缀不同,OAuth 端点不带 `/clickzetta-portal`)。 +- `expires_in`(秒)统一换算为毫秒返回,便于复用 `token.ts` 的 `expireTimeMs` 语义。 +- 错误映射:解析响应中的 OAuth 错误码(`invalid_request` / `invalid_client` / `invalid_scope` / `invalid_grant` / `invalid_token`),抛出 `InterfaceError`(auth 层失败),message 含错误码语义但**不含敏感值**(需求 7.6)。 +- 复用 `generateRequestId` 思路携带 requestId(需求 7.7)。 + +### 4. `src/auth/login.ts`(改造) + +在 `loginWithRetry` 成功路径后追加 OAuth 换取逻辑(需求 1、3.7、4、8)。 + +- `LoginResponse` 扩展可选字段 `authorizationCode?: string`。 +- `loginWithPassword` / `loginWithPat`: + - 调用 `generatePkce()`,构造 `oauthLoginParam`(`oauthLogin:true`、`clientId`、`redirectUri`、`scope`、`codeChallenge`、`codeChallengeMethod:"S256"`),与原 body 合并发往 `/user/loginSingle`。 + - 登录成功后:若 `data.authorizationCode` 非空 → 调用 `exchangeAuthorizationCode(baseUrl, code, codeVerifier)`,用返回值构造 `AuthToken`;否则保留 legacy token(不调用 `/oauth2/token`,需求 8.2)。 +- 公开签名不变(需求 8.3)。`baseUrl` 已在函数内可用,因此换取 token 不需要新增参数。 + +> 说明:当前服务端的 `/user/loginSingle` 在携带 `oauthLoginParam` 时直接在登录响应里返回 `authorizationCode`,这正是本期“手动粘贴 / 免浏览器”默认路径的实现基础(需求 3.7),无需依赖浏览器回传。 + +### 5. `src/auth/callback-server.ts`(新增,默认禁用 —— 需求 3.5/3.6) + +为未来前端就绪后的自动回传预留:在 loopback 上启动一次性 HTTP 监听,解析 `?code=...&state=...` 并校验 `state`,拿到授权码后关闭监听。 + +```ts +export interface CallbackOptions { + expectedState: string + timeoutMs?: number +} +export function waitForAuthorizationCode(opts: CallbackOptions): Promise +``` + +- 本期**不被默认链路调用**;由开关(如环境变量 `CZ_OAUTH_LOCAL_CALLBACK=1` 或配置项)控制启用。 +- 禁用时不占用任何端口(需求 3.6)。 + +### 6. `src/auth/token.ts`(改造 —— 需求 5) + +- `isTokenExpired` 逻辑不变(`EXPIRED_FACTOR = 0.8`)。 +- `getToken`:当缓存 token 过期且 `cachedToken.refreshToken` 存在时,优先 `refreshAccessToken(baseUrl, refreshToken)` 续期;续期成功则更新缓存(含轮换后的新 refresh token,需求 5.3)。 +- 续期失败(`invalid_grant`)→ 清缓存并回退到完整 `fetchToken`(需求 5.4)。 +- 仅持有 legacy token(无 refreshToken)时维持现有重新登录行为(需求 5.5)。 + +### 7. `src/types/index.ts`(改造 —— 需求 4.5、8.4) + +`AuthToken` 新增可选字段: + +```ts +export interface AuthToken { + token: string + instanceId: number + userId: number + expireTimeMs: number + obtainedAt: number + refreshToken?: string // OAuth refresh token;传统登录模式下为 undefined +} +``` + +## Data Models + +### `oauthLoginParam`(请求字段,发往 `/user/loginSingle`) + +| 字段 | 值 | 来源 | +|------|----|------| +| `oauthLogin` | `true` | 常量 | +| `clientId` | `"official-cli"` | `OAUTH_CLIENT_ID` | +| `redirectUri` | `"http://127.0.0.1/callback"` | `OAUTH_REDIRECT_URI` | +| `scope` | `"openid profile offline_access"` | `OAUTH_SCOPE` | +| `codeChallenge` | `base64url(sha256(codeVerifier))` | `pkce.ts` | +| `codeChallengeMethod` | `"S256"` | 常量 | + +### `/oauth2/token` 请求(authorization_code) + +`grant_type=authorization_code`、`code`、`client_id`、`redirect_uri`、`code_verifier`。 + +### `/oauth2/token` 请求(refresh_token) + +`grant_type=refresh_token`、`refresh_token`、`client_id`。 + +### `AuthToken` 映射 + +| 服务端字段 | AuthToken 字段 | 转换 | +|-----------|----------------|------| +| `access_token` | `token` | 直接 | +| `refresh_token` | `refreshToken` | 直接(可空) | +| `expires_in` (秒) | `expireTimeMs` | `* 1000` | +| — | `obtainedAt` | `Date.now()` | +| portal `instanceId`/`userId` | `instanceId`/`userId` | 来自登录响应 | + +## Error Handling + +| 场景 | 服务端语义 | CLI 行为 | 需求 | +|------|-----------|----------|------| +| 缺 `codeChallenge` / method 非 S256 / redirectUri 不匹配 | `invalid_request` | 抛 `InterfaceError`,提示请求参数问题 | 7.1 | +| client 配置缺失 | `invalid_client` | 抛 `InterfaceError`,提示 client 配置 | 7.2 | +| scope 越权 | `invalid_scope` | 抛 `InterfaceError`,提示 scope 越权 | 7.3 | +| 授权码过期/复用/redirect 不一致/verifier 不匹配 | `invalid_grant` | 抛 `InterfaceError`,不复用同一授权码 | 7.4 | +| access_token 失效 | `invalid_token` | 触发 refresh 续期或返回认证失败 | 7.5、6.4 | +| 手动粘贴空值/取消 | — | 终止登录,不调用 `/oauth2/token` | 3.4 | +| refresh 续期失败 | `invalid_grant` | 清缓存并回退完整登录 | 5.4 | + +通用约束:任何错误信息与日志**禁止**输出 `code_verifier`、授权码明文、`access_token`、`refresh_token`(需求 7.6)。 + +## Testing Strategy + +- **单元测试(已有 + 扩展)**:`test/login-oauth.test.ts` 已覆盖“发送 oauthLoginParam + 换取授权码”与“无 authorizationCode 保留 legacy token”。需补充: + - PKCE:`codeChallenge === base64url(sha256(codeVerifier))`,长度/字符集合法。 + - refresh 续期:过期后用 `grant_type=refresh_token` 续期并轮换 refresh token。 + - userinfo:带 `Authorization: Bearer`,不回显敏感字段。 + - 负向:各 OAuth 错误码映射到 `InterfaceError`,错误信息不含敏感值。 + - 兼容:传统登录(无 oauthLoginParam 响应)行为不变;既有重试/退避/实例错误识别保持。 +- **运行方式**:从包目录执行 `cd packages/clickzetta-sdk && bun test test/login-oauth.test.ts`;`bun typecheck`。 +- **避免 mock 业务逻辑**:沿用现有以 `globalThis.fetch` 桩代替网络的方式,不复制实现逻辑到测试。 + +## Correctness Properties + +用于属性测试(PBT)的可执行正确性属性: + +### Property 1: PKCE 一致性 +对任意 `generatePkce()` 产出,`codeChallenge == base64url(sha256(codeVerifier))` 恒成立,且 `codeVerifier` 长度 ∈ [43,128] 且仅含 unreserved 字符。 +**Validates: Requirements 2.1, 2.2** + +### Property 2: PKCE 唯一性 +连续多次 `generatePkce()` 产出的 `codeVerifier` 互不相同(高概率)。 +**Validates: Requirements 2.3** + +### Property 3: OAuth 触发条件 +当且仅当登录响应 `authorizationCode` 非空时才调用 `/oauth2/token`;为空时调用次数为 0。 +**Validates: Requirements 1.4, 8.2** + +### Property 4: Token 映射不变量 +换取成功后 `AuthToken.token == access_token` 且 `AuthToken.expireTimeMs == expires_in * 1000`。 +**Validates: Requirements 4.3, 4.4** + +### Property 5: Refresh 轮换单调性 +每次 refresh 成功后,缓存中的 `refreshToken` 被替换为服务端返回的最新值;后续续期使用最新值。 +**Validates: Requirements 5.2, 5.3** + +### Property 6: 向后兼容 +不携带 `oauthLoginParam` 或响应无 `authorizationCode` 时,返回的 `AuthToken` 不含 `refreshToken`,且公开函数签名与既有行为不变。 +**Validates: Requirements 8.2, 8.3, 8.4** + +### Property 7: 敏感信息不泄露 +任意错误路径产生的 message / 日志均不包含 `code_verifier`、授权码明文、`access_token`、`refresh_token` 子串。 +**Validates: Requirements 7.6, 2.4** + +## Addendum: Refresh Token 持久化(需求 9) + +### 设计目标 + +让 OAuth token(尤其 refresh token)跨进程复用,同时不让 SDK 认证层依赖文件系统或 profile 概念。采用**依赖注入**:在 `ConnectionConfig` 上新增一个可选的 token 存储接口,由 cz-cli 层注入 profile-backed 实现;SDK 的 `token.ts` 只面向接口编程。未注入时退化为现有纯内存缓存(向后兼容,需求 9.7)。 + +### 组件 + +#### A. `ConnectionConfig.tokenStore`(SDK 类型扩展) + +```ts +export interface TokenStore { + load(): AuthToken | undefined + save(token: AuthToken): void + clear(): void +} + +export interface ConnectionConfig { + // ...现有字段 + tokenStore?: TokenStore +} +``` + +#### B. `src/auth/token.ts`(SDK,改造) + +`getToken(config)` 的取值顺序调整为: +1. 内存缓存命中且未过期 → 返回。 +2. 内存无缓存但 `config.tokenStore?.load()` 返回了 token: + - 未过期 → 放入内存缓存并返回(需求 9.3,跨进程免登录)。 + - 已过期且含 `refreshToken` → 走 refresh 续期(成功则 `tokenStore.save()` 回写并更新内存,需求 9.4)。 + - 已过期且无 `refreshToken` → 完整登录。 +3. 否则完整登录。 +4. 登录或刷新成功后,若存在 `tokenStore` 则 `save()`(需求 9.1)。 +5. refresh 失败 → `tokenStore.clear()` + 清内存缓存 + 回退完整登录(需求 9.5)。 + +并发仍由现有 `pendingFetch` 合并。`isTokenExpired` / `EXPIRED_FACTOR` 不变。 + +#### C. `connection/profile-store.ts`(cz-cli,新增 profile-backed 实现) + +```ts +export function makeProfileTokenStore(profileName: string | undefined, cacheKey: string): TokenStore +``` + +- 存储位置:`profiles.toml` 中对应 profile 条目下的 `[profiles..oauth]` 子表,按 `cacheKey`(`instance:pat-or-username`)区分,键用 snake_case:`access_token`、`refresh_token`、`expire_time_ms`、`obtained_at`、`instance_id`、`user_id`。 +- 复用现有 `writeProfilesFile` 的原子写 + `0o600`(需求 9.2)。 +- `load` 读不到或解析失败时返回 `undefined`(best-effort,绝不抛错阻塞 CLI)。 +- `clear` 删除该 profile 的 oauth 子表条目。 + +#### D. `connection/config.ts`(cz-cli,注入点) + +`resolveConnectionConfig(args)` 解析出 profileName 后,在返回的 `ConnectionConfig` 上挂载 `tokenStore = makeProfileTokenStore(profileName, cacheKey)`,从而 `exec.ts`、`studio-context.ts` 等经由该函数的调用方自动获得持久化。setup/verify 等临时构造 config 的调用点不注入(保持简单,未过期复用对它们非必需)。 + +### Error Handling 补充 + +| 场景 | 行为 | 需求 | +|------|------|------| +| profiles.toml 不存在/损坏/无权限 | load 返回 undefined,正常走登录;save/clear 静默 best-effort | 9.2 | +| 持久化 refresh 续期 invalid_grant | clear 持久化 token + 回退完整登录 | 9.5 | +| 不同 profile | 按 profile 条目 + cacheKey 隔离 | 9.6 | + +### Correctness Properties(补充) + +### Property 8: 持久化复用不重复登录 +WHERE 注入 tokenStore 且持久化 token 未过期,新进程 `getToken` 不触发 `/clickzetta-portal/user/loginSingle` 与 `/oauth2/token` 调用,直接复用持久化 access_token。 +**Validates: Requirements 9.3** + +### Property 9: 持久化轮换回写 +每次基于持久化 refresh token 的续期成功后,`tokenStore.save` 以轮换后的新 refresh token 覆盖旧值,后续 load 得到最新值。 +**Validates: Requirements 9.1, 9.4** + +### Property 10: 注入缺省向后兼容 +WHERE 未注入 tokenStore,`getToken` 行为与改造前的纯内存缓存完全一致。 +**Validates: Requirements 9.7** + +## Addendum 2: 浏览器 loopback 授权流程(需求 10) + +### 背景与现状修正 + +需求 3/设计原稿把 `redirect_uri` 固定为 `http://127.0.0.1/callback`,并把「凭据登录直接返回 authorizationCode」作为默认路径。需求 10 引入标准 OAuth 浏览器 loopback 流程:本地随机端口监听 → 动态 `redirect_uri` → 打开 accounts 登录页 → 前端回跳本地监听 → 换 token。服务端对 `127.0.0.1` 的 redirect_uri 校验忽略端口,因此动态端口可用。该流程由 `CZ_OAUTH_LOCAL_CALLBACK` 开关控制,默认仍走凭据/手动路径。 + +### 组件改造 + +#### E. `src/auth/oauth-constants.ts`(SDK) +- 保留 `OAUTH_REDIRECT_URI` 作为默认/兼容值。 +- 新增 `loopbackRedirectUri(port: number): string` → `http://127.0.0.1:${port}/callback`。 + +#### F. `src/auth/oauth.ts`(SDK,签名调整) +- `exchangeAuthorizationCode(baseUrl, code, codeVerifier, redirectUri)` 新增 `redirectUri` 参数(取代内部固定常量),由调用方决定。现有调用方(凭据路径)传 `OAUTH_REDIRECT_URI`,浏览器路径传动态 loopback 值。 + +#### G. `src/auth/callback-server.ts`(SDK,API 增强) +- 新增「先拿端口、后等 code」的分离式 API,便于在打开浏览器前就拿到 `redirect_uri`: + ```ts + export interface LoopbackCallback { + port: number + redirectUri: string // http://127.0.0.1:/callback + waitForCode(): Promise // resolve on validated code, then closes + close(): void + } + export function startLoopbackCallback(opts: { expectedState: string; timeoutMs?: number }): Promise + ``` +- 复用现有 `waitForAuthorizationCode` 的请求解析/state 校验/超时/关闭逻辑;`startLoopbackCallback` 在 `listen` 完成(拿到端口)后 resolve,`waitForCode` 暴露内部的 code Promise。`isLocalCallbackEnabled()` 不变。 + +#### H. `src/auth/oauth-login-param.ts`(SDK,新增小工具) +- `buildOauthLoginParam({ redirectUri, codeChallenge, state })` → 返回 `oauthLoginParam` 对象(`oauthLogin`/`clientId`/`scope`/`codeChallengeMethod` 用常量填充)。 +- `encodeOauthLoginParam(param): string` → `base64(JSON.stringify(param))`,供 authorize URL 拼接与登录 body 复用。 + +#### I. cz-cli:authorize URL 推导与浏览器编排 +- `connection/accounts-url.ts`(或复用 `account-login.ts` 的环境推导):`accountsBaseUrl(service): string`,prod → `https://accounts.`,dev/sit/uat → `https://-accounts.`,允许 `CZ_OAUTH_ACCOUNTS_URL` 或 profile 覆盖。 +- `commands` 层编排 `loginWithBrowser`: + 1. `generatePkce()` → `{codeVerifier, codeChallenge}`;生成随机 `state`。 + 2. `startLoopbackCallback({state})` → 拿到 `redirectUri`。 + 3. `buildOauthLoginParam({redirectUri, codeChallenge, state})` → `encodeOauthLoginParam` → 拼 `${accountsBase}/login?oauthLoginParam=`。 + 4. 打开系统浏览器(跨平台 open:darwin `open` / win `start` / linux `xdg-open`),同时在终端打印该 URL。 + 5. `await waitForCode()` 取 `code`。 + 6. `exchangeAuthorizationCode(baseUrl, code, codeVerifier, redirectUri)` → `AuthToken`。 + 7. 经由现有 token 持久化(`tokenStore`)保存。 +- 仅当 `isLocalCallbackEnabled()` 为真时启用;否则走现有凭据/手动路径。 + +### 错误处理补充(需求 10.7/10.10) + +| 场景 | 行为 | +|------|------| +| 回调缺 code / state 不匹配 | 监听 reject + 关闭,登录失败,不换 token | +| 超时未收到回调 | reject 超时错误并关闭监听 | +| 浏览器打不开 | 仍打印 URL,提示用户手动打开(不视为致命) | +| 任意失败 | 不输出 code_verifier/授权码/token 明文 | + +### Correctness Properties(补充) + +### Property 11: 动态 redirect_uri 一致性 +浏览器 loopback 流程中,authorize URL 内 `oauthLoginParam.redirectUri` 与换 token 时 `/oauth2/token` 的 `redirect_uri` 逐字相同,且等于 `http://127.0.0.1:<实际监听端口>/callback`。 +**Validates: Requirements 10.2, 10.8, 10.9** + +### Property 12: state 往返校验 +仅当回调携带的 `state` 等于本次发起生成的随机 `state` 时才取出 `code`;不匹配则失败。 +**Validates: Requirements 10.6, 10.7** + +### Property 13: 开关隔离 +WHERE `CZ_OAUTH_LOCAL_CALLBACK` 未启用,不启动本地监听、不打开浏览器,行为与现有默认路径一致。 +**Validates: Requirements 10.1** diff --git a/.kiro/specs/oauth-login/requirements.md b/.kiro/specs/oauth-login/requirements.md new file mode 100644 index 000000000..4fec49a2f --- /dev/null +++ b/.kiro/specs/oauth-login/requirements.md @@ -0,0 +1,179 @@ +# Requirements Document + +> cz-cli OAuth2 登录接入 + +## Introduction + +当前 cz-cli 在登录 ClickZetta 服务时,使用 `/clickzetta-portal/user/loginSingle` 接口,通过 PAT 或用户名/密码换取传统会话 token(`AuthToken`)。为了对接服务端新引入的 OAuth2 授权码流程(Authorization Code + PKCE),需要对 CLI 的登录链路做改造,使其能够获取 OAuth `access_token` / `refresh_token`,并在 token 过期时通过 refresh token 轮换续期。 + +本期的关键约束:登录前端 Web 服务尚未就绪,浏览器登录成功后**暂时无法回调 CLI 的本地监听服务**回传 authorization code。因此本期以**手动粘贴授权码**作为默认获取方式;同时实现(但默认禁用)本地回调监听流程,待前端就绪后可通过开关启用,无需再次改造核心链路。 + +本需求遵循服务端鉴权测试文档 `oauth-login-integration-test.md` 所定义的接口契约: +- `/clickzetta-portal/user/login`、`/clickzetta-portal/user/loginSingle`:在请求体携带 `oauthLoginParam` 时,登录成功返回 `authorizationCode`。 +- `/oauth2/token`:用授权码换取 `access_token` + `refresh_token`,并支持 refresh token 轮换。 +- `/oauth2/userinfo`:使用 Bearer `access_token` 查询当前用户信息。 + +OAuth client 固定为 `official-cli`(public 类型,强制 PKCE),scope 为 `openid profile offline_access`,redirect_uri 为 `http://127.0.0.1/callback`,授权码 TTL 120 秒且一次性使用。 + +## Glossary + +| 术语 | 说明 | +|------|------| +| OAuth 登录模式 | 在登录请求体中携带 `oauthLoginParam.oauthLogin = true`,使服务端在登录成功后额外签发 authorization code 的登录方式 | +| 传统登录模式 | 不携带 `oauthLoginParam` 的现有登录方式,返回原有会话 token,不返回非空 authorizationCode | +| PKCE | Proof Key for Code Exchange。CLI 生成 `code_verifier`,对其做 SHA-256 后 base64url 编码得到 `code_challenge`(method `S256`) | +| authorization code | 登录成功后服务端签发的一次性授权码,TTL 120 秒,用于换取 token | +| access_token | OAuth 访问令牌,用于访问受保护资源与 `/oauth2/userinfo` | +| refresh_token | OAuth 刷新令牌,用于轮换出新的 access_token;每次刷新后服务端会轮换(rotate)出新的 refresh_token | +| 手动粘贴流程 | 默认流程:CLI 引导用户在浏览器登录并复制授权码,用户将授权码粘贴回 CLI | +| 本地回调流程 | 未来流程:CLI 在 loopback redirect_uri 启动本地 HTTP 监听,由浏览器重定向自动回传授权码(本期实现但默认禁用) | +| `AuthToken` | SDK 内部令牌结构,定义于 `packages/clickzetta-sdk/src/types/index.ts` | + +## Requirements + +### 需求 1:以 OAuth 模式发起登录 + +**用户故事:** 作为 cz-cli 用户,我希望在登录时以 OAuth 模式向服务端发起请求,以便登录成功后能获得 authorization code 用于换取 OAuth token。 + +#### 验收标准 + +1. WHEN 用户以 OAuth 登录模式调用 `loginWithPassword` THEN CLI SHALL 在 `/clickzetta-portal/user/loginSingle` 请求体中除原有 `username`、`password`、`instanceName` 外,附加 `oauthLoginParam` 对象。 +2. WHERE 携带 `oauthLoginParam` THE CLI SHALL 设置 `oauthLogin = true`、`clientId = "official-cli"`、`redirectUri = "http://127.0.0.1/callback"`、`scope = "openid profile offline_access"`、`codeChallengeMethod = "S256"`。 +3. WHEN 构造 `oauthLoginParam` THEN CLI SHALL 生成 PKCE `code_verifier` 并将其 SHA-256 的 base64url 编码作为 `codeChallenge`,且保留 `code_verifier` 供后续换取 token 使用。 +4. WHEN 登录请求成功且响应 `data.authorizationCode` 非空 THEN CLI SHALL 进入授权码换取 token 流程(见需求 4)。 +5. IF 服务端返回业务错误码(`code` 非 0/200)THEN CLI SHALL 复用现有重试与错误处理逻辑(最多重试、退避、实例配置错误识别),不得因附加 `oauthLoginParam` 而改变既有重试语义。 + +### 需求 2:PKCE 参数生成 + +**用户故事:** 作为安全负责人,我希望 CLI 每次发起 OAuth 登录都生成符合规范的 PKCE 参数,以防止授权码被截获后被他人使用。 + +#### 验收标准 + +1. WHEN 发起一次 OAuth 登录 THEN CLI SHALL 生成一个高熵随机 `code_verifier`,其字符集与长度满足 RFC 7636(43–128 个 unreserved 字符)。 +2. WHEN 由 `code_verifier` 计算 `code_challenge` THEN CLI SHALL 使用 `base64url(SHA-256(code_verifier))` 且不含 padding(`=`)。 +3. THE CLI SHALL 为每一次登录尝试生成全新的 `code_verifier` / `code_challenge`,不得跨登录复用。 +4. WHILE 在 `code_verifier` 被用于换取 token 之前 THE CLI SHALL 仅在内存中持有该值,不得写入磁盘或日志。 + +### 需求 3:获取授权码(手动粘贴默认,本地回调备用) + +**用户故事:** 作为 cz-cli 用户,由于登录前端暂未就绪无法自动回传授权码,我希望能在浏览器登录后手动粘贴授权码完成登录;同时希望工具已为未来的自动回传做好准备。 + +#### 验收标准 + +1. THE CLI SHALL 默认采用手动粘贴流程获取 authorization code。 +2. WHEN 采用手动粘贴流程 THEN CLI SHALL 打开(或提示用户打开)浏览器登录页面,并提示用户在登录成功后将授权码粘贴回 CLI。 +3. WHEN 用户粘贴授权码 THEN CLI SHALL 去除首尾空白后作为 authorization code 进入换取 token 流程。 +4. IF 用户提交空字符串或取消输入 THEN CLI SHALL 终止本次登录并返回明确的错误提示,不得继续调用 `/oauth2/token`。 +5. THE CLI SHALL 实现本地回调监听流程(在 `http://127.0.0.1/callback` 对应的 loopback 端口启动本地 HTTP 监听以接收授权码),但 THE 该流程 SHALL 默认禁用,由配置开关或标志控制启用。 +6. WHILE 本地回调流程处于禁用状态 THE CLI SHALL 不启动本地监听端口,仅使用手动粘贴流程。 +7. WHERE 当前 `loginWithPassword` 已在一次请求中同时完成登录与授权码签发(服务端在登录响应中直接返回 `authorizationCode`)THE CLI SHALL 支持该“免浏览器”的直接授权码获取路径,作为手动粘贴流程的实现基础,并保持与需求 4 一致的换取逻辑。 + +### 需求 4:授权码换取 OAuth Token + +**用户故事:** 作为 cz-cli 用户,我希望工具用授权码换取 access_token 与 refresh_token,以便后续用 OAuth token 访问服务。 + +#### 验收标准 + +1. WHEN 持有非空 authorization code 与对应 `code_verifier` THEN CLI SHALL 以 `application/x-www-form-urlencoded` 向 `/oauth2/token` 发起 POST 请求。 +2. WHERE 调用 `/oauth2/token` 换取授权码 THE 请求体 SHALL 包含 `grant_type = "authorization_code"`、`code`(授权码)、`client_id = "official-cli"`、`redirect_uri = "http://127.0.0.1/callback"`、`code_verifier`。 +3. WHEN `/oauth2/token` 返回成功 THEN CLI SHALL 从响应中读取 `access_token`、`refresh_token`、`expires_in`、`token_type`。 +4. WHEN 构造内部 `AuthToken` THEN CLI SHALL 将 `access_token` 写入 `token` 字段、将 `refresh_token` 写入新增的 `refreshToken` 字段、将 `expires_in`(秒)换算为毫秒写入 `expireTimeMs`,并记录 `obtainedAt`。 +5. THE `AuthToken` 接口 SHALL 新增可选 `refreshToken` 字段以承载 OAuth refresh token,且不破坏传统登录模式下不含该字段的既有用法。 +6. WHEN 授权码必须在 TTL(120 秒)内使用 THEN CLI SHALL 在拿到授权码后尽快发起换取,并在授权码过期时返回明确错误(见需求 7)。 + +### 需求 5:Refresh Token 轮换与续期 + +**用户故事:** 作为 cz-cli 用户,我希望 access_token 过期时工具能用 refresh_token 自动续期,以便长时间使用而无需重复登录。 + +#### 验收标准 + +1. WHEN 检测到 access_token 已过期(依据现有 `EXPIRED_FACTOR = 0.8` 的过期判定)且持有 refresh_token THEN CLI SHALL 优先使用 refresh_token 续期,而非重新走完整登录。 +2. WHERE 使用 refresh_token 续期 THE CLI SHALL 向 `/oauth2/token` 发起 POST,请求体包含 `grant_type = "refresh_token"`、`refresh_token`、`client_id = "official-cli"`。 +3. WHEN 续期成功返回新的 `access_token` 与 `refresh_token` THEN CLI SHALL 用新的 refresh_token 覆盖旧值,后续续期一律使用最新的 refresh_token。 +4. IF refresh_token 续期失败(如 refresh_token 失效,服务端返回 `invalid_grant`)THEN CLI SHALL 清除已失效的 token 并回退到完整登录流程或返回明确错误。 +5. WHILE 仅持有传统会话 token(无 refresh_token)THE CLI SHALL 保持现有 `forceRefreshToken` 的重新登录行为,不调用 `/oauth2/token`。 + +### 需求 6:查询 UserInfo + +**用户故事:** 作为 cz-cli 用户,我希望工具能用 access_token 查询当前用户信息,以确认登录身份。 + +#### 验收标准 + +1. WHEN 需要获取当前 OAuth 用户信息 THEN CLI SHALL 以 `Authorization: Bearer ` 头调用 `GET /oauth2/userinfo`。 +2. WHEN `/oauth2/userinfo` 返回成功 THEN CLI SHALL 解析并返回用户信息字段。 +3. THE CLI SHALL NOT 在 userinfo 的处理或展示中输出 access_token、refresh_token 等敏感字段。 +4. IF access_token 为空或已过期导致 `/oauth2/userinfo` 返回 `invalid_token` THEN CLI SHALL 触发续期(见需求 5)或返回明确的认证失败错误。 + +### 需求 7:错误处理与负向场景 + +**用户故事:** 作为 cz-cli 用户,我希望在 OAuth 流程出错时获得清晰、可操作的错误信息,而不是泄露敏感信息或静默失败。 + +#### 验收标准 + +1. WHEN 服务端返回 `invalid_request`(缺少 `codeChallenge`、`codeChallengeMethod` 非 `S256`、`redirectUri` 不在白名单等)THEN CLI SHALL 返回指明请求参数问题的错误。 +2. WHEN 服务端返回 `invalid_client`(client 配置缺失)THEN CLI SHALL 返回指明 OAuth client 配置问题的错误。 +3. WHEN 服务端返回 `invalid_scope`(请求 scope 越权)THEN CLI SHALL 返回指明 scope 越权的错误。 +4. WHEN 服务端返回 `invalid_grant`(授权码过期、授权码已被使用、`redirect_uri` 不一致、`code_verifier` 与 `code_challenge` 不匹配)THEN CLI SHALL 返回指明授权码/校验失败的错误,并不得重复使用同一授权码再次换取。 +5. WHEN 服务端返回 `invalid_token`(access_token 无效/过期)THEN CLI SHALL 按需求 5/6 处理续期或返回认证失败。 +6. IF 任何 OAuth 步骤失败 THEN CLI SHALL NOT 在错误信息或日志中输出 `code_verifier`、authorization code 明文、access_token、refresh_token 等敏感值。 +7. WHILE 处于 OAuth 流程 THE CLI SHALL 保留现有登录的请求关联标识(requestId)以便服务端日志排查。 + +### 需求 8:向后兼容传统登录 + +**用户故事:** 作为现有 cz-cli 用户,我希望在服务端未启用 OAuth 或我未选择 OAuth 模式时,登录行为与现状完全一致,以便平滑过渡。 + +#### 验收标准 + +1. WHEN 登录请求未携带 `oauthLoginParam`(传统登录模式)THEN CLI SHALL 保持现有行为,返回原会话 token 且不进行授权码换取。 +2. WHEN 服务端在 OAuth 模式登录响应中未返回非空 `authorizationCode` THEN CLI SHALL 保留登录返回的传统 token(legacy token),且 SHALL NOT 调用 `/oauth2/token`。 +3. THE 改造 SHALL 保持 `loginWithPat`、`loginWithPassword` 的现有公开函数签名兼容,不破坏既有调用方。 +4. WHERE 传统登录模式 THE `AuthToken` SHALL 不包含 `refreshToken` 字段(或其为 undefined),且现有依赖 `AuthToken` 的代码不受影响。 +5. THE 既有登录重试、退避(最多 5 次重试、`min(2^n*100, 2000)` ms 退避、单次 10s 超时)、实例配置错误识别("没有这样的元素" / "No such element")SHALL 在 OAuth 改造后继续成立。 + +### 需求 9:Refresh Token 跨进程持久化 + +**用户故事:** 作为 cz-cli 用户,我希望登录拿到的 OAuth token(含 refresh token)能持久化到本地 profile,以便后续命令在不同进程中复用,token 未过期时免登录、过期时用 refresh token 续期,而不必每条命令都重新走完整登录。 + +#### 验收标准 + +1. WHEN 一次登录或刷新成功得到含 `refreshToken` 的 `AuthToken` THEN CLI SHALL 将 `token`(access_token)、`refreshToken`、`expireTimeMs`、`obtainedAt`、`instanceId`、`userId` 持久化到当前 profile 在 `~/.clickzetta/profiles.toml` 中的条目下(OAuth 子表)。 +2. WHERE 写入 profiles.toml THE CLI SHALL 复用现有的原子写入与 `0o600` 权限机制,不得降低文件权限,不得将 token 写入任何日志。 +3. WHEN 新进程发起需要 token 的操作且持久化的 token 未过期(依据现有 `EXPIRED_FACTOR = 0.8` 判定)THEN CLI SHALL 直接复用持久化的 access_token,SHALL NOT 重新登录或调用 `/oauth2/token`。 +4. WHEN 持久化的 token 已过期但含 `refreshToken` THEN CLI SHALL 使用该 refresh token 调用 `/oauth2/token` 续期,并将轮换后的新 token 回写持久化存储。 +5. IF 使用持久化的 refresh token 续期失败(如 `invalid_grant`)THEN CLI SHALL 清除该 profile 的持久化 OAuth token,并回退到完整登录流程。 +6. WHERE 持久化按 profile + instance 维度隔离 THE CLI SHALL 以 profile 条目下、按 **instance**(而非 pat/username)作为 token slot key 存储 OAuth token,确保不同 profile/instance 的 token 互不串用;OAuth token 代表用户自身登录,移除或轮换 pat/username 不得导致已持久化的 token slot 失联。 +7. THE 持久化机制 SHALL 通过 `ConnectionConfig` 上一个可选的 token 存储接口注入到 SDK 认证层;WHERE 未注入该接口(如直接使用 SDK 的调用方)THE 行为 SHALL 退化为现有的纯内存缓存,保持向后兼容。 +8. WHILE 传统登录(无 refreshToken 的 legacy token)THE CLI MAY 持久化 access_token 以复用未过期会话,但 SHALL NOT 因此改变 legacy token 过期后重新登录的既有行为。 + +### 需求 10:浏览器 loopback 授权流程(动态 redirect_uri) + +**用户故事:** 作为 cz-cli 用户,当开启本地回调流程时,我希望 CLI 在本地随机端口起一个一次性监听服务、用该端口生成 `redirect_uri`、打开浏览器到 accounts 登录页,登录成功后由前端把授权码回跳到本地监听,CLI 再用同一 `redirect_uri` 换取 token,从而获得标准 OAuth 浏览器登录体验。服务端对 `127.0.0.1` 的 redirect_uri 校验忽略端口。 + +#### 验收标准 + +1. WHILE 开关 `CZ_OAUTH_LOCAL_CALLBACK` 启用 THE CLI SHALL 走浏览器 loopback 授权流程;WHILE 未启用 THE CLI SHALL 保持现有默认路径(手动/凭据直接返回授权码),不启动本地监听、不打开浏览器。 +2. WHEN 发起浏览器 loopback 流程 THEN CLI SHALL 先在 `127.0.0.1` 上以系统分配的随机端口启动一次性 HTTP 监听,并据实际端口生成 `redirect_uri = "http://127.0.0.1:/callback"`。 +3. WHEN 构造浏览器 authorize URL THEN CLI SHALL 将 `oauthLoginParam`(含 `oauthLogin=true`、`clientId`、动态 `redirectUri`、`scope`、`codeChallenge`、`codeChallengeMethod="S256"`、随机 `state`)序列化为 JSON 并 base64 编码,作为 query 参数 `oauthLoginParam` 拼接到 accounts 登录页 URL(示例:`https://accounts.clickzetta.com/login?oauthLoginParam=`)。 +4. WHERE 推导 accounts 登录页 host THE CLI SHALL 依据当前 `service` 的环境推导(prod → `accounts.`,dev/sit/uat → `-accounts.`),并允许通过配置(环境变量或 profile)覆盖。 +5. WHEN authorize URL 就绪 THEN CLI SHALL 打开系统默认浏览器访问该 URL,并在终端同时打印该 URL 以便手动打开。 +6. WHEN 前端登录成功回跳 `http://127.0.0.1:/callback?code=...&state=...` THEN 本地监听 SHALL 校验 `state` 与本次发起的值一致后取出 `code`,随即关闭监听。 +7. IF 回调缺少 `code`、`state` 不匹配,或在超时时间内未收到回调 THEN CLI SHALL 终止登录并返回明确错误,且不得用错误/伪造的 code 继续换 token。 +8. WHEN 取得 `code` THEN CLI SHALL 以与第 2 步**完全一致**的动态 `redirect_uri`、`client_id`、`code_verifier` 调用 `/oauth2/token` 换取 token(`redirect_uri` 两处必须逐字一致)。 +9. THE `redirect_uri` SHALL 不再硬编码为固定 `http://127.0.0.1/callback`;换取 token 的接口 SHALL 接受调用方传入的 `redirect_uri`,由登录流程统一决定其取值。 +10. WHILE 浏览器 loopback 流程进行中 THE CLI SHALL NOT 在日志中输出 `code_verifier`、授权码明文、`access_token`、`refresh_token`;`state` 仅用于一次性校验。 + +### 需求 11:`cz-cli login` 命令接入浏览器登录 + +**用户故事:** 作为 cz-cli 用户,我希望有一个 `cz-cli login` 命令直接发起浏览器 OAuth 登录,自动拉起系统浏览器完成登录并把 token 持久化到当前 profile,这样后续命令可直接复用。 + +#### 验收标准 + +1. THE CLI SHALL 提供顶层命令 `cz-cli login`,复用全局连接参数(`--profile`/`--instance`/`--service` 等)解析当前 `ConnectionConfig`。 +2. WHEN 执行 `cz-cli login --browser`,或在 `CZ_OAUTH_LOCAL_CALLBACK` 启用时执行 `cz-cli login` THEN CLI SHALL 走浏览器 loopback 授权流程(需求 10),自动拉起系统浏览器并打印 authorize URL。 +3. WHEN 浏览器登录成功取得 token THEN CLI SHALL 通过该 profile 的 token 存储持久化 token(需求 9),并输出成功结果(不回显 access_token/refresh_token 等敏感值)。 +4. IF 登录失败(state 不匹配、超时、换 token 失败等)THEN CLI SHALL 返回明确错误并以非零退出码结束,不持久化任何 token。 +5. WHERE 既未传 `--browser` 也未启用 `CZ_OAUTH_LOCAL_CALLBACK` THE `cz-cli login` SHALL 提示当前为浏览器登录入口并指引启用方式(不静默改变现有默认登录路径)。 +6. WHEN 浏览器登录成功取得 token THEN CLI SHALL 调用 `/oauth2/userinfo` 查询当前用户信息,并将其中的 `userId`(`userId` 数字,缺省时回退解析 `sub`)与 `instanceId`(`instanceList[0].id`)回填到待持久化的 `AuthToken`,仅当解析出的值为有效正整数时覆盖原值;同时将登录上下文(`service`/`protocol`/`instance`/`workspace`/`schema`/`vcluster`/`user_id`)写回当前 profile 条目,其中 `instance` 优先取 userinfo 的 `instanceName`(缺省回退 `instanceList[0].name` 或解析得到的实例),并按最终 `instance` 作为 token slot key(**仅按 instance,不含 pat/username**)持久化 token,使后续命令可直接复用。 +7. IF `/oauth2/userinfo` 查询失败(如返回 `invalid_token`、网络异常)THEN CLI SHALL 视为非致命:保留已换取的 token 完成登录(`userId`/`instanceId` 维持默认值 0、不写回连接上下文),SHALL NOT 因 userinfo 失败而使整体登录失败,且不得在日志中输出 access_token/refresh_token 等敏感值(最多以 `CZ_OAUTH_DEBUG` 输出 userinfo 字段名)。 +8. WHEN `cz-cli sql`(及其他消费 token 的命令)解析出的 profile 既无 pat 也无 username/password,但该 profile 在对应 instance 的 OAuth slot 下存在可加载(未过期或可凭 refresh token 续期)的持久化 OAuth token THEN CLI SHALL 将该持久化 OAuth token 视为充分的鉴权凭据,直接据此鉴权,SHALL NOT 抛出 `NO_CREDENTIALS`/"Authentication required" 缺少凭据错误;IF 既无 pat/username 也无持久化 OAuth token THEN CLI SHALL 仍报缺少鉴权的错误并指引登录方式(`--pat`/`--username`/`--password`、`cz-cli login --browser` 或 `cz-cli setup`)。 +9. WHEN 浏览器登录成功并调用 `/oauth2/userinfo` 取得响应体 THEN CLI SHALL 将该 userinfo **完整、原样、无损**地持久化到当前 profile 条目下的 `[profiles..userinfo]` 子表(不丢弃任何字段,包括 `instanceList` 对象数组、`gatewayMapping` JSON 字符串、`aimeshEndpointBaseUrl`、`apiKey` 等),并额外将 `account_id`(来自 `account_id`,数字)与 `account_name`(来自 `accountName`,字符串)映射到 profile 条目;WHERE 写入 THE CLI SHALL 复用现有原子写入与 `0o600` 权限机制,敏感值(如 `apiKey`)随该 `0o600` 文件存储,SHALL NOT 打印到成功输出或任何日志;IF userinfo 查询失败 THEN CLI SHALL NOT 写入 `userinfo` 子表(视为非致命,见 11.7)。 diff --git a/.kiro/specs/oauth-login/tasks.md b/.kiro/specs/oauth-login/tasks.md new file mode 100644 index 000000000..c0a4480e3 --- /dev/null +++ b/.kiro/specs/oauth-login/tasks.md @@ -0,0 +1,215 @@ +# Implementation Plan + +> cz-cli OAuth2 登录接入 — 任务清单 + +## Overview + +工作目录:`packages/clickzetta-sdk`。验证命令:`bun test ` 与 `bun typecheck`(均从包目录执行,不可在仓库根目录运行)。遵循 TDD:先写/对齐测试,再实现到测试通过。改造集中在 SDK 认证层(`types`、`pkce.ts`、`oauth.ts`、`login.ts`、`token.ts`、`callback-server.ts`)。 + +## Tasks + +- [x] 1. 扩展 `AuthToken` 类型并新增 OAuth 常量 + - 在 `packages/clickzetta-sdk/src/types/index.ts` 的 `AuthToken` 接口新增可选字段 `refreshToken?: string` + - 新增 `packages/clickzetta-sdk/src/auth/oauth-constants.ts`,导出 `OAUTH_CLIENT_ID`、`OAUTH_REDIRECT_URI`、`OAUTH_SCOPE`、`OAUTH_CODE_CHALLENGE_METHOD` + - 运行 `bun typecheck` 确认无类型回归(修复 `login-oauth.test.ts` 中 `token.refreshToken` 的类型错误) + - _Requirements: 4.5, 8.4_ + +- [x] 2. 实现 PKCE 生成模块 +- [x] 2.1 编写 PKCE 单元/属性测试 + - 新增 `packages/clickzetta-sdk/test/pkce.test.ts` + - 断言 `codeChallenge === base64url(sha256(codeVerifier))`(无 padding) + - 断言 `codeVerifier` 长度 ∈ [43,128] 且仅含 unreserved 字符 + - 断言多次生成的 `codeVerifier` 互不相同 + - _Requirements: 2.1, 2.2, 2.3_ + - _Properties: 1, 2_ +- [x] 2.2 实现 `pkce.ts` + - 新增 `packages/clickzetta-sdk/src/auth/pkce.ts`,导出 `generatePkce(): Pkce` + - 使用 `crypto.getRandomValues` 生成高熵 verifier,base64url 编码并裁剪到合法长度 + - `codeChallenge` = `base64url(sha256(codeVerifier))`,去除 `=`、`+`→`-`、`/`→`_` + - 运行 `bun test test/pkce.test.ts` 通过 + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + - _Properties: 1, 2_ + +- [x] 3. 实现 OAuth 端点客户端 `oauth.ts` +- [x] 3.1 编写 `oauth.ts` 单元测试 + - 新增 `packages/clickzetta-sdk/test/oauth.test.ts`,以 `globalThis.fetch` 桩模拟 `/oauth2/token` 与 `/oauth2/userinfo` + - 覆盖:`exchangeAuthorizationCode` 发送正确的 form 字段(`grant_type`、`code`、`client_id`、`redirect_uri`、`code_verifier`)并解析 `expires_in→expiresInMs` + - 覆盖:`refreshAccessToken` 发送 `grant_type=refresh_token`、`refresh_token`、`client_id` + - 覆盖:`fetchUserInfo` 带 `Authorization: Bearer`,不回显敏感字段 + - 覆盖:各 OAuth 错误码(`invalid_request`/`invalid_client`/`invalid_scope`/`invalid_grant`/`invalid_token`)映射为 `InterfaceError`,且 message 不含敏感值 + - _Requirements: 4.1, 4.2, 4.3, 5.2, 6.1, 6.2, 6.3, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_ + - _Properties: 4, 7_ +- [x] 3.2 实现 `oauth.ts` + - 新增 `packages/clickzetta-sdk/src/auth/oauth.ts`,导出 `exchangeAuthorizationCode`、`refreshAccessToken`、`fetchUserInfo`、`OAuthTokenResult` + - 请求体用 `URLSearchParams`(`application/x-www-form-urlencoded`);端点为 `${baseUrl}/oauth2/token`、`${baseUrl}/oauth2/userinfo` + - `expires_in`(秒) → `expiresInMs`(毫秒);携带 requestId + - 解析并映射 OAuth 错误码为 `InterfaceError`,错误信息屏蔽敏感值 + - 运行 `bun test test/oauth.test.ts` 通过 + - _Requirements: 4.1, 4.2, 4.3, 5.2, 6.1, 6.2, 6.3, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_ + - _Properties: 4, 7_ + +- [x] 4. 在 `login.ts` 接入 OAuth 授权码换取 + - 在 `packages/clickzetta-sdk/src/auth/login.ts` 的 `LoginResponse` 增加可选 `authorizationCode?: string` + - `loginWithPassword`/`loginWithPat`:调用 `generatePkce()`,构造 `oauthLoginParam`(`oauthLogin:true`、`clientId`、`redirectUri`、`scope`、`codeChallenge`、`codeChallengeMethod:"S256"`)合并进登录 body + - 登录成功后:`data.authorizationCode` 非空 → `exchangeAuthorizationCode(baseUrl, code, codeVerifier)` 并构造 `AuthToken`(`token=access_token`、`refreshToken`、`expireTimeMs=expires_in*1000`);为空 → 保留 legacy token,不调用 `/oauth2/token` + - 保持公开函数签名与既有重试/退避/实例错误识别逻辑不变 + - 运行 `bun test test/login-oauth.test.ts` 使其全部通过 + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 3.7, 4.4, 8.1, 8.2, 8.3, 8.5_ + - _Properties: 3, 6_ + +- [x] 5. 在 `token.ts` 接入 refresh token 续期 +- [x] 5.1 编写 token 续期测试 + - 在 `packages/clickzetta-sdk/test/`(新增或扩展 token 测试)覆盖:缓存 token 过期且含 `refreshToken` 时优先调用 `refreshAccessToken` 而非完整登录 + - 覆盖:续期成功后用轮换的新 `refreshToken` 覆盖旧值,后续续期使用最新值 + - 覆盖:续期失败(`invalid_grant`)清缓存并回退完整登录;仅 legacy token 时维持现有重新登录行为 + - _Requirements: 5.1, 5.3, 5.4, 5.5_ + - _Properties: 5_ +- [x] 5.2 实现 `token.ts` 续期逻辑 + - 在 `packages/clickzetta-sdk/src/auth/token.ts` 的 `getToken` 中:过期且 `cachedToken.refreshToken` 存在时优先 `refreshAccessToken(baseUrl, refreshToken)`,成功则更新缓存(含新 refreshToken) + - 续期失败回退 `fetchToken`;无 refreshToken 维持现状;`isTokenExpired` 与 `EXPIRED_FACTOR` 不变 + - 运行 token 相关测试通过 + - _Requirements: 5.1, 5.3, 5.4, 5.5_ + - _Properties: 5_ + +- [x] 6. 实现本地回调监听模块(默认禁用) + - 新增 `packages/clickzetta-sdk/src/auth/callback-server.ts`,导出 `waitForAuthorizationCode(opts)`:在 loopback 启动一次性 HTTP 监听,解析 `?code=&state=`,校验 `state` 后关闭 + - 由开关(如 `CZ_OAUTH_LOCAL_CALLBACK`)控制;默认禁用时不占用端口、默认链路不调用 + - 新增 `packages/clickzetta-sdk/test/callback-server.test.ts`:启用时能从回调请求解析 code 并校验 state;禁用时不监听端口 + - _Requirements: 3.5, 3.6_ + +- [x] 7. 导出与集成收尾 + - 在 `packages/clickzetta-sdk/src/index.ts` 按需导出新增公共 API(`fetchUserInfo` 等,若供 CLI 使用) + - 运行 `bun typecheck` 与全量 `bun test`(在 `packages/clickzetta-sdk` 包目录)确认无回归 + - 同步更新/新增 OpenSpec 规格 `openspec/specs//spec.md`(中文、WHEN/THEN),与 `setup`/登录相关 spec 对齐(遵循仓库 spec-driven 工作流) + - _Requirements: 6.1, 8.3_ + +- [x] 8. Refresh Token 跨进程持久化 +- [x] 8.1 SDK:新增 `TokenStore` 接口并接入 `token.ts` + - 在 `packages/clickzetta-sdk/src/types/index.ts` 新增 `TokenStore { load(): AuthToken | undefined; save(token: AuthToken): void; clear(): void }`,并在 `ConnectionConfig` 增加可选 `tokenStore?: TokenStore` + - 改造 `packages/clickzetta-sdk/src/auth/token.ts` 的 `getToken`:内存未命中时先 `tokenStore.load()`;未过期直接复用;过期且有 refreshToken 则续期并 `save` 回写;登录/刷新成功 `save`;refresh 失败 `clear` 并回退完整登录;未注入 tokenStore 时退化为现有内存缓存 + - 先写测试 `packages/clickzetta-sdk/test/token-store.test.ts`:以内存假实现注入 tokenStore,覆盖「持久化未过期直接复用、不发请求」「过期用持久化 refresh 续期并回写轮换值」「续期失败 clear 后回退登录」「未注入时行为不变」 + - 从 `packages/clickzetta-sdk` 运行 `bun test test/token-store.test.ts`、全量 `bun test`、`bun typecheck` + - _Requirements: 9.3, 9.4, 9.5, 9.7, 9.8_ + - _Properties: 8, 9, 10_ +- [x] 8.2 cz-cli:profile-backed `TokenStore` 实现 + - 在 `packages/cz-cli/src/connection/profile-store.ts` 新增 `makeProfileTokenStore(profileName, cacheKey): TokenStore`,读写 `[profiles..oauth]` 子表(snake_case 键),复用 `writeProfilesFile` 原子写 + `0o600`;load/save/clear 均 best-effort 不抛错 + - 新增测试 `packages/cz-cli/test/profile-token-store.test.ts`(用 `CLICKZETTA_TEST_HOME` 指向临时目录):save 后 load 往返一致、写入文件权限 0o600、clear 删除条目、不同 profile 隔离 + - 从 `packages/cz-cli` 运行相关 `bun test` 与 `bun typecheck` + - _Requirements: 9.1, 9.2, 9.6_ +- [x] 8.3 cz-cli:在 `resolveConnectionConfig` 注入 token store + - 在 `packages/cz-cli/src/connection/config.ts` 解析出 profileName 后,将 `makeProfileTokenStore(profileName, cacheKey)` 挂到返回的 `ConnectionConfig.tokenStore`(cacheKey 用 `instance:pat||username`,与 SDK `cacheKey` 对齐) + - 确认 `exec.ts`、`studio-context.ts` 经由该函数自动获得持久化;setup/verify 临时 config 不注入 + - 运行 `packages/cz-cli` 的 `bun typecheck` 与受影响测试,确保无回归 + - _Requirements: 9.3, 9.7_ +- [x] 8.4 OpenSpec 同步与全量验证 + - 更新 `openspec/specs/oauth-login/spec.md`,补充「refresh token 跨进程持久化」需求(中文、WHEN/THEN,含正向与异常场景) + - 从 `packages/clickzetta-sdk` 与 `packages/cz-cli` 分别运行全量 `bun test` 与 `bun typecheck`,展示输出 + - _Requirements: 9.1, 9.7_ + +- [x] 9. 浏览器 loopback 授权流程(动态 redirect_uri) +- [x] 9.1 SDK:`redirect_uri` 参数化 + loopback 常量 + - `packages/clickzetta-sdk/src/auth/oauth-constants.ts` 新增 `loopbackRedirectUri(port)`;保留 `OAUTH_REDIRECT_URI` 作默认 + - `packages/clickzetta-sdk/src/auth/oauth.ts` 的 `exchangeAuthorizationCode` 增加 `redirectUri` 参数(取代内部固定常量);更新 `login.ts` 调用方传入 `OAUTH_REDIRECT_URI`(保持现有凭据路径行为不变) + - 更新/新增测试:`test/oauth.test.ts` 断言换 token 使用传入的 redirectUri;全量 `bun test`、`bun typecheck` + - _Requirements: 10.9_ +- [x] 9.2 SDK:oauthLoginParam 构造/编码工具 + - 新增 `packages/clickzetta-sdk/src/auth/oauth-login-param.ts`:`buildOauthLoginParam({redirectUri, codeChallenge, state})`、`encodeOauthLoginParam(param)`(base64(JSON)) + - `login.ts` 复用 `buildOauthLoginParam` 构造登录 body 中的 oauthLoginParam(行为等价,回归现有 login-oauth 测试) + - 新增 `test/oauth-login-param.test.ts`:字段填充正确、base64 可解码还原;全量 `bun test`、`bun typecheck` + - _Requirements: 10.3_ +- [x] 9.3 SDK:callback-server 分离式 API + - 在 `packages/clickzetta-sdk/src/auth/callback-server.ts` 新增 `startLoopbackCallback({expectedState, timeoutMs})` → `{ port, redirectUri, waitForCode(), close() }`,在 listen 完成后 resolve,复用现有解析/state 校验/超时/关闭逻辑 + - 扩展 `test/callback-server.test.ts`:先拿到 redirectUri(含实际端口)、再回调使 waitForCode resolve;state 不匹配/超时 reject + - 从 `packages/clickzetta-sdk` 运行相关 `bun test` 与 `bun typecheck` + - _Requirements: 10.2, 10.6, 10.7_ + - _Properties: 11, 12_ +- [x] 9.4 cz-cli:accounts URL 推导 + - 新增 `packages/cz-cli/src/connection/accounts-url.ts`:`accountsBaseUrl(service)` 按环境推导(复用 `account-login.ts` 的 rootDomain/env 逻辑),支持 `CZ_OAUTH_ACCOUNTS_URL`/profile 覆盖 + - 新增测试覆盖 prod/dev/sit/uat 推导与覆盖项 + - 从 `packages/cz-cli` 运行 `bun test`、`bun typecheck` + - _Requirements: 10.4_ +- [x] 9.5 cz-cli:浏览器 loopback 登录编排(开关控制) + - 新增浏览器登录编排:generatePkce + 随机 state → `startLoopbackCallback` 拿 redirectUri → `buildOauthLoginParam`+`encodeOauthLoginParam` 拼 `${accountsBase}/login?oauthLoginParam=` → 跨平台打开浏览器并打印 URL → `waitForCode` → `exchangeAuthorizationCode(baseUrl, code, codeVerifier, redirectUri)` → 经 tokenStore 持久化 + - 仅当 `isLocalCallbackEnabled()` 启用;默认走现有路径 + - 测试:注入假的 open/fetch,验证 authorize URL 内 redirectUri 与换 token 的 redirect_uri 逐字一致、state 往返、开关关闭时不起监听不开浏览器 + - 从 `packages/cz-cli` 运行相关 `bun test` 与 `bun typecheck` + - _Requirements: 10.1, 10.5, 10.8, 10.10_ + - _Properties: 11, 12, 13_ +- [x] 9.6 OpenSpec 同步与全量验证 + - 更新 `openspec/specs/oauth-login/spec.md` 增补「浏览器 loopback 授权流程」需求(中文 WHEN/THEN,正向+异常场景) + - 分别在 `packages/clickzetta-sdk` 与 `packages/cz-cli` 运行全量 `bun test`、`bun typecheck`,展示输出 + - _Requirements: 10.1, 10.9_ + +- [x] 10. `cz-cli login` 命令接入浏览器登录 +- [x] 10.1 实现 `login` 命令并注册 + - 新增 `packages/cz-cli/src/commands/login.ts`:`registerLoginCommand(cli)`,命令 `login`,选项 `--browser`(boolean) + - handler:`resolveConnectionConfig(argv)` → 当 `--browser` 或 `isLocalCallbackEnabled()` 为真时,`loginWithBrowser({ baseUrl: toServiceUrl(cfg.service, cfg.protocol), accountsBaseUrl: accountsBaseUrl(cfg.service) })`,成功后用 `cfg.tokenStore?.save(token)` 持久化并 `success(...)` 输出(不回显敏感值);否则提示需 `--browser`/`CZ_OAUTH_LOCAL_CALLBACK` 启用 + - 失败时 `error(...)` 并设非零退出码,不持久化 + - 在 `packages/cz-cli/src/register-commands.ts` 注册 `registerLoginCommand(cli)`,并在 `cli.ts` 的 `KNOWN_COMMANDS` 集合加入 `login` + - 新增 `packages/cz-cli/test/login-command.test.ts`:注入 fake openBrowser/fetch,验证启用时走浏览器流程并持久化 token、未启用时给出指引、失败时不持久化 + - 从 `packages/cz-cli` 运行相关 `bun test` 与 `bun typecheck` + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_ +- [x] 10.2 OpenSpec 同步与验证 + - 更新 `openspec/specs/oauth-login/spec.md` 增补 `cz-cli login` 命令需求(中文 WHEN/THEN,正向+异常) + - `packages/cz-cli` 运行 `bun typecheck` 与 OAuth 相关 `bun test`,展示输出 + - _Requirements: 11.1_ + +## Task Dependency Graph + +```mermaid +graph TD + T1[1. AuthToken 字段 + OAuth 常量] + T2_1[2.1 PKCE 测试] + T2_2[2.2 PKCE 实现] + T3_1[3.1 oauth.ts 测试] + T3_2[3.2 oauth.ts 实现] + T4[4. login.ts 接入] + T5_1[5.1 token 续期测试] + T5_2[5.2 token 续期实现] + T6[6. 本地回调监听] + T7[7. 导出与收尾] + + T1 --> T2_2 + T1 --> T3_2 + T2_1 --> T2_2 + T3_1 --> T3_2 + T2_2 --> T4 + T3_2 --> T4 + T4 --> T5_2 + T5_1 --> T5_2 + T3_2 --> T5_2 + T4 --> T6 + T5_2 --> T7 + T6 --> T7 +``` + +```json +{ + "waves": [ + { "wave": 1, "tasks": ["1", "2.1", "3.1", "5.1"] }, + { "wave": 2, "tasks": ["2.2", "3.2"] }, + { "wave": 3, "tasks": ["4"] }, + { "wave": 4, "tasks": ["5.2", "6"] }, + { "wave": 5, "tasks": ["7"] }, + { "wave": 6, "tasks": ["8.1"] }, + { "wave": 7, "tasks": ["8.2"] }, + { "wave": 8, "tasks": ["8.3"] }, + { "wave": 9, "tasks": ["8.4"] }, + { "wave": 10, "tasks": ["9.1", "9.2", "9.4"] }, + { "wave": 11, "tasks": ["9.3"] }, + { "wave": 12, "tasks": ["9.5"] }, + { "wave": 13, "tasks": ["9.6"] }, + { "wave": 14, "tasks": ["10.1"] }, + { "wave": 15, "tasks": ["10.2"] } + ] +} +``` + +## Notes + +- 任务 1、2.1、3.1、5.1 之间互相独立,可并行(Wave 1):类型/常量与各测试用例先行。 +- 2.2 依赖 1 与 2.1;3.2 依赖 1 与 3.1(Wave 2)。 +- 任务 4(login 接入)依赖 PKCE 与 oauth 客户端实现(Wave 3),完成后 `login-oauth.test.ts` 应通过。 +- 5.2(token 续期)依赖 4 与 5.1、3.2;任务 6(本地回调)依赖 4,二者可并行(Wave 4)。 +- 任务 7 收尾:导出公共 API、全量 typecheck/test,并按仓库 spec-driven 工作流同步 OpenSpec 规格(Wave 5)。 +- 全程禁止在日志/错误信息中输出 `code_verifier`、授权码明文、`access_token`、`refresh_token`。 diff --git a/openspec/config.yaml b/openspec/config.yaml index f9dd0c231..160956fc1 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -16,6 +16,7 @@ context: | skill 分发(内置 .builtin、外部 agent 注册)→ openspec/specs/skill-install/spec.md CLI 顶层命令路由、agent runtime 透出 → openspec/specs/cli-command-routing/spec.md setup 交互式引导、账号站点 credential 流程 → openspec/specs/setup/spec.md + SDK OAuth2 登录、PKCE、token 续期、userinfo(packages/clickzetta-sdk/src/auth) → openspec/specs/oauth-login/spec.md Areas NOT yet covered by specs (create a new spec when changing): GitHub Actions workflow (release-cos.yml, build targets, upload steps) diff --git a/openspec/specs/oauth-login/spec.md b/openspec/specs/oauth-login/spec.md new file mode 100644 index 000000000..f58ff9418 --- /dev/null +++ b/openspec/specs/oauth-login/spec.md @@ -0,0 +1,190 @@ +# oauth-login 规格说明 + +## 目的 +定义 cz-cli SDK 认证层(`packages/clickzetta-sdk/src/auth`)接入 ClickZetta OAuth2 授权码 + PKCE 登录的行为:以 OAuth 模式发起登录、用授权码换取 `access_token`/`refresh_token`、token 过期时通过 refresh token 轮换续期、用 access_token 查询 userinfo,并在服务端未启用 OAuth 时保持与传统登录完全兼容。本地回调监听流程已实现但默认禁用。 + +OAuth client 固定为 `official-cli`(public 类型,强制 PKCE),`scope = "openid profile offline_access"`,`redirect_uri = "http://127.0.0.1/callback"`,`codeChallengeMethod = "S256"`。任何错误信息或日志均不得输出 `code_verifier`、授权码明文、`access_token`、`refresh_token`。 + +## 需求 + +### 需求:以 OAuth 模式发起登录并生成 PKCE + +`loginWithPassword` / `loginWithPat` 在向 `/clickzetta-portal/user/loginSingle` 发起登录时,应在原有请求体基础上附加 `oauthLoginParam` 对象,包含 `oauthLogin = true`、`clientId = "official-cli"`、`redirectUri = "http://127.0.0.1/callback"`、`scope = "openid profile offline_access"`、`codeChallengeMethod = "S256"`,以及由每次登录新生成的 PKCE `code_verifier` 计算出的 `codeChallenge`(`base64url(SHA-256(code_verifier))`,无 padding)。`code_verifier` 仅驻留内存,不得写入磁盘或日志。 + +#### 场景:登录请求携带 oauthLoginParam 与合法 codeChallenge + +- **当** 用户调用 `loginWithPassword(baseUrl, user, pass, instance)` 时 +- **则** 发往 `/clickzetta-portal/user/loginSingle` 的请求体保留 `username`/`password`/`instanceName`,并附加 `oauthLoginParam`,其中 `oauthLogin=true`、`clientId="official-cli"`、`redirectUri="http://127.0.0.1/callback"`、`scope="openid profile offline_access"`、`codeChallengeMethod="S256"` +- **且** `oauthLoginParam.codeChallenge` 等于发往 `/oauth2/token` 的 `code_verifier` 的 `base64url(SHA-256(...))` + +#### 场景:每次登录生成全新且不复用的 code_verifier(边界) + +- **当** 连续发起多次 OAuth 登录时 +- **则** 每次生成的 `code_verifier` 长度落在 RFC 7636 规定的 43–128 个 unreserved 字符范围内 +- **且** 各次登录的 `code_verifier` 互不相同,不得跨登录复用 + +### 需求:授权码换取 OAuth Token + +当登录响应 `data.authorizationCode` 非空时,CLI 应以 `application/x-www-form-urlencoded` 向 `/oauth2/token` 发起 POST,请求体包含 `grant_type="authorization_code"`、`code`、`client_id="official-cli"`、`redirect_uri="http://127.0.0.1/callback"`、`code_verifier`。成功后将 `access_token` 写入 `AuthToken.token`、`refresh_token` 写入 `AuthToken.refreshToken`、`expires_in`(秒)乘以 1000 写入 `expireTimeMs`,并记录 `obtainedAt`。 + +#### 场景:授权码换取成功并正确映射 AuthToken + +- **当** 登录返回 `authorizationCode` 且 `/oauth2/token` 返回 `access_token`、`refresh_token`、`expires_in=900` 时 +- **则** 返回的 `AuthToken.token` 等于 `access_token`、`AuthToken.refreshToken` 等于 `refresh_token` +- **且** `AuthToken.expireTimeMs` 等于 `900 * 1000` + +#### 场景:换取失败返回 invalid_grant 错误且不泄露敏感值(异常) + +- **当** `/oauth2/token` 对授权码换取返回 `error=invalid_grant`(授权码过期/已使用/校验失败)时 +- **则** CLI 抛出标识授权码/校验失败的 `InterfaceError`,且不重复使用同一授权码再次换取 +- **且** 错误信息不包含 `code_verifier`、授权码明文、`access_token` 或 `refresh_token` + +### 需求:Refresh Token 轮换与续期 + +当缓存的 token 依据 `EXPIRED_FACTOR = 0.8` 判定为过期且持有 `refreshToken` 时,`getToken` 应优先以 `grant_type="refresh_token"`、`refresh_token`、`client_id="official-cli"` 调用 `/oauth2/token` 续期,而不是重新走完整登录。续期成功后应使用服务端轮换返回的新 `refresh_token` 覆盖旧值,后续续期一律使用最新值。续期失败(如 `invalid_grant`)时应清除缓存并回退到完整登录。 + +#### 场景:过期 token 用 refresh_token 续期并轮换 + +- **当** 缓存 token 过期且持有 `refresh-1`,再次调用 `getToken` 时 +- **则** CLI 以 `grant_type=refresh_token` 携带 `refresh-1` 调用 `/oauth2/token`,不触发新的门户登录 +- **且** 续期成功后缓存的 `refreshToken` 被替换为服务端返回的最新值,下一次续期使用该最新值 + +#### 场景:续期失败回退完整登录(异常) + +- **当** 缓存 token 过期且 refresh 续期返回 `invalid_grant` 时 +- **则** CLI 清除失效缓存并执行一次完整门户登录作为回退 +- **且** 最终返回经由回退登录获得的新 token + +### 需求:查询 UserInfo + +CLI 应能以 `Authorization: Bearer ` 调用 `GET /oauth2/userinfo` 获取当前用户信息,并解析返回字段;处理与展示过程中不得输出 `access_token`、`refresh_token` 等敏感字段。 + +#### 场景:携带 Bearer 成功获取 userinfo + +- **当** 以有效 `access_token` 调用 `fetchUserInfo(baseUrl, accessToken)` 时 +- **则** 请求携带 `Authorization: Bearer ` 头并请求 `/oauth2/userinfo` +- **且** 返回解析后的用户信息字段 + +#### 场景:access_token 失效返回 invalid_token 且不泄露 token(异常) + +- **当** `/oauth2/userinfo` 因 token 失效返回 `error=invalid_token` 时 +- **则** CLI 抛出标识认证失败的 `InterfaceError` +- **且** 错误信息不包含 `access_token` 的明文值 + +### 需求:向后兼容传统登录 + +未携带 `oauthLoginParam`,或服务端在 OAuth 模式登录响应中未返回非空 `authorizationCode` 时,CLI 应保留登录返回的传统 token(legacy token),不得调用 `/oauth2/token`。`loginWithPat`、`loginWithPassword` 的公开签名保持兼容,传统模式下 `AuthToken` 不含 `refreshToken`,既有重试、退避与实例配置错误识别逻辑不变。 + +#### 场景:无 authorizationCode 时保留 legacy token + +- **当** 登录成功但响应 `data.authorizationCode` 为空时 +- **则** 返回的 `AuthToken.token` 等于门户返回的 legacy token +- **且** 调用 `/oauth2/token` 的次数为 0,且 `AuthToken.refreshToken` 为 undefined + +#### 场景:仅持有 legacy token 过期时重新登录而非续期(边界) + +- **当** 缓存中仅持有过期的 legacy token(无 `refreshToken`)再次调用 `getToken` 时 +- **则** CLI 执行一次完整门户登录获取新 token +- **且** 全程不调用 `/oauth2/token` + +### 需求:本地回调监听默认禁用 + +CLI 应实现本地 loopback 回调监听流程(`waitForAuthorizationCode`,在 `127.0.0.1` 上一次性接收并校验 `?code=&state=`),但该流程默认禁用,仅当开关 `CZ_OAUTH_LOCAL_CALLBACK` 设为 `1` 或 `true` 时启用。禁用状态下默认登录链路不得启动任何本地监听端口。 + +#### 场景:默认禁用时不启动本地监听 + +- **当** 未设置 `CZ_OAUTH_LOCAL_CALLBACK`(或其值非 `1`/`true`)执行默认登录时 +- **则** `isLocalCallbackEnabled()` 返回 false +- **且** 默认登录链路不调用 `waitForAuthorizationCode`,不占用任何本地端口 + +#### 场景:启用后能解析授权码并校验 state(边界) + +- **当** 设置 `CZ_OAUTH_LOCAL_CALLBACK=1` 并显式调用 `waitForAuthorizationCode` 接收携带 `code` 且 `state` 匹配的回调请求时 +- **则** 该方法以解析出的授权码 resolve 并关闭监听 +- **且** 当回调缺少 `code` 或 `state` 不匹配时,该方法 reject 并关闭监听,不泄露授权码值 + +### 需求:Refresh Token 跨进程持久化 + +登录或刷新成功得到含 `refreshToken` 的 OAuth `AuthToken` 时,CLI 应将 `token`(access_token)、`refreshToken`、`expireTimeMs`、`obtainedAt`、`instanceId`、`userId` 持久化到当前 profile 在 `~/.clickzetta/profiles.toml` 中的条目下(OAuth 子表),复用现有原子写入与 `0o600` 权限机制,且不得将 token 写入任何日志。新进程发起需要 token 的操作时:持久化 token 依据 `EXPIRED_FACTOR = 0.8` 判定未过期则直接复用,不重新登录也不调用 `/oauth2/token`;已过期但含 `refreshToken` 则用该 refresh token 调用 `/oauth2/token` 续期并将轮换后的新值回写持久化存储;续期失败(如 `invalid_grant`)则清除该 profile 的持久化 OAuth token 并回退完整登录。持久化按 profile + instance 维度隔离:OAuth token slot 以 **instance** 作为 key(不再附带 pat/username),不同 profile/instance 的 token 互不串用。OAuth token 代表用户自身的登录身份,移除或轮换 pat/username 不得导致已持久化的 token slot 失联。该机制通过 `ConnectionConfig` 上可选的 `tokenStore` 接口注入到 SDK 认证层;未注入该接口时行为退化为现有纯内存缓存,保持向后兼容。 + +#### 场景:持久化未过期 token 在新进程直接复用 + +- **当** 注入了 profile-backed `tokenStore` 且持久化的 access_token 未过期,新进程调用 `getToken` 时 +- **则** CLI 直接复用持久化的 access_token,不调用 `/clickzetta-portal/user/loginSingle` 也不调用 `/oauth2/token` +- **且** 不向 profiles.toml 写入新的 token 条目 + +#### 场景:持久化 refresh token 续期失败回退完整登录(异常) + +- **当** 持久化的 token 已过期,CLI 用持久化的 refresh token 调用 `/oauth2/token` 续期返回 `error=invalid_grant` 时 +- **则** CLI 清除该 profile 在 profiles.toml 中的持久化 OAuth token 条目,并回退执行一次完整门户登录 +- **且** 错误处理与日志不输出 `code_verifier`、授权码明文、`access_token` 或 `refresh_token` + +### 需求:持久化 OAuth token 作为 SQL 鉴权凭据 + +`cz-cli sql` 等需要 token 的命令在 `getExecContext` 中执行鉴权预检:当解析出的 profile 既无 pat 也无 username/password,但其在对应 instance 的 OAuth slot 下存在可加载的持久化 OAuth token(通过注入的 `tokenStore.load()` 取得)时,CLI 应将该持久化 OAuth token 视为充分的鉴权凭据,直接进入取 token / 执行流程,不得抛出缺少凭据错误(`NO_CREDENTIALS` / 以「Authentication required」开头的错误)。当既无 pat/username 也无持久化 OAuth token 时,CLI 应仍抛出以「Authentication required」开头的错误(由错误分类映射为 `NO_CREDENTIALS`),并指引 `--pat`/`--username`/`--password`、`cz-cli login --browser` 或 `cz-cli setup`。 + +#### 场景:纯 OAuth profile 用持久化 token 执行 SQL 不报 NO_CREDENTIALS + +- **当** 某 profile 无 pat 且无 username/password,但其在 instance 对应的 OAuth slot 下存在持久化的 OAuth token,执行 `cz-cli sql` 触发 `getExecContext` 鉴权预检时 +- **则** 预检通过,不抛出以「Authentication required」开头的缺少凭据错误,直接使用该持久化 OAuth token 鉴权 + +#### 场景:既无凭据又无 OAuth token 时仍报鉴权缺失(边界) + +- **当** 某 profile 既无 pat/username/password,也无任何持久化 OAuth token,执行 `cz-cli sql` 触发鉴权预检时 +- **则** CLI 抛出以「Authentication required」开头的错误(映射为 `NO_CREDENTIALS`),并指引 `--pat`/`--username`/`--password`、`cz-cli login --browser` 或 `cz-cli setup` + +### 需求:浏览器 loopback 授权流程(动态 redirect_uri) + +当开关 `CZ_OAUTH_LOCAL_CALLBACK` 启用时,CLI 应走标准 OAuth 浏览器 loopback 授权流程:先在 `127.0.0.1` 上以系统分配的随机端口启动一次性 HTTP 监听,据实际端口生成动态 `redirect_uri = "http://127.0.0.1:/callback"`;将 `oauthLoginParam`(含 `oauthLogin=true`、`clientId`、动态 `redirectUri`、`scope`、`codeChallenge`、`codeChallengeMethod="S256"`、随机 `state`)序列化为 JSON 并 base64 编码,作为 query 参数 `oauthLoginParam` 拼接到 accounts 登录页 URL,随后打开系统默认浏览器访问该 URL 并在终端打印该 URL 以便手动打开;前端登录成功后回跳 `http://127.0.0.1:/callback?code=...&state=...`,本地监听校验 `state` 与本次发起值一致后取出 `code` 并关闭监听;CLI 再以与监听阶段**完全一致**的动态 `redirect_uri`、`client_id`、`code_verifier` 调用 `/oauth2/token` 换取 token。`redirect_uri` 不再硬编码为固定 `http://127.0.0.1/callback`,换 token 接口接受调用方传入的 `redirect_uri`。流程全程不得在日志中输出 `code_verifier`、授权码明文、`access_token`、`refresh_token`,`state` 仅用于一次性校验。开关未启用时保持现有默认路径,不启动本地监听、不打开浏览器。 + +#### 场景:动态 redirect_uri 在 authorize URL 与换 token 时逐字一致 + +- **当** 启用浏览器 loopback 流程,本地监听在随机端口 `` 就绪并据此生成 `redirect_uri = "http://127.0.0.1:/callback"` 时 +- **则** 拼接到 accounts 登录页的 `oauthLoginParam`(base64-JSON 解码后)中 `redirectUri` 等于该动态 `redirect_uri` +- **且** 前端回跳取得 `code` 后,调用 `/oauth2/token` 携带的 `redirect_uri` 与 authorize URL 内的 `redirectUri` 逐字相同,且等于 `http://127.0.0.1:<实际监听端口>/callback` + +#### 场景:state 不匹配或回调超时则失败且不换 token(异常) + +- **当** 本地监听收到的回调 `state` 与本次发起生成的随机 `state` 不一致,或在超时时间内未收到回调时 +- **则** CLI 终止登录并返回明确错误,关闭本地监听 +- **且** 不以错误或伪造的 `code` 继续调用 `/oauth2/token`,错误信息不输出 `code_verifier`、授权码明文、`access_token` 或 `refresh_token` + +### 需求:`cz-cli login` 命令接入浏览器登录 + +CLI 应提供顶层命令 `cz-cli login`,复用全局连接参数(`--profile`/`--instance`/`--service` 等)解析当前 `ConnectionConfig`。当执行 `cz-cli login --browser`,或在 `CZ_OAUTH_LOCAL_CALLBACK` 启用时执行 `cz-cli login`,CLI 应走浏览器 loopback 授权流程(见「浏览器 loopback 授权流程」需求),自动拉起系统浏览器并在终端打印 authorize URL。浏览器登录成功取得 token 后,CLI 应调用 `/oauth2/userinfo` 查询当前用户信息,把其中的 `userId`(缺省回退解析 `sub`)与 `instanceId`(`instanceList[0].id`)回填到待持久化的 `AuthToken`(仅当为有效正整数时覆盖),并将登录上下文(`service`/`protocol`/`instance`/`workspace`/`schema`/`vcluster`/`user_id`,以及映射自 `account_id`/`accountName` 的 `account_id`/`account_name`)写回当前 profile 条目,其中 `instance` 优先取 userinfo 的 `instanceName`(缺省回退 `instanceList[0].name`);此外 CLI 应把完整 userinfo 响应体**原样无损**归档到 `[profiles..userinfo]` 子表(不丢弃任何字段,含 `instanceList` 对象数组、`gatewayMapping` JSON 字符串、`apiKey` 等),敏感值随 `0o600` 文件存储且不打印到成功输出;随后按最终 `instance` 作为 token slot key(仅按 instance,不含 pat/username)通过该 profile 的 token 存储(见「Refresh Token 跨进程持久化」需求)持久化 token,并输出成功结果,且不回显 `access_token`、`refresh_token` 等敏感值。`/oauth2/userinfo` 查询失败(如 `invalid_token`、网络异常)视为非致命:保留已换取的 token 完成登录、不写回连接上下文与 userinfo 子表,不得使整体登录失败,也不得在日志输出 token 明文(最多以 `CZ_OAUTH_DEBUG` 输出 userinfo 字段名)。若登录失败(`state` 不匹配、超时、换 token 失败等),CLI 应返回明确错误并以非零退出码结束,且不持久化任何 token。若既未传 `--browser` 也未启用 `CZ_OAUTH_LOCAL_CALLBACK`,`cz-cli login` 应提示当前为浏览器登录入口并指引启用方式,不静默改变现有默认登录路径,也不发起登录或持久化。 + +#### 场景:启用浏览器登录后成功并持久化 token 且输出不含敏感值 + +- **当** 执行 `cz-cli login --browser`(或在 `CZ_OAUTH_LOCAL_CALLBACK` 启用时执行 `cz-cli login`)且浏览器 loopback 流程成功换取到 OAuth token 时 +- **则** CLI 通过当前 profile 的 token 存储持久化该 token(含 `access_token`、`refreshToken`、`expireTimeMs`、`userId`),并输出登录成功结果 +- **且** 成功输出不包含 `access_token` 或 `refresh_token` 的明文值,退出码为 0 + +#### 场景:userinfo 回填身份与连接上下文并写回 profile + +- **当** 浏览器登录换取 token 成功后,`/oauth2/userinfo` 返回 `userId=110000011361`、`instanceList[0].id=159973`、`instanceName="89b94150"`、`workspaceName="quick_start"`、`schema="public"`、`virtualCluster="DEFAULT_AP"` 时 +- **则** 待持久化的 `AuthToken.userId` 被回填为 `110000011361`、`AuthToken.instanceId` 被回填为 `159973`,并将 `service`/`protocol`/`instance`(取 `89b94150`)/`workspace`/`schema`/`vcluster`/`user_id` 写回当前 profile 条目 +- **且** token 以最终 instance slot key `89b94150`(仅按 instance,不含 pat/username)持久化,后续 `resolveConnectionConfig` 能据此找回该 token + +#### 场景:完整 userinfo 无损归档进 profile 且敏感值不打印 + +- **当** 浏览器登录成功后 `/oauth2/userinfo` 返回完整响应体(含 `instanceList` 对象数组、`gatewayMapping` JSON 字符串、`aimeshEndpointBaseUrl`、`apiKey`、`account_id=112407`、`accountName="wynptmks"` 等字段)时 +- **则** CLI SHALL 将该 userinfo 完整、原样、无损地写入当前 profile 条目下的 `[profiles..userinfo]` 子表(写入后再解析与原值深度相等,不丢弃任何字段),并把 `account_id`/`account_name` 映射到 profile 条目,写入复用原子写入与 `0o600` 权限机制 +- **且(边界/安全)** `apiKey` 等敏感值仅随该 `0o600` 文件存储,不出现在登录成功输出或任何日志中;写入 `userinfo` 子表不触碰该 profile 的 `oauth` 子表与其他无关字段;若 userinfo 查询失败则不写入 `userinfo` 子表,整体登录仍成功 + +#### 场景:userinfo 查询失败时登录仍成功(边界) + +- **当** 浏览器登录换取 token 成功,但随后 `/oauth2/userinfo` 返回 `error=invalid_token`(HTTP 401)或网络异常时 +- **则** CLI 仍以已换取的 token 完成登录,`userId`/`instanceId` 维持默认值 0,不写回 userinfo 派生的连接上下文 +- **且** 整体登录不因 userinfo 失败而失败,日志不输出 `access_token`/`refresh_token` 明文 + +#### 场景:未启用浏览器登录时给出指引且不发起登录(边界) + +- **当** 执行 `cz-cli login` 但既未传 `--browser` 也未启用 `CZ_OAUTH_LOCAL_CALLBACK` 时 +- **则** CLI 提示当前为浏览器登录入口并指引通过 `--browser` 或 `CZ_OAUTH_LOCAL_CALLBACK=1` 启用,以非零退出码结束 +- **且** 不发起浏览器 loopback 流程,不调用任何登录或换 token 接口,也不持久化任何 token + +#### 场景:浏览器登录失败时非零退出且不持久化(异常) + +- **当** 执行 `cz-cli login --browser` 但浏览器 loopback 流程因 `state` 不匹配、回调超时或换 token 失败而抛错时 +- **则** CLI 返回明确错误并以非零退出码结束 +- **且** 不向 profile 的 token 存储写入任何 token,错误信息不输出 `code_verifier`、授权码明文、`access_token` 或 `refresh_token` diff --git a/packages/clickzetta-sdk/src/auth/callback-server.ts b/packages/clickzetta-sdk/src/auth/callback-server.ts new file mode 100644 index 000000000..dc85975ad --- /dev/null +++ b/packages/clickzetta-sdk/src/auth/callback-server.ts @@ -0,0 +1,219 @@ +import { createServer } from "node:http" + +import { loopbackRedirectUri } from "./oauth-constants.js" + +export interface CallbackOptions { + expectedState: string + // Reject and stop listening after this many ms (default 120000). + timeoutMs?: number + // Loopback port to bind. Defaults to 0 (ephemeral) so callers/tests can + // bind without a privileged port. + port?: number + // Invoked once the listener is bound, with the chosen port. Useful for + // tests (port:0) and callers that must build the redirect URL. + onListening?: (port: number) => void +} + +/** + * Separated loopback callback handle (design Addendum 2, component G). + * + * `startLoopbackCallback` resolves this once the listener is bound, exposing + * the actual `port` / `redirectUri` so the caller can build the dynamic + * redirect_uri and open the browser BEFORE the authorization code arrives. + * `waitForCode()` resolves later when a validated code is received. + */ +export interface LoopbackCallback { + port: number + redirectUri: string + // Resolves on a validated code (then closes); rejects on bad state, missing + // code, timeout, or close(). + waitForCode(): Promise + // Tear down the listener if the caller aborts; causes a pending + // waitForCode() to reject if not already settled. + close(): void +} + +const DEFAULT_TIMEOUT_MS = 120000 + +const SUCCESS_PAGE = + "Login complete" + + "

Login complete

You can close this window and return to the CLI.

" + +const FAILURE_PAGE = + "Login failed" + + "

Login failed

The authorization callback was invalid. Please retry from the CLI.

" + +/** + * Feature switch for the local loopback callback flow. Returns true ONLY when + * `CZ_OAUTH_LOCAL_CALLBACK` is set to "1" or "true". The default (unset) is + * disabled, so the default login flow never starts a listener (requirement 3.6). + */ +export function isLocalCallbackEnabled(): boolean { + const flag = process.env.CZ_OAUTH_LOCAL_CALLBACK + return flag === "1" || flag === "true" +} + +/** + * Debug switch for the loopback callback. When `CZ_OAUTH_DEBUG` is "1"/"true", + * every incoming request to the listener is logged to stderr (method, path, + * query param keys, and the full raw URL) so integration mismatches — wrong + * path, missing `state`, unexpected param name — can be inspected. Opt-in only; + * the raw URL may contain the authorization code, so it is never logged unless + * the developer explicitly enables this. + */ +function isOauthDebug(): boolean { + const flag = process.env.CZ_OAUTH_DEBUG + return flag === "1" || flag === "true" +} + +interface CallbackCore { + port: number + waitForCode: () => Promise + close: () => void +} + +/** + * Shared core for both the one-shot `waitForAuthorizationCode` and the + * separated `startLoopbackCallback` API. Binds 127.0.0.1, parses the incoming + * request's authorization code (`?code=` or `?authorizationCode=`) and + * `?state=`, validates `state === expectedState`, replies with + * a small success/failure page, and resolves/rejects an internal "code" + * promise. Never logs the code or state value. + * + * The returned Promise resolves once the listener is bound (exposing the actual + * port); the internal code promise is exposed via `waitForCode()`. The timeout + * timer starts when listening begins. + */ +function startCallbackCore(opts: { + expectedState: string + timeoutMs?: number + port?: number +}): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + return new Promise((resolveListen, rejectListen) => { + let settled = false + let listening = false + let timer: ReturnType | undefined + const pending = Promise.withResolvers() + // Attach a noop handler so a close()/timeout rejection that lands before the + // caller awaits waitForCode() does not surface as an unhandled rejection. + // The original promise still rejects for the real awaiter. + pending.promise.catch(() => {}) + + const finish = (action: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + server.close(() => action()) + } + + const server = createServer((req, res) => { + if (settled) return + const url = new URL(req.url ?? "/", "http://127.0.0.1") + + if (isOauthDebug()) { + const keys = Array.from(url.searchParams.keys()).join(",") + console.error(`[oauth-callback] ${req.method} ${url.pathname} params=[${keys}] url=${req.url}`) + } + + // Only the loopback callback path is the real OAuth redirect. Browsers and + // the OS routinely probe a freshly-opened loopback port (favicon.ico, the + // root path, connectivity checks) — those carry no `code` and must NOT + // consume this one-shot listener. Answer 404 and keep waiting so the + // genuine `/callback?code=...&state=...` request still settles it. + if (url.pathname !== "/callback") { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("not found") + return + } + + const code = url.searchParams.get("code") ?? url.searchParams.get("authorizationCode") + const state = url.searchParams.get("state") + + if (!code || state !== opts.expectedState) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(FAILURE_PAGE) + finish(() => + pending.reject( + new Error( + !code + ? "missing authorization code in callback request" + : "state mismatch in authorization callback", + ), + ), + ) + return + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(SUCCESS_PAGE) + finish(() => pending.resolve(code)) + }) + + server.on("error", (err) => { + if (!listening) { + rejectListen(err) + return + } + finish(() => pending.reject(err)) + }) + + server.listen(opts.port ?? 0, "127.0.0.1", () => { + listening = true + timer = setTimeout( + () => finish(() => pending.reject(new Error("timed out waiting for authorization callback"))), + timeoutMs, + ) + timer.unref?.() + + const addr = server.address() + const port = addr && typeof addr === "object" ? addr.port : 0 + resolveListen({ + port, + waitForCode: () => pending.promise, + close: () => + finish(() => + pending.reject( + new Error("callback listener closed before receiving authorization code"), + ), + ), + }) + }) + }) +} + +/** + * Start a one-shot loopback HTTP listener that captures an OAuth authorization + * code redirected back from the browser (requirement 3.5). + * + * It binds 127.0.0.1, validates `state`, resolves with the `code`, then closes + * the listener. On missing code, state mismatch, timeout, or a bind error it + * rejects and closes. Never logs the code value. + */ +export function waitForAuthorizationCode(opts: CallbackOptions): Promise { + return startCallbackCore(opts).then((core) => { + opts.onListening?.(core.port) + return core.waitForCode() + }) +} + +/** + * Separated loopback callback API (design Addendum 2, component G; needs 10.2, + * 10.6, 10.7). Starts the listener and resolves once it is bound, exposing the + * actual `port` and `redirectUri = loopbackRedirectUri(port)` so the caller can + * build the authorize URL and open the browser BEFORE the code arrives. The + * code itself is awaited later via `waitForCode()`. The timeout starts when + * listening begins. `close()` aborts a pending wait. + */ +export function startLoopbackCallback(opts: { + expectedState: string + timeoutMs?: number + port?: number +}): Promise { + return startCallbackCore(opts).then((core) => ({ + port: core.port, + redirectUri: loopbackRedirectUri(core.port), + waitForCode: core.waitForCode, + close: core.close, + })) +} diff --git a/packages/clickzetta-sdk/src/auth/login.ts b/packages/clickzetta-sdk/src/auth/login.ts index 8132f518b..ea716159e 100644 --- a/packages/clickzetta-sdk/src/auth/login.ts +++ b/packages/clickzetta-sdk/src/auth/login.ts @@ -2,12 +2,17 @@ import { type ClientOptions } from "../client.js" import { ClickZettaApiError, type ApiResponse } from "../types/api.js" import { InterfaceError } from "../types/errors.js" import type { AuthToken } from "../types/index.js" +import { generatePkce } from "./pkce.js" +import { exchangeAuthorizationCode } from "./oauth.js" +import { OAUTH_REDIRECT_URI } from "./oauth-constants.js" +import { buildOauthLoginParam } from "./oauth-login-param.js" interface LoginResponse { token: string instanceId: number userId: number expireTime: number + authorizationCode?: string } // Mirrors Python connector login loop at client.py:296-392: @@ -57,23 +62,17 @@ async function postLogin( requestId: string, ): Promise> { const url = `${baseUrl}/clickzetta-portal/user/loginSingle` - let resp: Response - try { - resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*", - "requestId": requestId, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(LOGIN_TIMEOUT_MS), - }) - } catch (e) { - console.error("DEBUG fetch error:", url, e) - throw e - } - const text = await resp.text() + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "requestId": requestId, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(LOGIN_TIMEOUT_MS), + }) + const text = await resp.text() if (!resp.ok) { // Throw something the outer retry loop can inspect for instance errors. throw new ClickZettaApiError( @@ -94,16 +93,28 @@ async function postLogin( async function loginWithRetry( baseUrl: string, - body: unknown, + body: Record, instance: string, ): Promise { let lastError: Error | undefined // client.py:305 — one request id for the entire login attempt sequence const requestId = generateRequestId() + // PKCE is generated once per login sequence; codeVerifier stays in memory + // only and is never logged. codeChallenge is sent to the portal so the + // gateway can later validate the matching verifier at /oauth2/token. + const pkce = generatePkce() + const loginBody = { + ...body, + oauthLoginParam: buildOauthLoginParam({ + redirectUri: OAUTH_REDIRECT_URI, + codeChallenge: pkce.codeChallenge, + }), + } + for (let attempt = 0; attempt <= LOGIN_MAX_RETRIES; attempt++) { try { - const resp = await postLogin(baseUrl, body, requestId) + const resp = await postLogin(baseUrl, loginBody, requestId) if (resp.code !== 0 && resp.code !== "0" && resp.code !== 200 && resp.code !== "200") { const serverMsg = resp.message ?? "" const instErr = instanceConfigError(serverMsg, instance) @@ -115,11 +126,27 @@ async function loginWithRetry( } else if (!resp.data?.token) { lastError = new ClickZettaApiError("AUTH_FAILED", "Login succeeded but no token returned") } else { + const data = resp.data + // OAuth path: a non-empty authorizationCode means the portal opted + // into the code exchange. Swap the legacy token for the OAuth tokens + // while keeping the portal-issued instanceId/userId. + if (data.authorizationCode) { + const oauth = await exchangeAuthorizationCode(baseUrl, data.authorizationCode, pkce.codeVerifier, OAUTH_REDIRECT_URI) + return { + token: oauth.accessToken, + refreshToken: oauth.refreshToken, + instanceId: data.instanceId, + userId: data.userId, + expireTimeMs: oauth.expiresInMs, + obtainedAt: Date.now(), + } + } + // Legacy path: no authorization code, keep the portal token as-is. return { - token: resp.data.token, - instanceId: resp.data.instanceId, - userId: resp.data.userId, - expireTimeMs: resp.data.expireTime, + token: data.token, + instanceId: data.instanceId, + userId: data.userId, + expireTimeMs: data.expireTime, obtainedAt: Date.now(), } } diff --git a/packages/clickzetta-sdk/src/auth/oauth-constants.ts b/packages/clickzetta-sdk/src/auth/oauth-constants.ts new file mode 100644 index 000000000..94ea844dd --- /dev/null +++ b/packages/clickzetta-sdk/src/auth/oauth-constants.ts @@ -0,0 +1,22 @@ +export const OAUTH_CLIENT_ID = "official-cli" +export const OAUTH_REDIRECT_URI = "http://127.0.0.1/callback" +export const OAUTH_SCOPE = "openid profile offline_access" +export const OAUTH_CODE_CHALLENGE_METHOD = "S256" + +/** + * Gateway service-path prefix the OAuth endpoints are mounted under. Through + * the API gateway the hornhub service lives at `/clickzetta-hornhub` (mirroring + * how login lives under `/clickzetta-portal`); the OAuth `/oauth2/*` endpoints + * must be addressed as `${baseUrl}/clickzetta-hornhub/oauth2/*`. Hitting the + * bare `/oauth2/*` at the gateway root reaches a different handler. + */ +export const OAUTH_PATH_PREFIX = "/clickzetta-hornhub" + +/** + * Build the loopback redirect_uri for the browser flow using the actual + * listening port. The gateway ignores the port when validating + * `127.0.0.1` redirect URIs, so a dynamic port is acceptable. + */ +export function loopbackRedirectUri(port: number): string { + return `http://127.0.0.1:${port}/callback` +} diff --git a/packages/clickzetta-sdk/src/auth/oauth-login-param.ts b/packages/clickzetta-sdk/src/auth/oauth-login-param.ts new file mode 100644 index 000000000..5f249fc56 --- /dev/null +++ b/packages/clickzetta-sdk/src/auth/oauth-login-param.ts @@ -0,0 +1,52 @@ +import { + OAUTH_CLIENT_ID, + OAUTH_CODE_CHALLENGE_METHOD, + OAUTH_SCOPE, +} from "./oauth-constants.js" + +/** + * The `oauthLoginParam` object sent to the portal (`/user/loginSingle`) and + * serialized into the browser authorize URL. `state` is optional and only + * present in the browser loopback flow (requirement 10.3); the credential + * path omits it. + */ +export interface OauthLoginParam { + oauthLogin: true + clientId: string + redirectUri: string + scope: string + codeChallenge: string + codeChallengeMethod: string + state?: string +} + +/** + * Build the `oauthLoginParam` payload. The fixed fields (`oauthLogin`, + * `clientId`, `scope`, `codeChallengeMethod`) come from constants; the + * caller supplies the dynamic `redirectUri`, `codeChallenge`, and optional + * `state`. `state` is only included when provided so the credential path + * stays byte-equivalent to its previous payload. + */ +export function buildOauthLoginParam(input: { + redirectUri: string + codeChallenge: string + state?: string +}): OauthLoginParam { + return { + oauthLogin: true, + clientId: OAUTH_CLIENT_ID, + redirectUri: input.redirectUri, + scope: OAUTH_SCOPE, + codeChallenge: input.codeChallenge, + codeChallengeMethod: OAUTH_CODE_CHALLENGE_METHOD, + ...(input.state === undefined ? {} : { state: input.state }), + } +} + +/** + * Base64-encode the JSON form of an {@link OauthLoginParam} for use as the + * `oauthLoginParam` query parameter on the accounts authorize URL. + */ +export function encodeOauthLoginParam(param: OauthLoginParam): string { + return Buffer.from(JSON.stringify(param)).toString("base64") +} diff --git a/packages/clickzetta-sdk/src/auth/oauth.ts b/packages/clickzetta-sdk/src/auth/oauth.ts new file mode 100644 index 000000000..95bd8746f --- /dev/null +++ b/packages/clickzetta-sdk/src/auth/oauth.ts @@ -0,0 +1,177 @@ +import { OAUTH_CLIENT_ID, OAUTH_PATH_PREFIX } from "./oauth-constants.js" +import { InterfaceError } from "../types/errors.js" + +export interface OAuthTokenResult { + accessToken: string + refreshToken?: string + expiresInMs: number + tokenType: string +} + +/** + * Human-readable semantics for the OAuth error codes the gateway returns. + * These are static strings only — never interpolate request inputs + * (`code`, `code_verifier`, `refresh_token`, `access_token`) so error + * messages and logs cannot leak sensitive values (design Property 7, + * requirement 7.6). + */ +const OAUTH_ERROR_SEMANTICS: Record = { + invalid_request: "the OAuth request was malformed (missing or invalid parameters)", + invalid_client: "the OAuth client configuration is missing or invalid", + invalid_scope: "the requested OAuth scope was rejected", + invalid_grant: "the authorization grant is invalid, expired, or already used", + invalid_token: "the access token is invalid or expired", +} + +/** + * Generate a request id carried on the `requestId` header so gateway logs + * can correlate OAuth calls (requirement 7.7). The value is random hex and + * contains no sensitive material. + */ +function generateRequestId(): string { + const hex = Array.from(crypto.getRandomValues(new Uint8Array(6))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + return `tssdk-oauth-${hex}` +} + +/** + * Debug switch shared with the callback server. When `CZ_OAUTH_DEBUG` is + * "1"/"true", token requests log their grant type + param keys and the raw + * response so integration mismatches can be inspected. Opt-in only. + */ +function isOauthDebug(): boolean { + const flag = process.env.CZ_OAUTH_DEBUG + return flag === "1" || flag === "true" +} + +/** + * Build an {@link InterfaceError} (auth-layer failure) from an OAuth error + * code. The message exposes the error code, its semantics, and the server's + * `error_description` (which describes the request problem, not a secret), but + * never the caller-supplied credentials. + */ +function oauthError( + error: string | undefined, + status: number, + requestId: string, + description?: string, +): InterfaceError { + const code = error ?? "oauth_error" + const semantics = OAUTH_ERROR_SEMANTICS[code] ?? "the OAuth request failed" + const detail = description ? `: ${description}` : "" + return new InterfaceError( + `OAuth request failed (${code}): ${semantics}${detail} (request id: ${requestId})`, + { code, statusCode: status }, + ) +} + +async function requestToken(baseUrl: string, params: URLSearchParams): Promise { + const requestId = generateRequestId() + const tokenUrl = `${baseUrl}${OAUTH_PATH_PREFIX}/oauth2/token` + if (isOauthDebug()) { + console.error( + `[oauth-token] POST ${tokenUrl} grant=${params.get("grant_type")} params=[${Array.from(params.keys()).join(",")}]`, + ) + } + const resp = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "requestId": requestId, + }, + body: params.toString(), + }) + + const body = (await resp.json()) as Record + if (isOauthDebug()) { + console.error(`[oauth-token] status=${resp.status} returned=[${Object.keys(body).join(",")}]`) + if (typeof body.error === "string") { + console.error(`[oauth-token] error=${body.error} error_description=${String(body.error_description ?? "")}`) + } + } + if (!resp.ok || typeof body.error === "string") { + throw oauthError( + typeof body.error === "string" ? body.error : undefined, + resp.status, + requestId, + typeof body.error_description === "string" ? body.error_description : undefined, + ) + } + + return { + accessToken: String(body.access_token), + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined, + expiresInMs: typeof body.expires_in === "number" ? body.expires_in * 1000 : 0, + tokenType: typeof body.token_type === "string" ? body.token_type : "Bearer", + } +} + +/** + * Exchange an authorization code for tokens (`grant_type=authorization_code`). + * The `redirectUri` is supplied by the caller so it matches the value used to + * obtain the code (a fixed loopback for the credential path, or a dynamic + * loopback port for the browser flow). + */ +export function exchangeAuthorizationCode( + baseUrl: string, + code: string, + codeVerifier: string, + redirectUri: string, +): Promise { + return requestToken( + baseUrl, + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + ) +} + +/** + * Rotate tokens using a refresh token (`grant_type=refresh_token`). + */ +export function refreshAccessToken( + baseUrl: string, + refreshToken: string, +): Promise { + return requestToken( + baseUrl, + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OAUTH_CLIENT_ID, + }), + ) +} + +/** + * Fetch the OpenID userinfo for an access token. Failures (e.g. + * `invalid_token`) surface as {@link InterfaceError} without leaking the + * token value. + */ +export async function fetchUserInfo( + baseUrl: string, + accessToken: string, +): Promise> { + const requestId = generateRequestId() + const resp = await fetch(`${baseUrl}${OAUTH_PATH_PREFIX}/oauth2/userinfo`, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "requestId": requestId, + }, + }) + + const body = (await resp.json()) as Record + if (!resp.ok || typeof body.error === "string") { + throw oauthError(typeof body.error === "string" ? body.error : undefined, resp.status, requestId) + } + + return body +} diff --git a/packages/clickzetta-sdk/src/auth/pkce.ts b/packages/clickzetta-sdk/src/auth/pkce.ts new file mode 100644 index 000000000..e9a54759c --- /dev/null +++ b/packages/clickzetta-sdk/src/auth/pkce.ts @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto" + +export interface Pkce { + codeVerifier: string + codeChallenge: string // base64url(SHA-256(codeVerifier)), no padding +} + +// base64url encode without padding: '+'→'-', '/'→'_', strip '='. +function base64Url(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") +} + +// Generate RFC 7636 PKCE parameters. +// 64 random bytes → 86 base64url chars, comfortably within the [43,128] range, +// and base64url already yields only unreserved characters [A-Za-z0-9-._~]. +export function generatePkce(): Pkce { + const bytes = new Uint8Array(64) + crypto.getRandomValues(bytes) + const codeVerifier = base64Url(Buffer.from(bytes)) + const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest()) + return { codeVerifier, codeChallenge } +} diff --git a/packages/clickzetta-sdk/src/auth/token.ts b/packages/clickzetta-sdk/src/auth/token.ts index aca8d6f69..f76fb0285 100644 --- a/packages/clickzetta-sdk/src/auth/token.ts +++ b/packages/clickzetta-sdk/src/auth/token.ts @@ -1,5 +1,6 @@ import type { AuthToken, ConnectionConfig } from "../types/index.js" import { loginWithPat, loginWithPassword } from "./login.js" +import { refreshAccessToken } from "./oauth.js" import { toServiceUrl } from "../config/region.js" const EXPIRED_FACTOR = 0.8 @@ -35,6 +36,37 @@ async function fetchToken(config: ConnectionConfig): Promise { ) } +/** + * Rotate an expired OAuth token via `/oauth2/token`. On success the rotated + * refresh token replaces the old one (requirement 5.3) so the next refresh + * uses the latest value. On any refresh failure (e.g. `invalid_grant`) the + * cache is dropped and we fall back to a full portal login (requirement 5.4). + * When a `tokenStore` is injected, a refresh failure also clears the persisted + * token so a stale refresh token is not reused next process (requirement 9.5). + */ +async function refreshOrLogin( + config: ConnectionConfig, + previous: AuthToken, + refreshTokenValue: string, +): Promise { + const baseUrl = toServiceUrl(config.service, config.protocol) + try { + const oauth = await refreshAccessToken(baseUrl, refreshTokenValue) + return { + token: oauth.accessToken, + refreshToken: oauth.refreshToken ?? refreshTokenValue, + instanceId: previous.instanceId, + userId: previous.userId, + expireTimeMs: oauth.expiresInMs, + obtainedAt: Date.now(), + } + } catch { + clearTokenCache() + config.tokenStore?.clear() + return fetchToken(config) + } +} + export async function getToken(config: ConnectionConfig): Promise { const key = cacheKey(config) if (cachedToken && cachedKey === key && !isTokenExpired(cachedToken)) { @@ -43,11 +75,35 @@ export async function getToken(config: ConnectionConfig): Promise { if (pendingFetch) { return pendingFetch } + // Reaching here means any in-memory token for this key is expired/absent. + // A persisted token (requirement 9) is consulted when memory has nothing: + // an unexpired persisted token is reused without any network call + // (requirement 9.3); an expired one with a refresh token feeds the refresh + // path below (requirement 9.4). + const store = config.tokenStore + let candidate = cachedToken && cachedKey === key ? cachedToken : undefined + if (!candidate && store) { + const loaded = store.load() + if (loaded) { + if (!isTokenExpired(loaded)) { + cachedToken = loaded + cachedKey = key + return loaded + } + candidate = loaded + } + } + // If the candidate carries a refresh token, rotate it instead of a full + // login (requirement 5.1); legacy tokens without one always re-login + // (requirement 5.5). On success the token is persisted (requirement 9.1). pendingFetch = (async () => { try { - const token = await fetchToken(config) + const token = candidate?.refreshToken + ? await refreshOrLogin(config, candidate, candidate.refreshToken) + : await fetchToken(config) cachedToken = token cachedKey = key + store?.save(token) return token } finally { pendingFetch = undefined diff --git a/packages/clickzetta-sdk/src/index.ts b/packages/clickzetta-sdk/src/index.ts index e6ca7e4af..c1ca340b3 100644 --- a/packages/clickzetta-sdk/src/index.ts +++ b/packages/clickzetta-sdk/src/index.ts @@ -5,6 +5,11 @@ export * from "./client.js" export * from "./auth/login.js" export * from "./auth/token.js" export * from "./auth/user.js" +export * from "./auth/oauth.js" +export * from "./auth/oauth-constants.js" +export * from "./auth/oauth-login-param.js" +export * from "./auth/pkce.js" +export * from "./auth/callback-server.js" export * from "./config/region.js" export * from "./config/parseUrl.js" export * from "./sql/types.js" diff --git a/packages/clickzetta-sdk/src/types/index.ts b/packages/clickzetta-sdk/src/types/index.ts index acc7181da..a78fbdeda 100644 --- a/packages/clickzetta-sdk/src/types/index.ts +++ b/packages/clickzetta-sdk/src/types/index.ts @@ -9,6 +9,20 @@ export interface ConnectionConfig { schema: string vcluster: string customHeaders?: Record + tokenStore?: TokenStore +} + +/** + * Pluggable persistence seam for OAuth tokens (requirement 9). When a + * `ConnectionConfig` carries a `tokenStore`, the token cache layer uses it to + * load/save/clear tokens across processes (cz-cli injects a profile-backed + * implementation). When absent, the cache falls back to in-memory only, + * preserving the previous behavior (requirement 9.7). + */ +export interface TokenStore { + load(): AuthToken | undefined + save(token: AuthToken): void + clear(): void } export const DEFAULT_CONNECTION: ConnectionConfig = { @@ -29,6 +43,7 @@ export interface AuthToken { userId: number expireTimeMs: number obtainedAt: number + refreshToken?: string // OAuth refresh token;传统登录模式下为 undefined } export interface StudioConfig { diff --git a/packages/clickzetta-sdk/test/callback-server.test.ts b/packages/clickzetta-sdk/test/callback-server.test.ts new file mode 100644 index 000000000..f68f65816 --- /dev/null +++ b/packages/clickzetta-sdk/test/callback-server.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { get } from "node:http" + +import { + isLocalCallbackEnabled, + startLoopbackCallback, + waitForAuthorizationCode, +} from "../src/auth/callback-server.js" + +// Issue the loopback request via node:http (not global fetch) so the test is +// immune to any fetch stub a sibling test file may leave installed, and never +// relies on the privileged port 80. +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = get(url, (res) => { + res.resume() // drain so the socket closes + resolve(res.statusCode ?? 0) + }) + req.on("error", reject) + }) +} + +// Bind an ephemeral port (0) and resolve once the listener is ready. +async function startListener(expectedState: string) { + const portReady = Promise.withResolvers() + const codePromise = waitForAuthorizationCode({ + expectedState, + port: 0, + onListening: (port) => portReady.resolve(port), + }) + // Swallow the rejection path here; individual tests assert on codePromise. + codePromise.catch(() => {}) + return { port: await portReady.promise, codePromise } +} + +describe("waitForAuthorizationCode", () => { + // Requirement 3.5: capture the authorization code from the loopback callback + // and validate state before resolving. + test("resolves with the code when state matches", async () => { + const { port, codePromise } = await startListener("state-match-123") + const status = await httpGet( + `http://127.0.0.1:${port}/callback?code=auth-code-xyz&state=state-match-123`, + ) + expect(status).toBe(200) + expect(await codePromise).toBe("auth-code-xyz") + }) + + // Requirement 3.5: a state mismatch must be rejected (and the listener closed). + test("rejects when state does not match", async () => { + const { port, codePromise } = await startListener("expected-state") + const status = await httpGet( + `http://127.0.0.1:${port}/callback?code=auth-code-xyz&state=wrong-state`, + ) + expect(status).toBe(400) + await expect(codePromise).rejects.toThrow(/state mismatch/) + }) + + // Requirement 3.5: a missing code must be rejected. + test("rejects when code is missing", async () => { + const { port, codePromise } = await startListener("any-state") + const status = await httpGet(`http://127.0.0.1:${port}/callback?state=any-state`) + expect(status).toBe(400) + await expect(codePromise).rejects.toThrow(/missing authorization code/) + }) + + // Requirement 3.5: honor the timeout and reject without leaking resources. + test("rejects after the timeout elapses", async () => { + await expect( + waitForAuthorizationCode({ expectedState: "s", port: 0, timeoutMs: 20 }), + ).rejects.toThrow(/timed out/) + }) +}) + +describe("startLoopbackCallback", () => { + // Property 11/12 (Requirements 10.2, 10.6, 10.7): the API resolves once bound, + // exposing the real port + redirectUri so the caller can build the redirect + // URL before the code arrives; a matching callback then resolves waitForCode. + test("resolves with port + redirectUri, then waitForCode resolves on matching callback", async () => { + const cb = await startLoopbackCallback({ expectedState: "state-abc", port: 0 }) + expect(cb.port).toBeGreaterThan(0) + expect(cb.redirectUri).toBe(`http://127.0.0.1:${cb.port}/callback`) + + const codePromise = cb.waitForCode() + codePromise.catch(() => {}) + const status = await httpGet(`${cb.redirectUri}?code=auth-code-abc&state=state-abc`) + expect(status).toBe(200) + expect(await codePromise).toBe("auth-code-abc") + }) + + // Requirement 10.7: a state mismatch must reject waitForCode (and close). + test("waitForCode rejects on state mismatch", async () => { + const cb = await startLoopbackCallback({ expectedState: "expected", port: 0 }) + const codePromise = cb.waitForCode() + codePromise.catch(() => {}) + const status = await httpGet(`${cb.redirectUri}?code=auth-code-abc&state=wrong`) + expect(status).toBe(400) + await expect(codePromise).rejects.toThrow(/state mismatch/) + }) + + // Requirement 10.7: a missing code must reject waitForCode. + test("waitForCode rejects when code is missing", async () => { + const cb = await startLoopbackCallback({ expectedState: "any", port: 0 }) + const codePromise = cb.waitForCode() + codePromise.catch(() => {}) + const status = await httpGet(`${cb.redirectUri}?state=any`) + expect(status).toBe(400) + await expect(codePromise).rejects.toThrow(/missing authorization code/) + }) + + // Requirement 10.7: honor the timeout and reject without leaking resources. + test("waitForCode rejects after the timeout elapses", async () => { + const cb = await startLoopbackCallback({ expectedState: "s", port: 0, timeoutMs: 20 }) + await expect(cb.waitForCode()).rejects.toThrow(/timed out/) + }) + + // close() before any callback must reject a pending waitForCode(). + test("close() rejects a pending waitForCode", async () => { + const cb = await startLoopbackCallback({ expectedState: "s", port: 0 }) + const codePromise = cb.waitForCode() + codePromise.catch(() => {}) + cb.close() + await expect(codePromise).rejects.toThrow(/closed/) + }) + + // The front end may name the code param `authorizationCode` (current accounts + // contract) instead of the OAuth-standard `code`; both must be accepted. + test("resolves when the code is passed as authorizationCode with matching state", async () => { + const cb = await startLoopbackCallback({ expectedState: "state-acode", port: 0 }) + const codePromise = cb.waitForCode() + codePromise.catch(() => {}) + const status = await httpGet(`${cb.redirectUri}?authorizationCode=ac-123&state=state-acode`) + expect(status).toBe(200) + expect(await codePromise).toBe("ac-123") + }) + + // A stray probe (favicon / root / connectivity check) to the loopback port + // must NOT consume the one-shot listener; the real /callback still resolves. + test("ignores non-callback probes then resolves on the real callback", async () => { + const cb = await startLoopbackCallback({ expectedState: "state-probe", port: 0 }) + const codePromise = cb.waitForCode() + codePromise.catch(() => {}) + + const faviconStatus = await httpGet(`http://127.0.0.1:${cb.port}/favicon.ico`) + expect(faviconStatus).toBe(404) + const rootStatus = await httpGet(`http://127.0.0.1:${cb.port}/`) + expect(rootStatus).toBe(404) + + const status = await httpGet(`${cb.redirectUri}?code=real-code&state=state-probe`) + expect(status).toBe(200) + expect(await codePromise).toBe("real-code") + }) +}) + +describe("isLocalCallbackEnabled", () => { + const original = process.env.CZ_OAUTH_LOCAL_CALLBACK + + afterEach(() => { + if (original === undefined) delete process.env.CZ_OAUTH_LOCAL_CALLBACK + else process.env.CZ_OAUTH_LOCAL_CALLBACK = original + }) + + // Requirement 3.6: default (unset) MUST be disabled. + test("returns false when the env var is unset", () => { + delete process.env.CZ_OAUTH_LOCAL_CALLBACK + expect(isLocalCallbackEnabled()).toBe(false) + }) + + test("returns true when set to \"1\" or \"true\"", () => { + process.env.CZ_OAUTH_LOCAL_CALLBACK = "1" + expect(isLocalCallbackEnabled()).toBe(true) + process.env.CZ_OAUTH_LOCAL_CALLBACK = "true" + expect(isLocalCallbackEnabled()).toBe(true) + }) + + test("returns false for other values", () => { + process.env.CZ_OAUTH_LOCAL_CALLBACK = "0" + expect(isLocalCallbackEnabled()).toBe(false) + process.env.CZ_OAUTH_LOCAL_CALLBACK = "yes" + expect(isLocalCallbackEnabled()).toBe(false) + }) +}) diff --git a/packages/clickzetta-sdk/test/login-oauth.test.ts b/packages/clickzetta-sdk/test/login-oauth.test.ts new file mode 100644 index 000000000..b9c21194f --- /dev/null +++ b/packages/clickzetta-sdk/test/login-oauth.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" + +import { loginWithPassword } from "../src/auth/login.js" + +function base64Url(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") +} + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe("OAuth login", () => { + test("loginWithPassword sends oauthLoginParam and exchanges authorizationCode", async () => { + let loginPayload: Record | undefined + let tokenPayload: URLSearchParams | undefined + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === "/clickzetta-portal/user/loginSingle" && init?.method === "POST") { + loginPayload = JSON.parse(String(init.body)) as Record + return new Response(JSON.stringify({ + code: 0, + data: { + token: "legacy-token", + authorizationCode: "auth-code-1", + userId: 7, + instanceId: 9, + expireTime: 123, + }, + }), { status: 200, headers: { "content-type": "application/json" } }) + } + if (url.pathname === "/clickzetta-hornhub/oauth2/token" && init?.method === "POST") { + tokenPayload = new URLSearchParams(String(init.body)) + return new Response(JSON.stringify({ + access_token: "oauth-access-token", + refresh_token: "oauth-refresh-token", + token_type: "Bearer", + expires_in: 900, + }), { status: 200, headers: { "content-type": "application/json" } }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + const token = await loginWithPassword("https://service.example.com", "user", "pass", "inst") + + const oauthLoginParam = loginPayload?.oauthLoginParam as Record + expect(loginPayload?.username).toBe("user") + expect(loginPayload?.password).toBe("pass") + expect(loginPayload?.instanceName).toBe("inst") + expect(oauthLoginParam.oauthLogin).toBe(true) + expect(oauthLoginParam.clientId).toBe("official-cli") + expect(oauthLoginParam.redirectUri).toBe("http://127.0.0.1/callback") + expect(oauthLoginParam.scope).toBe("openid profile offline_access") + expect(oauthLoginParam.codeChallengeMethod).toBe("S256") + + const verifier = tokenPayload?.get("code_verifier") ?? "" + expect(tokenPayload?.get("grant_type")).toBe("authorization_code") + expect(tokenPayload?.get("code")).toBe("auth-code-1") + expect(tokenPayload?.get("client_id")).toBe("official-cli") + expect(tokenPayload?.get("redirect_uri")).toBe("http://127.0.0.1/callback") + expect(oauthLoginParam.codeChallenge).toBe(base64Url(createHash("sha256").update(verifier).digest())) + expect(token.token).toBe("oauth-access-token") + expect(token.refreshToken).toBe("oauth-refresh-token") + expect(token.expireTimeMs).toBe(900_000) + }) + + test("loginWithPassword keeps legacy token when authorizationCode is absent", async () => { + let tokenExchangeCalls = 0 + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === "/clickzetta-portal/user/loginSingle" && init?.method === "POST") { + return new Response(JSON.stringify({ + code: 0, + data: { + token: "legacy-token", + userId: 7, + instanceId: 9, + expireTime: 123, + }, + }), { status: 200, headers: { "content-type": "application/json" } }) + } + if (url.pathname === "/clickzetta-hornhub/oauth2/token") { + tokenExchangeCalls += 1 + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + const token = await loginWithPassword("https://service.example.com", "user", "pass", "inst") + + expect(token.token).toBe("legacy-token") + expect(tokenExchangeCalls).toBe(0) + }) +}) diff --git a/packages/clickzetta-sdk/test/oauth-login-param.test.ts b/packages/clickzetta-sdk/test/oauth-login-param.test.ts new file mode 100644 index 000000000..2a69cbae2 --- /dev/null +++ b/packages/clickzetta-sdk/test/oauth-login-param.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" + +import { + buildOauthLoginParam, + encodeOauthLoginParam, +} from "../src/auth/oauth-login-param.js" + +describe("buildOauthLoginParam", () => { + test("fills constant fields and the provided redirectUri/codeChallenge", () => { + const param = buildOauthLoginParam({ + redirectUri: "http://127.0.0.1:54321/callback", + codeChallenge: "challenge-xyz", + }) + + expect(param.oauthLogin).toBe(true) + expect(param.clientId).toBe("official-cli") + expect(param.scope).toBe("openid profile offline_access") + expect(param.codeChallengeMethod).toBe("S256") + expect(param.redirectUri).toBe("http://127.0.0.1:54321/callback") + expect(param.codeChallenge).toBe("challenge-xyz") + expect(param.state).toBeUndefined() + expect("state" in param).toBe(false) + }) + + test("includes state only when provided", () => { + const param = buildOauthLoginParam({ + redirectUri: "http://127.0.0.1:54321/callback", + codeChallenge: "challenge-xyz", + state: "random-state", + }) + + expect(param.state).toBe("random-state") + }) +}) + +describe("encodeOauthLoginParam", () => { + test("round-trips through base64 + JSON", () => { + const param = buildOauthLoginParam({ + redirectUri: "http://127.0.0.1:54321/callback", + codeChallenge: "challenge-xyz", + state: "random-state", + }) + + const decoded = JSON.parse(Buffer.from(encodeOauthLoginParam(param), "base64").toString()) + expect(decoded).toEqual(param) + }) +}) diff --git a/packages/clickzetta-sdk/test/oauth.test.ts b/packages/clickzetta-sdk/test/oauth.test.ts new file mode 100644 index 000000000..6558ba508 --- /dev/null +++ b/packages/clickzetta-sdk/test/oauth.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, test } from "bun:test" + +import { + exchangeAuthorizationCode, + fetchUserInfo, + refreshAccessToken, +} from "../src/auth/oauth.js" +import { OAUTH_REDIRECT_URI, loopbackRedirectUri } from "../src/auth/oauth-constants.js" +import { InterfaceError } from "../src/types/errors.js" + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +// Distinctive sensitive values; used to assert error messages never leak them. +const SECRET_CODE = "super-secret-authorization-code-PLAINTEXT" +const SECRET_VERIFIER = "super-secret-code-verifier-PLAINTEXT" +const SECRET_REFRESH = "super-secret-refresh-token-PLAINTEXT" +const SECRET_ACCESS = "super-secret-access-token-PLAINTEXT" + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +describe("exchangeAuthorizationCode", () => { + // Validates: Requirements 4.1, 4.2, 4.3, 10.9 + test("POSTs form-urlencoded body to /oauth2/token and maps expires_in to ms", async () => { + let requestUrl: string | undefined + let method: string | undefined + let contentType: string | undefined + let payload: URLSearchParams | undefined + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input) + method = init?.method + contentType = new Headers(init?.headers).get("content-type") ?? undefined + payload = new URLSearchParams(String(init?.body)) + return jsonResponse({ + access_token: "oauth-access-token", + refresh_token: "oauth-refresh-token", + token_type: "Bearer", + expires_in: 900, + }) + }) as typeof fetch + + const result = await exchangeAuthorizationCode( + "https://service.example.com", + "auth-code-1", + "verifier-1", + OAUTH_REDIRECT_URI, + ) + + expect(requestUrl).toBe("https://service.example.com/clickzetta-hornhub/oauth2/token") + expect(method).toBe("POST") + expect(contentType).toContain("application/x-www-form-urlencoded") + + expect(payload?.get("grant_type")).toBe("authorization_code") + expect(payload?.get("code")).toBe("auth-code-1") + expect(payload?.get("client_id")).toBe("official-cli") + expect(payload?.get("redirect_uri")).toBe(OAUTH_REDIRECT_URI) + expect(payload?.get("code_verifier")).toBe("verifier-1") + + expect(result.accessToken).toBe("oauth-access-token") + expect(result.refreshToken).toBe("oauth-refresh-token") + expect(result.tokenType).toBe("Bearer") + expect(result.expiresInMs).toBe(900_000) + }) + + // Validates: Requirements 10.8, 10.9 (Property 11) + test("sends the caller-supplied dynamic loopback redirect_uri verbatim", async () => { + let payload: URLSearchParams | undefined + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + payload = new URLSearchParams(String(init?.body)) + return jsonResponse({ + access_token: "oauth-access-token", + token_type: "Bearer", + expires_in: 900, + }) + }) as typeof fetch + + const dynamicRedirectUri = loopbackRedirectUri(54321) + await exchangeAuthorizationCode( + "https://service.example.com", + "auth-code-2", + "verifier-2", + dynamicRedirectUri, + ) + + expect(dynamicRedirectUri).toBe("http://127.0.0.1:54321/callback") + expect(payload?.get("redirect_uri")).toBe(dynamicRedirectUri) + }) + + // Validates: Requirements 7.4, 7.6 (Property 7) + test("rejects with InterfaceError on invalid_grant without leaking sensitive values", async () => { + globalThis.fetch = (async () => + jsonResponse({ error: "invalid_grant", error_description: "code expired" }, 400)) as typeof fetch + + const promise = exchangeAuthorizationCode( + "https://service.example.com", + SECRET_CODE, + SECRET_VERIFIER, + OAUTH_REDIRECT_URI, + ) + + await expect(promise).rejects.toBeInstanceOf(InterfaceError) + + const err = await promise.catch((e) => e as Error) + expect(err.message).not.toContain(SECRET_CODE) + expect(err.message).not.toContain(SECRET_VERIFIER) + }) + + // Validates: Requirements 7.1, 7.6 + test("rejects with InterfaceError on invalid_request", async () => { + globalThis.fetch = (async () => + jsonResponse({ error: "invalid_request" }, 400)) as typeof fetch + + await expect( + exchangeAuthorizationCode("https://service.example.com", SECRET_CODE, SECRET_VERIFIER, OAUTH_REDIRECT_URI), + ).rejects.toBeInstanceOf(InterfaceError) + }) + + // Validates: Requirements 7.2, 7.6 + test("rejects with InterfaceError on invalid_client", async () => { + globalThis.fetch = (async () => + jsonResponse({ error: "invalid_client" }, 401)) as typeof fetch + + await expect( + exchangeAuthorizationCode("https://service.example.com", SECRET_CODE, SECRET_VERIFIER, OAUTH_REDIRECT_URI), + ).rejects.toBeInstanceOf(InterfaceError) + }) + + // Validates: Requirements 7.3, 7.6 + test("rejects with InterfaceError on invalid_scope", async () => { + globalThis.fetch = (async () => + jsonResponse({ error: "invalid_scope" }, 400)) as typeof fetch + + await expect( + exchangeAuthorizationCode("https://service.example.com", SECRET_CODE, SECRET_VERIFIER, OAUTH_REDIRECT_URI), + ).rejects.toBeInstanceOf(InterfaceError) + }) +}) + +describe("refreshAccessToken", () => { + // Validates: Requirements 5.2, 5.3 + test("POSTs grant_type=refresh_token and returns rotated tokens", async () => { + let requestUrl: string | undefined + let method: string | undefined + let contentType: string | undefined + let payload: URLSearchParams | undefined + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input) + method = init?.method + contentType = new Headers(init?.headers).get("content-type") ?? undefined + payload = new URLSearchParams(String(init?.body)) + return jsonResponse({ + access_token: "rotated-access-token", + refresh_token: "rotated-refresh-token", + token_type: "Bearer", + expires_in: 600, + }) + }) as typeof fetch + + const result = await refreshAccessToken("https://service.example.com", "old-refresh-token") + + expect(requestUrl).toBe("https://service.example.com/clickzetta-hornhub/oauth2/token") + expect(method).toBe("POST") + expect(contentType).toContain("application/x-www-form-urlencoded") + + expect(payload?.get("grant_type")).toBe("refresh_token") + expect(payload?.get("refresh_token")).toBe("old-refresh-token") + expect(payload?.get("client_id")).toBe("official-cli") + + expect(result.accessToken).toBe("rotated-access-token") + expect(result.refreshToken).toBe("rotated-refresh-token") + expect(result.expiresInMs).toBe(600_000) + }) + + // Validates: Requirements 5.4, 7.4, 7.6 (Property 7) + test("rejects with InterfaceError on invalid_grant without leaking the refresh token", async () => { + globalThis.fetch = (async () => + jsonResponse({ error: "invalid_grant" }, 400)) as typeof fetch + + const promise = refreshAccessToken("https://service.example.com", SECRET_REFRESH) + + await expect(promise).rejects.toBeInstanceOf(InterfaceError) + + const err = await promise.catch((e) => e as Error) + expect(err.message).not.toContain(SECRET_REFRESH) + }) +}) + +describe("fetchUserInfo", () => { + // Validates: Requirements 6.1, 6.2 + test("GETs /oauth2/userinfo with Bearer header and returns the user info object", async () => { + let requestUrl: string | undefined + let method: string | undefined + let authHeader: string | undefined + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input) + method = init?.method ?? "GET" + authHeader = new Headers(init?.headers).get("authorization") ?? undefined + return jsonResponse({ sub: "user-7", name: "Alice", instance: "inst" }) + }) as typeof fetch + + const info = await fetchUserInfo("https://service.example.com", "the-access-token") + + expect(requestUrl).toBe("https://service.example.com/clickzetta-hornhub/oauth2/userinfo") + expect(method).toBe("GET") + expect(authHeader).toBe("Bearer the-access-token") + expect(info.sub).toBe("user-7") + expect(info.name).toBe("Alice") + }) + + // Validates: Requirements 6.4, 7.5, 7.6 (Property 7) + test("rejects with InterfaceError on invalid_token without leaking the access token", async () => { + globalThis.fetch = (async () => + jsonResponse({ error: "invalid_token" }, 401)) as typeof fetch + + const promise = fetchUserInfo("https://service.example.com", SECRET_ACCESS) + + await expect(promise).rejects.toBeInstanceOf(InterfaceError) + + const err = await promise.catch((e) => e as Error) + expect(err.message).not.toContain(SECRET_ACCESS) + }) +}) + +describe("loopbackRedirectUri", () => { + // Validates: Requirements 10.2, 10.9 + test("builds the loopback redirect_uri from the listening port", () => { + expect(loopbackRedirectUri(54321)).toBe("http://127.0.0.1:54321/callback") + expect(loopbackRedirectUri(8080)).toBe("http://127.0.0.1:8080/callback") + }) +}) diff --git a/packages/clickzetta-sdk/test/pkce.test.ts b/packages/clickzetta-sdk/test/pkce.test.ts new file mode 100644 index 000000000..6b432f74f --- /dev/null +++ b/packages/clickzetta-sdk/test/pkce.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" + +import { generatePkce } from "../src/auth/pkce.js" + +function base64Url(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") +} + +// RFC 7636 unreserved character set for code_verifier. +const UNRESERVED = /^[A-Za-z0-9\-._~]+$/ + +describe("generatePkce", () => { + // Property 1: PKCE 一致性 — codeChallenge == base64url(sha256(codeVerifier)), + // codeVerifier 长度 ∈ [43,128] 且仅含 unreserved 字符。 + // Validates: Requirements 2.1, 2.2 + test("codeChallenge equals base64url(sha256(codeVerifier)) with no padding", () => { + for (let i = 0; i < 100; i++) { + const pkce = generatePkce() + const expected = base64Url(createHash("sha256").update(pkce.codeVerifier).digest()) + expect(pkce.codeChallenge).toBe(expected) + expect(pkce.codeChallenge).not.toContain("=") + expect(pkce.codeChallenge).not.toContain("+") + expect(pkce.codeChallenge).not.toContain("/") + } + }) + + // Property 1: codeVerifier 长度 ∈ [43,128] 且仅含 RFC 7636 unreserved 字符。 + // Validates: Requirements 2.1 + test("codeVerifier length is within [43,128] and uses only unreserved characters", () => { + for (let i = 0; i < 100; i++) { + const pkce = generatePkce() + expect(pkce.codeVerifier.length).toBeGreaterThanOrEqual(43) + expect(pkce.codeVerifier.length).toBeLessThanOrEqual(128) + expect(pkce.codeVerifier).toMatch(UNRESERVED) + } + }) + + // Property 2: PKCE 唯一性 — 连续多次生成的 codeVerifier 互不相同。 + // Validates: Requirements 2.3 + test("multiple calls produce distinct codeVerifier values", () => { + const verifiers = Array.from({ length: 100 }, () => generatePkce().codeVerifier) + expect(new Set(verifiers).size).toBe(verifiers.length) + }) +}) diff --git a/packages/clickzetta-sdk/test/session.test.ts b/packages/clickzetta-sdk/test/session.test.ts index 2624777e4..6f9e8ec98 100644 --- a/packages/clickzetta-sdk/test/session.test.ts +++ b/packages/clickzetta-sdk/test/session.test.ts @@ -1,6 +1,16 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import type { AuthToken, ConnectionConfig } from "../src/types/index.js" +/** + * `mock.module` registrations persist for the rest of the process. Without + * restoring them, the `getToken` stub below leaks into later test files (e.g. + * token-refresh.test.ts would see `token: "tok-test"` instead of the real + * refresh behavior). Capture the real modules first and re-register them in + * `afterAll` to isolate the leak. + */ +const realToken = { ...(await import("../src/auth/token.js")) } +const realRegion = { ...(await import("../src/config/region.js")) } + mock.module("../src/auth/token.js", () => ({ getToken: async (): Promise => ({ token: "tok-test", @@ -15,6 +25,11 @@ mock.module("../src/config/region.js", () => ({ toServiceUrl: (_service: string, _protocol: string) => "https://test.invalid", })) +afterAll(() => { + mock.module("../src/auth/token.js", () => realToken) + mock.module("../src/config/region.js", () => realRegion) +}) + import { DEFAULT_MAXIMUM_TIMEOUT, SqlSession, diff --git a/packages/clickzetta-sdk/test/token-refresh.test.ts b/packages/clickzetta-sdk/test/token-refresh.test.ts new file mode 100644 index 000000000..8373e2e52 --- /dev/null +++ b/packages/clickzetta-sdk/test/token-refresh.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" + +import { clearTokenCache, getToken } from "../src/auth/token.js" +import type { ConnectionConfig } from "../src/types/index.js" + +/** + * Task 5.1 — token 续期测试 (Requirements 5.1, 5.3, 5.4, 5.5; Property 5). + * + * These tests exercise the *public* token seam (`getToken` / `clearTokenCache`) + * and only stub `globalThis.fetch` and the clock (`Date.now`). No business + * logic is mocked — real `login.ts` / `oauth.ts` / `token.ts` run. + * + * How expiry is driven deterministically (no real waiting): + * `isTokenExpired` compares `Date.now() - token.obtainedAt` against + * `token.expireTimeMs * EXPIRED_FACTOR` (0.8). We stub `Date.now` with a + * fake clock `now`. The cached token's `obtainedAt` is frozen at the value + * of `now` when it was fetched; advancing `now` past `expireTimeMs * 0.8` + * makes the cached token expired on the next `getToken` call. + * + * The refresh behavior under test is implemented in Task 5.2, so the + * refresh-path assertions are expected to FAIL until then (red state). + */ + +const originalFetch = globalThis.fetch +const originalDateNow = Date.now +let now = 1_000_000 + +beforeEach(() => { + now = 1_000_000 + Date.now = () => now +}) + +afterEach(() => { + globalThis.fetch = originalFetch + Date.now = originalDateNow + clearTokenCache() +}) + +function config(): ConnectionConfig { + return { + pat: "", + username: "user", + password: "pass", + service: "dev-api.clickzetta.com", + protocol: "https", + instance: "inst", + workspace: "", + schema: "public", + vcluster: "default", + } +} + +interface TokenResult { + body: Record + status?: number +} + +interface StubCalls { + login: number + tokenGrants: string[] + refreshTokensSent: Array +} + +/** + * Install a `globalThis.fetch` stub driven by two handlers and return a + * counters object so tests can assert which endpoints were hit and with what. + * - `login()` returns the `data` field for /clickzetta-portal/user/loginSingle + * - `token(params)` returns the body/status for /oauth2/token + */ +function buildFetch(handlers: { + login: () => Record + token: (params: URLSearchParams) => TokenResult +}): StubCalls { + const calls: StubCalls = { login: 0, tokenGrants: [], refreshTokensSent: [] } + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === "/clickzetta-portal/user/loginSingle" && init?.method === "POST") { + calls.login += 1 + return new Response(JSON.stringify({ code: 0, data: handlers.login() }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (url.pathname === "/clickzetta-hornhub/oauth2/token" && init?.method === "POST") { + const params = new URLSearchParams(String(init.body)) + const grant = params.get("grant_type") ?? "" + calls.tokenGrants.push(grant) + if (grant === "refresh_token") calls.refreshTokensSent.push(params.get("refresh_token")) + const result = handlers.token(params) + return new Response(JSON.stringify(result.body), { + status: result.status ?? 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + return calls +} + +describe("token refresh (getToken)", () => { + test("Scenario A: expired token with refreshToken refreshes instead of full login", async () => { + let refreshCount = 0 + const calls = buildFetch({ + login: () => ({ + token: "legacy", + authorizationCode: "auth-code-1", + userId: 7, + instanceId: 9, + expireTime: 999, + }), + token: (params) => { + if (params.get("grant_type") === "authorization_code") { + return { + body: { + access_token: "access-1", + refresh_token: "refresh-1", + token_type: "Bearer", + expires_in: 900, + }, + } + } + // grant_type=refresh_token → rotate + refreshCount += 1 + return { + body: { + access_token: `access-${refreshCount + 1}`, + refresh_token: `refresh-${refreshCount + 1}`, + token_type: "Bearer", + expires_in: 900, + }, + } + }, + }) + + const cfg = config() + const first = await getToken(cfg) + expect(first.token).toBe("access-1") + expect(first.refreshToken).toBe("refresh-1") + expect(calls.login).toBe(1) + + // Advance past expiry: 0.8 * 900000 = 720000ms. + now += 800_000 + + const second = await getToken(cfg) + // The refresh endpoint must be hit with the cached refresh token... + expect(calls.tokenGrants).toContain("refresh_token") + expect(calls.refreshTokensSent).toEqual(["refresh-1"]) + // ...and NO new portal login should happen on the refresh path. + expect(calls.login).toBe(1) + expect(second.token).toBe("access-2") + expect(second.refreshToken).toBe("refresh-2") + }) + + test("Scenario B: subsequent refresh uses the latest rotated refresh token", async () => { + let refreshCount = 0 + const calls = buildFetch({ + login: () => ({ + token: "legacy", + authorizationCode: "auth-code-1", + userId: 7, + instanceId: 9, + expireTime: 999, + }), + token: (params) => { + if (params.get("grant_type") === "authorization_code") { + return { + body: { + access_token: "access-1", + refresh_token: "refresh-1", + token_type: "Bearer", + expires_in: 900, + }, + } + } + refreshCount += 1 + return { + body: { + access_token: `access-${refreshCount + 1}`, + refresh_token: `refresh-${refreshCount + 1}`, + token_type: "Bearer", + expires_in: 900, + }, + } + }, + }) + + const cfg = config() + await getToken(cfg) // access-1 / refresh-1 + + now += 800_000 + const second = await getToken(cfg) // refresh-1 → access-2 / refresh-2 + expect(second.refreshToken).toBe("refresh-2") + + now += 800_000 + const third = await getToken(cfg) // refresh-2 → access-3 / refresh-3 + expect(third.refreshToken).toBe("refresh-3") + + // Each refresh used the most recently rotated refresh token. + expect(calls.refreshTokensSent).toEqual(["refresh-1", "refresh-2"]) + expect(calls.login).toBe(1) + }) + + test("Scenario C: refresh failure (invalid_grant) clears cache and falls back to full login", async () => { + let loginPhase = 0 + const calls = buildFetch({ + login: () => { + loginPhase += 1 + return { + token: "legacy", + authorizationCode: `auth-code-${loginPhase}`, + userId: 7, + instanceId: 9, + expireTime: 999, + } + }, + token: (params) => { + if (params.get("grant_type") === "refresh_token") { + return { body: { error: "invalid_grant" }, status: 400 } + } + // authorization_code exchange, vary per login phase + return { + body: { + access_token: `access-p${loginPhase}`, + refresh_token: `refresh-p${loginPhase}`, + token_type: "Bearer", + expires_in: 900, + }, + } + }, + }) + + const cfg = config() + const first = await getToken(cfg) + expect(first.token).toBe("access-p1") + expect(calls.login).toBe(1) + + now += 800_000 + const second = await getToken(cfg) + // A refresh was attempted... + expect(calls.tokenGrants).toContain("refresh_token") + // ...and after it failed, a full portal login was performed as fallback. + expect(calls.login).toBe(2) + expect(second.token).toBe("access-p2") + }) + + test("Scenario D: expired legacy token (no refreshToken) re-logs in, never calls /oauth2/token", async () => { + const calls = buildFetch({ + // No authorizationCode → legacy token path, AuthToken has no refreshToken. + login: () => ({ + token: "legacy-token", + userId: 7, + instanceId: 9, + expireTime: 900_000, + }), + token: () => ({ body: { error: "should_not_be_called" }, status: 400 }), + }) + + const cfg = config() + const first = await getToken(cfg) + expect(first.token).toBe("legacy-token") + expect(first.refreshToken).toBeUndefined() + expect(calls.login).toBe(1) + + // Advance past expiry: 0.8 * 900000 = 720000ms. + now += 800_000 + const second = await getToken(cfg) + // No OAuth refresh attempt for a legacy token... + expect(calls.tokenGrants.length).toBe(0) + // ...just a fresh full login. + expect(calls.login).toBe(2) + expect(second.token).toBe("legacy-token") + }) +}) diff --git a/packages/clickzetta-sdk/test/token-store.test.ts b/packages/clickzetta-sdk/test/token-store.test.ts new file mode 100644 index 000000000..d01a7e746 --- /dev/null +++ b/packages/clickzetta-sdk/test/token-store.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" + +import { clearTokenCache, getToken } from "../src/auth/token.js" +import type { AuthToken, ConnectionConfig, TokenStore } from "../src/types/index.js" + +/** + * Task 8.1 — TokenStore persistence integration + * (Requirements 9.1, 9.3, 9.4, 9.5, 9.7; Properties 8, 9, 10). + * + * These tests exercise the *public* token seam (`getToken` / `clearTokenCache`) + * with an injected in-memory fake `TokenStore`. Only `globalThis.fetch` and the + * clock (`Date.now`) are stubbed — real `login.ts` / `oauth.ts` / `token.ts` + * run. No business logic is mocked. + * + * Expiry is driven deterministically by the fake clock `now` (see + * token-refresh.test.ts for the mechanics): `isTokenExpired` compares + * `Date.now() - token.obtainedAt` against `token.expireTimeMs * 0.8`. + */ + +const originalFetch = globalThis.fetch +const originalDateNow = Date.now +let now = 1_000_000 + +beforeEach(() => { + now = 1_000_000 + Date.now = () => now +}) + +afterEach(() => { + globalThis.fetch = originalFetch + Date.now = originalDateNow + clearTokenCache() +}) + +function config(store?: TokenStore): ConnectionConfig { + return { + pat: "", + username: "user", + password: "pass", + service: "dev-api.clickzetta.com", + protocol: "https", + instance: "inst", + workspace: "", + schema: "public", + vcluster: "default", + tokenStore: store, + } +} + +/** + * A minimal in-memory TokenStore that records how often save/clear/load were + * called so tests can assert persistence interactions. + */ +interface FakeStore extends TokenStore { + current: AuthToken | undefined + loadCount: number + saveCount: number + clearCount: number + saved: AuthToken[] +} + +function makeStore(seed?: AuthToken): FakeStore { + const store: FakeStore = { + current: seed, + loadCount: 0, + saveCount: 0, + clearCount: 0, + saved: [], + load() { + this.loadCount += 1 + return this.current + }, + save(token: AuthToken) { + this.saveCount += 1 + this.saved.push(token) + this.current = token + }, + clear() { + this.clearCount += 1 + this.current = undefined + }, + } + return store +} + +interface TokenResult { + body: Record + status?: number +} + +interface StubCalls { + login: number + tokenGrants: string[] + refreshTokensSent: Array +} + +function buildFetch(handlers: { + login: () => Record + token: (params: URLSearchParams) => TokenResult +}): StubCalls { + const calls: StubCalls = { login: 0, tokenGrants: [], refreshTokensSent: [] } + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === "/clickzetta-portal/user/loginSingle" && init?.method === "POST") { + calls.login += 1 + return new Response(JSON.stringify({ code: 0, data: handlers.login() }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (url.pathname === "/clickzetta-hornhub/oauth2/token" && init?.method === "POST") { + const params = new URLSearchParams(String(init.body)) + const grant = params.get("grant_type") ?? "" + calls.tokenGrants.push(grant) + if (grant === "refresh_token") calls.refreshTokensSent.push(params.get("refresh_token")) + const result = handlers.token(params) + return new Response(JSON.stringify(result.body), { + status: result.status ?? 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + return calls +} + +describe("token store persistence (getToken)", () => { + test("persisted-and-valid: reuses persisted token without any HTTP call [Property 8 / Req 9.3]", async () => { + const persisted: AuthToken = { + token: "persisted-access", + refreshToken: "persisted-refresh", + instanceId: 9, + userId: 7, + expireTimeMs: 900_000, + obtainedAt: now, // fresh → not expired + } + const store = makeStore(persisted) + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls += 1 + return new Response("not found", { status: 404 }) + }) as typeof fetch + + clearTokenCache() // empty memory cache + const token = await getToken(config(store)) + + expect(token.token).toBe("persisted-access") + expect(token.refreshToken).toBe("persisted-refresh") + expect(fetchCalls).toBe(0) + expect(store.loadCount).toBeGreaterThanOrEqual(1) + }) + + test("persisted-but-expired: refreshes via persisted refresh token and saves rotated value [Property 9 / Req 9.4]", async () => { + const persisted: AuthToken = { + token: "old-access", + refreshToken: "r1", + instanceId: 9, + userId: 7, + expireTimeMs: 900_000, + obtainedAt: 0, // elapsed 1_000_000 > 720_000 → expired + } + const store = makeStore(persisted) + const calls = buildFetch({ + login: () => ({ + token: "legacy", + authorizationCode: "auth-code", + userId: 7, + instanceId: 9, + expireTime: 999, + }), + token: (params) => { + expect(params.get("grant_type")).toBe("refresh_token") + return { + body: { + access_token: "new-access", + refresh_token: "r2", + token_type: "Bearer", + expires_in: 900, + }, + } + }, + }) + + clearTokenCache() + const token = await getToken(config(store)) + + expect(calls.refreshTokensSent).toEqual(["r1"]) + expect(calls.login).toBe(0) // no portal login on refresh path + expect(token.token).toBe("new-access") + expect(token.refreshToken).toBe("r2") + // store.save was called with the rotated refresh token. + expect(store.saveCount).toBeGreaterThanOrEqual(1) + expect(store.current?.refreshToken).toBe("r2") + expect(store.current?.token).toBe("new-access") + }) + + test("refresh failure: clears store then performs full portal login [Req 9.5]", async () => { + const persisted: AuthToken = { + token: "old-access", + refreshToken: "r1", + instanceId: 9, + userId: 7, + expireTimeMs: 900_000, + obtainedAt: 0, // expired + } + const store = makeStore(persisted) + const calls = buildFetch({ + login: () => ({ + token: "legacy", + authorizationCode: "auth-code", + userId: 7, + instanceId: 9, + expireTime: 999, + }), + token: (params) => { + if (params.get("grant_type") === "refresh_token") { + return { body: { error: "invalid_grant" }, status: 400 } + } + return { + body: { + access_token: "login-access", + refresh_token: "login-refresh", + token_type: "Bearer", + expires_in: 900, + }, + } + }, + }) + + clearTokenCache() + const token = await getToken(config(store)) + + expect(calls.tokenGrants).toContain("refresh_token") + expect(store.clearCount).toBeGreaterThanOrEqual(1) + expect(calls.login).toBe(1) // fell back to full login + expect(token.token).toBe("login-access") + // store ends cleared-or-overwritten by the new login token. + expect(store.current?.token).toBe("login-access") + }) + + test("save after fresh login: empty store gets save called with the OAuth token [Req 9.1]", async () => { + const store = makeStore(undefined) + const calls = buildFetch({ + login: () => ({ + token: "legacy", + authorizationCode: "auth-code", + userId: 7, + instanceId: 9, + expireTime: 999, + }), + token: () => ({ + body: { + access_token: "fresh-access", + refresh_token: "fresh-refresh", + token_type: "Bearer", + expires_in: 900, + }, + }), + }) + + clearTokenCache() + const token = await getToken(config(store)) + + expect(calls.login).toBe(1) + expect(token.token).toBe("fresh-access") + expect(store.saveCount).toBeGreaterThanOrEqual(1) + expect(store.current?.token).toBe("fresh-access") + expect(store.current?.refreshToken).toBe("fresh-refresh") + }) + + test("backward compat: no tokenStore behaves as before, in-memory cache only [Property 10 / Req 9.7]", async () => { + const calls = buildFetch({ + login: () => ({ + token: "legacy", + authorizationCode: "auth-code", + userId: 7, + instanceId: 9, + expireTime: 999, + }), + token: () => ({ + body: { + access_token: "access-1", + refresh_token: "refresh-1", + token_type: "Bearer", + expires_in: 900, + }, + }), + }) + + const cfg = config(undefined) + const first = await getToken(cfg) + expect(first.token).toBe("access-1") + expect(calls.login).toBe(1) + + // Second call within validity window → served from memory, no new login. + const second = await getToken(cfg) + expect(second.token).toBe("access-1") + expect(calls.login).toBe(1) + }) +}) diff --git a/packages/clickzetta-sdk/test/token.test.ts b/packages/clickzetta-sdk/test/token.test.ts index 12652fd48..03b6f55f0 100644 --- a/packages/clickzetta-sdk/test/token.test.ts +++ b/packages/clickzetta-sdk/test/token.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" import type { AuthToken, ConnectionConfig } from "../src/types/index.js" @@ -6,8 +6,16 @@ import type { AuthToken, ConnectionConfig } from "../src/types/index.js" * We mock the login + region modules BEFORE importing token.ts so * the module picks up our stubs. This verifies that N concurrent * `getToken` callers coalesce onto a single login call. + * + * `mock.module` registrations persist for the rest of the process and would + * otherwise leak into later test files (e.g. login-oauth.test.ts would import + * the never-resolving `loginWithPassword` stub below and time out). We capture + * the real modules first and re-register them in `afterAll` to isolate the leak. */ +const realLogin = { ...(await import("../src/auth/login.js")) } +const realRegion = { ...(await import("../src/config/region.js")) } + let loginCalls = 0 let loginResolver: ((t: AuthToken) => void) | undefined @@ -35,6 +43,12 @@ const { getToken, clearTokenCache, forceRefreshToken } = await import( "../src/auth/token.js" ) +// Restore the real modules so the leak does not break later test files. +afterAll(() => { + mock.module("../src/auth/login.js", () => realLogin) + mock.module("../src/config/region.js", () => realRegion) +}) + function freshConfig(seed: number): ConnectionConfig { return { pat: `pat-${seed}`, diff --git a/packages/cz-cli/src/cli.ts b/packages/cz-cli/src/cli.ts index 5121f10ac..3dc804f50 100644 --- a/packages/cz-cli/src/cli.ts +++ b/packages/cz-cli/src/cli.ts @@ -135,7 +135,7 @@ export function createCli(args: string[]) { const aiMessage = "Run the command with --help to see available options and usage." const message = (msg && msg.trim() !== "") ? msg : (() => { const KNOWN_FLAGS = new Set(["profile", "p", "jdbc", "pat", "username", "password", "service", "protocol", "instance", "workspace", "schema", "s", "vcluster", "v", "format", "field", "debug", "d", "help", "h", "version", "target", "t"]) - const KNOWN_COMMANDS = new Set(["sql", "schema", "table", "workspace", "status", "profile", "task", "runs", "attempts", "job", "agent", "serve", "setup", "update", "datasource", "ai-gateway", "analytics-agent"]) + const KNOWN_COMMANDS = new Set(["sql", "schema", "table", "workspace", "status", "login", "profile", "task", "runs", "attempts", "job", "agent", "serve", "setup", "update", "datasource", "ai-gateway", "analytics-agent"]) const unknownFlags = args.filter((a) => a.startsWith("-")).map((a) => a.replace(/^-+/, "").split("=")[0]).filter((a) => !KNOWN_FLAGS.has(a)) if (unknownFlags.length > 0) return `Unknown argument: ${unknownFlags[0]}` const topLevelCmd = args.find((a) => !a.startsWith("-")) diff --git a/packages/cz-cli/src/commands/account-login.ts b/packages/cz-cli/src/commands/account-login.ts index 24fcd182d..08991a9e7 100644 --- a/packages/cz-cli/src/commands/account-login.ts +++ b/packages/cz-cli/src/commands/account-login.ts @@ -15,7 +15,7 @@ interface AccountSiteLoginResult { token: string } -function splitEndpoint(value: string): { host: string; protocol: string } { +export function splitEndpoint(value: string): { host: string; protocol: string } { const raw = value.trim() if (!raw) return { host: "", protocol: "https" } if (raw.startsWith("http://") || raw.startsWith("https://")) { @@ -29,7 +29,7 @@ export function stripProtocol(value: string): string { return splitEndpoint(value).host } -function extractRootDomain(host: string): string { +export function extractRootDomain(host: string): string { for (const suffix of [".clickzetta.com", ".singdata.com", ".clickzetta-inc.com"]) { if (host.endsWith(suffix)) return suffix.slice(1) } @@ -75,7 +75,7 @@ function serviceHostFromInput(host: string): string { return host } -function serviceEnvFromApiHost(host: string): string { +export function serviceEnvFromApiHost(host: string): string { const hyphenEnv = host.match(/^([^.]+)-api\./) if (hyphenEnv?.[1]) return hyphenEnv[1] const dottedEnv = host.match(/^([^.]+)\.api\./) diff --git a/packages/cz-cli/src/commands/exec.ts b/packages/cz-cli/src/commands/exec.ts index 0c679b52e..99d33cd5c 100644 --- a/packages/cz-cli/src/commands/exec.ts +++ b/packages/cz-cli/src/commands/exec.ts @@ -95,8 +95,25 @@ async function getCookieToken(config: ConnectionConfig): Promise): Promise { const config = resolveConnectionConfig(args) + // Keep the "Authentication required" prefix: classifyExecError maps it to NO_CREDENTIALS. + if (!hasUsableCredentials(config)) { + throw new Error("Authentication required. Provide --pat or --username/--password, run `cz-cli login --browser` for OAuth, or run `cz-cli setup` to configure a connection profile.") + } if (!config.instance) { throw new Error("Instance is required. Provide --instance or configure it in your profile.") } diff --git a/packages/cz-cli/src/commands/login-browser.ts b/packages/cz-cli/src/commands/login-browser.ts new file mode 100644 index 000000000..4892a1268 --- /dev/null +++ b/packages/cz-cli/src/commands/login-browser.ts @@ -0,0 +1,185 @@ +import { spawn } from "node:child_process" + +import { + buildOauthLoginParam, + encodeOauthLoginParam, + exchangeAuthorizationCode, + fetchUserInfo, + generatePkce, + startLoopbackCallback, + type AuthToken, +} from "@clickzetta/sdk" + +/** + * Generate a random hex `state` for CSRF protection of the authorize round + * trip (requirement 10.3/10.6). It is used once for callback validation and is + * not a secret. + */ +function randomState(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(16))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +/** + * Best-effort cross-platform system browser opener (design Addendum 2, step 4). + * darwin → `open`, win32 → `start ""`, else `xdg-open`. Failures are swallowed: + * the authorize URL is already printed to the terminal so the user can open it + * manually while the loopback listener keeps waiting (requirement 10.5). + */ +function openSystemBrowser(url: string): void { + const isWindows = process.platform === "win32" + const command = process.platform === "darwin" ? "open" : isWindows ? "start" : "xdg-open" + // Windows `start` is a shell builtin and treats the first quoted arg as the + // window title, hence the leading empty string. + const args = isWindows ? ["", url] : [url] + try { + const child = spawn(command, args, { stdio: "ignore", detached: true, shell: isWindows }) + child.on("error", () => {}) + child.unref() + } catch { + // best-effort: ignore spawn failures, the URL was already printed + } +} + +/** + * Connection context parsed from the `/oauth2/userinfo` response. Carried back + * to `runLogin` so it can backfill the persisted token (userId/instanceId) and + * write the logged-in context (instance/workspace/schema/vcluster/accountName) + * into the current profile. All fields are optional: userinfo is best-effort. + */ +export interface BrowserLoginResult { + token: AuthToken + userInfo?: { + instanceName?: string + workspace?: string + schema?: string + vcluster?: string + accountName?: string + accountId?: number + userId?: number + instanceId?: number + } + // The unmodified `/oauth2/userinfo` body, present only when userinfo + // succeeded. Archived verbatim into the profile so nothing is discarded. + raw?: Record +} + +function isOauthDebug(): boolean { + const flag = process.env.CZ_OAUTH_DEBUG + return flag === "1" || flag === "true" +} + +function str(val: unknown): string | undefined { + return typeof val === "string" && val.length > 0 ? val : undefined +} + +/** + * Map the raw `/oauth2/userinfo` body to our connection context per the + * confirmed dev response shape. `userId` falls back to parsing `sub`; both id + * fields guard against NaN/absent so callers can decide whether to override. + */ +function parseUserInfo(body: Record): BrowserLoginResult["userInfo"] { + const userId = typeof body.userId === "number" ? body.userId : parseInt(String(body.sub), 10) + const instanceList = Array.isArray(body.instanceList) ? (body.instanceList as Array>) : [] + const firstInstance = instanceList[0] + const instanceId = firstInstance && typeof firstInstance.id === "number" ? firstInstance.id : 0 + const instanceName = str(body.instanceName) ?? (firstInstance ? str(firstInstance.name) : undefined) + + return { + instanceName, + workspace: str(body.workspaceName), + schema: str(body.schema), + vcluster: str(body.virtualCluster), + accountName: str(body.accountName), + accountId: typeof body.account_id === "number" ? body.account_id : undefined, + userId: Number.isNaN(userId) ? undefined : userId, + instanceId, + } +} + +export interface LoginWithBrowserOptions { + // Service base URL used to exchange the code (toServiceUrl(service, protocol)). + baseUrl: string + // Accounts login-site base URL (accountsBaseUrl(service)). + accountsBaseUrl: string + // Injectable browser opener; defaults to the system browser. Tests inject a + // fake that drives the loopback callback. + openBrowser?: (url: string) => void + // Optional callback timeout in ms (forwarded to startLoopbackCallback). + timeoutMs?: number +} + +/** + * Browser loopback OAuth login orchestration (design Addendum 2, component I; + * requirements 10.1/10.5/10.8/10.10). Callers MUST only invoke this when + * `isLocalCallbackEnabled()` is true; otherwise the existing default path runs. + * + * Flow: generate PKCE + state → start the loopback listener to learn the + * dynamic `redirect_uri` → build the authorize URL and open the browser (also + * printing the URL) → wait for the validated `code` on the loopback → exchange + * the code for tokens using the SAME `redirect_uri` (Property 11). On any + * failure the listener is closed so it never leaks. Never logs `code_verifier`, + * the authorization code, or tokens — printing the authorize URL is fine since + * it only carries `code_challenge` + `state`. + */ +export async function loginWithBrowser(opts: LoginWithBrowserOptions): Promise { + const pkce = generatePkce() + const state = randomState() + const cb = await startLoopbackCallback({ expectedState: state, timeoutMs: opts.timeoutMs }) + + try { + const authorizeUrl = + `${opts.accountsBaseUrl}/login?oauthLoginParam=` + + encodeURIComponent( + encodeOauthLoginParam( + buildOauthLoginParam({ redirectUri: cb.redirectUri, codeChallenge: pkce.codeChallenge, state }), + ), + ) + + console.log(`Open this URL in your browser to sign in:\n${authorizeUrl}`) + ;(opts.openBrowser ?? openSystemBrowser)(authorizeUrl) + + const code = await cb.waitForCode() + const result = await exchangeAuthorizationCode(opts.baseUrl, code, pkce.codeVerifier, cb.redirectUri) + + const token: AuthToken = { + token: result.accessToken, + refreshToken: result.refreshToken, + // expireTimeMs is a DURATION in ms (expires_in * 1000), matching the + // login.ts OAuth path and token.ts isTokenExpired semantics + // (elapsed = now - obtainedAt > expireTimeMs * EXPIRED_FACTOR). It must + // NOT be an absolute timestamp. + expireTimeMs: result.expiresInMs, + obtainedAt: Date.now(), + // Populated below from userinfo when available; default to 0 so legacy + // consumers and the token store keep working when userinfo is missing. + instanceId: 0, + userId: 0, + } + + // Best-effort userinfo backfill (requirement 11.6/11.7). A userinfo failure + // must NOT fail the login: keep the token, leave userId/instanceId at 0. + let userInfo: BrowserLoginResult["userInfo"] + let raw: Record | undefined + try { + const body = await fetchUserInfo(opts.baseUrl, result.accessToken) + raw = body + const parsed = parseUserInfo(body) + if (isOauthDebug()) console.error(`[oauth-userinfo] keys=[${Object.keys(parsed ?? {}).join(",")}]`) + userInfo = parsed + // Only override when the parsed identity is a real, positive value. + if (parsed?.userId !== undefined && parsed.userId > 0) token.userId = parsed.userId + if (parsed?.instanceId !== undefined && parsed.instanceId > 0) token.instanceId = parsed.instanceId + } catch (err) { + if (isOauthDebug()) console.error(`[oauth-userinfo] failed: ${err instanceof Error ? err.message : String(err)}`) + } + + return { token, userInfo, raw } + } catch (err) { + // Ensure the listener never leaks if we fail before/after waitForCode + // settles. close() is a no-op once the core already settled. + cb.close() + throw err + } +} diff --git a/packages/cz-cli/src/commands/login.ts b/packages/cz-cli/src/commands/login.ts new file mode 100644 index 000000000..7e6b3005b --- /dev/null +++ b/packages/cz-cli/src/commands/login.ts @@ -0,0 +1,114 @@ +import type { Argv } from "yargs" +import { isLocalCallbackEnabled, toServiceUrl, type ConnectionConfig } from "@clickzetta/sdk" +import type { GlobalArgs } from "../cli.js" +import { error, success, EXIT_USAGE_ERROR } from "../output/index.js" +import { resolveConnectionConfig } from "../connection/config.js" +import { accountsBaseUrl } from "../connection/accounts-url.js" +import { makeProfileTokenStore, patchProfileConnection, patchProfileUserInfo } from "../connection/profile-store.js" +import { loginWithBrowser, type BrowserLoginResult } from "./login-browser.js" + +interface LoginArgs extends GlobalArgs { + browser?: boolean +} + +// Dependency seam for tests: the yargs handler always uses the real imports, +// while unit tests inject fakes (a fake browser login + a fake config carrying +// a spy tokenStore) to exercise the orchestration without real network/browser. +export interface RunLoginDeps { + loginWithBrowser?: (opts: { baseUrl: string; accountsBaseUrl: string }) => Promise + resolveConnectionConfig?: (argv: LoginArgs) => ConnectionConfig + accountsBaseUrl?: (service: string) => string +} + +/** + * `cz-cli login` orchestration (requirement 11). Browser OAuth login is the + * entry point: it runs when `--browser` is passed or `CZ_OAUTH_LOCAL_CALLBACK` + * is enabled. Otherwise it prints guidance and exits with a usage error + * WITHOUT touching the existing default login path (requirement 11.5). + * + * On success the token is persisted via the profile-backed tokenStore + * (requirement 11.3) and a result is printed that MUST NOT echo the + * access_token / refresh_token. On failure nothing is persisted (requirement + * 11.4) and a non-zero exit code is set. + */ +export async function runLogin(argv: LoginArgs, deps: RunLoginDeps = {}): Promise { + const resolve = deps.resolveConnectionConfig ?? resolveConnectionConfig + const doBrowserLogin = deps.loginWithBrowser ?? loginWithBrowser + const toAccountsBaseUrl = deps.accountsBaseUrl ?? accountsBaseUrl + + const cfg = resolve(argv) + const useBrowser = argv.browser === true || isLocalCallbackEnabled() + + if (!useBrowser) { + error( + "LOGIN_MODE_REQUIRED", + "Browser login is the entry point for `cz-cli login`. Re-run with --browser or set CZ_OAUTH_LOCAL_CALLBACK=1.", + { format: argv.format, exitCode: EXIT_USAGE_ERROR }, + ) + return + } + + try { + const { token, userInfo, raw } = await doBrowserLogin({ + baseUrl: toServiceUrl(cfg.service, cfg.protocol), + accountsBaseUrl: toAccountsBaseUrl(cfg.service), + }) + + // The userinfo response may carry a different instance than the one used to + // resolve config; prefer it so persistence lines up with reality. + const finalInstance = userInfo?.instanceName || cfg.instance + + // Persist the logged-in connection context into the profile (requirement + // 11.6/11.7). Best-effort, never throws. + patchProfileConnection(argv.profile, { + service: cfg.service, + protocol: cfg.protocol, + instance: finalInstance, + workspace: userInfo?.workspace, + schema: userInfo?.schema, + vcluster: userInfo?.vcluster, + userId: token.userId || undefined, + accountId: userInfo?.accountId, + accountName: userInfo?.accountName, + }) + + // Archive the FULL userinfo body verbatim so nothing is discarded + // (requirement 11.9). Best-effort, never throws. + if (raw) patchProfileUserInfo(argv.profile, raw) + + // Persist the token under the instance-only slot so a subsequent + // resolveConnectionConfig (which keys the store on the instance) finds it. + // We rebuild the store here because the instance may have changed from what + // cfg.tokenStore was keyed on (requirement 11.3/11.6). + makeProfileTokenStore(argv.profile, finalInstance).save(token) + + success( + { + logged_in: true, + instance: finalInstance || null, + workspace: userInfo?.workspace || null, + user_id: token.userId || null, + expires_in_ms: token.expireTimeMs, + }, + { format: argv.format }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + error("LOGIN_FAILED", msg, { format: argv.format, debug: argv.debug }) + } +} + +export function registerLoginCommand(cli: Argv): void { + cli.command( + "login", + "Sign in via browser-based OAuth and persist the token to the current profile", + (y) => + y.option("browser", { + type: "boolean", + describe: "Use browser-based OAuth login", + }), + async (argv) => { + await runLogin(argv as LoginArgs) + }, + ) +} diff --git a/packages/cz-cli/src/connection/accounts-url.ts b/packages/cz-cli/src/connection/accounts-url.ts new file mode 100644 index 000000000..60af0af8c --- /dev/null +++ b/packages/cz-cli/src/connection/accounts-url.ts @@ -0,0 +1,18 @@ +import { extractRootDomain, serviceEnvFromApiHost, splitEndpoint } from "../commands/account-login" + +function stripTrailingSlash(value: string): string { + return value.trim().replace(/\/+$/, "") +} + +// Derive the accounts login-site base URL for a given service host. +// Precedence: CZ_OAUTH_ACCOUNTS_URL override → derive from service host. +// prod → https://accounts.; dev/sit/uat → https://-accounts.. +export function accountsBaseUrl(service: string): string { + const override = process.env.CZ_OAUTH_ACCOUNTS_URL + if (override?.trim()) return stripTrailingSlash(override) + + const host = splitEndpoint(service).host + const rootDomain = extractRootDomain(host) + const env = serviceEnvFromApiHost(host) + return env ? `https://${env}-accounts.${rootDomain}` : `https://accounts.${rootDomain}` +} diff --git a/packages/cz-cli/src/connection/config.ts b/packages/cz-cli/src/connection/config.ts index a88f53a4a..32c405115 100644 --- a/packages/cz-cli/src/connection/config.ts +++ b/packages/cz-cli/src/connection/config.ts @@ -1,5 +1,5 @@ import { DEFAULT_CONNECTION, type ConnectionConfig } from "@clickzetta/sdk" -import { getProfileConfig } from "./profile-store.js" +import { getProfileConfig, makeProfileTokenStore } from "./profile-store.js" import { parseJdbcUrl } from "./jdbc.js" export interface CliArgs { @@ -85,6 +85,17 @@ export function resolveConnectionConfig(cliArgs: Partial = {}): Connect cfg.customHeaders = { ...profileCfg.customHeaders, ...cfg.customHeaders } } + // Attach a profile-backed OAuth token store so callers routing through this + // function (exec.ts, studio-context.ts) get cross-process persistence + // (requirement 9.3, 9.7). The OAuth token represents the user's own login, + // so the slot is keyed by INSTANCE ONLY (not pat/username): removing or + // rotating a pat must not orphan the persisted token (requirement 9.6/11.6). + // The store is self-keyed — the SDK calls load/save/clear on it without + // re-deriving a key — so this key need not mirror token.ts's in-memory key. + if (cfg.instance) { + cfg.tokenStore = makeProfileTokenStore(profileName, cfg.instance) + } + return cfg } diff --git a/packages/cz-cli/src/connection/profile-store.ts b/packages/cz-cli/src/connection/profile-store.ts index 06b50bb33..899fa7ab0 100644 --- a/packages/cz-cli/src/connection/profile-store.ts +++ b/packages/cz-cli/src/connection/profile-store.ts @@ -2,7 +2,7 @@ import { readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync } from "n import { homedir } from "node:os" import { join, dirname } from "node:path" import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml" -import { DEFAULT_CONNECTION, type ConnectionConfig } from "@clickzetta/sdk" +import { DEFAULT_CONNECTION, type ConnectionConfig, type TokenStore, type AuthToken } from "@clickzetta/sdk" function profilesFile() { return join(process.env.CLICKZETTA_TEST_HOME || homedir(), ".clickzetta", "profiles.toml") @@ -177,8 +177,7 @@ export function setTelemetry(enabled: boolean): void { * Write userId into the active profile entry so it can be used as enduser.id * in telemetry. No-op if the profile already has user_id — never throws. */ -export function patchProfileUserId(profileName: string | undefined, userId: number): void { - try { +export function patchProfileUserId(profileName: string | undefined, userId: number): void { try { const text = readFileSync(profilesFile(), "utf-8") const data = parseTOML(text) as Record const profiles = (data.profiles ?? {}) as Record> @@ -198,3 +197,203 @@ export function patchProfileUserId(profileName: string | undefined, userId: numb // best-effort: never block the CLI } } + +/** + * Merge logged-in connection context into the active profile entry so a later + * `resolveConnectionConfig` picks up the instance/workspace/schema/etc. the + * user actually authenticated against (requirement 11.6/11.7). Resolves the + * target profile the same way other helpers do (explicit → default_profile → + * first profile); no-op when none resolvable. Only defined, non-empty fields + * are written (userId → `user_id`). Best-effort: never throws, and never + * touches the profile's `oauth` subtable or unrelated fields. + */ +export function patchProfileConnection( + profileName: string | undefined, + fields: { + service?: string + protocol?: string + instance?: string + workspace?: string + schema?: string + vcluster?: string + userId?: number + accountId?: number + accountName?: string + }, +): void { + try { + const data = parseTOML(readFileSync(profilesFile(), "utf-8")) as Record + const profiles = (data.profiles ?? {}) as Record> + + const name = resolveProfileName(data, profileName) + if (!name || !profiles[name]) return + + const profile = profiles[name] + const assign = (key: string, value: string | undefined) => { + if (value !== undefined && value.length > 0) profile[key] = value + } + assign("service", fields.service) + assign("protocol", fields.protocol) + assign("instance", fields.instance) + assign("workspace", fields.workspace) + assign("schema", fields.schema) + assign("vcluster", fields.vcluster) + assign("account_name", fields.accountName) + if (typeof fields.userId === "number" && fields.userId > 0) profile["user_id"] = fields.userId + if (typeof fields.accountId === "number" && fields.accountId > 0) profile["account_id"] = fields.accountId + + data.profiles = profiles + writeProfilesFile(stringifyTOML(data)) + } catch { + // best-effort: never block the CLI + } +} + +/** + * Archive the FULL `/oauth2/userinfo` body verbatim into the active profile + * under `[profiles..userinfo]` so nothing is discarded (requirement + * 11.9). The userinfo carries nested values (`instanceList` is an array of + * objects, `gatewayMapping` is a JSON string); smol-toml `stringify` round- + * trips these losslessly (verified: write → re-parse → deep-equal), so we + * persist as a native nested TOML subtable rather than a JSON blob. Resolves + * the target profile like the other helpers do; no-ops when none resolvable or + * the body is empty. Best-effort: never throws, and never touches the profile's + * `oauth` subtable or unrelated fields. Sensitive values (e.g. `apiKey`) are + * stored under the same `0o600` file and are never printed. + */ +export function patchProfileUserInfo(profileName: string | undefined, userInfo: Record): void { + if (!userInfo || Object.keys(userInfo).length === 0) return + try { + const data = parseTOML(readFileSync(profilesFile(), "utf-8")) as Record + const profiles = (data.profiles ?? {}) as Record> + + const name = resolveProfileName(data, profileName) + if (!name || !profiles[name]) return + + profiles[name]["userinfo"] = userInfo + data.profiles = profiles + writeProfilesFile(stringifyTOML(data)) + } catch { + // best-effort: never block the CLI + } +} + +/** + * Deterministically map a cacheKey (e.g. "instance:pat-or-username") to a TOML + * bare-key-safe string. The raw cacheKey may contain ':' and other characters + * that complicate quoting, so we collapse anything outside [A-Za-z0-9_] to '_'. + * The mapping is stable, so save/load/clear stay consistent for the same key. + */ +function sanitizeCacheKey(cacheKey: string): string { + return cacheKey.replace(/[^A-Za-z0-9_]/g, "_") +} + +/** + * Resolve the profile entry name the same way the other helpers do: + * explicit name → default_profile → first profile. Returns undefined when no + * profile can be resolved (e.g. empty/missing profiles.toml). + */ +function resolveProfileName(data: Record, profileName: string | undefined): string | undefined { + const profiles = (data.profiles ?? {}) as Record + if (profileName) return profileName + if (typeof data.default_profile === "string") return data.default_profile + return Object.keys(profiles)[0] +} + +function num(val: unknown): number | undefined { + return typeof val === "number" ? val : undefined +} + +/** + * Build a profile-backed {@link TokenStore} that persists OAuth tokens under + * `[profiles..oauth.]` in `~/.clickzetta/profiles.toml`. + * + * All operations are best-effort and never throw: the CLI must keep working + * even when the profile file is missing, corrupt, or unwritable (requirement + * 9.2). Token values are never logged. Writes reuse {@link writeProfilesFile} + * for atomic replace + `0o600` permissions. + */ +export function makeProfileTokenStore(profileName: string | undefined, cacheKey: string): TokenStore { + const key = sanitizeCacheKey(cacheKey) + + return { + load(): AuthToken | undefined { + try { + const data = parseTOML(readFileSync(profilesFile(), "utf-8")) as Record + const name = resolveProfileName(data, profileName) + if (!name) return undefined + const profiles = (data.profiles ?? {}) as Record> + const oauth = profiles[name]?.oauth as Record | undefined + const entry = oauth?.[key] as Record | undefined + if (!entry) return undefined + + const token = str(entry.access_token, undefined) + const expireTimeMs = num(entry.expire_time_ms) + const obtainedAt = num(entry.obtained_at) + const instanceId = num(entry.instance_id) + const userId = num(entry.user_id) + if (token === undefined || expireTimeMs === undefined || obtainedAt === undefined) return undefined + if (instanceId === undefined || userId === undefined) return undefined + + const refreshToken = str(entry.refresh_token, undefined) + const result: AuthToken = { token, instanceId, userId, expireTimeMs, obtainedAt } + if (refreshToken !== undefined) result.refreshToken = refreshToken + return result + } catch { + // best-effort: missing/corrupt file → behave as no cached token + return undefined + } + }, + + save(token: AuthToken): void { + try { + let data: Record = {} + try { + data = parseTOML(readFileSync(profilesFile(), "utf-8")) as Record + } catch { + // file doesn't exist or is invalid — start fresh + } + const name = resolveProfileName(data, profileName) + if (!name) return + + const profiles = (data.profiles ?? {}) as Record> + const profile = profiles[name] ?? {} + const oauth = (profile.oauth ?? {}) as Record + + const entry: Record = { + access_token: token.token, + expire_time_ms: token.expireTimeMs, + obtained_at: token.obtainedAt, + instance_id: token.instanceId, + user_id: token.userId, + } + if (token.refreshToken !== undefined) entry.refresh_token = token.refreshToken + + oauth[key] = entry + profile.oauth = oauth + profiles[name] = profile + data.profiles = profiles + writeProfilesFile(stringifyTOML(data)) + } catch { + // best-effort: never block the CLI on persistence failure + } + }, + + clear(): void { + try { + const data = parseTOML(readFileSync(profilesFile(), "utf-8")) as Record + const name = resolveProfileName(data, profileName) + if (!name) return + const profiles = (data.profiles ?? {}) as Record> + const oauth = profiles[name]?.oauth as Record | undefined + if (!oauth || !(key in oauth)) return + + delete oauth[key] + data.profiles = profiles + writeProfilesFile(stringifyTOML(data)) + } catch { + // best-effort: missing/corrupt file → nothing to clear + } + }, + } +} diff --git a/packages/cz-cli/src/register-commands.ts b/packages/cz-cli/src/register-commands.ts index fab841619..796896970 100644 --- a/packages/cz-cli/src/register-commands.ts +++ b/packages/cz-cli/src/register-commands.ts @@ -5,6 +5,7 @@ import { registerSchemaCommand } from "./commands/schema.js" import { registerTableCommand } from "./commands/table.js" import { registerWorkspaceCommand } from "./commands/workspace.js" import { registerStatusCommand } from "./commands/status.js" +import { registerLoginCommand } from "./commands/login.js" import { registerProfileCommand } from "./commands/profile.js" import { registerTaskCommand } from "./commands/task.js" import { registerRunsCommand } from "./commands/runs.js" @@ -23,6 +24,7 @@ export function registerCommands(cli: Argv): Argv { registerTableCommand(cli) registerWorkspaceCommand(cli) registerStatusCommand(cli) + registerLoginCommand(cli) registerProfileCommand(cli) registerTaskCommand(cli) registerRunsCommand(cli) diff --git a/packages/cz-cli/test/accounts-url.test.ts b/packages/cz-cli/test/accounts-url.test.ts new file mode 100644 index 000000000..285ff2dcc --- /dev/null +++ b/packages/cz-cli/test/accounts-url.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { accountsBaseUrl } from "../src/connection/accounts-url" + +const ENV_KEY = "CZ_OAUTH_ACCOUNTS_URL" +const original = process.env[ENV_KEY] + +afterEach(() => { + if (original === undefined) delete process.env[ENV_KEY] + else process.env[ENV_KEY] = original +}) + +describe("accountsBaseUrl", () => { + test("prod api host derives bare accounts host", () => { + delete process.env[ENV_KEY] + expect(accountsBaseUrl("api.clickzetta.com")).toBe("https://accounts.clickzetta.com") + }) + + test("dev api host derives env-prefixed accounts host", () => { + delete process.env[ENV_KEY] + expect(accountsBaseUrl("dev-api.clickzetta.com")).toBe("https://dev-accounts.clickzetta.com") + }) + + test("sit and uat api hosts derive env-prefixed accounts hosts", () => { + delete process.env[ENV_KEY] + expect(accountsBaseUrl("sit-api.clickzetta.com")).toBe("https://sit-accounts.clickzetta.com") + expect(accountsBaseUrl("uat-api.clickzetta.com")).toBe("https://uat-accounts.clickzetta.com") + }) + + test("singdata root domain is preserved", () => { + delete process.env[ENV_KEY] + expect(accountsBaseUrl("api.singdata.com")).toBe("https://accounts.singdata.com") + expect(accountsBaseUrl("dev-api.singdata.com")).toBe("https://dev-accounts.singdata.com") + }) + + test("accepts a full URL service input", () => { + delete process.env[ENV_KEY] + expect(accountsBaseUrl("https://dev-api.clickzetta.com")).toBe("https://dev-accounts.clickzetta.com") + expect(accountsBaseUrl("https://api.clickzetta.com/")).toBe("https://accounts.clickzetta.com") + }) + + test("CZ_OAUTH_ACCOUNTS_URL overrides derivation and is trimmed without trailing slash", () => { + process.env[ENV_KEY] = " https://custom-accounts.example.com/ " + expect(accountsBaseUrl("api.clickzetta.com")).toBe("https://custom-accounts.example.com") + }) +}) diff --git a/packages/cz-cli/test/exec-auth.test.ts b/packages/cz-cli/test/exec-auth.test.ts new file mode 100644 index 000000000..f73cab2aa --- /dev/null +++ b/packages/cz-cli/test/exec-auth.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { AuthToken } from "@clickzetta/sdk" +import { hasUsableCredentials } from "../src/commands/exec.ts" +import { resolveConnectionConfig } from "../src/connection/config.ts" +import { makeProfileTokenStore, saveProfiles } from "../src/connection/profile-store.ts" + +// getExecContext's auth precheck is extracted into the pure `hasUsableCredentials` +// so it can be unit-tested hermetically (no network). It must treat a valid +// persisted OAuth token as sufficient even when the profile carries no +// pat/username (requirement 11.8), while still rejecting profiles with neither. + +const previousTestHome = process.env.CLICKZETTA_TEST_HOME +const previousEnv = { + CZ_PROFILE: process.env.CZ_PROFILE, + CZ_PAT: process.env.CZ_PAT, + CZ_USERNAME: process.env.CZ_USERNAME, + CZ_PASSWORD: process.env.CZ_PASSWORD, + CZ_INSTANCE: process.env.CZ_INSTANCE, +} +let home: string + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "cz-exec-auth-")) + process.env.CLICKZETTA_TEST_HOME = home + delete process.env.CZ_PROFILE + delete process.env.CZ_PAT + delete process.env.CZ_USERNAME + delete process.env.CZ_PASSWORD + delete process.env.CZ_INSTANCE +}) + +afterEach(() => { + if (previousTestHome === undefined) delete process.env.CLICKZETTA_TEST_HOME + else process.env.CLICKZETTA_TEST_HOME = previousTestHome + for (const [k, v] of Object.entries(previousEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + rmSync(home, { recursive: true, force: true }) +}) + +const oauthToken: AuthToken = { + token: "access-oauth", + refreshToken: "refresh-oauth", + expireTimeMs: 3600_000, + obtainedAt: Date.now(), + instanceId: 42, + userId: 7, +} + +test("a pure-OAuth profile (no pat/username) with a persisted token is usable", () => { + saveProfiles({ czcli: { instance: "oauthonly", service: "api.example.com" } }) + // Seed the OAuth slot under the instance-only key. + makeProfileTokenStore("czcli", "oauthonly").save(oauthToken) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(cfg.pat).toBeFalsy() + expect(cfg.username).toBeFalsy() + expect(hasUsableCredentials(cfg)).toBe(true) +}) + +test("a profile with neither creds nor a persisted OAuth token is not usable", () => { + saveProfiles({ czcli: { instance: "oauthonly", service: "api.example.com" } }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(hasUsableCredentials(cfg)).toBe(false) +}) + +test("a pat profile is usable regardless of any persisted token", () => { + saveProfiles({ czcli: { pat: "the-pat", instance: "inst", service: "api.example.com" } }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(hasUsableCredentials(cfg)).toBe(true) +}) + +test("a username/password profile is usable", () => { + saveProfiles({ czcli: { username: "alice", password: "secret", instance: "inst", service: "api.example.com" } }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(hasUsableCredentials(cfg)).toBe(true) +}) diff --git a/packages/cz-cli/test/login-browser.test.ts b/packages/cz-cli/test/login-browser.test.ts new file mode 100644 index 000000000..9263538ca --- /dev/null +++ b/packages/cz-cli/test/login-browser.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { get } from "node:http" + +import { isLocalCallbackEnabled } from "@clickzetta/sdk" +import { loginWithBrowser } from "../src/commands/login-browser" + +const realFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = realFetch +}) + +// Decode the base64(JSON) oauthLoginParam carried on the authorize URL so the +// fake browser can read the dynamic redirectUri + state, mirroring what the +// real accounts front end does. +function decodeAuthorizeUrl(authorizeUrl: string): { redirectUri: string; state: string } { + const encoded = new URL(authorizeUrl).searchParams.get("oauthLoginParam") + if (!encoded) throw new Error("authorize URL missing oauthLoginParam") + const param = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) + return { redirectUri: param.redirectUri, state: param.state } +} + +// Fire the loopback callback via node:http (not global fetch, which we stub for +// /oauth2/token) so the listener resolves with the code. +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + get(url, (res) => { + res.resume() + res.on("end", () => resolve()) + }).on("error", reject) + }) +} + +const SAMPLE_USERINFO = { + userId: 110000011361, + accountName: "wynptmks", + gatewayMapping: '{"1-1":"https://dev-api.clickzetta.com","1-2":"https://dev-api.clickzetta.com"}', + instanceList: [{ cspId: 1, regionId: 1, serviceId: 1, id: 159973, name: "89b94150" }], + instanceName: "89b94150", + workspaceName: "quick_start", + schema: "public", + virtualCluster: "DEFAULT_AP", + aimeshEndpointBaseUrl: "https://dev-aimesh.clickzetta.com/", + apiKey: "secret-api-key", + sub: "110000011361", + preferred_username: "weiliu", + name: "weiliu", + account_id: 112407, +} + +describe("loginWithBrowser", () => { + // Property 11 (Requirements 10.2, 10.8): the redirectUri inside the authorize + // URL is the dynamic loopback, and the redirect_uri sent to /oauth2/token is + // byte-identical. Property 12 (Requirement 10.6): state round-trips. + // Requirement 11.6: userinfo backfills userId/instanceId + connection context. + test("happy path: dynamic redirect_uri round-trips into the token exchange", async () => { + const seen: { authorizeRedirectUri?: string; tokenRedirectUri?: string; authorizeState?: string } = {} + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/oauth2/token")) { + const body = new URLSearchParams(String(init?.body)) + seen.tokenRedirectUri = body.get("redirect_uri") ?? undefined + expect(body.get("code")).toBe("THE_CODE") + return new Response( + JSON.stringify({ + access_token: "access-xyz", + refresh_token: "refresh-xyz", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + if (url.endsWith("/oauth2/userinfo")) { + return new Response(JSON.stringify(SAMPLE_USERINFO), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + throw new Error(`unexpected fetch to ${url}`) + }) as typeof fetch + + const fakeBrowser = (authorizeUrl: string) => { + const parsed = decodeAuthorizeUrl(authorizeUrl) + seen.authorizeRedirectUri = parsed.redirectUri + seen.authorizeState = parsed.state + // Drive the loopback callback like the real front end would. + void httpGet(`${parsed.redirectUri}?code=THE_CODE&state=${parsed.state}`) + } + + const result = await loginWithBrowser({ + baseUrl: "https://api.example.com", + accountsBaseUrl: "https://accounts.example.com", + openBrowser: fakeBrowser, + timeoutMs: 5000, + }) + + // (a) authorize redirectUri is the dynamic loopback callback + expect(seen.authorizeRedirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/) + // (b) Property 11: token redirect_uri is byte-identical to the authorize one + expect(seen.tokenRedirectUri).toBe(seen.authorizeRedirectUri) + // (c) returned AuthToken carries the access/refresh tokens + expect(result.token.token).toBe("access-xyz") + expect(result.token.refreshToken).toBe("refresh-xyz") + // (c2) expireTimeMs is a DURATION (expires_in * 1000), NOT an absolute + // timestamp. mocked expires_in=3600 → 3600000ms. The < 1e12 guard catches + // the regression where Date.now()+duration produced an absolute time. + expect(result.token.expireTimeMs).toBe(3600 * 1000) + expect(result.token.expireTimeMs).toBeLessThan(1e12) + // (d) Property 12: state matched what the callback validated (no rejection) + expect(seen.authorizeState).toBeDefined() + // (e) Requirement 11.6: userinfo backfilled identity into the token... + expect(result.token.userId).toBe(110000011361) + expect(result.token.instanceId).toBe(159973) + // ...and the connection context surfaced on userInfo. + expect(result.userInfo?.workspace).toBe("quick_start") + expect(result.userInfo?.vcluster).toBe("DEFAULT_AP") + expect(result.userInfo?.instanceName).toBe("89b94150") + // ...account identity mapped from userinfo. + expect(result.userInfo?.accountName).toBe("wynptmks") + expect(result.userInfo?.accountId).toBe(112407) + // Requirement 11.9: the FULL userinfo body is carried verbatim on `raw`, + // including fields we never map to dedicated columns. + expect(result.raw).toBeDefined() + expect(result.raw?.aimeshEndpointBaseUrl).toBe("https://dev-aimesh.clickzetta.com/") + expect(result.raw?.apiKey).toBe("secret-api-key") + expect(result.raw?.gatewayMapping).toBe(SAMPLE_USERINFO.gatewayMapping) + expect(result.raw?.instanceList).toEqual(SAMPLE_USERINFO.instanceList) + }) + + // Requirement 11.7: a userinfo failure must NOT fail the login — the token is + // still returned and userInfo is undefined. + test("userinfo failure is non-fatal: token resolves, userInfo undefined", async () => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/oauth2/token")) { + const body = new URLSearchParams(String(init?.body)) + expect(body.get("code")).toBe("THE_CODE") + return new Response( + JSON.stringify({ + access_token: "access-xyz", + refresh_token: "refresh-xyz", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + if (url.endsWith("/oauth2/userinfo")) { + return new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + throw new Error(`unexpected fetch to ${url}`) + }) as typeof fetch + + const fakeBrowser = (authorizeUrl: string) => { + const parsed = decodeAuthorizeUrl(authorizeUrl) + void httpGet(`${parsed.redirectUri}?code=THE_CODE&state=${parsed.state}`) + } + + const result = await loginWithBrowser({ + baseUrl: "https://api.example.com", + accountsBaseUrl: "https://accounts.example.com", + openBrowser: fakeBrowser, + timeoutMs: 5000, + }) + + expect(result.token.token).toBe("access-xyz") + // userinfo failed → identity stays at the default and context is absent. + expect(result.token.userId).toBe(0) + expect(result.token.instanceId).toBe(0) + expect(result.userInfo).toBeUndefined() + // raw is only present when userinfo succeeded. + expect(result.raw).toBeUndefined() + }) + + // Property 12 (Requirement 10.7): a callback with the wrong state must reject. + test("rejects when the browser returns a mismatched state", async () => { + globalThis.fetch = (async () => { + throw new Error("/oauth2/token must not be called on state mismatch") + }) as typeof fetch + + const fakeBrowser = (authorizeUrl: string) => { + const parsed = decodeAuthorizeUrl(authorizeUrl) + void httpGet(`${parsed.redirectUri}?code=THE_CODE&state=not-the-state`) + } + + await expect( + loginWithBrowser({ + baseUrl: "https://api.example.com", + accountsBaseUrl: "https://accounts.example.com", + openBrowser: fakeBrowser, + timeoutMs: 5000, + }), + ).rejects.toThrow(/state mismatch/) + }) + + // Property 13 (Requirement 10.1): with CZ_OAUTH_LOCAL_CALLBACK unset the + // gating check is false, so callers keep the existing default path. + test("gating: isLocalCallbackEnabled is false when the switch is unset", () => { + const original = process.env.CZ_OAUTH_LOCAL_CALLBACK + delete process.env.CZ_OAUTH_LOCAL_CALLBACK + try { + expect(isLocalCallbackEnabled()).toBe(false) + } finally { + if (original !== undefined) process.env.CZ_OAUTH_LOCAL_CALLBACK = original + } + }) +}) diff --git a/packages/cz-cli/test/login-command.test.ts b/packages/cz-cli/test/login-command.test.ts new file mode 100644 index 000000000..bc8e91800 --- /dev/null +++ b/packages/cz-cli/test/login-command.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { AuthToken, ConnectionConfig } from "@clickzetta/sdk" +import { runLogin } from "../src/commands/login" +import type { BrowserLoginResult } from "../src/commands/login-browser" +import { makeProfileTokenStore, saveProfiles } from "../src/connection/profile-store" +import { GlobalArgs } from "../src/cli" + +const PAT = "pat-secret-123" +const PROFILE = "czcli" + +// Token returned by the fake browser login, already backfilled (as the real +// loginWithBrowser would after userinfo) with the userinfo instance identity. +const KNOWN_TOKEN: AuthToken = { + token: "access-secret-xyz", + refreshToken: "refresh-secret-xyz", + expireTimeMs: 3600 * 1000, + obtainedAt: Date.now(), + instanceId: 159973, + userId: 110000011361, +} + +const KNOWN_RESULT: BrowserLoginResult = { + token: KNOWN_TOKEN, + userInfo: { + instanceName: "89b94150", + workspace: "quick_start", + schema: "public", + vcluster: "DEFAULT_AP", + accountName: "wynptmks", + accountId: 112407, + userId: 110000011361, + instanceId: 159973, + }, + raw: { + userId: 110000011361, + accountName: "wynptmks", + gatewayMapping: '{"1-1":"https://dev-api.clickzetta.com","1-2":"https://dev-api.clickzetta.com"}', + instanceList: [{ cspId: 1, regionId: 1, serviceId: 1, id: 159973, name: "89b94150" }], + instanceName: "89b94150", + workspaceName: "quick_start", + schema: "public", + virtualCluster: "DEFAULT_AP", + aimeshEndpointBaseUrl: "https://dev-aimesh.clickzetta.com/", + apiKey: "secret-api-key", + sub: "110000011361", + preferred_username: "weiliu", + name: "weiliu", + account_id: 112407, + }, +} + +const ORIGINAL_CALLBACK = process.env.CZ_OAUTH_LOCAL_CALLBACK +const previousTestHome = process.env.CLICKZETTA_TEST_HOME +let home: string + +// Capture stdout so we can assert the success payload never echoes secrets. +function captureStdout(): { restore: () => void; text: () => string } { + const original = process.stdout.write.bind(process.stdout) + let buffer = "" + process.stdout.write = ((chunk: unknown) => { + buffer += String(chunk) + return true + }) as typeof process.stdout.write + return { restore: () => (process.stdout.write = original), text: () => buffer } +} + +function makeArgs(overrides: Partial & { browser?: boolean } = {}) { + return { format: "json", debug: false, profile: PROFILE, ...overrides } as GlobalArgs & { browser?: boolean } +} + +// Config resolved from the seeded profile: pat + instance + service. finalInstance +// will become the userinfo instanceName so the cacheKey is `89b94150:`. +function makeConfig(): ConnectionConfig { + return { + service: "https://api.example.com", + protocol: "https", + instance: "old-instance", + pat: PAT, + } as ConnectionConfig +} + +function profilesPath() { + return join(home, ".clickzetta", "profiles.toml") +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "cz-login-cmd-")) + process.env.CLICKZETTA_TEST_HOME = home + delete process.env.CZ_OAUTH_LOCAL_CALLBACK + process.exitCode = 0 +}) + +afterEach(() => { + if (previousTestHome === undefined) delete process.env.CLICKZETTA_TEST_HOME + else process.env.CLICKZETTA_TEST_HOME = previousTestHome + if (ORIGINAL_CALLBACK === undefined) delete process.env.CZ_OAUTH_LOCAL_CALLBACK + else process.env.CZ_OAUTH_LOCAL_CALLBACK = ORIGINAL_CALLBACK + process.exitCode = 0 + rmSync(home, { recursive: true, force: true }) +}) + +describe("runLogin", () => { + // Requirement 11.3/11.6/11.7: --browser drives the browser flow, persists the + // token under the FINAL cacheKey, and writes the logged-in connection context + // into the profile — without echoing secrets. + test("--browser: persists token + connection context to the real profile", async () => { + saveProfiles({ [PROFILE]: { pat: PAT, instance: "old-instance", service: "https://api.example.com" } }) + + let browserCalls = 0 + const out = captureStdout() + try { + await runLogin(makeArgs({ browser: true }), { + loginWithBrowser: async () => { + browserCalls++ + return KNOWN_RESULT + }, + resolveConnectionConfig: () => makeConfig(), + accountsBaseUrl: () => "https://accounts.example.com", + }) + } finally { + out.restore() + } + + expect(browserCalls).toBe(1) + + const text = readFileSync(profilesPath(), "utf-8") + // Connection context backfilled from userinfo. + expect(text).toContain('instance = "89b94150"') + expect(text).toContain('workspace = "quick_start"') + expect(text).toContain('vcluster = "DEFAULT_AP"') + // Requirement 11.9: account identity mapped + full userinfo archived. + expect(text).toContain("account_id = 112407") + expect(text).toContain('account_name = "wynptmks"') + expect(text).toContain("[profiles.czcli.userinfo]") + expect(text).toContain('aimeshEndpointBaseUrl = "https://dev-aimesh.clickzetta.com/"') + + // Token persisted under the instance-only slot `89b94150` and loadable. + const loaded = makeProfileTokenStore(PROFILE, "89b94150").load() + expect(loaded).toEqual(KNOWN_TOKEN) + + // Requirement 11.3: success output MUST NOT include token/refresh values. + expect(out.text()).not.toContain("access-secret-xyz") + expect(out.text()).not.toContain("refresh-secret-xyz") + expect(out.text()).toContain("logged_in") + expect(process.exitCode).toBe(0) + }) + + // Requirement 11.5: without --browser and with the switch unset, no browser + // flow runs and nothing is persisted — only guidance + non-zero exit. + test("gating: no --browser and switch unset does not run browser login", async () => { + saveProfiles({ [PROFILE]: { pat: PAT, instance: "old-instance", service: "https://api.example.com" } }) + + let browserCalls = 0 + const out = captureStdout() + try { + await runLogin(makeArgs(), { + loginWithBrowser: async () => { + browserCalls++ + return KNOWN_RESULT + }, + resolveConnectionConfig: () => makeConfig(), + accountsBaseUrl: () => "https://accounts.example.com", + }) + } finally { + out.restore() + } + + expect(browserCalls).toBe(0) + // No token persisted under the instance-only slot. + expect(makeProfileTokenStore(PROFILE, "89b94150").load()).toBeUndefined() + // Profile instance unchanged. + expect(readFileSync(profilesPath(), "utf-8")).toContain('instance = "old-instance"') + expect(process.exitCode).toBe(2) + }) + + // Requirement 11.4: a failed login persists nothing and surfaces an error. + test("failure: does not persist token when browser login throws", async () => { + saveProfiles({ [PROFILE]: { pat: PAT, instance: "old-instance", service: "https://api.example.com" } }) + + const out = captureStdout() + try { + await runLogin(makeArgs({ browser: true }), { + loginWithBrowser: async () => { + throw new Error("state mismatch") + }, + resolveConnectionConfig: () => makeConfig(), + accountsBaseUrl: () => "https://accounts.example.com", + }) + } finally { + out.restore() + } + + expect(makeProfileTokenStore(PROFILE, "89b94150").load()).toBeUndefined() + // Profile context unchanged on failure. + expect(readFileSync(profilesPath(), "utf-8")).toContain('instance = "old-instance"') + expect(out.text()).toContain("LOGIN_FAILED") + expect(process.exitCode).not.toBe(0) + }) +}) diff --git a/packages/cz-cli/test/profile-token-store.test.ts b/packages/cz-cli/test/profile-token-store.test.ts new file mode 100644 index 000000000..3c36eb7e1 --- /dev/null +++ b/packages/cz-cli/test/profile-token-store.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { mkdtempSync, rmSync, statSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { isDeepStrictEqual } from "node:util" +import { parse as parseTOML } from "smol-toml" +import type { AuthToken } from "@clickzetta/sdk" +import { + makeProfileTokenStore, + patchProfileConnection, + patchProfileUserInfo, + saveProfiles, +} from "../src/connection/profile-store.ts" + +const previousTestHome = process.env.CLICKZETTA_TEST_HOME +let home: string + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "cz-token-store-")) + process.env.CLICKZETTA_TEST_HOME = home +}) + +afterEach(() => { + if (previousTestHome === undefined) delete process.env.CLICKZETTA_TEST_HOME + else process.env.CLICKZETTA_TEST_HOME = previousTestHome + rmSync(home, { recursive: true, force: true }) +}) + +function profilesPath() { + return join(home, ".clickzetta", "profiles.toml") +} + +const sampleToken: AuthToken = { + token: "access-abc", + refreshToken: "refresh-xyz", + expireTimeMs: 3600_000, + obtainedAt: 1_700_000_000_000, + instanceId: 42, + userId: 7, +} + +const cacheKey = "myinstance:czcli-user" + +test("save then load returns an equal AuthToken including refreshToken", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance" } }) + + const store = makeProfileTokenStore("czcli", cacheKey) + store.save(sampleToken) + + expect(store.load()).toEqual(sampleToken) +}) + +test("legacy token without refreshToken round-trips without the field", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance" } }) + + const legacy: AuthToken = { + token: "legacy-token", + expireTimeMs: 1_000, + obtainedAt: 1_700_000_000_000, + instanceId: 1, + userId: 2, + } + const store = makeProfileTokenStore("czcli", cacheKey) + store.save(legacy) + + const loaded = store.load() + expect(loaded).toEqual(legacy) + expect(loaded?.refreshToken).toBeUndefined() +}) + +test("profiles.toml stays mode 0o600 after save", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance" } }) + + makeProfileTokenStore("czcli", cacheKey).save(sampleToken) + + if (process.platform !== "win32") { + expect(statSync(profilesPath()).mode & 0o777).toBe(0o600) + } +}) + +test("clear removes the entry so load returns undefined", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance" } }) + + const store = makeProfileTokenStore("czcli", cacheKey) + store.save(sampleToken) + expect(store.load()).toBeDefined() + + store.clear() + expect(store.load()).toBeUndefined() +}) + +test("tokens are isolated across profiles", () => { + saveProfiles({ + a: { pat: "pa", instance: "myinstance" }, + b: { pat: "pb", instance: "myinstance" }, + }) + + makeProfileTokenStore("a", cacheKey).save(sampleToken) + + expect(makeProfileTokenStore("a", cacheKey).load()).toEqual(sampleToken) + expect(makeProfileTokenStore("b", cacheKey).load()).toBeUndefined() +}) + +test("tokens are isolated across cache keys within the same profile", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance" } }) + + const tokenA: AuthToken = { ...sampleToken, token: "access-A", refreshToken: "refresh-A" } + const tokenB: AuthToken = { ...sampleToken, token: "access-B", refreshToken: "refresh-B" } + + makeProfileTokenStore("czcli", "instance:userA").save(tokenA) + makeProfileTokenStore("czcli", "instance:userB").save(tokenB) + + expect(makeProfileTokenStore("czcli", "instance:userA").load()).toEqual(tokenA) + expect(makeProfileTokenStore("czcli", "instance:userB").load()).toEqual(tokenB) +}) + +test("save does not clobber the profile's existing fields", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance", workspace: "ws" } }) + + makeProfileTokenStore("czcli", cacheKey).save(sampleToken) + + const reloaded = makeProfileTokenStore("czcli", cacheKey).load() + expect(reloaded).toEqual(sampleToken) +}) + +// Requirement 11.6/11.7: patchProfileConnection merges the logged-in context +// into the profile entry without touching oauth or unrelated fields. +test("patchProfileConnection merges connection context into the profile", () => { + saveProfiles({ czcli: { pat: "p", instance: "old-instance", region: "cn" } }) + // Seed an oauth slot to prove it stays untouched. + makeProfileTokenStore("czcli", cacheKey).save(sampleToken) + + patchProfileConnection("czcli", { + service: "api.clickzetta.com", + protocol: "https", + instance: "89b94150", + workspace: "quick_start", + schema: "public", + vcluster: "DEFAULT_AP", + userId: 110000011361, + accountId: 112407, + accountName: "wynptmks", + }) + + const text = readFileSync(profilesPath(), "utf-8") + // Patched fields are reflected in profiles.toml. + expect(text).toContain('service = "api.clickzetta.com"') + expect(text).toContain('instance = "89b94150"') + expect(text).toContain('workspace = "quick_start"') + expect(text).toContain('schema = "public"') + expect(text).toContain('vcluster = "DEFAULT_AP"') + expect(text).toContain("user_id = 110000011361") + // account_id / account_name mapped onto the profile entry. + expect(text).toContain("account_id = 112407") + expect(text).toContain('account_name = "wynptmks"') + // Unrelated field preserved. + expect(text).toContain('region = "cn"') + // 0o600 preserved. + if (process.platform !== "win32") { + expect(statSync(profilesPath()).mode & 0o777).toBe(0o600) + } + // oauth subtable untouched: the persisted token still loads. + expect(makeProfileTokenStore("czcli", cacheKey).load()).toEqual(sampleToken) +}) + +test("patchProfileConnection ignores empty/undefined fields and no-ops without profile", () => { + saveProfiles({ czcli: { pat: "p", instance: "keep-me" } }) + + patchProfileConnection("czcli", { instance: "", workspace: undefined, userId: 0 }) + + const text = readFileSync(profilesPath(), "utf-8") + // Empty instance did not overwrite the existing value. + expect(text).toContain('instance = "keep-me"') + // Zero userId is not written. + expect(text).not.toContain("user_id") + + // Unresolvable profile name is a safe no-op (does not throw). + expect(() => patchProfileConnection("does-not-exist", { instance: "x" })).not.toThrow() +}) + +// The full `/oauth2/userinfo` body (dev shape), used to prove lossless archival. +const SAMPLE_USERINFO: Record = { + userId: 110000011361, + accountName: "wynptmks", + gatewayMapping: '{"1-1":"https://dev-api.clickzetta.com","1-2":"https://dev-api.clickzetta.com"}', + instanceList: [{ cspId: 1, regionId: 1, serviceId: 1, id: 159973, name: "89b94150" }], + instanceName: "89b94150", + workspaceName: "quick_start", + schema: "public", + virtualCluster: "DEFAULT_AP", + aimeshEndpointBaseUrl: "https://dev-aimesh.clickzetta.com/", + apiKey: "secret-api-key", + sub: "110000011361", + preferred_username: "weiliu", + name: "weiliu", + account_id: 112407, +} + +// Requirement 11.9: patchProfileUserInfo archives the FULL userinfo verbatim +// under [profiles..userinfo] — lossless including the instanceList array +// of objects, the gatewayMapping JSON string, and the apiKey. +test("patchProfileUserInfo round-trips the full userinfo losslessly", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance" } }) + // Seed an oauth slot to prove it stays untouched. + makeProfileTokenStore("czcli", cacheKey).save(sampleToken) + + patchProfileUserInfo("czcli", SAMPLE_USERINFO) + + const data = parseTOML(readFileSync(profilesPath(), "utf-8")) as Record + const profiles = data.profiles as Record> + const stored = profiles.czcli.userinfo as Record + + // Deep-equal proves nothing was discarded or mangled across write+reparse. + expect(isDeepStrictEqual(stored, SAMPLE_USERINFO)).toBe(true) + // Spot-check the trickier nested + sensitive shapes explicitly. + expect(stored.instanceList).toEqual(SAMPLE_USERINFO.instanceList) + expect(stored.gatewayMapping).toBe(SAMPLE_USERINFO.gatewayMapping) + expect(stored.apiKey).toBe("secret-api-key") + + // 0o600 preserved. + if (process.platform !== "win32") { + expect(statSync(profilesPath()).mode & 0o777).toBe(0o600) + } + + // oauth subtable untouched: the persisted token still loads. + expect(makeProfileTokenStore("czcli", cacheKey).load()).toEqual(sampleToken) +}) + +test("patchProfileUserInfo preserves unrelated fields and no-ops on empty/unresolvable", () => { + saveProfiles({ czcli: { pat: "p", instance: "myinstance", region: "cn" } }) + + patchProfileUserInfo("czcli", SAMPLE_USERINFO) + + const text = readFileSync(profilesPath(), "utf-8") + expect(text).toContain('region = "cn"') + expect(text).toContain("[profiles.czcli.userinfo]") + + // Empty body is a safe no-op. + expect(() => patchProfileUserInfo("czcli", {})).not.toThrow() + // Unresolvable profile name is a safe no-op (does not throw). + expect(() => patchProfileUserInfo("does-not-exist", SAMPLE_USERINFO)).not.toThrow() +}) diff --git a/packages/cz-cli/test/resolve-token-store.test.ts b/packages/cz-cli/test/resolve-token-store.test.ts new file mode 100644 index 000000000..84a3ade0a --- /dev/null +++ b/packages/cz-cli/test/resolve-token-store.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { AuthToken } from "@clickzetta/sdk" +import { resolveConnectionConfig } from "../src/connection/config.ts" +import { saveProfiles } from "../src/connection/profile-store.ts" + +const previousTestHome = process.env.CLICKZETTA_TEST_HOME +const previousEnv = { + CZ_PROFILE: process.env.CZ_PROFILE, + CZ_PAT: process.env.CZ_PAT, + CZ_USERNAME: process.env.CZ_USERNAME, + CZ_PASSWORD: process.env.CZ_PASSWORD, + CZ_INSTANCE: process.env.CZ_INSTANCE, +} +let home: string + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "cz-resolve-token-store-")) + process.env.CLICKZETTA_TEST_HOME = home + // Isolate from the host environment so env-derived auth never leaks in. + delete process.env.CZ_PROFILE + delete process.env.CZ_PAT + delete process.env.CZ_USERNAME + delete process.env.CZ_PASSWORD + delete process.env.CZ_INSTANCE +}) + +afterEach(() => { + if (previousTestHome === undefined) delete process.env.CLICKZETTA_TEST_HOME + else process.env.CLICKZETTA_TEST_HOME = previousTestHome + for (const [k, v] of Object.entries(previousEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + rmSync(home, { recursive: true, force: true }) +}) + +const sampleToken: AuthToken = { + token: "access-abc", + refreshToken: "refresh-xyz", + expireTimeMs: 3600_000, + obtainedAt: 1_700_000_000_000, + instanceId: 42, + userId: 7, +} + +test("resolveConnectionConfig attaches a token store that round-trips under the instance-only cacheKey", () => { + saveProfiles({ czcli: { pat: "the-pat", instance: "myinstance", service: "api.example.com" } }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(cfg.tokenStore).toBeDefined() + + // The OAuth slot is keyed by INSTANCE ONLY (decoupled from pat/username). + // Save via the resolved store, then load via a freshly-built store using the + // instance key to prove they line up. + cfg.tokenStore!.save(sampleToken) + + expect(cfg.instance).toBe("myinstance") + + const { makeProfileTokenStore } = require("../src/connection/profile-store.ts") + const independent = makeProfileTokenStore("czcli", cfg.instance) + expect(independent.load()).toEqual(sampleToken) +}) + +test("resolveConnectionConfig keys the store by instance even with username auth", () => { + saveProfiles({ + czcli: { username: "alice", password: "secret", instance: "inst2", service: "api.example.com" }, + }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(cfg.tokenStore).toBeDefined() + + cfg.tokenStore!.save(sampleToken) + + expect(cfg.instance).toBe("inst2") + + const { makeProfileTokenStore } = require("../src/connection/profile-store.ts") + expect(makeProfileTokenStore("czcli", cfg.instance).load()).toEqual(sampleToken) +}) + +test("resolveConnectionConfig attaches a token store when only an instance is known (no pat/username)", () => { + // A pure-OAuth profile carries no pat and no username/password, but the + // OAuth slot must still be keyed/attached so a persisted login is reachable. + saveProfiles({ czcli: { instance: "oauthonly", service: "api.example.com" } }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(cfg.pat).toBeFalsy() + expect(cfg.username).toBeFalsy() + expect(cfg.instance).toBe("oauthonly") + expect(cfg.tokenStore).toBeDefined() + + cfg.tokenStore!.save(sampleToken) + const { makeProfileTokenStore } = require("../src/connection/profile-store.ts") + expect(makeProfileTokenStore("czcli", "oauthonly").load()).toEqual(sampleToken) +}) + +test("resolveConnectionConfig leaves tokenStore undefined when no auth identity resolves", () => { + const cfg = resolveConnectionConfig({}) + expect(cfg.tokenStore).toBeUndefined() +}) + +test("resolveConnectionConfig leaves tokenStore undefined when instance is missing", () => { + saveProfiles({ czcli: { pat: "the-pat", service: "api.example.com" } }) + + const cfg = resolveConnectionConfig({ profile: "czcli" }) + expect(cfg.instance).toBeFalsy() + expect(cfg.tokenStore).toBeUndefined() +}) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index e2f605e9e..73431a1dd 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -229,6 +229,11 @@ for (let i = 0; i < targets.length; i++) { if (item.os === "darwin" && process.platform === "darwin") { const binaryPath = `dist/${name}/bin/cz-cli` console.log(`Codesigning: ${binaryPath}`) + // Strip any pre-existing signature first. Bun-compiled binaries can embed a + // malformed ad-hoc signature that makes `codesign --force` fail with + // "invalid or unsupported format for signature"; removing it first lets the + // re-sign succeed. Best-effort: a binary with no signature yet is fine. + await $`codesign --remove-signature ${binaryPath}`.nothrow() await $`codesign --force --sign - ${binaryPath}` await $`xattr -dr com.apple.quarantine ${binaryPath}`.nothrow() } diff --git a/packages/opencode/test/build-script-signing.test.ts b/packages/opencode/test/build-script-signing.test.ts index bb76645d1..bccd564c9 100644 --- a/packages/opencode/test/build-script-signing.test.ts +++ b/packages/opencode/test/build-script-signing.test.ts @@ -7,6 +7,7 @@ const repoRoot = path.resolve(import.meta.dirname, "../../..") test("macOS build clears quarantine xattr after ad-hoc codesign", () => { const script = fs.readFileSync(path.join(repoRoot, "packages", "opencode", "script", "build.ts"), "utf8") + expect(script).toContain("codesign --remove-signature") expect(script).toContain("codesign --force --sign -") expect(script).toContain("xattr -dr com.apple.quarantine") })