@willyim/idp 0.1.1 → 0.2.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/README.md +73 -12
- package/dist/src/api.d.ts +31 -55
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +16 -11
- package/dist/src/claims.d.ts +9 -34
- package/dist/src/claims.d.ts.map +1 -1
- package/dist/src/claims.js +12 -40
- package/dist/src/client.d.ts +30 -34
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +16 -22
- package/dist/src/drizzle/index.d.ts +78 -13
- package/dist/src/drizzle/index.d.ts.map +1 -1
- package/dist/src/errors.d.ts +8 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +12 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/schemas/index.d.ts +207 -0
- package/dist/src/schemas/index.d.ts.map +1 -0
- package/dist/src/schemas/index.js +122 -0
- package/dist/src/schemas/openapi.d.ts +31 -0
- package/dist/src/schemas/openapi.d.ts.map +1 -0
- package/dist/src/schemas/openapi.js +110 -0
- package/dist/src/schemas/operations.d.ts +297 -0
- package/dist/src/schemas/operations.d.ts.map +1 -0
- package/dist/src/schemas/operations.js +118 -0
- package/dist/src/session.d.ts +17 -3
- package/dist/src/session.d.ts.map +1 -1
- package/dist/src/session.js +12 -1
- package/dist/src/user-keys.d.ts +124 -0
- package/dist/src/user-keys.d.ts.map +1 -0
- package/dist/src/user-keys.js +174 -0
- package/dist/src/validate.d.ts +13 -0
- package/dist/src/validate.d.ts.map +1 -0
- package/dist/src/validate.js +28 -0
- package/dist/src/wire.d.ts +164 -0
- package/dist/src/wire.d.ts.map +1 -0
- package/dist/src/wire.js +100 -0
- package/openapi/idp-api.json +3 -6
- package/package.json +16 -7
- package/dist/src/generated/idp-api.d.ts +0 -1022
package/README.md
CHANGED
|
@@ -7,13 +7,18 @@ someone is and what they may do. An app installing this package runs no auth
|
|
|
7
7
|
framework: it owns one session table, which is a _handle_ to IdP truth rather
|
|
8
8
|
than a record of it. No user table, no account table, no `ADMIN_EMAILS` list.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
`zod` is the one runtime dependency: every payload the IdP sends — discovery,
|
|
11
|
+
tokens, claims, management API responses — is parsed against a schema rather
|
|
12
|
+
than cast, so a malformed response is an `IdpError` naming the field instead of
|
|
13
|
+
a `TypeError` several frames later. Otherwise it is `fetch`, WebCrypto and
|
|
14
|
+
`Request`/`Response` only, so the same build runs on Cloudflare Workers,
|
|
15
|
+
Node 20+ and Bun.
|
|
12
16
|
|
|
13
17
|
```
|
|
14
|
-
@willyim/idp
|
|
15
|
-
@willyim/idp/drizzle
|
|
16
|
-
@willyim/idp/react-router
|
|
18
|
+
@willyim/idp core: OIDC client + server sessions
|
|
19
|
+
@willyim/idp/drizzle the session store, and the `idp_session` table
|
|
20
|
+
@willyim/idp/react-router the auth route and the loader guards
|
|
21
|
+
@willyim/idp/schemas the management API wire shapes, as zod schemas
|
|
17
22
|
```
|
|
18
23
|
|
|
19
24
|
## Install
|
|
@@ -272,15 +277,71 @@ const idp = createIdp({
|
|
|
272
277
|
})
|
|
273
278
|
```
|
|
274
279
|
|
|
280
|
+
## End-user API keys
|
|
281
|
+
|
|
282
|
+
Keys an app's own users create to call *that app's* API. The IdP is the key
|
|
283
|
+
store: the app mints, lists, revokes and validates `wak_…` tokens over the
|
|
284
|
+
management API and never persists a plaintext or a hash.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { createUserKeys } from "@willyim/idp"
|
|
288
|
+
|
|
289
|
+
const keys = createUserKeys({
|
|
290
|
+
baseUrl: "https://idp.willy.im", // the API is at the root, not under /auth
|
|
291
|
+
token: env.IDP_MANAGEMENT_KEY, // the app's own wim_… key
|
|
292
|
+
app: "luchy",
|
|
293
|
+
cache: { ttlMs: 60_000 }, // validation cache; revocation lag is bounded by it
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// Mint — the plaintext exists exactly once, in this response.
|
|
297
|
+
const minted = await keys.create({
|
|
298
|
+
userId: session.userId,
|
|
299
|
+
name: "cli",
|
|
300
|
+
scopes: ["analytics:read"], // must be in the app's product permission catalog
|
|
301
|
+
workspaceId: session.workspaceId,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// Check, on the request path.
|
|
305
|
+
const auth = await keys.authenticate(request, { scopes: ["analytics:read"] })
|
|
306
|
+
if (!auth.ok) return new Response(auth.reason, { status: auth.status })
|
|
307
|
+
auth.key // { keyId, userId, workspaceId, scopes, name }
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`authenticate` reads `Authorization: Bearer …`, then `X-API-Key`, and returns a
|
|
311
|
+
result rather than throwing so the caller owns the response shape. Underneath,
|
|
312
|
+
`validate` caches verdicts by digest of the token (60s for a hit, 10s for a
|
|
313
|
+
miss, never for a failed round trip) and collapses concurrent checks of the same
|
|
314
|
+
token into one request. Revoking through some other channel is visible only once
|
|
315
|
+
the entry expires; `forget(token)` drops it immediately when you hold the
|
|
316
|
+
plaintext.
|
|
317
|
+
|
|
318
|
+
Filter with `list({ userId, workspaceId })`, revoke with `revoke(id)`. Scope
|
|
319
|
+
enforcement is the app's job — the IdP stores the scopes and reports them.
|
|
320
|
+
|
|
321
|
+
Only for **secret** credentials. A key embedded in a web page — an analytics
|
|
322
|
+
ingest token, say — identifies a site rather than a user, cannot be kept secret,
|
|
323
|
+
and must not pay a round trip per hit. Keep those in the app's own table and
|
|
324
|
+
gate them on `Origin` plus rate limiting.
|
|
325
|
+
|
|
275
326
|
## Management API types
|
|
276
327
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
`
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
`.
|
|
328
|
+
Endpoints without sugar of their own go through `createManagementApi`, whose
|
|
329
|
+
paths, methods, path parameters, bodies and response shapes all come from the
|
|
330
|
+
operations table in `@willyim/idp/schemas` — one zod definition per shape,
|
|
331
|
+
which also builds `openapi/idp-api.json` (`npm run openapi`) and which the IdP
|
|
332
|
+
itself validates incoming requests with. A typo in a path is a compile error,
|
|
333
|
+
not a 404 in production, and a response that doesn't match its schema throws
|
|
334
|
+
instead of reaching your code as `undefined`.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const api = createManagementApi({ baseUrl, token })
|
|
338
|
+
const { members } = await api.request("get", "/api/v1/apps/{app}/members", {
|
|
339
|
+
params: { app: "luchy" },
|
|
340
|
+
})
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The OIDC endpoints are not in that document and never will be: they are
|
|
344
|
+
standards-defined and discovered at runtime from `.well-known`.
|
|
284
345
|
|
|
285
346
|
## Licence
|
|
286
347
|
|
package/dist/src/api.d.ts
CHANGED
|
@@ -1,64 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The typed door to the IdP's `/api/v1/*` management surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Paths, methods, request bodies and success shapes all come from the
|
|
5
|
+
* operations table in `./schemas/operations.ts` — the same table that builds
|
|
6
|
+
* the OpenAPI document and that `apps/idp` validates requests with. A typo in
|
|
7
|
+
* a path is a compile error, not a 404 in production, and a response that
|
|
8
|
+
* doesn't match its schema raises an `IdpError` naming the field rather than
|
|
9
|
+
* surfacing as `undefined` somewhere downstream.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Path parameters are read off the path template itself, so `{app}` in the
|
|
12
|
+
* string is what makes `params.app` required.
|
|
13
|
+
*
|
|
14
|
+
* This is the escape hatch for any endpoint without sugar of its own; see
|
|
15
|
+
* `user-keys.ts` for the shaped client over the end-user key endpoints.
|
|
14
16
|
*/
|
|
15
|
-
import type {
|
|
16
|
-
|
|
17
|
-
export type Method =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
[K in M]: object;
|
|
22
|
-
} ? P : never;
|
|
23
|
-
}[keyof paths];
|
|
24
|
-
export type Operation<P extends keyof paths, M extends Method> = M extends keyof paths[P] ? paths[P][M] : never;
|
|
25
|
-
type PathParams<O> = O extends {
|
|
26
|
-
parameters: {
|
|
27
|
-
path: infer T;
|
|
28
|
-
};
|
|
29
|
-
} ? T extends object ? T : never : never;
|
|
30
|
-
type RequestBody<O> = O extends {
|
|
31
|
-
requestBody: {
|
|
32
|
-
content: {
|
|
33
|
-
"application/json": infer B;
|
|
34
|
-
};
|
|
35
|
-
};
|
|
36
|
-
} ? B : never;
|
|
37
|
-
/** The 200 or 201 JSON body, whichever this operation declares. */
|
|
38
|
-
type SuccessBody<O> = O extends {
|
|
39
|
-
responses: infer R;
|
|
40
|
-
} ? R extends {
|
|
41
|
-
200: {
|
|
42
|
-
content: {
|
|
43
|
-
"application/json": infer T;
|
|
44
|
-
};
|
|
45
|
-
};
|
|
46
|
-
} ? T : R extends {
|
|
47
|
-
201: {
|
|
48
|
-
content: {
|
|
49
|
-
"application/json": infer T;
|
|
50
|
-
};
|
|
51
|
-
};
|
|
52
|
-
} ? T : never : never;
|
|
53
|
-
export type RequestOptions<O> = ([PathParams<O>] extends [never] ? {
|
|
17
|
+
import type { z } from "zod";
|
|
18
|
+
import { type HttpMethod, type OperationFor, type PathParamNames, type PathsFor } from "./schemas/operations.js";
|
|
19
|
+
export type Method = HttpMethod;
|
|
20
|
+
export type { PathsFor };
|
|
21
|
+
/** What the caller must supply: `{app}` in the path becomes `params.app`. */
|
|
22
|
+
type PathParams<P extends string> = [PathParamNames<P>] extends [never] ? {
|
|
54
23
|
params?: undefined;
|
|
55
24
|
} : {
|
|
56
|
-
params:
|
|
57
|
-
}
|
|
58
|
-
|
|
25
|
+
params: Record<PathParamNames<P>, string>;
|
|
26
|
+
};
|
|
27
|
+
type RequestBody<O> = O extends {
|
|
28
|
+
input: infer S extends z.ZodType;
|
|
29
|
+
} ? {
|
|
30
|
+
body: z.input<S>;
|
|
59
31
|
} : {
|
|
60
|
-
body
|
|
61
|
-
}
|
|
32
|
+
body?: undefined;
|
|
33
|
+
};
|
|
34
|
+
/** The 200 or 201 JSON body, whichever this operation declares. */
|
|
35
|
+
export type SuccessBody<O> = O extends {
|
|
36
|
+
success: infer S extends z.ZodType;
|
|
37
|
+
} ? z.output<S> : never;
|
|
38
|
+
export type RequestOptions<P extends string, O> = PathParams<P> & RequestBody<O> & {
|
|
62
39
|
query?: Record<string, string | number | boolean | undefined>;
|
|
63
40
|
signal?: AbortSignal;
|
|
64
41
|
};
|
|
@@ -70,8 +47,7 @@ export type ManagementApiOptions = {
|
|
|
70
47
|
fetch?: typeof fetch;
|
|
71
48
|
};
|
|
72
49
|
export declare function createManagementApi(options: ManagementApiOptions): {
|
|
73
|
-
request<M extends Method, P extends PathsFor<M>>(method: M, path: P, init?: RequestOptions<
|
|
50
|
+
request<M extends Method, P extends PathsFor<M>>(method: M, path: P, init?: RequestOptions<P, OperationFor<M, P>>): Promise<SuccessBody<OperationFor<M, P>>>;
|
|
74
51
|
};
|
|
75
52
|
export type ManagementApi = ReturnType<typeof createManagementApi>;
|
|
76
|
-
export {};
|
|
77
53
|
//# sourceMappingURL=api.d.ts.map
|
package/dist/src/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAG5B,OAAO,EAEL,KAAK,UAAU,EAEf,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,QAAQ,EACd,MAAM,yBAAyB,CAAA;AAGhC,MAAM,MAAM,MAAM,GAAG,UAAU,CAAA;AAC/B,YAAY,EAAE,QAAQ,EAAE,CAAA;AAExB,6EAA6E;AAC7E,KAAK,UAAU,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GACnE;IAAE,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GACtB;IAAE,MAAM,EAAE,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAA;CAAE,CAAA;AAEjD,KAAK,WAAW,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,OAAO,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;CAAE,GACpB;IAAE,IAAI,CAAC,EAAE,SAAS,CAAA;CAAE,CAAA;AAExB,mEAAmE;AACnE,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,OAAO,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,OAAO,CAAA;CAAE,GACzE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GACX,KAAK,CAAA;AAET,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,GAC7D,WAAW,CAAC,CAAC,CAAC,GAAG;IACf,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAA;IAC7D,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB,CAAA;AAEH,MAAM,MAAM,oBAAoB,GAAG;IACjC,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAA;IACf,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB;YAK/C,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,QAAQ,CAAC,CAAC,CAAC,UAC3C,CAAC,QACH,CAAC,SACD,cAAc,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAC1C,OAAO,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;EAuC9C;AAED,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAA"}
|
package/dist/src/api.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The typed door to the IdP's `/api/v1/*` management surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Paths, methods, request bodies and success shapes all come from the
|
|
5
|
+
* operations table in `./schemas/operations.ts` — the same table that builds
|
|
6
|
+
* the OpenAPI document and that `apps/idp` validates requests with. A typo in
|
|
7
|
+
* a path is a compile error, not a 404 in production, and a response that
|
|
8
|
+
* doesn't match its schema raises an `IdpError` naming the field rather than
|
|
9
|
+
* surfacing as `undefined` somewhere downstream.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Path parameters are read off the path template itself, so `{app}` in the
|
|
12
|
+
* string is what makes `params.app` required.
|
|
13
|
+
*
|
|
14
|
+
* This is the escape hatch for any endpoint without sugar of its own; see
|
|
15
|
+
* `user-keys.ts` for the shaped client over the end-user key endpoints.
|
|
14
16
|
*/
|
|
15
|
-
import { IdpError } from "./
|
|
17
|
+
import { IdpError } from "./errors.js";
|
|
18
|
+
import { lookup, } from "./schemas/operations.js";
|
|
19
|
+
import { parseWire } from "./validate.js";
|
|
16
20
|
export function createManagementApi(options) {
|
|
17
21
|
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
18
22
|
const doFetch = options.fetch ?? globalThis.fetch;
|
|
19
23
|
return {
|
|
20
24
|
async request(method, path, init = {}) {
|
|
25
|
+
const operation = lookup(method, path);
|
|
21
26
|
const params = (init.params ?? {});
|
|
22
27
|
const rendered = String(path).replace(/\{([^}]+)\}/g, (_, name) => {
|
|
23
28
|
const value = params[name];
|
|
@@ -44,7 +49,7 @@ export function createManagementApi(options) {
|
|
|
44
49
|
if (!response.ok) {
|
|
45
50
|
throw new IdpError(`${method.toUpperCase()} ${rendered} failed (${response.status})`, response.status, json);
|
|
46
51
|
}
|
|
47
|
-
return json;
|
|
52
|
+
return parseWire(operation.success, json, `${method.toUpperCase()} ${rendered}`);
|
|
48
53
|
},
|
|
49
54
|
};
|
|
50
55
|
}
|
package/dist/src/claims.d.ts
CHANGED
|
@@ -1,41 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `claims["https://willy.im/permissions"]`.
|
|
2
|
+
* Claim types and the permission check over them. The shapes themselves live
|
|
3
|
+
* in `./wire.ts` as zod schemas — this module re-exports them so app code has
|
|
4
|
+
* one obvious import, and owns `grants`, which is logic rather than parsing.
|
|
6
5
|
*/
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
/** A tenant inside THIS app. `domain` is set for multi-domain apps, else null. */
|
|
10
|
-
export type Workspace = {
|
|
11
|
-
id: string;
|
|
12
|
-
slug: string;
|
|
13
|
-
name: string;
|
|
14
|
-
domain: string | null;
|
|
15
|
-
role: string;
|
|
16
|
-
};
|
|
6
|
+
import { type Claims } from "./wire.js";
|
|
7
|
+
export { ActorSchema, ClaimsSchema, PERMISSIONS_CLAIM, WORKSPACES_CLAIM, WorkspaceSchema, type Actor, type Claims, type Workspace, } from "./wire.js";
|
|
17
8
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
9
|
+
* Wire claims -> `Claims`. Tolerant by design: a missing or malformed optional
|
|
10
|
+
* claim degrades to an empty value. Only `sub` is required — without it there
|
|
11
|
+
* is no identity to seat a session on.
|
|
20
12
|
*/
|
|
21
|
-
export
|
|
22
|
-
sub: string;
|
|
23
|
-
email?: string;
|
|
24
|
-
};
|
|
25
|
-
export type Claims = {
|
|
26
|
-
sub: string;
|
|
27
|
-
email: string;
|
|
28
|
-
emailVerified: boolean;
|
|
29
|
-
name: string | null;
|
|
30
|
-
image: string | null;
|
|
31
|
-
/** Product permissions granted in this app. Unwrapped from the namespace. */
|
|
32
|
-
permissions: string[];
|
|
33
|
-
/** Workspaces the user belongs to in this app. Unwrapped from the namespace. */
|
|
34
|
-
workspaces: Workspace[];
|
|
35
|
-
actor: Actor | null;
|
|
36
|
-
};
|
|
37
|
-
/** Wire claims -> `Claims`. Tolerant: a missing claim is an empty value, not a throw. */
|
|
38
|
-
export declare function normalizeClaims(payload: Record<string, unknown>): Claims;
|
|
13
|
+
export declare function normalizeClaims(payload: unknown): Claims;
|
|
39
14
|
/**
|
|
40
15
|
* Does this permission set grant `permission`? Exact match, plus prefix
|
|
41
16
|
* wildcards — a grant of `invoices:*` covers `invoices:read`, and `*` covers
|
package/dist/src/claims.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../../src/claims.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../../src/claims.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,WAAW,CAAA;AAGrD,OAAO,EACL,WAAW,EACX,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,EACf,KAAK,KAAK,EACV,KAAK,MAAM,EACX,KAAK,SAAS,GACf,MAAM,WAAW,CAAA;AAElB;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAExD;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAOlF"}
|
package/dist/src/claims.js
CHANGED
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
2
|
+
* Claim types and the permission check over them. The shapes themselves live
|
|
3
|
+
* in `./wire.ts` as zod schemas — this module re-exports them so app code has
|
|
4
|
+
* one obvious import, and owns `grants`, which is logic rather than parsing.
|
|
5
|
+
*/
|
|
6
|
+
import { ClaimsSchema } from "./wire.js";
|
|
7
|
+
import { parseWire } from "./validate.js";
|
|
8
|
+
export { ActorSchema, ClaimsSchema, PERMISSIONS_CLAIM, WORKSPACES_CLAIM, WorkspaceSchema, } from "./wire.js";
|
|
9
|
+
/**
|
|
10
|
+
* Wire claims -> `Claims`. Tolerant by design: a missing or malformed optional
|
|
11
|
+
* claim degrades to an empty value. Only `sub` is required — without it there
|
|
12
|
+
* is no identity to seat a session on.
|
|
6
13
|
*/
|
|
7
|
-
export const PERMISSIONS_CLAIM = "https://willy.im/permissions";
|
|
8
|
-
export const WORKSPACES_CLAIM = "https://willy.im/workspaces";
|
|
9
|
-
/** Wire claims -> `Claims`. Tolerant: a missing claim is an empty value, not a throw. */
|
|
10
14
|
export function normalizeClaims(payload) {
|
|
11
|
-
return
|
|
12
|
-
sub: str(payload.sub) ?? "",
|
|
13
|
-
email: str(payload.email) ?? "",
|
|
14
|
-
emailVerified: payload.email_verified === true,
|
|
15
|
-
name: str(payload.name),
|
|
16
|
-
image: str(payload.picture) ?? str(payload.image),
|
|
17
|
-
permissions: Array.isArray(payload[PERMISSIONS_CLAIM])
|
|
18
|
-
? payload[PERMISSIONS_CLAIM].map(String)
|
|
19
|
-
: [],
|
|
20
|
-
workspaces: Array.isArray(payload[WORKSPACES_CLAIM])
|
|
21
|
-
? payload[WORKSPACES_CLAIM].map((w) => ({
|
|
22
|
-
id: String(w.id ?? ""),
|
|
23
|
-
slug: String(w.slug ?? ""),
|
|
24
|
-
name: String(w.name ?? ""),
|
|
25
|
-
domain: str(w.domain),
|
|
26
|
-
role: String(w.role ?? "member"),
|
|
27
|
-
}))
|
|
28
|
-
: [],
|
|
29
|
-
actor: actorOf(payload.act),
|
|
30
|
-
};
|
|
15
|
+
return parseWire(ClaimsSchema, payload, "userinfo");
|
|
31
16
|
}
|
|
32
17
|
/**
|
|
33
18
|
* Does this permission set grant `permission`? Exact match, plus prefix
|
|
@@ -46,16 +31,3 @@ export function grants(permissions, permission) {
|
|
|
46
31
|
}
|
|
47
32
|
return false;
|
|
48
33
|
}
|
|
49
|
-
function str(value) {
|
|
50
|
-
return typeof value === "string" && value ? value : null;
|
|
51
|
-
}
|
|
52
|
-
function actorOf(value) {
|
|
53
|
-
if (!value || typeof value !== "object")
|
|
54
|
-
return null;
|
|
55
|
-
const act = value;
|
|
56
|
-
const sub = str(act.sub);
|
|
57
|
-
if (!sub)
|
|
58
|
-
return null;
|
|
59
|
-
const email = str(act.email);
|
|
60
|
-
return email ? { sub, email } : { sub };
|
|
61
|
-
}
|
package/dist/src/client.d.ts
CHANGED
|
@@ -6,7 +6,18 @@
|
|
|
6
6
|
* `fetch` + WebCrypto only, so it runs anywhere the platform is web-standard.
|
|
7
7
|
*/
|
|
8
8
|
import { type Claims } from "./claims.js";
|
|
9
|
-
|
|
9
|
+
import { type Discovery } from "./wire.js";
|
|
10
|
+
export { IdpError } from "./errors.js";
|
|
11
|
+
export type { Discovery, Tokens } from "./wire.js";
|
|
12
|
+
/**
|
|
13
|
+
* Every scope the IdP advertises in `scopes_supported`. Typed rather than
|
|
14
|
+
* `string[]` so a typo (`"offline-access"`) is a compile error instead of a
|
|
15
|
+
* redirect the IdP rejects at runtime. Widen this when the IdP grows a scope.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SUPPORTED_SCOPES: readonly ["openid", "profile", "email", "offline_access"];
|
|
18
|
+
export type IdpScope = (typeof SUPPORTED_SCOPES)[number];
|
|
19
|
+
/** What `authorizationUrl` asks for when a caller says nothing. */
|
|
20
|
+
export declare const DEFAULT_SCOPES: readonly IdpScope[];
|
|
10
21
|
export type IdpClientOptions = {
|
|
11
22
|
/**
|
|
12
23
|
* The OIDC issuer, including the basepath the IdP is mounted on:
|
|
@@ -17,45 +28,16 @@ export type IdpClientOptions = {
|
|
|
17
28
|
clientId: string;
|
|
18
29
|
clientSecret: string;
|
|
19
30
|
/** Default scopes for `authorizationUrl`. `offline_access` buys refresh tokens. */
|
|
20
|
-
scopes?:
|
|
31
|
+
scopes?: readonly IdpScope[];
|
|
21
32
|
/** Override `fetch` — tests, instrumentation, a Worker's bound fetcher. */
|
|
22
33
|
fetch?: typeof fetch;
|
|
23
34
|
};
|
|
24
|
-
/** The subset of the discovery document we use, plus the fields we may. */
|
|
25
|
-
export type Discovery = {
|
|
26
|
-
issuer: string;
|
|
27
|
-
authorization_endpoint: string;
|
|
28
|
-
token_endpoint: string;
|
|
29
|
-
userinfo_endpoint: string;
|
|
30
|
-
end_session_endpoint?: string;
|
|
31
|
-
jwks_uri?: string;
|
|
32
|
-
/**
|
|
33
|
-
* Per-app session ceiling, in seconds. Not standard OIDC — an IdP extension
|
|
34
|
-
* the SDK clamps `session.expiresIn` against when present.
|
|
35
|
-
*/
|
|
36
|
-
session_max_age?: number;
|
|
37
|
-
};
|
|
38
|
-
/** Token-endpoint output, camelCased so app code never sees the wire shape. */
|
|
39
|
-
export type Tokens = {
|
|
40
|
-
accessToken: string;
|
|
41
|
-
tokenType: string;
|
|
42
|
-
/** Seconds until the access token expires, when the IdP says. */
|
|
43
|
-
expiresIn: number | null;
|
|
44
|
-
refreshToken: string | null;
|
|
45
|
-
idToken: string | null;
|
|
46
|
-
scope: string | null;
|
|
47
|
-
};
|
|
48
|
-
export declare class IdpError extends Error {
|
|
49
|
-
readonly status: number;
|
|
50
|
-
readonly body: unknown;
|
|
51
|
-
constructor(message: string, status: number, body?: unknown);
|
|
52
|
-
}
|
|
53
35
|
export type AuthorizationUrlInput = {
|
|
54
36
|
redirectUri: string;
|
|
55
37
|
state: string;
|
|
56
38
|
/** The S256 challenge. PKCE is not optional — see `createPkce`. */
|
|
57
39
|
codeChallenge: string;
|
|
58
|
-
scopes?:
|
|
40
|
+
scopes?: readonly IdpScope[];
|
|
59
41
|
/** `login` to force re-authentication, `none` for a silent check. */
|
|
60
42
|
prompt?: string;
|
|
61
43
|
loginHint?: string;
|
|
@@ -75,9 +57,23 @@ export declare function createIdpClient(options: IdpClientOptions): {
|
|
|
75
57
|
code: string;
|
|
76
58
|
redirectUri: string;
|
|
77
59
|
codeVerifier: string;
|
|
78
|
-
}): Promise<
|
|
60
|
+
}): Promise<{
|
|
61
|
+
accessToken: string;
|
|
62
|
+
tokenType: string;
|
|
63
|
+
expiresIn: number | null;
|
|
64
|
+
refreshToken: string | null;
|
|
65
|
+
idToken: string | null;
|
|
66
|
+
scope: string | null;
|
|
67
|
+
}>;
|
|
79
68
|
/** Requires `offline_access` at login. Throws `IdpError` once the grant is gone. */
|
|
80
|
-
refresh(refreshToken: string): Promise<
|
|
69
|
+
refresh(refreshToken: string): Promise<{
|
|
70
|
+
accessToken: string;
|
|
71
|
+
tokenType: string;
|
|
72
|
+
expiresIn: number | null;
|
|
73
|
+
refreshToken: string | null;
|
|
74
|
+
idToken: string | null;
|
|
75
|
+
scope: string | null;
|
|
76
|
+
}>;
|
|
81
77
|
/**
|
|
82
78
|
* Live claims for an access token. Unlike the id_token — a login-time
|
|
83
79
|
* snapshot — this reflects permissions and workspaces as they are *now*,
|
package/dist/src/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAmB,KAAK,MAAM,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAmB,KAAK,MAAM,EAAE,MAAM,aAAa,CAAA;AAI1D,OAAO,EAAiC,KAAK,SAAS,EAAe,MAAM,WAAW,CAAA;AAEtF,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACtC,YAAY,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAElD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,2DAA4D,CAAA;AAEzF,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAA;AAExD,mEAAmE;AACnE,eAAO,MAAM,cAAc,EAAE,SAAS,QAAQ,EAAqB,CAAA;AAEnE,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,mFAAmF;IACnF,MAAM,CAAC,EAAE,SAAS,QAAQ,EAAE,CAAA;IAC5B,2EAA2E;IAC3E,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,mEAAmE;IACnE,aAAa,EAAE,MAAM,CAAA;IACrB,MAAM,CAAC,EAAE,SAAS,QAAQ,EAAE,CAAA;IAC5B,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAA;AAE1D,oDAAoD;AACpD,wBAAsB,UAAU,IAAI,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC,CAG3F;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB;oBAOlC,OAAO,CAAC,SAAS,CAAC;IA0CrC,2CAA2C;4BACb,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IAerE,oFAAoF;wBAChE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE;;;;;;;;IAY/E,oFAAoF;0BAC9D,MAAM;;;;;;;;IAO5B;;;;OAIG;0BACyB,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAepD;;;;;;;;;;;;;;;;;;;OAmBG;qBACoB;QACrB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QACvB,UAAU,CAAC,EAAE,MAAM,CAAA;KACpB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;EAU7B"}
|
package/dist/src/client.js
CHANGED
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { normalizeClaims } from "./claims.js";
|
|
9
9
|
import { randomToken, sha256Base64url } from "./crypto.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
import { IdpError } from "./errors.js";
|
|
11
|
+
import { parseWire } from "./validate.js";
|
|
12
|
+
import { DiscoverySchema, TokensSchema } from "./wire.js";
|
|
13
|
+
export { IdpError } from "./errors.js";
|
|
14
|
+
/**
|
|
15
|
+
* Every scope the IdP advertises in `scopes_supported`. Typed rather than
|
|
16
|
+
* `string[]` so a typo (`"offline-access"`) is a compile error instead of a
|
|
17
|
+
* redirect the IdP rejects at runtime. Widen this when the IdP grows a scope.
|
|
18
|
+
*/
|
|
19
|
+
export const SUPPORTED_SCOPES = ["openid", "profile", "email", "offline_access"];
|
|
20
|
+
/** What `authorizationUrl` asks for when a caller says nothing. */
|
|
21
|
+
export const DEFAULT_SCOPES = SUPPORTED_SCOPES;
|
|
21
22
|
/** A fresh PKCE verifier and its S256 challenge. */
|
|
22
23
|
export async function createPkce() {
|
|
23
24
|
const codeVerifier = randomToken(32);
|
|
@@ -44,7 +45,7 @@ export function createIdpClient(options) {
|
|
|
44
45
|
cached = null;
|
|
45
46
|
throw new IdpError(`discovery failed (${response.status})`, response.status, await text(response));
|
|
46
47
|
}
|
|
47
|
-
return (await response.json());
|
|
48
|
+
return parseWire(DiscoverySchema, await response.json().catch(() => null), "discovery");
|
|
48
49
|
})();
|
|
49
50
|
return cached;
|
|
50
51
|
}
|
|
@@ -57,18 +58,11 @@ export function createIdpClient(options) {
|
|
|
57
58
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
58
59
|
body,
|
|
59
60
|
});
|
|
60
|
-
const json =
|
|
61
|
+
const json = await response.json().catch(() => null);
|
|
61
62
|
if (!response.ok || !json) {
|
|
62
63
|
throw new IdpError(`${what} failed (${response.status})`, response.status, json);
|
|
63
64
|
}
|
|
64
|
-
return
|
|
65
|
-
accessToken: String(json.access_token ?? ""),
|
|
66
|
-
tokenType: String(json.token_type ?? "Bearer"),
|
|
67
|
-
expiresIn: typeof json.expires_in === "number" ? json.expires_in : null,
|
|
68
|
-
refreshToken: typeof json.refresh_token === "string" ? json.refresh_token : null,
|
|
69
|
-
idToken: typeof json.id_token === "string" ? json.id_token : null,
|
|
70
|
-
scope: typeof json.scope === "string" ? json.scope : null,
|
|
71
|
-
};
|
|
65
|
+
return parseWire(TokensSchema, json, what);
|
|
72
66
|
}
|
|
73
67
|
return {
|
|
74
68
|
discover,
|
|
@@ -115,7 +109,7 @@ export function createIdpClient(options) {
|
|
|
115
109
|
if (!response.ok) {
|
|
116
110
|
throw new IdpError(`userinfo failed (${response.status})`, response.status, await text(response));
|
|
117
111
|
}
|
|
118
|
-
return normalizeClaims(
|
|
112
|
+
return normalizeClaims(await response.json().catch(() => null));
|
|
119
113
|
},
|
|
120
114
|
/**
|
|
121
115
|
* RP-initiated logout, built from the `end_session_endpoint` in discovery —
|