@jskit-ai/auth-provider-oidc-core 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/README.md +31 -2
- package/bin/jskit-oidc-sign-in.js +76 -0
- package/migrations/auth_oidc_initial.cjs +58 -0
- package/package.json +94 -4
- package/patterns/oidc-sign-in/PATTERN.md +87 -0
- package/patterns/oidc-sign-in/example/.env.example +13 -0
- package/patterns/oidc-sign-in/example/package.json +8 -0
- package/src/server/lib/backchannelLogout.js +64 -0
- package/src/server/lib/config.js +116 -0
- package/src/server/lib/index.js +6 -0
- package/src/server/lib/issuer.js +134 -0
- package/src/server/lib/localAccounts.js +96 -0
- package/src/server/lib/routes.js +60 -0
- package/src/server/lib/service.js +393 -0
- package/src/server/lib/store.js +147 -0
- package/src/server/lib/transactionCookie.js +50 -0
- package/src/server/providers/AuthOidcProvider.js +54 -0
- package/src/shared/index.js +15 -0
- package/test/localAccounts.test.js +141 -0
- package/test/providerRuntime.test.js +213 -0
- package/test/relyingPartySecurity.test.js +159 -0
- package/test/signInFlow.test.js +183 -0
- package/test-support/harness.js +113 -0
- package/test-support/mockIssuer.js +116 -0
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jskit-ai/auth-provider-oidc-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sign people in to a JSKIT app through an OpenID Connect issuer instead of
|
|
4
|
+
app-local passwords: an accounts hub (`@jskit-ai/oidc-issuer-core`) or any
|
|
5
|
+
issuer such as Google Workspace or Okta. The app keeps its own `users` rows and
|
|
6
|
+
session; the issuer only proves who the person is.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm uninstall @jskit-ai/auth-provider-local-core
|
|
10
|
+
npm install @jskit-ai/auth-provider-oidc-core @jskit-ai/auth-web
|
|
11
|
+
npm run db:migrate
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| Name | Meaning |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `AUTH_OIDC_ISSUER` | The issuer's address. |
|
|
17
|
+
| `AUTH_OIDC_REGISTRATION_TOKEN` | For an accounts hub: the hub's registration token. The app registers itself. |
|
|
18
|
+
| `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET` | For other issuers, instead of the token. |
|
|
19
|
+
| `AUTH_OIDC_SESSION_SECRET` | At least 32 characters; required in production. |
|
|
20
|
+
| `APP_PUBLIC_URL` | The address people use; required in production behind a proxy. |
|
|
21
|
+
|
|
22
|
+
Without `AUTH_OIDC_ISSUER` the app still starts and the login page explains
|
|
23
|
+
what is missing. Inside a frame, such as an editor's preview, sign-in opens a
|
|
24
|
+
pop-up window. A production address waits for the hub owner's approval on the
|
|
25
|
+
hub's Apps page.
|
|
26
|
+
|
|
27
|
+
`npx jskit-oidc-sign-in export-local-accounts` and `adopt-oidc` move existing
|
|
28
|
+
JSKIT local accounts to a hub, keeping ids and passwords. Run them only on the
|
|
29
|
+
app owner's explicit request.
|
|
30
|
+
|
|
31
|
+
Follow the `auth/oidc-sign-in` pattern and the guide "Sign in through an OIDC
|
|
32
|
+
issuer" in the JSKIT guide (`packages/agent-docs/site/guide/app-setup/`).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import createKnex from "knex";
|
|
6
|
+
import { adoptOidcAccounts, exportLocalAccounts } from "../src/server/lib/localAccounts.js";
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage (run in the app's folder, with its database Env):
|
|
9
|
+
jskit-oidc-sign-in export-local-accounts --out <file>
|
|
10
|
+
jskit-oidc-sign-in adopt-oidc --issuer <url> --in <file> [--apply]
|
|
11
|
+
|
|
12
|
+
export-local-accounts writes this app's local accounts for an accounts hub to import.
|
|
13
|
+
adopt-oidc moves the users rows listed in that file to the hub; it only reports
|
|
14
|
+
counts unless --apply is given.`;
|
|
15
|
+
|
|
16
|
+
async function appKnex() {
|
|
17
|
+
try {
|
|
18
|
+
process.loadEnvFile?.(".env");
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error?.code !== "ENOENT") throw error;
|
|
21
|
+
}
|
|
22
|
+
const knexfile = await import(pathToFileURL(path.resolve("knexfile.js")).href);
|
|
23
|
+
return createKnex(knexfile.default);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sameAddress(left, right) {
|
|
27
|
+
return String(left || "").replace(/\/+$/u, "") === String(right || "").replace(/\/+$/u, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main(argv) {
|
|
31
|
+
const [command, ...rest] = argv;
|
|
32
|
+
const { values } = parseArgs({
|
|
33
|
+
args: rest,
|
|
34
|
+
options: {
|
|
35
|
+
out: { type: "string" },
|
|
36
|
+
in: { type: "string" },
|
|
37
|
+
issuer: { type: "string" },
|
|
38
|
+
apply: { type: "boolean", default: false }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
if (command === "export-local-accounts" && values.out) {
|
|
42
|
+
const knex = await appKnex();
|
|
43
|
+
try {
|
|
44
|
+
const { exported } = await exportLocalAccounts({ knex, out: path.resolve(values.out) });
|
|
45
|
+
console.log(`Exported ${exported} accounts to ${values.out}. It contains password hashes: delete it after the import.`);
|
|
46
|
+
} finally {
|
|
47
|
+
await knex.destroy();
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (command === "adopt-oidc" && values.issuer && values.in) {
|
|
52
|
+
const configured = process.env.AUTH_OIDC_ISSUER;
|
|
53
|
+
if (configured && !sameAddress(configured, values.issuer)) {
|
|
54
|
+
throw new Error(`--issuer does not match AUTH_OIDC_ISSUER (${configured}).`);
|
|
55
|
+
}
|
|
56
|
+
const knex = await appKnex();
|
|
57
|
+
try {
|
|
58
|
+
const counts = await adoptOidcAccounts({ knex, file: path.resolve(values.in), apply: values.apply });
|
|
59
|
+
const verb = values.apply ? "Adopted" : "Would adopt";
|
|
60
|
+
console.log(`${verb} ${counts.adopt} accounts, ${counts.alreadyAdopted} already adopted, ` +
|
|
61
|
+
`${counts.notFound} not found, ${counts.conflicts} conflicts.`);
|
|
62
|
+
} finally {
|
|
63
|
+
await knex.destroy();
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
console.error(USAGE);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main(process.argv.slice(2)).then((code) => {
|
|
72
|
+
process.exitCode = code;
|
|
73
|
+
}, (error) => {
|
|
74
|
+
console.error(error?.message || error);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const CLIENTS_TABLE = "auth_oidc_clients";
|
|
2
|
+
const SESSIONS_TABLE = "auth_oidc_sessions";
|
|
3
|
+
const LOGOUT_TOKENS_TABLE = "auth_oidc_logout_tokens";
|
|
4
|
+
|
|
5
|
+
exports.up = async function up(knex) {
|
|
6
|
+
if (!(await knex.schema.hasTable(CLIENTS_TABLE))) {
|
|
7
|
+
await knex.schema.createTable(CLIENTS_TABLE, (table) => {
|
|
8
|
+
table.increments("id").primary();
|
|
9
|
+
table.string("issuer", 255).notNullable();
|
|
10
|
+
table.string("origin", 255).notNullable();
|
|
11
|
+
table.string("client_id", 255).notNullable();
|
|
12
|
+
table.text("client_secret").nullable();
|
|
13
|
+
table.text("registration_access_token").nullable();
|
|
14
|
+
table.string("registration_client_uri", 1024).nullable();
|
|
15
|
+
table.timestamp("created_at").notNullable();
|
|
16
|
+
table.timestamp("updated_at").notNullable();
|
|
17
|
+
table.unique(["issuer", "origin"], "uq_auth_oidc_clients_issuer_origin");
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!(await knex.schema.hasTable(SESSIONS_TABLE))) {
|
|
22
|
+
await knex.schema.createTable(SESSIONS_TABLE, (table) => {
|
|
23
|
+
table.string("id", 80).primary();
|
|
24
|
+
table.string("token_hash", 128).notNullable();
|
|
25
|
+
table.string("issuer", 255).notNullable();
|
|
26
|
+
table.string("client_id", 255).nullable();
|
|
27
|
+
table.string("sub", 255).notNullable();
|
|
28
|
+
table.string("sid", 255).nullable();
|
|
29
|
+
table.string("email", 320).notNullable();
|
|
30
|
+
table.string("display_name", 160).notNullable();
|
|
31
|
+
table.text("id_token").nullable();
|
|
32
|
+
table.string("purpose", 32).notNullable();
|
|
33
|
+
table.timestamp("expires_at").notNullable();
|
|
34
|
+
table.timestamp("revoked_at").nullable();
|
|
35
|
+
table.timestamp("created_at").notNullable();
|
|
36
|
+
table.timestamp("updated_at").notNullable();
|
|
37
|
+
table.unique(["token_hash"], "uq_auth_oidc_sessions_token_hash");
|
|
38
|
+
table.index(["issuer", "sub"], "idx_auth_oidc_sessions_subject");
|
|
39
|
+
table.index(["issuer", "sid"], "idx_auth_oidc_sessions_sid");
|
|
40
|
+
table.index(["expires_at"], "idx_auth_oidc_sessions_expires");
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!(await knex.schema.hasTable(LOGOUT_TOKENS_TABLE))) {
|
|
45
|
+
await knex.schema.createTable(LOGOUT_TOKENS_TABLE, (table) => {
|
|
46
|
+
table.string("issuer", 255).notNullable();
|
|
47
|
+
table.string("jti", 255).notNullable();
|
|
48
|
+
table.timestamp("expires_at").notNullable();
|
|
49
|
+
table.primary(["issuer", "jti"]);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
exports.down = async function down(knex) {
|
|
55
|
+
await knex.schema.dropTableIfExists(LOGOUT_TOKENS_TABLE);
|
|
56
|
+
await knex.schema.dropTableIfExists(SESSIONS_TABLE);
|
|
57
|
+
await knex.schema.dropTableIfExists(CLIENTS_TABLE);
|
|
58
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,96 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/auth-provider-oidc-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test --test-concurrency=1"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./server/providers/AuthOidcProvider": "./src/server/providers/AuthOidcProvider.js",
|
|
10
|
+
"./server/lib/index": "./src/server/lib/index.js",
|
|
11
|
+
"./shared": "./src/shared/index.js"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"jskit-oidc-sign-in": "./bin/jskit-oidc-sign-in.js"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"jose": "6.2.12",
|
|
18
|
+
"knex": "^3.1.0",
|
|
19
|
+
"openid-client": "6.8.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@jskit-ai/auth-provider-local-core": "0.1.146",
|
|
23
|
+
"@jskit-ai/auth-web": "0.1.246",
|
|
24
|
+
"oidc-provider": "9.12.2"
|
|
25
|
+
},
|
|
26
|
+
"description": "Sign in through any OpenID Connect issuer: self-registration or a static client, app-owned sessions, and back-channel logout.",
|
|
27
|
+
"jskit": {
|
|
28
|
+
"kind": "runtime",
|
|
29
|
+
"capabilities": {
|
|
30
|
+
"provides": [
|
|
31
|
+
"auth.service"
|
|
32
|
+
],
|
|
33
|
+
"requires": [
|
|
34
|
+
"auth.extensions",
|
|
35
|
+
"runtime.app-root",
|
|
36
|
+
"runtime.database",
|
|
37
|
+
"runtime.env",
|
|
38
|
+
"runtime.fastify",
|
|
39
|
+
"runtime.logger"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"runtime": {
|
|
43
|
+
"server": {
|
|
44
|
+
"providers": [
|
|
45
|
+
{
|
|
46
|
+
"entrypoint": "src/server/providers/AuthOidcProvider.js",
|
|
47
|
+
"export": "AuthOidcProvider"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"metadata": {
|
|
53
|
+
"jskit": {
|
|
54
|
+
"tableOwnership": {
|
|
55
|
+
"tables": [
|
|
56
|
+
{
|
|
57
|
+
"tableName": "auth_oidc_clients"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"tableName": "auth_oidc_sessions"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"tableName": "auth_oidc_logout_tokens"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"apiSummary": {
|
|
69
|
+
"surfaces": [
|
|
70
|
+
{
|
|
71
|
+
"subpath": "./server/providers/AuthOidcProvider",
|
|
72
|
+
"summary": "Provides the OpenID Connect sign-in service and its back-channel and everywhere-logout routes."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"subpath": "./server/lib/index",
|
|
76
|
+
"summary": "Exports the OIDC auth service factory, configuration reader, and the local-account export and adopt commands."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"subpath": "./shared",
|
|
80
|
+
"summary": "Exports the provider id, redirect path and route paths apps and issuers register."
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"capability": "auth.service"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"migrations": {
|
|
87
|
+
"directories": [
|
|
88
|
+
"migrations"
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"@jskit-ai/auth-core": "0.1.243",
|
|
94
|
+
"@jskit-ai/kernel": "0.1.245"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: auth/oidc-sign-in
|
|
3
|
+
title: Sign in through an OIDC issuer
|
|
4
|
+
summary: Let people sign in with the accounts they already have in an accounts hub or another OpenID Connect issuer, instead of app-local passwords.
|
|
5
|
+
keywords: auth, authentication, oidc, openid connect, single sign-on, sso, accounts hub, master login, issuer, back-channel logout, dynamic client registration
|
|
6
|
+
requires: @jskit-ai/auth-provider-oidc-core, @jskit-ai/auth-web
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Sign in through an OIDC issuer
|
|
10
|
+
|
|
11
|
+
## Use when
|
|
12
|
+
|
|
13
|
+
Several apps should share one set of accounts: people sign in once at an
|
|
14
|
+
accounts hub (see `auth/accounts-hub`) or at an organisation's OpenID Connect
|
|
15
|
+
issuer, and every app signs them in from there. The app keeps its own `users`
|
|
16
|
+
rows and its own session; the issuer only proves who the person is.
|
|
17
|
+
|
|
18
|
+
## Do not use when
|
|
19
|
+
|
|
20
|
+
The app needs its own passwords (use `auth-provider-local-core`), or the product
|
|
21
|
+
has deliberately chosen Supabase (`auth/supabase-auth`). Install exactly one
|
|
22
|
+
`auth.service` provider.
|
|
23
|
+
|
|
24
|
+
## Product decisions
|
|
25
|
+
|
|
26
|
+
- Which issuer. A hub gives each app a registration token; other issuers
|
|
27
|
+
(Google Workspace, Okta) give a fixed client id and secret instead.
|
|
28
|
+
- The button label people see.
|
|
29
|
+
- The production address, and who approves the app in the hub.
|
|
30
|
+
- Whether existing local accounts move to the hub (one-time commands below) or
|
|
31
|
+
the app starts fresh.
|
|
32
|
+
|
|
33
|
+
## Invariants
|
|
34
|
+
|
|
35
|
+
- One client per app address. With a registration token the app registers
|
|
36
|
+
itself the first time it is used from a new address (or at start when
|
|
37
|
+
`APP_PUBLIC_URL` is set) and stores the client in `auth_oidc_clients`.
|
|
38
|
+
- The redirect URI is auth-web's login route, `AUTH_OIDC_REDIRECT_PATH`
|
|
39
|
+
(`/auth/login`). Where to go afterwards travels in a sealed, ten-minute,
|
|
40
|
+
single-use attempt cookie, never in the redirect URI.
|
|
41
|
+
- Every sign-in uses PKCE, `state`, `nonce` and the RFC 9207 `iss` check; ID
|
|
42
|
+
tokens must name this issuer and this client.
|
|
43
|
+
- The app session is the app's own cookie (30 days), backed by
|
|
44
|
+
`auth_oidc_sessions`. Signing out of the app does not sign out of the issuer
|
|
45
|
+
unless the person asks for it.
|
|
46
|
+
- Secrets stay in Env, never in source.
|
|
47
|
+
|
|
48
|
+
## Framework APIs
|
|
49
|
+
|
|
50
|
+
- `@jskit-ai/auth-provider-oidc-core` provides `auth.service`, its migration and
|
|
51
|
+
the `jskit-oidc-sign-in` command.
|
|
52
|
+
- `@jskit-ai/auth-provider-oidc-core/shared` exports `AUTH_OIDC_PROVIDER_ID`,
|
|
53
|
+
`AUTH_OIDC_REDIRECT_PATH`, `AUTH_OIDC_LOGOUT_PATH` and
|
|
54
|
+
`AUTH_OIDC_BACKCHANNEL_LOGOUT_PATH`.
|
|
55
|
+
- auth-web's login page shows the one button. Inside a frame (an editor
|
|
56
|
+
Preview) it opens the issuer in a pop-up window and reloads signed in.
|
|
57
|
+
|
|
58
|
+
## Example files
|
|
59
|
+
|
|
60
|
+
`example/package.json` declares the provider and the login surface.
|
|
61
|
+
`example/.env.example` names every Env value without credentials.
|
|
62
|
+
|
|
63
|
+
## Variation points
|
|
64
|
+
|
|
65
|
+
- Static client (`AUTH_OIDC_CLIENT_ID`/`AUTH_OIDC_CLIENT_SECRET`) instead of
|
|
66
|
+
the registration token; never both.
|
|
67
|
+
- `AUTH_OIDC_LABEL` for the button.
|
|
68
|
+
- A sign-out-everywhere link: `/api/auth/oidc/logout?everywhere=1` also ends the
|
|
69
|
+
issuer session through its end-session endpoint.
|
|
70
|
+
|
|
71
|
+
## Verification
|
|
72
|
+
|
|
73
|
+
- Missing `AUTH_OIDC_ISSUER` shows "Sign-in is not set up yet" instead of a
|
|
74
|
+
crash.
|
|
75
|
+
- Sign in unframed and inside the Preview frame; refresh stays signed in.
|
|
76
|
+
- A production address the hub has not approved yet reaches the hub's waiting
|
|
77
|
+
page; a blocked one reads "Your accounts hub has blocked this app's address."
|
|
78
|
+
- Disabling the account in the hub ends the app session (back-channel logout).
|
|
79
|
+
- Preview identity (`tools/preview-identity`) still signs in as an existing
|
|
80
|
+
`users` row.
|
|
81
|
+
|
|
82
|
+
## Avoid
|
|
83
|
+
|
|
84
|
+
- keeping the local provider installed alongside this one
|
|
85
|
+
- putting the return path or any secret in the redirect URI
|
|
86
|
+
- sharing one registered client between addresses
|
|
87
|
+
- running the account move commands without the person's explicit yes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# The accounts hub (or any OpenID Connect issuer). Discovery is used.
|
|
2
|
+
AUTH_OIDC_ISSUER=https://accounts.example.com/oidc
|
|
3
|
+
# Preferred: the app registers itself, once per address, with this token.
|
|
4
|
+
AUTH_OIDC_REGISTRATION_TOKEN=replace-me
|
|
5
|
+
# Instead of the token, for issuers without self-registration:
|
|
6
|
+
# AUTH_OIDC_CLIENT_ID=replace-me
|
|
7
|
+
# AUTH_OIDC_CLIENT_SECRET=replace-me
|
|
8
|
+
# Optional button label.
|
|
9
|
+
# AUTH_OIDC_LABEL=Continue with your account
|
|
10
|
+
# Required in production, at least 32 characters. Generated in development.
|
|
11
|
+
# AUTH_OIDC_SESSION_SECRET=replace-me
|
|
12
|
+
# The address people use. Registers at start; required behind a non-loopback proxy.
|
|
13
|
+
APP_PUBLIC_URL=http://localhost:5173
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
2
|
+
|
|
3
|
+
const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
4
|
+
const MAX_TOKEN_AGE_SECONDS = 10 * 60;
|
|
5
|
+
|
|
6
|
+
class BackchannelLogoutError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "BackchannelLogoutError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenID Connect Back-Channel Logout 1.0 for this app: the issuer posts a
|
|
15
|
+
* signed logout_token and the matching app sessions end.
|
|
16
|
+
*/
|
|
17
|
+
function createBackchannelLogout({ config, store, issuer, fetchImpl = null, now = Date.now }) {
|
|
18
|
+
let keySet = null;
|
|
19
|
+
|
|
20
|
+
async function keys() {
|
|
21
|
+
if (keySet) return keySet;
|
|
22
|
+
const metadata = await issuer.serverMetadata();
|
|
23
|
+
if (!metadata.jwks_uri) throw new BackchannelLogoutError("The issuer publishes no signing keys.");
|
|
24
|
+
keySet = createRemoteJWKSet(new URL(metadata.jwks_uri), fetchImpl ? { [customFetch]: fetchImpl } : {});
|
|
25
|
+
return keySet;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function handle(logoutToken) {
|
|
29
|
+
const token = String(logoutToken || "").trim();
|
|
30
|
+
if (!token) throw new BackchannelLogoutError("logout_token is required.");
|
|
31
|
+
const audience = await issuer.audiences();
|
|
32
|
+
if (!audience.length) throw new BackchannelLogoutError("This app has no client at the issuer.");
|
|
33
|
+
let payload;
|
|
34
|
+
try {
|
|
35
|
+
({ payload } = await jwtVerify(token, await keys(), {
|
|
36
|
+
issuer: config.issuer,
|
|
37
|
+
audience,
|
|
38
|
+
maxTokenAge: MAX_TOKEN_AGE_SECONDS,
|
|
39
|
+
requiredClaims: ["iat", "jti"],
|
|
40
|
+
currentDate: new Date(now())
|
|
41
|
+
}));
|
|
42
|
+
} catch {
|
|
43
|
+
throw new BackchannelLogoutError("logout_token is not valid.");
|
|
44
|
+
}
|
|
45
|
+
const event = payload.events?.[BACKCHANNEL_LOGOUT_EVENT];
|
|
46
|
+
if (!event || typeof event !== "object") {
|
|
47
|
+
throw new BackchannelLogoutError("logout_token has no back-channel logout event.");
|
|
48
|
+
}
|
|
49
|
+
if (Object.hasOwn(payload, "nonce")) throw new BackchannelLogoutError("logout_token must not carry a nonce.");
|
|
50
|
+
const sub = typeof payload.sub === "string" ? payload.sub : "";
|
|
51
|
+
const sid = typeof payload.sid === "string" ? payload.sid : "";
|
|
52
|
+
if (!sub && !sid) throw new BackchannelLogoutError("logout_token names no subject or session.");
|
|
53
|
+
const expiresAt = new Date((Number(payload.iat) + MAX_TOKEN_AGE_SECONDS) * 1000);
|
|
54
|
+
if (!(await store.rememberLogoutToken(config.issuer, String(payload.jti), expiresAt))) {
|
|
55
|
+
throw new BackchannelLogoutError("logout_token was already used.");
|
|
56
|
+
}
|
|
57
|
+
const ended = await store.revokeIssuerSessions(config.issuer, { sub, sid });
|
|
58
|
+
return { ended: Number(ended) || 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Object.freeze({ handle });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { BACKCHANNEL_LOGOUT_EVENT, BackchannelLogoutError, createBackchannelLogout };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { assertDevAuthPolicy, resolveDevAuthPolicyFromEnv } from "@jskit-ai/auth-core/server/devAuth";
|
|
5
|
+
import { AUTH_OIDC_DEFAULT_LABEL } from "../../shared/index.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_STORE_DIR = ".jskit/auth";
|
|
8
|
+
const SESSION_SECRET_FILE = "oidc-session.secret";
|
|
9
|
+
|
|
10
|
+
function text(value) {
|
|
11
|
+
return String(value ?? "").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isLoopbackHostname(hostname) {
|
|
15
|
+
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/gu, "");
|
|
16
|
+
return normalized === "localhost" || normalized.endsWith(".localhost") ||
|
|
17
|
+
normalized === "::1" || /^127(?:\.\d{1,3}){3}$/u.test(normalized);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseAddress(value, name) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(value);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`${name} must be a full address such as https://accounts.example.com.`);
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
|
|
28
|
+
throw new Error(`${name} must use https (plain http is allowed only on this computer).`);
|
|
29
|
+
}
|
|
30
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
31
|
+
throw new Error(`${name} must be an address without a query or credentials.`);
|
|
32
|
+
}
|
|
33
|
+
return url;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The name an accounts hub shows on its Apps page: the app's package name.
|
|
37
|
+
function resolveAppName(appRoot) {
|
|
38
|
+
try {
|
|
39
|
+
const name = String(JSON.parse(fs.readFileSync(path.join(appRoot, "package.json"), "utf8")).name || "");
|
|
40
|
+
const words = name.replace(/^@[^/]+\//u, "").replace(/[-_.]+/gu, " ").trim();
|
|
41
|
+
return words ? `${words[0].toUpperCase()}${words.slice(1)}`.slice(0, 80) : "App";
|
|
42
|
+
} catch {
|
|
43
|
+
return "App";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveSessionSecret(env, { appRoot, isProduction }) {
|
|
48
|
+
const explicit = text(env.AUTH_OIDC_SESSION_SECRET);
|
|
49
|
+
if (explicit) {
|
|
50
|
+
if (explicit.length < 32) throw new Error("AUTH_OIDC_SESSION_SECRET must be at least 32 characters.");
|
|
51
|
+
return explicit;
|
|
52
|
+
}
|
|
53
|
+
if (isProduction) throw new Error("AUTH_OIDC_SESSION_SECRET is required in production.");
|
|
54
|
+
const storeDir = path.resolve(appRoot, text(env.AUTH_OIDC_STORE_DIR) || DEFAULT_STORE_DIR);
|
|
55
|
+
const secretPath = path.join(storeDir, SESSION_SECRET_FILE);
|
|
56
|
+
try {
|
|
57
|
+
const existing = fs.readFileSync(secretPath, "utf8").trim();
|
|
58
|
+
if (existing) return existing;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code !== "ENOENT") throw error;
|
|
61
|
+
}
|
|
62
|
+
fs.mkdirSync(storeDir, { recursive: true, mode: 0o700 });
|
|
63
|
+
const generated = randomBytes(32).toString("base64url");
|
|
64
|
+
fs.writeFileSync(secretPath, `${generated}\n`, { mode: 0o600 });
|
|
65
|
+
return generated;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads the OIDC sign-in Env. Malformed values stop the app with one sentence.
|
|
70
|
+
* A missing issuer or client is not fatal: the app runs and sign-in explains
|
|
71
|
+
* what to set (`setupProblem`), so a project can be prepared before its
|
|
72
|
+
* accounts hub exists.
|
|
73
|
+
*/
|
|
74
|
+
function resolveOidcConfig({ env = {}, appRoot = process.cwd() } = {}) {
|
|
75
|
+
const isProduction = text(env.NODE_ENV) === "production";
|
|
76
|
+
const issuerValue = text(env.AUTH_OIDC_ISSUER);
|
|
77
|
+
const registrationToken = text(env.AUTH_OIDC_REGISTRATION_TOKEN);
|
|
78
|
+
const clientId = text(env.AUTH_OIDC_CLIENT_ID);
|
|
79
|
+
const clientSecret = text(env.AUTH_OIDC_CLIENT_SECRET);
|
|
80
|
+
const publicUrlValue = text(env.APP_PUBLIC_URL);
|
|
81
|
+
|
|
82
|
+
if (registrationToken && clientId) {
|
|
83
|
+
throw new Error("Set AUTH_OIDC_REGISTRATION_TOKEN or AUTH_OIDC_CLIENT_ID, not both.");
|
|
84
|
+
}
|
|
85
|
+
if (clientSecret && !clientId) {
|
|
86
|
+
throw new Error("AUTH_OIDC_CLIENT_SECRET needs AUTH_OIDC_CLIENT_ID.");
|
|
87
|
+
}
|
|
88
|
+
if (clientId && !clientSecret) {
|
|
89
|
+
throw new Error("AUTH_OIDC_CLIENT_ID needs AUTH_OIDC_CLIENT_SECRET.");
|
|
90
|
+
}
|
|
91
|
+
const issuer = issuerValue ? parseAddress(issuerValue, "AUTH_OIDC_ISSUER").href.replace(/\/$/u, "") : "";
|
|
92
|
+
const appPublicOrigin = publicUrlValue ? parseAddress(publicUrlValue, "APP_PUBLIC_URL").origin : "";
|
|
93
|
+
const devAuth = resolveDevAuthPolicyFromEnv(env);
|
|
94
|
+
assertDevAuthPolicy(devAuth);
|
|
95
|
+
|
|
96
|
+
let setupProblem = "";
|
|
97
|
+
if (!issuer) setupProblem = "Sign-in is not set up yet: AUTH_OIDC_ISSUER is missing.";
|
|
98
|
+
else if (!registrationToken && !clientId) {
|
|
99
|
+
setupProblem = "Sign-in is not set up yet: set AUTH_OIDC_REGISTRATION_TOKEN, or AUTH_OIDC_CLIENT_ID and AUTH_OIDC_CLIENT_SECRET.";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
isProduction,
|
|
104
|
+
issuer,
|
|
105
|
+
registrationToken,
|
|
106
|
+
staticClient: clientId ? Object.freeze({ clientId, clientSecret }) : null,
|
|
107
|
+
label: text(env.AUTH_OIDC_LABEL) || AUTH_OIDC_DEFAULT_LABEL,
|
|
108
|
+
appName: resolveAppName(appRoot),
|
|
109
|
+
appPublicOrigin,
|
|
110
|
+
sessionSecret: resolveSessionSecret(env, { appRoot, isProduction }),
|
|
111
|
+
devAuth,
|
|
112
|
+
setupProblem
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { isLoopbackHostname, resolveOidcConfig };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createBackchannelLogout } from "./backchannelLogout.js";
|
|
2
|
+
export { resolveOidcConfig } from "./config.js";
|
|
3
|
+
export { createIssuerClient, registrationMetadata } from "./issuer.js";
|
|
4
|
+
export { adoptOidcAccounts, exportLocalAccounts } from "./localAccounts.js";
|
|
5
|
+
export { createOidcAuthService } from "./service.js";
|
|
6
|
+
export { createOidcStore } from "./store.js";
|