@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/dist/grants.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { ERR } from "./errors.js";
|
|
2
|
+
import { isInvalidGrant } from "./oauth-errors.js";
|
|
3
|
+
const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
4
|
+
export class MemoryGrantStore {
|
|
5
|
+
#grants = new Map();
|
|
6
|
+
get(grantId) {
|
|
7
|
+
return Promise.resolve(this.#grants.get(grantId));
|
|
8
|
+
}
|
|
9
|
+
set(grantId, refreshToken) {
|
|
10
|
+
this.#grants.set(grantId, refreshToken);
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
}
|
|
13
|
+
delete(grantId) {
|
|
14
|
+
this.#grants.delete(grantId);
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class InProcessGrantLock {
|
|
19
|
+
#tails = new Map();
|
|
20
|
+
async run(grantId, task) {
|
|
21
|
+
const previous = this.#tails.get(grantId) ?? Promise.resolve();
|
|
22
|
+
const current = previous.then(task, task);
|
|
23
|
+
const tail = current.catch(() => undefined);
|
|
24
|
+
this.#tails.set(grantId, tail);
|
|
25
|
+
try {
|
|
26
|
+
return await current;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
if (this.#tails.get(grantId) === tail)
|
|
30
|
+
this.#tails.delete(grantId);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The only place that refreshes tokens. Access tokens are minted per resource
|
|
36
|
+
* (RFC 8707) from the stored refresh token and cached in memory until shortly
|
|
37
|
+
* before they expire; they are never persisted.
|
|
38
|
+
*/
|
|
39
|
+
export class GrantManager {
|
|
40
|
+
#auth;
|
|
41
|
+
#store;
|
|
42
|
+
#lock;
|
|
43
|
+
#skewSeconds;
|
|
44
|
+
#cache = new Map();
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.#auth = options.auth;
|
|
47
|
+
this.#store = options.store;
|
|
48
|
+
this.#lock = options.lock ?? new InProcessGrantLock();
|
|
49
|
+
this.#skewSeconds =
|
|
50
|
+
options.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
|
|
51
|
+
}
|
|
52
|
+
/** Stores the refresh token obtained at sign-in, replacing any older chain. */
|
|
53
|
+
async save(grantId, refreshToken) {
|
|
54
|
+
await this.#lock.run(grantId, async () => {
|
|
55
|
+
await this.#store.set(grantId, refreshToken);
|
|
56
|
+
this.#forget(grantId);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async has(grantId) {
|
|
60
|
+
return (await this.#store.get(grantId)) !== undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Returns an access token for `resource` (or for the whole sign-in set when
|
|
64
|
+
* omitted). Throws TOK_002 when the user has no grant or the provider
|
|
65
|
+
* rejected it: the grant is then dropped and only a new sign-in restores it.
|
|
66
|
+
*/
|
|
67
|
+
async getAccessToken(grantId, options = {}) {
|
|
68
|
+
const key = cacheKey(grantId, options.resource);
|
|
69
|
+
const cached = this.#cached(key);
|
|
70
|
+
if (cached)
|
|
71
|
+
return cached;
|
|
72
|
+
return this.#lock.run(grantId, async () => {
|
|
73
|
+
// Another caller may have refreshed while this one waited for the lock.
|
|
74
|
+
const refreshed = this.#cached(key);
|
|
75
|
+
if (refreshed)
|
|
76
|
+
return refreshed;
|
|
77
|
+
const tokens = await this.#rotate(grantId, options.resource ? { resource: options.resource } : {});
|
|
78
|
+
const token = {
|
|
79
|
+
accessToken: tokens.accessToken,
|
|
80
|
+
expiresAt: tokens.expiresAt,
|
|
81
|
+
};
|
|
82
|
+
this.#cache.set(key, token);
|
|
83
|
+
return token;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Refreshes without narrowing to a resource and returns the whole token set,
|
|
88
|
+
* ID token included: the way to renew the claims of a session. The rotated
|
|
89
|
+
* refresh token is stored. Failures are those of `getAccessToken`.
|
|
90
|
+
*/
|
|
91
|
+
async refreshTokens(grantId) {
|
|
92
|
+
return this.#lock.run(grantId, () => this.#rotate(grantId, {}));
|
|
93
|
+
}
|
|
94
|
+
/** Drops a cached access token the API refused, so the next call mints a fresh one. */
|
|
95
|
+
invalidate(grantId, resource) {
|
|
96
|
+
this.#cache.delete(cacheKey(grantId, resource));
|
|
97
|
+
}
|
|
98
|
+
/** Revokes the refresh token at the provider, then forgets the grant. */
|
|
99
|
+
async revoke(grantId) {
|
|
100
|
+
await this.#lock.run(grantId, async () => {
|
|
101
|
+
const refreshToken = await this.#store.get(grantId);
|
|
102
|
+
try {
|
|
103
|
+
if (refreshToken !== undefined) {
|
|
104
|
+
await this.#auth.revoke(refreshToken, "refresh_token");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
// An unreachable provider must not keep the user signed in locally.
|
|
109
|
+
await this.#store.delete(grantId);
|
|
110
|
+
this.#forget(grantId);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** One refresh under the grant lock the caller already holds. */
|
|
115
|
+
async #rotate(grantId, options) {
|
|
116
|
+
const refreshToken = await this.#store.get(grantId);
|
|
117
|
+
if (refreshToken === undefined)
|
|
118
|
+
throw ERR.TOK_002();
|
|
119
|
+
let tokens;
|
|
120
|
+
try {
|
|
121
|
+
tokens = await this.#auth.refresh(refreshToken, options);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (isInvalidGrant(err)) {
|
|
125
|
+
await this.#store.delete(grantId);
|
|
126
|
+
this.#forget(grantId);
|
|
127
|
+
throw ERR.TOK_002(undefined, { cause: err });
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
// Providers may omit the refresh token when they do not rotate it.
|
|
132
|
+
await this.#store.set(grantId, tokens.refreshToken ?? refreshToken);
|
|
133
|
+
return tokens;
|
|
134
|
+
}
|
|
135
|
+
#cached(key) {
|
|
136
|
+
const token = this.#cache.get(key);
|
|
137
|
+
if (!token)
|
|
138
|
+
return undefined;
|
|
139
|
+
if (token.expiresAt - this.#skewSeconds > Date.now() / 1000)
|
|
140
|
+
return token;
|
|
141
|
+
this.#cache.delete(key);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
#forget(grantId) {
|
|
145
|
+
const prefix = `${grantId}\0`;
|
|
146
|
+
for (const key of this.#cache.keys()) {
|
|
147
|
+
if (key.startsWith(prefix))
|
|
148
|
+
this.#cache.delete(key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function cacheKey(grantId, resource) {
|
|
153
|
+
return `${grantId}\0${resource ?? ""}`;
|
|
154
|
+
}
|
|
155
|
+
export function createGrantManager(options) {
|
|
156
|
+
return new GrantManager(options);
|
|
157
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Supplies the Bearer token of a call, by the scope or audience the endpoint requires. */
|
|
3
|
+
export interface HttpTokens {
|
|
4
|
+
getToken(key: string): Promise<string>;
|
|
5
|
+
/** Drops a token the API refused, so the retry mints a fresh one. */
|
|
6
|
+
invalidate(key: string): void;
|
|
7
|
+
}
|
|
8
|
+
export interface JsonHttpOptions {
|
|
9
|
+
/** Origin the request paths are resolved against: no trailing slash. */
|
|
10
|
+
readonly baseUrl: string;
|
|
11
|
+
readonly fetch: typeof fetch;
|
|
12
|
+
readonly timeoutMs: number;
|
|
13
|
+
readonly tokens: HttpTokens;
|
|
14
|
+
}
|
|
15
|
+
export interface ApiRequest<T> {
|
|
16
|
+
readonly method: "GET" | "POST";
|
|
17
|
+
/** Path below the base URL; segments must already be URL-encoded. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
/** Key passed to the token supplier: a machine scope, or an audience. */
|
|
20
|
+
readonly scope: string;
|
|
21
|
+
readonly query?: Readonly<Record<string, string | number | undefined>>;
|
|
22
|
+
readonly body?: unknown;
|
|
23
|
+
readonly schema: z.ZodType<T>;
|
|
24
|
+
}
|
|
25
|
+
/** Authenticated JSON calls to the auth-service REST API. */
|
|
26
|
+
export declare class JsonHttp {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(options: JsonHttpOptions);
|
|
29
|
+
request<T>(request: ApiRequest<T>): Promise<T>;
|
|
30
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERR } from "./errors.js";
|
|
3
|
+
const apiErrorSchema = z.object({
|
|
4
|
+
error: z.object({
|
|
5
|
+
code: z.string(),
|
|
6
|
+
message: z.string(),
|
|
7
|
+
}),
|
|
8
|
+
});
|
|
9
|
+
/** Authenticated JSON calls to the auth-service REST API. */
|
|
10
|
+
export class JsonHttp {
|
|
11
|
+
#options;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.#options = options;
|
|
14
|
+
}
|
|
15
|
+
async request(request) {
|
|
16
|
+
let response = await this.#send(request);
|
|
17
|
+
// A revoked or rotated credential surfaces as 401: retry once with a fresh token.
|
|
18
|
+
if (response.status === 401) {
|
|
19
|
+
this.#options.tokens.invalidate(request.scope);
|
|
20
|
+
response = await this.#send(request);
|
|
21
|
+
}
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
throw await this.#toError(response);
|
|
24
|
+
const parsed = request.schema.safeParse(await response.json());
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
throw ERR.API_002(undefined, { cause: parsed.error });
|
|
27
|
+
}
|
|
28
|
+
return parsed.data;
|
|
29
|
+
}
|
|
30
|
+
async #send(request) {
|
|
31
|
+
const url = new URL(`${this.#options.baseUrl}${request.path}`);
|
|
32
|
+
for (const [name, value] of Object.entries(request.query ?? {})) {
|
|
33
|
+
if (value !== undefined)
|
|
34
|
+
url.searchParams.set(name, String(value));
|
|
35
|
+
}
|
|
36
|
+
const token = await this.#options.tokens.getToken(request.scope);
|
|
37
|
+
return this.#options.fetch(url, {
|
|
38
|
+
method: request.method,
|
|
39
|
+
headers: {
|
|
40
|
+
authorization: `Bearer ${token}`,
|
|
41
|
+
accept: "application/json",
|
|
42
|
+
...(request.body === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { "content-type": "application/json" }),
|
|
45
|
+
},
|
|
46
|
+
...(request.body === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { body: JSON.stringify(request.body) }),
|
|
49
|
+
redirect: "error",
|
|
50
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async #toError(response) {
|
|
54
|
+
const body = apiErrorSchema.safeParse(await response.json().catch(() => undefined));
|
|
55
|
+
if (!body.success) {
|
|
56
|
+
return ERR.API_001(`auth-service answered ${response.status}`, {
|
|
57
|
+
details: { status: response.status },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const { code, message } = body.data.error;
|
|
61
|
+
return ERR.API_001(`auth-service answered ${response.status} ${code}: ${message}`, { details: { status: response.status, apiCode: code } });
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { AuthClient, createAuthClient } from "./client.js";
|
|
2
|
+
export type { AuthClientOptions } from "./client.js";
|
|
3
|
+
export { DEFAULT_SCOPES } from "./config.js";
|
|
4
|
+
export type { AuthClientConfig, ResolvedAuthClientConfig } from "./config.js";
|
|
5
|
+
export { getFeature, hasFeature, hasPermission, accessTokenClaimsSchema, hasRole, idTokenClaimsSchema, } from "./claims.js";
|
|
6
|
+
export type { FeatureValue, IdTokenClaims, PermissionAction, } from "./claims.js";
|
|
7
|
+
export { AuthAiError, ERR } from "./errors.js";
|
|
8
|
+
export type { AuthAiErrorCode, AuthAiErrorDetails, AuthAiErrorOptions, } from "./errors.js";
|
|
9
|
+
export { GrantManager, InProcessGrantLock, MemoryGrantStore, createGrantManager, } from "./grants.js";
|
|
10
|
+
export type { AccessToken, GrantLock, GrantManagerOptions, GrantStore, } from "./grants.js";
|
|
11
|
+
export type { IntrospectionResult } from "./introspection.js";
|
|
12
|
+
export type { ClientCredentialsOptions, LoginHandshake, LoginRequest, LoginResult, LogoutUrlOptions, RefreshOptions, StartLoginOptions, TokenSet, TokenTypeHint, } from "./types.js";
|
|
13
|
+
export { AiClient, UserAi, createAiClient } from "./ai.js";
|
|
14
|
+
export type { AiClientOptions } from "./ai.js";
|
|
15
|
+
export { GatewayClient } from "./gateway.js";
|
|
16
|
+
export type { GatewayAgent, GatewayClientOptions, GatewayMcpServer, GatewayModel, GatewayTokens, } from "./gateway.js";
|
|
17
|
+
export { isInsufficientCredit, isInsufficientCreditStatus, toGatewayError, } from "./gateway-errors.js";
|
|
18
|
+
export { MICRO_PER_EUR, WalletClient, formatEuros } from "./wallet.js";
|
|
19
|
+
export type { ListTransactionsOptions, TopupOptions, WalletAccount, WalletSessionUsage, WalletSessionUsageOptions, WalletStats, WalletStatsOptions, WalletTransaction, WalletTransactionPage, WalletTransactionType, } from "./wallet.js";
|
|
20
|
+
export { WALLET_SELF_SCOPE } from "./config.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { AuthClient, createAuthClient } from "./client.js";
|
|
2
|
+
export { DEFAULT_SCOPES } from "./config.js";
|
|
3
|
+
export { getFeature, hasFeature, hasPermission, accessTokenClaimsSchema, hasRole, idTokenClaimsSchema, } from "./claims.js";
|
|
4
|
+
export { AuthAiError, ERR } from "./errors.js";
|
|
5
|
+
export { GrantManager, InProcessGrantLock, MemoryGrantStore, createGrantManager, } from "./grants.js";
|
|
6
|
+
export { AiClient, UserAi, createAiClient } from "./ai.js";
|
|
7
|
+
export { GatewayClient } from "./gateway.js";
|
|
8
|
+
export { isInsufficientCredit, isInsufficientCreditStatus, toGatewayError, } from "./gateway-errors.js";
|
|
9
|
+
export { MICRO_PER_EUR, WalletClient, formatEuros } from "./wallet.js";
|
|
10
|
+
export { WALLET_SELF_SCOPE } from "./config.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const introspectionSchema: z.ZodObject<{
|
|
3
|
+
active: z.ZodBoolean;
|
|
4
|
+
scope: z.ZodOptional<z.ZodString>;
|
|
5
|
+
client_id: z.ZodOptional<z.ZodString>;
|
|
6
|
+
sub: z.ZodOptional<z.ZodString>;
|
|
7
|
+
username: z.ZodOptional<z.ZodString>;
|
|
8
|
+
iss: z.ZodOptional<z.ZodString>;
|
|
9
|
+
aud: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
10
|
+
exp: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
iat: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
}, z.core.$loose>;
|
|
13
|
+
export type IntrospectionResult = z.infer<typeof introspectionSchema>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const introspectionSchema = z.looseObject({
|
|
3
|
+
active: z.boolean(),
|
|
4
|
+
scope: z.string().optional(),
|
|
5
|
+
client_id: z.string().optional(),
|
|
6
|
+
sub: z.string().optional(),
|
|
7
|
+
username: z.string().optional(),
|
|
8
|
+
iss: z.string().optional(),
|
|
9
|
+
aud: z.union([z.string(), z.array(z.string())]).optional(),
|
|
10
|
+
exp: z.number().optional(),
|
|
11
|
+
iat: z.number().optional(),
|
|
12
|
+
});
|
package/dist/jwt.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { JWTPayload } from "jose";
|
|
2
|
+
export interface JwtVerifierOptions {
|
|
3
|
+
readonly issuer: string;
|
|
4
|
+
readonly jwksUri: string;
|
|
5
|
+
/** Pinned to the algorithms the provider declares: `none` and HMAC never qualify. */
|
|
6
|
+
readonly algorithms: readonly string[];
|
|
7
|
+
readonly clockToleranceSeconds: number;
|
|
8
|
+
readonly timeoutMs: number;
|
|
9
|
+
readonly fetch: typeof fetch | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface VerifyJwtOptions {
|
|
12
|
+
readonly audience: string;
|
|
13
|
+
readonly typ?: string;
|
|
14
|
+
}
|
|
15
|
+
export type JwtVerifier = (token: string, options: VerifyJwtOptions) => Promise<JWTPayload>;
|
|
16
|
+
export declare function createJwtVerifier(options: JwtVerifierOptions): JwtVerifier;
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createRemoteJWKSet, customFetch, errors as joseErrors, jwtVerify, } from "jose";
|
|
2
|
+
import { ERR } from "./errors.js";
|
|
3
|
+
// Re-fetch an unknown `kid` (key rotation) at most once per cooldown.
|
|
4
|
+
const JWKS_COOLDOWN_MS = 30_000;
|
|
5
|
+
const JWKS_MAX_AGE_MS = 3_600_000;
|
|
6
|
+
export function createJwtVerifier(options) {
|
|
7
|
+
const jwks = createRemoteJWKSet(new URL(options.jwksUri), {
|
|
8
|
+
cooldownDuration: JWKS_COOLDOWN_MS,
|
|
9
|
+
cacheMaxAge: JWKS_MAX_AGE_MS,
|
|
10
|
+
timeoutDuration: options.timeoutMs,
|
|
11
|
+
...(options.fetch ? { [customFetch]: options.fetch } : {}),
|
|
12
|
+
});
|
|
13
|
+
return async (token, { audience, typ }) => {
|
|
14
|
+
try {
|
|
15
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
16
|
+
issuer: options.issuer,
|
|
17
|
+
audience,
|
|
18
|
+
algorithms: [...options.algorithms],
|
|
19
|
+
clockTolerance: options.clockToleranceSeconds,
|
|
20
|
+
requiredClaims: ["exp", "iss", "sub"],
|
|
21
|
+
...(typ ? { typ } : {}),
|
|
22
|
+
});
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err instanceof joseErrors.JOSEError) {
|
|
27
|
+
throw ERR.TOK_001(`Token verification failed: ${err.message}`, {
|
|
28
|
+
details: { reason: err.code },
|
|
29
|
+
cause: err,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
throw ERR.TOK_001(undefined, { cause: err });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AuthAiError } from "./errors.js";
|
|
2
|
+
export type OAuthContext = "discovery" | "authorization-response" | "token-request";
|
|
3
|
+
/**
|
|
4
|
+
* Maps an openid-client failure to the SDK registry. Response bodies are
|
|
5
|
+
* reduced to the RFC 6749 `error` and `error_description` fields, which carry
|
|
6
|
+
* no credential.
|
|
7
|
+
*/
|
|
8
|
+
export declare function toAuthAiError(err: unknown, context: OAuthContext): AuthAiError;
|
|
9
|
+
export declare function isInvalidGrant(err: unknown): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AuthorizationResponseError, ClientError, ResponseBodyError, WWWAuthenticateChallengeError, } from "openid-client";
|
|
2
|
+
import { AuthAiError, ERR } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Maps an openid-client failure to the SDK registry. Response bodies are
|
|
5
|
+
* reduced to the RFC 6749 `error` and `error_description` fields, which carry
|
|
6
|
+
* no credential.
|
|
7
|
+
*/
|
|
8
|
+
export function toAuthAiError(err, context) {
|
|
9
|
+
if (err instanceof AuthAiError)
|
|
10
|
+
return err;
|
|
11
|
+
if (context === "discovery") {
|
|
12
|
+
return ERR.OIDC_001(undefined, { cause: err });
|
|
13
|
+
}
|
|
14
|
+
if (err instanceof ResponseBodyError) {
|
|
15
|
+
const details = {
|
|
16
|
+
oauthError: err.error,
|
|
17
|
+
status: err.status,
|
|
18
|
+
};
|
|
19
|
+
if (err.error_description)
|
|
20
|
+
details["description"] = err.error_description;
|
|
21
|
+
return ERR.OIDC_003(`Token endpoint rejected the request: ${err.error}`, {
|
|
22
|
+
details,
|
|
23
|
+
cause: err,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// The provider answers 401 with a Basic challenge when client authentication fails.
|
|
27
|
+
if (err instanceof WWWAuthenticateChallengeError) {
|
|
28
|
+
return ERR.OIDC_003(`Token endpoint challenged the request (HTTP ${err.status}): check clientId and clientSecret`, { details: { status: err.status }, cause: err });
|
|
29
|
+
}
|
|
30
|
+
if (err instanceof AuthorizationResponseError) {
|
|
31
|
+
const details = { oauthError: err.error };
|
|
32
|
+
return ERR.OIDC_002(`Authorization failed: ${err.error}`, {
|
|
33
|
+
details,
|
|
34
|
+
cause: err,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (context === "authorization-response" && err instanceof ClientError) {
|
|
38
|
+
return ERR.OIDC_002(undefined, {
|
|
39
|
+
details: err.code ? { reason: err.code } : {},
|
|
40
|
+
cause: err,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return context === "authorization-response"
|
|
44
|
+
? ERR.OIDC_002(undefined, { cause: err })
|
|
45
|
+
: ERR.OIDC_003(undefined, { cause: err });
|
|
46
|
+
}
|
|
47
|
+
export function isInvalidGrant(err) {
|
|
48
|
+
return (err instanceof AuthAiError &&
|
|
49
|
+
err.details?.["oauthError"] === "invalid_grant");
|
|
50
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as oidc from "openid-client";
|
|
2
|
+
import type { ResolvedAuthClientConfig } from "./config.js";
|
|
3
|
+
import type { JwtVerifier } from "./jwt.js";
|
|
4
|
+
export interface Provider {
|
|
5
|
+
readonly configuration: oidc.Configuration;
|
|
6
|
+
readonly verifyJwt: JwtVerifier;
|
|
7
|
+
}
|
|
8
|
+
export declare function createProviderLoader(config: ResolvedAuthClientConfig, fetchImpl: typeof fetch | undefined): () => Promise<Provider>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as oidc from "openid-client";
|
|
2
|
+
import { ERR } from "./errors.js";
|
|
3
|
+
import { createJwtVerifier } from "./jwt.js";
|
|
4
|
+
import { toAuthAiError } from "./oauth-errors.js";
|
|
5
|
+
// Discovery and keys change rarely; an hour keeps rotation picked up without a restart.
|
|
6
|
+
const PROVIDER_TTL_MS = 3_600_000;
|
|
7
|
+
const ASYMMETRIC_ALGORITHMS = new Set([
|
|
8
|
+
"EdDSA",
|
|
9
|
+
"ES256",
|
|
10
|
+
"ES384",
|
|
11
|
+
"ES512",
|
|
12
|
+
"RS256",
|
|
13
|
+
"RS384",
|
|
14
|
+
"RS512",
|
|
15
|
+
"PS256",
|
|
16
|
+
"PS384",
|
|
17
|
+
"PS512",
|
|
18
|
+
]);
|
|
19
|
+
function clientAuthentication(config) {
|
|
20
|
+
if (!config.clientSecret)
|
|
21
|
+
return oidc.None();
|
|
22
|
+
return config.tokenEndpointAuthMethod === "client_secret_post"
|
|
23
|
+
? oidc.ClientSecretPost(config.clientSecret)
|
|
24
|
+
: oidc.ClientSecretBasic(config.clientSecret);
|
|
25
|
+
}
|
|
26
|
+
// openid-client types its body wider than RequestInit under `exactOptionalPropertyTypes`
|
|
27
|
+
// (it admits a Uint8Array over a SharedArrayBuffer), but only ever sends strings or form data.
|
|
28
|
+
function asCustomFetch(fetchImpl) {
|
|
29
|
+
return (url, { body, ...init }) => fetchImpl(url, body === undefined ? init : { ...init, body: body });
|
|
30
|
+
}
|
|
31
|
+
async function loadProvider(config, fetchImpl) {
|
|
32
|
+
const timeoutSeconds = Math.ceil(config.timeoutMs / 1000);
|
|
33
|
+
const auth = clientAuthentication(config);
|
|
34
|
+
let discovered;
|
|
35
|
+
try {
|
|
36
|
+
discovered = await oidc.discovery(new URL(config.issuer), config.clientId, undefined, auth, {
|
|
37
|
+
timeout: timeoutSeconds,
|
|
38
|
+
...(fetchImpl ? { [oidc.customFetch]: asCustomFetch(fetchImpl) } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw toAuthAiError(err, "discovery");
|
|
43
|
+
}
|
|
44
|
+
const server = discovered.serverMetadata();
|
|
45
|
+
const algorithms = (server.id_token_signing_alg_values_supported ?? []).filter((alg) => ASYMMETRIC_ALGORITHMS.has(alg));
|
|
46
|
+
const [idTokenAlgorithm] = algorithms;
|
|
47
|
+
if (!idTokenAlgorithm || !server.jwks_uri) {
|
|
48
|
+
throw ERR.OIDC_001("The provider advertises no asymmetric ID token algorithm or no jwks_uri");
|
|
49
|
+
}
|
|
50
|
+
// The library assumes RS256 unless told otherwise, whereas auth-service signs with EdDSA.
|
|
51
|
+
const configuration = new oidc.Configuration(server, config.clientId, {
|
|
52
|
+
id_token_signed_response_alg: idTokenAlgorithm,
|
|
53
|
+
[oidc.clockTolerance]: config.clockToleranceSeconds,
|
|
54
|
+
}, auth);
|
|
55
|
+
configuration.timeout = timeoutSeconds;
|
|
56
|
+
if (fetchImpl)
|
|
57
|
+
configuration[oidc.customFetch] = asCustomFetch(fetchImpl);
|
|
58
|
+
return {
|
|
59
|
+
configuration,
|
|
60
|
+
verifyJwt: createJwtVerifier({
|
|
61
|
+
issuer: config.issuer,
|
|
62
|
+
jwksUri: server.jwks_uri,
|
|
63
|
+
algorithms,
|
|
64
|
+
clockToleranceSeconds: config.clockToleranceSeconds,
|
|
65
|
+
timeoutMs: config.timeoutMs,
|
|
66
|
+
fetch: fetchImpl,
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function createProviderLoader(config, fetchImpl) {
|
|
71
|
+
let cached;
|
|
72
|
+
return () => {
|
|
73
|
+
if (cached && Date.now() - cached.loadedAt < PROVIDER_TTL_MS) {
|
|
74
|
+
return cached.provider;
|
|
75
|
+
}
|
|
76
|
+
const provider = loadProvider(config, fetchImpl);
|
|
77
|
+
const entry = { loadedAt: Date.now(), provider };
|
|
78
|
+
cached = entry;
|
|
79
|
+
// A failed load is not cached: the next call retries.
|
|
80
|
+
provider.catch(() => {
|
|
81
|
+
if (cached === entry)
|
|
82
|
+
cached = undefined;
|
|
83
|
+
});
|
|
84
|
+
return provider;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { BrowserAuth, WalletView } from "../browser/client.js";
|
|
3
|
+
import type { AuthState, ResourceState } from "../browser/store.js";
|
|
4
|
+
import type { FeatureValue, PermissionAction } from "../claims.js";
|
|
5
|
+
import type { GatewayModel } from "../gateway.js";
|
|
6
|
+
import type { SessionUser } from "../session-user.js";
|
|
7
|
+
import type { WalletStats } from "../wallet.js";
|
|
8
|
+
export interface AuthProviderProps {
|
|
9
|
+
/** Defaults to a client on `basePath`. */
|
|
10
|
+
readonly auth?: BrowserAuth;
|
|
11
|
+
readonly basePath?: string;
|
|
12
|
+
/** Re-reads the session when the tab becomes visible again. */
|
|
13
|
+
readonly refreshOnFocus?: boolean;
|
|
14
|
+
readonly children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
export declare function AuthProvider(props: AuthProviderProps): ReactNode;
|
|
17
|
+
export interface UseAuthResult extends AuthState {
|
|
18
|
+
readonly login: (next?: string) => void;
|
|
19
|
+
readonly logout: () => Promise<void>;
|
|
20
|
+
readonly refresh: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare function useAuth(): UseAuthResult;
|
|
23
|
+
/** The signed-in user, or null while loading and when signed out. */
|
|
24
|
+
export declare function useUser(): SessionUser | null;
|
|
25
|
+
export interface Entitlements {
|
|
26
|
+
hasRole(role: string): boolean;
|
|
27
|
+
hasPermission(resource: string, action?: PermissionAction): boolean;
|
|
28
|
+
hasFeature(key: string): boolean;
|
|
29
|
+
getFeature(key: string): FeatureValue | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Entitlement checks for the current user: all false when signed out. */
|
|
32
|
+
export declare function useEntitlements(): Entitlements;
|
|
33
|
+
export interface UseResourceResult<T> extends ResourceState<T> {
|
|
34
|
+
readonly refresh: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface UseWalletOptions {
|
|
37
|
+
/** Reload the balance on this interval; 15 s is plenty. */
|
|
38
|
+
readonly pollIntervalMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** The user's credit; `data.canSpend` tells whether a paid action may start. */
|
|
41
|
+
export declare function useWallet(options?: UseWalletOptions): UseResourceResult<WalletView>;
|
|
42
|
+
export declare function useWalletStats(days?: number): UseResourceResult<WalletStats>;
|
|
43
|
+
/** The models the user's plan may call on the gateway. */
|
|
44
|
+
export declare function useModels(): UseResourceResult<GatewayModel[]>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore, } from "react";
|
|
2
|
+
import { createBrowserAuth } from "../browser/client.js";
|
|
3
|
+
import { createAuthStore, createResourceStore } from "../browser/store.js";
|
|
4
|
+
import { getFeature, hasFeature, hasPermission, hasRole } from "../claims.js";
|
|
5
|
+
const AuthContext = createContext(null);
|
|
6
|
+
function useAuthContext() {
|
|
7
|
+
const context = useContext(AuthContext);
|
|
8
|
+
if (!context)
|
|
9
|
+
throw new Error("Wrap the application in <AuthProvider>");
|
|
10
|
+
return context;
|
|
11
|
+
}
|
|
12
|
+
export function AuthProvider(props) {
|
|
13
|
+
const { auth: provided, basePath, refreshOnFocus = true, children } = props;
|
|
14
|
+
const value = useMemo(() => {
|
|
15
|
+
const auth = provided ?? createBrowserAuth(basePath ? { basePath } : {});
|
|
16
|
+
return { auth, store: createAuthStore(auth) };
|
|
17
|
+
}, [provided, basePath]);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
void value.store.refresh();
|
|
20
|
+
if (!refreshOnFocus)
|
|
21
|
+
return undefined;
|
|
22
|
+
const onVisible = () => {
|
|
23
|
+
if (document.visibilityState === "visible")
|
|
24
|
+
void value.store.refresh();
|
|
25
|
+
};
|
|
26
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
27
|
+
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
28
|
+
}, [value, refreshOnFocus]);
|
|
29
|
+
return createElement(AuthContext.Provider, { value }, children);
|
|
30
|
+
}
|
|
31
|
+
export function useAuth() {
|
|
32
|
+
const { auth, store } = useAuthContext();
|
|
33
|
+
const state = useSyncExternalStore(store.state.subscribe, store.state.get, store.state.get);
|
|
34
|
+
return useMemo(() => ({
|
|
35
|
+
...state,
|
|
36
|
+
login: (next) => auth.login(next),
|
|
37
|
+
logout: () => auth.logout(),
|
|
38
|
+
refresh: () => store.refresh(),
|
|
39
|
+
}), [state, auth, store]);
|
|
40
|
+
}
|
|
41
|
+
/** The signed-in user, or null while loading and when signed out. */
|
|
42
|
+
export function useUser() {
|
|
43
|
+
return useAuth().user;
|
|
44
|
+
}
|
|
45
|
+
/** Entitlement checks for the current user: all false when signed out. */
|
|
46
|
+
export function useEntitlements() {
|
|
47
|
+
const user = useUser();
|
|
48
|
+
return useMemo(() => {
|
|
49
|
+
const claims = user ?? {};
|
|
50
|
+
return {
|
|
51
|
+
hasRole: (role) => hasRole(claims, role),
|
|
52
|
+
hasPermission: (resource, action) => hasPermission(claims, resource, action),
|
|
53
|
+
hasFeature: (key) => hasFeature(claims, key),
|
|
54
|
+
getFeature: (key) => getFeature(claims, key),
|
|
55
|
+
};
|
|
56
|
+
}, [user]);
|
|
57
|
+
}
|
|
58
|
+
function useResource(load, options, deps) {
|
|
59
|
+
const { store: authStore } = useAuthContext();
|
|
60
|
+
const { pollIntervalMs } = options;
|
|
61
|
+
const resource = useMemo(() => createResourceStore(load, {
|
|
62
|
+
...(pollIntervalMs === undefined ? {} : { pollIntervalMs }),
|
|
63
|
+
// A 401 means the session ended: let the auth state say so.
|
|
64
|
+
onError: (error) => {
|
|
65
|
+
if (error.code === "SES_001")
|
|
66
|
+
void authStore.refresh();
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
// `load` is rebuilt on every render: its inputs are the deps.
|
|
70
|
+
[authStore, pollIntervalMs, ...deps]);
|
|
71
|
+
useEffect(() => resource.start(), [resource]);
|
|
72
|
+
const state = useSyncExternalStore(resource.state.subscribe, resource.state.get, resource.state.get);
|
|
73
|
+
return useMemo(() => ({ ...state, refresh: resource.refresh }), [state, resource]);
|
|
74
|
+
}
|
|
75
|
+
/** The user's credit; `data.canSpend` tells whether a paid action may start. */
|
|
76
|
+
export function useWallet(options = {}) {
|
|
77
|
+
const { auth } = useAuthContext();
|
|
78
|
+
return useResource(() => auth.getWallet(), options, [auth]);
|
|
79
|
+
}
|
|
80
|
+
export function useWalletStats(days) {
|
|
81
|
+
const { auth } = useAuthContext();
|
|
82
|
+
return useResource(() => auth.getWalletStats(days), {}, [auth, days]);
|
|
83
|
+
}
|
|
84
|
+
/** The models the user's plan may call on the gateway. */
|
|
85
|
+
export function useModels() {
|
|
86
|
+
const { auth } = useAuthContext();
|
|
87
|
+
return useResource(() => auth.listModels(), {}, [auth]);
|
|
88
|
+
}
|