From aa089a9fb842054d683097535f5c13755b882053 Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:00:03 +0800 Subject: [PATCH] feat(forward): support SA token issuance and revocation --- CHANGELOG.md | 4 + README.md | 57 ++++++++++ docs/api/forward/README.md | 6 + docs/api/forward/classes/Identities.md | 6 + .../classes/IdentityServiceAccountTokens.md | 69 ++++++++++++ .../forward/classes/ServiceAccountTokens.md | 91 ++++++++++++++++ .../forward/interfaces/ServiceAccountToken.md | 75 +++++++++++++ .../ServiceAccountTokenCreateParams.md | 31 ++++++ .../ServiceAccountTokenRevokeParams.md | 35 ++++++ .../ServiceAccountTokenRevokeResponse.md | 17 +++ docs/api/index/classes/ForwardClient.md | 6 + examples/auth/forward-tokens.mjs | 37 +++++++ package.json | 1 + scripts/run-sa-live.mjs | 9 +- src/forward/forwardClient.ts | 2 + src/forward/identity-service-account-token.ts | 23 ++++ src/forward/identity.ts | 2 + src/forward/index.ts | 2 + src/forward/service-account-token.ts | 44 ++++++++ src/forward/types.ts | 39 +++++++ tests/api-contracts.test.mjs | 4 +- tests/fixtures/forward/api-contracts.json | 47 ++++++++ tests/fixtures/forward/api-methods.json | 90 +++++++++++++++ .../fixtures/forward/api-operation-cases.json | 66 ++++++++++- .../forward/service-account-tokens.json | 84 ++++++++++++++ tests/forward-service-account-tokens.test.mjs | 103 ++++++++++++++++++ tests/helpers.mjs | 3 +- tests/sa-live-configuration.test.mjs | 8 +- tests/sa/tokens-live.mjs | 82 ++++++++++++++ tests/types-smoke.ts | 22 +++- 30 files changed, 1053 insertions(+), 12 deletions(-) create mode 100644 docs/api/forward/classes/IdentityServiceAccountTokens.md create mode 100644 docs/api/forward/classes/ServiceAccountTokens.md create mode 100644 docs/api/forward/interfaces/ServiceAccountToken.md create mode 100644 docs/api/forward/interfaces/ServiceAccountTokenCreateParams.md create mode 100644 docs/api/forward/interfaces/ServiceAccountTokenRevokeParams.md create mode 100644 docs/api/forward/interfaces/ServiceAccountTokenRevokeResponse.md create mode 100644 examples/auth/forward-tokens.mjs create mode 100644 src/forward/identity-service-account-token.ts create mode 100644 src/forward/service-account-token.ts create mode 100644 tests/fixtures/forward/service-account-tokens.json create mode 100644 tests/forward-service-account-tokens.test.mjs create mode 100644 tests/sa/tokens-live.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c6ef0..ee91be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Forward admin and Identity SAT issuance and Forward SAT revocation, using explicit per-call SA Key authentication, typed issuance/revocation metadata, and no automatic retries. Existing platform credentials and PAT/SAT business authentication remain unchanged. + ## [0.4.0] - 2026-10-12 ### Added diff --git a/README.md b/README.md index 08dd8c8..798c837 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,63 @@ cleanup verification, run `npm run test:live:sa:execution` with SA credentials; `QODER_AUTH_MODE` switches examples, not the PAT test suites. See the [official authentication contract](https://docs.qoder.com/cloud-agents/api/conventions/authentication). +### Forward SAT management + +Forward exposes explicit admin/Identity SAT issuance and revocation in addition +to automatic platform exchange. These three endpoints authenticate with an +**SA Key**, supplied per method call; ordinary business requests still use PAT +or SAT. The Key overrides client/request Authorization only for that call, is +never included in the JSON body, and is not saved on the client. + +`ttl_seconds` is optional (1-43200); omission uses the issuer's default. Issuance +and revocation are not automatically retried, even when the client or request +allows retries. Do not blindly repeat an issuance after an uncertain network +failure: it may already have issued a token. Metadata is for audit information, +not secrets. Keep the issuance response's Token ID, Credential ID and expiry for +precise revocation. A returned static SAT does not renew automatically, and +revocation affects its Forward binding; it does not cancel an already-running +Session or revoke unrelated Managed/platform access. Never log the SA Key, SAT, +serialized token response or Authorization headers. + +Run `npm run test:live:sa:tokens` with `QODER_SA_CN_KEY`, +`QODER_SA_GLOBAL_KEY` and `QODER_SA_LIVE_ALLOW_WRITE=true` to verify both regions. +This separate suite issues admin and Identity SATs, checks business access and +cross-Identity refusal, then revokes the tokens, verifies access is refused and +deletes its temporary Identities. It does not execute a model or require +`QODER_SA_LIVE_ALLOW_EXECUTION=true`. + +```ts +import { ForwardClient } from 'qca-sdk'; + +const forwardBaseURL = process.env.QODER_FORWARD_BASE_URL || 'https://api.qoder.com/api/v1/forward'; +const saKey = process.env.QODER_SA_KEY!; +const identityID = process.env.QODER_IDENTITY_ID!; +const issuer = new ForwardClient({ baseURL: forwardBaseURL }); +// Admin: issuer.serviceAccountTokens.create({ saKey, ttl_seconds: 600 }). +const token = await issuer.identities.serviceAccountTokens.create(identityID, { + saKey, ttl_seconds: 600, +}); +try { + const runtime = new ForwardClient({ + baseURL: forwardBaseURL, authMode: 'sa', accessToken: token.access_token, + }); + const page = await runtime.sessions.list({ identity_ids: [identityID], limit: 1 }); +} finally { + await issuer.serviceAccountTokens.revoke(token.auth_token_id, { + saKey, target_credential_id: token.credential_id, + target_expires_at: token.expires_at, reason: 'runtime complete', + }); +} +``` + +After building, run `node examples/auth/forward-tokens.mjs`. It revokes its token +even if the business call fails. Set `QODER_SA_KEY` and `QODER_SA_REGION=cn|global`; +set `QODER_IDENTITY_ID` for Identity mode, or leave it unset for admin mode. + +See the official [admin issuance](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create), +[Identity issuance](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity) +and [revocation](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke) contracts. + ### Authentication examples The two read-only examples under `examples/auth/` show client construction directly: diff --git a/docs/api/forward/README.md b/docs/api/forward/README.md index 7cc5a83..10c3c72 100644 --- a/docs/api/forward/README.md +++ b/docs/api/forward/README.md @@ -18,12 +18,14 @@ - [Identities](classes/Identities.md) - [IdentityConfigs](classes/IdentityConfigs.md) - [IdentityMemoryStores](classes/IdentityMemoryStores.md) +- [IdentityServiceAccountTokens](classes/IdentityServiceAccountTokens.md) - [MemoryStoreMemories](classes/MemoryStoreMemories.md) - [MemoryStoreMemoryVersions](classes/MemoryStoreMemoryVersions.md) - [MemoryStores](classes/MemoryStores.md) - [Models](classes/Models.md) - [ScheduleRuns](classes/ScheduleRuns.md) - [Schedules](classes/Schedules.md) +- [ServiceAccountTokens](classes/ServiceAccountTokens.md) - [SessionEvents](classes/SessionEvents.md) - [SessionResources](classes/SessionResources.md) - [Sessions](classes/Sessions.md) @@ -156,6 +158,10 @@ - [ScheduleTriggerPolicy](interfaces/ScheduleTriggerPolicy.md) - [ScheduleUnpauseParams](interfaces/ScheduleUnpauseParams.md) - [ScheduleUpdateParams](interfaces/ScheduleUpdateParams.md) +- [ServiceAccountToken](interfaces/ServiceAccountToken.md) +- [ServiceAccountTokenCreateParams](interfaces/ServiceAccountTokenCreateParams.md) +- [ServiceAccountTokenRevokeParams](interfaces/ServiceAccountTokenRevokeParams.md) +- [ServiceAccountTokenRevokeResponse](interfaces/ServiceAccountTokenRevokeResponse.md) - [Session](interfaces/Session.md) - [SessionArchiveParams](interfaces/SessionArchiveParams.md) - [SessionCancelParams](interfaces/SessionCancelParams.md) diff --git a/docs/api/forward/classes/Identities.md b/docs/api/forward/classes/Identities.md index 6ad67e4..1e0db1b 100644 --- a/docs/api/forward/classes/Identities.md +++ b/docs/api/forward/classes/Identities.md @@ -52,6 +52,12 @@ > `readonly` **memoryStores**: [`IdentityMemoryStores`](IdentityMemoryStores.md) +*** + +### serviceAccountTokens + +> `readonly` **serviceAccountTokens**: [`IdentityServiceAccountTokens`](IdentityServiceAccountTokens.md) + ## Methods ### clear() diff --git a/docs/api/forward/classes/IdentityServiceAccountTokens.md b/docs/api/forward/classes/IdentityServiceAccountTokens.md new file mode 100644 index 0000000..6144396 --- /dev/null +++ b/docs/api/forward/classes/IdentityServiceAccountTokens.md @@ -0,0 +1,69 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / IdentityServiceAccountTokens + +# Class: IdentityServiceAccountTokens + +Issues SATs bound to a Forward Identity. + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new IdentityServiceAccountTokens**(`_client`): `IdentityServiceAccountTokens` + +#### Parameters + +##### \_client + +[`APIClient`](../../index/classes/APIClient.md) + +#### Returns + +`IdentityServiceAccountTokens` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](../../index/classes/APIClient.md) + +#### Inherited from + +`APIResource._client` + +## Methods + +### create() + +> **create**(`identityID`, `params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`ServiceAccountToken`](../interfaces/ServiceAccountToken.md)\> + +Issue an Identity SAT using the explicit SA Key. Automatic retries are disabled. + +#### Parameters + +##### identityID + +`string` + +##### params + +[`ServiceAccountTokenCreateParams`](../interfaces/ServiceAccountTokenCreateParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`ServiceAccountToken`](../interfaces/ServiceAccountToken.md)\> diff --git a/docs/api/forward/classes/ServiceAccountTokens.md b/docs/api/forward/classes/ServiceAccountTokens.md new file mode 100644 index 0000000..2c33c89 --- /dev/null +++ b/docs/api/forward/classes/ServiceAccountTokens.md @@ -0,0 +1,91 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / ServiceAccountTokens + +# Class: ServiceAccountTokens + +Forward SAT issuance and revocation require an explicit SA Key per call. + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new ServiceAccountTokens**(`_client`): `ServiceAccountTokens` + +#### Parameters + +##### \_client + +[`APIClient`](../../index/classes/APIClient.md) + +#### Returns + +`ServiceAccountTokens` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](../../index/classes/APIClient.md) + +#### Inherited from + +`APIResource._client` + +## Methods + +### create() + +> **create**(`params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`ServiceAccountToken`](../interfaces/ServiceAccountToken.md)\> + +Issue an admin SAT. Automatic retries are disabled to avoid duplicate issuance. + +#### Parameters + +##### params + +[`ServiceAccountTokenCreateParams`](../interfaces/ServiceAccountTokenCreateParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`ServiceAccountToken`](../interfaces/ServiceAccountToken.md)\> + +*** + +### revoke() + +> **revoke**(`authTokenID`, `params`, `options?`): [`APIPromise`](../../index/classes/APIPromise.md)\<[`ServiceAccountTokenRevokeResponse`](../interfaces/ServiceAccountTokenRevokeResponse.md)\> + +Revoke a Forward SAT binding from the same SA. Creation metadata disambiguates the target. + +#### Parameters + +##### authTokenID + +`string` + +##### params + +[`ServiceAccountTokenRevokeParams`](../interfaces/ServiceAccountTokenRevokeParams.md) + +##### options? + +[`RequestOptions`](../../index/interfaces/RequestOptions.md) + +#### Returns + +[`APIPromise`](../../index/classes/APIPromise.md)\<[`ServiceAccountTokenRevokeResponse`](../interfaces/ServiceAccountTokenRevokeResponse.md)\> diff --git a/docs/api/forward/interfaces/ServiceAccountToken.md b/docs/api/forward/interfaces/ServiceAccountToken.md new file mode 100644 index 0000000..9c0cc77 --- /dev/null +++ b/docs/api/forward/interfaces/ServiceAccountToken.md @@ -0,0 +1,75 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / ServiceAccountToken + +# Interface: ServiceAccountToken + +A newly issued Forward SAT. Treat access_token and serialized responses as secrets. + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### access\_token + +> **access\_token**: `string` + +*** + +### auth\_token\_id + +> **auth\_token\_id**: `string` + +*** + +### credential\_id + +> **credential\_id**: `string` + +*** + +### expires\_at + +> **expires\_at**: `string` + +*** + +### expires\_in + +> **expires\_in**: `number` + +*** + +### identity\_id? + +> `optional` **identity\_id?**: `string` + +Absent for admin tokens. + +*** + +### service\_account\_id + +> **service\_account\_id**: `string` + +*** + +### subject\_type + +> **subject\_type**: `"admin"` \| `"identity"` + +*** + +### token\_type + +> **token\_type**: `"Bearer"` + +*** + +### type + +> **type**: `"service_account_token"` diff --git a/docs/api/forward/interfaces/ServiceAccountTokenCreateParams.md b/docs/api/forward/interfaces/ServiceAccountTokenCreateParams.md new file mode 100644 index 0000000..8de9f05 --- /dev/null +++ b/docs/api/forward/interfaces/ServiceAccountTokenCreateParams.md @@ -0,0 +1,31 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / ServiceAccountTokenCreateParams + +# Interface: ServiceAccountTokenCreateParams + +## Properties + +### metadata? + +> `optional` **metadata?**: `Record`\<`string`, `unknown`\> + +Business audit metadata; do not include credentials or other secrets. + +*** + +### saKey + +> **saKey**: `string` + +SA Key sent only in this request's Authorization header; not part of the JSON body. + +*** + +### ttl\_seconds? + +> `optional` **ttl\_seconds?**: `number` + +Lifetime in seconds, 1-43200. Omit to use the issuer's default. diff --git a/docs/api/forward/interfaces/ServiceAccountTokenRevokeParams.md b/docs/api/forward/interfaces/ServiceAccountTokenRevokeParams.md new file mode 100644 index 0000000..36fa60c --- /dev/null +++ b/docs/api/forward/interfaces/ServiceAccountTokenRevokeParams.md @@ -0,0 +1,35 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / ServiceAccountTokenRevokeParams + +# Interface: ServiceAccountTokenRevokeParams + +## Properties + +### reason? + +> `optional` **reason?**: `string` + +*** + +### saKey + +> **saKey**: `string` + +SA Key used only for this request. + +*** + +### target\_credential\_id? + +> `optional` **target\_credential\_id?**: `string` + +*** + +### target\_expires\_at? + +> `optional` **target\_expires\_at?**: `string` + +RFC 3339 expiry from the issuance response. diff --git a/docs/api/forward/interfaces/ServiceAccountTokenRevokeResponse.md b/docs/api/forward/interfaces/ServiceAccountTokenRevokeResponse.md new file mode 100644 index 0000000..b7cca45 --- /dev/null +++ b/docs/api/forward/interfaces/ServiceAccountTokenRevokeResponse.md @@ -0,0 +1,17 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [forward](../README.md) / ServiceAccountTokenRevokeResponse + +# Interface: ServiceAccountTokenRevokeResponse + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### revoked + +> **revoked**: `boolean` diff --git a/docs/api/index/classes/ForwardClient.md b/docs/api/index/classes/ForwardClient.md index 3118e50..fda088c 100644 --- a/docs/api/index/classes/ForwardClient.md +++ b/docs/api/index/classes/ForwardClient.md @@ -152,6 +152,12 @@ *** +### serviceAccountTokens + +> `readonly` **serviceAccountTokens**: [`ServiceAccountTokens`](../../forward/classes/ServiceAccountTokens.md) + +*** + ### sessions > `readonly` **sessions**: [`Sessions`](../../forward/classes/Sessions.md) diff --git a/examples/auth/forward-tokens.mjs b/examples/auth/forward-tokens.mjs new file mode 100644 index 0000000..dc680dc --- /dev/null +++ b/examples/auth/forward-tokens.mjs @@ -0,0 +1,37 @@ +// Issue a Forward SAT, make one read-only business call, then revoke it. +// Set QODER_SA_KEY and QODER_SA_REGION=cn|global; QODER_IDENTITY_ID selects Identity mode. +import { ForwardClient } from '../../dist/index.js'; +import { isMain } from '../lib/forward.mjs'; + +export async function main() { + if (process.argv.includes('--help')) { + console.log('Set QODER_SA_KEY and QODER_SA_REGION=cn|global; optionally QODER_IDENTITY_ID. Issues, uses and revokes a Forward SAT.'); + return; + } + const region = process.env.QODER_SA_REGION || 'cn'; + if (!['cn', 'global'].includes(region)) throw new Error('QODER_SA_REGION must be cn or global'); + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const baseURL = `${host}/api/v1/forward`; + const saKey = process.env.QODER_SA_KEY; + if (!saKey?.trim()) throw new Error('Configure QODER_SA_KEY'); + const identityID = process.env.QODER_IDENTITY_ID; + const issuer = new ForwardClient({ baseURL, authMode: 'sa' }); + const params = { saKey, ttl_seconds: 600, metadata: { purpose: 'sdk example' } }; + const token = identityID + ? await issuer.identities.serviceAccountTokens.create(identityID, params) + : await issuer.serviceAccountTokens.create(params); + try { + const runtime = new ForwardClient({ baseURL, authMode: 'sa', accessToken: token.access_token, maxRetries: 0 }); + if (identityID) console.log('Identity sessions:', (await runtime.sessions.list({ identity_ids: [identityID], limit: 1 })).data.length); + else console.log('Forward templates:', (await runtime.templates.list({ limit: 1 })).data.length); + } finally { + await issuer.serviceAccountTokens.revoke(token.auth_token_id, { + saKey, target_credential_id: token.credential_id, target_expires_at: token.expires_at, reason: 'sdk example complete', + }, { timeout: 30_000 }); + } +} + +if (isMain(import.meta.url)) main().catch(error => { + console.error(`Forward token example failed: ${error.name}`); + process.exitCode = 1; +}); diff --git a/package.json b/package.json index a77dafc..ae848e8 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "docs:check": "node scripts/docs-check.mjs", "test:live:sa": "npm run build && node scripts/run-sa-live.mjs readonly", "test:live:sa:execution": "npm run build && node scripts/run-sa-live.mjs execution", + "test:live:sa:tokens": "npm run build && node scripts/run-sa-live.mjs tokens", "test:live:sa:check": "node --check tests/sa/auth-live.mjs && node --check tests/sa/execution-live.mjs" }, "devDependencies": { diff --git a/scripts/run-sa-live.mjs b/scripts/run-sa-live.mjs index 7a80798..ec2ced7 100644 --- a/scripts/run-sa-live.mjs +++ b/scripts/run-sa-live.mjs @@ -4,12 +4,13 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export function validateSAEnvironment(environment, mode) { - if (!['readonly', 'execution'].includes(mode)) throw new Error('SA suite must be readonly or execution'); + if (!['readonly', 'execution', 'tokens'].includes(mode)) throw new Error('SA suite must be readonly, execution or tokens'); for (const name of ['QODER_SA_CN_KEY', 'QODER_SA_GLOBAL_KEY']) { if (!environment[name]?.trim()) throw new Error(`${name} is required`); } - if (mode === 'execution') { - for (const name of ['QODER_SA_LIVE_ALLOW_WRITE', 'QODER_SA_LIVE_ALLOW_EXECUTION']) { + if (mode === 'execution' || mode === 'tokens') { + const flags = mode === 'execution' ? ['QODER_SA_LIVE_ALLOW_WRITE', 'QODER_SA_LIVE_ALLOW_EXECUTION'] : ['QODER_SA_LIVE_ALLOW_WRITE']; + for (const name of flags) { if (environment[name] !== 'true') throw new Error(`${name}=true is required`); } } @@ -21,7 +22,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur const envFile = resolve(process.env.LIVE_ENV_FILE ?? '.env.live'); if (existsSync(envFile)) process.loadEnvFile(envFile); validateSAEnvironment(process.env, mode); - const file = mode === 'execution' ? 'execution-live.mjs' : 'auth-live.mjs'; + const file = mode === 'execution' ? 'execution-live.mjs' : mode === 'tokens' ? 'tokens-live.mjs' : 'auth-live.mjs'; const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1', `tests/sa/${file}`], { stdio: 'inherit' }); process.exitCode = result.status ?? 1; } catch (error) { diff --git a/src/forward/forwardClient.ts b/src/forward/forwardClient.ts index 380bdaa..1a604a9 100644 --- a/src/forward/forwardClient.ts +++ b/src/forward/forwardClient.ts @@ -15,6 +15,7 @@ import { Vaults } from './vault.js'; import { MemoryStores } from './memory-store.js'; import { Models } from './model.js'; import { Usage } from './usage.js'; +import { ServiceAccountTokens } from './service-account-token.js'; export type ForwardClientOptions = ClientOptions; @@ -34,6 +35,7 @@ export class ForwardClient extends APIClient { readonly memoryStores: MemoryStores = new MemoryStores(this); readonly models: Models = new Models(this); readonly usage: Usage = new Usage(this); + readonly serviceAccountTokens: ServiceAccountTokens = new ServiceAccountTokens(this); constructor(options: ForwardClientOptions = {}) { super(options, 'forward'); diff --git a/src/forward/identity-service-account-token.ts b/src/forward/identity-service-account-token.ts new file mode 100644 index 0000000..7495eda --- /dev/null +++ b/src/forward/identity-service-account-token.ts @@ -0,0 +1,23 @@ +import { APIResource } from '../core/resource.js'; +import type { RequestOptions } from '../core/client.js'; +import type { APIPromise } from '../core/api-promise.js'; +import { pathParam } from './_utils.js'; +import { saKeyRequestOptions } from './service-account-token.js'; +import type { ServiceAccountToken, ServiceAccountTokenCreateParams } from './types.js'; + +/** Issues SATs bound to a Forward Identity. */ +export class IdentityServiceAccountTokens extends APIResource { + /** Issue an Identity SAT using the explicit SA Key. Automatic retries are disabled. */ + create(identityID: string, params: ServiceAccountTokenCreateParams, options?: RequestOptions): APIPromise { + const path = `identities/${pathParam(identityID, 'identity_id')}/service_account_tokens`; + const { saKey, ...payload } = params; + return this._client.request({ + method: 'POST', + path, + body: payload, + ...saKeyRequestOptions(saKey, options), + }); + } +} + +export type { ServiceAccountToken, ServiceAccountTokenCreateParams } from './types.js'; diff --git a/src/forward/identity.ts b/src/forward/identity.ts index bbd5f82..07838f8 100644 --- a/src/forward/identity.ts +++ b/src/forward/identity.ts @@ -7,10 +7,12 @@ import { pathParam, requestHeaders } from './_utils.js'; import type { DeletedIdentity, Identity, IdentityClearParams, IdentityClearResponse, IdentityCreateParams, IdentityListParams, IdentityListTemplatesResponse, IdentityStats, IdentityUpdateParams } from './types.js'; import { IdentityConfigs } from './identity-config.js'; import { IdentityMemoryStores } from './identity-memory-store.js'; +import { IdentityServiceAccountTokens } from './identity-service-account-token.js'; export class Identities extends APIResource { readonly configs: IdentityConfigs = new IdentityConfigs(this._client); readonly memoryStores: IdentityMemoryStores = new IdentityMemoryStores(this._client); + readonly serviceAccountTokens: IdentityServiceAccountTokens = new IdentityServiceAccountTokens(this._client); /** * 列出 Identities. diff --git a/src/forward/index.ts b/src/forward/index.ts index b414caa..e55cb9d 100644 --- a/src/forward/index.ts +++ b/src/forward/index.ts @@ -39,3 +39,5 @@ export type { AuthMode, ServiceAccountCredentialOptions } from '../core/credenti export type { Credential } from '../core/credentials.js'; export * from '../core/error.js'; export { Usage } from './usage.js'; +export { ServiceAccountTokens } from './service-account-token.js'; +export { IdentityServiceAccountTokens } from './identity-service-account-token.js'; diff --git a/src/forward/service-account-token.ts b/src/forward/service-account-token.ts new file mode 100644 index 0000000..1414f21 --- /dev/null +++ b/src/forward/service-account-token.ts @@ -0,0 +1,44 @@ +import { APIResource } from '../core/resource.js'; +import { mergeHeaders, type RequestOptions } from '../core/client.js'; +import type { APIPromise } from '../core/api-promise.js'; +import { pathParam } from './_utils.js'; +import type { ServiceAccountToken, ServiceAccountTokenCreateParams, ServiceAccountTokenRevokeParams, ServiceAccountTokenRevokeResponse } from './types.js'; + +/** @internal Per-call Key authentication overrides ordinary PAT/SAT authentication. */ +export function saKeyRequestOptions(saKey: string, options?: RequestOptions): RequestOptions { + if (typeof saKey !== 'string' || saKey.length === 0 || /[\u0000-\u0020\u007f]/.test(saKey)) { + throw new Error('saKey must be non-empty and contain no whitespace or control characters'); + } + const headers = mergeHeaders(options?.headers); + headers.set('Authorization', `Bearer ${saKey}`); + headers.set('Content-Type', 'application/json'); + return { ...options, headers, maxRetries: 0 }; +} + +/** Forward SAT issuance and revocation require an explicit SA Key per call. */ +export class ServiceAccountTokens extends APIResource { + /** Issue an admin SAT. Automatic retries are disabled to avoid duplicate issuance. */ + create(params: ServiceAccountTokenCreateParams, options?: RequestOptions): APIPromise { + const { saKey, ...payload } = params; + return this._client.request({ + method: 'POST', + path: 'service_account_tokens', + body: payload, + ...saKeyRequestOptions(saKey, options), + }); + } + + /** Revoke a Forward SAT binding from the same SA. Creation metadata disambiguates the target. */ + revoke(authTokenID: string, params: ServiceAccountTokenRevokeParams, options?: RequestOptions): APIPromise { + const path = `service_account_tokens/${pathParam(authTokenID, 'auth_token_id')}/revoke`; + const { saKey, ...payload } = params; + return this._client.request({ + method: 'POST', + path, + body: payload, + ...saKeyRequestOptions(saKey, options), + }); + } +} + +export type { ServiceAccountToken, ServiceAccountTokenCreateParams, ServiceAccountTokenRevokeParams, ServiceAccountTokenRevokeResponse } from './types.js'; diff --git a/src/forward/types.ts b/src/forward/types.ts index 894df81..9f0b93a 100644 --- a/src/forward/types.ts +++ b/src/forward/types.ts @@ -2,6 +2,45 @@ // Wire names and required/nullable fields follow the API's JSON, query and header contracts. import type { Uploadable } from '../core/uploads.js'; +export interface ServiceAccountTokenCreateParams { + /** SA Key sent only in this request's Authorization header; not part of the JSON body. */ + saKey: string; + /** Lifetime in seconds, 1-43200. Omit to use the issuer's default. */ + ttl_seconds?: number; + /** Business audit metadata; do not include credentials or other secrets. */ + metadata?: Record; +} + +export interface ServiceAccountTokenRevokeParams { + /** SA Key used only for this request. */ + saKey: string; + target_credential_id?: string; + /** RFC 3339 expiry from the issuance response. */ + target_expires_at?: string; + reason?: string; +} + +/** A newly issued Forward SAT. Treat access_token and serialized responses as secrets. */ +export interface ServiceAccountToken { + type: 'service_account_token'; + access_token: string; + token_type: 'Bearer'; + expires_in: number; + expires_at: string; + auth_token_id: string; + service_account_id: string; + credential_id: string; + subject_type: 'admin' | 'identity'; + /** Absent for admin tokens. */ + identity_id?: string; + [key: string]: unknown; +} + +export interface ServiceAccountTokenRevokeResponse { + revoked: boolean; + [key: string]: unknown; +} + export interface BatchListParams { /** * 按状态过滤。 diff --git a/tests/api-contracts.test.mjs b/tests/api-contracts.test.mjs index 3c190c1..b1e29e7 100644 --- a/tests/api-contracts.test.mjs +++ b/tests/api-contracts.test.mjs @@ -9,7 +9,7 @@ const handwrittenMethods = { }; for (const mode of ['forward', 'managed']) { - test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 113 : 98} APIs)`, () => { + test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 116 : 98} APIs)`, () => { const client = testClient(mode, () => { throw Error('inventory must not call network'); }); const expectedRoots = [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]; const actualRoots = Object.entries(client).filter(([, value]) => value && typeof value === 'object' && Object.hasOwn(value, '_client')).map(([key]) => key); @@ -26,7 +26,7 @@ for (const mode of ['forward', 'managed']) { }; for (const entry of [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]) walk(client[entry], entry); const expected = methods[mode].map(m => `${m.entry}.${m.method}`); - assert.equal(expected.length, mode === 'forward' ? 113 : 98); + assert.equal(expected.length, mode === 'forward' ? 116 : 98); assert.equal(new Set(expected).size, expected.length); assert.deepEqual(actual.sort(), [...expected, ...handwrittenMethods[mode]].sort()); }); diff --git a/tests/fixtures/forward/api-contracts.json b/tests/fixtures/forward/api-contracts.json index 51fcaba..4a8c3eb 100644 --- a/tests/fixtures/forward/api-contracts.json +++ b/tests/fixtures/forward/api-contracts.json @@ -2802,5 +2802,52 @@ "operation_id": "listTemplateUsage", "entry": "Usage", "empty": false + }, + { + "operation_id": "createServiceAccountToken", + "entry": "ServiceAccountTokens", + "response": { + "type": "service_account_token", + "access_token": "sat-contract-secret", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": "2026-10-13T12:00:00Z", + "auth_token_id": "auth_contract", + "service_account_id": "sa_contract", + "credential_id": "sak_contract", + "subject_type": "admin" + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create", + "id": "serviceAccountTokens.create", + "empty": false + }, + { + "operation_id": "createIdentityServiceAccountToken", + "entry": "Identities.ServiceAccountTokens", + "response": { + "type": "service_account_token", + "access_token": "sat-contract-secret", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": "2026-10-13T12:00:00Z", + "auth_token_id": "auth_contract", + "service_account_id": "sa_contract", + "credential_id": "sak_contract", + "subject_type": "identity", + "identity_id": "idn_contract" + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity", + "id": "identities.serviceAccountTokens.create", + "empty": false + }, + { + "operation_id": "revokeServiceAccountToken", + "entry": "ServiceAccountTokens", + "response": { + "revoked": true + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke", + "id": "serviceAccountTokens.revoke", + "empty": false } ] diff --git a/tests/fixtures/forward/api-methods.json b/tests/fixtures/forward/api-methods.json index 1c26ce7..6f9867e 100644 --- a/tests/fixtures/forward/api-methods.json +++ b/tests/fixtures/forward/api-methods.json @@ -2902,5 +2902,95 @@ } ], "paginated": true + }, + { + "id": "serviceAccountTokens.create", + "entry": "serviceAccountTokens", + "method": "create", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "saKey", + "location": "auth" + }, + { + "wire": "ttl_seconds", + "location": "json" + }, + { + "wire": "metadata", + "location": "json" + } + ] + } + ], + "paginated": false + }, + { + "id": "identities.serviceAccountTokens.create", + "entry": "identities.serviceAccountTokens", + "method": "create", + "args": [ + { + "name": "identity_id", + "kind": "path" + }, + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "saKey", + "location": "auth" + }, + { + "wire": "ttl_seconds", + "location": "json" + }, + { + "wire": "metadata", + "location": "json" + } + ] + } + ], + "paginated": false + }, + { + "id": "serviceAccountTokens.revoke", + "entry": "serviceAccountTokens", + "method": "revoke", + "args": [ + { + "name": "auth_token_id", + "kind": "path" + }, + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "saKey", + "location": "auth" + }, + { + "wire": "target_credential_id", + "location": "json" + }, + { + "wire": "target_expires_at", + "location": "json" + }, + { + "wire": "reason", + "location": "json" + } + ] + } + ], + "paginated": false } ] diff --git a/tests/fixtures/forward/api-operation-cases.json b/tests/fixtures/forward/api-operation-cases.json index 9128b75..e15b3db 100644 --- a/tests/fixtures/forward/api-operation-cases.json +++ b/tests/fixtures/forward/api-operation-cases.json @@ -1,7 +1,7 @@ { "schema_version": 1, "openapi_sha256": "cfc0d3556f61838f65174b4c8ae92eded0d7c9fc00dcbd58d07dd6365e711f96", - "operation_count": 113, + "operation_count": 116, "cases": [ { "operation_id": "addSessionResource", @@ -4925,6 +4925,70 @@ ], "request_body": null, "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "operation_id": "createServiceAccountToken", + "http_method": "POST", + "path": "/service_account_tokens", + "auth": "sa_key", + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create", + "parameters": [], + "request_body": { + "kind": "json", + "value": { + "ttl_seconds": 3600, + "metadata": { + "purpose": "admin automation" + } + } + } + }, + { + "operation_id": "createIdentityServiceAccountToken", + "http_method": "POST", + "path": "/identities/{identity_id}/service_account_tokens", + "auth": "sa_key", + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity", + "parameters": [ + { + "wire_name": "identity_id", + "location": "path", + "required": true, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "ttl_seconds": 3600, + "metadata": { + "purpose": "identity runtime" + } + } + } + }, + { + "operation_id": "revokeServiceAccountToken", + "http_method": "POST", + "path": "/service_account_tokens/{auth_token_id}/revoke", + "auth": "sa_key", + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke", + "parameters": [ + { + "wire_name": "auth_token_id", + "location": "path", + "required": true, + "value": "auth_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "target_credential_id": "sak_contract", + "target_expires_at": "2026-10-13T12:00:00Z", + "reason": "session ended" + } + } } ] } diff --git a/tests/fixtures/forward/service-account-tokens.json b/tests/fixtures/forward/service-account-tokens.json new file mode 100644 index 0000000..95e2f89 --- /dev/null +++ b/tests/fixtures/forward/service-account-tokens.json @@ -0,0 +1,84 @@ +{ + "source": { + "docs": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/", + "checked_at": "2026-10-13" + }, + "operations": [ + { + "operation_id": "createServiceAccountToken", + "entry": "service_account_tokens", + "tsentry": "serviceAccountTokens", + "goentry": "ServiceAccountTokens", + "service": "ServiceAccountTokenService", + "method": "create", + "goname": "New", + "path": "/service_account_tokens", + "doc": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create", + "body": { + "ttl_seconds": 3600, + "metadata": { + "purpose": "admin automation" + } + }, + "response": { + "type": "service_account_token", + "access_token": "sat-contract-secret", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": "2026-10-13T12:00:00Z", + "auth_token_id": "auth_contract", + "service_account_id": "sa_contract", + "credential_id": "sak_contract", + "subject_type": "admin" + } + }, + { + "operation_id": "createIdentityServiceAccountToken", + "entry": "identities.service_account_tokens", + "tsentry": "identities.serviceAccountTokens", + "goentry": "Identities.ServiceAccountTokens", + "service": "IdentityServiceAccountTokenService", + "method": "create", + "goname": "New", + "path": "/identities/{identity_id}/service_account_tokens", + "doc": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity", + "body": { + "ttl_seconds": 3600, + "metadata": { + "purpose": "identity runtime" + } + }, + "response": { + "type": "service_account_token", + "access_token": "sat-contract-secret", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": "2026-10-13T12:00:00Z", + "auth_token_id": "auth_contract", + "service_account_id": "sa_contract", + "credential_id": "sak_contract", + "subject_type": "identity", + "identity_id": "idn_contract" + } + }, + { + "operation_id": "revokeServiceAccountToken", + "entry": "service_account_tokens", + "tsentry": "serviceAccountTokens", + "goentry": "ServiceAccountTokens", + "service": "ServiceAccountTokenService", + "method": "revoke", + "goname": "Revoke", + "path": "/service_account_tokens/{auth_token_id}/revoke", + "doc": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke", + "body": { + "target_credential_id": "sak_contract", + "target_expires_at": "2026-10-13T12:00:00Z", + "reason": "session ended" + }, + "response": { + "revoked": true + } + } + ] +} diff --git a/tests/forward-service-account-tokens.test.mjs b/tests/forward-service-account-tokens.test.mjs new file mode 100644 index 0000000..2439aea --- /dev/null +++ b/tests/forward-service-account-tokens.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { fixture, sdk, response, resource } from './helpers.mjs'; +import * as cjs from '../dist/forward/index.js'; +import * as esm from '../dist/esm/forward/index.js'; + +const operations = fixture('forward', 'service-account-tokens.json').operations; +const invoke = (client, op, params, options) => resource(client, op.tsentry)[op.method]( + ...((op.path.includes('{identity_id}') || op.path.includes('{auth_token_id}')) ? ['id /?%#'] : []), params, options, +); +const transport = handler => async (input, init) => handler(new Request(input, init)); + +for (const op of operations) { + test(`${op.operation_id}: documented Key authentication, body, path and response`, async () => { + let calls = 0; + const client = new sdk.ForwardClient({ + accessToken: 'business-token', authMode: 'sa', baseURL: 'https://api.test/api/v1/forward', + defaultHeaders: { authorization: 'Bearer default-token' }, + fetch: transport(async req => { + calls++; + assert.equal(req.method, 'POST'); + assert.equal(new URL(req.url).pathname, `/api/v1/forward${op.path.replace(/\{[^}]+\}/g, encodeURIComponent('id /?%#'))}`); + assert.equal(req.headers.get('authorization'), 'Bearer explicit-sa-key'); + assert.equal(req.headers.get('x-trace'), 'test'); + assert.equal(req.headers.get('content-type'), 'application/json'); + assert.deepEqual(await req.json(), op.body); + return response(op.response, op.method === 'revoke' ? 200 : 201, { 'x-request-id': 'req_sat' }); + }), + }); + const result = await invoke(client, op, { saKey: 'explicit-sa-key', ...op.body }, { headers: new Headers({ AUTHORIZATION: 'Bearer wrong', 'x-trace': 'test' }) }); + assert.deepEqual(result, op.response); + assert.equal(result._request_id, 'req_sat'); + assert.equal(calls, 1); + }); + + for (const failure of [429, 503, 'network']) test(`${op.operation_id}: ${failure} never retries`, async () => { + let calls = 0; + const client = new sdk.ForwardClient({ maxRetries: 3, fetch: transport(() => { + calls++; + if (failure === 'network') throw Error('connection interrupted'); + return response({ error: { message: 'response uncertain' } }, failure, { 'x-should-retry': 'true', 'retry-after-ms': '1' }); + }) }); + await assert.rejects(() => invoke(client, op, { saKey: 'sa-key' }, { maxRetries: 4, idempotencyKey: 'unsupported-key' })); + assert.equal(calls, 1); + }); + + test(`${op.operation_id}: request options retain async body and do not mutate caller headers`, async () => { + const headers = new Headers({ authorization: 'Bearer original', 'x-trace': 'kept' }); + const client = new sdk.ForwardClient({ fetch: transport(async req => { + assert.deepEqual(await req.json(), op.body); + assert.equal(req.headers.get('authorization'), 'Bearer sa-key'); + assert.equal(req.headers.get('x-trace'), 'kept'); + return response(op.response); + }) }); + await invoke(client, op, { saKey: 'sa-key' }, { headers, body: Promise.resolve(op.body) }); + assert.equal(headers.get('authorization'), 'Bearer original'); + }); + + for (const key of ['', ' ', 'key\nInjected:value', 'key\t', 'key\x00', undefined]) test(`${op.operation_id}: invalid Key ${JSON.stringify(key)} cannot use business auth`, () => { + const client = new sdk.ForwardClient({ pat: 'business-token', fetch: transport(() => assert.fail('invalid Key reached fetch')) }); + assert.throws(() => invoke(client, op, { saKey: key }), /saKey/); + }); +} + +for (const auth of ['none', 'ambient', 'static', 'provider']) test(`per-call SA Key preserves ${auth} business auth`, async () => { + const previous = [process.env.QODER_PAT, process.env.QODER_ACCESS_TOKEN]; + let providerCalls = 0; + const requests = []; + try { + delete process.env.QODER_PAT; + delete process.env.QODER_ACCESS_TOKEN; + if (auth === 'ambient') { + process.env.QODER_PAT = 'business-token'; + process.env.QODER_ACCESS_TOKEN = 'business-token'; + } + const options = auth === 'provider' ? { credential: { getToken() { providerCalls++; return 'business-token'; } } } + : auth === 'static' ? { accessToken: 'business-token', authMode: 'sa' } : {}; + const client = new sdk.ForwardClient({ ...options, fetch: transport(async req => { + requests.push(req); + if (req.method === 'GET') return response({ data: [] }); + assert.equal(req.headers.get('authorization'), 'Bearer sa-key'); + assert.equal(await req.text(), '{}'); + return response(req.url.endsWith('/revoke') ? { revoked: true } : operations[0].response); + }) }); + for (const op of operations) await invoke(client, op, { saKey: 'sa-key' }); + assert.equal(providerCalls, 0); + if (auth !== 'none') { + await client.models.list(); + assert.equal(requests.at(-1).headers.get('authorization'), 'Bearer business-token'); + assert.equal(providerCalls, auth === 'provider' ? 1 : 0); + } + } finally { + ['QODER_PAT', 'QODER_ACCESS_TOKEN'].forEach((key, i) => { if (previous[i] === undefined) delete process.env[key]; else process.env[key] = previous[i]; }); + } +}); + +test('Forward resource exports work in CJS/ESM and do not appear on Managed', () => { + for (const module of [cjs, esm]) { + assert.equal(typeof module.ServiceAccountTokens, 'function'); + assert.equal(typeof module.IdentityServiceAccountTokens, 'function'); + } + assert.equal('serviceAccountTokens' in new sdk.ManagedClient(), false); +}); diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 92bcd0c..2e79f64 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -36,6 +36,7 @@ export async function argumentsFor(mode, m, op, overrides = {}) { if (a.kind === 'path') return pathSegment; for (const f of a.fields ?? []) { if (f.location === 'path') params[f.wire] = pathSegment; + if (f.location === 'auth') params[f.wire] = 'contract-sa-key'; if (f.binary && !params[f.wire]) { const file = await sdk.toFile(Buffer.from('sdk-contract'), 'skill/SKILL.md', { type: 'text/markdown' }); params[f.wire] = f.binaryArray ? [file] : file; @@ -54,7 +55,7 @@ export async function assertRequest(req, mode, c, op) { const route = op?.path ?? c.route; assert.equal(req.method, method); assert.equal(url.pathname, `/api/v1/${mode === 'forward' ? 'forward' : 'cloud'}${route.replace(/\{[^}]+\}/g, encodeURIComponent(pathSegment))}`); - assert.equal(req.headers.get('authorization'), 'Bearer secret-pat'); + assert.equal(req.headers.get('authorization'), op?.auth === 'sa_key' ? 'Bearer contract-sa-key' : 'Bearer secret-pat'); assert.equal(url.searchParams.has('beta'), false); for (const p of op?.parameters ?? []) { if (p.location === 'query') { diff --git a/tests/sa-live-configuration.test.mjs b/tests/sa-live-configuration.test.mjs index 59fc263..9b8e642 100644 --- a/tests/sa-live-configuration.test.mjs +++ b/tests/sa-live-configuration.test.mjs @@ -8,7 +8,7 @@ import { validateSAEnvironment } from '../scripts/run-sa-live.mjs'; import { safeFailure } from './sa/diagnostics.mjs'; test('SA preflight validates both regions before starting read or execution tests', () => { - for (const mode of ['readonly', 'execution']) { + for (const mode of ['readonly', 'execution', 'tokens']) { assert.throws(() => validateSAEnvironment({ QODER_SA_CN_KEY: 'cn' }, mode), /QODER_SA_GLOBAL_KEY is required/); } validateSAEnvironment({ QODER_SA_CN_KEY: 'cn', QODER_SA_GLOBAL_KEY: 'global' }, 'readonly'); @@ -30,6 +30,12 @@ test('SA execution requires both write and execution flags', () => { validateSAEnvironment(env, 'execution'); }); +test('Forward SAT management requires writes but does not require model execution', () => { + const env = { QODER_SA_CN_KEY: 'cn', QODER_SA_GLOBAL_KEY: 'global', QODER_SA_LIVE_ALLOW_EXECUTION: 'false' }; + assert.throws(() => validateSAEnvironment(env, 'tokens'), /QODER_SA_LIVE_ALLOW_WRITE=true is required/); + validateSAEnvironment({ ...env, QODER_SA_LIVE_ALLOW_WRITE: 'true' }, 'tokens'); +}); + test('SA entry loads the env file, respects explicit empty environment and fails before child tests', () => { const dir = mkdtempSync(join(tmpdir(), 'qca-sa-entry-')); try { diff --git a/tests/sa/tokens-live.mjs b/tests/sa/tokens-live.mjs new file mode 100644 index 0000000..bf27b9f --- /dev/null +++ b/tests/sa/tokens-live.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import { ForwardClient } from '../../dist/index.js'; +import { safeFailure } from './diagnostics.mjs'; +import { validateSAEnvironment } from '../../scripts/run-sa-live.mjs'; + +validateSAEnvironment(process.env, 'tokens'); + +async function requestLog(input, init) { + const response = await fetch(input, init); + const url = new URL(typeof input === 'string' ? input : input.url); + console.log(`http method=${init?.method ?? input.method ?? 'GET'} path=${url.pathname} status=${response.status} request_id=${response.headers.get('x-request-id') ?? ''}`); + return response; +} + +function verifyToken(token, subject, identityID) { + assert.ok(token.access_token && token.type === 'service_account_token' && token.token_type === 'Bearer' + && token.auth_token_id && token.credential_id && token.service_account_id + && token.subject_type === subject && token.identity_id === identityID + && token.expires_in > 0 && token.expires_in <= 600 && Date.parse(token.expires_at) > Date.now(), + 'Issued SAT metadata did not match the requested subject and lifetime'); + console.log(`issued subject=${subject} auth_token_id=${token.auth_token_id} credential_id=${token.credential_id} identity_id=${token.identity_id ?? ''} expires_at=${token.expires_at}`); +} + +async function denied(action, statuses) { + try { await action(); } + catch (error) { + if (statuses.includes(error.status)) return; + throw new Error(`Unexpected access refusal: ${safeFailure(error)}`); + } + throw new Error('Expected access refusal, but the request succeeded'); +} + +for (const region of ['cn', 'global']) { + test(`Forward SAT management: ${region} admin, Identity isolation, revoke`, { timeout: 180_000 }, async () => { + const saKey = process.env[region === 'cn' ? 'QODER_SA_CN_KEY' : 'QODER_SA_GLOBAL_KEY']; + const host = region === 'cn' ? 'https://api.qoder.com.cn' : 'https://api.qoder.com'; + const options = { baseURL: `${host}/api/v1/forward/`, authMode: 'sa', maxRetries: 0, timeout: 30_000, fetch: requestLog }; + const issuer = new ForwardClient(options); + const params = { saKey, ttl_seconds: 600, metadata: { purpose: 'sdk verification', sdk: 'ts' } }; + const cleanups = []; + async function revoke(token, runtime, identityID) { + const result = await issuer.serviceAccountTokens.revoke(token.auth_token_id, { + saKey, target_credential_id: token.credential_id, target_expires_at: token.expires_at, reason: 'sdk verification complete', + }); + assert.equal(result.revoked, true, 'Revoke did not acknowledge success'); + await denied(() => identityID ? runtime.identities.configs.list(identityID, { limit: 1 }) + : runtime.templates.list({ limit: 1 }), [401, 403]); + } + let originalFailure; + try { + const adminToken = await issuer.serviceAccountTokens.create(params); + const admin = new ForwardClient({ ...options, accessToken: adminToken.access_token }); + cleanups.push([`admin SAT ${adminToken.auth_token_id}`, () => revoke(adminToken, admin)]); + verifyToken(adminToken, 'admin', undefined); + assert.ok(Array.isArray((await admin.templates.list({ limit: 1 })).data)); + const identities = []; + for (let i = 0; i < 2; i++) { + const identity = await admin.identities.create({ external_id: `sdk-ts-sat-${randomUUID()}` }); + cleanups.push([`identity ${identity.id}`, () => admin.identities.delete(identity.id)]); + assert.ok(identity.id, 'Created Identity must have an ID'); + identities.push(identity.id); + } + const identityToken = await issuer.identities.serviceAccountTokens.create(identities[0], params); + const runtime = new ForwardClient({ ...options, accessToken: identityToken.access_token }); + cleanups.push([`identity SAT ${identityToken.auth_token_id}`, () => revoke(identityToken, runtime, identities[0])]); + verifyToken(identityToken, 'identity', identities[0]); + assert.ok(Array.isArray((await runtime.identities.configs.list(identities[0], { limit: 1 })).data)); + await denied(() => runtime.identities.configs.list(identities[1], { limit: 1 }), [403]); + console.log(`verified ${region} business access and Identity isolation`); + } catch (error) { originalFailure = safeFailure(error); } + const failures = originalFailure ? [originalFailure] : []; + for (const [label, action] of cleanups.reverse()) { + try { + await action(); + console.log(`cleanup ${label}: completed`); + } catch (error) { failures.push(`cleanup ${label}: ${safeFailure(error)}`); } + } + if (failures.length) throw new Error(failures.join('; ')); + }); +} diff --git a/tests/types-smoke.ts b/tests/types-smoke.ts index ee01bbc..809827c 100644 --- a/tests/types-smoke.ts +++ b/tests/types-smoke.ts @@ -2,11 +2,28 @@ import { ForwardClient, ManagedClient, APIPromise, Stream, ResumableSessionEvent APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, NotFoundError } from 'qca-sdk'; import Forward from 'qca-sdk/forward'; import Managed from 'qca-sdk/managed'; -import type { TemplateUpdateParams, SessionEvent } from 'qca-sdk/forward'; +import type { TemplateUpdateParams, SessionEvent, ServiceAccountToken, ServiceAccountTokenRevokeResponse } from 'qca-sdk/forward'; import type { AgentUpdateParams, ManagedAgentsStreamSessionEventsUnion, SessionEventStreamParams as ManagedSessionEventStreamParams } from 'qca-sdk/managed'; const forward: ForwardClient = new Forward({ pat: async () => 'token', timeout: 5000 }); const managed: ManagedClient = new Managed(); +async function forwardTokenManagement() { + const admin: ServiceAccountToken = await forward.serviceAccountTokens.create({ saKey: 'key', ttl_seconds: 600 }); + const identity: ServiceAccountToken = await forward.identities.serviceAccountTokens.create('idn_one', { saKey: 'key', metadata: { purpose: 'runtime' } }); + const revoked: ServiceAccountTokenRevokeResponse = await forward.serviceAccountTokens.revoke(identity.auth_token_id, { + saKey: 'key', target_credential_id: identity.credential_id, target_expires_at: identity.expires_at, + }); + const runtime = new Forward({ authMode: 'sa', accessToken: admin.access_token }); + void runtime; + void revoked; + // @ts-expect-error SA Key is required even when the client has business credentials. + forward.serviceAccountTokens.create({ ttl_seconds: 600 }); + // @ts-expect-error Forward token management is not part of Managed. + managed.serviceAccountTokens; + // @ts-expect-error ttl_seconds uses seconds as a number. + forward.serviceAccountTokens.create({ saKey: 'key', ttl_seconds: '600' }); +} +void forwardTokenManagement; const derivedForward: ForwardClient = forward.withOptions({ timeout: 30_000 }); const derivedManaged: ManagedClient = managed.withOptions({ maxRetries: 0 }); void derivedForward.templates.list({}); @@ -118,8 +135,7 @@ const generatedHeaders: Headers = APIError.generate(418, {}).headers; void [timeoutError, generatedStatus, generatedHeaders]; // @ts-expect-error creating a Template requires environment_id. void forward.templates.create({ name: 'missing environment', model: 'ultimate' }); -// @ts-expect-error there is no Service Account Token resource. -forward.serviceAccountTokens; +void forward.serviceAccountTokens; // @ts-expect-error the Managed scope excludes webhooks. managed.webhooks; // @ts-expect-error Model shape is a string or object, never a number.