@circle-rd/auth-ai 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/ai.d.ts +32 -0
- package/dist/ai.js +65 -0
- package/dist/browser/client.d.ts +39 -0
- package/dist/browser/client.js +88 -0
- package/dist/browser/index.d.ts +10 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/store.d.ts +43 -0
- package/dist/browser/store.js +99 -0
- package/dist/claims.d.ts +60 -0
- package/dist/claims.js +68 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +204 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +80 -0
- package/dist/errors.d.ts +82 -0
- package/dist/errors.js +87 -0
- package/dist/fastify/index.d.ts +21 -0
- package/dist/fastify/index.js +88 -0
- package/dist/gateway-errors.d.ts +14 -0
- package/dist/gateway-errors.js +48 -0
- package/dist/gateway.d.ts +72 -0
- package/dist/gateway.js +115 -0
- package/dist/grants.d.ts +72 -0
- package/dist/grants.js +157 -0
- package/dist/http.d.ts +30 -0
- package/dist/http.js +63 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +10 -0
- package/dist/introspection.d.ts +13 -0
- package/dist/introspection.js +12 -0
- package/dist/jwt.d.ts +16 -0
- package/dist/jwt.js +35 -0
- package/dist/oauth-errors.d.ts +9 -0
- package/dist/oauth-errors.js +50 -0
- package/dist/provider.d.ts +8 -0
- package/dist/provider.js +86 -0
- package/dist/react/index.d.ts +44 -0
- package/dist/react/index.js +88 -0
- package/dist/server/backchannel-logout.d.ts +47 -0
- package/dist/server/backchannel-logout.js +105 -0
- package/dist/server/bearer.d.ts +30 -0
- package/dist/server/bearer.js +58 -0
- package/dist/server/billing.d.ts +78 -0
- package/dist/server/billing.js +83 -0
- package/dist/server/consumption.d.ts +40 -0
- package/dist/server/consumption.js +79 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.js +6 -0
- package/dist/server/machine-api.d.ts +17 -0
- package/dist/server/machine-api.js +31 -0
- package/dist/server/machine-tokens.d.ts +13 -0
- package/dist/server/machine-tokens.js +49 -0
- package/dist/server/protected-resource.d.ts +27 -0
- package/dist/server/protected-resource.js +37 -0
- package/dist/session-user.d.ts +21 -0
- package/dist/session-user.js +35 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.js +1 -0
- package/dist/vue/index.d.ts +42 -0
- package/dist/vue/index.js +82 -0
- package/dist/wallet.d.ts +182 -0
- package/dist/wallet.js +184 -0
- package/dist/web/ai-routes.d.ts +8 -0
- package/dist/web/ai-routes.js +38 -0
- package/dist/web/cookies.d.ts +11 -0
- package/dist/web/cookies.js +46 -0
- package/dist/web/handlers.d.ts +47 -0
- package/dist/web/handlers.js +290 -0
- package/dist/web/http.d.ts +24 -0
- package/dist/web/http.js +85 -0
- package/dist/web/index.d.ts +7 -0
- package/dist/web/index.js +4 -0
- package/dist/web/seal.d.ts +11 -0
- package/dist/web/seal.js +39 -0
- package/dist/web/sessions.d.ts +43 -0
- package/dist/web/sessions.js +39 -0
- package/package.json +127 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CIRCLE <https://circle-cyber.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,324 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs-site/public/logo-dark.svg">
|
|
4
|
+
<img src="docs-site/public/logo-light.svg" alt="CIRCLE auth-ai logo" width="140" />
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<h1 align="center">CIRCLE auth-ai</h1>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<em>The TypeScript SDK for CIRCLE auth-service — sign-in, entitlements, billing and LLM credit, in one library.</em>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://github.com/circle-rd/auth-ai/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/circle-rd/auth-ai/actions/workflows/ci.yml/badge.svg"></a>
|
|
16
|
+
<a href="https://www.npmjs.com/package/@circle-rd/auth-ai"><img alt="npm" src="https://img.shields.io/npm/v/@circle-rd/auth-ai?logo=npm"></a>
|
|
17
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg"></a>
|
|
18
|
+
<a href="https://nodejs.org"><img alt="Node" src="https://img.shields.io/badge/node-22-3c873a?logo=node.js&logoColor=white"></a>
|
|
19
|
+
<a href="https://www.typescriptlang.org"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-strict-3178C6.svg?logo=typescript&logoColor=white"></a>
|
|
20
|
+
<a href="https://github.com/circle-rd/auth-service"><img alt="auth-service" src="https://img.shields.io/badge/for-auth--service-black.svg"></a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
Let your application sign users in with a self-hosted
|
|
24
|
+
[CIRCLE auth-service](https://github.com/circle-rd/auth-service), read what they
|
|
25
|
+
are entitled to (roles, permissions, plan features), meter what they consume,
|
|
26
|
+
and call LLMs on their behalf through a [LiteLLM](https://docs.litellm.ai)
|
|
27
|
+
gateway that bills their credit wallet — without writing any OAuth, token
|
|
28
|
+
refresh, logout or billing plumbing yourself.
|
|
29
|
+
|
|
30
|
+
Full documentation: **https://docs.circle-cyber.com/auth-ai/**
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const handlers = await createAuthHandlers({
|
|
34
|
+
auth,
|
|
35
|
+
grants,
|
|
36
|
+
sessions,
|
|
37
|
+
secret,
|
|
38
|
+
ai,
|
|
39
|
+
});
|
|
40
|
+
await app.register(authPlugin, { handlers }); // /auth/login, /callback, /me, /logout…
|
|
41
|
+
|
|
42
|
+
app.get("/api/models", { preHandler: requireAuth(handlers) }, async (request) =>
|
|
43
|
+
ai.forUser(request.authSession!.userId).gateway.listModels(),
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Principles
|
|
48
|
+
|
|
49
|
+
- **Do not reinvent OAuth.** Protocol work stays with the maintained libraries
|
|
50
|
+
[`openid-client`](https://github.com/panva/openid-client) and
|
|
51
|
+
[`jose`](https://github.com/panva/jose); the SDK adds what is specific to
|
|
52
|
+
auth-service on top of them.
|
|
53
|
+
- **Backend-for-frontend by default.** The browser holds one opaque session
|
|
54
|
+
cookie. Access and refresh tokens never reach it.
|
|
55
|
+
- **Fail loud, leak nothing.** Invalid state is rejected, not repaired. Errors
|
|
56
|
+
carry stable codes; HTTP responses expose the code and a generic message, and
|
|
57
|
+
the detail stays in your logs.
|
|
58
|
+
- **Strict at every boundary.** Every external payload (tokens, API answers,
|
|
59
|
+
cookies, environment) is validated with [Zod](https://zod.dev); `any` is
|
|
60
|
+
banned.
|
|
61
|
+
- **One package, hard boundaries.** Browser entry points import no server code
|
|
62
|
+
(a test enforces it); frameworks are optional peer dependencies.
|
|
63
|
+
- **Centralized spending.** The SDK never debits a wallet itself: the gateway
|
|
64
|
+
bills each call, so margin and pricing stay under the administrator's control.
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
**Sign-in and sessions**
|
|
69
|
+
|
|
70
|
+
- Authorization-code flow with PKCE, `state` and `nonce`, multi-audience
|
|
71
|
+
tokens ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)), TOTP handled by
|
|
72
|
+
the provider.
|
|
73
|
+
- One serialized refresh-token chain per user, with per-audience access tokens
|
|
74
|
+
minted on demand and never stored.
|
|
75
|
+
- Sealed one-shot login cookie, hashed session ids, `__Host-` cookies,
|
|
76
|
+
same-origin redirect targets, refused cross-site logout.
|
|
77
|
+
- Claims (roles, permissions, features, plan, organization) renewed from the
|
|
78
|
+
provider on a schedule.
|
|
79
|
+
- **Back-channel logout**: signature, `typ`, audience, event, lifetime and
|
|
80
|
+
replay checks, then the sessions and grant of the user end.
|
|
81
|
+
|
|
82
|
+
**Entitlements**
|
|
83
|
+
|
|
84
|
+
- Typed claims and helpers: `hasRole`, `hasPermission` (write implies read),
|
|
85
|
+
`hasFeature`, `getFeature`.
|
|
86
|
+
- Bearer-token verification for your own API, with RFC 9728
|
|
87
|
+
protected-resource metadata and `WWW-Authenticate` challenges (MCP-friendly).
|
|
88
|
+
|
|
89
|
+
**LLM gateway and credit wallet**
|
|
90
|
+
|
|
91
|
+
- One switch in auth-service ("LiteLLM support") and one option here
|
|
92
|
+
(`gatewayUrl`) turn on the gateway and wallet audiences.
|
|
93
|
+
- An origin-locked `fetch` that signs OpenAI-compatible calls with a fresh user
|
|
94
|
+
token; model listing; MCP endpoint URLs; gateway errors mapped
|
|
95
|
+
(`402` → `GW_001`, `429` with `Retry-After`, …).
|
|
96
|
+
- Wallet reads: balance, spend statistics, per-session usage, transactions,
|
|
97
|
+
top-up link. Amounts are exact `bigint` micro-euros.
|
|
98
|
+
|
|
99
|
+
**Billing and consumption**
|
|
100
|
+
|
|
101
|
+
- Machine client (`client_credentials`, cached and single-flighted): a user's
|
|
102
|
+
subscription, metered usage and invoices; usage reporting with idempotency
|
|
103
|
+
keys for features billed per call.
|
|
104
|
+
|
|
105
|
+
**Frontend**
|
|
106
|
+
|
|
107
|
+
- Framework-agnostic browser client and observable stores, React hooks
|
|
108
|
+
(`useAuth`, `useUser`, `useEntitlements`, `useWallet`, `useModels`…) and Vue
|
|
109
|
+
composables.
|
|
110
|
+
|
|
111
|
+
**Delivery**
|
|
112
|
+
|
|
113
|
+
- Web-standard `Request` → `Response` handlers (mount them anywhere) and a
|
|
114
|
+
Fastify plugin.
|
|
115
|
+
- ESM, Node 22+, full type declarations, MIT licensed.
|
|
116
|
+
|
|
117
|
+
## Install
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
pnpm add @circle-rd/auth-ai
|
|
121
|
+
# optional peers, as you need them
|
|
122
|
+
pnpm add fastify react vue
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Register your application in auth-service first: a confidential application with
|
|
126
|
+
the **LiteLLM support** switch on (if you call the gateway), the redirect URI
|
|
127
|
+
`https://<host>/auth/callback` and the back-channel logout URI
|
|
128
|
+
`https://<host>/auth/backchannel-logout`.
|
|
129
|
+
|
|
130
|
+
## Quick start
|
|
131
|
+
|
|
132
|
+
A Fastify backend (the stores below live in memory: demo only, see
|
|
133
|
+
[Production](#production)):
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import Fastify from "fastify";
|
|
137
|
+
import {
|
|
138
|
+
GrantManager,
|
|
139
|
+
MemoryGrantStore,
|
|
140
|
+
createAiClient,
|
|
141
|
+
createAuthClient,
|
|
142
|
+
} from "@circle-rd/auth-ai";
|
|
143
|
+
import { authPlugin, requireAuth } from "@circle-rd/auth-ai/fastify";
|
|
144
|
+
import { MemorySessionStore, createAuthHandlers } from "@circle-rd/auth-ai/web";
|
|
145
|
+
|
|
146
|
+
const auth = createAuthClient({
|
|
147
|
+
issuer: "https://auth.example.com",
|
|
148
|
+
clientId: "my-app",
|
|
149
|
+
clientSecret: process.env.AUTH_CLIENT_SECRET,
|
|
150
|
+
redirectUri: "https://app.example.com/auth/callback",
|
|
151
|
+
gatewayUrl: "https://llm.example.com",
|
|
152
|
+
});
|
|
153
|
+
const grants = new GrantManager({ auth, store: new MemoryGrantStore() });
|
|
154
|
+
const ai = createAiClient({ auth, grants });
|
|
155
|
+
|
|
156
|
+
const handlers = await createAuthHandlers({
|
|
157
|
+
auth,
|
|
158
|
+
grants,
|
|
159
|
+
ai, // adds /auth/wallet, /auth/wallet/stats and /auth/models for your frontend
|
|
160
|
+
sessions: new MemorySessionStore(),
|
|
161
|
+
secret: process.env.COOKIE_SECRET!, // 32+ random characters
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const app = Fastify();
|
|
165
|
+
await app.register(authPlugin, { handlers });
|
|
166
|
+
|
|
167
|
+
app.get("/api/models", { preHandler: requireAuth(handlers) }, async (request) =>
|
|
168
|
+
ai.forUser(request.authSession!.userId).gateway.listModels(),
|
|
169
|
+
);
|
|
170
|
+
await app.listen({ port: 3000 });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A React frontend:
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
import { AuthProvider, useAuth, useWallet } from "@circle-rd/auth-ai/react";
|
|
177
|
+
|
|
178
|
+
function Header() {
|
|
179
|
+
const { status, user, login, logout } = useAuth();
|
|
180
|
+
const { data: wallet } = useWallet({ pollIntervalMs: 15_000 });
|
|
181
|
+
if (status !== "authenticated")
|
|
182
|
+
return <button onClick={() => login()}>Sign in</button>;
|
|
183
|
+
return (
|
|
184
|
+
<p>
|
|
185
|
+
{user?.name} · canSpend={String(wallet?.canSpend)}{" "}
|
|
186
|
+
<button onClick={() => void logout()}>Sign out</button>
|
|
187
|
+
</p>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
root.render(
|
|
192
|
+
<AuthProvider>
|
|
193
|
+
<Header />
|
|
194
|
+
</AuthProvider>,
|
|
195
|
+
);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Calling the gateway with any OpenAI-compatible SDK:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const { gateway } = ai.forUser(userId);
|
|
202
|
+
const client = new OpenAI({
|
|
203
|
+
baseURL: gateway.openAiBaseUrl,
|
|
204
|
+
apiKey: "unused", // replaced by a fresh user token on every request
|
|
205
|
+
fetch: gateway.fetch,
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A complete, deployable application lives in [`examples/minimal`](examples/minimal).
|
|
210
|
+
|
|
211
|
+
## Entry points
|
|
212
|
+
|
|
213
|
+
| Import | Runs in | What it gives you |
|
|
214
|
+
| ---------------------------- | ------------------- | ---------------------------------------------------------------------------------- |
|
|
215
|
+
| `@circle-rd/auth-ai` | Node, edge, browser | OIDC client, grants, claims helpers, wallet and gateway clients, error registry |
|
|
216
|
+
| `@circle-rd/auth-ai/server` | Node, edge | Bearer verification, back-channel logout, protected-resource metadata, machine API |
|
|
217
|
+
| `@circle-rd/auth-ai/web` | Node, edge | Sign-in handlers (`Request` → `Response`), session store contract |
|
|
218
|
+
| `@circle-rd/auth-ai/fastify` | Node | Fastify plugin and `requireAuth` / `loadSession` hooks (`fastify` ≥ 5.12.6) |
|
|
219
|
+
| `@circle-rd/auth-ai/client` | Browser | Cookie-only browser client and observable stores |
|
|
220
|
+
| `@circle-rd/auth-ai/react` | Browser | `AuthProvider` and hooks (`react` ≥ 18) |
|
|
221
|
+
| `@circle-rd/auth-ai/vue` | Browser | `createAuthPlugin` and composables (`vue` ≥ 3.4) |
|
|
222
|
+
|
|
223
|
+
## Architecture
|
|
224
|
+
|
|
225
|
+
```mermaid
|
|
226
|
+
sequenceDiagram
|
|
227
|
+
autonumber
|
|
228
|
+
participant B as Browser
|
|
229
|
+
participant A as Your backend (auth-ai)
|
|
230
|
+
participant S as auth-service
|
|
231
|
+
participant G as LiteLLM gateway
|
|
232
|
+
B->>A: GET /auth/login
|
|
233
|
+
A-->>B: 302 to auth-service (PKCE, resources) + sealed cookie
|
|
234
|
+
B->>S: sign in (password, TOTP…)
|
|
235
|
+
S-->>B: 302 /auth/callback?code
|
|
236
|
+
B->>A: GET /auth/callback
|
|
237
|
+
A->>S: code → tokens (client secret)
|
|
238
|
+
A-->>B: 303 + opaque session cookie
|
|
239
|
+
B->>A: GET /auth/wallet (cookie)
|
|
240
|
+
A->>S: wallet read (user token for auth-service)
|
|
241
|
+
B->>A: POST /api/chat (cookie)
|
|
242
|
+
A->>G: call (user token for the gateway)
|
|
243
|
+
G->>S: debit the wallet after the call
|
|
244
|
+
S-->>A: back-channel logout on sign-out anywhere
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
auth-ai/
|
|
249
|
+
├── 📁 src/
|
|
250
|
+
│ ├── client.ts AuthClient — discovery, PKCE login, refresh, logout, revoke, introspect
|
|
251
|
+
│ ├── config.ts Zod-validated configuration (gatewayUrl adds the gateway audiences)
|
|
252
|
+
│ ├── grants.ts GrantManager — serialized refresh rotation, per-audience tokens
|
|
253
|
+
│ ├── claims.ts Claim schemas and entitlement helpers
|
|
254
|
+
│ ├── wallet.ts WalletClient and wallet schemas (bigint micro-euros)
|
|
255
|
+
│ ├── gateway.ts GatewayClient — origin-locked fetch, listings, MCP URLs
|
|
256
|
+
│ ├── ai.ts createAiClient().forUser(grantId)
|
|
257
|
+
│ ├── errors.ts AuthAiError + ERR registry (OIDC_*, TOK_*, GW_*, SES_*…)
|
|
258
|
+
│ ├── 📁 server/ Bearer verifier, back-channel logout, machine API
|
|
259
|
+
│ ├── 📁 web/ Sign-in handlers, sealed cookies, session store contract
|
|
260
|
+
│ ├── 📁 fastify/ Fastify adapter
|
|
261
|
+
│ ├── 📁 browser/ Cookie-only client and stores (entry point `/client`)
|
|
262
|
+
│ ├── 📁 react/ React bindings
|
|
263
|
+
│ └── 📁 vue/ Vue bindings
|
|
264
|
+
├── 📁 examples/minimal/ Deployable end-to-end app with a self-test
|
|
265
|
+
└── 📁 docs-site/ Docus documentation (built into a container image)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Production
|
|
269
|
+
|
|
270
|
+
The SDK is storage-agnostic on purpose. Before going live:
|
|
271
|
+
|
|
272
|
+
- **Persist sessions and grants.** Implement `SessionStore` and `GrantStore` on
|
|
273
|
+
your database. The in-memory stores sign everyone out on restart and cannot
|
|
274
|
+
be shared by replicas.
|
|
275
|
+
- **Several replicas?** Provide a distributed `GrantLock` (a refresh token
|
|
276
|
+
rotates on every use; a replayed one revokes the whole family) and a shared
|
|
277
|
+
`ReplayStore` for back-channel logout tokens.
|
|
278
|
+
- **Secrets from the environment only:** the client secret and a 32+ character
|
|
279
|
+
cookie secret. Neither is ever logged or returned.
|
|
280
|
+
- **Registered URIs must match:** `redirectUri` is `<origin><basePath>/callback`
|
|
281
|
+
and the back-channel URI is `<origin><basePath>/backchannel-logout`, reachable
|
|
282
|
+
over HTTPS by auth-service.
|
|
283
|
+
- **Log with `onError`, answer with `toPublicJSON()`.** Never forward
|
|
284
|
+
`error.message` or `error.details` to a browser.
|
|
285
|
+
- **Keep `fastify` ≥ 5.12.6** (several 2026 advisories are fixed there).
|
|
286
|
+
|
|
287
|
+
## Documentation
|
|
288
|
+
|
|
289
|
+
The full documentation is published at
|
|
290
|
+
[docs.circle-cyber.com/auth-ai](https://docs.circle-cyber.com/auth-ai/):
|
|
291
|
+
concepts, one guide per framework, the configuration and API reference, the
|
|
292
|
+
error-code registry and a production checklist.
|
|
293
|
+
|
|
294
|
+
**For AI agents** — the documentation is exposed through a built-in MCP server
|
|
295
|
+
(HTTP), so agents can query it directly:
|
|
296
|
+
|
|
297
|
+
| Environment | MCP endpoint |
|
|
298
|
+
| ----------- | --------------------------------------------------------------- |
|
|
299
|
+
| Production | `https://docs.circle-cyber.com/auth-ai/mcp` |
|
|
300
|
+
| Local dev | `http://localhost:3000/mcp` (run `npm run dev` in `docs-site/`) |
|
|
301
|
+
|
|
302
|
+
**Contributing** — the development rules in [AGENTS.md](AGENTS.md) (naming,
|
|
303
|
+
TypeScript strictness, error handling, testing, security) and the process in
|
|
304
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) are mandatory for any contribution.
|
|
305
|
+
Security reports: see [SECURITY.md](SECURITY.md). Releases are tracked in
|
|
306
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
307
|
+
|
|
308
|
+
## Roadmap
|
|
309
|
+
|
|
310
|
+
- **Python port** of the same behaviours (the test suite pins them), for the
|
|
311
|
+
Python backends of the CIRCLE stack.
|
|
312
|
+
- **Migration of [gine](https://github.com/circle-rd/gine)** onto the SDK.
|
|
313
|
+
- **npm Trusted Publishing** once the first release is out, replacing the
|
|
314
|
+
publish token.
|
|
315
|
+
- **More adapters** on top of the Web-standard handlers, as needs appear.
|
|
316
|
+
|
|
317
|
+
## Credits & license
|
|
318
|
+
|
|
319
|
+
Built and maintained by [CIRCLE](https://circle-cyber.com). Stands on the
|
|
320
|
+
shoulders of [openid-client](https://github.com/panva/openid-client),
|
|
321
|
+
[jose](https://github.com/panva/jose), [Zod](https://zod.dev) and
|
|
322
|
+
[Fastify](https://fastify.dev).
|
|
323
|
+
|
|
324
|
+
Licensed under the **MIT License** — see [LICENSE](LICENSE).
|
package/dist/ai.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AuthClient } from "./client.js";
|
|
2
|
+
import { GatewayClient } from "./gateway.js";
|
|
3
|
+
import type { GrantManager } from "./grants.js";
|
|
4
|
+
import { WalletClient } from "./wallet.js";
|
|
5
|
+
type AiAuth = Pick<AuthClient, "config" | "fetch">;
|
|
6
|
+
type AiGrants = Pick<GrantManager, "getAccessToken" | "invalidate">;
|
|
7
|
+
export interface AiClientOptions {
|
|
8
|
+
readonly auth: AiAuth;
|
|
9
|
+
/** Holds the refresh token of each user: access tokens are minted from it. */
|
|
10
|
+
readonly grants: AiGrants;
|
|
11
|
+
}
|
|
12
|
+
/** What an application does for one signed-in user: read the wallet, call the gateway. */
|
|
13
|
+
export declare class UserAi {
|
|
14
|
+
#private;
|
|
15
|
+
readonly wallet: WalletClient;
|
|
16
|
+
constructor(auth: AiAuth, grants: AiGrants, grantId: string);
|
|
17
|
+
/** Needs `gatewayUrl` in the client configuration. */
|
|
18
|
+
get gateway(): GatewayClient;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Wallet and gateway clients bound to a user's grant. Each call mints (or
|
|
22
|
+
* reuses) a short-lived access token for the right audience: the wallet is
|
|
23
|
+
* served by auth-service, the gateway by its own URL.
|
|
24
|
+
*/
|
|
25
|
+
export declare class AiClient {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(options: AiClientOptions);
|
|
28
|
+
/** `grantId` is the key the user's refresh token was saved under (typically `sub`). */
|
|
29
|
+
forUser(grantId: string): UserAi;
|
|
30
|
+
}
|
|
31
|
+
export declare function createAiClient(options: AiClientOptions): AiClient;
|
|
32
|
+
export {};
|
package/dist/ai.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ERR } from "./errors.js";
|
|
2
|
+
import { GatewayClient } from "./gateway.js";
|
|
3
|
+
import { JsonHttp } from "./http.js";
|
|
4
|
+
import { WalletClient } from "./wallet.js";
|
|
5
|
+
/** What an application does for one signed-in user: read the wallet, call the gateway. */
|
|
6
|
+
export class UserAi {
|
|
7
|
+
wallet;
|
|
8
|
+
#auth;
|
|
9
|
+
#gatewayTokens;
|
|
10
|
+
#gateway;
|
|
11
|
+
constructor(auth, grants, grantId) {
|
|
12
|
+
this.#auth = auth;
|
|
13
|
+
const { config } = auth;
|
|
14
|
+
const tokensFor = (resource) => ({
|
|
15
|
+
getToken: async () => (await grants.getAccessToken(grantId, { resource })).accessToken,
|
|
16
|
+
invalidate: () => grants.invalidate(grantId, resource),
|
|
17
|
+
});
|
|
18
|
+
// A user has a single wallet audience, so the supplier ignores the key.
|
|
19
|
+
const walletTokens = tokensFor(config.issuer);
|
|
20
|
+
this.wallet = new WalletClient(new JsonHttp({
|
|
21
|
+
baseUrl: config.issuer,
|
|
22
|
+
fetch: auth.fetch,
|
|
23
|
+
timeoutMs: config.timeoutMs,
|
|
24
|
+
tokens: {
|
|
25
|
+
getToken: () => walletTokens.getToken(),
|
|
26
|
+
invalidate: () => walletTokens.invalidate(),
|
|
27
|
+
},
|
|
28
|
+
}), config.issuer);
|
|
29
|
+
this.#gatewayTokens = config.gatewayUrl
|
|
30
|
+
? tokensFor(config.gatewayUrl)
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Needs `gatewayUrl` in the client configuration. */
|
|
34
|
+
get gateway() {
|
|
35
|
+
const { gatewayUrl, timeoutMs } = this.#auth.config;
|
|
36
|
+
if (!gatewayUrl || !this.#gatewayTokens) {
|
|
37
|
+
throw ERR.CFG_001("The gateway requires `gatewayUrl` in the configuration");
|
|
38
|
+
}
|
|
39
|
+
this.#gateway ??= new GatewayClient({
|
|
40
|
+
gatewayUrl,
|
|
41
|
+
tokens: this.#gatewayTokens,
|
|
42
|
+
fetch: this.#auth.fetch,
|
|
43
|
+
timeoutMs,
|
|
44
|
+
});
|
|
45
|
+
return this.#gateway;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Wallet and gateway clients bound to a user's grant. Each call mints (or
|
|
50
|
+
* reuses) a short-lived access token for the right audience: the wallet is
|
|
51
|
+
* served by auth-service, the gateway by its own URL.
|
|
52
|
+
*/
|
|
53
|
+
export class AiClient {
|
|
54
|
+
#options;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.#options = options;
|
|
57
|
+
}
|
|
58
|
+
/** `grantId` is the key the user's refresh token was saved under (typically `sub`). */
|
|
59
|
+
forUser(grantId) {
|
|
60
|
+
return new UserAi(this.#options.auth, this.#options.grants, grantId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createAiClient(options) {
|
|
64
|
+
return new AiClient(options);
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { GatewayModel } from "../gateway.js";
|
|
3
|
+
import type { SessionUser } from "../session-user.js";
|
|
4
|
+
import type { WalletStats } from "../wallet.js";
|
|
5
|
+
declare const walletSchema: z.ZodObject<{
|
|
6
|
+
balance: z.ZodPipe<z.ZodString, z.ZodTransform<bigint, string>>;
|
|
7
|
+
isUnlimited: z.ZodBoolean;
|
|
8
|
+
canSpend: z.ZodBoolean;
|
|
9
|
+
currency: z.ZodLiteral<"eur">;
|
|
10
|
+
topupUrl: z.ZodString;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export type WalletView = z.infer<typeof walletSchema>;
|
|
13
|
+
export interface BrowserAuthOptions {
|
|
14
|
+
/** Where the backend mounted the sign-in routes. */
|
|
15
|
+
readonly basePath?: string;
|
|
16
|
+
readonly fetch?: typeof fetch;
|
|
17
|
+
/** Navigation target of `login` and `logout`; the page's `location` by default. */
|
|
18
|
+
readonly location?: Pick<Location, "assign" | "pathname" | "search">;
|
|
19
|
+
}
|
|
20
|
+
export interface BrowserAuth {
|
|
21
|
+
/** The signed-in user, or null when signed out. */
|
|
22
|
+
getUser(): Promise<SessionUser | null>;
|
|
23
|
+
loginUrl(next?: string): string;
|
|
24
|
+
/** Sends the browser to the sign-in, then back to `next` (the current page by default). */
|
|
25
|
+
login(next?: string): void;
|
|
26
|
+
/** Ends the session and the provider's single sign-on session. */
|
|
27
|
+
logout(): Promise<void>;
|
|
28
|
+
/** Needs the backend routes enabled with the `ai` option. */
|
|
29
|
+
getWallet(): Promise<WalletView>;
|
|
30
|
+
getWalletStats(days?: number): Promise<WalletStats>;
|
|
31
|
+
listModels(): Promise<GatewayModel[]>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The browser side of the backend-for-frontend: it holds no token, only talks
|
|
35
|
+
* to the application's own routes with the session cookie. Importing it pulls
|
|
36
|
+
* no server code.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createBrowserAuth(options?: BrowserAuthOptions): BrowserAuth;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AuthAiError, ERR, isAuthAiErrorCode } from "../errors.js";
|
|
3
|
+
import { gatewayModelSchema } from "../gateway.js";
|
|
4
|
+
import { sessionUserSchema } from "../session-user.js";
|
|
5
|
+
import { walletAccountSchema, walletStatsSchema } from "../wallet.js";
|
|
6
|
+
const errorEnvelopeSchema = z.object({
|
|
7
|
+
error: z.object({ code: z.string(), message: z.string() }),
|
|
8
|
+
});
|
|
9
|
+
const meSchema = z.object({ user: sessionUserSchema, expiresAt: z.number() });
|
|
10
|
+
const logoutSchema = z.object({ logoutUrl: z.string().nullable() });
|
|
11
|
+
const walletSchema = walletAccountSchema.extend({ topupUrl: z.string() });
|
|
12
|
+
const modelsSchema = z.object({ models: z.array(gatewayModelSchema) });
|
|
13
|
+
function toError(status, body) {
|
|
14
|
+
const envelope = errorEnvelopeSchema.safeParse(body);
|
|
15
|
+
if (!envelope.success) {
|
|
16
|
+
return ERR.API_001(`The server answered ${status}`, {
|
|
17
|
+
details: { status },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const { code, message } = envelope.data.error;
|
|
21
|
+
return isAuthAiErrorCode(code)
|
|
22
|
+
? new AuthAiError(code, message, { details: { status } })
|
|
23
|
+
: ERR.API_001(message, { details: { status, apiCode: code } });
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The browser side of the backend-for-frontend: it holds no token, only talks
|
|
27
|
+
* to the application's own routes with the session cookie. Importing it pulls
|
|
28
|
+
* no server code.
|
|
29
|
+
*/
|
|
30
|
+
export function createBrowserAuth(options = {}) {
|
|
31
|
+
const basePath = options.basePath ?? "/auth";
|
|
32
|
+
const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
33
|
+
const where = () => options.location ?? globalThis.location;
|
|
34
|
+
async function send(path, init = {}) {
|
|
35
|
+
const response = await doFetch(`${basePath}${path}`, {
|
|
36
|
+
credentials: "same-origin",
|
|
37
|
+
redirect: "error",
|
|
38
|
+
...init,
|
|
39
|
+
headers: { accept: "application/json", ...init.headers },
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
status: response.status,
|
|
43
|
+
ok: response.ok,
|
|
44
|
+
body: await response.json().catch(() => undefined),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function parse(schema, body) {
|
|
48
|
+
const parsed = schema.safeParse(body);
|
|
49
|
+
if (!parsed.success)
|
|
50
|
+
throw ERR.API_002(undefined, { cause: parsed.error });
|
|
51
|
+
return parsed.data;
|
|
52
|
+
}
|
|
53
|
+
async function call(path, schema, init) {
|
|
54
|
+
const result = await send(path, init);
|
|
55
|
+
if (!result.ok)
|
|
56
|
+
throw toError(result.status, result.body);
|
|
57
|
+
return parse(schema, result.body);
|
|
58
|
+
}
|
|
59
|
+
const loginUrl = (next) => next === undefined
|
|
60
|
+
? `${basePath}/login`
|
|
61
|
+
: `${basePath}/login?next=${encodeURIComponent(next)}`;
|
|
62
|
+
return {
|
|
63
|
+
async getUser() {
|
|
64
|
+
const result = await send("/me");
|
|
65
|
+
if (result.status === 401)
|
|
66
|
+
return null;
|
|
67
|
+
if (!result.ok)
|
|
68
|
+
throw toError(result.status, result.body);
|
|
69
|
+
return parse(meSchema, result.body).user;
|
|
70
|
+
},
|
|
71
|
+
loginUrl,
|
|
72
|
+
login(next) {
|
|
73
|
+
const { pathname, search } = where();
|
|
74
|
+
where().assign(loginUrl(next ?? `${pathname}${search}`));
|
|
75
|
+
},
|
|
76
|
+
async logout() {
|
|
77
|
+
const { logoutUrl } = await call("/logout", logoutSchema, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
});
|
|
80
|
+
where().assign(logoutUrl ?? "/");
|
|
81
|
+
},
|
|
82
|
+
getWallet: () => call("/wallet", walletSchema),
|
|
83
|
+
getWalletStats: (days) => call(days === undefined ? "/wallet/stats" : `/wallet/stats?days=${days}`, walletStatsSchema),
|
|
84
|
+
async listModels() {
|
|
85
|
+
return (await call("/models", modelsSchema)).models;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createBrowserAuth } from "./client.js";
|
|
2
|
+
export type { BrowserAuth, BrowserAuthOptions, WalletView } from "./client.js";
|
|
3
|
+
export { INITIAL_AUTH_STATE, createAuthStore, createObservable, createResourceStore, } from "./store.js";
|
|
4
|
+
export type { AuthState, AuthStatus, AuthStore, Observable, ResourceOptions, ResourceState, ResourceStatus, ResourceStore, } from "./store.js";
|
|
5
|
+
export { getFeature, hasFeature, hasPermission, hasRole } from "../claims.js";
|
|
6
|
+
export { AuthAiError } from "../errors.js";
|
|
7
|
+
export { formatEuros } from "../wallet.js";
|
|
8
|
+
export type { SessionUser } from "../session-user.js";
|
|
9
|
+
export type { GatewayModel } from "../gateway.js";
|
|
10
|
+
export type { WalletStats } from "../wallet.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createBrowserAuth } from "./client.js";
|
|
2
|
+
export { INITIAL_AUTH_STATE, createAuthStore, createObservable, createResourceStore, } from "./store.js";
|
|
3
|
+
export { getFeature, hasFeature, hasPermission, hasRole } from "../claims.js";
|
|
4
|
+
export { AuthAiError } from "../errors.js";
|
|
5
|
+
export { formatEuros } from "../wallet.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AuthAiError } from "../errors.js";
|
|
2
|
+
import type { SessionUser } from "../session-user.js";
|
|
3
|
+
import type { BrowserAuth } from "./client.js";
|
|
4
|
+
type Listener = () => void;
|
|
5
|
+
/** A value that notifies subscribers when it changes: what React and Vue both bind to. */
|
|
6
|
+
export interface Observable<T> {
|
|
7
|
+
get(): T;
|
|
8
|
+
set(value: T): void;
|
|
9
|
+
subscribe(listener: Listener): () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function createObservable<T>(initial: T): Observable<T>;
|
|
12
|
+
export type AuthStatus = "loading" | "authenticated" | "unauthenticated" | "error";
|
|
13
|
+
export interface AuthState {
|
|
14
|
+
readonly status: AuthStatus;
|
|
15
|
+
readonly user: SessionUser | null;
|
|
16
|
+
readonly error: AuthAiError | null;
|
|
17
|
+
}
|
|
18
|
+
export declare const INITIAL_AUTH_STATE: AuthState;
|
|
19
|
+
export interface AuthStore {
|
|
20
|
+
readonly state: Observable<AuthState>;
|
|
21
|
+
/** Reads the session again: on mount, on focus, or after an API answered 401. */
|
|
22
|
+
refresh(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function createAuthStore(auth: Pick<BrowserAuth, "getUser">): AuthStore;
|
|
25
|
+
export type ResourceStatus = "idle" | "loading" | "ready" | "error";
|
|
26
|
+
export interface ResourceState<T> {
|
|
27
|
+
readonly status: ResourceStatus;
|
|
28
|
+
readonly data: T | null;
|
|
29
|
+
readonly error: AuthAiError | null;
|
|
30
|
+
}
|
|
31
|
+
export interface ResourceStore<T> {
|
|
32
|
+
readonly state: Observable<ResourceState<T>>;
|
|
33
|
+
refresh(): Promise<void>;
|
|
34
|
+
/** Loads now and, with `pollIntervalMs`, keeps reloading; returns the stop function. */
|
|
35
|
+
start(): () => void;
|
|
36
|
+
}
|
|
37
|
+
export interface ResourceOptions {
|
|
38
|
+
readonly pollIntervalMs?: number;
|
|
39
|
+
/** Called when the load fails: e.g. refresh the session on SES_001. */
|
|
40
|
+
readonly onError?: (error: AuthAiError) => void;
|
|
41
|
+
}
|
|
42
|
+
export declare function createResourceStore<T>(load: () => Promise<T>, options?: ResourceOptions): ResourceStore<T>;
|
|
43
|
+
export {};
|