@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { AuthAiError, ERR } from "../errors.js";
|
|
2
|
+
export function createObservable(initial) {
|
|
3
|
+
let value = initial;
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
return {
|
|
6
|
+
get: () => value,
|
|
7
|
+
set(next) {
|
|
8
|
+
if (Object.is(next, value))
|
|
9
|
+
return;
|
|
10
|
+
value = next;
|
|
11
|
+
for (const listener of [...listeners])
|
|
12
|
+
listener();
|
|
13
|
+
},
|
|
14
|
+
subscribe(listener) {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => listeners.delete(listener);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function asError(err) {
|
|
21
|
+
return err instanceof AuthAiError
|
|
22
|
+
? err
|
|
23
|
+
: ERR.API_001("The request failed", { cause: err });
|
|
24
|
+
}
|
|
25
|
+
export const INITIAL_AUTH_STATE = {
|
|
26
|
+
status: "loading",
|
|
27
|
+
user: null,
|
|
28
|
+
error: null,
|
|
29
|
+
};
|
|
30
|
+
export function createAuthStore(auth) {
|
|
31
|
+
const state = createObservable(INITIAL_AUTH_STATE);
|
|
32
|
+
// A slow answer must not overwrite a newer one.
|
|
33
|
+
let latest = 0;
|
|
34
|
+
return {
|
|
35
|
+
state,
|
|
36
|
+
async refresh() {
|
|
37
|
+
const ticket = (latest += 1);
|
|
38
|
+
try {
|
|
39
|
+
const user = await auth.getUser();
|
|
40
|
+
if (ticket !== latest)
|
|
41
|
+
return;
|
|
42
|
+
state.set(user
|
|
43
|
+
? { status: "authenticated", user, error: null }
|
|
44
|
+
: { status: "unauthenticated", user: null, error: null });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (ticket !== latest)
|
|
48
|
+
return;
|
|
49
|
+
// Keep the last known user: a failed refresh does not sign anyone out.
|
|
50
|
+
state.set({
|
|
51
|
+
status: "error",
|
|
52
|
+
user: state.get().user,
|
|
53
|
+
error: asError(err),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function createResourceStore(load, options = {}) {
|
|
60
|
+
const state = createObservable({
|
|
61
|
+
status: "idle",
|
|
62
|
+
data: null,
|
|
63
|
+
error: null,
|
|
64
|
+
});
|
|
65
|
+
let latest = 0;
|
|
66
|
+
async function refresh() {
|
|
67
|
+
const ticket = (latest += 1);
|
|
68
|
+
const previous = state.get().data;
|
|
69
|
+
state.set({ status: "loading", data: previous, error: null });
|
|
70
|
+
try {
|
|
71
|
+
const data = await load();
|
|
72
|
+
if (ticket === latest)
|
|
73
|
+
state.set({ status: "ready", data, error: null });
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (ticket !== latest)
|
|
77
|
+
return;
|
|
78
|
+
const error = asError(err);
|
|
79
|
+
state.set({ status: "error", data: previous, error });
|
|
80
|
+
options.onError?.(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
state,
|
|
85
|
+
refresh,
|
|
86
|
+
start() {
|
|
87
|
+
void refresh();
|
|
88
|
+
const timer = options.pollIntervalMs
|
|
89
|
+
? setInterval(() => void refresh(), options.pollIntervalMs)
|
|
90
|
+
: undefined;
|
|
91
|
+
return () => {
|
|
92
|
+
// A load in flight when the consumer leaves must not write afterwards.
|
|
93
|
+
latest += 1;
|
|
94
|
+
if (timer !== undefined)
|
|
95
|
+
clearInterval(timer);
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/dist/claims.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const featureValueSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
3
|
+
/** Claims of an ID token issued by auth-service. Unknown claims are kept as is. */
|
|
4
|
+
export declare const idTokenClaimsSchema: z.ZodObject<{
|
|
5
|
+
roles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
6
|
+
permissions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
7
|
+
features: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
8
|
+
plan: z.ZodOptional<z.ZodString>;
|
|
9
|
+
org_id: z.ZodOptional<z.ZodString>;
|
|
10
|
+
sid: z.ZodOptional<z.ZodString>;
|
|
11
|
+
azp: z.ZodOptional<z.ZodString>;
|
|
12
|
+
name: z.ZodOptional<z.ZodString>;
|
|
13
|
+
picture: z.ZodOptional<z.ZodString>;
|
|
14
|
+
given_name: z.ZodOptional<z.ZodString>;
|
|
15
|
+
family_name: z.ZodOptional<z.ZodString>;
|
|
16
|
+
company: z.ZodOptional<z.ZodString>;
|
|
17
|
+
updated_at: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
email: z.ZodOptional<z.ZodString>;
|
|
19
|
+
email_verified: z.ZodOptional<z.ZodBoolean>;
|
|
20
|
+
phone_number: z.ZodOptional<z.ZodString>;
|
|
21
|
+
iss: z.ZodString;
|
|
22
|
+
sub: z.ZodString;
|
|
23
|
+
aud: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
24
|
+
exp: z.ZodNumber;
|
|
25
|
+
iat: z.ZodNumber;
|
|
26
|
+
}, z.core.$loose>;
|
|
27
|
+
/**
|
|
28
|
+
* Claims of a JWT access token. `azp` is the OAuth client that obtained the
|
|
29
|
+
* token and `scope` is a space-separated list.
|
|
30
|
+
*/
|
|
31
|
+
export declare const accessTokenClaimsSchema: z.ZodObject<{
|
|
32
|
+
roles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
33
|
+
permissions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
34
|
+
features: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
35
|
+
plan: z.ZodOptional<z.ZodString>;
|
|
36
|
+
org_id: z.ZodOptional<z.ZodString>;
|
|
37
|
+
azp: z.ZodString;
|
|
38
|
+
scope: z.ZodOptional<z.ZodString>;
|
|
39
|
+
sid: z.ZodOptional<z.ZodString>;
|
|
40
|
+
iss: z.ZodString;
|
|
41
|
+
sub: z.ZodString;
|
|
42
|
+
aud: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
43
|
+
exp: z.ZodNumber;
|
|
44
|
+
iat: z.ZodNumber;
|
|
45
|
+
}, z.core.$loose>;
|
|
46
|
+
export type FeatureValue = z.infer<typeof featureValueSchema>;
|
|
47
|
+
export type IdTokenClaims = z.infer<typeof idTokenClaimsSchema>;
|
|
48
|
+
export type AccessTokenClaims = z.infer<typeof accessTokenClaimsSchema>;
|
|
49
|
+
export type PermissionAction = "read" | "write";
|
|
50
|
+
type EntitlementClaims = Pick<IdTokenClaims, "roles" | "permissions" | "features">;
|
|
51
|
+
export declare function hasRole(claims: EntitlementClaims, role: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* `resource` grants read access and `resource.write` grants write access,
|
|
54
|
+
* which also implies read: auth-service keeps only the highest action.
|
|
55
|
+
*/
|
|
56
|
+
export declare function hasPermission(claims: EntitlementClaims, resource: string, action?: PermissionAction): boolean;
|
|
57
|
+
/** A feature is enabled when present and neither `false` nor `0` nor `""`. */
|
|
58
|
+
export declare function hasFeature(claims: EntitlementClaims, key: string): boolean;
|
|
59
|
+
export declare function getFeature(claims: EntitlementClaims, key: string): FeatureValue | undefined;
|
|
60
|
+
export {};
|
package/dist/claims.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const featureValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
|
3
|
+
const audienceSchema = z.union([z.string(), z.array(z.string())]);
|
|
4
|
+
// Present only when the matching scope was granted.
|
|
5
|
+
const entitlementShape = {
|
|
6
|
+
roles: z.array(z.string()).optional(),
|
|
7
|
+
permissions: z.array(z.string()).optional(),
|
|
8
|
+
features: z.record(z.string(), featureValueSchema).optional(),
|
|
9
|
+
plan: z.string().optional(),
|
|
10
|
+
org_id: z.string().optional(),
|
|
11
|
+
};
|
|
12
|
+
const registeredShape = {
|
|
13
|
+
iss: z.string(),
|
|
14
|
+
sub: z.string(),
|
|
15
|
+
aud: audienceSchema,
|
|
16
|
+
exp: z.number(),
|
|
17
|
+
iat: z.number(),
|
|
18
|
+
};
|
|
19
|
+
/** Claims of an ID token issued by auth-service. Unknown claims are kept as is. */
|
|
20
|
+
export const idTokenClaimsSchema = z.looseObject({
|
|
21
|
+
...registeredShape,
|
|
22
|
+
sid: z.string().optional(),
|
|
23
|
+
azp: z.string().optional(),
|
|
24
|
+
name: z.string().optional(),
|
|
25
|
+
picture: z.string().optional(),
|
|
26
|
+
given_name: z.string().optional(),
|
|
27
|
+
family_name: z.string().optional(),
|
|
28
|
+
company: z.string().optional(),
|
|
29
|
+
updated_at: z.number().optional(),
|
|
30
|
+
email: z.string().optional(),
|
|
31
|
+
email_verified: z.boolean().optional(),
|
|
32
|
+
phone_number: z.string().optional(),
|
|
33
|
+
...entitlementShape,
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Claims of a JWT access token. `azp` is the OAuth client that obtained the
|
|
37
|
+
* token and `scope` is a space-separated list.
|
|
38
|
+
*/
|
|
39
|
+
export const accessTokenClaimsSchema = z.looseObject({
|
|
40
|
+
...registeredShape,
|
|
41
|
+
azp: z.string(),
|
|
42
|
+
scope: z.string().optional(),
|
|
43
|
+
sid: z.string().optional(),
|
|
44
|
+
...entitlementShape,
|
|
45
|
+
});
|
|
46
|
+
export function hasRole(claims, role) {
|
|
47
|
+
return claims.roles?.includes(role) ?? false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `resource` grants read access and `resource.write` grants write access,
|
|
51
|
+
* which also implies read: auth-service keeps only the highest action.
|
|
52
|
+
*/
|
|
53
|
+
export function hasPermission(claims, resource, action = "read") {
|
|
54
|
+
const permissions = claims.permissions;
|
|
55
|
+
if (!permissions)
|
|
56
|
+
return false;
|
|
57
|
+
if (permissions.includes(`${resource}.write`))
|
|
58
|
+
return true;
|
|
59
|
+
return action === "read" && permissions.includes(resource);
|
|
60
|
+
}
|
|
61
|
+
/** A feature is enabled when present and neither `false` nor `0` nor `""`. */
|
|
62
|
+
export function hasFeature(claims, key) {
|
|
63
|
+
const value = claims.features?.[key];
|
|
64
|
+
return value !== undefined && value !== false && value !== 0 && value !== "";
|
|
65
|
+
}
|
|
66
|
+
export function getFeature(claims, key) {
|
|
67
|
+
return claims.features?.[key];
|
|
68
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IdTokenClaims } from "./claims.js";
|
|
2
|
+
import type { AuthClientConfig, ResolvedAuthClientConfig } from "./config.js";
|
|
3
|
+
import type { IntrospectionResult } from "./introspection.js";
|
|
4
|
+
import type { JWTPayload } from "jose";
|
|
5
|
+
import type { VerifyJwtOptions } from "./jwt.js";
|
|
6
|
+
import type { LoginHandshake, LoginRequest, LoginResult, ClientCredentialsOptions, LogoutUrlOptions, RefreshOptions, StartLoginOptions, TokenSet, TokenTypeHint } from "./types.js";
|
|
7
|
+
export interface AuthClientOptions {
|
|
8
|
+
/** Replaces the global `fetch`, e.g. to add a proxy or an observability layer. */
|
|
9
|
+
readonly fetch?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* OIDC relying party for auth-service: authorization-code grant with PKCE,
|
|
13
|
+
* refresh with RFC 8707 resource narrowing, logout, revocation and
|
|
14
|
+
* introspection. It keeps no session: callers persist the handshake, the
|
|
15
|
+
* tokens and the claims where it suits them.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AuthClient {
|
|
18
|
+
#private;
|
|
19
|
+
readonly config: ResolvedAuthClientConfig;
|
|
20
|
+
/** The fetch used for every request, shared with the REST API clients built on this one. */
|
|
21
|
+
readonly fetch: typeof fetch;
|
|
22
|
+
constructor(config: AuthClientConfig, options?: AuthClientOptions);
|
|
23
|
+
startLogin(options?: StartLoginOptions): Promise<LoginRequest>;
|
|
24
|
+
/**
|
|
25
|
+
* Completes the sign-in from the callback URL. `resource` is deliberately not
|
|
26
|
+
* sent on the code exchange: the token is then issued for every audience
|
|
27
|
+
* requested at sign-in and each refresh narrows it to one.
|
|
28
|
+
*/
|
|
29
|
+
handleCallback(callbackUrl: URL | string, handshake: LoginHandshake): Promise<LoginResult>;
|
|
30
|
+
verifyIdToken(idToken: string): Promise<IdTokenClaims>;
|
|
31
|
+
/**
|
|
32
|
+
* Exchanges a refresh token for fresh tokens. The provider rotates the
|
|
33
|
+
* refresh token on every use: the returned one replaces the presented one,
|
|
34
|
+
* and a replayed stale token revokes the whole family. Concurrent callers
|
|
35
|
+
* should go through a grant manager. `scope` is never sent: the rotated
|
|
36
|
+
* token would be left with the scopes of that request only.
|
|
37
|
+
*/
|
|
38
|
+
refresh(refreshToken: string, options?: RefreshOptions): Promise<TokenSet>;
|
|
39
|
+
/** URL ending the provider session (RP-initiated logout). */
|
|
40
|
+
logoutUrl(options?: LogoutUrlOptions): Promise<string>;
|
|
41
|
+
revoke(token: string, hint?: TokenTypeHint): Promise<void>;
|
|
42
|
+
introspect(token: string, hint?: TokenTypeHint): Promise<IntrospectionResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Verifies the signature, issuer, audience and expiry of a JWT signed by the
|
|
45
|
+
* provider. Low-level: prefer the bearer and logout token verifiers.
|
|
46
|
+
*/
|
|
47
|
+
verifyJwt(token: string, options: VerifyJwtOptions): Promise<JWTPayload>;
|
|
48
|
+
/**
|
|
49
|
+
* `client_credentials` grant for the application itself: no user is bound to
|
|
50
|
+
* the token. Needs a confidential client.
|
|
51
|
+
*/
|
|
52
|
+
clientCredentials(options?: ClientCredentialsOptions): Promise<TokenSet>;
|
|
53
|
+
}
|
|
54
|
+
export declare function createAuthClient(config: AuthClientConfig, options?: AuthClientOptions): AuthClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as oidc from "openid-client";
|
|
2
|
+
import { idTokenClaimsSchema } from "./claims.js";
|
|
3
|
+
import { parseAuthClientConfig } from "./config.js";
|
|
4
|
+
import { ERR } from "./errors.js";
|
|
5
|
+
import { introspectionSchema } from "./introspection.js";
|
|
6
|
+
import { toAuthAiError } from "./oauth-errors.js";
|
|
7
|
+
import { createProviderLoader } from "./provider.js";
|
|
8
|
+
function toTokenSet(response) {
|
|
9
|
+
const expiresIn = response.expiresIn();
|
|
10
|
+
if (expiresIn === undefined) {
|
|
11
|
+
throw ERR.OIDC_003("The token response carries no expires_in");
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
accessToken: response.access_token,
|
|
15
|
+
tokenType: response.token_type,
|
|
16
|
+
expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
|
|
17
|
+
...(response.scope ? { scope: response.scope } : {}),
|
|
18
|
+
...(response.refresh_token ? { refreshToken: response.refresh_token } : {}),
|
|
19
|
+
...(response.id_token ? { idToken: response.id_token } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* OIDC relying party for auth-service: authorization-code grant with PKCE,
|
|
24
|
+
* refresh with RFC 8707 resource narrowing, logout, revocation and
|
|
25
|
+
* introspection. It keeps no session: callers persist the handshake, the
|
|
26
|
+
* tokens and the claims where it suits them.
|
|
27
|
+
*/
|
|
28
|
+
export class AuthClient {
|
|
29
|
+
config;
|
|
30
|
+
/** The fetch used for every request, shared with the REST API clients built on this one. */
|
|
31
|
+
fetch;
|
|
32
|
+
#loadProvider;
|
|
33
|
+
constructor(config, options = {}) {
|
|
34
|
+
this.config = parseAuthClientConfig(config);
|
|
35
|
+
this.fetch =
|
|
36
|
+
options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
37
|
+
this.#loadProvider = createProviderLoader(this.config, options.fetch);
|
|
38
|
+
}
|
|
39
|
+
async startLogin(options = {}) {
|
|
40
|
+
const { configuration } = await this.#loadProvider();
|
|
41
|
+
const handshake = {
|
|
42
|
+
state: oidc.randomState(),
|
|
43
|
+
nonce: oidc.randomNonce(),
|
|
44
|
+
codeVerifier: oidc.randomPKCECodeVerifier(),
|
|
45
|
+
};
|
|
46
|
+
const parameters = new URLSearchParams({
|
|
47
|
+
redirect_uri: this.config.redirectUri,
|
|
48
|
+
scope: this.config.scopes.join(" "),
|
|
49
|
+
state: handshake.state,
|
|
50
|
+
nonce: handshake.nonce,
|
|
51
|
+
code_challenge: await oidc.calculatePKCECodeChallenge(handshake.codeVerifier),
|
|
52
|
+
code_challenge_method: "S256",
|
|
53
|
+
});
|
|
54
|
+
if (options.prompt)
|
|
55
|
+
parameters.set("prompt", options.prompt);
|
|
56
|
+
if (options.loginHint)
|
|
57
|
+
parameters.set("login_hint", options.loginHint);
|
|
58
|
+
for (const resource of this.config.resources) {
|
|
59
|
+
parameters.append("resource", resource);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
url: oidc.buildAuthorizationUrl(configuration, parameters).href,
|
|
63
|
+
handshake,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Completes the sign-in from the callback URL. `resource` is deliberately not
|
|
68
|
+
* sent on the code exchange: the token is then issued for every audience
|
|
69
|
+
* requested at sign-in and each refresh narrows it to one.
|
|
70
|
+
*/
|
|
71
|
+
async handleCallback(callbackUrl, handshake) {
|
|
72
|
+
const provider = await this.#loadProvider();
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await oidc.authorizationCodeGrant(provider.configuration, new URL(callbackUrl), {
|
|
76
|
+
pkceCodeVerifier: handshake.codeVerifier,
|
|
77
|
+
expectedState: handshake.state,
|
|
78
|
+
expectedNonce: handshake.nonce,
|
|
79
|
+
idTokenExpected: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw toAuthAiError(err, "authorization-response");
|
|
84
|
+
}
|
|
85
|
+
const tokens = toTokenSet(response);
|
|
86
|
+
if (!tokens.idToken) {
|
|
87
|
+
throw ERR.OIDC_002("The token response carries no ID token");
|
|
88
|
+
}
|
|
89
|
+
const claims = await this.#verifyIdToken(provider, tokens.idToken);
|
|
90
|
+
if (claims["nonce"] !== handshake.nonce) {
|
|
91
|
+
throw ERR.TOK_001("ID token nonce mismatch");
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
tokens: { ...tokens, idToken: tokens.idToken },
|
|
95
|
+
claims,
|
|
96
|
+
sessionId: claims.sid,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async verifyIdToken(idToken) {
|
|
100
|
+
return this.#verifyIdToken(await this.#loadProvider(), idToken);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Exchanges a refresh token for fresh tokens. The provider rotates the
|
|
104
|
+
* refresh token on every use: the returned one replaces the presented one,
|
|
105
|
+
* and a replayed stale token revokes the whole family. Concurrent callers
|
|
106
|
+
* should go through a grant manager. `scope` is never sent: the rotated
|
|
107
|
+
* token would be left with the scopes of that request only.
|
|
108
|
+
*/
|
|
109
|
+
async refresh(refreshToken, options = {}) {
|
|
110
|
+
const { configuration } = await this.#loadProvider();
|
|
111
|
+
try {
|
|
112
|
+
const response = await oidc.refreshTokenGrant(configuration, refreshToken, options.resource ? { resource: options.resource } : undefined);
|
|
113
|
+
return toTokenSet(response);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
throw toAuthAiError(err, "token-request");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** URL ending the provider session (RP-initiated logout). */
|
|
120
|
+
async logoutUrl(options = {}) {
|
|
121
|
+
const { configuration } = await this.#loadProvider();
|
|
122
|
+
if (!configuration.serverMetadata().end_session_endpoint) {
|
|
123
|
+
throw ERR.OIDC_004("The provider advertises no end_session_endpoint");
|
|
124
|
+
}
|
|
125
|
+
const parameters = new URLSearchParams();
|
|
126
|
+
if (this.config.postLogoutRedirectUri) {
|
|
127
|
+
parameters.set("post_logout_redirect_uri", this.config.postLogoutRedirectUri);
|
|
128
|
+
}
|
|
129
|
+
if (options.idTokenHint) {
|
|
130
|
+
parameters.set("id_token_hint", options.idTokenHint);
|
|
131
|
+
}
|
|
132
|
+
if (options.state)
|
|
133
|
+
parameters.set("state", options.state);
|
|
134
|
+
return oidc.buildEndSessionUrl(configuration, parameters).href;
|
|
135
|
+
}
|
|
136
|
+
async revoke(token, hint) {
|
|
137
|
+
const { configuration } = await this.#loadProvider();
|
|
138
|
+
if (!configuration.serverMetadata().revocation_endpoint) {
|
|
139
|
+
throw ERR.OIDC_004("The provider advertises no revocation_endpoint");
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await oidc.tokenRevocation(configuration, token, hint ? { token_type_hint: hint } : undefined);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
throw toAuthAiError(err, "token-request");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async introspect(token, hint) {
|
|
149
|
+
const { configuration } = await this.#loadProvider();
|
|
150
|
+
if (!configuration.serverMetadata().introspection_endpoint) {
|
|
151
|
+
throw ERR.OIDC_004("The provider advertises no introspection_endpoint");
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const response = await oidc.tokenIntrospection(configuration, token, hint ? { token_type_hint: hint } : undefined);
|
|
155
|
+
return introspectionSchema.parse(response);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw toAuthAiError(err, "token-request");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Verifies the signature, issuer, audience and expiry of a JWT signed by the
|
|
163
|
+
* provider. Low-level: prefer the bearer and logout token verifiers.
|
|
164
|
+
*/
|
|
165
|
+
async verifyJwt(token, options) {
|
|
166
|
+
return (await this.#loadProvider()).verifyJwt(token, options);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* `client_credentials` grant for the application itself: no user is bound to
|
|
170
|
+
* the token. Needs a confidential client.
|
|
171
|
+
*/
|
|
172
|
+
async clientCredentials(options = {}) {
|
|
173
|
+
if (!this.config.clientSecret) {
|
|
174
|
+
throw ERR.CFG_001("clientCredentials requires a clientSecret");
|
|
175
|
+
}
|
|
176
|
+
const { configuration } = await this.#loadProvider();
|
|
177
|
+
const parameters = {};
|
|
178
|
+
if (options.scope)
|
|
179
|
+
parameters["scope"] = options.scope;
|
|
180
|
+
if (options.resource)
|
|
181
|
+
parameters["resource"] = options.resource;
|
|
182
|
+
try {
|
|
183
|
+
return toTokenSet(await oidc.clientCredentialsGrant(configuration, parameters));
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
throw toAuthAiError(err, "token-request");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async #verifyIdToken(provider, idToken) {
|
|
190
|
+
const payload = await provider.verifyJwt(idToken, {
|
|
191
|
+
audience: this.config.clientId,
|
|
192
|
+
});
|
|
193
|
+
const claims = idTokenClaimsSchema.safeParse(payload);
|
|
194
|
+
if (!claims.success) {
|
|
195
|
+
throw ERR.TOK_001("The ID token claims have an unexpected shape", {
|
|
196
|
+
cause: claims.error,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return claims.data;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export function createAuthClient(config, options = {}) {
|
|
203
|
+
return new AuthClient(config, options);
|
|
204
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const DEFAULT_SCOPES: readonly ["openid", "profile", "email", "roles", "permissions", "features", "offline_access"];
|
|
3
|
+
export declare const WALLET_SELF_SCOPE = "wallet:self:read";
|
|
4
|
+
/**
|
|
5
|
+
* With a gateway, the sign-in also asks for the audiences of the gateway and
|
|
6
|
+
* of auth-service (which serves the wallet) and for the wallet scope: this is
|
|
7
|
+
* what the application's "LiteLLM support" switch authorises.
|
|
8
|
+
*/
|
|
9
|
+
export declare const authClientConfigSchema: z.ZodPipe<z.ZodObject<{
|
|
10
|
+
issuer: z.ZodURL;
|
|
11
|
+
clientId: z.ZodString;
|
|
12
|
+
clientSecret: z.ZodOptional<z.ZodString>;
|
|
13
|
+
tokenEndpointAuthMethod: z.ZodDefault<z.ZodEnum<{
|
|
14
|
+
client_secret_basic: "client_secret_basic";
|
|
15
|
+
client_secret_post: "client_secret_post";
|
|
16
|
+
}>>;
|
|
17
|
+
redirectUri: z.ZodURL;
|
|
18
|
+
postLogoutRedirectUri: z.ZodOptional<z.ZodURL>;
|
|
19
|
+
scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
20
|
+
resources: z.ZodDefault<z.ZodArray<z.ZodURL>>;
|
|
21
|
+
gatewayUrl: z.ZodOptional<z.ZodURL>;
|
|
22
|
+
clockToleranceSeconds: z.ZodDefault<z.ZodNumber>;
|
|
23
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
24
|
+
}, z.core.$strip>, z.ZodTransform<{
|
|
25
|
+
issuer: string;
|
|
26
|
+
clientId: string;
|
|
27
|
+
tokenEndpointAuthMethod: "client_secret_basic" | "client_secret_post";
|
|
28
|
+
redirectUri: string;
|
|
29
|
+
scopes: string[];
|
|
30
|
+
resources: string[];
|
|
31
|
+
clockToleranceSeconds: number;
|
|
32
|
+
timeoutMs: number;
|
|
33
|
+
clientSecret?: string | undefined;
|
|
34
|
+
postLogoutRedirectUri?: string | undefined;
|
|
35
|
+
gatewayUrl?: string | undefined;
|
|
36
|
+
}, {
|
|
37
|
+
issuer: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
tokenEndpointAuthMethod: "client_secret_basic" | "client_secret_post";
|
|
40
|
+
redirectUri: string;
|
|
41
|
+
scopes: string[];
|
|
42
|
+
resources: string[];
|
|
43
|
+
clockToleranceSeconds: number;
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
clientSecret?: string | undefined;
|
|
46
|
+
postLogoutRedirectUri?: string | undefined;
|
|
47
|
+
gatewayUrl?: string | undefined;
|
|
48
|
+
}>>;
|
|
49
|
+
export type AuthClientConfig = z.input<typeof authClientConfigSchema>;
|
|
50
|
+
export type ResolvedAuthClientConfig = z.output<typeof authClientConfigSchema>;
|
|
51
|
+
export declare function parseAuthClientConfig(input: unknown): ResolvedAuthClientConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERR } from "./errors.js";
|
|
3
|
+
export const DEFAULT_SCOPES = [
|
|
4
|
+
"openid",
|
|
5
|
+
"profile",
|
|
6
|
+
"email",
|
|
7
|
+
"roles",
|
|
8
|
+
"permissions",
|
|
9
|
+
"features",
|
|
10
|
+
"offline_access",
|
|
11
|
+
];
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
13
|
+
// Scope that lets a user token read the user's own wallet.
|
|
14
|
+
export const WALLET_SELF_SCOPE = "wallet:self:read";
|
|
15
|
+
const issuerSchema = z
|
|
16
|
+
.url({ protocol: /^https$/ })
|
|
17
|
+
.refine((value) => !value.endsWith("/"), {
|
|
18
|
+
message: "must not end with a slash: it is compared verbatim to `iss`",
|
|
19
|
+
});
|
|
20
|
+
// http is allowed for loopback only, so local development needs no tunnel.
|
|
21
|
+
const redirectUriSchema = z.url().refine((value) => {
|
|
22
|
+
// Zod runs refinements even when `z.url()` failed.
|
|
23
|
+
if (!URL.canParse(value))
|
|
24
|
+
return false;
|
|
25
|
+
const url = new URL(value);
|
|
26
|
+
return (url.protocol === "https:" ||
|
|
27
|
+
(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)));
|
|
28
|
+
}, { message: "must be https (http is only accepted for localhost)" });
|
|
29
|
+
// An audience is compared verbatim to `aud`: a trailing slash would never match.
|
|
30
|
+
const gatewayUrlSchema = redirectUriSchema.refine((value) => !value.endsWith("/"), { message: "must not end with a slash: it is compared verbatim to `aud`" });
|
|
31
|
+
const authClientConfigShape = z.object({
|
|
32
|
+
issuer: issuerSchema,
|
|
33
|
+
clientId: z.string().min(1),
|
|
34
|
+
// Absent for a public client, which then relies on PKCE alone.
|
|
35
|
+
clientSecret: z.string().min(1).optional(),
|
|
36
|
+
tokenEndpointAuthMethod: z
|
|
37
|
+
.enum(["client_secret_basic", "client_secret_post"])
|
|
38
|
+
.default("client_secret_basic"),
|
|
39
|
+
redirectUri: redirectUriSchema,
|
|
40
|
+
postLogoutRedirectUri: redirectUriSchema.optional(),
|
|
41
|
+
scopes: z
|
|
42
|
+
.array(z.string().min(1))
|
|
43
|
+
.refine((scopes) => scopes.includes("openid"), {
|
|
44
|
+
message: "must include `openid`",
|
|
45
|
+
})
|
|
46
|
+
.default([...DEFAULT_SCOPES]),
|
|
47
|
+
// RFC 8707 audiences requested at sign-in; each refresh narrows to one.
|
|
48
|
+
resources: z.array(z.url()).default([]),
|
|
49
|
+
// Public URL of the LiteLLM gateway: turns on the gateway and wallet audiences.
|
|
50
|
+
gatewayUrl: gatewayUrlSchema.optional(),
|
|
51
|
+
clockToleranceSeconds: z.number().int().min(0).max(300).default(5),
|
|
52
|
+
timeoutMs: z.number().int().positive().default(10_000),
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* With a gateway, the sign-in also asks for the audiences of the gateway and
|
|
56
|
+
* of auth-service (which serves the wallet) and for the wallet scope: this is
|
|
57
|
+
* what the application's "LiteLLM support" switch authorises.
|
|
58
|
+
*/
|
|
59
|
+
export const authClientConfigSchema = authClientConfigShape.transform((config) => {
|
|
60
|
+
if (!config.gatewayUrl)
|
|
61
|
+
return config;
|
|
62
|
+
return {
|
|
63
|
+
...config,
|
|
64
|
+
scopes: [...new Set([...config.scopes, WALLET_SELF_SCOPE])],
|
|
65
|
+
resources: [
|
|
66
|
+
...new Set([...config.resources, config.gatewayUrl, config.issuer]),
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
export function parseAuthClientConfig(input) {
|
|
71
|
+
const result = authClientConfigSchema.safeParse(input);
|
|
72
|
+
if (result.success)
|
|
73
|
+
return result.data;
|
|
74
|
+
const issues = result.error.issues
|
|
75
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
76
|
+
.join("; ");
|
|
77
|
+
throw ERR.CFG_001(`Invalid SDK configuration: ${issues}`, {
|
|
78
|
+
cause: result.error,
|
|
79
|
+
});
|
|
80
|
+
}
|