@browserlessapi/inferlo 0.6.0 → 0.8.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 +9 -5
- package/dist/ai/ai-sdk.d.ts +48 -0
- package/dist/ai/ai-sdk.js +235 -0
- package/dist/ai/ai-sdk.js.map +1 -0
- package/dist/ai/index.d.ts +37 -3
- package/dist/ai/index.js +96 -7
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/public.d.ts +5 -1
- package/dist/ai/public.js +2 -0
- package/dist/ai/public.js.map +1 -1
- package/dist/ai/ui.d.ts +47 -0
- package/dist/ai/ui.js +33 -0
- package/dist/ai/ui.js.map +1 -0
- package/dist/app.d.ts +55 -4
- package/dist/app.js +439 -39
- package/dist/app.js.map +1 -1
- package/dist/astro.d.ts +60 -0
- package/dist/astro.js +68 -0
- package/dist/astro.js.map +1 -0
- package/dist/audit.d.ts +8 -1
- package/dist/audit.js +9 -2
- package/dist/audit.js.map +1 -1
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/authjs.d.ts +70 -0
- package/dist/authjs.js +94 -0
- package/dist/authjs.js.map +1 -0
- package/dist/azure.d.ts +66 -0
- package/dist/azure.js +119 -0
- package/dist/azure.js.map +1 -0
- package/dist/better-auth.d.ts +89 -0
- package/dist/better-auth.js +75 -0
- package/dist/better-auth.js.map +1 -0
- package/dist/cache.d.ts +13 -9
- package/dist/cache.js +11 -9
- package/dist/cache.js.map +1 -1
- package/dist/cli/main.js +5 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/client-ip.d.ts +41 -1
- package/dist/client-ip.js +85 -1
- package/dist/client-ip.js.map +1 -1
- package/dist/cross-site.d.ts +14 -0
- package/dist/cross-site.js +56 -0
- package/dist/cross-site.js.map +1 -0
- package/dist/db/memory.js +2 -1
- package/dist/db/memory.js.map +1 -1
- package/dist/db/postgres.js +23 -6
- package/dist/db/postgres.js.map +1 -1
- package/dist/db/table.d.ts +37 -5
- package/dist/db/table.js +96 -15
- package/dist/db/table.js.map +1 -1
- package/dist/db/types.d.ts +2 -0
- package/dist/describe.d.ts +23 -5
- package/dist/describe.js +20 -2
- package/dist/describe.js.map +1 -1
- package/dist/disk-storage.d.ts +6 -0
- package/dist/disk-storage.js +96 -0
- package/dist/disk-storage.js.map +1 -0
- package/dist/events.d.ts +134 -0
- package/dist/events.js +196 -0
- package/dist/events.js.map +1 -0
- package/dist/files.d.ts +23 -0
- package/dist/files.js +168 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +26 -8
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +56 -10
- package/dist/jobs.js +71 -13
- package/dist/jobs.js.map +1 -1
- package/dist/jwt.d.ts +83 -0
- package/dist/jwt.js +312 -0
- package/dist/jwt.js.map +1 -0
- package/dist/langchain.d.ts +58 -0
- package/dist/langchain.js +220 -0
- package/dist/langchain.js.map +1 -0
- package/dist/model.d.ts +23 -6
- package/dist/model.js +41 -7
- package/dist/model.js.map +1 -1
- package/dist/oidc.d.ts +70 -0
- package/dist/oidc.js +379 -0
- package/dist/oidc.js.map +1 -0
- package/dist/openapi.js +17 -2
- package/dist/openapi.js.map +1 -1
- package/dist/passport.d.ts +54 -0
- package/dist/passport.js +68 -0
- package/dist/passport.js.map +1 -0
- package/dist/postgres-js.d.ts +39 -0
- package/dist/postgres-js.js +71 -0
- package/dist/postgres-js.js.map +1 -0
- package/dist/rate-limit.d.ts +5 -3
- package/dist/rate-limit.js +3 -3
- package/dist/rate-limit.js.map +1 -1
- package/dist/redis-events.d.ts +27 -0
- package/dist/redis-events.js +163 -0
- package/dist/redis-events.js.map +1 -0
- package/dist/redis-jobs.d.ts +24 -0
- package/dist/redis-jobs.js +203 -0
- package/dist/redis-jobs.js.map +1 -0
- package/dist/redis-sockets.d.ts +24 -0
- package/dist/redis-sockets.js +75 -0
- package/dist/redis-sockets.js.map +1 -0
- package/dist/redis.d.ts +30 -0
- package/dist/redis.js +106 -0
- package/dist/redis.js.map +1 -0
- package/dist/response-body.d.ts +6 -0
- package/dist/response-body.js +7 -0
- package/dist/response-body.js.map +1 -0
- package/dist/route.d.ts +117 -3
- package/dist/route.js +27 -0
- package/dist/route.js.map +1 -1
- package/dist/schema.d.ts +23 -3
- package/dist/schema.js +70 -0
- package/dist/schema.js.map +1 -1
- package/dist/serve.d.ts +24 -1
- package/dist/serve.js +200 -8
- package/dist/serve.js.map +1 -1
- package/dist/service-client.d.ts +91 -0
- package/dist/service-client.js +177 -0
- package/dist/service-client.js.map +1 -0
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.js +36 -4
- package/dist/sessions.js.map +1 -1
- package/dist/sockets.d.ts +58 -0
- package/dist/sockets.js +111 -0
- package/dist/sockets.js.map +1 -0
- package/dist/storage.d.ts +47 -0
- package/dist/storage.js +123 -0
- package/dist/storage.js.map +1 -0
- package/dist/tracing.d.ts +7 -3
- package/dist/tracing.js +8 -2
- package/dist/tracing.js.map +1 -1
- package/dist/websocket.d.ts +81 -0
- package/dist/websocket.js +251 -0
- package/dist/websocket.js.map +1 -0
- package/llms.txt +2 -0
- package/package.json +77 -5
- package/templates/app/AGENTS.md +9 -5
- package/templates/app/test/notes.test.ts +2 -0
package/dist/model.d.ts
CHANGED
|
@@ -4,27 +4,43 @@ export type SystemShape = {
|
|
|
4
4
|
createdAt: Schema<Date, Date | string>;
|
|
5
5
|
updatedAt: Schema<Date, Date | string>;
|
|
6
6
|
};
|
|
7
|
-
|
|
7
|
+
/** Options of model(). */
|
|
8
|
+
export interface ModelOptions {
|
|
9
|
+
/**
|
|
10
|
+
* true: the table belongs to tenants (organisations, workspaces, customers). Every row gets a
|
|
11
|
+
* tenantId column, and ctx.db filters every query by and stamps every insert with the current
|
|
12
|
+
* tenant (auth.tenant(user), default user.tenantId). Unique fields are unique per tenant.
|
|
13
|
+
*/
|
|
14
|
+
tenant?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Longest tenant id Inferlo accepts. */
|
|
17
|
+
export declare const MAX_TENANT_ID = 200;
|
|
18
|
+
export type TenantShape = {
|
|
19
|
+
tenantId: Schema<string>;
|
|
20
|
+
};
|
|
21
|
+
export declare class Model<N extends string = string, S extends Shape = Shape, T extends boolean = false> {
|
|
8
22
|
/** Table name, also the property name on `db` (db.tickets). */
|
|
9
23
|
readonly name: N;
|
|
10
24
|
/** Fields you defined. */
|
|
11
25
|
readonly fields: S;
|
|
12
|
-
/** Full row schema including id, createdAt and updatedAt. */
|
|
13
|
-
readonly row: ObjectSchema<SystemShape & S>;
|
|
26
|
+
/** Full row schema including id, createdAt and updatedAt (and tenantId on tenant-scoped models). */
|
|
27
|
+
readonly row: ObjectSchema<SystemShape & S & (T extends true ? TenantShape : {})>;
|
|
28
|
+
/** True when the model is tenant-scoped (model(name, fields, { tenant: true })). */
|
|
29
|
+
readonly tenant: T;
|
|
14
30
|
/** Input schema for inserts. Fields with defaults or .optional() may be left out. */
|
|
15
31
|
readonly create: ObjectSchema<S>;
|
|
16
32
|
/** Input schema for updates. Every field optional. */
|
|
17
33
|
readonly update: ObjectSchema<PartialShape<S>>;
|
|
18
34
|
/** Type-only: the row type. Prefer Row<typeof Model>. */
|
|
19
|
-
readonly $row: ShapeOutput<SystemShape & S>;
|
|
35
|
+
readonly $row: ShapeOutput<SystemShape & S & (T extends true ? TenantShape : {})>;
|
|
20
36
|
readonly $create: ShapeInput<S>;
|
|
21
|
-
constructor(name: N, fields: S);
|
|
37
|
+
constructor(name: N, fields: S, options?: ModelOptions);
|
|
22
38
|
/** Input schema with only some fields, e.g. for a route body: Ticket.pick("title", "body"). */
|
|
23
39
|
pick<K extends keyof S & string>(...keys: K[]): ObjectSchema<Pick<S, K>>;
|
|
24
40
|
/** Input schema without some fields, e.g. Ticket.omit("ownerId"). */
|
|
25
41
|
omit<K extends keyof S & string>(...keys: K[]): ObjectSchema<Omit<S, K>>;
|
|
26
42
|
}
|
|
27
|
-
export type AnyModel = Model<string, any>;
|
|
43
|
+
export type AnyModel = Model<string, any, boolean>;
|
|
28
44
|
/** Row type of a model: Row<typeof Ticket>. */
|
|
29
45
|
export type Row<M extends AnyModel> = M["$row"];
|
|
30
46
|
/** Insert input type of a model. */
|
|
@@ -32,5 +48,6 @@ export type CreateInput<M extends AnyModel> = M["$create"];
|
|
|
32
48
|
/** Update input type of a model. */
|
|
33
49
|
export type UpdateInput<M extends AnyModel> = Partial<M["$create"]>;
|
|
34
50
|
export declare function model<const N extends string, S extends Shape>(name: N, fields: S): Model<N, S>;
|
|
51
|
+
export declare function model<const N extends string, S extends Shape, const O extends ModelOptions>(name: N, fields: S, options: O): Model<N, S, O["tenant"] extends true ? true : false>;
|
|
35
52
|
/** camelCase field name to snake_case column name. */
|
|
36
53
|
export declare function columnName(field: string): string;
|
package/dist/model.js
CHANGED
|
@@ -8,38 +8,68 @@
|
|
|
8
8
|
* })
|
|
9
9
|
*
|
|
10
10
|
* Every model gets id (uuid), createdAt and updatedAt automatically.
|
|
11
|
+
*
|
|
12
|
+
* Tenant-scoped models (multi-tenant apps) also get tenantId, set from the signed-in user:
|
|
13
|
+
*
|
|
14
|
+
* export const Ticket = model("tickets", { title: t.string() }, { tenant: true })
|
|
11
15
|
*/
|
|
12
16
|
import { InferloError } from "./errors.js";
|
|
13
|
-
import { ObjectSchema, Schema, t } from "./schema.js";
|
|
17
|
+
import { ObjectSchema, Schema, containsFile, t } from "./schema.js";
|
|
14
18
|
const TABLE_NAME = /^[a-z][a-z0-9_]{0,62}$/;
|
|
15
19
|
const FIELD_NAME = /^[a-zA-Z][a-zA-Z0-9]{0,62}$/;
|
|
16
20
|
const RESERVED = new Set(["id", "createdAt", "updatedAt"]);
|
|
17
21
|
function system(schema, kind) {
|
|
18
22
|
return new Schema({ ...schema.def, system: kind });
|
|
19
23
|
}
|
|
24
|
+
/** Longest tenant id Inferlo accepts. */
|
|
25
|
+
export const MAX_TENANT_ID = 200;
|
|
20
26
|
export class Model {
|
|
21
27
|
/** Table name, also the property name on `db` (db.tickets). */
|
|
22
28
|
name;
|
|
23
29
|
/** Fields you defined. */
|
|
24
30
|
fields;
|
|
25
|
-
/** Full row schema including id, createdAt and updatedAt. */
|
|
31
|
+
/** Full row schema including id, createdAt and updatedAt (and tenantId on tenant-scoped models). */
|
|
26
32
|
row;
|
|
33
|
+
/** True when the model is tenant-scoped (model(name, fields, { tenant: true })). */
|
|
34
|
+
tenant;
|
|
27
35
|
/** Input schema for inserts. Fields with defaults or .optional() may be left out. */
|
|
28
36
|
create;
|
|
29
37
|
/** Input schema for updates. Every field optional. */
|
|
30
38
|
update;
|
|
31
|
-
constructor(name, fields) {
|
|
39
|
+
constructor(name, fields, options = {}) {
|
|
32
40
|
if (!TABLE_NAME.test(name)) {
|
|
33
41
|
throw new InferloError("IL4005", `Model name "${name}" is not a valid table name.`, {
|
|
34
42
|
fix: 'Use lowercase letters, digits and underscores, starting with a letter, e.g. model("support_tickets", {...}).',
|
|
35
43
|
});
|
|
36
44
|
}
|
|
45
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
46
|
+
throw new InferloError("IL4026", `The options of model "${name}" must be an object.`, { fix: `Pass model("${name}", { ...fields }, { tenant: true }), or leave the options out.` });
|
|
47
|
+
}
|
|
48
|
+
for (const key of Object.keys(options)) {
|
|
49
|
+
if (key !== "tenant") {
|
|
50
|
+
throw new InferloError("IL4026", `Unknown option "${key}" in model "${name}".`, { fix: "model() takes one option: { tenant: true }." });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (options.tenant !== undefined && typeof options.tenant !== "boolean") {
|
|
54
|
+
throw new InferloError("IL4026", `The tenant option of model "${name}" must be true or false.`, { fix: `Use model("${name}", { ...fields }, { tenant: true }).` });
|
|
55
|
+
}
|
|
56
|
+
const tenant = options.tenant === true;
|
|
37
57
|
for (const key of Object.keys(fields)) {
|
|
58
|
+
if (tenant && key === "tenantId") {
|
|
59
|
+
throw new InferloError("IL4006", `Model "${name}" defines "tenantId", which Inferlo adds to tenant-scoped models.`, {
|
|
60
|
+
fix: 'Remove "tenantId" from the model. With { tenant: true } every row already has tenantId, set from the current tenant.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
38
63
|
if (RESERVED.has(key)) {
|
|
39
64
|
throw new InferloError("IL4006", `Model "${name}" defines "${key}", which Inferlo adds automatically.`, {
|
|
40
65
|
fix: `Remove "${key}" from the model. Every model already has id, createdAt and updatedAt.`,
|
|
41
66
|
});
|
|
42
67
|
}
|
|
68
|
+
if (containsFile(fields[key])) {
|
|
69
|
+
throw new InferloError("IL1004", `Field "${name}.${key}" uses t.file(), which only works in route bodies.`, {
|
|
70
|
+
fix: "Accept the upload in a route body (body: t.object({ file: t.file() })), store the file elsewhere, and keep its key or URL in a t.string() field of the model.",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
43
73
|
if (!FIELD_NAME.test(key)) {
|
|
44
74
|
throw new InferloError("IL4005", `Field "${name}.${key}" is not a valid field name.`, {
|
|
45
75
|
fix: "Use camelCase letters and digits, e.g. ownerId. Inferlo stores it as owner_id.",
|
|
@@ -47,15 +77,19 @@ export class Model {
|
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
this.name = name;
|
|
50
|
-
this.
|
|
80
|
+
this.tenant = tenant;
|
|
51
81
|
this.create = t.object(fields);
|
|
52
82
|
this.update = this.create.partial();
|
|
83
|
+
// tenantId is a column like the others for the adapters, migrations and describe(), but never input.
|
|
84
|
+
this.fields = (tenant
|
|
85
|
+
? { ...fields, tenantId: system(t.string().min(1).max(MAX_TENANT_ID).index().describe("Set automatically from the current tenant"), "tenantId") }
|
|
86
|
+
: fields);
|
|
53
87
|
const systemShape = {
|
|
54
88
|
id: system(t.uuid(), "id"),
|
|
55
89
|
createdAt: system(t.date(), "createdAt"),
|
|
56
90
|
updatedAt: system(t.date(), "updatedAt"),
|
|
57
91
|
};
|
|
58
|
-
this.row = t.object({ ...systemShape, ...fields });
|
|
92
|
+
this.row = t.object({ ...systemShape, ...this.fields });
|
|
59
93
|
}
|
|
60
94
|
/** Input schema with only some fields, e.g. for a route body: Ticket.pick("title", "body"). */
|
|
61
95
|
pick(...keys) {
|
|
@@ -66,8 +100,8 @@ export class Model {
|
|
|
66
100
|
return this.create.omit(...keys);
|
|
67
101
|
}
|
|
68
102
|
}
|
|
69
|
-
export function model(name, fields) {
|
|
70
|
-
return new Model(name, fields);
|
|
103
|
+
export function model(name, fields, options) {
|
|
104
|
+
return new Model(name, fields, options);
|
|
71
105
|
}
|
|
72
106
|
/** camelCase field name to snake_case column name. */
|
|
73
107
|
export function columnName(field) {
|
package/dist/model.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model.js","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"model.js","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
|
package/dist/oidc.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type JwtClaims } from "./jwt.ts";
|
|
2
|
+
import { type AppDb, type RouteDef } from "./route.ts";
|
|
3
|
+
import type { Sessions } from "./sessions.ts";
|
|
4
|
+
/** The claims of a verified ID token. Providers add their own (email, email_verified, name, groups, tid, hd, ...). */
|
|
5
|
+
export interface OidcClaims extends JwtClaims {
|
|
6
|
+
nonce?: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
email_verified?: boolean;
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
/** The provider's answer to the code exchange. */
|
|
12
|
+
export interface OidcTokens {
|
|
13
|
+
idToken: string;
|
|
14
|
+
/** For calling the provider's APIs (userinfo, Microsoft Graph, ...); never stored by Inferlo. */
|
|
15
|
+
accessToken?: string;
|
|
16
|
+
refreshToken?: string;
|
|
17
|
+
/** Seconds until the access token expires, as the provider says. */
|
|
18
|
+
expiresIn?: number;
|
|
19
|
+
scope?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface OidcOptions {
|
|
22
|
+
/** The provider's issuer exactly as its OpenID configuration names it, such as "https://acme.okta.com". https, except on localhost. */
|
|
23
|
+
issuer: string;
|
|
24
|
+
/** The app's client id at the provider. */
|
|
25
|
+
clientId: string;
|
|
26
|
+
/** The app's client secret (keep it in config({ secrets })). Leave it out for a public client, which PKCE protects. */
|
|
27
|
+
clientSecret?: string;
|
|
28
|
+
/** The sessions() the sign-in ends in; also the app's auth: createApp({ auth: session }). */
|
|
29
|
+
session: Sessions;
|
|
30
|
+
/**
|
|
31
|
+
* The id of the app's user for a verified ID token (find or create the user), or null to refuse the sign-in
|
|
32
|
+
* (403 IL3011). Check claims.email_verified before trusting claims.email.
|
|
33
|
+
*/
|
|
34
|
+
userId: (claims: OidcClaims, ctx: {
|
|
35
|
+
db: AppDb;
|
|
36
|
+
req: Request;
|
|
37
|
+
tokens: OidcTokens;
|
|
38
|
+
}) => string | null | undefined | Promise<string | null | undefined>;
|
|
39
|
+
/** Where the provider sends the browser back, registered at the provider. Default: <the request's origin><basePath>/callback. */
|
|
40
|
+
redirectUri?: string;
|
|
41
|
+
/** Scopes to ask for. Default ["openid", "email", "profile"]; "openid" is always sent. */
|
|
42
|
+
scopes?: string[];
|
|
43
|
+
/** Where the login, callback and logout routes are. Default "/auth": GET /auth/login, GET /auth/callback and POST /auth/logout. */
|
|
44
|
+
basePath?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Extra parameters for every sign-in at the provider, such as { prompt: "select_account" }, Google's
|
|
47
|
+
* { hd: "acme.com" } or Entra ID's { domain_hint: "acme.com" }. The flow's own parameters cannot be changed.
|
|
48
|
+
*/
|
|
49
|
+
authorizationParams?: Record<string, string>;
|
|
50
|
+
/** Where the provider sends the browser after signing out, registered at the provider. Default: <the request's origin>/. */
|
|
51
|
+
postLogoutRedirectUri?: string;
|
|
52
|
+
}
|
|
53
|
+
/** routes: [sso] adds the two routes; oidcLogin starts a sign-in, oidcCallback finishes it. */
|
|
54
|
+
export type Oidc = {
|
|
55
|
+
/** GET <basePath>/login?returnTo=/path: sends the browser to the provider. */
|
|
56
|
+
oidcLogin: RouteDef;
|
|
57
|
+
/** GET <basePath>/callback: the redirect URI registered at the provider. */
|
|
58
|
+
oidcCallback: RouteDef;
|
|
59
|
+
/** POST <basePath>/logout (with the session's _csrf field): ends the session, then signs out at the provider. */
|
|
60
|
+
oidcLogout: RouteDef;
|
|
61
|
+
/** The login route's path, for links: <a href={sso.loginPath}>. */
|
|
62
|
+
loginPath: string;
|
|
63
|
+
/** The logout route's path, for a form: <form method="post" action={sso.logoutPath}>. */
|
|
64
|
+
logoutPath: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* OpenID Connect sign-in for browser apps: routes: [sso] with sso = oidc({ issuer, clientId, clientSecret,
|
|
68
|
+
* session, userId }). The sign-in ends in the session's cookie.
|
|
69
|
+
*/
|
|
70
|
+
export declare function oidc(options: OidcOptions): Oidc;
|
package/dist/oidc.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single sign-on for browser apps with OpenID Connect (Okta, Microsoft Entra ID, Google, Auth0, Keycloak, Ping,
|
|
3
|
+
* OneLogin, JumpCloud, Authentik, Zitadel and any other OpenID provider): the authorization code flow with PKCE,
|
|
4
|
+
* state and nonce, ending in a sessions() cookie.
|
|
5
|
+
*
|
|
6
|
+
* export const session = sessions({ secret: settings.SESSION_SECRET, user: ({ userId }, { db }) => db.users.find(userId) });
|
|
7
|
+
* export const sso = oidc({
|
|
8
|
+
* issuer: "https://acme.okta.com",
|
|
9
|
+
* clientId: settings.OIDC_CLIENT_ID,
|
|
10
|
+
* clientSecret: settings.OIDC_CLIENT_SECRET,
|
|
11
|
+
* session,
|
|
12
|
+
* userId: async (claims, { db }) => (await db.users.findOne({ email: claims.email }))?.id ?? null,
|
|
13
|
+
* });
|
|
14
|
+
* createApp({ auth: session, routes: [notes, sso] });
|
|
15
|
+
*
|
|
16
|
+
* GET /auth/login (?returnTo=/path) sends the browser to the provider; GET /auth/callback checks what comes back
|
|
17
|
+
* (state, the code exchanged with the PKCE verifier, the ID token's signature, issuer, audience, expiry and nonce),
|
|
18
|
+
* asks userId(claims) which user signs in, starts the session and sends the browser back. POST /auth/logout ends the
|
|
19
|
+
* session and signs the user out at the provider too (RP-initiated logout). No runtime dependencies.
|
|
20
|
+
*/
|
|
21
|
+
import { safeEqual } from "./auth.js";
|
|
22
|
+
import { InferloError } from "./errors.js";
|
|
23
|
+
import { ASYMMETRIC, createJwtVerifier, isLocal } from "./jwt.js";
|
|
24
|
+
import { route } from "./route.js";
|
|
25
|
+
const OPTIONS = ["issuer", "clientId", "clientSecret", "session", "userId", "redirectUri", "scopes", "basePath", "authorizationParams", "postLogoutRedirectUri"];
|
|
26
|
+
/** The parameters oidc() sets itself, which authorizationParams cannot change. */
|
|
27
|
+
const RESERVED_PARAMS = ["response_type", "client_id", "redirect_uri", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "response_mode", "request", "request_uri"];
|
|
28
|
+
/** The prompt values a login link may ask for with ?prompt= (OpenID Connect Core and Initiating User Registration). */
|
|
29
|
+
const PROMPTS = ["none", "login", "consent", "select_account", "create"];
|
|
30
|
+
/** ID tokens up to this size are kept for signing out at the provider; larger ones would not fit in a cookie. */
|
|
31
|
+
const MAX_ID_TOKEN_COOKIE = 3500;
|
|
32
|
+
/** How long the ID token is kept for signing out (the longest a sessions() cookie can last). */
|
|
33
|
+
const ID_TOKEN_COOKIE_SECONDS = 400 * 86_400;
|
|
34
|
+
const FETCH_TIMEOUT = 5000;
|
|
35
|
+
const DISCOVERY_CACHE_MS = 600_000;
|
|
36
|
+
const PENDING_MS = 600_000;
|
|
37
|
+
const fix = 'Use oidc({ issuer: "https://login.example.com", clientId: settings.OIDC_CLIENT_ID, clientSecret: settings.OIDC_CLIENT_SECRET, session, userId: (claims, { db }) => ... }) with the issuer, client id and secret your identity provider shows for this app.';
|
|
38
|
+
function invalid(message, fixText = fix) {
|
|
39
|
+
return new InferloError("IL6047", message, { fix: fixText });
|
|
40
|
+
}
|
|
41
|
+
function failed(reason) {
|
|
42
|
+
return new InferloError("IL3009", `The sign-in could not be completed: ${reason}.`, {
|
|
43
|
+
status: 400,
|
|
44
|
+
fix: "Start the sign-in again from the app's login link, in the same browser. If it keeps failing, check the app's OpenID Connect settings (issuer, client id, redirect URI) at the provider.",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function unreachable(what, cause) {
|
|
48
|
+
return new InferloError("IL3010", `The identity provider could not be used: ${what}.`, {
|
|
49
|
+
status: 502,
|
|
50
|
+
fix: `Check that the issuer, client id and client secret are right and that the provider is reachable from the server (${cause instanceof Error ? cause.message : String(cause)}).`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function httpsUrl(value, what) {
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(String(value));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw invalid(`oidc() ${what} must be a URL, got ${JSON.stringify(value)}.`);
|
|
60
|
+
}
|
|
61
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal(url))) {
|
|
62
|
+
throw invalid(`oidc() ${what} must use https (plain http only on localhost), got ${JSON.stringify(value)}.`);
|
|
63
|
+
}
|
|
64
|
+
return url;
|
|
65
|
+
}
|
|
66
|
+
function base64url(bytes) {
|
|
67
|
+
let binary = "";
|
|
68
|
+
for (const byte of bytes)
|
|
69
|
+
binary += String.fromCharCode(byte);
|
|
70
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
71
|
+
}
|
|
72
|
+
function random() {
|
|
73
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
74
|
+
}
|
|
75
|
+
/** A path on this app to return to after the sign-in, or "/": never another site (open redirect). */
|
|
76
|
+
function safeReturnTo(value) {
|
|
77
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\") || value.length > 2000)
|
|
78
|
+
return "/";
|
|
79
|
+
if (/[\u0000-\u001f\u007f\\]/.test(value))
|
|
80
|
+
return "/";
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function readCookie(header, name) {
|
|
84
|
+
if (!header)
|
|
85
|
+
return undefined;
|
|
86
|
+
for (const part of header.split(";")) {
|
|
87
|
+
const index = part.indexOf("=");
|
|
88
|
+
if (index !== -1 && part.slice(0, index).trim() === name)
|
|
89
|
+
return part.slice(index + 1).trim();
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The short-lived cookie that carries state, nonce and the PKCE verifier from login to callback ("inferlo_oidc"),
|
|
95
|
+
* and the one that keeps the ID token for signing out at the provider ("inferlo_oidc_id").
|
|
96
|
+
*/
|
|
97
|
+
function cookieFor(url, base = "inferlo_oidc") {
|
|
98
|
+
const local = url.protocol === "http:" && isLocal(url);
|
|
99
|
+
// __Host- cookies cannot be set by subdomains or for other paths; browsers need Secure for them.
|
|
100
|
+
return local ? { name: base, attributes: "Path=/; HttpOnly; SameSite=Lax" } : { name: `__Host-${base}`, attributes: "Path=/; HttpOnly; Secure; SameSite=Lax" };
|
|
101
|
+
}
|
|
102
|
+
/** The iss claim of a JWT, without checking it (only to pick the provider an ID token came from). */
|
|
103
|
+
function issuerOf(token) {
|
|
104
|
+
try {
|
|
105
|
+
const payload = token.split(".")[1] ?? "";
|
|
106
|
+
const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
|
|
107
|
+
return typeof claims.iss === "string" ? claims.iss : undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* OpenID Connect sign-in for browser apps: routes: [sso] with sso = oidc({ issuer, clientId, clientSecret,
|
|
115
|
+
* session, userId }). The sign-in ends in the session's cookie.
|
|
116
|
+
*/
|
|
117
|
+
export function oidc(options) {
|
|
118
|
+
if (!options || typeof options !== "object" || Array.isArray(options))
|
|
119
|
+
throw invalid("oidc() needs { issuer, clientId, session, userId }.");
|
|
120
|
+
for (const key of Object.keys(options))
|
|
121
|
+
if (!OPTIONS.includes(key))
|
|
122
|
+
throw invalid(`oidc() has no option "${key}".`, `Use ${OPTIONS.join(", ")}.`);
|
|
123
|
+
if (typeof options.issuer !== "string" || !options.issuer)
|
|
124
|
+
throw invalid("oidc() needs the issuer: the provider's URL as its OpenID configuration names it.");
|
|
125
|
+
httpsUrl(options.issuer, "issuer");
|
|
126
|
+
if (typeof options.clientId !== "string" || !options.clientId)
|
|
127
|
+
throw invalid("oidc() needs the clientId the provider gave this app.");
|
|
128
|
+
if (options.clientSecret !== undefined && (typeof options.clientSecret !== "string" || !options.clientSecret)) {
|
|
129
|
+
throw invalid("oidc() clientSecret must be a non-empty string, or left out for a public client.", "Keep the client secret in config({ secrets }) and pass it as clientSecret.");
|
|
130
|
+
}
|
|
131
|
+
const session = options.session;
|
|
132
|
+
if (!session || typeof session !== "object" || typeof session.signIn !== "function" || typeof session.resolve !== "function") {
|
|
133
|
+
throw invalid("oidc() needs session: the sessions() the sign-in ends in.", "Pass session = sessions({ secret, user }), and use it as the app's auth too: createApp({ auth: session }).");
|
|
134
|
+
}
|
|
135
|
+
if (typeof options.userId !== "function")
|
|
136
|
+
throw invalid("oidc() needs userId(claims, { db }): the id of the app's user for a verified sign-in, or null to refuse it.");
|
|
137
|
+
const redirectUri = options.redirectUri === undefined ? undefined : httpsUrl(options.redirectUri, "redirectUri").href;
|
|
138
|
+
const scopes = options.scopes ?? ["openid", "email", "profile"];
|
|
139
|
+
if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string" || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(s))) {
|
|
140
|
+
throw invalid('oidc() scopes must be a list of scope names, such as ["openid", "email", "profile"].');
|
|
141
|
+
}
|
|
142
|
+
const scope = [...new Set(["openid", ...scopes])].join(" ");
|
|
143
|
+
const basePath = options.basePath ?? "/auth";
|
|
144
|
+
if (typeof basePath !== "string" || !/^\/[A-Za-z0-9._~\-/]*$/.test(basePath) || basePath.includes("//") || !basePath.replace(/\/+$/, "")) {
|
|
145
|
+
throw invalid(`oidc() basePath must be a path such as "/auth", got ${JSON.stringify(basePath)}.`);
|
|
146
|
+
}
|
|
147
|
+
const base = basePath.replace(/\/+$/, "");
|
|
148
|
+
const extraParams = options.authorizationParams ?? {};
|
|
149
|
+
if (!extraParams || typeof extraParams !== "object" || Array.isArray(extraParams)) {
|
|
150
|
+
throw invalid('oidc() authorizationParams must be an object of parameter names and string values, such as { prompt: "select_account" }.');
|
|
151
|
+
}
|
|
152
|
+
for (const [key, value] of Object.entries(extraParams)) {
|
|
153
|
+
if (RESERVED_PARAMS.includes(key)) {
|
|
154
|
+
throw invalid(`oidc() authorizationParams cannot set "${key}": oidc() sets it itself.`, `Leave "${key}" out of authorizationParams; use scopes for the scope and redirectUri for the redirect URI.`);
|
|
155
|
+
}
|
|
156
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(key) || typeof value !== "string" || value.length > 1000 || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
157
|
+
throw invalid(`oidc() authorizationParams.${key} must be a string of at most 1000 characters without control characters, under a parameter name such as "prompt" or "domain_hint".`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const postLogoutRedirectUri = options.postLogoutRedirectUri === undefined ? undefined : httpsUrl(options.postLogoutRedirectUri, "postLogoutRedirectUri").href;
|
|
161
|
+
const { issuer, clientId, clientSecret } = options;
|
|
162
|
+
// The provider's OpenID configuration: fetched on first use, kept for 10 minutes, and kept longer while the
|
|
163
|
+
// provider cannot be reached.
|
|
164
|
+
let discovered;
|
|
165
|
+
let loading;
|
|
166
|
+
const loadDiscovery = async () => {
|
|
167
|
+
const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
|
|
168
|
+
const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new Error(`${url} answered ${response.status}`);
|
|
171
|
+
const body = (await response.json());
|
|
172
|
+
if (!body || typeof body !== "object")
|
|
173
|
+
throw new Error(`${url} did not answer a JSON object`);
|
|
174
|
+
if (body.issuer !== issuer)
|
|
175
|
+
throw new Error(`the OpenID configuration names the issuer ${JSON.stringify(body.issuer)}, not ${JSON.stringify(issuer)}`);
|
|
176
|
+
for (const key of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) {
|
|
177
|
+
if (typeof body[key] !== "string")
|
|
178
|
+
throw new Error(`the OpenID configuration has no ${key}`);
|
|
179
|
+
httpsUrl(body[key], key);
|
|
180
|
+
}
|
|
181
|
+
if (body.end_session_endpoint !== undefined) {
|
|
182
|
+
if (typeof body.end_session_endpoint !== "string")
|
|
183
|
+
throw new Error("the OpenID configuration's end_session_endpoint is not a URL");
|
|
184
|
+
httpsUrl(body.end_session_endpoint, "end_session_endpoint");
|
|
185
|
+
}
|
|
186
|
+
return body;
|
|
187
|
+
};
|
|
188
|
+
const discovery = async () => {
|
|
189
|
+
if (discovered && Date.now() - discovered.at < DISCOVERY_CACHE_MS)
|
|
190
|
+
return discovered.value;
|
|
191
|
+
try {
|
|
192
|
+
loading ??= loadDiscovery().finally(() => (loading = undefined));
|
|
193
|
+
const value = await loading;
|
|
194
|
+
discovered = { value, at: Date.now() };
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (discovered)
|
|
199
|
+
return discovered.value;
|
|
200
|
+
throw unreachable("its OpenID configuration could not be loaded", error);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const verifyIdToken = createJwtVerifier({
|
|
204
|
+
issuer,
|
|
205
|
+
audiences: [clientId],
|
|
206
|
+
allowed: ASYMMETRIC,
|
|
207
|
+
tolerance: 60,
|
|
208
|
+
cacheMs: DISCOVERY_CACHE_MS,
|
|
209
|
+
discoverJwksUrl: async () => (await discovery()).jwks_uri,
|
|
210
|
+
});
|
|
211
|
+
const callbackUrl = (url) => redirectUri ?? new URL(`${base}/callback`, url).href;
|
|
212
|
+
const start = async (ctx) => {
|
|
213
|
+
const config = await discovery();
|
|
214
|
+
const pending = { s: random(), n: random(), v: random(), r: safeReturnTo(ctx.url.searchParams.get("returnTo")), e: Date.now() + PENDING_MS };
|
|
215
|
+
const challenge = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pending.v))));
|
|
216
|
+
const target = new URL(config.authorization_endpoint);
|
|
217
|
+
target.searchParams.set("response_type", "code");
|
|
218
|
+
target.searchParams.set("client_id", clientId);
|
|
219
|
+
target.searchParams.set("redirect_uri", callbackUrl(ctx.url));
|
|
220
|
+
target.searchParams.set("scope", scope);
|
|
221
|
+
target.searchParams.set("state", pending.s);
|
|
222
|
+
target.searchParams.set("nonce", pending.n);
|
|
223
|
+
target.searchParams.set("code_challenge", challenge);
|
|
224
|
+
target.searchParams.set("code_challenge_method", "S256");
|
|
225
|
+
for (const [key, value] of Object.entries(extraParams))
|
|
226
|
+
target.searchParams.set(key, value);
|
|
227
|
+
// Per sign-in: ?loginHint= fills in the user's name at the provider, ?prompt= asks it to show the login or
|
|
228
|
+
// account picker again. Anything else in the query is ignored.
|
|
229
|
+
const loginHint = ctx.url.searchParams.get("loginHint");
|
|
230
|
+
if (loginHint && loginHint.length <= 256 && !/[\u0000-\u001f\u007f]/.test(loginHint))
|
|
231
|
+
target.searchParams.set("login_hint", loginHint);
|
|
232
|
+
const prompt = ctx.url.searchParams.get("prompt");
|
|
233
|
+
if (prompt && prompt.split(" ").every((p) => PROMPTS.includes(p)))
|
|
234
|
+
target.searchParams.set("prompt", prompt);
|
|
235
|
+
const cookie = cookieFor(ctx.url);
|
|
236
|
+
ctx.header("set-cookie", `${cookie.name}=${base64url(new TextEncoder().encode(JSON.stringify(pending)))}; ${cookie.attributes}; Max-Age=${PENDING_MS / 1000}`);
|
|
237
|
+
ctx.header("cache-control", "no-store");
|
|
238
|
+
return new Response(null, { status: 302, headers: { location: target.href } });
|
|
239
|
+
};
|
|
240
|
+
const exchange = async (config, code, verifier, url) => {
|
|
241
|
+
const form = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: callbackUrl(url), code_verifier: verifier });
|
|
242
|
+
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
243
|
+
const methods = Array.isArray(config.token_endpoint_auth_methods_supported) ? config.token_endpoint_auth_methods_supported : undefined;
|
|
244
|
+
if (clientSecret === undefined)
|
|
245
|
+
form.set("client_id", clientId);
|
|
246
|
+
else if (methods && !methods.includes("client_secret_basic") && methods.includes("client_secret_post")) {
|
|
247
|
+
form.set("client_id", clientId);
|
|
248
|
+
form.set("client_secret", clientSecret);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// client_secret_basic, the default of OpenID Connect: both parts form-encoded first (RFC 6749, section 2.3.1).
|
|
252
|
+
const encode = (value) => encodeURIComponent(value).replace(/%20/g, "+");
|
|
253
|
+
headers.authorization = `Basic ${btoa(`${encode(clientId)}:${encode(clientSecret)}`)}`;
|
|
254
|
+
}
|
|
255
|
+
let response;
|
|
256
|
+
let body;
|
|
257
|
+
try {
|
|
258
|
+
response = await fetch(config.token_endpoint, { method: "POST", headers, body: form.toString(), redirect: "error", signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
|
259
|
+
body = (await response.json()) ?? {};
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
throw unreachable("its token endpoint did not answer", error);
|
|
263
|
+
}
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const error = typeof body.error === "string" && /^[\w.-]{1,64}$/.test(body.error) ? body.error : `status ${response.status}`;
|
|
266
|
+
// A wrong client id or secret is the app's configuration; anything else (a used or expired code) is this sign-in.
|
|
267
|
+
if (error === "invalid_client" || error === "unauthorized_client" || response.status >= 500)
|
|
268
|
+
throw unreachable(`its token endpoint refused the code exchange (${error})`, error);
|
|
269
|
+
throw failed(`the identity provider refused the code (${error})`);
|
|
270
|
+
}
|
|
271
|
+
if (typeof body.id_token !== "string")
|
|
272
|
+
throw failed("the identity provider sent no ID token (is the openid scope allowed for this app?)");
|
|
273
|
+
const tokens = { idToken: body.id_token };
|
|
274
|
+
if (typeof body.access_token === "string")
|
|
275
|
+
tokens.accessToken = body.access_token;
|
|
276
|
+
if (typeof body.refresh_token === "string")
|
|
277
|
+
tokens.refreshToken = body.refresh_token;
|
|
278
|
+
if (typeof body.expires_in === "number")
|
|
279
|
+
tokens.expiresIn = body.expires_in;
|
|
280
|
+
if (typeof body.scope === "string")
|
|
281
|
+
tokens.scope = body.scope;
|
|
282
|
+
return tokens;
|
|
283
|
+
};
|
|
284
|
+
const finish = async (ctx) => {
|
|
285
|
+
const params = ctx.url.searchParams;
|
|
286
|
+
const cookie = cookieFor(ctx.url);
|
|
287
|
+
// The pending sign-in is used once, whatever happens.
|
|
288
|
+
ctx.header("set-cookie", `${cookie.name}=; ${cookie.attributes}; Max-Age=0`);
|
|
289
|
+
ctx.header("cache-control", "no-store");
|
|
290
|
+
let pending;
|
|
291
|
+
try {
|
|
292
|
+
const raw = readCookie(ctx.req.headers.get("cookie"), cookie.name);
|
|
293
|
+
if (raw && raw.length < 4096) {
|
|
294
|
+
const parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(raw.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
|
|
295
|
+
if (["s", "n", "v", "r"].every((k) => typeof parsed[k] === "string") && typeof parsed.e === "number")
|
|
296
|
+
pending = parsed;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
pending = undefined;
|
|
301
|
+
}
|
|
302
|
+
if (!pending || pending.e <= Date.now())
|
|
303
|
+
throw failed("it expired or was started in another browser");
|
|
304
|
+
const state = params.get("state");
|
|
305
|
+
if (!state || !safeEqual(state, pending.s))
|
|
306
|
+
throw failed("the state does not match the sign-in this browser started");
|
|
307
|
+
// RFC 9207: a provider that names itself in the redirect must be the configured one (mix-up attacks).
|
|
308
|
+
const iss = params.get("iss");
|
|
309
|
+
if (iss !== null && iss !== issuer)
|
|
310
|
+
throw failed("the answer came from another identity provider");
|
|
311
|
+
const error = params.get("error");
|
|
312
|
+
if (error !== null)
|
|
313
|
+
throw failed(`the identity provider answered ${/^[\w.-]{1,64}$/.test(error) ? error : "an error"}`);
|
|
314
|
+
const code = params.get("code");
|
|
315
|
+
if (!code)
|
|
316
|
+
throw failed("the identity provider sent no code");
|
|
317
|
+
const config = await discovery();
|
|
318
|
+
const tokens = await exchange(config, code, pending.v, ctx.url);
|
|
319
|
+
const claims = (await verifyIdToken(tokens.idToken));
|
|
320
|
+
if (!claims)
|
|
321
|
+
throw failed("the ID token is not valid for this app (signature, issuer, audience or expiry)");
|
|
322
|
+
if (typeof claims.nonce !== "string" || !safeEqual(claims.nonce, pending.n))
|
|
323
|
+
throw failed("the ID token's nonce does not match this sign-in");
|
|
324
|
+
if (Array.isArray(claims.aud) && claims.aud.length > 1 && claims.azp !== clientId)
|
|
325
|
+
throw failed("the ID token was issued to another app");
|
|
326
|
+
const userId = await options.userId(claims, { db: ctx.db, req: ctx.req, tokens });
|
|
327
|
+
if (userId === null || userId === undefined) {
|
|
328
|
+
throw new InferloError("IL3011", "This account may not sign in to the app.", { status: 403, fix: "Ask an administrator of the app for access." });
|
|
329
|
+
}
|
|
330
|
+
if (typeof userId !== "string" || !userId) {
|
|
331
|
+
throw new InferloError("IL6047", "oidc() userId must return the user's id as a non-empty string, or null to refuse the sign-in.", { fix: "Return the id of the app's user: userId: async (claims, { db }) => (await db.users.findOne({ email: claims.email }))?.id ?? null." });
|
|
332
|
+
}
|
|
333
|
+
await session.signIn(ctx, userId);
|
|
334
|
+
// The ID token is kept (HttpOnly) for signing out at the provider later, as id_token_hint.
|
|
335
|
+
const idCookie = cookieFor(ctx.url, "inferlo_oidc_id");
|
|
336
|
+
if (tokens.idToken.length <= MAX_ID_TOKEN_COOKIE && /^[\w-]+\.[\w-]+\.[\w-]*$/.test(tokens.idToken)) {
|
|
337
|
+
ctx.header("set-cookie", `${idCookie.name}=${tokens.idToken}; ${idCookie.attributes}; Max-Age=${ID_TOKEN_COOKIE_SECONDS}`);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
ctx.header("set-cookie", `${idCookie.name}=; ${idCookie.attributes}; Max-Age=0`);
|
|
341
|
+
}
|
|
342
|
+
return new Response(null, { status: 302, headers: { location: pending.r } });
|
|
343
|
+
};
|
|
344
|
+
const logout = async (ctx) => {
|
|
345
|
+
// A signed-in browser must send the session's CSRF token (IL3005), so other sites cannot sign users out.
|
|
346
|
+
await session.resolve(ctx.req, { db: ctx.db, url: ctx.url });
|
|
347
|
+
session.signOut(ctx);
|
|
348
|
+
const idCookie = cookieFor(ctx.url, "inferlo_oidc_id");
|
|
349
|
+
const idToken = readCookie(ctx.req.headers.get("cookie"), idCookie.name);
|
|
350
|
+
ctx.header("set-cookie", `${idCookie.name}=; ${idCookie.attributes}; Max-Age=0`);
|
|
351
|
+
ctx.header("cache-control", "no-store");
|
|
352
|
+
const afterLogout = postLogoutRedirectUri ?? new URL("/", ctx.url).href;
|
|
353
|
+
let endSession;
|
|
354
|
+
try {
|
|
355
|
+
endSession = (await discovery()).end_session_endpoint;
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// The app's session is over; a provider that cannot be reached cannot end its own.
|
|
359
|
+
endSession = undefined;
|
|
360
|
+
}
|
|
361
|
+
if (!endSession)
|
|
362
|
+
return new Response(null, { status: 303, headers: { location: afterLogout } });
|
|
363
|
+
const target = new URL(endSession);
|
|
364
|
+
// Only an ID token this provider issued is sent back to it (with several providers, each has its basePath).
|
|
365
|
+
if (idToken && idToken.length <= MAX_ID_TOKEN_COOKIE && issuerOf(idToken) === issuer)
|
|
366
|
+
target.searchParams.set("id_token_hint", idToken);
|
|
367
|
+
target.searchParams.set("client_id", clientId);
|
|
368
|
+
target.searchParams.set("post_logout_redirect_uri", afterLogout);
|
|
369
|
+
return new Response(null, { status: 303, headers: { location: target.href } });
|
|
370
|
+
};
|
|
371
|
+
return {
|
|
372
|
+
oidcLogin: route.get(`${base}/login`, { auth: "public", summary: "Sign in with the identity provider (OpenID Connect)" }).handle(start),
|
|
373
|
+
oidcCallback: route.get(`${base}/callback`, { auth: "public", summary: "Finish the sign-in with the identity provider (OpenID Connect)" }).handle(finish),
|
|
374
|
+
oidcLogout: route.post(`${base}/logout`, { auth: "public", summary: "Sign out of the app and at the identity provider (OpenID Connect)" }).handle(logout),
|
|
375
|
+
loginPath: `${base}/login`,
|
|
376
|
+
logoutPath: `${base}/logout`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
//# sourceMappingURL=oidc.js.map
|