feat: initial auth-runtime module

This commit is contained in:
ivanberry 2026-03-12 07:33:43 +08:00
commit 70cf86889e
31 changed files with 897 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

103
README.md Normal file
View File

@ -0,0 +1,103 @@
# @clawd/auth-runtime
Shared TypeScript auth runtime for OpenClaw skills.
## Features
- Token caching with configurable TTL
- Automatic token refresh
- Session-expired auto retry (401/403)
- Environment-based configuration
- Type-safe API
## Installation
```bash
bun add file:../../_shared/auth-runtime
```
## Usage
```typescript
import {
createEnvConfig,
getAccessToken,
requestApiWithAutoRefresh,
} from '@clawd/auth-runtime';
const config = createEnvConfig();
const token = await getAccessToken(false, config);
const result = await requestApiWithAutoRefresh(
'POST',
`${config.authBase}/ecom/tasks/scrape`,
false,
config,
JSON.stringify({ url: 'https://detail.1688.com/offer/123.html' }),
token,
);
```
## API
### Functions
#### `createEnvConfig(): EnvConfig`
Create configuration from environment variables:
- `AUTH_BASE`: Auth base URL (default: https://api-gw-test.yuanwei-lnc.com)
- `CLIENT_KEY`: Client key (required)
- `AUTH_CACHE_DIR`: Cache directory (default: /tmp/skill-auth-cache)
- `AUTH_MIN_TTL_SEC`: Minimum token TTL in seconds (default: 60)
#### `getAccessToken(dryRun, config): Promise<string>`
Get access token with caching.
#### `refreshAccessToken(dryRun, config): Promise<string>`
Refresh access token (bypass cache).
#### `fetchSessionJson(dryRun, config): Promise<SessionResponse>`
Fetch session JSON from auth endpoint.
#### `requestApi(method, url, authToken?, body?): Promise<ApiResponse>`
Make HTTP request with optional authorization header.
#### `isRetryableSessionError(response): boolean`
Check whether response likely indicates expired runtime session.
#### `requestApiWithAutoRefresh(method, url, dryRun, config, body?, accessToken?): Promise<ApiResponse>`
Call API with one automatic token refresh + retry on session-expired style errors.
### Types
#### `EnvConfig`
Environment configuration.
#### `SessionResponse`
Session response from auth endpoint.
#### `CachedTokenData`
Cached token data.
#### `HttpMethod`
Supported HTTP methods.
#### `ApiResponse`
HTTP response.
## Building
```bash
bun run build
```

20
bun.lock Normal file
View File

@ -0,0 +1,20 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@clawd/auth-runtime",
"devDependencies": {
"@types/node": "^25.3.3",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

26
dist/auth.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import type { ApiResponse, EnvConfig, HttpMethod, SessionResponse } from './types.js';
/**
* Create environment configuration from process.env
*/
export declare function createEnvConfig(): EnvConfig;
/**
* Fetch session JSON from auth endpoint
*/
export declare function fetchSessionJson(dryRun: boolean, config: EnvConfig): Promise<SessionResponse>;
/**
* Get access token with caching
*/
export declare function getAccessToken(dryRun: boolean, config: EnvConfig): Promise<string>;
/**
* Refresh access token (bypass cache)
*/
export declare function refreshAccessToken(dryRun: boolean, config: EnvConfig): Promise<string>;
/**
* Check whether response likely indicates expired/invalid runtime session.
*/
export declare function isRetryableSessionError(response: ApiResponse): boolean;
/**
* Make API request with automatic runtime token refresh and one retry.
*/
export declare function requestApiWithAutoRefresh(method: HttpMethod, url: string, dryRun: boolean, config: EnvConfig, body?: string, accessToken?: string): Promise<ApiResponse>;
//# sourceMappingURL=auth.d.ts.map

1
dist/auth.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAatF;;GAEG;AACH,wBAAgB,eAAe,IAAI,SAAS,CAO3C;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,eAAe,CAAC,CAiC1B;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,MAAM,CAAC,CAoBjB;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,MAAM,CAAC,CAejB;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,WAAW,GAAG,OAAO,CAWtE;AAED;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,SAAS,EACjB,IAAI,CAAC,EAAE,MAAM,EACb,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,WAAW,CAAC,CAUtB"}

107
dist/auth.js vendored Normal file
View File

@ -0,0 +1,107 @@
import { requestApi } from './http.js';
import { getCacheFile, readCachedToken, writeCache, deleteCache } from './cache.js';
const SESSION_RETRYABLE_STATUS = new Set([401, 403]);
const SESSION_RETRYABLE_BODY_MARKERS = [
'session not found or expired',
'invalid or expired token',
'unauthorized',
'client key expired',
'client key revoked',
];
/**
* Create environment configuration from process.env
*/
export function createEnvConfig() {
return {
authBase: (process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''),
clientKey: process.env.CLIENT_KEY || '',
authCacheDir: process.env.AUTH_CACHE_DIR || '/tmp/skill-auth-cache',
authMinTtlSec: parseInt(process.env.AUTH_MIN_TTL_SEC || '60', 10),
};
}
/**
* Fetch session JSON from auth endpoint
*/
export async function fetchSessionJson(dryRun, config) {
if (dryRun) {
return {
accessToken: '<dry-run-token>',
hookUrl: '<dry-run-hook-url>',
hookToken: '<dry-run-hook-token>',
expiresIn: 900,
};
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const payload = JSON.stringify({ clientKey: config.clientKey });
const result = await requestApi('POST', `${config.authBase}/auth/skill-credit/session`, undefined, payload);
if (result.status < 200 || result.status >= 300) {
throw new Error(`Auth session request failed: HTTP ${result.status} - ${result.body}`);
}
const session = JSON.parse(result.body);
if (!session.accessToken) {
throw new Error(`Missing accessToken in session response: ${result.body}`);
}
return session;
}
/**
* Get access token with caching
*/
export async function getAccessToken(dryRun, config) {
if (dryRun) {
return '<dry-run-token>';
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir);
const cachedToken = readCachedToken(cacheFile, config.authMinTtlSec);
if (cachedToken) {
return cachedToken;
}
const session = await fetchSessionJson(dryRun, config);
writeCache(cacheFile, session);
return session.accessToken;
}
/**
* Refresh access token (bypass cache)
*/
export async function refreshAccessToken(dryRun, config) {
if (dryRun) {
return '<dry-run-token>';
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir);
// Remove cache file if exists
deleteCache(cacheFile);
return getAccessToken(dryRun, config);
}
/**
* Check whether response likely indicates expired/invalid runtime session.
*/
export function isRetryableSessionError(response) {
if (!SESSION_RETRYABLE_STATUS.has(response.status)) {
return false;
}
const body = (response.body || '').toLowerCase();
if (!body) {
return true;
}
return SESSION_RETRYABLE_BODY_MARKERS.some((marker) => body.includes(marker));
}
/**
* Make API request with automatic runtime token refresh and one retry.
*/
export async function requestApiWithAutoRefresh(method, url, dryRun, config, body, accessToken) {
const token = accessToken || await getAccessToken(dryRun, config);
const first = await requestApi(method, url, token, body);
if (!isRetryableSessionError(first)) {
return first;
}
const freshToken = await refreshAccessToken(dryRun, config);
return requestApi(method, url, freshToken, body);
}
//# sourceMappingURL=auth.js.map

1
dist/auth.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}

24
dist/cache.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
/**
* Generate SHA256 hash for cache key
*/
export declare function sha256(input: string): string;
/**
* Get cache file path for current AUTH_BASE and CLIENT_KEY
*/
export declare function getCacheFile(authBase: string, clientKey: string, cacheDir: string): string;
/**
* Read cached token if valid
*/
export declare function readCachedToken(cacheFile: string, minTtlSec: number): string | null;
/**
* Write token to cache
*/
export declare function writeCache(cacheFile: string, sessionJson: {
accessToken: string;
expiresIn: number;
}): void;
/**
* Delete cache file if exists
*/
export declare function deleteCache(cacheFile: string): void;
//# sourceMappingURL=cache.d.ts.map

1
dist/cache.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQ1F;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAsBf;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACtD,IAAI,CAYN;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAInD"}

65
dist/cache.js vendored Normal file
View File

@ -0,0 +1,65 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
/**
* Generate SHA256 hash for cache key
*/
export function sha256(input) {
return crypto.createHash('sha256').update(input).digest('hex');
}
/**
* Get cache file path for current AUTH_BASE and CLIENT_KEY
*/
export function getCacheFile(authBase, clientKey, cacheDir) {
const key = sha256(`${authBase}|${clientKey}`);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
return path.join(cacheDir, `session_${key}.json`);
}
/**
* Read cached token if valid
*/
export function readCachedToken(cacheFile, minTtlSec) {
if (!fs.existsSync(cacheFile)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
const now = Math.floor(Date.now() / 1000);
if (!data.accessToken || data.expiresAtEpoch <= 0) {
return null;
}
// Check if token is still valid (with min TTL buffer)
if (now + minTtlSec >= data.expiresAtEpoch) {
return null;
}
return data.accessToken;
}
catch (error) {
return null;
}
}
/**
* Write token to cache
*/
export function writeCache(cacheFile, sessionJson) {
const nowEpoch = Math.floor(Date.now() / 1000);
const expiresIn = sessionJson.expiresIn > 0 ? sessionJson.expiresIn : 900;
const expiresAtEpoch = nowEpoch + expiresIn;
const cacheData = {
accessToken: sessionJson.accessToken,
expiresAtEpoch,
createdAtEpoch: nowEpoch,
};
fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2), 'utf-8');
}
/**
* Delete cache file if exists
*/
export function deleteCache(cacheFile) {
if (fs.existsSync(cacheFile)) {
fs.unlinkSync(cacheFile);
}
}
//# sourceMappingURL=cache.js.map

1
dist/cache.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAGjC;;GAEG;AACH,MAAM,UAAU,MAAM,CAAC,KAAa;IAClC,OAAO,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACjE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,SAAiB,EAAE,QAAgB;IAChF,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,QAAQ,IAAI,SAAS,EAAE,CAAC,CAAC;IAE/C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC7B,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9C,CAAC;IAED,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,WAAW,GAAG,OAAO,CAAC,CAAC;AACpD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe,CAC7B,SAAiB,EACjB,SAAiB;IAEjB,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAoB,CAAC;QAChF,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;QAE1C,IAAI,CAAC,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,cAAc,IAAI,CAAC,EAAE,CAAC;YAClD,OAAO,IAAI,CAAC;QACd,CAAC;QAED,sDAAsD;QACtD,IAAI,GAAG,GAAG,SAAS,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YAC3C,OAAO,IAAI,CAAC;QACd,CAAC;QAED,OAAO,IAAI,CAAC,WAAW,CAAC;IAC1B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CACxB,SAAiB,EACjB,WAAuD;IAEvD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAC/C,MAAM,SAAS,GAAG,WAAW,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC;IAC1E,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;IAE5C,MAAM,SAAS,GAAoB;QACjC,WAAW,EAAE,WAAW,CAAC,WAAW;QACpC,cAAc;QACd,cAAc,EAAE,QAAQ;KACzB,CAAC;IAEF,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;AAC3E,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,WAAW,CAAC,SAAiB;IAC3C,IAAI,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAC7B,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IAC3B,CAAC;AACH,CAAC"}

6
dist/http.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
import type { ApiResponse, HttpMethod } from './types.js';
/**
* Make HTTP request to API
*/
export declare function requestApi(method: HttpMethod, url: string, authToken?: string, body?: string): Promise<ApiResponse>;
//# sourceMappingURL=http.d.ts.map

1
dist/http.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE1D;;GAEG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,EAClB,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,WAAW,CAAC,CAyBtB"}

25
dist/http.js vendored Normal file
View File

@ -0,0 +1,25 @@
/**
* Make HTTP request to API
*/
export async function requestApi(method, url, authToken, body) {
const headers = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const options = {
method,
headers,
};
if (body) {
options.body = body;
}
const response = await fetch(url, options);
const responseBody = await response.text();
return {
status: response.status,
body: responseBody,
};
}
//# sourceMappingURL=http.js.map

1
dist/http.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"http.js","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,MAAkB,EAClB,GAAW,EACX,SAAkB,EAClB,IAAa;IAEb,MAAM,OAAO,GAA2B;QACtC,cAAc,EAAE,kBAAkB;KACnC,CAAC;IAEF,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,CAAC,eAAe,CAAC,GAAG,UAAU,SAAS,EAAE,CAAC;IACnD,CAAC;IAED,MAAM,OAAO,GAAgB;QAC3B,MAAM;QACN,OAAO;KACR,CAAC;IAEF,IAAI,IAAI,EAAE,CAAC;QACT,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IACtB,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC3C,MAAM,YAAY,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IAE3C,OAAO;QACL,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,IAAI,EAAE,YAAY;KACnB,CAAC;AACJ,CAAC"}

12
dist/index.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/**
* @clawd/auth-runtime
*
* Shared TypeScript auth runtime for OpenClaw skills.
*
* Provides authentication, token caching, and HTTP utilities.
*/
export { createEnvConfig, fetchSessionJson, getAccessToken, refreshAccessToken, isRetryableSessionError, requestApiWithAutoRefresh, } from './auth.js';
export { requestApi } from './http.js';
export { sha256, getCacheFile, readCachedToken, writeCache, deleteCache, } from './cache.js';
export type { EnvConfig, SessionResponse, CachedTokenData, ApiResponse, HttpMethod, } from './types.js';
//# sourceMappingURL=index.d.ts.map

1
dist/index.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,uBAAuB,EACvB,yBAAyB,GAC1B,MAAM,WAAW,CAAC;AAGnB,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAGvC,OAAO,EACL,MAAM,EACN,YAAY,EACZ,eAAe,EACf,UAAU,EACV,WAAW,GACZ,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,SAAS,EACT,eAAe,EACf,eAAe,EACf,WAAW,EACX,UAAU,GACX,MAAM,YAAY,CAAC"}

14
dist/index.js vendored Normal file
View File

@ -0,0 +1,14 @@
/**
* @clawd/auth-runtime
*
* Shared TypeScript auth runtime for OpenClaw skills.
*
* Provides authentication, token caching, and HTTP utilities.
*/
// Auth functions
export { createEnvConfig, fetchSessionJson, getAccessToken, refreshAccessToken, isRetryableSessionError, requestApiWithAutoRefresh, } from './auth.js';
// HTTP utilities
export { requestApi } from './http.js';
// Cache utilities
export { sha256, getCacheFile, readCachedToken, writeCache, deleteCache, } from './cache.js';
//# sourceMappingURL=index.js.map

1
dist/index.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,iBAAiB;AACjB,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,uBAAuB,EACvB,yBAAyB,GAC1B,MAAM,WAAW,CAAC;AAEnB,iBAAiB;AACjB,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC,kBAAkB;AAClB,OAAO,EACL,MAAM,EACN,YAAY,EACZ,eAAe,EACf,UAAU,EACV,WAAW,GACZ,MAAM,YAAY,CAAC"}

43
dist/types.d.ts vendored Normal file
View File

@ -0,0 +1,43 @@
/**
* Environment configuration for auth runtime
*/
export interface EnvConfig {
/** Authentication base URL */
authBase: string;
/** Client key for authentication */
clientKey: string;
/** Directory for storing auth cache files */
authCacheDir: string;
/** Minimum TTL for cached tokens in seconds */
authMinTtlSec: number;
}
/**
* Session response from /auth/skill-credit/session
*/
export interface SessionResponse {
accessToken: string;
ownerSessionToken?: string;
hookUrl?: string;
hookToken?: string;
expiresIn: number;
}
/**
* Cached token data stored on disk
*/
export interface CachedTokenData {
accessToken: string;
expiresAtEpoch: number;
createdAtEpoch: number;
}
/**
* HTTP method used by requestApi
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
/**
* HTTP API response
*/
export interface ApiResponse {
status: number;
body: string;
}
//# sourceMappingURL=types.d.ts.map

1
dist/types.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,6CAA6C;IAC7C,YAAY,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9E;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd"}

2
dist/types.js vendored Normal file
View File

@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=types.js.map

1
dist/types.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}

33
install.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Bootstrap auth-runtime into the skill store.
# Run this ONCE before installing any skills.
#
# Usage:
# curl -fsSL https://your-forgejo/raw/auth-runtime/main/install.sh | bash
# # or:
# ./install.sh [skill-store-dir]
set -euo pipefail
STORE="${1:-$HOME/.openclaw/skills}"
DEST="$STORE/_shared/auth-runtime"
REPO="${AUTH_RUNTIME_REPO:-}" # set via env or hardcode below
# Hardcode your Forgejo URL here:
# REPO="https://git.yourserver.com/you/auth-runtime.git"
if [[ -z "$REPO" ]]; then
echo "Error: set AUTH_RUNTIME_REPO=https://git.yourserver.com/you/auth-runtime.git"
exit 1
fi
if [[ -d "$DEST/.git" ]]; then
echo "Updating auth-runtime..."
git -C "$DEST" pull --ff-only
else
echo "Installing auth-runtime to $DEST..."
mkdir -p "$STORE/_shared"
git clone "$REPO" "$DEST"
fi
echo "auth-runtime ready at $DEST"

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@clawd/auth-runtime",
"version": "1.0.0",
"description": "Shared TypeScript auth runtime for OpenClaw skills",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist"
},
"keywords": [
"auth",
"runtime",
"openclaw"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.3.3",
"typescript": "^5.9.3"
}
}

154
src/auth.ts Normal file
View File

@ -0,0 +1,154 @@
import type { ApiResponse, EnvConfig, HttpMethod, SessionResponse } from './types.js';
import { requestApi } from './http.js';
import { getCacheFile, readCachedToken, writeCache, deleteCache } from './cache.js';
const SESSION_RETRYABLE_STATUS = new Set([401, 403]);
const SESSION_RETRYABLE_BODY_MARKERS = [
'session not found or expired',
'invalid or expired token',
'unauthorized',
'client key expired',
'client key revoked',
];
/**
* Create environment configuration from process.env
*/
export function createEnvConfig(): EnvConfig {
return {
authBase: (process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''),
clientKey: process.env.CLIENT_KEY || '',
authCacheDir: process.env.AUTH_CACHE_DIR || '/tmp/skill-auth-cache',
authMinTtlSec: parseInt(process.env.AUTH_MIN_TTL_SEC || '60', 10),
};
}
/**
* Fetch session JSON from auth endpoint
*/
export async function fetchSessionJson(
dryRun: boolean,
config: EnvConfig
): Promise<SessionResponse> {
if (dryRun) {
return {
accessToken: '<dry-run-token>',
hookUrl: '<dry-run-hook-url>',
hookToken: '<dry-run-hook-token>',
expiresIn: 900,
};
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const payload = JSON.stringify({ clientKey: config.clientKey });
const result = await requestApi(
'POST',
`${config.authBase}/auth/skill-credit/session`,
undefined,
payload
);
if (result.status < 200 || result.status >= 300) {
throw new Error(`Auth session request failed: HTTP ${result.status} - ${result.body}`);
}
const session = JSON.parse(result.body) as SessionResponse;
if (!session.accessToken) {
throw new Error(`Missing accessToken in session response: ${result.body}`);
}
return session;
}
/**
* Get access token with caching
*/
export async function getAccessToken(
dryRun: boolean,
config: EnvConfig
): Promise<string> {
if (dryRun) {
return '<dry-run-token>';
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir);
const cachedToken = readCachedToken(cacheFile, config.authMinTtlSec);
if (cachedToken) {
return cachedToken;
}
const session = await fetchSessionJson(dryRun, config);
writeCache(cacheFile, session);
return session.accessToken;
}
/**
* Refresh access token (bypass cache)
*/
export async function refreshAccessToken(
dryRun: boolean,
config: EnvConfig
): Promise<string> {
if (dryRun) {
return '<dry-run-token>';
}
if (!config.clientKey) {
throw new Error('CLIENT_KEY is required');
}
const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir);
// Remove cache file if exists
deleteCache(cacheFile);
return getAccessToken(dryRun, config);
}
/**
* Check whether response likely indicates expired/invalid runtime session.
*/
export function isRetryableSessionError(response: ApiResponse): boolean {
if (!SESSION_RETRYABLE_STATUS.has(response.status)) {
return false;
}
const body = (response.body || '').toLowerCase();
if (!body) {
return true;
}
return SESSION_RETRYABLE_BODY_MARKERS.some((marker) => body.includes(marker));
}
/**
* Make API request with automatic runtime token refresh and one retry.
*/
export async function requestApiWithAutoRefresh(
method: HttpMethod,
url: string,
dryRun: boolean,
config: EnvConfig,
body?: string,
accessToken?: string,
): Promise<ApiResponse> {
const token = accessToken || await getAccessToken(dryRun, config);
const first = await requestApi(method, url, token, body);
if (!isRetryableSessionError(first)) {
return first;
}
const freshToken = await refreshAccessToken(dryRun, config);
return requestApi(method, url, freshToken, body);
}

83
src/cache.ts Normal file
View File

@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import type { CachedTokenData } from './types.js';
/**
* Generate SHA256 hash for cache key
*/
export function sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
/**
* Get cache file path for current AUTH_BASE and CLIENT_KEY
*/
export function getCacheFile(authBase: string, clientKey: string, cacheDir: string): string {
const key = sha256(`${authBase}|${clientKey}`);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
return path.join(cacheDir, `session_${key}.json`);
}
/**
* Read cached token if valid
*/
export function readCachedToken(
cacheFile: string,
minTtlSec: number
): string | null {
if (!fs.existsSync(cacheFile)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) as CachedTokenData;
const now = Math.floor(Date.now() / 1000);
if (!data.accessToken || data.expiresAtEpoch <= 0) {
return null;
}
// Check if token is still valid (with min TTL buffer)
if (now + minTtlSec >= data.expiresAtEpoch) {
return null;
}
return data.accessToken;
} catch (error) {
return null;
}
}
/**
* Write token to cache
*/
export function writeCache(
cacheFile: string,
sessionJson: { accessToken: string; expiresIn: number }
): void {
const nowEpoch = Math.floor(Date.now() / 1000);
const expiresIn = sessionJson.expiresIn > 0 ? sessionJson.expiresIn : 900;
const expiresAtEpoch = nowEpoch + expiresIn;
const cacheData: CachedTokenData = {
accessToken: sessionJson.accessToken,
expiresAtEpoch,
createdAtEpoch: nowEpoch,
};
fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2), 'utf-8');
}
/**
* Delete cache file if exists
*/
export function deleteCache(cacheFile: string): void {
if (fs.existsSync(cacheFile)) {
fs.unlinkSync(cacheFile);
}
}

36
src/http.ts Normal file
View File

@ -0,0 +1,36 @@
import type { ApiResponse, HttpMethod } from './types.js';
/**
* Make HTTP request to API
*/
export async function requestApi(
method: HttpMethod,
url: string,
authToken?: string,
body?: string
): Promise<ApiResponse> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const options: RequestInit = {
method,
headers,
};
if (body) {
options.body = body;
}
const response = await fetch(url, options);
const responseBody = await response.text();
return {
status: response.status,
body: responseBody,
};
}

38
src/index.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* @clawd/auth-runtime
*
* Shared TypeScript auth runtime for OpenClaw skills.
*
* Provides authentication, token caching, and HTTP utilities.
*/
// Auth functions
export {
createEnvConfig,
fetchSessionJson,
getAccessToken,
refreshAccessToken,
isRetryableSessionError,
requestApiWithAutoRefresh,
} from './auth.js';
// HTTP utilities
export { requestApi } from './http.js';
// Cache utilities
export {
sha256,
getCacheFile,
readCachedToken,
writeCache,
deleteCache,
} from './cache.js';
// Types
export type {
EnvConfig,
SessionResponse,
CachedTokenData,
ApiResponse,
HttpMethod,
} from './types.js';

46
src/types.ts Normal file
View File

@ -0,0 +1,46 @@
/**
* Environment configuration for auth runtime
*/
export interface EnvConfig {
/** Authentication base URL */
authBase: string;
/** Client key for authentication */
clientKey: string;
/** Directory for storing auth cache files */
authCacheDir: string;
/** Minimum TTL for cached tokens in seconds */
authMinTtlSec: number;
}
/**
* Session response from /auth/skill-credit/session
*/
export interface SessionResponse {
accessToken: string;
ownerSessionToken?: string;
hookUrl?: string;
hookToken?: string;
expiresIn: number;
}
/**
* Cached token data stored on disk
*/
export interface CachedTokenData {
accessToken: string;
expiresAtEpoch: number;
createdAtEpoch: number;
}
/**
* HTTP method used by requestApi
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
/**
* HTTP API response
*/
export interface ApiResponse {
status: number;
body: string;
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}