diff --git a/.changeset/extract-package-core.md b/.changeset/extract-package-core.md new file mode 100644 index 0000000..95d26dc --- /dev/null +++ b/.changeset/extract-package-core.md @@ -0,0 +1,5 @@ +--- +"@devalexllc/interlock-auth": minor +--- + +Extract the package core from Interlock into the root entry: `createKeycloakAuthOptions`, the session-grant store, hooks and refresher, the exposure allow-list, the back-channel receiver and RP-initiated logout, the cookie-name policy, and a `normalizeClaims` stub. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 748f1c6..0a4cc42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,17 +44,63 @@ jobs: # map typechecks fine and fails only in the consumer, so install the # packed tarball into a throwaway project and import every subpath by # its package name, the way a consumer would. + # + # The package builds with Bundler resolution, but `apps/interlock` + # consumes the root entry from an ESM project under NodeNext, so the + # root's declarations are also type-checked that way, strict, with the + # app's `skipLibCheck` (Better Auth's own declarations name `bun:` and + # `node:` modules a consumer without those types cannot resolve). - name: Verify package contents run: | + tsgo="$PWD/node_modules/.bin/tsgo" consumer="$(mktemp -d)" npm pack --pack-destination "$consumer" cd "$consumer" npm init -y >/dev/null + npm pkg set type=module npm install --silent ./*.tgz for sub in "" "/next" "/react"; do echo "import @devalexllc/interlock-auth$sub" node --input-type=module -e "await import('@devalexllc/interlock-auth$sub')" done + cat > consumer.ts <<'EOF' + import type { BetterAuthOptions } from "better-auth"; + import type { Kysely } from "kysely"; + + import { + createGrantRefresher, + createKeycloakAuthOptions, + createPostgresStore, + InFlightWork, + KyselyGrantStore, + normalizeClaims, + REFRESH_DEFAULTS, + type AuthLogger, + type KeycloakAuthConfig, + type NormalizedClaims, + } from "@devalexllc/interlock-auth"; + + declare const config: KeycloakAuthConfig; + declare const db: Kysely; + declare const logger: AuthLogger; + + const grants = new KyselyGrantStore(db, "interlock"); + const options: BetterAuthOptions = createKeycloakAuthOptions(config, { + database: createPostgresStore(db, "interlock"), + grants, + logger, + }); + const refresher = createGrantRefresher( + grants, + { keycloak: config.keycloak, refresh: REFRESH_DEFAULTS }, + { logger, inFlight: new InFlightWork() }, + ); + const claims: NormalizedClaims = normalizeClaims({ groups: ["a"] }); + + export { claims, options, refresher }; + EOF + echo "type-check consumer.ts (NodeNext)" + "$tsgo" --noEmit --module NodeNext --moduleResolution NodeNext --strict --skipLibCheck consumer.ts # A separate job, not a step in the matrix: the analysis is a property of the # code, not of the Node version it ran under, and pushing the same project key diff --git a/README.md b/README.md index bf9c6a4..413b102 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,138 @@ The shared authentication package for Interlock and the applications that sign i the Better Auth wiring and claim normalization Interlock runs on, and the session clients its Next.js and React applications use. One package, one session model, released on one cadence. -This release is a scaffold. The three entry points exist, resolve, and export nothing; the work -items that fill them are listed below. Do not depend on it yet. - -| Entry point | Contents | Filled by | -| ----------- | ---------------------------------------------------------------------------------------------- | --------- | -| `.` | Better Auth options for the Keycloak provider and claim normalization | 3.5 | -| `./next` | The Next.js session behavior: session reads, the session route handler, provider and monitor | 8.1 | -| `./react` | A session provider over `better-auth/react` and a fetch wrapper that applies the auth contract | 3.6 | +| Entry point | Contents | Status | +| ----------- | ---------------------------------------------------------------------------------------------- | ----------------- | +| `.` | Better Auth options for the Keycloak provider, session-scoped grants, and claim normalization | Since 0.1.0 (3.5) | +| `./next` | The Next.js session behavior: session reads, the session route handler, provider and monitor | Empty until 8.1 | +| `./react` | A session provider over `better-auth/react` and a fetch wrapper that applies the auth contract | Empty until 3.6 | The root entry stays free of React and Next.js, so a server or a test process can import it -without either. Peer dependencies (`better-auth`, `react`, `next`) arrive with the entry points -that need them. +without either. + +## The root entry + +```ts +import { betterAuth } from "better-auth"; +import { + assertCallbackPath, + assertReceiverRoute, + createGrantRefresher, + createKeycloakAuthOptions, + createPostgresStore, + InFlightWork, + KyselyGrantStore, + receiverRouteOf, +} from "@devalexllc/interlock-auth"; + +const auth = betterAuth( + createKeycloakAuthOptions(config, { + database: createPostgresStore(db, schema), + grants: new KyselyGrantStore(db, schema), + logger, + }), +); +await assertCallbackPath(auth, { logger }); +assertReceiverRoute(auth, receiverRouteOf(config)); + +const inFlight = new InFlightWork(); // the same one the pool's shutdown drains +const refresher = createGrantRefresher(new KyselyGrantStore(db, schema), config, { + logger, + inFlight, +}); +``` + +`config` is a validated `KeycloakAuthConfig` (base URL and versioned secrets, the Keycloak +issuer, client and back-channel logout path, trusted origins, cookie-name policy), plus a +`RefreshConfig` for the refresher; `db` is a `Kysely` over the host's pool. What the root +entry exports: + +- **Options.** `createKeycloakAuthOptions(config, { database, grants, logger, cookiePrefix? })` + returns Better Auth options for a database-backed session (no cookie cache, no account + cookie, encrypted provider tokens), the `genericOAuth` Keycloak provider with explicit + endpoints beside discovery, the profile mapping keyed on `sub`, the grant hooks, the + exposed-route allow-list, sign-out that ends the Keycloak session from the server, the + back-channel logout receiver, and the cookie-name policy. It is pure: nothing connects until + Better Auth initializes. `createPostgresStore`, `cookieSettings` and `KEYCLOAK_SCOPES` are + the pieces it uses. +- **Session-scoped grants.** Each session's Keycloak tokens live on its own `session_grant` + row, never on `account`. `KyselyGrantStore` is the runtime store; `GrantStore` is its + interface. `createGrantRefresher` builds the refresh lease state machine, whose + `resolveAccessToken` answers `ok`, `ended` or `unavailable`; `refreshResponse` and + `dependencyFailureBody` turn those into the 401 and 503 bodies a BFF sends. +- **Startup checks.** `assertCallbackPath` fails startup unless Better Auth serves the realm's + redirect URI; `assertReceiverRoute` fails it unless the receiver is the only endpoint at its + route. Both throw `StartupError`. +- **Migrations.** `appMigrations(schema)` holds the package's own migration + (`0001_session_grant`, for Kysely's `Migrator`, after Better Auth's tables exist); + `ensureAccountTokensInvariant` scrubs and guards `account` against tokens and belongs in + every migration run; `accountTokensProblems` verifies it. +- **Claims.** `normalizeClaims` maps Keycloak's claims to + `{ roles, groups, tenants, sites, internal_groups }`. It is a stub until item 6.2 ships the + mapping the APIs apply; `sites` stays empty until the realm has a source for it. + +### Logging + +Every function that logs takes an `AuthLogger` (`debug`, `log`, `warn`, `error`, each given a +`{ message, ...fields }` record) and none has a default: a silent logger would drop refused +logout tokens and indeterminate refreshes without a trace. The package's own fields are named +clear of the fragments `token`, `secret`, `password` and `credential` and never carry a token. + +Better Auth's own log calls are forwarded as `{ message: "[better-auth] …", args }`, with +Better Auth's arguments under `args` as they are. Those can hold tokens, request bodies and +database errors whose `detail` quotes a row, so **the host's logger must redact** by key before +any transport sees a record. + +### One copy of Better Auth + +`better-auth` is a peer dependency pinned to one exact version, and the host must depend on +that same version. The hooks throw Better Auth's `APIError` and read its context types; with a +second copy installed, the router would not recognize the package's errors as its own. npm +refuses a conflicting peer at install time, but that does not prove a single copy, so check +`npm ls better-auth` after an install. `kysely` (`^0.29.0`) is a peer for the same reason; +`jose` is the package's own dependency. + +**Better Auth bumps go to the package first, then to its consumers.** Bump the peer and the +development dependency here, run the suite (the route-table pin in `exposure.test.ts` fails +until a new or renamed route is reviewed against `EXPOSED_ROUTES`), release, and then move each +consumer to that release and that Better Auth version together. + +### The database host contract + +The host owns the pool; the package's timing assumes how it is configured. A host meets all +of these, and a new host (the `./next` entry's applications) must meet them for its own pool: + +- One `pg` pool behind one `Kysely`, shared by the session store, the grant store and the + refresher. +- The pool's `connectionTimeoutMillis` equals `POOL_CONNECTION_TIMEOUT_MS`, and every + connection's server-side `statement_timeout` equals `STATEMENT_TIMEOUT_MS`. The refresher's + read race and the lease bound (`leaseBound`, `waitBound`) assume exactly these numbers. Use + the server's timeout, not `pg`'s `query_timeout`, which only rejects the promise and can + return the connection to the pool inside an open transaction. +- `idle_in_transaction_session_timeout` is set, so the server ends a session left idle in a + transaction. +- A socket inactivity timeout destroys an established connection that stops answering. + Neither timeout above bounds that case, and a grant write would otherwise stay pending + forever. +- `error` listeners on the pool and on every client. `pg-pool` drops its own listener at + checkout, and a destroyed client emits `error`; an unheard `error` ends the process. +- Shutdown waits up to `drainBound(refresh)` for `inFlight.drained()`, on the `InFlightWork` + passed to `createGrantRefresher`, after the HTTP server has closed and before the pool ends, + so a dispatched refresh can still store its rotated tokens. The termination grace must allow + that long. + +### Configuration vocabulary + +Hosts that read their configuration from the environment use these names, and +`refreshBoundProblems` words its problems in the `REFRESH_*` ones. + +| Variable | `RefreshConfig` field | Default | Bound | +| ---------------------------- | --------------------- | ------- | ---------------------------------------------------------------- | +| `REFRESH_REQUEST_TIMEOUT_MS` | `requestTimeoutMs` | 5000 | The token request's deadline | +| `REFRESH_LEASE_MS` | `leaseMs` | 13000 | At least `leaseBound(requestTimeoutMs)` | +| `REFRESH_WAIT_MS` | `waitMs` | 5000 | `waitBound(waitMs)` less than `leaseMs` | +| `REFRESH_LEEWAY_MS` | `leewayMs` | 60000 | At least 1000 | +| `COOKIE_NAME_POLICY` | `cookieNamePolicy` | (none) | `host` (`__Host-.*`) or `secure` (`__Secure-.*`) | ## Development @@ -30,8 +150,17 @@ npx changeset # describe a change; CI opens the version PR `lefthook` formats staged files on commit and runs the linter on push; `npm ci` installs the hooks through the `prepare` script. +The suite runs in two vitest projects: `core` (Node) for the root entry under `src/core`, and +`dom` (jsdom) for the entry-point checks and the React and Next.js entries. The helpers in +`src/core/__tests__/helpers` (the memory grant store, the fake Keycloak, the auth instance) are +copies of Interlock's own test helpers, which its remaining suites still use; the two sets +drift unless they change together. A `./testing` entry is the way out if the React or Next.js +entries need the fakes too. + CI packs the package, installs the tarball into a throwaway project, and imports every entry -point by name, so a broken `exports` map fails the pull request rather than a consumer. +point by name, so a broken `exports` map fails the pull request rather than a consumer. It also +type-checks a consumer of the root entry under NodeNext resolution, the way Interlock imports +it, since the package itself builds with Bundler resolution. Releases go out through the changesets action: merging the generated "chore: version package" PR is what publishes. Nothing reaches npm without that merge. diff --git a/oxlint.config.ts b/oxlint.config.ts index e5fd29d..9182271 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -18,9 +18,20 @@ export default defineConfig({ env: { browser: true, builtin: true, + // The root entry runs on the server (`process`, `Buffer`, `node:*`). + node: true, vitest: true, }, ignorePatterns: ["dist/", "node_modules/", "package-lock.json"], + overrides: [ + { + // The core's loops are sequential by design: the refresh lease's rounds and its polling + // wait (each step depends on the row the previous one read), the hook composition (order is + // the contract) and the shutdown drain. None has independent iterations to run together. + files: ["src/core/**"], + rules: { "no-await-in-loop": "off" }, + }, + ], options: { maxWarnings: 0, typeAware: true, diff --git a/package-lock.json b/package-lock.json index d0054ca..b8bc5d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,34 +1,45 @@ { "name": "@devalexllc/interlock-auth", - "version": "0.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devalexllc/interlock-auth", - "version": "0.0.0", + "version": "0.0.1", "license": "Apache-2.0", + "dependencies": { + "jose": "6.2.12" + }, "devDependencies": { "@changesets/cli": "3.0.1", "@testing-library/react": "16.3.2", "@types/node": "24.13.2", + "@types/pg": "8.23.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "@typescript/native-preview": "7.0.0-dev.20260421.2", "@vitejs/plugin-react": "6.0.2", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", + "better-auth": "1.7.7", "jsdom": "28.0.0", + "kysely": "0.29.6", "lefthook": "2.1.9", "oxfmt": "0.54.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", + "pg": "8.23.1", "react": "19.2.0", "react-dom": "19.2.0", "vitest": "4.1.11" }, "engines": { "node": ">=22.18.0" + }, + "peerDependencies": { + "better-auth": "1.7.7", + "kysely": "^0.29.0" } }, "node_modules/@acemir/cssom": { @@ -159,6 +170,127 @@ "node": ">=18" } }, + "node_modules/@better-auth/core": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", + "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", + "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", + "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", + "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", + "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", + "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", + "dev": true, + "license": "MIT" + }, "node_modules/@changesets/apply-release-plan": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-8.1.1.tgz", @@ -660,6 +792,42 @@ "node": ">=20.0.0" } }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.153.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", @@ -1781,6 +1949,18 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -2185,6 +2365,156 @@ "dev": true, "license": "MIT" }, + "node_modules/better-auth": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", + "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.7", + "@better-auth/drizzle-adapter": "1.7.7", + "@better-auth/kysely-adapter": "1.7.7", + "@better-auth/memory-adapter": "1.7.7", + "@better-auth/mongo-adapter": "1.7.7", + "@better-auth/prisma-adapter": "1.7.7", + "@better-auth/telemetry": "1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.2.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.3.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", + "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, "node_modules/bidi-js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", @@ -2298,6 +2628,13 @@ "dev": true, "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2573,6 +2910,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2628,6 +2974,16 @@ "dev": true, "license": "MIT" }, + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/launch-editor": { "version": "2.14.2", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz", @@ -3165,6 +3521,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -3325,6 +3697,103 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3374,6 +3843,49 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3475,6 +3987,13 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3508,6 +4027,13 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.4.tgz", + "integrity": "sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==", + "dev": true, + "license": "MIT" + }, "node_modules/shell-quote": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", @@ -3560,6 +4086,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3971,6 +4507,16 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", @@ -3986,6 +4532,16 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index b69c723..7158842 100644 --- a/package.json +++ b/package.json @@ -55,25 +55,36 @@ "typecheck": "tsgo --noEmit", "prepublishOnly": "npm run clean && npm run build && npm test" }, + "dependencies": { + "jose": "6.2.12" + }, "devDependencies": { "@changesets/cli": "3.0.1", "@testing-library/react": "16.3.2", "@types/node": "24.13.2", + "@types/pg": "8.23.1", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "@typescript/native-preview": "7.0.0-dev.20260421.2", "@vitejs/plugin-react": "6.0.2", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", + "better-auth": "1.7.7", "jsdom": "28.0.0", + "kysely": "0.29.6", "lefthook": "2.1.9", "oxfmt": "0.54.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", + "pg": "8.23.1", "react": "19.2.0", "react-dom": "19.2.0", "vitest": "4.1.11" }, + "peerDependencies": { + "better-auth": "1.7.7", + "kysely": "^0.29.0" + }, "engines": { "node": ">=22.18.0" }, diff --git a/sonar-project.properties b/sonar-project.properties index 43d34f0..596dfd3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,14 +6,15 @@ sonar.projectName=interlock-auth # analyzed as test code instead of being reported as production code that nothing covers. sonar.sources=. sonar.tests=. -sonar.test.inclusions=src/__tests__/**,**/*.test.ts,**/*.test.tsx +sonar.test.inclusions=src/**/__tests__/**,**/*.test.ts,**/*.test.tsx # Test files appear here *and* in `sonar.test.inclusions` on purpose: the inclusion assigns them to -# the test set, this exclusion removes them from the source set. Both are required. +# the test set, this exclusion removes them from the source set. Both are required. Every +# `__tests__` directory counts, at any depth: `src/core/__tests__/helpers` holds the specs' fakes. # # `dist` and `coverage` are build artifacts that exist in the workspace by the time the scan runs, # because the scan follows the build and the coverage run. -sonar.exclusions=src/__tests__/**,**/*.test.ts,**/*.test.tsx,**/dist/**,**/coverage/**,**/node_modules/** +sonar.exclusions=src/**/__tests__/**,**/*.test.ts,**/*.test.tsx,**/dist/**,**/coverage/**,**/node_modules/** # Analyzed for issues, but not expected to carry coverage. The barrel files re-export and nothing # else; the exclusion matches `coverage.exclude` in `vitest.config.ts`, so both tools measure the diff --git a/src/__tests__/entry-points.test.ts b/src/__tests__/entry-points.test.ts index d2cc7b4..0ed86a1 100644 --- a/src/__tests__/entry-points.test.ts +++ b/src/__tests__/entry-points.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "vitest"; -// Pins the scaffold's contract: every published entry point loads and exports nothing. The item -// that fills an entry replaces its case here with real tests. +// Pins the entry points' contract. The root entry carries the core; the entries not yet filled +// load and export nothing, and the item that fills one replaces its case here with real tests. describe("entry points", () => { + it(". exports the core", async () => { + const mod = await import("../index.js"); + + expect(mod.createKeycloakAuthOptions).toBeInstanceOf(Function); + expect(mod.normalizeClaims).toBeInstanceOf(Function); + }); + it.each([ - [".", async () => import("../index.js")], ["./next", async () => import("../next/index.js")], ["./react", async () => import("../react/index.js")], ])("%s is empty", async (_entry, load) => { diff --git a/src/core/__tests__/auth.options.test.ts b/src/core/__tests__/auth.options.test.ts new file mode 100644 index 0000000..c34e3bb --- /dev/null +++ b/src/core/__tests__/auth.options.test.ts @@ -0,0 +1,362 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { createCookieGetter } from "better-auth/cookies"; +import type { GenericOAuthConfig, GenericOAuthOptions } from "better-auth/plugins/generic-oauth"; +import { Kysely } from "kysely"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + cookieSettings, + createKeycloakAuthOptions, + createPostgresStore, + type KeycloakAuthOptions, +} from "../auth.options.js"; +import { StartupError } from "../callback-path.js"; +import { COOKIE_NAME_POLICIES, type CookieNamePolicy } from "../config.js"; +import type { GrantStore } from "../grants/grant-store.js"; +import type { AuthLogger } from "../logger.js"; +import { assertReceiverRoute } from "../logout/backchannel.js"; +import { + defined, + testLogger, + withKeycloak, + type TestConfig, + type TestLogger, +} from "./helpers/config.js"; +import { TEST_SCHEMA, unreachableKysely } from "./helpers/kysely.js"; +import { + createMemoryAuthOptions, + createMemoryAuthSetup, + MemoryGrantStore, +} from "./helpers/memory-store.js"; + +const config = (issuer = "https://auth.example.com/realms/main") => withKeycloak({ issuer }); + +const store = memoryAdapter({}); +const grants = new MemoryGrantStore(); + +/** The options over the shared memory store, with `overrides` in place. */ +const build = (overrides: Partial = {}, cfg: TestConfig = config()) => + createKeycloakAuthOptions(cfg, { database: store, grants, logger: testLogger(), ...overrides }); + +const providerOf = (options: BetterAuthOptions): GenericOAuthConfig => { + const plugin = defined( + options.plugins?.find((p) => p.id === "generic-oauth"), + "the generic OAuth plugin", + ); + return defined((plugin.options as GenericOAuthOptions).config[0], "the Keycloak provider"); +}; + +const receiverPathOf = (options: BetterAuthOptions) => + defined( + options.plugins?.find((p) => p.id === "backchannel-logout")?.endpoints?.backchannelLogout, + "the receiver endpoint", + ).path; + +describe("createKeycloakAuthOptions", () => { + afterEach(() => vi.restoreAllMocks()); + + it.each(["https://auth.example.com/realms/main", "https://auth.example.com/realms/main/"])( + "should register keycloak with explicit endpoints derived from %s", + (issuer) => { + const provider = providerOf(build({}, config(issuer))); + const base = "https://auth.example.com/realms/main"; + + expect(provider).toMatchObject({ + providerId: "keycloak", + clientId: "interlock", + clientSecret: "test-client-secret", + discoveryUrl: `${base}/.well-known/openid-configuration`, + authorizationUrl: `${base}/protocol/openid-connect/auth`, + tokenUrl: `${base}/protocol/openid-connect/token`, + userInfoUrl: `${base}/protocol/openid-connect/userinfo`, + disableProviderLogout: true, + }); + expect(provider.scopes).toEqual(["openid", "profile", "email"]); + expect(provider.redirectURI).toBeUndefined(); + expect(provider.accountSubject).toBeInstanceOf(Function); + expect(provider.mapProfileToUser).toBeInstanceOf(Function); + }, + ); + + it("should keep sessions and tokens on the server, in the store it is given", () => { + const options = build(); + + expect(options.database).toBe(store); + expect(options.session?.cookieCache?.enabled).toBe(false); + expect(options.account).toEqual({ encryptOAuthTokens: true, storeAccountCookie: false }); + }); + + // Without a database Better Auth 1.7 silently keeps sessions in cookies. + it.each([undefined, null])("should refuse to build options with no store (%s)", (database) => { + expect(() => build({ database })).toThrow("Better Auth needs a database"); + }); + + // Without the hooks the account row keeps the tokens and no session has a grant. + it.each([undefined, null])( + "should refuse to build options with no grant store (%s)", + (missing) => { + expect(() => build({ grants: missing as unknown as GrantStore })).toThrow( + "Better Auth needs a grant store", + ); + }, + ); + + // Without a logger the first hook to log would throw mid-request. + it.each([undefined, null])("should refuse to build options with no logger (%s)", (missing) => { + expect(() => build({ logger: missing as unknown as AuthLogger })).toThrow( + "Better Auth needs a logger", + ); + }); + + it("should carry the grant hooks", () => { + const { databaseHooks } = build(); + + expect(databaseHooks?.account?.create?.before).toBeInstanceOf(Function); + expect(databaseHooks?.account?.update?.before).toBeInstanceOf(Function); + expect(databaseHooks?.session?.create?.before).toBeInstanceOf(Function); + expect(databaseHooks?.session?.create?.after).toBeInstanceOf(Function); + expect(databaseHooks?.session?.update?.before).toBeInstanceOf(Function); + }); + + // The refresher owns `expiresAt`; the callback's start anchors the login; + // the after hook strips provider tokens from every result. + it("should turn the session slide off and install the request hooks", () => { + const options = build(); + + expect(options.session?.disableSessionRefresh).toBe(true); + expect(options.hooks?.before).toBeInstanceOf(Function); + expect(options.hooks?.after).toBeInstanceOf(Function); + }); + + it("should pass the validated configuration through", () => { + const options = build(); + + expect(options).toMatchObject({ + baseURL: "https://interlock.test", + basePath: "/api/auth", + secrets: [ + { version: 1, value: "test-secret-current" }, + { version: 0, value: "test-secret-previous" }, + ], + trustedOrigins: ["https://interlock.test"], + telemetry: { enabled: false }, + advanced: { + useSecureCookies: false, + cookiePrefix: "__Host-interlock", + defaultCookieAttributes: { secure: true }, + disableOriginCheck: false, + disableCSRFCheck: false, + }, + }); + expect(options.secret).toBeUndefined(); + }); + + describe("the back-channel logout receiver", () => { + it("should mount the receiver at its route and exempt it from the rate limiter", () => { + const options = build(); + + expect(receiverPathOf(options)).toBe("/backchannel-logout"); + expect(options.rateLimit).toEqual({ customRules: { "/backchannel-logout": false } }); + }); + + it("should serve and expose a receiver configured at another path, and only there", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const custom = withKeycloak({ backchannelLogoutPath: "/api/auth/other" }); + const { options } = createMemoryAuthSetup(custom); + const auth = betterAuth(options); + const post = async (path: string) => + auth.handler( + new Request(`https://interlock.test/api/auth${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "logout_token=not-a-jwt", + }), + ); + + expect(receiverPathOf(options)).toBe("/other"); + expect(options.rateLimit).toEqual({ customRules: { "/other": false } }); + const served = await post("/other"); + expect(served.status).toBe(400); + expect(await served.json()).toMatchObject({ code: "logout_token_invalid" }); + expect((await post("/backchannel-logout")).status).toBe(404); + expect(() => assertReceiverRoute(auth, "/other")).not.toThrow(); + }); + + // The exposure hook allows a path whatever serves it, so a receiver path + // that Better Auth also serves would expose, or shadow, that route. + it.each([ + ["/list-sessions", "/list-sessions"], + ["/callback/keycloak", "/callback/:id"], + ["/reset-password/x", "/reset-password/:token"], + ])("should refuse to start with the receiver at %s", (route, other) => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const custom = withKeycloak({ backchannelLogoutPath: `/api/auth${route}` }); + const auth = betterAuth(createMemoryAuthSetup(custom).options); + + expect(() => assertReceiverRoute(auth, route)).toThrow(StartupError); + expect(() => assertReceiverRoute(auth, route)).toThrow( + `which Better Auth also serves (${other})`, + ); + }); + + it("should accept the receiver at its usual route, and only where it is served", () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const auth = betterAuth(build()); + + expect(() => assertReceiverRoute(auth, "/backchannel-logout")).not.toThrow(); + expect(() => assertReceiverRoute(auth, "/other")).toThrow( + "The back-channel logout receiver is not served at /other", + ); + }); + }); + + it("should give the memory store used by unit specs a fresh store on every call", () => { + expect(createMemoryAuthOptions(config()).database).not.toBe( + createMemoryAuthOptions(config()).database, + ); + }); + + describe("Better Auth's own logs", () => { + const logOf = (logger: TestLogger) => { + const options = build({ logger }); + expect(options.logger?.level).toBe("info"); + return defined(options.logger?.log, "Better Auth's log function"); + }; + + // A host logger that redacts works on record keys, so Better Auth's + // arguments must arrive as a record rather than as a formatted string. + it.each([ + ["error", "error"], + ["warn", "warn"], + ["info", "log"], + ["debug", "debug"], + ] as const)( + "should send Better Auth %s logs to the logger's %s as a record", + (level, method) => { + const logger = testLogger(); + + logOf(logger)(level, "something happened", { accessToken: "x" }); + + expect(logger[method]).toHaveBeenCalledWith({ + message: "[better-auth] something happened", + args: [{ accessToken: "x" }], + }); + }, + ); + + it("should not repeat the tag when Better Auth already wrote it", () => { + const logger = testLogger(); + + logOf(logger)("warn", "[better-auth] Warning: short secret"); + + expect(logger.warn).toHaveBeenCalledWith({ + message: "[better-auth] Warning: short secret", + args: [], + }); + }); + }); + + // R1's re-check on every Better Auth bump: a release that prefixes names + // regardless of `useSecureCookies`, or drops an attribute, fails here. + // cookie-policy.test.ts checks which kinds the endpoints really emit. + describe("cookie names", () => { + const KINDS = [ + "session_token", + "session_data", + "account_data", + "dont_remember", + "state", + "oauth_state", + ]; + const PREFIXES: Record = { + host: "__Host-interlock.", + secure: "__Secure-interlock.", + }; + + const cookiesOf = ( + overrides: Partial, + options: Partial = {}, + ) => { + const createCookie = createCookieGetter(build(options, { ...config(), ...overrides })); + return KINDS.map((kind) => ({ kind, ...createCookie(kind) })); + }; + + it.each(COOKIE_NAME_POLICIES)("should name every %s cookie with one prefix", (policy) => { + for (const { kind, name, attributes } of cookiesOf({ cookieNamePolicy: policy })) { + expect(name).toBe(`${PREFIXES[policy]}${kind}`); + expect(attributes).toMatchObject({ + secure: true, + httpOnly: true, + sameSite: "lax", + path: "/", + }); + expect(attributes).not.toHaveProperty("domain"); + } + }); + + // Pinned, not inferred from the base URL: on http the browser drops the + // cookies instead of receiving an insecure, unprefixed session cookie. + it.each(COOKIE_NAME_POLICIES)("should keep %s cookies Secure on an http base URL", (policy) => { + const base = config(); + const cookies = cookiesOf({ + cookieNamePolicy: policy, + betterAuth: { ...base.betterAuth, url: "http://interlock.test" }, + }); + + for (const { kind, name, attributes } of cookies) { + expect(name).toBe(`${PREFIXES[policy]}${kind}`); + expect(attributes.secure).toBe(true); + } + }); + + // Another application names its own cookies under the same policy. + it.each([ + ["host", "__Host-portal."], + ["secure", "__Secure-portal."], + ] as const)("should put a host's own prefix under the %s policy", (policy, prefix) => { + for (const { kind, name } of cookiesOf( + { cookieNamePolicy: policy }, + { cookiePrefix: "portal" }, + )) { + expect(name).toBe(`${prefix}${kind}`); + } + }); + + it("should derive the advanced cookie options from the policy and the prefix", () => { + expect(cookieSettings("host")).toEqual({ + useSecureCookies: false, + cookiePrefix: "__Host-interlock", + defaultCookieAttributes: { secure: true }, + }); + expect(cookieSettings("secure")).toEqual({ + useSecureCookies: true, + cookiePrefix: "interlock", + defaultCookieAttributes: { secure: true }, + }); + expect(cookieSettings("host", "portal")).toMatchObject({ cookiePrefix: "__Host-portal" }); + expect(cookieSettings("secure", "portal")).toMatchObject({ cookiePrefix: "portal" }); + }); + }); + + describe("createPostgresStore", () => { + // `transaction` is the one that matters: the `{ db }` shape treats unset as + // false, and then `createOAuthUser` commits the user before a failing + // account insert (Interlock's Postgres session-store suite proves both + // ways). + it("should qualify statements with the schema and run multi-step writes in a transaction", async () => { + const db = unreachableKysely(); + try { + expect(createPostgresStore(db, TEST_SCHEMA)).toEqual({ + db, + type: "postgres", + schemaName: TEST_SCHEMA, + transaction: true, + }); + expect(db).toBeInstanceOf(Kysely); + } finally { + await db.destroy(); + } + }); + }); +}); diff --git a/src/core/__tests__/callback-path.test.ts b/src/core/__tests__/callback-path.test.ts new file mode 100644 index 0000000..0160a8b --- /dev/null +++ b/src/core/__tests__/callback-path.test.ts @@ -0,0 +1,155 @@ +import { betterAuth } from "better-auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createKeycloakAuthOptions, createPostgresStore } from "../auth.options.js"; +import { + assertCallbackPath, + resolveCallbackPath, + StartupError, + type CallbackPathSource, +} from "../callback-path.js"; +import { KEYCLOAK_CALLBACK_PATH } from "../constants.js"; +import { KyselyGrantStore } from "../grants/grant-store.js"; +import { TEST_CONFIG, testLogger, type TestLogger } from "./helpers/config.js"; +import { TEST_SCHEMA, unreachableKysely } from "./helpers/kysely.js"; +import { createMemoryAuthOptions } from "./helpers/memory-store.js"; + +const issuer = "https://127.0.0.1:1/realms/test"; + +const discovery = { + issuer, + authorization_endpoint: `${issuer}/protocol/openid-connect/auth`, + token_endpoint: `${issuer}/protocol/openid-connect/token`, + userinfo_endpoint: `${issuer}/protocol/openid-connect/userinfo`, + end_session_endpoint: `${issuer}/protocol/openid-connect/logout`, + jwks_uri: `${issuer}/protocol/openid-connect/certs`, + id_token_signing_alg_values_supported: ["RS256"], +}; + +/** A real instance built from the test configuration, with discovery stubbed. */ +const createAuth = (logger: TestLogger, url?: string) => + betterAuth( + createMemoryAuthOptions( + url === undefined + ? TEST_CONFIG + : { ...TEST_CONFIG, betterAuth: { ...TEST_CONFIG.betterAuth, url } }, + logger, + ), + ); + +/** A stand-in instance, for the cases a real one cannot produce. */ +const fakeAuth = ( + providers: { id: string; callbackPath?: string; issuer?: string }[], + endpointPath = "/callback/:id", +): CallbackPathSource => ({ + $context: Promise.resolve({ + baseURL: "https://interlock.test/api/auth", + socialProviders: providers, + }), + api: { callbackOAuth: Object.assign(() => undefined, { path: endpointPath }) }, +}); + +describe("callback path", () => { + let logger: TestLogger; + + beforeEach(() => { + logger = testLogger(); + }); + + afterEach(() => vi.restoreAllMocks()); + + // The R6 guard: a Better Auth release that moves the route fails here. + describe("with a real Better Auth instance", () => { + it("should resolve the contract path when discovery succeeds", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(discovery)); + const auth = createAuth(logger); + + await expect(assertCallbackPath(auth, { logger })).resolves.toBe(KEYCLOAK_CALLBACK_PATH); + expect(KEYCLOAK_CALLBACK_PATH).toBe("/api/auth/callback/keycloak"); + expect(logger.log).toHaveBeenCalledWith({ + message: "Keycloak callback path is /api/auth/callback/keycloak", + }); + expect(logger.warn).not.toHaveBeenCalledWith({ + message: expect.stringContaining("discovery failed") as string, + }); + }); + + it("should keep the provider, and warn, when discovery fails", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const auth = createAuth(logger); + + await expect(assertCallbackPath(auth, { logger })).resolves.toBe(KEYCLOAK_CALLBACK_PATH); + expect(logger.warn).toHaveBeenCalledWith({ + message: expect.stringContaining("discovery failed") as string, + }); + }); + + // The runtime wiring. The pool points at a refused port, so this also + // shows that building the instance never touches Postgres: a database + // that is down does not stop the host from booting. + it("should store sessions in Postgres through Kysely, not in cookies", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const db = unreachableKysely(); + try { + const ctx = await betterAuth( + createKeycloakAuthOptions(TEST_CONFIG, { + database: createPostgresStore(db, TEST_SCHEMA), + grants: new KyselyGrantStore(db, TEST_SCHEMA), + logger, + }), + ).$context; + + expect(ctx.adapter.id).toBe("kysely"); + expect(ctx.options.session?.cookieCache?.enabled).toBe(false); + expect(ctx.options.account?.storeAccountCookie).toBe(false); + } finally { + await db.destroy(); + } + }); + + // Better Auth keeps a base URL that already has a path and ignores + // `basePath`, so the callback silently loses `/api/auth`. + it("should refuse a BETTER_AUTH_URL with a path", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const auth = createAuth(logger, "https://interlock.test/prefix"); + + await expect(assertCallbackPath(auth, { logger })).rejects.toThrow( + "Better Auth serves the keycloak callback at /prefix/callback/keycloak, " + + "but the realm's redirect URI is /api/auth/callback/keycloak", + ); + }); + }); + + describe("resolveCallbackPath", () => { + it("should honor a provider's own callbackPath", async () => { + const auth = fakeAuth([{ id: "keycloak", callbackPath: "/oauth2/callback/keycloak" }]); + + await expect(resolveCallbackPath(auth, "keycloak")).resolves.toMatchObject({ + path: "/api/auth/oauth2/callback/keycloak", + }); + await expect(assertCallbackPath(auth, { logger })).rejects.toBeInstanceOf(StartupError); + }); + + it("should fail when the provider is not registered", async () => { + await expect(resolveCallbackPath(fakeAuth([]), "keycloak")).rejects.toThrow( + "OAuth provider keycloak is not registered", + ); + }); + + it("should fail when the core callback endpoint moves", async () => { + const auth = fakeAuth([{ id: "keycloak" }], "/oauth2/callback/:id"); + + await expect(resolveCallbackPath(auth, "keycloak")).rejects.toThrow( + 'no longer serves OAuth callbacks at /callback/:id (found "/oauth2/callback/:id")', + ); + }); + }); + + it("should fail when the expected path differs", async () => { + const auth = fakeAuth([{ id: "keycloak", issuer }]); + + await expect( + assertCallbackPath(auth, { logger, expected: "/auth/callback/keycloak" }), + ).rejects.toThrow(StartupError); + }); +}); diff --git a/src/core/__tests__/claims.test.ts b/src/core/__tests__/claims.test.ts new file mode 100644 index 0000000..d8602f4 --- /dev/null +++ b/src/core/__tests__/claims.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeClaims } from "../claims.js"; + +describe("normalizeClaims", () => { + it("should map Keycloak's claims onto the shape the APIs authorize on", () => { + expect( + normalizeClaims({ + sub: "f3a1", + realm_access: { roles: ["user", "offline_access"] }, + resource_access: { + interlock: { roles: ["viewer"] }, + api: { roles: ["admin", "user"] }, + }, + groups: ["tenant-a", "analysts"], + tenant_ids: ["t-1", "t-2"], + internal_groups: ["ops"], + }), + ).toEqual({ + roles: ["user", "offline_access", "viewer", "admin"], + groups: ["tenant-a", "analysts"], + tenants: ["t-1", "t-2"], + sites: [], + internal_groups: ["ops"], + }); + }); + + it("should give empty lists for claims that are missing", () => { + expect(normalizeClaims({ sub: "f3a1" })).toEqual({ + roles: [], + groups: [], + tenants: [], + sites: [], + internal_groups: [], + }); + }); + + // Never thrown on: a token is the realm's to shape, and a bad claim + // grants nothing rather than failing the request. + it.each([ + ["a string instead of a list", { groups: "analysts", tenant_ids: "t-1" }], + ["an object instead of a list", { groups: { 0: "analysts" }, internal_groups: {} }], + ["null access claims", { realm_access: null, resource_access: null }], + ["access claims without roles", { realm_access: {}, resource_access: { interlock: {} } }], + ["roles that are not a list", { realm_access: { roles: "admin" } }], + ["a client entry that is not an object", { resource_access: { interlock: ["admin"] } }], + ])("should drop %s", (_case, claims) => { + expect(normalizeClaims(claims)).toEqual({ + roles: [], + groups: [], + tenants: [], + sites: [], + internal_groups: [], + }); + }); + + it("should keep the strings of a list and drop every other entry", () => { + expect( + normalizeClaims({ + realm_access: { roles: ["user", 1, null, "", { name: "admin" }] }, + groups: ["analysts", 2, "", false], + }), + ).toMatchObject({ roles: ["user"], groups: ["analysts"] }); + }); + + // The stub has no `sites` source to read (R3); 6.2 decides the claim. + it("should leave sites empty even when the token carries the claim", () => { + expect(normalizeClaims({ sites: ["s-1"] }).sites).toEqual([]); + }); +}); diff --git a/src/core/__tests__/cookie-policy.test.ts b/src/core/__tests__/cookie-policy.test.ts new file mode 100644 index 0000000..044a39d --- /dev/null +++ b/src/core/__tests__/cookie-policy.test.ts @@ -0,0 +1,134 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { makeSignature } from "better-auth/crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { COOKIE_NAME_POLICIES, type CookieNamePolicy } from "../config.js"; +import { TEST_CONFIG } from "./helpers/config.js"; +import { createMemoryAuthOptions } from "./helpers/memory-store.js"; +import { checkSetCookies, namesOf } from "./helpers/set-cookie.js"; + +const BASE = "https://interlock.test"; +const PREFIXES: Record = { + host: "__Host-interlock.", + secure: "__Secure-interlock.", +}; + +const emitted = (res: Response) => checkSetCookies(res.headers.getSetCookie()); + +/** + * Every cookie a login, a session refresh, and a logout emit, through a real + * instance's handler on the memory store, under each cookie-name policy. + * Each step asserts the exact set of names it emits: the cookie getter takes + * any kind string, so only the endpoints can show that Better Auth still uses + * the kinds the policy was checked against. Interlock's own cookie suite + * repeats this through its HTTP server and Postgres. + * + * The options turn Better Auth's session slide off (the refresher owns + * `expiresAt`), so this instance turns it back on: the slide is still the + * path on which Better Auth reissues the session cookie, and its name must + * still follow the policy if the slide is ever re-enabled. The e2e suite + * checks that the running server reissues nothing. + */ +describe.each(COOKIE_NAME_POLICIES)("cookie-name policy %s", (policy: CookieNamePolicy) => { + const prefix = PREFIXES[policy]; + let auth: ReturnType; + + // Discovery runs while the instance initializes, so the stubs go first. + beforeAll(async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const options = createMemoryAuthOptions({ ...TEST_CONFIG, cookieNamePolicy: policy }); + const slide: BetterAuthOptions = { + ...options, + session: { ...options.session, disableSessionRefresh: false }, + }; + auth = betterAuth(slide); + await auth.$context; + }); + + afterAll(() => vi.restoreAllMocks()); + + /** A user with a session; `due` makes the session ready for its refresh. */ + const signedIn = async (due: boolean) => { + const ctx = await auth.$context; + const user = await ctx.internalAdapter.createUser( + { email: `ann-${crypto.randomUUID()}@example.com`, name: "ann", emailVerified: true }, + { method: "oauth", oauth: { providerId: "keycloak" } }, + ); + const { expiresIn, updateAge } = ctx.sessionConfig; + // get-session refreshes once `expiresAt - expiresIn + updateAge <= now`. + // `createSession` spreads the override before its own `expiresAt`, so + // the last argument (`overrideAll`) is what makes the override stick. + const session = due + ? await ctx.internalAdapter.createSession( + user.id, + false, + { expiresAt: new Date(Date.now() + (expiresIn - updateAge) * 1000 - 1000) }, + true, + ) + : await ctx.internalAdapter.createSession(user.id, false); + const signature = await makeSignature(session.token, ctx.secret); + return { + session, + cookie: `${ctx.authCookies.sessionToken.name}=${encodeURIComponent(`${session.token}.${signature}`)}`, + }; + }; + + const getSession = async (cookie: string) => + auth.handler(new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } })); + + it("should set only the state cookie when a login starts", async () => { + const res = await auth.handler( + new Request(`${BASE}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ provider: "keycloak", callbackURL: "/" }), + }), + ); + + expect(res.status).toBe(200); + expect(emitted(res)).toEqual([{ name: `${prefix}state`, maxAge: 300 }]); + }); + + // The control: without it the refresh assertion below would also pass if + // get-session set the cookie on every call. + it("should set no cookie on get-session while the session is fresh", async () => { + const { session, cookie } = await signedIn(false); + const res = await getSession(cookie); + + expect(res.status).toBe(200); + expect(res.headers.getSetCookie()).toEqual([]); + expect(await res.json()).toMatchObject({ session: { token: session.token } }); + }); + + it("should reissue only the session cookie on get-session once a refresh is due", async () => { + const { session, cookie } = await signedIn(true); + const { sessionConfig } = await auth.$context; + const res = await getSession(cookie); + + expect(res.status).toBe(200); + expect(emitted(res)).toEqual([ + { name: `${prefix}session_token`, maxAge: sessionConfig.expiresIn }, + ]); + expect(sessionConfig.expiresIn).toBe(604800); + expect(await res.json()).toMatchObject({ session: { token: session.token } }); + }); + + it("should expire the session cookies on sign-out", async () => { + const { cookie } = await signedIn(false); + const res = await auth.handler( + new Request(`${BASE}/api/auth/sign-out`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE, cookie }, + body: "{}", + }), + ); + const cookies = emitted(res); + + expect(res.status).toBe(200); + expect(namesOf(cookies)).toEqual( + ["dont_remember", "session_data", "session_token"].map((kind) => `${prefix}${kind}`), + ); + expect(cookies.map((c) => c.maxAge)).toEqual([0, 0, 0]); + await expect(getSession(cookie).then(async (r) => r.json())).resolves.toBeNull(); + }); +}); diff --git a/src/core/__tests__/database/in-flight-work.test.ts b/src/core/__tests__/database/in-flight-work.test.ts new file mode 100644 index 0000000..899c3f9 --- /dev/null +++ b/src/core/__tests__/database/in-flight-work.test.ts @@ -0,0 +1,66 @@ +import { performance } from "node:perf_hooks"; + +import { describe, expect, it } from "vitest"; + +import { InFlightWork } from "../../database/in-flight-work.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("InFlightWork", () => { + it("should count work until it settles, resolved or rejected, and hand it back unchanged", async () => { + const work = new InFlightWork(); + const ok = deferred(); + const failed = deferred(); + + const tracked = work.track(ok.promise); + work.track(failed.promise).catch(() => undefined); + expect(work.size).toBe(2); + + ok.resolve("done"); + expect(await tracked).toBe("done"); + failed.reject(new Error("refused")); + await expect(failed.promise).rejects.toThrow("refused"); + await Promise.resolve(); + expect(work.size).toBe(0); + }); + + it("should resolve drained once everything has settled, work added meanwhile included", async () => { + const work = new InFlightWork(); + const first = deferred(); + const second = deferred(); + void work.track(first.promise); + + const drained = work.drained(5000); + void work.track(second.promise); + first.resolve(undefined); + await Promise.resolve(); + expect(work.size).toBe(1); + second.resolve(undefined); + + expect(await drained).toBe(0); + }); + + it("should give up at maxMs and report what is still pending", async () => { + const work = new InFlightWork(); + const never = deferred(); + void work.track(never.promise); + + const started = performance.now(); + expect(await work.drained(50)).toBe(1); + expect(performance.now() - started).toBeGreaterThanOrEqual(45); + expect(performance.now() - started).toBeLessThan(1000); + never.resolve(undefined); + }); + + it("should resolve drained at once with nothing tracked", async () => { + expect(await new InFlightWork().drained(5000)).toBe(0); + }); +}); diff --git a/src/core/__tests__/exposure.test.ts b/src/core/__tests__/exposure.test.ts new file mode 100644 index 0000000..10c33b3 --- /dev/null +++ b/src/core/__tests__/exposure.test.ts @@ -0,0 +1,253 @@ +import { runInNewContext } from "node:vm"; + +import { APIError } from "better-auth/api"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { + EXPOSED_ROUTES, + ID_TOKEN_FORBIDDEN, + ROUTE_NOT_EXPOSED, + scrubProviderTokens, + stripProviderTokens, +} from "../exposure.js"; +import { GRANT_UNSUPPORTED_LOGIN } from "../grants/grant-errors.js"; +import { TOKEN_FIELDS } from "../grants/grant-hooks.js"; +import { + BASE, + buildAuth, + sessionCookieName, + setsSession, + type AuthInstance, +} from "./helpers/auth-instance.js"; +import { AUTH_ROUTES, concretePath } from "./helpers/auth-routes.js"; + +/** Every key of a parsed JSON value, at any depth. */ +const keysOf = (value: unknown): string[] => { + if (value === null || typeof value !== "object") { + return []; + } + return Object.entries(value).flatMap(([key, item]) => [key, ...keysOf(item)]); +}; + +/** A route's request through the handler, as a logged-in browser on its own origin. */ +const send = async (setup: AuthInstance, path: string, method: string, cookie: string) => + setup.auth.handler( + new Request(`${BASE}${concretePath(path)}`, { + method, + headers: { + origin: BASE, + cookie, + ...(method === "POST" ? { "content-type": "application/json" } : {}), + }, + ...(method === "POST" ? { body: "{}" } : {}), + }), + ); + +const signIn = async (setup: AuthInstance) => + setup.post("sign-in/social", "", { + provider: "keycloak", + idToken: { token: setup.keycloak.idToken(99), accessToken: "client-access" }, + }); + +describe("route exposure", () => { + afterEach(() => vi.restoreAllMocks()); + + // Risk R6: a Better Auth bump that adds or renames a route fails here until + // the route is reviewed against EXPOSED_ROUTES and helpers/auth-routes.ts. + it("should pin Better Auth's route table", () => { + const { auth } = buildAuth(); + const table = Object.values(auth.api as Record) + .filter((endpoint) => typeof endpoint.path === "string") + .map((endpoint) => { + const { method } = endpoint.options as { method: string | string[] }; + return { path: endpoint.path as string, methods: [method].flat() }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + expect(table).toEqual(AUTH_ROUTES); + const paths = AUTH_ROUTES.map((route) => route.path); + for (const path of EXPOSED_ROUTES) { + expect(paths).toContain(path); + } + }); + + describe("as a logged-in browser", () => { + let setup: AuthInstance; + let cookie: string; + + beforeAll(async () => { + setup = buildAuth(); + ({ cookie } = await setup.login()); + }); + + afterAll(() => vi.restoreAllMocks()); + + it.each( + AUTH_ROUTES.filter((route) => !EXPOSED_ROUTES.has(route.path)).flatMap((route) => + route.methods.map((method) => [method, route.path] as const), + ), + )("should answer %s %s with a 404", async (method, path) => { + const res = await send(setup, path, method, cookie); + + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ code: ROUTE_NOT_EXPOSED }); + }); + + it.each( + AUTH_ROUTES.filter( + (route) => EXPOSED_ROUTES.has(route.path) && route.path !== "/sign-out", + ).flatMap((route) => route.methods.map((method) => [method, route.path] as const)), + )("should let %s %s through", async (method, path) => { + const res = await send(setup, path, method, cookie); + + expect(res.status).not.toBe(404); + }); + + it("should let sign-out through", async () => { + const res = await send(setup, "/sign-out", "POST", cookie); + + expect(res.status).toBe(200); + }); + }); + + it("should answer get-session with no provider token at any depth", async () => { + const setup = buildAuth(); + const { cookie } = await setup.login(); + const res = await setup.auth.handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } }), + ); + const body = (await res.json()) as { session: { token: string } }; + + expect(res.status).toBe(200); + expect(body.session.token).toEqual(expect.any(String)); + const keys = keysOf(body); + for (const field of TOKEN_FIELDS) { + expect(keys).not.toContain(field); + } + }); + + describe("an idToken sign-in body, with discovery on", () => { + it("should be a 400 before any Keycloak or JWKS contact", async () => { + const setup = buildAuth({ discovery: true }); + // Discovery runs as the instance initializes; only later calls count. + await setup.auth.$context; + setup.fetch.mockClear(); + const res = await signIn(setup); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: ID_TOKEN_FORBIDDEN }); + expect(setsSession(res.headers.getSetCookie(), await sessionCookieName(setup.auth))).toBe( + false, + ); + expect(setup.tables.session).toEqual([]); + expect(setup.grants.rows).toEqual([]); + expect(setup.keycloak.codeExchanges).toBe(0); + expect(setup.fetch).not.toHaveBeenCalled(); + }); + + it("should still meet the session hook without the exposure hook (control)", async () => { + const setup = buildAuth({ discovery: true, exposure: false }); + const res = await signIn(setup); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: GRANT_UNSUPPORTED_LOGIN }); + expect(setup.tables.session).toEqual([]); + }); + + it("should let a sign-in body without idToken through", async () => { + const setup = buildAuth({ discovery: true }); + const res = await setup.post("sign-in/social", "", { provider: "keycloak" }); + + expect(res.status).toBe(200); + }); + }); +}); + +const tokens = () => ({ + accessToken: "a", + refreshToken: "r", + idToken: "i", + accessTokenExpiresAt: new Date(0), + refreshTokenExpiresAt: new Date(0), +}); + +describe("scrubProviderTokens", () => { + it("should delete every token field at any depth and keep everything else", () => { + const when = new Date(1_000); + const value = { + ...tokens(), + session: { token: "session-token", expiresAt: when, ...tokens() }, + accounts: [{ id: "x", ...tokens() }, [{ ...tokens() }]], + bare: Object.assign(Object.create(null) as object, tokens()), + }; + scrubProviderTokens(value); + + expect(value).toEqual({ + session: { token: "session-token", expiresAt: when }, + accounts: [{ id: "x" }, [{}]], + bare: {}, + }); + expect(value.session.expiresAt).toBe(when); + }); + + // A request body or a result can come from another realm's JSON.parse. + it("should scrub an object from another realm", () => { + const value = runInNewContext('({ user: { id: "u", idToken: "i" }, accessToken: "a" })') as { + user: object; + }; + scrubProviderTokens(value); + + expect(JSON.stringify(value)).toBe('{"user":{"id":"u"}}'); + }); + + it("should leave null, primitives, dates, errors and responses untouched", () => { + const error = Object.assign(new APIError("FOUND"), { accessToken: "a" }); + const response = Object.assign(new Response("{}"), { accessToken: "a" }); + const date = Object.assign(new Date(0), { accessToken: "a" }); + for (const value of [null, undefined, "accessToken", 1, error, response, date]) { + scrubProviderTokens(value); + } + + expect(error.accessToken).toBe("a"); + expect(response.accessToken).toBe("a"); + expect(date.accessToken).toBe("a"); + }); + + // A strict-mode `delete` would throw here too; `Reflect.deleteProperty` + // returns false instead, so an unchecked call would serialize the token. + it.each([ + ["frozen", () => Object.freeze({ id: "u", accessToken: "a" })], + [ + "non-configurable", + () => Object.defineProperty({ id: "u" }, "idToken", { value: "i", enumerable: true }), + ], + ])("should throw on a %s token field rather than leave it", (_case, make) => { + const value = { user: make() }; + + expect(() => scrubProviderTokens(value)).toThrow(/Could not strip the provider token field/); + expect(JSON.stringify(value)).toMatch(/"(accessToken|idToken)"/); + }); + + it("should stop at a cycle", () => { + const value: Record = { idToken: "i" }; + value.self = value; + value.list = [value]; + scrubProviderTokens(value); + + expect(value).not.toHaveProperty("idToken"); + expect(value.self).toBe(value); + }); + + it("should strip the returned value in place as an after hook", async () => { + const returned = { session: { token: "t" }, user: { id: "u", ...tokens() } }; + const { response } = (await stripProviderTokens()({ + path: "/get-session", + context: { returned }, + headers: new Headers(), + returnHeaders: true, + } as never)) as unknown as { response: unknown }; + + expect(returned).toEqual({ session: { token: "t" }, user: { id: "u" } }); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/core/__tests__/grants/callback-start.test.ts b/src/core/__tests__/grants/callback-start.test.ts new file mode 100644 index 0000000..c217068 --- /dev/null +++ b/src/core/__tests__/grants/callback-start.test.ts @@ -0,0 +1,133 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CALLBACK_ENDPOINT, + callbackStartOf, + composeBeforeHooks, + recordCallbackStart, +} from "../../grants/callback-start.js"; +import { defined, TEST_CONFIG } from "../helpers/config.js"; +import { FakeKeycloak, loginThrough } from "../helpers/fake-keycloak.js"; +import { createMemoryAuthSetup } from "../helpers/memory-store.js"; + +type Hook = NonNullable["before"]>; + +/** Runs a before hook the way Better Auth's dispatcher does. */ +const run = async (hook: Hook, path: string, context: object = {}) => + hook({ path, context, headers: new Headers(), returnHeaders: true } as never) as Promise<{ + headers: Headers; + response: unknown; + }>; + +describe("recordCallbackStart", () => { + afterEach(() => vi.restoreAllMocks()); + + it("should record a start on the callback and answer nothing", async () => { + const context = {}; + const before = performance.now(); + const { headers, response } = await run(recordCallbackStart(), CALLBACK_ENDPOINT, context); + + expect(response).toBeUndefined(); + expect([...headers.keys()]).toEqual([]); + expect(callbackStartOf(context)).toBeGreaterThanOrEqual(before); + expect(callbackStartOf(context)).toBeLessThanOrEqual(performance.now()); + }); + + // Every route Better Auth serves goes through the hook; only the callback + // is touched, and none is answered. + it("should pass every other route through unchanged", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + const auth = betterAuth(createMemoryAuthSetup(TEST_CONFIG).options); + const paths = Object.values(auth.api) + .map((endpoint) => Reflect.get(endpoint, "path") as unknown) + .filter((path): path is string => typeof path === "string"); + + expect(paths).toEqual(expect.arrayContaining(["/get-session", "/sign-out", CALLBACK_ENDPOINT])); + for (const path of paths) { + const context = {}; + const { headers, response } = await run(recordCallbackStart(), path, context); + expect(response).toBeUndefined(); + expect([...headers.keys()]).toEqual([]); + expect(callbackStartOf(context) !== undefined).toBe(path === CALLBACK_ENDPOINT); + } + }); + + // The anchor works only because the before hook and the database hooks of + // one request share the auth context object (Better Auth 1.7.7). + it("should be found by the same callback's database hooks", async () => { + const keycloak = new FakeKeycloak(); + vi.spyOn(globalThis, "fetch").mockImplementation(keycloak.fetch); + const { options } = createMemoryAuthSetup(TEST_CONFIG); + const seen: (number | undefined)[] = []; + const databaseHooks = defined(options.databaseHooks, "the database hooks"); + const sessionHooks = defined(databaseHooks.session, "the session hooks"); + const create = defined(sessionHooks.create, "the session create hooks"); + const after = defined(create.after, "the session create after hook"); + const auth = betterAuth({ + ...options, + databaseHooks: { + ...databaseHooks, + session: { + ...sessionHooks, + create: { + ...create, + after: async (session, context) => { + seen.push(callbackStartOf(context?.context)); + return after(session, context); + }, + }, + }, + }, + }); + + const before = performance.now(); + const res = await loginThrough(auth.handler, keycloak); + + expect(res.location).toBe("/"); + expect(seen).toHaveLength(1); + expect(seen[0]).toBeGreaterThanOrEqual(before); + }); +}); + +const answering = (body: unknown, cookie?: string) => + createAuthMiddleware(async (ctx) => { + if (cookie) { + ctx.setCookie(cookie, "v"); + } + ctx.setHeader("x-hook", cookie ?? "none"); + return Promise.resolve(body); + }); + +describe("composeBeforeHooks", () => { + it("should run every hook in order and answer with nothing when none answers", async () => { + const calls: string[] = []; + const quiet = (name: string) => + createAuthMiddleware(async () => { + calls.push(name); + return Promise.resolve(); + }); + const { response } = await run(composeBeforeHooks([quiet("a"), quiet("b")]), "/x"); + + expect(calls).toEqual(["a", "b"]); + expect(response).toBeUndefined(); + }); + + it("should answer with the first answer and keep the headers set so far", async () => { + const later = vi.fn<() => Promise>(async () => "never"); + const { headers, response } = await run( + composeBeforeHooks([ + answering(undefined, "first"), + answering({ ok: 1 }, "second"), + createAuthMiddleware(later), + ]), + "/x", + ); + + expect(response).toEqual({ ok: 1 }); + expect(later).not.toHaveBeenCalled(); + expect(headers.getSetCookie().map((c) => c.split("=")[0])).toEqual(["first", "second"]); + expect(headers.get("x-hook")).toBe("second"); + }); +}); diff --git a/src/core/__tests__/grants/grant-hooks.test.ts b/src/core/__tests__/grants/grant-hooks.test.ts new file mode 100644 index 0000000..6f448c5 --- /dev/null +++ b/src/core/__tests__/grants/grant-hooks.test.ts @@ -0,0 +1,450 @@ +import { decryptOAuthToken } from "better-auth/oauth2"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ID_TOKEN_FORBIDDEN, ROUTE_NOT_EXPOSED } from "../../exposure.js"; +import { + GRANT_ID_TOKEN_MISSING, + GRANT_NOT_CREATED, + GRANT_REFRESH_EXPIRY_MISSING, + GRANT_SID_MISSING, + GRANT_UNSUPPORTED_LOGIN, +} from "../../grants/grant-errors.js"; +import { idTokenClaims, TOKEN_FIELDS } from "../../grants/grant-hooks.js"; +import type { LifetimeAnchor, NewGrant } from "../../grants/grant-store.js"; +import { + BASE, + buildAuth, + sessionCookieName, + setsSession, + type AuthInstance, + type BuildOptions, +} from "../helpers/auth-instance.js"; +import { defined, records, testLogger, type TestLogger } from "../helpers/config.js"; +import { ISSUER, SUBJECT, type LoginResult } from "../helpers/fake-keycloak.js"; +import { MemoryGrantStore, type MemoryTables } from "../helpers/memory-store.js"; + +/** Better Auth's versioned envelope under secret version 1, the newest test secret. */ +const ENVELOPE = /^\$ba\$1\$[0-9a-f]+$/; + +const errorOf = ({ location }: LoginResult) => { + const url = new URL(location ?? "", BASE); + return { path: url.pathname, error: url.searchParams.get("error") }; +}; + +const expectStripped = (tables: MemoryTables) => { + expect(tables.account.length).toBeGreaterThan(0); + for (const account of tables.account) { + for (const field of TOKEN_FIELDS) { + expect(account[field] ?? null).toBeNull(); + } + } +}; + +const signIn = async (setup: AuthInstance) => + setup.post("sign-in/social", "", { + provider: "keycloak", + idToken: { + token: setup.keycloak.idToken(99), + accessToken: "client-access", + refreshToken: "client-refresh", + }, + }); + +const callEndpoints = async (setup: AuthInstance) => { + const { cookie } = await setup.login(); + const body = { accountId: defined(setup.tables.account[0]).id }; + const access = await setup.post("get-access-token", cookie, body); + const refresh = await setup.post("refresh-token", cookie, body); + return { + access: { status: access.status, text: await access.text() }, + refresh: { status: refresh.status, text: await refresh.text() }, + }; +}; + +const jwt = (payload: unknown) => + `e30.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.sig`; + +/** + * The grant hooks through a real instance's handler on the memory store, + * with Keycloak faked at `fetch`. The memory adapter has no foreign keys, so + * the cascade on sign-out is proved by Interlock's Postgres grant suite. + */ +describe("session grant hooks", () => { + let logger: TestLogger; + let error: TestLogger["error"]; + + beforeEach(() => { + logger = testLogger(); + ({ error } = logger); + }); + + afterEach(() => vi.restoreAllMocks()); + + const build = (options: BuildOptions = {}) => buildAuth({ logger, ...options }); + + describe.each([ + ["without discovery", false], + ["with discovery, verifying the ID token", true], + ])("a callback login %s", (_mode, discovery) => { + it("should write one grant with the encrypted tokens and strip the account row", async () => { + const { auth, keycloak, tables, grants, login } = build({ discovery }); + const res = await login(); + const ctx = await auth.$context; + + // The mode really is what it says: discovery set the provider's issuer. + expect(ctx.socialProviders.find((p) => p.id === "keycloak")?.issuer).toBe( + discovery ? ISSUER : undefined, + ); + expect(res.status).toBe(302); + expect(res.location).toBe("/"); + expect(setsSession(res.setCookies, await sessionCookieName(auth))).toBe(true); + expect(tables.session).toHaveLength(1); + expect(grants.rows).toHaveLength(1); + + const grant = defined(grants.rows[0], "the grant"); + const idToken = defined(keycloak.idTokens[0], "the ID token"); + expect(grant).toMatchObject({ + sessionId: defined(tables.session[0]).id, + keycloakSid: "sid-1", + keycloakSubject: SUBJECT, + leaseHolder: null, + dispatched: false, + version: 1, + }); + expect(grant.accessTokenExpiresAt).toBeInstanceOf(Date); + expect(grant.refreshTokenExpiresAt).toBeInstanceOf(Date); + // One deadline on both rows, the grant's, in the store's time. + expect(defined(tables.session[0]).expiresAt).toEqual(grant.refreshTokenExpiresAt); + // Prefix and inequality first: decryptOAuthToken returns a string that + // is not encrypted unchanged, so decrypting alone would pass on plaintext. + for (const [stored, plain] of [ + [grant.accessToken, "access-1"], + [grant.refreshToken, "refresh-1"], + [grant.idToken, idToken], + ] as const) { + expect(stored).toMatch(ENVELOPE); + expect(stored).not.toBe(plain); + await expect(decryptOAuthToken(stored, ctx)).resolves.toBe(plain); + } + + expectStripped(tables); + expect(tables.account).toHaveLength(1); + expect(tables.account[0]).toMatchObject({ + providerId: "keycloak", + accountId: SUBJECT, + scope: expect.any(String) as string, + }); + }); + }); + + it("should write a second grant with its own tokens when the same user logs in again", async () => { + const { auth, tables, grants, login } = build(); + const first = await login(); + const second = await login(); + const ctx = await auth.$context; + + expect([first.status, second.status]).toEqual([302, 302]); + expect(first.cookie).not.toBe(second.cookie); + expect(tables.session).toHaveLength(2); + expect(grants.rows.map((g) => g.sessionId).toSorted()).toEqual( + tables.session.map((s) => String(s.id)).toSorted(), + ); + expect(grants.rows.map((g) => g.keycloakSid)).toEqual(["sid-1", "sid-2"]); + await expect( + Promise.all(grants.rows.map(async (g) => decryptOAuthToken(g.refreshToken, ctx))), + ).resolves.toEqual(["refresh-1", "refresh-2"]); + // The repeat login went through the account update path. + expect(tables.account).toHaveLength(1); + expectStripped(tables); + }); + + it.each([ + ["an ID token without sid", { omitSid: true }, GRANT_SID_MISSING], + ["a token response without an ID token", { omitIdToken: true }, GRANT_ID_TOKEN_MISSING], + ])("should refuse the login on %s", async (_case, next, code) => { + const { auth, keycloak, tables, grants, login } = build(); + keycloak.next = next; + const res = await login(); + + expect(res.status).toBe(302); + expect(errorOf(res)).toEqual({ path: "/api/auth/error", error: code }); + expect(setsSession(res.setCookies, await sessionCookieName(auth))).toBe(false); + expect(tables.session).toEqual([]); + expect(grants.rows).toEqual([]); + expectStripped(tables); + }); + + it("should refuse the login, delete the session and log no token when the grant insert fails", async () => { + const attempted: NewGrant[] = []; + const failing = new MemoryGrantStore(); + failing.insert = async (grant: NewGrant, _anchor: LifetimeAnchor) => { + attempted.push(grant); + // A database error's detail can quote the failing row. + throw Object.assign(new Error("insert failed"), { + code: "23502", + detail: `Failing row contains (${grant.accessToken}, ${grant.refreshToken}, access-1)`, + }); + }; + const { auth, keycloak, tables, login } = build({ grants: failing }); + const res = await login(); + + expect(errorOf(res)).toEqual({ path: "/api/auth/error", error: GRANT_NOT_CREATED }); + expect(setsSession(res.setCookies, await sessionCookieName(auth))).toBe(false); + expect(tables.session).toEqual([]); + expect(attempted).toHaveLength(1); + expect(error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not store the session grant") as string, + }), + ); + const logged = JSON.stringify(records(error)); + const tried = defined(attempted[0], "the attempted grant"); + for (const value of [ + "access-1", + "refresh-1", + defined(keycloak.idTokens[0], "the ID token"), + tried.accessToken, + tried.refreshToken, + tried.idToken, + ]) { + expect(logged).not.toContain(value); + } + }); + + // The deadlines start before the code exchange leaves the process, so a + // slow exchange or userinfo response can only shorten them, never extend + // them; the store writes them in its own (database) time. + describe("the login deadlines", () => { + it.each([ + ["a slow code exchange", "exchangeDelayMs"], + ["a slow userinfo response", "userinfoDelayMs"], + ] as const)( + "should be anchored at the callback start despite %s", + async (_case, delay) => { + const { keycloak, tables, grants, login } = build(); + keycloak[delay] = 2000; + const before = grants.now().getTime(); + const res = await login(); + const grant = defined(grants.rows[0], "the grant"); + + expect(res.status).toBe(302); + expect(res.location).toBe("/"); + const accessFrom = defined(grant.accessTokenExpiresAt).getTime() - 300_000 - before; + const refreshFrom = defined(grant.refreshTokenExpiresAt).getTime() - 1_800_000 - before; + for (const offset of [accessFrom, refreshFrom]) { + expect(offset).toBeGreaterThanOrEqual(-1); + expect(offset).toBeLessThan(1000); + } + expect(defined(tables.session[0]).expiresAt).toEqual(grant.refreshTokenExpiresAt); + }, + 10000, + ); + + it.each([ + ["no refresh_expires_in", { refresh_expires_in: undefined }], + ["a refresh_expires_in of 0 (an offline token or unlimited SSO)", { refresh_expires_in: 0 }], + ["no expires_in", { expires_in: undefined }], + ])("should refuse a login whose token response has %s", async (_case, fields) => { + const { auth, keycloak, tables, grants, login } = build(); + keycloak.next = { fields }; + const res = await login(); + + expect(res.status).toBe(302); + expect(errorOf(res)).toEqual({ + path: "/api/auth/error", + error: GRANT_REFRESH_EXPIRY_MISSING, + }); + expect(setsSession(res.setCookies, await sessionCookieName(auth))).toBe(false); + expect(tables.session).toEqual([]); + expect(grants.rows).toEqual([]); + expectStripped(tables); + const refusals = records(error).filter((record) => + record.message.includes("refresh_expires_in"), + ); + expect(refusals).toHaveLength(1); + const logged = JSON.stringify(records(error)); + for (const value of ["access-1", "refresh-1", defined(keycloak.idTokens[0])]) { + expect(logged).not.toContain(value); + } + }); + + it("should issue a session for such a response without the hooks (control)", async () => { + const { auth, keycloak, tables, login } = build({ hooks: false }); + keycloak.next = { fields: { refresh_expires_in: undefined } }; + const res = await login(); + + expect(res.location).toBe("/"); + expect(setsSession(res.setCookies, await sessionCookieName(auth))).toBe(true); + expect(tables.session).toHaveLength(1); + }); + + // The slide is off in Interlock; turned back on, the update hook still + // keeps the session inside the realm's deadline. + it("should clamp Better Auth's slide to the grant's refresh deadline", async () => { + const { auth, tables, grants, login } = build({ slide: true }); + const { cookie } = await login(); + const grant = defined(grants.rows[0], "the grant"); + const res = await auth.handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } }), + ); + + // The slide ran: Better Auth reissued the cookie for seven days. + expect(res.status).toBe(200); + expect(setsSession(res.headers.getSetCookie(), await sessionCookieName(auth))).toBe(true); + expect(defined(tables.session[0]).expiresAt).toEqual(grant.refreshTokenExpiresAt); + }); + }); + + it("should strip tokens from an account update outside the callback and write no grant", async () => { + const { auth, tables, grants, login } = build(); + await login(); + const ctx = await auth.$context; + + await ctx.internalAdapter.updateAccount(String(defined(tables.account[0]).id), { + accessToken: "outside-access", + refreshToken: "outside-refresh", + idToken: "outside-id", + }); + + expectStripped(tables); + expect(grants.rows).toHaveLength(1); + }); + + // The ID-token sign-in path exists once discovery has succeeded. Its tokens + // are the client's, so they never become a grant, and a session without a + // grant is never issued. The exposure hook refuses the body first; the + // session hook is the inner layer behind it. + describe("an ID-token sign-in", () => { + it("should be refused by the exposure hook before the session hook", async () => { + const setup = build({ discovery: true }); + const res = await signIn(setup); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: ID_TOKEN_FORBIDDEN }); + expect(setup.tables.session).toEqual([]); + expect(setup.grants.rows).toEqual([]); + }); + + it("should meet the session hook without the exposure hook, leaving no session or grant", async () => { + const setup = build({ discovery: true, exposure: false }); + const res = await signIn(setup); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: GRANT_UNSUPPORTED_LOGIN }); + expect(setsSession(res.headers.getSetCookie(), await sessionCookieName(setup.auth))).toBe( + false, + ); + expect(setup.tables.session).toEqual([]); + expect(setup.grants.rows).toEqual([]); + for (const account of setup.tables.account) { + for (const field of TOKEN_FIELDS) { + expect(account[field] ?? null).toBeNull(); + } + } + }); + + it("should issue a session without the hooks (control)", async () => { + const setup = build({ discovery: true, hooks: false }); + const res = await signIn(setup); + + expect(res.status).toBe(200); + expect(setsSession(res.headers.getSetCookie(), await sessionCookieName(setup.auth))).toBe( + true, + ); + expect(setup.tables.session).toHaveLength(1); + }); + }); + + it("should return no provider token from get-session", async () => { + const { auth, login } = build(); + const { cookie } = await login(); + const res = await auth.handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } }), + ); + const text = await res.text(); + const body = JSON.parse(text) as { session: { token: string }; user: { id: string } }; + + expect(res.status).toBe(200); + expect(body.session.token).toEqual(expect.any(String)); + expect(body.user.id).toEqual(expect.any(String)); + for (const key of ["accessToken", "refreshToken", "idToken"]) { + expect(text).not.toContain(`"${key}"`); + } + }); + + // Better Auth's own token endpoints read the account row. The exposure hook + // blocks them; behind it, the stripped row leaves them nothing to return + // or refresh with. + describe("Better Auth's token endpoints", () => { + it("should be blocked by the exposure hook", async () => { + const setup = build(); + const { access, refresh } = await callEndpoints(setup); + + for (const res of [access, refresh]) { + expect(res.status).toBe(404); + expect(JSON.parse(res.text)).toMatchObject({ code: ROUTE_NOT_EXPOSED }); + } + expect(setup.keycloak.refreshes).toBe(0); + }); + + it("should have no token to return or refresh without the exposure hook", async () => { + const setup = build({ exposure: false }); + const { access, refresh } = await callEndpoints(setup); + + expect(refresh.status).toBe(400); + expect(JSON.parse(refresh.text)).toMatchObject({ code: "REFRESH_TOKEN_NOT_FOUND" }); + expect(access.status).toBe(200); + expect(JSON.parse(access.text)).toMatchObject({ accessToken: "" }); + for (const value of ["access-1", "refresh-1", setup.keycloak.idTokens[0]]) { + expect(access.text).not.toContain(value); + expect(refresh.text).not.toContain(value); + } + expect(setup.keycloak.codeExchanges).toBe(1); + expect(setup.keycloak.refreshes).toBe(0); + }); + + it("should return and refresh the tokens without the hooks (control)", async () => { + const setup = build({ hooks: false }); + const { access, refresh } = await callEndpoints(setup); + + expect(access.status).toBe(200); + expect(JSON.parse(access.text)).toMatchObject({ accessToken: "access-1" }); + expect(refresh.status).toBe(200); + expect(JSON.parse(refresh.text)).toMatchObject({ accessToken: "access-refreshed-1" }); + expect(setup.keycloak.refreshedWith).toEqual(["refresh-1"]); + }); + }); + + // Better Auth would build a browser end-session URL from the account row; + // its provider logout is off, and sign-out ends the Keycloak session from + // the server (logout/sign-out.spec.ts), with or without the hooks. + describe("sign-out", () => { + it.each([true, false])( + "should hand the browser no end-session URL (hooks: %s)", + async (hooks) => { + const setup = build({ discovery: true, hooks }); + const { cookie } = await setup.login(); + const res = await setup.post("sign-out", cookie, {}); + + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + expect(await res.json()).not.toHaveProperty("url"); + }, + ); + }); + + describe("idTokenClaims", () => { + it("should read sid and sub", () => { + expect(idTokenClaims(jwt({ sid: "s", sub: "u", exp: 1 }))).toEqual({ sid: "s", sub: "u" }); + }); + + it.each([ + ["a blank claim", jwt({ sid: " ", sub: "u" }), { sid: undefined, sub: "u" }], + ["a non-string claim", jwt({ sid: 1, sub: "u" }), { sid: undefined, sub: "u" }], + ["a payload that is not an object", jwt("x"), {}], + ["a token that is not a JWT", "opaque", {}], + ])("should drop %s", (_case, token, claims) => { + expect(idTokenClaims(token)).toEqual(claims); + }); + }); +}); diff --git a/src/core/__tests__/grants/refresh.test.ts b/src/core/__tests__/grants/refresh.test.ts new file mode 100644 index 0000000..7024577 --- /dev/null +++ b/src/core/__tests__/grants/refresh.test.ts @@ -0,0 +1,1279 @@ +import { performance } from "node:perf_hooks"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { betterAuth } from "better-auth"; +import { decryptOAuthToken } from "better-auth/oauth2"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RefreshConfig } from "../../config.js"; +import { encryptToken } from "../../encrypt-token.js"; +import { + AmbiguousCommitError, + CompletionBudgetError, + type CompleteInput, + type StoredGrant, +} from "../../grants/grant-store.js"; +import { + DEPENDENCY_FAILURE, + refreshResponse, + SESSION_INVALID, +} from "../../grants/refresh-responses.js"; +import { + createGrantRefresher, + MAX_ROUNDS, + type AuthContext, + type RefreshOutcome, +} from "../../grants/refresh.js"; +import type { LogRecord } from "../../logger.js"; +import { defined, TEST_CONFIG, testLogger, type TestLogger } from "../helpers/config.js"; +import { FakeKeycloak, fakeTransport, SUBJECT } from "../helpers/fake-keycloak.js"; +import { createMemoryAuthOptions, MemoryGrantStore } from "../helpers/memory-store.js"; + +/** Short values that still meet the configuration's bounds. */ +const SETTINGS: RefreshConfig = { + requestTimeoutMs: 200, + leaseMs: 8200, + waitMs: 400, + leewayMs: 60000, +}; + +/** A refresh event's record (`refresh-events.ts`). */ +type Event = LogRecord & { event: string; sessionId: string; leaseHolder: string | null }; + +/** The terminal events of a dispatched attempt (refresh-events.ts). */ +const TERMINAL = new Set(["refresh.succeeded", "refresh.invalid_grant", "refresh.indeterminate"]); + +const lease = ( + store: MemoryGrantStore, + msFromNow: number, + holder = "dead#1#x", + dispatched = false, +) => ({ + leaseHolder: holder, + leaseExpiresAt: new Date(store.now().getTime() + msFromNow), + dispatched, +}); + +const grantOf = (store: MemoryGrantStore, id = "s1") => store.rows.find((r) => r.sessionId === id); + +// Move both the monotonic clock and MemoryGrantStore's clock without +// sleeping, so expiry during an awaited operation is deterministic. +const clock = () => { + const now = performance.now.bind(performance); + let elapsed = 0; + vi.spyOn(performance, "now").mockImplementation(() => now() + elapsed); + return (ms: number) => { + elapsed += ms; + }; +}; + +const expiredOnArrival = (keycloak: FakeKeycloak) => { + keycloak.onRefresh = () => { + keycloak.nextRefresh = { delayMs: 1100, fields: { expires_in: 1 } }; + }; + keycloak.nextRefresh = { delayMs: 1100, fields: { expires_in: 1 } }; +}; + +/** `complete` that runs `effect` and then reports a lost COMMIT acknowledgement. */ +const ambiguous = (store: MemoryGrantStore, effect: (input: CompleteInput) => Promise) => { + store.complete = async (input) => { + await effect(input); + throw new AmbiguousCommitError(new Error("Connection terminated")); + }; +}; + +/** + * The state machine of section D on `MemoryGrantStore`, whose clock is its + * own, with Keycloak faked behind the two-phase transport. Every timestamp + * a test sets is the store's time. + */ +describe("GrantRefresher", () => { + let ctx: AuthContext; + let logger: TestLogger; + + beforeAll(async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + ctx = await betterAuth(createMemoryAuthOptions(TEST_CONFIG)).$context; + vi.restoreAllMocks(); + }); + + beforeEach(() => { + logger = testLogger(); + }); + + afterEach(() => vi.restoreAllMocks()); + + /** Every refresh event, in the order it was logged, whatever the level. */ + const events = (): Event[] => + [logger.log, logger.error] + .flatMap((level) => + level.mock.calls.map(([record], i) => ({ + record, + order: level.mock.invocationCallOrder[i] ?? 0, + })), + ) + .toSorted((a, b) => a.order - b.order) + .map(({ record }) => record) + .filter((record): record is Event => typeof record.event === "string"); + + const names = () => events().map((e) => e.event); + + const setup = (settings: Partial = {}) => { + const store = new MemoryGrantStore(); + const keycloak = new FakeKeycloak(); + const make = () => + createGrantRefresher( + store, + { keycloak: TEST_CONFIG.keycloak, refresh: { ...SETTINGS, ...settings } }, + { logger, transport: fakeTransport(keycloak) }, + ); + return { store, keycloak, refresher: make(), make }; + }; + + /** + * A session with a grant, timed in ms from the store's now: by default a + * fresh access token (300 s of 300 s left) and 1800 s of realm deadline. + */ + const seed = async ( + store: MemoryGrantStore, + { + id = "s1", + accessMs = 300_000, + refreshMs = 1_800_000 as number | null, + issuedMs = 0, + ...row + }: Partial & { + id?: string; + accessMs?: number; + refreshMs?: number | null; + issuedMs?: number; + } = {}, + ) => { + const now = store.now().getTime(); + store.addSession(id); + store.rows.push({ + sessionId: id, + keycloakSid: "sid-1", + keycloakSubject: SUBJECT, + accessToken: await encryptToken("access-0", ctx), + refreshToken: await encryptToken("refresh-0", ctx), + idToken: await encryptToken("id-0", ctx), + accessTokenExpiresAt: new Date(now + accessMs), + refreshTokenExpiresAt: refreshMs === null ? null : new Date(now + refreshMs), + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: 1, + createdAt: new Date(now - issuedMs), + updatedAt: new Date(now - issuedMs), + ...row, + }); + return id; + }; + + /** Due: 30 s left of a 300 s access token, inside the 60 s leeway. */ + const DUE = { accessMs: 30_000, issuedMs: 270_000 }; + + /** `grantOf`, for a grant the case knows is there. */ + const rowOf = (store: MemoryGrantStore, id = "s1") => defined(grantOf(store, id), `grant ${id}`); + const decrypt = async (value: string) => decryptOAuthToken(value, ctx); + + /** Exactly one terminal event per dispatched attempt, by `leaseHolder`. */ + const expectOneTerminalPerDispatch = () => { + for (const dispatched of events().filter((e) => e.event === "refresh.dispatched")) { + const terminals = events().filter( + (e) => TERMINAL.has(e.event) && e.leaseHolder === dispatched.leaseHolder, + ); + expect(terminals).toHaveLength(1); + } + }; + + describe("a grant that is not due", () => { + it("should answer ok without calling Keycloak", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store); + + const outcome = await refresher.refreshIfDue("s1", ctx); + + expect(outcome).toMatchObject({ kind: "ok", refreshed: false }); + expect(keycloak.refreshes).toBe(0); + expect(names()).toEqual([]); + }); + + it("should hand back the decrypted access token with its remaining life", async () => { + const { store, refresher } = setup(); + await seed(store); + + const outcome = await refresher.resolveAccessToken("s1", ctx); + + expect(outcome).toMatchObject({ kind: "ok", accessToken: "access-0", refreshed: false }); + expect(outcome.kind === "ok" && outcome.expiresInMs).toBeGreaterThan(299_000); + }); + }); + + describe("a due grant", () => { + it("should refresh once, store the new tokens and move the session to the new deadline", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + // The mark is in place before the request leaves. + let markedWhenSent: boolean | undefined; + keycloak.onRefresh = () => { + markedWhenSent = rowOf(store).dispatched; + }; + const before = store.now().getTime(); + + const outcome = await refresher.refreshIfDue("s1", ctx); + const grant = rowOf(store); + + expect(outcome).toMatchObject({ kind: "ok", refreshed: true }); + expect(keycloak.refreshedWith).toEqual(["refresh-0"]); + expect(markedWhenSent).toBe(true); + await expect(decrypt(grant.accessToken)).resolves.toBe("access-refreshed-1"); + await expect(decrypt(grant.refreshToken)).resolves.toBe("refresh-refreshed-1"); + await expect(decrypt(grant.idToken)).resolves.toBe( + keycloak.idTokens[keycloak.idTokens.length - 1], + ); + expect(grant).toMatchObject({ leaseHolder: null, leaseExpiresAt: null, dispatched: false }); + // claim, mark, complete + expect(grant.version).toBe(4); + expect(defined(grant.refreshTokenExpiresAt).getTime() - before).toBeGreaterThan(1_799_000); + expect(defined(grant.refreshTokenExpiresAt).getTime() - before).toBeLessThanOrEqual( + 1_800_000 + 50, + ); + expect(defined(store.session("s1")).expiresAt).toEqual(grant.refreshTokenExpiresAt); + expect(names()).toEqual(["refresh.dispatched", "refresh.succeeded"]); + const [dispatched, succeeded] = events(); + expect(succeeded).toMatchObject({ + sessionId: "s1", + leaseHolder: defined(dispatched).leaseHolder, + refreshExpiresAt: defined(grant.refreshTokenExpiresAt).toISOString(), + accessExpiresAt: defined(grant.accessTokenExpiresAt).toISOString(), + }); + expect(defined(dispatched).leaseHolder).toMatch(/^.+#\d+#[0-9a-f-]{36}$/); + + // A second call has nothing to do. + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: false }); + expect(keycloak.refreshes).toBe(1); + }); + + it("should refresh a 2.4 grant that has no refresh deadline before calling it usable", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, { refreshMs: null }); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(keycloak.refreshes).toBe(1); + expect(rowOf(store).refreshTokenExpiresAt).toBeInstanceOf(Date); + }); + + it("should refresh when the realm deadline is inside the leeway though the access token is fresh", async () => { + const { store, keycloak, refresher } = setup(); + // 30 s left of an 1800 s realm deadline; 300 s of access left. + await seed(store, { refreshMs: 30_000, issuedMs: 1_770_000 }); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(keycloak.refreshes).toBe(1); + }); + + // The cap: a leeway at or above the token's lifetime would make every + // fresh token due, and the waiter would refresh again. + it("should make one request for two callers when the leeway exceeds the token lifetime", async () => { + const { store, keycloak, make } = setup({ leewayMs: 300_000 }); + await seed(store, DUE); + keycloak.nextRefresh = { delayMs: 50 }; + + const outcomes = await Promise.all([ + make().refreshIfDue("s1", ctx), + make().refreshIfDue("s1", ctx), + ]); + + expect(outcomes.map((o) => o.kind)).toEqual(["ok", "ok"]); + expect(keycloak.refreshes).toBe(1); + expect(names().filter((n) => n === "refresh.waited")).toEqual(["refresh.waited"]); + expect(events().find((e) => e.event === "refresh.waited")).toMatchObject({ result: "ok" }); + }); + }); + + describe("the session deadline", () => { + it("should end a session whose stored refresh deadline has passed, without Keycloak", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, { refreshMs: -1 }); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_expired", + }); + expect(store.session("s1")).toBeUndefined(); + expect(grantOf(store)).toBeUndefined(); + expect(keycloak.refreshes).toBe(0); + }); + + it("should end a session with no grant", async () => { + const { store, refresher } = setup(); + store.addSession("bare"); + + await expect(refresher.refreshIfDue("bare", ctx)).resolves.toEqual({ + kind: "ended", + reason: "no_grant", + }); + expect(store.session("bare")).toBeUndefined(); + }); + + // The version predicate: a stale deadline never deletes a renewed session. + it("should not end a session a holder renewed between the read and the delete", async () => { + const { store, refresher } = setup(); + await seed(store, { refreshMs: -1 }); + const endIf = store.endIf.bind(store); + store.endIf = async (sessionId, predicate) => { + const row = rowOf(store, sessionId); + // The holder's completion commits first. + Object.assign(row, { + version: row.version + 3, + refreshTokenExpiresAt: new Date(store.now().getTime() + 1_800_000), + accessTokenExpiresAt: new Date(store.now().getTime() + 300_000), + updatedAt: store.now(), + }); + store.endIf = endIf; + return endIf(sessionId, predicate); + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ + kind: "ok", + refreshed: false, + }); + expect(store.session("s1")).toBeDefined(); + }); + + // A holder that dispatched just before the deadline may be extending the + // session at Keycloak; the caller arriving after it waits. + it("should wait for a holder dispatched just before the deadline, not end the session", async () => { + const { store, keycloak, make } = setup({ + requestTimeoutMs: 1000, + leaseMs: 9000, + waitMs: 1000, + }); + await seed(store, { refreshMs: 150, issuedMs: 1_799_850 }); + keycloak.nextRefresh = { delayMs: 400 }; + + const holder = make().refreshIfDue("s1", ctx); + await sleep(100); + expect(rowOf(store).dispatched).toBe(true); + await sleep(100); + // The stored deadline has passed; the lease is live. + expect(defined(rowOf(store).refreshTokenExpiresAt).getTime()).toBeLessThan( + store.now().getTime(), + ); + const late = await make().refreshIfDue("s1", ctx); + + expect(await holder).toMatchObject({ kind: "ok", refreshed: true }); + expect(late).toMatchObject({ kind: "ok", refreshed: false }); + expect(store.session("s1")).toBeDefined(); + expect(keycloak.refreshes).toBe(1); + }); + }); + + describe("Keycloak answers", () => { + it("should end the session on invalid_grant", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = { invalidGrant: true }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "invalid_grant", + }); + expect(store.session("s1")).toBeUndefined(); + expect(names()).toEqual(["refresh.dispatched", "refresh.invalid_grant"]); + }); + + it("should release the lease on a request that was never sent", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = { connectFail: "ECONNREFUSED" }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "keycloak_unreachable", + }); + const grant = rowOf(store); + expect(grant).toMatchObject({ leaseHolder: null, dispatched: false, version: 4 }); + await expect(decrypt(grant.refreshToken)).resolves.toBe("refresh-0"); + expect(keycloak.refreshes).toBe(0); + expect(names()).toEqual(["refresh.unsent"]); + expect(events()[0]).toMatchObject({ cause: "ECONNREFUSED" }); + + // The stored token is unused, so the next caller refreshes normally. + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(keycloak.refreshedWith).toEqual(["refresh-0"]); + }); + + it("should be unsent when the deadline passes while connecting", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = { connectDelayMs: 500 }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ + reason: "keycloak_unreachable", + }); + expect(names()).toEqual(["refresh.unsent"]); + }); + + it.each([ + ["a lost answer", { sentFail: "ECONNRESET" }, "ECONNRESET"], + ["the deadline after sending", { hang: true }, "timeout"], + ["a 5xx", { status: 502 }, "502"], + ["invalid_client", { status: 401, error: "invalid_client" }, "401"], + [ + "a 200 without refresh_expires_in", + { fields: { refresh_expires_in: undefined } }, + "malformed_response", + ], + ])("should end the session without replay on %s", async (_case, next, cause) => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = next; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_indeterminate", + }); + expect(store.session("s1")).toBeUndefined(); + expect(keycloak.refreshes).toBe(1); + expect(names()).toEqual(["refresh.dispatched", "refresh.indeterminate"]); + expect(events()[1]).toMatchObject({ by: "holder", cause }); + }); + }); + + describe("concurrent callers", () => { + it("should make one request for two callers; the loser waits and uses the result", async () => { + const { store, keycloak, make } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = { delayMs: 100 }; + + const outcomes = await Promise.all([ + make().refreshIfDue("s1", ctx), + make().refreshIfDue("s1", ctx), + ]); + + expect(outcomes.map((o) => o.kind === "ok" && o.refreshed)).toEqual( + expect.arrayContaining([false, true]), + ); + expect(keycloak.refreshes).toBe(1); + expect(names().toSorted()).toEqual([ + "refresh.dispatched", + "refresh.succeeded", + "refresh.waited", + ]); + }); + + it("should give up waiting on a live lease after the wait bound, leaving the session intact", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, { ...DUE, ...lease(store, 8000, "live#1#x", true) }); + const start = performance.now(); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "refresh_in_progress", + }); + const took = performance.now() - start; + expect(took).toBeGreaterThanOrEqual(SETTINGS.waitMs - 5); + expect(took).toBeLessThan(SETTINGS.waitMs + 300); + expect(store.session("s1")).toBeDefined(); + expect(keycloak.refreshes).toBe(0); + expect(events()).toEqual([ + expect.objectContaining({ + event: "refresh.waited", + result: "timed_out", + leaseHolder: "live#1#x", + }), + ]); + }); + + // A waiter that arrives late in a lease is never held past the lease's end. + it("should leave the wait when the lease of a holder that died expires, and recover it", async () => { + const { store, keycloak, refresher } = setup({ waitMs: 2000 }); + await seed(store, { ...DUE, ...lease(store, 150) }); + const start = performance.now(); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(performance.now() - start).toBeLessThan(1000); + expect(keycloak.refreshes).toBe(1); + expect(names()).toEqual([ + "refresh.waited", + "refresh.lease_recovered", + "refresh.dispatched", + "refresh.succeeded", + ]); + expect(events()[1]).toMatchObject({ recoveredFrom: "dead#1#x" }); + }); + + it("should record one recovery when two callers read one expired, unmarked lease", async () => { + const { store, keycloak, make } = setup(); + await seed(store, { ...DUE, ...lease(store, -1) }); + keycloak.nextRefresh = { delayMs: 50 }; + + const outcomes = await Promise.all([ + make().refreshIfDue("s1", ctx), + make().refreshIfDue("s1", ctx), + ]); + + expect(outcomes.map((o) => o.kind)).toEqual(["ok", "ok"]); + expect(keycloak.refreshes).toBe(1); + expect(names().filter((n) => n === "refresh.lease_recovered")).toHaveLength(1); + }); + + // README step 6, by a successor: the dead holder's attempt ends with the reason. + it("should end a session whose dispatched lease expired, without replay", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, { ...DUE, ...lease(store, -1, "dead#1#x", true) }); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_indeterminate", + }); + expect(store.session("s1")).toBeUndefined(); + expect(keycloak.refreshes).toBe(0); + expect(events()).toEqual([ + expect.objectContaining({ + event: "refresh.indeterminate", + by: "successor", + leaseHolder: "dead#1#x", + }), + ]); + }); + + it("should give the reason to exactly one of two successors", async () => { + const { store, make } = setup(); + await seed(store, { ...DUE, ...lease(store, -1, "dead#1#x", true) }); + + const outcomes = await Promise.all([ + make().refreshIfDue("s1", ctx), + make().refreshIfDue("s1", ctx), + ]); + + expect(outcomes).toEqual( + expect.arrayContaining([ + { kind: "ended", reason: "refresh_indeterminate" }, + { kind: "ended", reason: "ended_elsewhere" }, + ]), + ); + expect(names()).toEqual(["refresh.indeterminate"]); + }); + + // Keycloak is never called under an expired lease. + it("should recover its own lease when it expires between the claim and the mark", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + const claim = store.claim.bind(store); + store.claim = async (...args) => { + const row = await claim(...args); + store.claim = claim; + store.advance(SETTINGS.leaseMs + 1); + return row; + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(keycloak.refreshes).toBe(1); + expect(names()).toEqual([ + "refresh.lease_recovered", + "refresh.dispatched", + "refresh.succeeded", + ]); + const [recovered, dispatched] = events(); + expect(defined(recovered).recoveredFrom).not.toBe(defined(dispatched).leaseHolder); + }); + + it("should stop after a bounded number of rounds", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + let reads = 0; + const read = store.read.bind(store); + store.read = async (id) => { + reads += 1; + return read(id); + }; + store.claim = async () => undefined; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "refresh_in_progress", + }); + expect(reads).toBe(MAX_ROUNDS); + expect(keycloak.refreshes).toBe(0); + }); + }); + + describe("a holder that outlives its lease", () => { + /** + * Right after the mark, the holder pauses past its lease and a successor + * ends the attempt (step 6); then the holder carries on with `next`. + */ + const pausedHolder = async (next: FakeKeycloak["nextRefresh"]) => { + const { store, keycloak, make } = setup(); + await seed(store, DUE); + let successor: RefreshOutcome | undefined; + const mark = store.markDispatched.bind(store); + store.markDispatched = async (...args) => { + const version = await mark(...args); + store.markDispatched = mark; + store.advance(SETTINGS.leaseMs + 1); + successor = await make().refreshIfDue("s1", ctx); + keycloak.nextRefresh = next; + return version; + }; + const holder = await make().refreshIfDue("s1", ctx); + return { holder, successor, store, keycloak }; + }; + + it.each([ + ["a success", {}], + ["invalid_grant", { invalidGrant: true }], + ["an unsent failure", { connectFail: "ECONNREFUSED" }], + ["a timeout", { hang: true }], + ])("should report only lease_lost when it comes back with %s", async (_case, next) => { + const { holder, successor, store } = await pausedHolder(next); + + expect(successor).toEqual({ kind: "ended", reason: "refresh_indeterminate" }); + expect(holder).toEqual({ kind: "ended", reason: "ended_elsewhere" }); + expect(store.session("s1")).toBeUndefined(); + const all = events(); + const indeterminate = all.filter((e) => e.event === "refresh.indeterminate"); + const lost = all.filter((e) => e.event === "refresh.lease_lost"); + expect(indeterminate).toEqual([expect.objectContaining({ by: "successor" })]); + expect(lost).toHaveLength(1); + expect(defined(lost[0]).leaseHolder).toBe(defined(indeterminate[0]).leaseHolder); + expect( + all.filter((e) => + ["refresh.succeeded", "refresh.invalid_grant", "refresh.unsent"].includes(e.event), + ), + ).toEqual([]); + expectOneTerminalPerDispatch(); + }); + + it("should answer plain, never with the reason, when a sign-out ended the session during the request", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = { hang: true }; + keycloak.onRefresh = async () => store.endSession("s1"); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "ended_elsewhere", + }); + expect(names()).toEqual(["refresh.dispatched", "refresh.lease_lost"]); + }); + }); + + describe("clocks", () => { + it.each([ + ["ahead", 10 * 60_000], + ["behind", -10 * 60_000], + ])( + "should judge due on the store clock while the application clock is %s", + async (_case, skew) => { + const { store, keycloak, refresher } = setup(); + await seed(store, { id: "fresh" }); + await seed(store, { id: "due", ...DUE }); + const real = Date.now.bind(Date); + vi.spyOn(Date, "now").mockImplementation(() => real() + skew); + + await expect(refresher.refreshIfDue("fresh", ctx)).resolves.toMatchObject({ + refreshed: false, + }); + await expect(refresher.refreshIfDue("due", ctx)).resolves.toMatchObject({ + refreshed: true, + }); + expect(keycloak.refreshes).toBe(1); + }, + ); + + it("should not lengthen the deadlines when the wall clock steps back during the request", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + const before = store.now().getTime(); + keycloak.onRefresh = () => { + const real = Date.now.bind(Date); + vi.spyOn(Date, "now").mockImplementation(() => real() - 3_600_000); + }; + + await refresher.refreshIfDue("s1", ctx); + const after = store.now().getTime(); + const deadline = defined(rowOf(store).refreshTokenExpiresAt).getTime(); + + expect(deadline).toBeGreaterThanOrEqual(before + 1_800_000 - 1); + expect(deadline).toBeLessThanOrEqual(after + 1_800_000); + }); + + // Keycloak built the response with one second of realm lifetime left, + // and it was delivered after that second ran out. + it("should end the session when the refresh lifetime is exhausted on arrival", async () => { + const { store, keycloak, refresher } = setup({ requestTimeoutMs: 3000, leaseMs: 11000 }); + await seed(store, DUE); + keycloak.nextRefresh = { delayMs: 1200, fields: { refresh_expires_in: 1 } }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_expired", + }); + expect(store.session("s1")).toBeUndefined(); + expect(grantOf(store)).toBeUndefined(); + expect(names()).toEqual(["refresh.dispatched", "refresh.succeeded"]); + expect(events()[1]).toMatchObject({ remainingSeconds: 0 }); + }, 10000); + + it("should store an exhausted access token and refresh it again on the next call", async () => { + const { store, keycloak, refresher } = setup({ requestTimeoutMs: 3000, leaseMs: 11000 }); + await seed(store, DUE); + keycloak.nextRefresh = { delayMs: 1200, fields: { expires_in: 1 } }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(defined(rowOf(store).accessTokenExpiresAt).getTime()).toBeLessThanOrEqual( + store.now().getTime(), + ); + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ refreshed: true }); + expect(keycloak.refreshes).toBe(2); + }, 10000); + + it("should never hand back an access token with no life left", async () => { + const { store, keycloak, refresher } = setup({ requestTimeoutMs: 3000, leaseMs: 11000 }); + await seed(store, DUE); + keycloak.onRefresh = () => { + keycloak.nextRefresh = { delayMs: 1100, fields: { expires_in: 1 } }; + }; + keycloak.nextRefresh = { delayMs: 1100, fields: { expires_in: 1 } }; + + await expect(refresher.resolveAccessToken("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "keycloak_unreachable", + }); + expect(keycloak.refreshes).toBe(2); + }, 10000); + }); + + describe("token validity at handoff", () => { + it("should cap the usable token lifetime at the session deadline", async () => { + const { store, refresher } = setup(); + await seed(store, { refreshMs: 120_000 }); + + const outcome = await refresher.resolveAccessToken("s1", ctx); + + expect(outcome.kind).toBe("ok"); + expect(outcome.kind === "ok" && outcome.expiresInMs).toBeGreaterThan(119_000); + expect(outcome.kind === "ok" && outcome.expiresInMs).toBeLessThanOrEqual(120_000); + }); + + it.each([false, true])( + "should revalidate a session that expires during completion (ambiguous commit: %s)", + async (lostCommit) => { + const advance = clock(); + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + keycloak.nextRefresh = { fields: { refresh_expires_in: 1 } }; + const complete = store.complete.bind(store); + store.complete = async (input) => { + const result = await complete(input); + advance(2000); + if (lostCommit) { + throw new AmbiguousCommitError(new Error("Connection terminated")); + } + return result; + }; + + await expect(refresher.resolveAccessToken("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_expired", + }); + expect(store.session("s1")).toBeUndefined(); + expect(keycloak.refreshes).toBe(1); + expectOneTerminalPerDispatch(); + }, + ); + + it.each([ + [ + "access", + expect.objectContaining({ kind: "ok", accessToken: "access-refreshed-1" }), + { kept: true, refreshes: 1 }, + ], + ["session", { kind: "ended", reason: "refresh_expired" }, { kept: false, refreshes: 0 }], + ] as const)( + "should revalidate when the %s deadline passes during decryption", + async (deadline, expected, { kept, refreshes }) => { + const advance = clock(); + const { store, keycloak, refresher } = setup(); + await seed(store, { + accessMs: deadline === "access" ? 1000 : 300_000, + refreshMs: deadline === "session" ? 1000 : 1_800_000, + }); + let delayed = false; + const auth = { ...ctx }; + Object.defineProperty(auth, "secretConfig", { + get() { + if (!delayed) { + delayed = true; + advance(1500); + } + return ctx.secretConfig; + }, + }); + + const outcome = await refresher.resolveAccessToken("s1", auth); + + expect(outcome).toEqual(expected); + expect(store.session("s1") !== undefined).toBe(kept); + expect(keycloak.refreshes).toBe(refreshes); + }, + ); + }); + + describe("a token an upstream rejected", () => { + const REJECTED = { rejectedAccessToken: "access-0", refreshBudget: 1 }; + + it("should refresh a grant that still holds it, though it is not due", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store); + + const outcome = await refresher.resolveAccessToken("s1", ctx, REJECTED); + + expect(outcome).toMatchObject({ + kind: "ok", + accessToken: "access-refreshed-1", + refreshed: true, + }); + expect(keycloak.refreshes).toBe(1); + expect(names()).toEqual(["refresh.forced", "refresh.dispatched", "refresh.succeeded"]); + const [forced, dispatched] = events(); + expect(forced).toMatchObject({ sessionId: "s1", cause: "upstream_rejected" }); + expect(defined(forced).leaseHolder).toBe(defined(dispatched).leaseHolder); + expect(grantOf(store)).toMatchObject({ leaseHolder: null, dispatched: false }); + expect(await decrypt(rowOf(store).accessToken)).toBe("access-refreshed-1"); + expectOneTerminalPerDispatch(); + }); + + it("should hand back a replacement another caller stored, without calling Keycloak", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store); + + const outcome = await refresher.resolveAccessToken("s1", ctx, { + rejectedAccessToken: "access-before", + refreshBudget: 1, + }); + + expect(outcome).toMatchObject({ kind: "ok", accessToken: "access-0", refreshed: false }); + expect(keycloak.refreshes).toBe(0); + expect(names()).toEqual([]); + }); + + it.each([ + ["due", DUE], + ["expired", { accessMs: -1000, issuedMs: 301_000 }], + ])( + "should refresh a replacement that is %s once, through the normal claim", + async (_, timing) => { + const { store, keycloak, refresher } = setup(); + await seed(store, timing); + + const outcome = await refresher.resolveAccessToken("s1", ctx, { + rejectedAccessToken: "access-before", + refreshBudget: 1, + }); + + expect(outcome).toMatchObject({ + kind: "ok", + accessToken: "access-refreshed-1", + refreshed: true, + }); + expect(keycloak.refreshes).toBe(1); + expect(names()).toEqual(["refresh.dispatched", "refresh.succeeded"]); + }, + ); + + it("should wait on a live lease and use the token its holder stored", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, lease(store, 5000, "other#1#x", true)); + const replacement = await encryptToken("access-9", ctx); + setTimeout(() => { + Object.assign(rowOf(store), { + accessToken: replacement, + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: rowOf(store).version + 1, + updatedAt: store.now(), + }); + }, 100); + + const outcome = await refresher.resolveAccessToken("s1", ctx, REJECTED); + + expect(outcome).toMatchObject({ kind: "ok", accessToken: "access-9", refreshed: false }); + expect(keycloak.refreshes).toBe(0); + expect(names()).toEqual(["refresh.waited"]); + }); + + it("should give up on a live lease after the wait bound, leaving the session intact", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, lease(store, 5000, "other#1#x", true)); + + await expect(refresher.resolveAccessToken("s1", ctx, REJECTED)).resolves.toEqual({ + kind: "unavailable", + reason: "refresh_in_progress", + }); + expect(keycloak.refreshes).toBe(0); + expect(store.session("s1")).toBeDefined(); + }); + + it("should end the session on invalid_grant", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store); + keycloak.nextRefresh = { invalidGrant: true }; + + await expect(refresher.resolveAccessToken("s1", ctx, REJECTED)).resolves.toEqual({ + kind: "ended", + reason: "invalid_grant", + }); + expect(store.session("s1")).toBeUndefined(); + expect(names()).toEqual(["refresh.forced", "refresh.dispatched", "refresh.invalid_grant"]); + }); + + it("should release the lease and keep the session when the request was never sent", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store); + keycloak.nextRefresh = { connectFail: "ECONNREFUSED" }; + + await expect(refresher.resolveAccessToken("s1", ctx, REJECTED)).resolves.toEqual({ + kind: "unavailable", + reason: "keycloak_unreachable", + }); + expect(keycloak.refreshes).toBe(0); + expect(grantOf(store)).toMatchObject({ leaseHolder: null, dispatched: false }); + expect(await decrypt(rowOf(store).accessToken)).toBe("access-0"); + expect(names()).toEqual(["refresh.forced", "refresh.unsent"]); + }); + + it("should end the session with the reason when the outcome is unknown", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store); + keycloak.nextRefresh = { sentFail: "ECONNRESET" }; + + await expect(refresher.resolveAccessToken("s1", ctx, REJECTED)).resolves.toEqual({ + kind: "ended", + reason: "refresh_indeterminate", + }); + expect(store.session("s1")).toBeUndefined(); + }); + }); + + describe("the refresh budget", () => { + // The default budget's two requests are pinned by 'should never hand + // back an access token with no life left' above. + it("should make one request with a budget of one when the result has no life left", async () => { + const { store, keycloak, refresher } = setup({ requestTimeoutMs: 3000, leaseMs: 11000 }); + await seed(store, DUE); + expiredOnArrival(keycloak); + + await expect(refresher.resolveAccessToken("s1", ctx, { refreshBudget: 1 })).resolves.toEqual({ + kind: "unavailable", + reason: "keycloak_unreachable", + }); + expect(keycloak.refreshes).toBe(1); + expect(grantOf(store)).toMatchObject({ leaseHolder: null, dispatched: false }); + }, 10000); + + it("should make one request for a forced refresh whose result has no life left", async () => { + const { store, keycloak, refresher } = setup({ requestTimeoutMs: 3000, leaseMs: 11000 }); + await seed(store); + expiredOnArrival(keycloak); + + await expect( + refresher.resolveAccessToken("s1", ctx, { + rejectedAccessToken: "access-0", + refreshBudget: 1, + }), + ).resolves.toEqual({ kind: "unavailable", reason: "keycloak_unreachable" }); + expect(keycloak.refreshes).toBe(1); + }, 10000); + + it("should never claim with no budget, but still revalidate the row", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, { id: "due", ...DUE }); + await seed(store, { id: "fresh" }); + await seed(store, { id: "expired", refreshMs: -1000 }); + + await expect(refresher.resolveAccessToken("due", ctx, { refreshBudget: 0 })).resolves.toEqual( + { kind: "unavailable", reason: "keycloak_unreachable" }, + ); + await expect( + refresher.resolveAccessToken("fresh", ctx, { refreshBudget: 0 }), + ).resolves.toMatchObject({ kind: "ok", accessToken: "access-0" }); + await expect( + refresher.resolveAccessToken("expired", ctx, { refreshBudget: 0 }), + ).resolves.toEqual({ kind: "ended", reason: "refresh_expired" }); + await expect( + refresher.resolveAccessToken("missing", ctx, { refreshBudget: 0 }), + ).resolves.toEqual({ kind: "ended", reason: "no_grant" }); + expect(keycloak.refreshes).toBe(0); + expect(grantOf(store, "due")).toMatchObject({ leaseHolder: null }); + }); + + describe("spent between the two attempts", () => { + it("should still end a session whose refresh deadline passed", async () => { + const advance = clock(); + const { store, keycloak, refresher } = setup(); + await seed(store); + keycloak.nextRefresh = { fields: { expires_in: 1, refresh_expires_in: 1 } }; + const complete = store.complete.bind(store); + store.complete = async (input) => { + const result = await complete(input); + advance(2000); + return result; + }; + + await expect( + refresher.resolveAccessToken("s1", ctx, { + rejectedAccessToken: "access-0", + refreshBudget: 1, + }), + ).resolves.toEqual({ kind: "ended", reason: "refresh_expired" }); + expect(keycloak.refreshes).toBe(1); + expect(store.session("s1")).toBeUndefined(); + }); + + it("should still end a session whose row vanished", async () => { + const advance = clock(); + const { store, keycloak, refresher } = setup(); + await seed(store); + keycloak.nextRefresh = { fields: { expires_in: 1 } }; + const complete = store.complete.bind(store); + store.complete = async (input) => { + const result = await complete(input); + advance(2000); + await store.endSession("s1"); + return result; + }; + + await expect( + refresher.resolveAccessToken("s1", ctx, { + rejectedAccessToken: "access-0", + refreshBudget: 1, + }), + ).resolves.toMatchObject({ kind: "ended" }); + expect(keycloak.refreshes).toBe(1); + expect(store.session("s1")).toBeUndefined(); + }); + }); + }); + + describe("the database", () => { + it("should release without sending when the mark is acknowledged too late", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + const mark = store.markDispatched.bind(store); + store.markDispatched = async (...args) => { + const version = await mark(...args); + await sleep(1100); + return version; + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "database", + }); + expect(keycloak.refreshes).toBe(0); + expect(grantOf(store)).toMatchObject({ leaseHolder: null, dispatched: false }); + expect(events()).toEqual([ + expect.objectContaining({ event: "refresh.unsent", cause: "mark_latency" }), + ]); + }); + + it("should answer unavailable(database) and log no row detail on a Postgres error", async () => { + const { store, refresher } = setup(); + await seed(store, DUE); + store.claim = async () => { + throw Object.assign(new Error("connection lost"), { + code: "08006", + detail: "row (access-0)", + }); + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "database", + }); + expect(events()).toEqual([ + expect.objectContaining({ + event: "refresh.error", + code: "08006", + error: "connection lost", + }), + ]); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain("row (access-0)"); + }); + + it("should answer unavailable(database) when a read never settles", async () => { + const { store, refresher } = setup({ waitMs: 100, leaseMs: 8200 }); + await seed(store, DUE); + store.read = async () => new Promise(() => undefined); + const start = performance.now(); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "unavailable", + reason: "database", + }); + // The wait plus one pool wait and one statement. + expect(performance.now() - start).toBeLessThan(100 + 4000 + 300); + }, 10000); + + it("should end its own attempt with the reason when the completion runs out of budget", async () => { + const { store, keycloak, refresher } = setup(); + await seed(store, DUE); + store.complete = async () => { + throw new CompletionBudgetError(); + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_indeterminate", + }); + expect(keycloak.refreshes).toBe(1); + expect(names()).toEqual(["refresh.dispatched", "refresh.indeterminate"]); + expect(events()[1]).toMatchObject({ by: "holder", cause: "completion_budget" }); + }); + + describe("an ambiguous commit", () => { + it("should answer ok when the commit landed", async () => { + const { store, refresher } = setup(); + await seed(store, DUE); + const complete = store.complete.bind(store); + ambiguous(store, complete); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ + kind: "ok", + refreshed: true, + }); + await expect(decrypt(rowOf(store).refreshToken)).resolves.toBe("refresh-refreshed-1"); + expect(names()).toEqual(["refresh.dispatched", "refresh.succeeded"]); + }); + + it("should end its own attempt with the reason when the commit never landed", async () => { + const { store, refresher } = setup(); + await seed(store, DUE); + ambiguous(store, async () => undefined); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_indeterminate", + }); + expect(events()[1]).toMatchObject({ + event: "refresh.indeterminate", + cause: "ambiguous_commit", + }); + }); + + it("should answer ok when the commit lands between the re-read and the termination", async () => { + const { store, refresher } = setup(); + await seed(store, DUE); + const complete = store.complete.bind(store); + let landing: CompleteInput; + ambiguous(store, async (input) => { + landing = input; + }); + const endIf = store.endIf.bind(store); + store.endIf = async (...args) => { + await complete(landing); + return endIf(...args); + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ + kind: "ok", + refreshed: true, + }); + expect(store.session("s1")).toBeDefined(); + expect(names()).toEqual(["refresh.dispatched", "refresh.succeeded"]); + }); + + it("should answer plain when a successor ended the session meanwhile", async () => { + const { store, refresher } = setup(); + await seed(store, DUE); + ambiguous(store, async () => store.endSession("s1")); + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "ended_elsewhere", + }); + expect(names()).toEqual(["refresh.dispatched", "refresh.lease_lost"]); + }); + }); + + // A commit and the log line that reports it are two steps. + describe("a crash window", () => { + it("after the mark committed leaves only the successor’s indeterminate", async () => { + const { store, keycloak, make } = setup(); + await seed(store, DUE); + const mark = store.markDispatched.bind(store); + store.markDispatched = async (...args) => { + await mark(...args); + store.markDispatched = mark; + throw new Error("process died"); + }; + + await expect(make().refreshIfDue("s1", ctx)).resolves.toMatchObject({ reason: "database" }); + store.advance(SETTINGS.leaseMs + 1); + await expect(make().refreshIfDue("s1", ctx)).resolves.toEqual({ + kind: "ended", + reason: "refresh_indeterminate", + }); + expect(keycloak.refreshes).toBe(0); + expect(names()).toEqual(["refresh.error", "refresh.indeterminate"]); + }); + + it("after the completion committed leaves a dispatched attempt with no terminal event", async () => { + const { store, refresher } = setup(); + await seed(store, DUE); + const complete = store.complete.bind(store); + store.complete = async (input) => { + await complete(input); + throw new Error("process died"); + }; + + await expect(refresher.refreshIfDue("s1", ctx)).resolves.toMatchObject({ + reason: "database", + }); + expect(names()).toEqual(["refresh.dispatched", "refresh.error"]); + await expect(decrypt(rowOf(store).refreshToken)).resolves.toBe("refresh-refreshed-1"); + }); + }); + }); +}); + +describe("refreshResponse", () => { + it("should give the reason only to the response that ended the session at step 6", () => { + expect(refreshResponse({ kind: "ended", reason: "refresh_indeterminate" })).toEqual({ + status: SESSION_INVALID.status, + body: { + code: SESSION_INVALID.code, + message: SESSION_INVALID.message, + reason: "refresh_indeterminate", + }, + }); + for (const reason of [ + "no_grant", + "ended_elsewhere", + "refresh_expired", + "invalid_grant", + ] as const) { + expect(refreshResponse({ kind: "ended", reason })).toEqual({ + status: 401, + body: { code: SESSION_INVALID.code, message: SESSION_INVALID.message }, + }); + } + }); + + it.each(["refresh_in_progress", "keycloak_unreachable", "database"] as const)( + "should answer %s as a dependency failure, not a 401", + (reason) => { + expect(refreshResponse({ kind: "unavailable", reason })).toEqual({ + status: DEPENDENCY_FAILURE.status, + body: { code: DEPENDENCY_FAILURE.code, message: DEPENDENCY_FAILURE.message, reason }, + }); + }, + ); +}); diff --git a/src/core/__tests__/helpers/auth-instance.ts b/src/core/__tests__/helpers/auth-instance.ts new file mode 100644 index 0000000..1d7f8fa --- /dev/null +++ b/src/core/__tests__/helpers/auth-instance.ts @@ -0,0 +1,138 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import type { RemoteJWKSet } from "jose"; +import { vi } from "vitest"; + +import { + composeAfterHooks, + composeBeforeHooks, + recordCallbackStart, +} from "../../grants/callback-start.js"; +import { BACKCHANNEL_LOGOUT_ROUTE, backchannelLogout } from "../../logout/backchannel.js"; +import { captureSignOut, finishSignOut } from "../../logout/sign-out.js"; +import { TEST_CONFIG, testLogger, type TestLogger } from "./config.js"; +import { FakeKeycloak, loginThrough } from "./fake-keycloak.js"; +import { createMemoryAuthSetup, MemoryGrantStore } from "./memory-store.js"; + +export const BASE = "https://interlock.test"; + +export interface BuildOptions { + /** Whether the fake serves discovery, which turns the ID-token sign-in path on. */ + discovery?: boolean; + grants?: MemoryGrantStore; + /** False removes the grant hooks and every request hook, for the controls. */ + hooks?: boolean; + /** + * False removes the exposure hooks alone (the allow-list and the token + * strip), keeping the callback start, the sign-out hooks and the grant + * hooks, to reach the inner layers the allow-list stands in front of. + */ + exposure?: boolean; + /** True turns Better Auth's session slide back on. */ + slide?: boolean; + /** True turns Better Auth's rate limiter on, as `NODE_ENV=production` does. */ + rateLimit?: boolean; + /** The key set the back-channel logout receiver verifies with, in place of the realm's. */ + logoutKeys?: RemoteJWKSet; + /** Where everything logs; a fresh `testLogger()` unless given. */ + logger?: TestLogger; +} + +/** + * A real instance on a fresh memory store, with Keycloak faked at `fetch`. + * The caller restores the `fetch` spy (`vi.restoreAllMocks`). + * + * Interlock's own test helper of the same name, whose only users were the + * specs that moved here. + */ +export function buildAuth({ + discovery = false, + grants = new MemoryGrantStore(), + hooks = true, + exposure = true, + slide = false, + rateLimit = false, + logoutKeys, + logger = testLogger(), +}: BuildOptions = {}) { + const keycloak = new FakeKeycloak(discovery); + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(keycloak.fetch); + const config = TEST_CONFIG; + const setup = createMemoryAuthSetup(config, grants, logger); + let options: BetterAuthOptions = setup.options; + if (slide) { + options = { ...options, session: { ...options.session, disableSessionRefresh: false } }; + } + if (rateLimit) { + options = { ...options, rateLimit: { ...options.rateLimit, enabled: true } }; + } + if (logoutKeys) { + const receiver = backchannelLogout(grants, { + route: BACKCHANNEL_LOGOUT_ROUTE, + issuer: keycloak.issuer, + clientId: config.keycloak.clientId, + getKey: logoutKeys, + logger, + }); + options = { + ...options, + plugins: (options.plugins ?? []).map((plugin) => + plugin.id === receiver.id ? receiver : plugin, + ), + }; + } + if (!exposure) { + options = { + ...options, + hooks: { + before: composeBeforeHooks([recordCallbackStart(), captureSignOut(grants, logger)]), + after: composeAfterHooks([ + finishSignOut(grants, { + issuer: keycloak.issuer, + clientId: config.keycloak.clientId, + postLogoutRedirectUri: `${BASE}/`, + logger, + }), + ]), + }, + }; + } + if (!hooks) { + options = { ...options, databaseHooks: undefined, hooks: undefined }; + } + const auth = betterAuth(options); + return { + auth, + keycloak, + fetch, + logger, + tables: setup.tables, + grants, + login: async () => loginThrough(auth.handler, keycloak), + post: async (path: string, cookie: string, body: object) => + auth.handler( + new Request(`${BASE}/api/auth/${path}`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE, cookie }, + body: JSON.stringify(body), + }), + ), + /** A form POST as Keycloak's back-channel delivery sends one: no cookie, no `Origin`. */ + postForm: async (path: string, body: Record | string) => + auth.handler( + new Request(`${BASE}/api/auth/${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: typeof body === "string" ? body : new URLSearchParams(body).toString(), + }), + ), + }; +} + +export type AuthInstance = ReturnType; + +export const sessionCookieName = async (auth: AuthInstance["auth"]) => + (await auth.$context).authCookies.sessionToken.name; + +/** Whether a response set a session cookie with a value. */ +export const setsSession = (setCookies: readonly string[], name: string) => + setCookies.some((line) => line.startsWith(`${name}=`) && !line.startsWith(`${name}=;`)); diff --git a/src/core/__tests__/helpers/auth-routes.ts b/src/core/__tests__/helpers/auth-routes.ts new file mode 100644 index 0000000..70a1ce8 --- /dev/null +++ b/src/core/__tests__/helpers/auth-routes.ts @@ -0,0 +1,55 @@ +/** One Better Auth route as its endpoint declares it. */ +export interface AuthRoute { + path: string; + methods: readonly string[]; +} + +/** + * Every pathed route Better Auth 1.7.7 serves with the package's options, + * sorted by path: the core routes (`genericOAuth` adds none) and the + * back-channel logout receiver (`logout/backchannel.ts`). + * `setPassword` has no path and is not here. + * + * `exposure.test.ts` proves this equals the instance's own table and walks + * it; Interlock keeps a copy for its end-to-end sweep. A Better Auth bump + * that adds or renames a route fails that pin until the route is reviewed + * against `EXPOSED_ROUTES` (risk R6) and this list is updated. + */ +export const AUTH_ROUTES: readonly AuthRoute[] = [ + { path: "/account-info", methods: ["GET"] }, + // The receiver's plugin route, at its usual path. + { path: "/backchannel-logout", methods: ["POST"] }, + { path: "/callback/:id", methods: ["GET", "POST"] }, + { path: "/change-email", methods: ["POST"] }, + { path: "/change-password", methods: ["POST"] }, + { path: "/delete-user", methods: ["POST"] }, + { path: "/delete-user/callback", methods: ["GET"] }, + { path: "/error", methods: ["GET"] }, + { path: "/get-access-token", methods: ["POST"] }, + { path: "/get-session", methods: ["GET", "POST"] }, + { path: "/link-social", methods: ["POST"] }, + { path: "/list-accounts", methods: ["GET"] }, + { path: "/list-sessions", methods: ["GET"] }, + { path: "/ok", methods: ["GET"] }, + { path: "/refresh-token", methods: ["POST"] }, + { path: "/request-password-reset", methods: ["POST"] }, + { path: "/reset-password", methods: ["POST"] }, + { path: "/reset-password/:token", methods: ["GET"] }, + { path: "/revoke-other-sessions", methods: ["POST"] }, + { path: "/revoke-session", methods: ["POST"] }, + { path: "/revoke-sessions", methods: ["POST"] }, + { path: "/send-verification-email", methods: ["POST"] }, + { path: "/sign-in/email", methods: ["POST"] }, + { path: "/sign-in/social", methods: ["POST"] }, + { path: "/sign-out", methods: ["POST"] }, + { path: "/sign-up/email", methods: ["POST"] }, + { path: "/unlink-account", methods: ["POST"] }, + { path: "/update-session", methods: ["POST"] }, + { path: "/update-user", methods: ["POST"] }, + { path: "/verify-email", methods: ["GET"] }, + { path: "/verify-password", methods: ["POST"] }, +]; + +/** A route's path with concrete parameters, under `/api/auth`. */ +export const concretePath = (path: string) => + `/api/auth${path.replace(":id", "keycloak").replace(":token", "x")}`; diff --git a/src/core/__tests__/helpers/config.ts b/src/core/__tests__/helpers/config.ts new file mode 100644 index 0000000..a437274 --- /dev/null +++ b/src/core/__tests__/helpers/config.ts @@ -0,0 +1,63 @@ +import { vi, type Mock } from "vitest"; + +import { REFRESH_DEFAULTS, type KeycloakAuthConfig, type RefreshConfig } from "../../config.js"; +import type { AuthLogger, LogRecord } from "../../logger.js"; + +/** What the specs configure: the options' configuration and the refresher's bounds. */ +export type TestConfig = KeycloakAuthConfig & { refresh: RefreshConfig }; + +/** + * A complete, valid configuration: the values Interlock's own test setup uses. The + * Keycloak issuer is on port 1, so Better Auth's startup discovery fetch is refused without a DNS + * lookup unless a spec stubs `fetch`. Specs that change a value spread it; nothing mutates it. + */ +export const TEST_CONFIG: TestConfig = { + betterAuth: { + url: "https://interlock.test", + secrets: [ + { version: 1, value: "test-secret-current" }, + { version: 0, value: "test-secret-previous" }, + ], + }, + keycloak: { + issuer: "https://127.0.0.1:1/realms/test", + clientId: "interlock", + clientSecret: "test-client-secret", + backchannelLogoutPath: "/api/auth/backchannel-logout", + }, + trustedOrigins: ["https://interlock.test"], + cookieNamePolicy: "host", + refresh: REFRESH_DEFAULTS, +}; + +/** `TEST_CONFIG` with the Keycloak settings changed. */ +export const withKeycloak = (keycloak: Partial): TestConfig => ({ + ...TEST_CONFIG, + keycloak: { ...TEST_CONFIG.keycloak, ...keycloak }, +}); + +/** A logger whose four levels are mocks, so specs assert what was logged. */ +export type TestLogger = { [Level in keyof AuthLogger]: Mock<(record: LogRecord) => void> }; + +export const testLogger = (): TestLogger => ({ + debug: vi.fn<(record: LogRecord) => void>(), + log: vi.fn<(record: LogRecord) => void>(), + warn: vi.fn<(record: LogRecord) => void>(), + error: vi.fn<(record: LogRecord) => void>(), +}); + +/** The record of every call to one level. */ +export const records = (level: Mock<(record: LogRecord) => void>) => + level.mock.calls.map(([record]) => record); + +/** Every record the logger received, whatever the level. */ +export const allRecords = (logger: TestLogger) => + [logger.debug, logger.log, logger.warn, logger.error].flatMap(records); + +/** The value, or a thrown error naming what was missing; narrows where `expect` cannot. */ +export function defined(value: T | null | undefined, what = "value"): T { + if (value === null || value === undefined) { + throw new Error(`expected ${what} to be defined`); + } + return value; +} diff --git a/src/core/__tests__/helpers/fake-keycloak-server.ts b/src/core/__tests__/helpers/fake-keycloak-server.ts new file mode 100644 index 0000000..e41b43d --- /dev/null +++ b/src/core/__tests__/helpers/fake-keycloak-server.ts @@ -0,0 +1,60 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { FakeKeycloak } from "./fake-keycloak.js"; + +/** + * `FakeKeycloak` listening on plain HTTP on 127.0.0.1, so the refresher's + * real transport (`http.request` for an `http:` issuer) reaches it. A + * refresh with `sentFail` is processed (the token rotates) and the socket is + * then destroyed without an answer. A connecting-phase failure cannot be + * acted out by a listening server; a refused port is one. + * + * With `discovery` the fake also serves discovery and its key set, so + * Better Auth verifies the login's ID token and the back-channel logout + * receiver can verify the fake's logout tokens. + */ +export async function startFakeKeycloakServer({ discovery = false } = {}) { + const server: Server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const issuerOrigin = `http://127.0.0.1:${port}`; + const issuer = `${issuerOrigin}/realms/test`; + const keycloak = new FakeKeycloak(discovery, issuer); + server.on("request", (req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + void (async () => { + const url = `${issuerOrigin}${req.url ?? ""}`; + const { sentFail } = keycloak.nextRefresh; + const response = await keycloak.handle(url, Buffer.concat(chunks).toString("utf8"), { + cookie: req.headers.cookie !== undefined, + }); + if (response === undefined) { + res.writeHead(404).end(); + return; + } + if (sentFail !== undefined) { + req.socket.destroy(); + return; + } + res + .writeHead(response.status, { + "content-type": response.text === undefined ? "application/json" : "text/plain", + ...response.headers, + }) + .end(response.text ?? JSON.stringify(response.body)); + })(); + }); + }); + return { + keycloak, + issuer, + close: async () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} diff --git a/src/core/__tests__/helpers/fake-keycloak.ts b/src/core/__tests__/helpers/fake-keycloak.ts new file mode 100644 index 0000000..ed3ddcd --- /dev/null +++ b/src/core/__tests__/helpers/fake-keycloak.ts @@ -0,0 +1,589 @@ +import { generateKeyPairSync, randomUUID, sign, type KeyObject } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { setTimeout as sleep } from "node:timers/promises"; + +import type { TokenTransport } from "../../keycloak-token.js"; +import { defined } from "./config.js"; + +/** `TEST_CONFIG`'s issuer; nothing listens there. */ +export const ISSUER = "https://127.0.0.1:1/realms/test"; + +/** The one user every fake login is for. */ +export const SUBJECT = "sub-ann"; + +const BASE = "https://interlock.test"; +const KID = "fake-keycloak"; +const b64 = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +/** `header.payload.signature`, signed by `signer` when there is one, else `unsigned`. */ +const jws = (header: object, payload: object, signer: { key: KeyObject } | undefined) => { + const input = `${b64(header)}.${b64(payload)}`; + const signature = signer + ? sign("RSA-SHA256", Buffer.from(input), signer.key).toString("base64url") + : "unsigned"; + return `${input}.${signature}`; +}; + +/** The `events` member of a logout token (OIDC Back-Channel Logout 1.0). */ +const LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; + +/** What a logout token from `FakeKeycloak.logoutToken` should carry. */ +export interface LogoutTokenSpec { + /** The Keycloak session; the first login's by default, null leaves the claim out. */ + sid?: string | null; + /** The subject; the fake's own by default, null leaves the claim out. */ + sub?: string | null; + /** Overrides of the JWS header, e.g. `alg: 'none'` or an unknown `kid`. */ + header?: Record; + /** Overrides of the claims; `undefined` removes one. */ + claims?: Record; + /** A signature that does not verify. */ + unsigned?: boolean; + /** Sign with this key, by index (0 is the first); the newest by default. */ + keyIndex?: number; +} + +/** A JWKS answer other than the key set, for the receiver's failure specs. */ +export interface CertsResponse { + status?: number; + /** A JSON body. */ + body?: unknown; + /** A raw body, e.g. text that is not JSON. */ + text?: string; +} + +/** What the next code exchange should get wrong, for the failure specs. */ +export interface NextLogin { + omitIdToken?: boolean; + omitSid?: boolean; + /** Put this Keycloak session's `sid` on the ID token, as a second login in one SSO session would. */ + sid?: string; + /** Overrides of the token response's fields; `undefined` removes one. */ + fields?: Record; +} + +/** + * What the next refresh-token grant should do, consumed by it. The + * transport-level failures are acted out by `fakeTransport` (connecting: + * Keycloak never sees the request) or by the listening server + * (test/fake-keycloak-server.ts), which reads the body and then closes the + * socket for `sentFail`. + */ +export interface NextRefresh { + /** Answer 400 `invalid_grant`, as for a reused or revoked refresh token. */ + invalidGrant?: boolean; + /** Process the refresh (the token rotates) and never answer. */ + hang?: boolean; + /** Answer this status with an `error` body instead. */ + status?: number; + /** Keycloak's `error` code for `status`. */ + error?: string; + /** Wait this long before answering. */ + delayMs?: number; + /** Overrides of the response's fields; `undefined` removes one. */ + fields?: Record; + /** Fail before anything is written, with this code. */ + connectFail?: string; + /** Spend this long connecting first. */ + connectDelayMs?: number; + /** Process the refresh, then lose the answer with this code. */ + sentFail?: string; +} + +/** A response of the fake: status and JSON body (or raw `text`), or never. */ +export interface FakeResponse { + status: number; + body: unknown; + text?: string; + headers?: Record; +} + +/** What the next RP-initiated logout should do instead of Keycloak's normal answer. */ +export interface NextLogout { + /** Answer this status with an HTML page (and `location`, if given) and end nothing. */ + status?: number; + /** The page's text, e.g. Keycloak's confirmation page. */ + text?: string; + location?: string; + /** End the session, then redirect with a `state` other than the one sent. */ + wrongState?: boolean; + /** Never answer. */ + hang?: boolean; + /** Refuse the connection. */ + refuse?: boolean; +} + +/** One RP-initiated logout the fake received. */ +export interface ReceivedLogout { + /** The hint as sent, so specs can compare it with the issued ID tokens. */ + hint: string | null; + sid: string | undefined; + clientId: string | null; + redirect: string | null; + state: string | null; + /** Whether a cookie came with it; Interlock never sends one. */ + cookie: boolean; +} + +const html = (text: string) => `

${text}

`; + +/** Spreads overrides, dropping the keys set to undefined. */ +const withFields = (body: Record, fields: Record = {}) => + Object.fromEntries( + Object.entries({ ...body, ...fields }).filter(([, value]) => value !== undefined), + ); + +/** + * Keycloak's token, userinfo and (in discovery mode) discovery and JWKS + * endpoints, as a `fetch` replacement and as a request handler for the + * listening server. Login `n` gets `access-`, `refresh-` and an ID + * token with `sid-`, in the shape a real Keycloak token response has, + * `refresh_expires_in` included; refresh `n` gets `access-refreshed-`, + * `refresh-refreshed-` and a new ID token. Without discovery the ID token + * is unsigned, as the provider then never verifies it; with discovery it is + * signed RS256 with a key generated here, so Better Auth verifies it against + * the served JWKS. Any other URL is refused, as an unreachable host would be. + * + * The issuer defaults to `TEST_CONFIG`'s; the listening + * server passes its own origin. Calls arrive with a `URL` object as well as + * strings, so the URL is read with `String(input)`. + */ +export class FakeKeycloak { + /** Code exchanges so far; also the number of the last login. */ + codeExchanges = 0; + /** Refresh-token grants so far, and the refresh token each one presented. */ + refreshes = 0; + readonly refreshedWith: string[] = []; + /** Every ID token issued, in order. */ + readonly idTokens: string[] = []; + next: NextLogin = {}; + nextRefresh: NextRefresh = {}; + /** Delays of the login's own requests, for the login-anchor specs. */ + exchangeDelayMs = 0; + userinfoDelayMs = 0; + /** Awaited when a refresh arrives, before it is processed. */ + onRefresh: (() => unknown) | undefined; + /** The nonce the next ID token carries; the login helper sets it. */ + nonce: string | undefined; + /** + * The user every login is for. Suites that share the e2e schema and run in + * parallel each use their own, so one suite's cleanup never removes + * another's user. + */ + subject = SUBJECT; + /** The user's address; Better Auth links accounts by it, so it changes with `subject`. */ + email = "ann@example.com"; + /** + * Login `n`'s Keycloak session is `-`. An e2e suite that + * sends sid-bound logout tokens sets its own, because the schema is shared + * and a `sid-1` logout would end every parallel suite's first session. + */ + sidPrefix = "sid"; + + /** JWKS fetches so far (discovery mode). */ + certsFetches = 0; + /** Answer the JWKS endpoint with this instead of the keys, until cleared. */ + certsResponse: CertsResponse | undefined; + /** Every RP-initiated logout, in order, and the sids they ended. */ + readonly logouts: ReceivedLogout[] = []; + readonly endedSids = new Set(); + nextLogout: NextLogout = {}; + /** + * Awaited for each session a logout ends, before the answer: Keycloak + * delivers its back-channel logout to the session's clients first. + */ + onLogout: ((sid: string) => unknown) | undefined; + + /** The signing keys, oldest first; the newest signs. Discovery mode only. */ + private readonly keys: { key: KeyObject; jwk: object; kid: string }[] = []; + + constructor( + readonly discovery = false, + readonly issuer = ISSUER, + ) { + if (discovery) { + this.addKey(KID); + } + } + + private addKey(kid: string) { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + this.keys.push({ + key: privateKey, + jwk: { ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" }, + kid, + }); + } + + /** + * Rotates the realm key as Keycloak does: a new key with a new `kid` signs + * from now on, and the old one stays in the key set, passive. + */ + rotateKey() { + if (!this.discovery) { + throw new Error("the fake has keys only in discovery mode"); + } + this.addKey(`${KID}-${this.keys.length + 1}`); + } + + private get signer() { + return this.keys[this.keys.length - 1]; + } + + endpoint(name: string) { + return `${this.issuer}/protocol/openid-connect/${name}`; + } + + /** + * A back-channel logout token as Keycloak 26 sends one: header + * `typ: logout+jwt`, `exp` 120 s after `iat`, the client as audience, the + * subject, the session's `sid` and the logout event. Discovery mode only, + * since only then does the fake have a key and serve the key set. + */ + logoutToken({ + sid = `${this.sidPrefix}-1`, + sub = this.subject, + header = {}, + claims = {}, + unsigned = false, + keyIndex, + }: LogoutTokenSpec = {}) { + if (!this.discovery) { + throw new Error("logout tokens need the fake in discovery mode"); + } + const signer = defined( + keyIndex === undefined ? this.signer : this.keys[keyIndex], + "a signing key", + ); + const now = Math.floor(Date.now() / 1000); + const token = jws( + { alg: "RS256", typ: "logout+jwt", kid: signer.kid, ...header }, + withFields( + { + jti: randomUUID(), + iat: now, + exp: now + 120, + iss: this.issuer, + aud: "interlock", + typ: "Logout", + sub: sub ?? undefined, + sid: sid ?? undefined, + events: { [LOGOUT_EVENT]: {} }, + }, + claims, + ), + signer, + ); + return unsigned ? `${token.slice(0, token.lastIndexOf("."))}.unsigned` : token; + } + + /** An ID token for `n`, signed in discovery mode. */ + idToken(n: number, claims: Record = {}) { + const now = Math.floor(Date.now() / 1000); + return jws( + { alg: "RS256", typ: "JWT", kid: this.signer?.kid ?? KID }, + { + iss: this.issuer, + aud: "interlock", + sub: this.subject, + sid: `${this.sidPrefix}-${n}`, + iat: now, + exp: now + 300, + n, + ...(this.nonce ? { nonce: this.nonce } : {}), + ...claims, + }, + this.signer, + ); + } + + /** + * One request. Resolves to the response, or never (`hang`); undefined + * means an unknown URL, which `fetch` turns into a refused connection. + */ + async handle( + url: string, + body: string, + headers: { cookie?: boolean } = {}, + ): Promise { + if (url === this.endpoint("logout")) { + return this.logout(new URLSearchParams(body), headers.cookie === true); + } + if (url === this.endpoint("token")) { + const params = new URLSearchParams(body); + return params.get("grant_type") === "refresh_token" + ? this.refresh(params.get("refresh_token")) + : { status: 200, body: await this.exchange() }; + } + if (url === this.endpoint("userinfo")) { + await sleep(this.userinfoDelayMs); + return { + status: 200, + body: { + sub: this.subject, + preferred_username: "ann", + email: this.email, + email_verified: true, + }, + }; + } + if (this.discovery && url === `${this.issuer}/.well-known/openid-configuration`) { + return { + status: 200, + body: { + issuer: this.issuer, + authorization_endpoint: this.endpoint("auth"), + token_endpoint: this.endpoint("token"), + userinfo_endpoint: this.endpoint("userinfo"), + end_session_endpoint: this.endpoint("logout"), + jwks_uri: this.endpoint("certs"), + id_token_signing_alg_values_supported: ["RS256"], + }, + }; + } + if (this.discovery && url === this.endpoint("certs")) { + this.certsFetches += 1; + if (this.certsResponse !== undefined) { + const { status = 200, body: answer, text } = this.certsResponse; + return { status, body: answer, text }; + } + return { status: 200, body: { keys: this.keys.map(({ jwk }) => jwk) } }; + } + return undefined; + } + + readonly fetch = async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const body = init?.body; + const response = await this.handle( + url, + body instanceof URLSearchParams ? body.toString() : typeof body === "string" ? body : "", + { cookie: new Headers(init?.headers).has("cookie") }, + ); + if (response === undefined) { + throw new TypeError(`fetch failed: ${url}`); + } + return response.text === undefined + ? Response.json(response.body, { status: response.status, headers: response.headers }) + : new Response(response.text, { status: response.status, headers: response.headers }); + }; + + /** + * RP-initiated logout as Keycloak 26.7 answers a POST with `id_token_hint` + * and no browser cookies: an unknown hint or another client's `client_id` + * is a 400 page, an unregistered redirect a 400 page; otherwise the hint's + * session ends (`onLogout` runs first, as Keycloak's back-channel delivery + * does), and the answer is a 302 to `post_logout_redirect_uri` with the + * `state`, or without a redirect URI the 200 "You are logged out" page. A + * session already gone answers the same, as Keycloak does. + */ + private async logout( + params: URLSearchParams, + cookie: boolean, + ): Promise { + const next = this.nextLogout; + this.nextLogout = {}; + if (next.refuse) { + return undefined; + } + if (next.hang) { + return new Promise(() => undefined); + } + const hint = params.get("id_token_hint"); + const known = hint !== null && this.idTokens.includes(hint); + const sid = known + ? ( + JSON.parse(Buffer.from(hint.split(".")[1] ?? "", "base64url").toString("utf8")) as { + sid?: string; + } + ).sid + : undefined; + const clientId = params.get("client_id"); + const redirect = params.get("post_logout_redirect_uri"); + const state = params.get("state"); + this.logouts.push({ hint, sid, clientId, redirect, state, cookie }); + const page = (status: number, text: string, location?: string): FakeResponse => ({ + status, + body: undefined, + text: html(text), + headers: { "content-type": "text/html", ...(location ? { location } : {}) }, + }); + if (next.status !== undefined) { + return page(next.status, next.text ?? "Do you want to log out?", next.location); + } + if (!known || (clientId !== null && clientId !== "interlock")) { + return page(400, "Invalid parameter: id_token_hint"); + } + if (redirect !== null && !redirect.startsWith(`${BASE}/`)) { + return page(400, "Invalid redirect uri"); + } + if (sid !== undefined) { + await this.onLogout?.(sid); + this.endedSids.add(sid); + } + if (redirect === null) { + return page(200, "You are logged out"); + } + const location = new URL(redirect); + if (state !== null) { + location.searchParams.set("state", next.wrongState ? `${state}-other` : state); + } + return { status: 302, body: undefined, text: "", headers: { location: location.href } }; + } + + private async exchange() { + await sleep(this.exchangeDelayMs); + this.codeExchanges += 1; + const n = this.codeExchanges; + const { omitIdToken, omitSid, sid, fields } = this.next; + this.next = {}; + const idToken = omitIdToken + ? undefined + : this.idToken(n, omitSid ? { sid: undefined } : sid ? { sid } : {}); + if (idToken) { + this.idTokens.push(idToken); + } + return withFields( + { + access_token: `access-${n}`, + refresh_token: `refresh-${n}`, + id_token: idToken, + token_type: "Bearer", + expires_in: 300, + refresh_expires_in: 1800, + "not-before-policy": 0, + session_state: sid ?? `${this.sidPrefix}-${n}`, + scope: "openid profile email", + }, + fields, + ); + } + + private async refresh(presented: string | null): Promise { + const next = this.nextRefresh; + this.nextRefresh = {}; + await this.onRefresh?.(); + this.refreshes += 1; + this.refreshedWith.push(presented ?? ""); + const n = this.refreshes; + if (next.hang) { + return new Promise(() => undefined); + } + await sleep(next.delayMs ?? 0); + if (next.invalidGrant) { + return { + status: 400, + body: { error: "invalid_grant", error_description: "Token is not active" }, + }; + } + if (next.status !== undefined) { + return { status: next.status, body: { error: next.error ?? "server_error" } }; + } + const idToken = this.idToken(1000 + n); + this.idTokens.push(idToken); + return { + status: 200, + body: withFields( + { + access_token: `access-refreshed-${n}`, + refresh_token: `refresh-refreshed-${n}`, + id_token: idToken, + token_type: "Bearer", + expires_in: 300, + refresh_expires_in: 1800, + scope: "openid profile email", + }, + next.fields, + ), + }; + } +} + +/** + * The refresher's two-phase transport over a `FakeKeycloak`, for unit specs. + * A `connectFail` (or a `connectDelayMs` past the deadline) fails in the + * connecting phase, before Keycloak sees anything; everything after that is + * the sent phase, where Keycloak processes the refresh before a `sentFail`, + * a `hang` or the deadline loses its answer. + */ +export const fakeTransport = + (keycloak: FakeKeycloak): TokenTransport => + async ({ url, body, timeoutMs, onSent }) => { + const start = performance.now(); + const { connectFail, connectDelayMs = 0, sentFail } = keycloak.nextRefresh; + if (connectDelayMs > 0) { + await sleep(Math.min(connectDelayMs, timeoutMs)); + if (connectDelayMs >= timeoutMs) { + keycloak.nextRefresh = {}; + return { phase: "connecting", cause: "timeout" }; + } + } + if (connectFail !== undefined) { + keycloak.nextRefresh = {}; + return { phase: "connecting", cause: connectFail }; + } + const sentAt = performance.now(); + onSent?.(); + const left = Math.max(0, timeoutMs - (sentAt - start)); + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), left); + }); + const response = await Promise.race([keycloak.handle(url, body), deadline]); + clearTimeout(timer); + if (response === "timeout") { + return { phase: "sent", sentAt, cause: "timeout" }; + } + if (response === undefined || sentFail !== undefined) { + return { phase: "sent", sentAt, cause: sentFail ?? "ECONNRESET" }; + } + return { phase: "sent", sentAt, status: response.status, body: JSON.stringify(response.body) }; + }; + +/** The `name=value` pairs a response sets, skipping cookies it clears. */ +export const cookiesSetBy = (setCookies: readonly string[]) => + setCookies + .map((line) => line.split(";")[0] ?? "") + .filter((pair) => !pair.endsWith("=")) + .join("; "); + +export interface LoginResult { + status: number; + location: string | null; + /** The cookies the callback set, as a `Cookie` header. */ + cookie: string; + setCookies: string[]; +} + +/** + * One browser login through a Better Auth handler: sign-in, then the + * callback with the state cookie and a code, as Keycloak's redirect would + * arrive. The nonce from the authorization URL goes into the next ID token. + */ +export async function loginThrough( + handler: (request: Request) => Promise, + keycloak: FakeKeycloak, +): Promise { + const start = await handler( + new Request(`${BASE}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ provider: "keycloak", callbackURL: "/" }), + }), + ); + const authorize = new URL(((await start.json()) as { url: string }).url); + keycloak.nonce = authorize.searchParams.get("nonce") ?? undefined; + const state = authorize.searchParams.get("state"); + const callback = await handler( + new Request(`${BASE}/api/auth/callback/keycloak?code=fake-code&state=${state}`, { + headers: { cookie: cookiesSetBy(start.headers.getSetCookie()) }, + }), + ); + const setCookies = callback.headers.getSetCookie(); + return { + status: callback.status, + location: callback.headers.get("location"), + cookie: cookiesSetBy(setCookies), + setCookies, + }; +} diff --git a/src/core/__tests__/helpers/kysely.ts b/src/core/__tests__/helpers/kysely.ts new file mode 100644 index 0000000..b2b00c5 --- /dev/null +++ b/src/core/__tests__/helpers/kysely.ts @@ -0,0 +1,19 @@ +import { Kysely, PostgresDialect } from "kysely"; +import pg from "pg"; + +/** The schema the specs name; nothing is ever created in it. */ +export const TEST_SCHEMA = "interlock"; + +/** + * A Kysely over a pool pointed at port 1, where nothing listens: Postgres is refused at once, + * without a DNS lookup. For specs that show that building the options or the instance never + * touches the database. The caller destroys it, which ends the pool. + */ +export const unreachableKysely = () => + new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ + connectionString: "postgresql://interlock:interlock@127.0.0.1:1/interlock", + }), + }), + }); diff --git a/src/core/__tests__/helpers/memory-store.ts b/src/core/__tests__/helpers/memory-store.ts new file mode 100644 index 0000000..aec6b1b --- /dev/null +++ b/src/core/__tests__/helpers/memory-store.ts @@ -0,0 +1,296 @@ +import { performance } from "node:perf_hooks"; + +import { memoryAdapter } from "better-auth/adapters/memory"; +import { getAuthTables } from "better-auth/db"; + +import { createKeycloakAuthOptions } from "../../auth.options.js"; +import type { KeycloakAuthConfig } from "../../config.js"; +import { + deadlineFrom, + type CompleteInput, + type CompleteResult, + type EndPredicate, + type GrantStore, + type LifetimeAnchor, + type NewGrant, + type StoredGrant, +} from "../../grants/grant-store.js"; +import type { AuthLogger } from "../../logger.js"; +import { testLogger } from "./config.js"; + +/** One row of a memory adapter table. */ +export type MemoryRow = Record; + +/** + * The memory adapter's tables, by model name, for specs that inspect rows. `session` and `account` + * are always there; any other model may not be. + */ +export interface MemoryTables { + session: MemoryRow[]; + account: MemoryRow[]; + [model: string]: MemoryRow[]; +} + +/** + * `session_grant` in memory, for unit specs, with the compare-and-swap semantics of + * `KyselyGrantStore` (Interlock's Postgres refresh suite holds the two to the same + * cases on Postgres). Its "database" clock is its own: the real monotonic time plus an offset + * specs move with `advance`, independent of `Date.now()`, so a spec can skew the application + * clock alone. + * + * Session rows live in `tables.session`: Better Auth's memory tables when `createMemoryAuthSetup` + * attaches them, or rows a spec adds with `addSession`. Ending a session deletes both rows, as the + * foreign key's cascade does; a grant whose session row is gone reads as absent. + * + * A copy of Interlock's own memory store, made strict; the two drift unless both are + * changed together. + */ +export class MemoryGrantStore implements GrantStore { + rows: StoredGrant[] = []; + tables: MemoryTables = { session: [], account: [] }; + private offset = 0; + + /** The store's clock, as `clock_timestamp()` would read it. */ + now() { + return new Date(Math.floor(performance.timeOrigin + performance.now() + this.offset)); + } + + /** Moves the store's clock; `Date.now()` is untouched. */ + advance(ms: number) { + this.offset += ms; + } + + addSession(id: string, expiresAt = new Date(this.now().getTime() + 7 * 86400_000)) { + this.tables.session.push({ id, token: `token-${id}`, expiresAt }); + } + + session(id: string) { + return this.tables.session.find((row) => row.id === id); + } + + private row(sessionId: string) { + const row = this.rows.find((r) => r.sessionId === sessionId); + if (row !== undefined && this.session(sessionId) === undefined) { + this.rows = this.rows.filter((r) => r !== row); + return undefined; + } + return row; + } + + private dropSession(sessionId: string) { + this.tables.session = this.tables.session.filter((row) => row.id !== sessionId); + this.rows = this.rows.filter((row) => row.sessionId !== sessionId); + } + + async insert(grant: NewGrant, { startedAt, lifetimes }: LifetimeAnchor) { + if (this.rows.some((row) => row.sessionId === grant.sessionId)) { + throw new Error(`duplicate session grant ${grant.sessionId}`); + } + const now = this.now(); + const elapsed = performance.now() - startedAt; + const row: StoredGrant = { + ...grant, + accessTokenExpiresAt: deadlineFrom(now, lifetimes.accessExpiresIn, elapsed), + refreshTokenExpiresAt: deadlineFrom(now, lifetimes.refreshExpiresIn, elapsed), + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: 1, + createdAt: now, + updatedAt: now, + }; + this.rows.push(row); + return { ...row }; + } + + async findBySessionId(sessionId: string) { + const row = this.row(sessionId); + return row && { ...row }; + } + + async deleteBySessionId(sessionId: string) { + this.rows = this.rows.filter((row) => row.sessionId !== sessionId); + } + + async read(sessionId: string) { + const row = this.row(sessionId); + return row && { grant: { ...row }, now: this.now() }; + } + + async claim(sessionId: string, version: number, holder: string, leaseMs: number) { + const row = this.row(sessionId); + if (row?.version !== version) { + return undefined; + } + Object.assign(row, { + leaseHolder: holder, + leaseExpiresAt: new Date(this.now().getTime() + leaseMs), + dispatched: false, + version: version + 1, + }); + return { ...row }; + } + + async markDispatched(sessionId: string, version: number, holder: string, leaseMs: number) { + const row = this.row(sessionId); + const now = this.now(); + if ( + row?.version !== version || + row.leaseHolder !== holder || + row.leaseExpiresAt === null || + row.leaseExpiresAt <= now + ) { + return undefined; + } + Object.assign(row, { + dispatched: true, + leaseExpiresAt: new Date(now.getTime() + leaseMs), + version: version + 1, + }); + return row.version; + } + + async complete({ + sessionId, + version, + holder, + tokens, + startedAt, + lifetimes, + }: CompleteInput): Promise { + const session = this.session(sessionId); + if (session === undefined) { + return { status: "unmatched" }; + } + const now = this.now(); + const elapsed = performance.now() - startedAt; + const accessTokenExpiresAt = deadlineFrom(now, lifetimes.accessExpiresIn, elapsed); + const refreshTokenExpiresAt = deadlineFrom(now, lifetimes.refreshExpiresIn, elapsed); + if (refreshTokenExpiresAt.getTime() <= now.getTime()) { + const ended = await this.endIf(sessionId, { kind: "heldBy", version, holder }); + return { status: ended ? "exhausted" : "unmatched" }; + } + const row = this.row(sessionId); + if (row?.version !== version || row.leaseHolder !== holder) { + return { status: "unmatched" }; + } + Object.assign(row, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ...(tokens.idToken === undefined ? {} : { idToken: tokens.idToken }), + accessTokenExpiresAt, + refreshTokenExpiresAt, + updatedAt: now, + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: version + 1, + }); + session.expiresAt = refreshTokenExpiresAt; + return { status: "stored", grant: { ...row }, now }; + } + + async release(sessionId: string, version: number, holder: string) { + const row = this.row(sessionId); + if (row?.version !== version || row.leaseHolder !== holder) { + return false; + } + Object.assign(row, { + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: version + 1, + }); + return true; + } + + async endSession(sessionId: string) { + this.dropSession(sessionId); + } + + async endSessionByToken(token: string) { + for (const row of this.tables.session.filter((session) => session.token === token)) { + this.dropSession(String(row.id)); + } + } + + async endIf(sessionId: string, predicate: EndPredicate) { + const row = this.row(sessionId); + const now = this.now(); + if (row === undefined || row.version !== predicate.version) { + return false; + } + const leaseExpired = row.leaseExpiresAt !== null && row.leaseExpiresAt <= now; + let matches: boolean; + if (predicate.kind === "expiredRefresh") { + matches = + row.refreshTokenExpiresAt !== null && + row.refreshTokenExpiresAt <= now && + (row.leaseHolder === null || (!row.dispatched && leaseExpired)); + } else if (predicate.kind === "expiredDispatched") { + matches = row.dispatched && leaseExpired; + } else { + matches = row.leaseHolder === predicate.holder && row.dispatched; + } + if (matches) { + this.dropSession(sessionId); + } + return matches; + } + + async endSessionsBySid(keycloakSid: string) { + return this.endSessionsWhere((row) => row.keycloakSid === keycloakSid); + } + + async endSessionsBySubject(keycloakSubject: string) { + return this.endSessionsWhere((row) => row.keycloakSubject === keycloakSubject); + } + + private endSessionsWhere(matches: (row: StoredGrant) => boolean) { + const ended = this.rows + .filter((row) => matches(row) && this.session(row.sessionId) !== undefined) + .map((row) => row.sessionId); + for (const sessionId of ended) { + this.dropSession(sessionId); + } + return ended; + } +} + +/** + * The package's Better Auth options on an in-memory store, for specs that build a real instance + * without Postgres, with the tables and the grant store returned so specs can inspect them. Every + * table is seeded, because the memory adapter throws `Model not found` on the first query + * of a table it was not given. Each call returns a fresh store. + */ +export function createMemoryAuthSetup( + config: KeycloakAuthConfig, + grants: GrantStore = new MemoryGrantStore(), + logger: AuthLogger = testLogger(), +) { + const authTables = getAuthTables( + createKeycloakAuthOptions(config, { database: memoryAdapter({}), grants, logger }), + ); + const tables: MemoryTables = { + session: [], + account: [], + ...Object.fromEntries(Object.keys(authTables).map((table) => [table, []])), + }; + if (grants instanceof MemoryGrantStore) { + grants.tables = tables; + } + return { + options: createKeycloakAuthOptions(config, { + database: memoryAdapter(tables), + grants, + logger, + }), + tables, + grants, + logger, + }; +} + +/** The options alone, for specs that only need a real instance. */ +export const createMemoryAuthOptions = (config: KeycloakAuthConfig, logger?: AuthLogger) => + createMemoryAuthSetup(config, undefined, logger).options; diff --git a/src/core/__tests__/helpers/set-cookie.ts b/src/core/__tests__/helpers/set-cookie.ts new file mode 100644 index 0000000..fea8035 --- /dev/null +++ b/src/core/__tests__/helpers/set-cookie.ts @@ -0,0 +1,32 @@ +import { parseSetCookieHeader } from "better-auth/cookies"; +import { expect } from "vitest"; + +import { defined } from "./config.js"; + +/** One `Set-Cookie` line, reduced to what the cookie specs compare. */ +export interface EmittedCookie { + name: string; + maxAge: number | undefined; +} + +/** + * Asserts the Cookie attributes gate row on every `Set-Cookie` line of a + * response: `Secure`, `HttpOnly`, `Path=/`, `SameSite=Lax`, and no `Domain`, + * in any order. Returns each cookie's name and `Max-Age` so the caller can + * check which cookies the step emitted. + */ +export function checkSetCookies(lines: readonly string[]): EmittedCookie[] { + return lines.map((line) => { + const parsed = [...parseSetCookieHeader(line)]; + expect(parsed).toHaveLength(1); + const [name, attributes] = defined(parsed[0], "a parsed cookie"); + + expect(attributes).toMatchObject({ secure: true, httponly: true, path: "/", samesite: "lax" }); + expect(attributes).not.toHaveProperty("domain"); + expect(line).not.toMatch(/;\s*domain\s*=/i); + return { name, maxAge: attributes["max-age"] }; + }); +} + +/** The sorted names of what `checkSetCookies` returned, for an exact set comparison. */ +export const namesOf = (cookies: readonly EmittedCookie[]) => cookies.map((c) => c.name).sort(); diff --git a/src/core/__tests__/keycloak-profile.test.ts b/src/core/__tests__/keycloak-profile.test.ts new file mode 100644 index 0000000..d4b4dff --- /dev/null +++ b/src/core/__tests__/keycloak-profile.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { + isPlaceholderEmail, + keycloakAccountSubject, + mapKeycloakProfile, +} from "../keycloak-profile.js"; + +describe("mapKeycloakProfile", () => { + it("should take name, email and verification from the claims", () => { + expect( + mapKeycloakProfile({ + sub: "f3a1", + name: "Ann Example", + preferred_username: "ann", + email: "ann@example.com", + email_verified: true, + }), + ).toEqual({ name: "Ann Example", email: "ann@example.com", emailVerified: true }); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["blank", " "], + ["not a string", 42], + ])("should fall back to preferred_username when name is %s", (_, name) => { + expect(mapKeycloakProfile({ sub: "f3a1", name, preferred_username: "ann" }).name).toBe("ann"); + }); + + it("should give a profile without email a placeholder that is never verified", () => { + const user = mapKeycloakProfile({ sub: "f3a1", email: " ", email_verified: true }); + + expect(user.email).toBe("f3a1@keycloak.invalid"); + expect(user.emailVerified).toBe(false); + expect(isPlaceholderEmail(user.email)).toBe(true); + }); + + it("should not treat a truthy non-boolean email_verified as verified", () => { + expect( + mapKeycloakProfile({ sub: "f3a1", email: "ann@example.com", email_verified: "true" }) + .emailVerified, + ).toBe(false); + }); +}); + +describe("keycloakAccountSubject", () => { + it("should key the account on sub", () => { + expect(keycloakAccountSubject({ profile: { sub: "f3a1", id: "other" } })).toBe("f3a1"); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["blank", " "], + ["null", null], + ])("should throw rather than fall back to id when sub is %s", (_, sub) => { + expect(() => keycloakAccountSubject({ profile: { sub, id: "other" } })).toThrow("no sub claim"); + }); +}); + +describe("isPlaceholderEmail", () => { + it.each([ + ["ann@example.com", false], + ["keycloak.invalid@example.com", false], + [null, false], + [undefined, false], + ])("should report %s as %s", (email, expected) => { + expect(isPlaceholderEmail(email)).toBe(expected); + }); +}); diff --git a/src/core/__tests__/keycloak-token.test.ts b/src/core/__tests__/keycloak-token.test.ts new file mode 100644 index 0000000..fcf12f9 --- /dev/null +++ b/src/core/__tests__/keycloak-token.test.ts @@ -0,0 +1,429 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer as createHttpServer, type IncomingMessage, type Server } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import { createServer as createNetServer, type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import tls from "node:tls"; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + httpTransport, + refreshTokens, + tokenLifetimes, + type RefreshRequest, + type TokenRequest, + type TransportResult, +} from "../keycloak-token.js"; +import { allRecords, defined, testLogger, type TestLogger } from "./helpers/config.js"; + +const REFRESH = "refresh-canary-123"; + +/** The logger of the current `refreshTokens` case; `request` hands it over. */ +let logger: TestLogger = testLogger(); + +const request = (overrides: Partial = {}): RefreshRequest => ({ + tokenUrl: "https://keycloak.test/realms/test/protocol/openid-connect/token", + clientId: "interlock", + clientSecret: "client-secret-canary", + refreshToken: REFRESH, + timeoutMs: 1000, + logger, + ...overrides, +}); + +const OK_BODY = { + access_token: "a2", + refresh_token: "r2", + id_token: "i2", + expires_in: 300, + refresh_expires_in: 1800, +}; + +/** A transport that records what it was asked to send and answers `result`. */ +const answering = (result: TransportResult) => { + const sent: TokenRequest[] = []; + const transport = async (req: TokenRequest) => { + sent.push(req); + if (result.phase === "sent") { + req.onSent?.(); + } + return result; + }; + return { sent, transport }; +}; + +const respond = (status: number, body: unknown): TransportResult => ({ + phase: "sent", + sentAt: 1, + status, + body: typeof body === "string" ? body : JSON.stringify(body), +}); + +describe("tokenLifetimes", () => { + it.each([ + ["both present", { expires_in: 300, refresh_expires_in: 1800 }, [300, 1800]], + ["both missing", {}, [null, null]], + [ + "zero (an offline token or unlimited SSO)", + { expires_in: 0, refresh_expires_in: 0 }, + [null, null], + ], + ["negative", { expires_in: -1, refresh_expires_in: 1800 }, [null, 1800]], + ["non-numeric", { expires_in: "300", refresh_expires_in: null }, [null, null]], + ["not finite", { expires_in: Infinity, refresh_expires_in: Number.NaN }, [null, null]], + ["not an object", "nope", [null, null]], + ])("should read %s", (_case, raw, [accessExpiresIn, refreshExpiresIn]) => { + expect(tokenLifetimes(raw)).toEqual({ accessExpiresIn, refreshExpiresIn }); + }); +}); + +/** No path logs the refresh token, the client secret, or a description. */ +const expectNoSecretLogged = () => { + const logged = JSON.stringify(allRecords(logger)); + expect(logged).not.toContain(REFRESH); + expect(logged).not.toContain("client-secret-canary"); + expect(logged).not.toContain("description-canary"); +}; + +describe("refreshTokens", () => { + let error: TestLogger["error"]; + + beforeEach(() => { + logger = testLogger(); + ({ error } = logger); + }); + + afterEach(() => expectNoSecretLogged()); + + it("should post a client_secret_post refresh grant as a form", async () => { + const { sent, transport } = answering(respond(200, OK_BODY)); + await refreshTokens(request({ transport })); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + url: "https://keycloak.test/realms/test/protocol/openid-connect/token", + headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, + timeoutMs: 1000, + }); + expect(Object.fromEntries(new URLSearchParams(defined(sent[0]).body))).toEqual({ + grant_type: "refresh_token", + refresh_token: REFRESH, + client_id: "interlock", + client_secret: "client-secret-canary", + }); + }); + + it("should classify a complete 200 as ok, with the lifetimes and the send time", async () => { + const { transport } = answering(respond(200, OK_BODY)); + + await expect(refreshTokens(request({ transport }))).resolves.toEqual({ + kind: "ok", + tokens: { accessToken: "a2", refreshToken: "r2", idToken: "i2" }, + lifetimes: { accessExpiresIn: 300, refreshExpiresIn: 1800 }, + sentAt: 1, + }); + }); + + it("should keep the ID token optional", async () => { + const { transport } = answering(respond(200, { ...OK_BODY, id_token: undefined })); + const result = await refreshTokens(request({ transport })); + + expect(result).toMatchObject({ kind: "ok", tokens: { idToken: undefined } }); + }); + + it.each(["access_token", "refresh_token", "expires_in", "refresh_expires_in"])( + "should classify a 200 without %s as indeterminate and log the deviation", + async (field) => { + const { transport } = answering(respond(200, { ...OK_BODY, [field]: undefined })); + + await expect(refreshTokens(request({ transport }))).resolves.toEqual({ + kind: "indeterminate", + cause: "malformed_response", + }); + expect(error).toHaveBeenCalledWith(expect.objectContaining({ missing: [field] })); + }, + ); + + it("should classify a refresh_expires_in of 0 as indeterminate", async () => { + const { transport } = answering(respond(200, { ...OK_BODY, refresh_expires_in: 0 })); + + await expect(refreshTokens(request({ transport }))).resolves.toMatchObject({ + kind: "indeterminate", + }); + }); + + it("should classify a body that is not JSON as indeterminate", async () => { + const { transport } = answering(respond(200, "proxy error")); + + await expect(refreshTokens(request({ transport }))).resolves.toEqual({ + kind: "indeterminate", + cause: "malformed_response", + }); + }); + + it("should classify invalid_grant as the end of the Keycloak session", async () => { + const { transport } = answering( + respond(400, { error: "invalid_grant", error_description: "description-canary" }), + ); + + await expect(refreshTokens(request({ transport }))).resolves.toEqual({ kind: "invalid_grant" }); + expect(error).not.toHaveBeenCalled(); + }); + + it.each([ + [400, "invalid_request"], + [401, "invalid_client"], + [400, "unauthorized_client"], + ])( + "should classify %s %s as indeterminate and log the code, not the description", + async (status, code) => { + const { transport } = answering( + respond(status, { error: code, error_description: "description-canary" }), + ); + + await expect(refreshTokens(request({ transport }))).resolves.toEqual({ + kind: "indeterminate", + cause: String(status), + }); + expect(error).toHaveBeenCalledWith(expect.objectContaining({ status, error: code })); + }, + ); + + it.each([ + [302, ""], + [500, '{"error":"unknown_error"}'], + [503, "unavailable"], + ])("should classify a %s as indeterminate", async (status, body) => { + const { transport } = answering(respond(status, body)); + + await expect(refreshTokens(request({ transport }))).resolves.toEqual({ + kind: "indeterminate", + cause: String(status), + }); + }); + + // The phase, not the code, is the evidence. + it("should classify the same error code by the phase it happened in", async () => { + const connecting = answering({ phase: "connecting", cause: "EHOSTUNREACH" }); + const sent = answering({ phase: "sent", sentAt: 1, cause: "EHOSTUNREACH" }); + + await expect(refreshTokens(request({ transport: connecting.transport }))).resolves.toEqual({ + kind: "unsent", + cause: "EHOSTUNREACH", + }); + await expect(refreshTokens(request({ transport: sent.transport }))).resolves.toEqual({ + kind: "indeterminate", + cause: "EHOSTUNREACH", + }); + }); +}); + +/** What a server received, and a handler that answers with `answer`. */ +const recording = ( + answer: (req: IncomingMessage, body: string) => (res: import("node:http").ServerResponse) => void, +) => { + const received: { + headers: IncomingMessage["headers"]; + method: string | undefined; + url: string | undefined; + body: string; + }[] = []; + const handler = (req: IncomingMessage, res: import("node:http").ServerResponse) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + received.push({ headers: req.headers, method: req.method, url: req.url, body }); + answer(req, body)(res); + }); + }; + return { received, handler }; +}; + +const send = (url: string, timeoutMs = 2000) => { + const sent = vi.fn<() => void>(); + return { + sent, + result: httpTransport({ + url, + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: "grant_type=refresh_token&refresh_token=x", + timeoutMs, + onSent: sent, + }), + }; +}; + +/** + * The real transport against local servers. HTTPS needs a certificate: + * openssl makes a self-signed one, which the default CA bundle does not + * trust until a case adds it. + */ +describe("httpTransport", () => { + let dir: string; + let cert: string; + let key: string; + const servers: { close: () => void }[] = []; + + beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), "interlock-tls-")); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=127.0.0.1", + "-addext", + "subjectAltName=IP:127.0.0.1", + "-keyout", + path.join(dir, "key.pem"), + "-out", + path.join(dir, "cert.pem"), + ], + { stdio: "ignore" }, + ); + cert = readFileSync(path.join(dir, "cert.pem"), "utf8"); + key = readFileSync(path.join(dir, "key.pem"), "utf8"); + }); + + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + afterEach(() => { + for (const server of servers.splice(0)) { + server.close(); + } + }); + + const listen = async (server: Server | ReturnType) => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push({ + close: () => { + (server as Server).closeAllConnections?.(); + server.close(); + }, + }); + return (server.address() as AddressInfo).port; + }; + + const json = () => (res: import("node:http").ServerResponse) => + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(OK_BODY)); + + it("should send the form as specified and read the answer", async () => { + const { received, handler } = recording(json); + const port = await listen(createHttpServer(handler)); + const { sent, result } = send( + `http://127.0.0.1:${port}/realms/test/protocol/openid-connect/token`, + ); + + await expect(result).resolves.toMatchObject({ phase: "sent", status: 200 }); + expect(sent).toHaveBeenCalledTimes(1); + expect(received).toEqual([ + expect.objectContaining({ + method: "POST", + url: "/realms/test/protocol/openid-connect/token", + body: "grant_type=refresh_token&refresh_token=x", + }), + ]); + expect(defined(received[0]).headers).toMatchObject({ + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + "content-length": "40", + }); + }); + + it("should be unsent when the port refuses the connection", async () => { + const port = await listen(createNetServer()); + defined(servers.pop()).close(); + const { sent, result } = send(`https://127.0.0.1:${port}/token`); + + await expect(result).resolves.toEqual({ phase: "connecting", cause: "ECONNREFUSED" }); + expect(sent).not.toHaveBeenCalled(); + }); + + it("should be unsent when the certificate is not trusted", async () => { + const { received, handler } = recording(json); + const port = await listen(createHttpsServer({ cert, key }, handler)); + const { sent, result } = send(`https://127.0.0.1:${port}/token`); + + await expect(result).resolves.toMatchObject({ phase: "connecting" }); + expect(sent).not.toHaveBeenCalled(); + expect(received).toEqual([]); + }); + + it("should be unsent when the deadline passes during the TLS handshake", async () => { + // Accepts the TCP connection and never answers the ClientHello. + const port = await listen(createNetServer(() => undefined)); + const { sent, result } = send(`https://127.0.0.1:${port}/token`, 100); + + await expect(result).resolves.toEqual({ phase: "connecting", cause: "timeout" }); + expect(sent).not.toHaveBeenCalled(); + }); + + it("should be sent and failed when the deadline passes after the body was written", async () => { + const port = await listen(createHttpServer(() => undefined)); + const { sent, result } = send(`http://127.0.0.1:${port}/token`, 100); + + await expect(result).resolves.toMatchObject({ phase: "sent", cause: "timeout" }); + expect(sent).toHaveBeenCalledTimes(1); + }); + + it("should be sent and failed when the server reads the body and closes the socket", async () => { + const { received, handler } = recording((req) => () => req.socket.destroy()); + const port = await listen(createHttpServer(handler)); + const { result } = send(`http://127.0.0.1:${port}/token`); + + const outcome = await result; + expect(outcome).toMatchObject({ phase: "sent" }); + expect("status" in outcome).toBe(false); + expect(received).toHaveLength(1); + }); + + it("should not follow a redirect", async () => { + const { received, handler } = recording( + () => (res) => res.writeHead(307, { location: "/elsewhere" }).end(), + ); + const port = await listen(createHttpServer(handler)); + const { result } = send(`http://127.0.0.1:${port}/token`); + + await expect(result).resolves.toMatchObject({ phase: "sent", status: 307 }); + expect(received).toHaveLength(1); + }); + + // A reused keep-alive socket would never emit `secureConnect` again, and a + // transport waiting for it would call a request that never started unsent. + // Trusting the certificate needs `tls.setDefaultCACertificates`, which + // Node 22.18 (the `engines` floor) lacks; the case runs on 22.19 and 24.5 up. + it.skipIf(typeof tls.setDefaultCACertificates !== "function")( + "should answer two consecutive requests over trusted TLS", + async () => { + const defaults = tls.getCACertificates("default"); + tls.setDefaultCACertificates([...defaults, cert]); + try { + const { received, handler } = recording(json); + const port = await listen(createHttpsServer({ cert, key }, handler)); + const url = `https://127.0.0.1:${port}/token`; + + for (let i = 0; i < 2; i++) { + const { sent, result } = send(url); + await expect(result).resolves.toMatchObject({ phase: "sent", status: 200 }); + expect(sent).toHaveBeenCalledTimes(1); + } + expect(received).toHaveLength(2); + } finally { + tls.setDefaultCACertificates(defaults); + } + }, + ); +}); diff --git a/src/core/__tests__/logout/backchannel.test.ts b/src/core/__tests__/logout/backchannel.test.ts new file mode 100644 index 0000000..dd60f7a --- /dev/null +++ b/src/core/__tests__/logout/backchannel.test.ts @@ -0,0 +1,508 @@ +import { setTimeout as sleep } from "node:timers/promises"; + +import { createRemoteJWKSet } from "jose"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createGrantRefresher } from "../../grants/refresh.js"; +import { + KEY_FETCH_BACKOFF_MS, + KEY_REFETCH_COOLDOWN_MS, + LOGOUT_TOKEN_INVALID, + LOGOUT_TOKEN_UNVERIFIABLE, + realmKeys, + type KeyResolutionReason, + type LogoutTokenRejection, +} from "../../logout/backchannel.js"; +import { BASE, buildAuth, type AuthInstance, type BuildOptions } from "../helpers/auth-instance.js"; +import { + allRecords, + defined, + records, + TEST_CONFIG, + testLogger, + type TestLogger, +} from "../helpers/config.js"; +import { fakeTransport, type LogoutTokenSpec } from "../helpers/fake-keycloak.js"; + +/** Past the cooldown and the backoff, which are equal. */ +const PAST_COOLDOWN_MS = Math.max(KEY_REFETCH_COOLDOWN_MS, KEY_FETCH_BACKOFF_MS) + 150; + +const deliver = async (setup: AuthInstance, token: string) => + setup.postForm("backchannel-logout", { logout_token: token }); + +const token = (setup: AuthInstance, spec: LogoutTokenSpec = {}) => setup.keycloak.logoutToken(spec); + +const sessionIds = (setup: AuthInstance) => + setup.tables.session.map((row) => row.id as string).sort(); + +const getSession = async (setup: AuthInstance, cookie: string) => + ( + await setup.auth.handler(new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } })) + ).json() as Promise; + +const now = () => Math.floor(Date.now() / 1000); + +const post = async (setup: AuthInstance, init: RequestInit) => + setup.auth.handler( + new Request(`${BASE}/api/auth/backchannel-logout`, { method: "POST", ...init }), + ); + +describe("the back-channel logout receiver", () => { + let logger: TestLogger; + let log: TestLogger["log"]; + let warn: TestLogger["warn"]; + let error: TestLogger["error"]; + let debug: TestLogger["debug"]; + + beforeEach(() => { + logger = testLogger(); + ({ log, warn, error, debug } = logger); + }); + + afterEach(() => vi.restoreAllMocks()); + + const build = (options: BuildOptions = {}) => buildAuth({ logger, ...options }); + + /** An instance with the fake serving its key set, and two logins: sid-1 and sid-2. */ + const twoLogins = async (options: BuildOptions = {}) => { + const setup = build({ discovery: true, ...options }); + const a = await setup.login(); + const b = await setup.login(); + const [rowA, rowB] = setup.grants.rows; + return { + setup, + a: { ...a, id: defined(rowA, "the first grant").sessionId }, + b: { ...b, id: defined(rowB, "the second grant").sessionId }, + }; + }; + + /** No log record names or carries a logout token. */ + const expectNoTokenLogged = (...tokens: string[]) => { + for (const record of allRecords(logger)) { + expect(Object.keys(record).filter((key) => /token/i.test(key))).toEqual([]); + for (const value of tokens) { + expect(JSON.stringify(record)).not.toContain(value); + } + } + }; + + const expectRefused = async (res: Response, reason: LogoutTokenRejection) => { + expect(res.status).toBe(400); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.json()).toMatchObject({ code: LOGOUT_TOKEN_INVALID }); + expect(records(warn)).toContainEqual({ + message: "Refused a back-channel logout token", + reason, + }); + }; + + const expectUnverifiable = async (res: Response, reason: KeyResolutionReason) => { + expect(res.status).toBe(503); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.json()).toMatchObject({ code: LOGOUT_TOKEN_UNVERIFIABLE }); + expect(records(error)).toContainEqual(expect.objectContaining({ reason })); + }; + + describe("a valid token", () => { + it("should end the sessions of its sid only", async () => { + const { setup, a, b } = await twoLogins(); + const logout = token(setup, { sid: "sid-1" }); + + const res = await deliver(setup, logout); + + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.json()).toEqual({ ended: 1 }); + expect(sessionIds(setup)).toEqual([b.id]); + expect(setup.grants.rows.map((row) => row.sessionId)).toEqual([b.id]); + expect(await getSession(setup, a.cookie)).toBeNull(); + expect(await getSession(setup, b.cookie)).not.toBeNull(); + expect(records(log)).toContainEqual({ + message: "Back-channel logout ended the sessions of a Keycloak session", + keycloakSid: "sid-1", + subject: setup.keycloak.subject, + endedSessions: [a.id], + logoutJti: expect.any(String), + }); + expect(records(debug)).toContainEqual({ + message: "Back-channel logout token header", + alg: "RS256", + typ: "logout+jwt", + kid: "fake-keycloak", + }); + expectNoTokenLogged(logout); + }); + + it("should end every session of the subject when only sub is present, as a deviation", async () => { + const { setup, a, b } = await twoLogins(); + const logout = token(setup, { sid: null }); + + const res = await deliver(setup, logout); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ended: 2 }); + expect(setup.tables.session).toEqual([]); + expect(setup.grants.rows).toEqual([]); + expect(records(warn)).toContainEqual({ + message: expect.stringContaining("contract deviation"), + subject: setup.keycloak.subject, + endedSessions: expect.arrayContaining([a.id, b.id]), + logoutJti: expect.any(String), + }); + expectNoTokenLogged(logout); + }); + + it("should end the survivor with a sub-only token after a sid logout", async () => { + const { setup, b } = await twoLogins(); + + expect(await (await deliver(setup, token(setup, { sid: "sid-1" }))).json()).toEqual({ + ended: 1, + }); + const res = await deliver(setup, token(setup, { sid: null })); + + expect(await res.json()).toEqual({ ended: 1 }); + expect(records(warn)).toContainEqual(expect.objectContaining({ endedSessions: [b.id] })); + expect(setup.tables.session).toEqual([]); + }); + + it("should match a sid-only token by sid and log no subject", async () => { + const { setup, a, b } = await twoLogins(); + + const res = await deliver(setup, token(setup, { sid: "sid-2", sub: null })); + + expect(await res.json()).toEqual({ ended: 1 }); + expect(sessionIds(setup)).toEqual([a.id]); + const record = defined( + records(log).find((r) => r.endedSessions !== undefined), + "the record of the ended sessions", + ); + expect(record).toMatchObject({ endedSessions: [b.id] }); + expect(record.subject).toBeUndefined(); + }); + + it("should answer an unknown sid and a replay with ended 0", async () => { + const { setup } = await twoLogins(); + const logout = token(setup, { sid: "sid-1" }); + + expect(await (await deliver(setup, token(setup, { sid: "sid-9" }))).json()).toEqual({ + ended: 0, + }); + expect(await (await deliver(setup, logout)).json()).toEqual({ ended: 1 }); + const replay = await deliver(setup, logout); + + expect(replay.status).toBe(200); + expect(await replay.json()).toEqual({ ended: 0 }); + expect(setup.tables.session).toHaveLength(1); + }); + + // Without a jti cache, a sub-only token replayed inside its lifetime ends + // a session created after its first delivery (the gate record's residual). + it("should end a later session when a sub-only token is replayed", async () => { + const { setup } = await twoLogins(); + const logout = token(setup, { sid: null }); + expect(await (await deliver(setup, logout)).json()).toEqual({ ended: 2 }); + await setup.login(); + + const res = await deliver(setup, logout); + + expect(await res.json()).toEqual({ ended: 1 }); + expect(setup.tables.session).toEqual([]); + }); + + it("should leave nothing behind when it lands during an in-flight refresh", async () => { + const { setup, a } = await twoLogins(); + const ctx = await setup.auth.$context; + const refresher = createGrantRefresher(setup.grants, TEST_CONFIG, { + logger, + transport: fakeTransport(setup.keycloak), + }); + // Due: past the access token's lifetime less the leeway, on the store's clock. + setup.grants.advance(250_000); + let delivered: Response | undefined; + setup.keycloak.onRefresh = async () => { + delivered = await deliver(setup, token(setup, { sid: "sid-1" })); + }; + + const outcome = await refresher.refreshIfDue(a.id, ctx); + + expect(setup.keycloak.refreshes).toBe(1); + expect(delivered?.status).toBe(200); + expect(outcome).toMatchObject({ kind: "ended" }); + expect(setup.grants.rows.map((row) => row.sessionId)).not.toContain(a.id); + expect(sessionIds(setup)).not.toContain(a.id); + expect(await getSession(setup, a.cookie)).toBeNull(); + }); + }); + + describe("a refused token", () => { + const cases: [string, LogoutTokenSpec | string, LogoutTokenRejection][] = [ + ["a wrong issuer", { claims: { iss: "https://127.0.0.1:1/realms/other" } }, "issuer"], + ["a wrong audience", { claims: { aud: "other-client" } }, "audience"], + ["an iat seven minutes old", { claims: { iat: now() - 420, exp: now() + 60 } }, "iat_stale"], + [ + "an iat two minutes ahead", + { claims: { iat: now() + 120, exp: now() + 240 } }, + "iat_future", + ], + ["a passed exp", { claims: { iat: now() - 200, exp: now() - 80 } }, "expired"], + ["no iat", { claims: { iat: undefined } }, "iat_missing"], + ["no exp", { claims: { exp: undefined } }, "exp_missing"], + ["no jti", { claims: { jti: undefined } }, "jti_missing"], + ["a blank jti", { claims: { jti: " " } }, "jti_missing"], + ["no events", { claims: { events: undefined } }, "events_missing"], + ["events without the logout member", { claims: { events: { other: {} } } }, "events_missing"], + ["a nonce", { claims: { nonce: "n" } }, "nonce_present"], + ["neither sid nor sub", { sid: null, sub: null }, "subject_missing"], + ["a blank sid", { sid: " " }, "malformed"], + ["alg none", { header: { alg: "none" } }, "alg_not_allowed"], + ["alg HS256", { header: { alg: "HS256" } }, "alg_not_allowed"], + ["a signature that does not verify", { unsigned: true }, "signature"], + ["a string that is not a JWT", "not-a-jwt", "malformed"], + ]; + + it.each(cases)("should refuse %s with a 400 and end nothing", async (_case, spec, reason) => { + const { setup } = await twoLogins(); + const logout = typeof spec === "string" ? spec : token(setup, spec); + + await expectRefused(await deliver(setup, logout), reason); + expect(setup.tables.session).toHaveLength(2); + expect(setup.grants.rows).toHaveLength(2); + expectNoTokenLogged(logout); + }); + + it("should refuse an unknown kid outside the cooldown after exactly one fetch", async () => { + const { setup } = await twoLogins(); + expect((await deliver(setup, token(setup, { sid: "sid-9" }))).status).toBe(200); + await sleep(PAST_COOLDOWN_MS); + const before = setup.keycloak.certsFetches; + + await expectRefused( + await deliver(setup, token(setup, { header: { kid: "unknown" } })), + "unknown_key", + ); + expect(setup.keycloak.certsFetches - before).toBe(1); + expect(setup.tables.session).toHaveLength(2); + }); + + it("should refuse a missing or empty logout_token", async () => { + const { setup } = await twoLogins(); + + await expectRefused(await setup.postForm("backchannel-logout", {}), "token_missing"); + await expectRefused( + await setup.auth.handler( + new Request(`${BASE}/api/auth/backchannel-logout`, { method: "POST" }), + ), + "token_missing", + ); + await expectRefused( + await setup.postForm("backchannel-logout", { logout_token: "" }), + "token_missing", + ); + expect(setup.tables.session).toHaveLength(2); + }); + + // better-call keeps the last of repeated fields, so the duplicate itself + // is not detectable; the value kept is judged like any other. + it("should judge the last of repeated logout_token fields", async () => { + const { setup } = await twoLogins(); + + await expectRefused( + await setup.postForm("backchannel-logout", "logout_token=a&logout_token=b"), + "malformed", + ); + expect(setup.tables.session).toHaveLength(2); + }); + }); + + describe("key rotation", () => { + it("should answer 503 inside the cooldown and verify the new key after it", async () => { + const { setup, b } = await twoLogins(); + expect((await deliver(setup, token(setup, { sid: "sid-9" }))).status).toBe(200); + setup.keycloak.rotateKey(); + const rotated = token(setup, { sid: "sid-1" }); + const before = setup.keycloak.certsFetches; + + await expectUnverifiable(await deliver(setup, rotated), "key_refresh_cooling"); + expect(setup.keycloak.certsFetches).toBe(before); + expect(setup.tables.session).toHaveLength(2); + + await sleep(PAST_COOLDOWN_MS); + const res = await deliver(setup, rotated); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ended: 1 }); + expect(setup.keycloak.certsFetches - before).toBe(1); + + // The old key stays in the set, passive, and keeps verifying. + const old = await deliver(setup, token(setup, { sid: "sid-2", keyIndex: 0 })); + expect(old.status).toBe(200); + expect(await old.json()).toEqual({ ended: 1 }); + expect(sessionIds(setup)).not.toContain(b.id); + expect(setup.keycloak.certsFetches - before).toBe(1); + }); + }); + + describe("an unavailable key set", () => { + it.each([ + ["a 503", { status: 503, body: { error: "unavailable" } }, "jwks_status"], + ["a body that is not JSON", { text: "" }, "jwks_malformed"], + ["keys that are not an array", { body: { keys: "x" } }, "jwks_malformed"], + ] as const)("should answer %s from the JWKS with a 503", async (_case, response, reason) => { + const { setup } = await twoLogins(); + setup.keycloak.certsResponse = response; + const logout = token(setup, { sid: "sid-1" }); + + await expectUnverifiable(await deliver(setup, logout), reason); + expect(setup.tables.session).toHaveLength(2); + expectNoTokenLogged(logout); + }); + + it("should answer a refused connection with a 503", async () => { + const { setup } = await twoLogins({ + logoutKeys: realmKeys("https://127.0.0.1:1/realms/gone"), + }); + + await expectUnverifiable( + await deliver(setup, token(setup, { sid: "sid-1" })), + "jwks_unreachable", + ); + expect(setup.tables.session).toHaveLength(2); + }); + + it("should answer a JWKS timeout with a 503", async () => { + const certs = "https://127.0.0.1:1/realms/test/protocol/openid-connect/certs"; + const { setup } = await twoLogins({ + logoutKeys: createRemoteJWKSet(new URL(certs), { timeoutDuration: 50 }), + }); + const serve = setup.keycloak.fetch; + // jose asks with a string URL and a timeout signal; the rest is the fake. + setup.fetch.mockImplementation(async (input, init) => { + if (input !== certs) { + return serve(input, init); + } + const signal = defined(init?.signal, "the timeout signal"); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason)); + }); + }); + + await expectUnverifiable( + await deliver(setup, token(setup, { sid: "sid-1" })), + "jwks_timeout", + ); + expect(setup.tables.session).toHaveLength(2); + }); + + it("should fetch once per backoff during an outage and keep verifying cached keys", async () => { + const { setup } = await twoLogins(); + expect((await deliver(setup, token(setup, { sid: "sid-9" }))).status).toBe(200); + await sleep(PAST_COOLDOWN_MS); + setup.keycloak.certsResponse = { status: 503, body: {} }; + const before = setup.keycloak.certsFetches; + + const answers: number[] = []; + for (let i = 0; i < 5; i += 1) { + answers.push( + (await deliver(setup, token(setup, { header: { kid: `unknown-${i}` } }))).status, + ); + } + + expect(answers).toEqual([503, 503, 503, 503, 503]); + expect(setup.keycloak.certsFetches - before).toBe(1); + expect( + records(error) + .map((r) => r.reason) + .filter((reason) => reason !== undefined), + ).toEqual(["jwks_status", "jwks_backoff", "jwks_backoff", "jwks_backoff", "jwks_backoff"]); + const cached = await deliver(setup, token(setup, { sid: "sid-1" })); + expect(cached.status).toBe(200); + expect(await cached.json()).toEqual({ ended: 1 }); + expect(setup.keycloak.certsFetches - before).toBe(1); + + await sleep(PAST_COOLDOWN_MS); + await expectUnverifiable( + await deliver(setup, token(setup, { header: { kid: "unknown-5" } })), + "jwks_status", + ); + expect(setup.keycloak.certsFetches - before).toBe(2); + }); + + it("should keep verifying an expired cache through an outage", async () => { + const certs = `${TEST_CONFIG.keycloak.issuer}/protocol/openid-connect/certs`; + const { setup } = await twoLogins({ + logoutKeys: createRemoteJWKSet(new URL(certs), { + cooldownDuration: KEY_REFETCH_COOLDOWN_MS, + cacheMaxAge: 1_500, + }), + }); + const unknown = async () => deliver(setup, token(setup, { header: { kid: "unknown" } })); + expect((await deliver(setup, token(setup, { sid: "sid-9" }))).status).toBe(200); + await sleep(1_650); + setup.keycloak.certsResponse = { status: 503, body: {} }; + const before = setup.keycloak.certsFetches; + + // The request that triggers the failed fetch still verifies. + expect((await deliver(setup, token(setup, { sid: "sid-9" }))).status).toBe(200); + expect(setup.keycloak.certsFetches - before).toBe(1); + // During the backoff: no fetch, cached keys verify, unknown ones are 503s. + expect((await deliver(setup, token(setup, { sid: "sid-1" }))).status).toBe(200); + await expectUnverifiable(await unknown(), "jwks_backoff"); + expect(setup.keycloak.certsFetches - before).toBe(1); + + // After the backoff the next request fetches again, fails, and still verifies. + await sleep(PAST_COOLDOWN_MS); + const res = await deliver(setup, token(setup, { sid: "sid-2" })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ended: 1 }); + expect(setup.keycloak.certsFetches - before).toBe(2); + await expectUnverifiable(await unknown(), "jwks_backoff"); + }); + }); + + describe("requests the framework answers", () => { + it.each([ + ["a JSON body", { "content-type": "application/json" }, '{"logout_token":"x"}'], + ["malformed JSON", { "content-type": "application/json" }, '{"logout_token":'], + ["a text/plain body", { "content-type": "text/plain" }, "logout_token=x"], + ["no content type", {}, new TextEncoder().encode("logout_token=x")], + ] as const)("should answer %s with a 415 before the handler", async (_case, headers, body) => { + const { setup } = await twoLogins(); + + const res = await post(setup, { headers, body }); + + expect(res.status).toBe(415); + expect(await res.json()).toMatchObject({ code: "UNSUPPORTED_MEDIA_TYPE" }); + // A recorded exception: framework answers carry no no-store, and no session data. + expect(res.headers.get("cache-control")).toBeNull(); + expect(warn).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Refused a back-channel logout token" }), + ); + expect(setup.tables.session).toHaveLength(2); + }); + + it("should answer GET with a 404", async () => { + const { setup } = await twoLogins(); + + const res = await setup.auth.handler(new Request(`${BASE}/api/auth/backchannel-logout`)); + + expect(res.status).toBe(404); + }); + }); + + // Keycloak sends every logout from one address and never retries a 429. + it("should be exempt from the rate limiter that governs sign-out", async () => { + const setup = build({ discovery: true, rateLimit: true }); + await setup.auth.$context; + + const receiver: number[] = []; + for (let i = 0; i < 150; i += 1) { + receiver.push((await deliver(setup, token(setup, { sid: `sid-${i + 100}` }))).status); + } + const signOut: number[] = []; + for (let i = 0; i < 150; i += 1) { + signOut.push((await setup.post("sign-out", "", {})).status); + } + + expect(new Set(receiver)).toEqual(new Set([200])); + expect(signOut).toContain(429); + }); +}); diff --git a/src/core/__tests__/logout/sign-out.test.ts b/src/core/__tests__/logout/sign-out.test.ts new file mode 100644 index 0000000..ecc8e0e --- /dev/null +++ b/src/core/__tests__/logout/sign-out.test.ts @@ -0,0 +1,369 @@ +import { betterAuth } from "better-auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createGrantRefresher } from "../../grants/refresh.js"; +import { + KEYCLOAK_LOGOUT_DEADLINE_MS, + type UnconfirmedReason, +} from "../../logout/keycloak-logout.js"; +import { SIGN_OUT_INCOMPLETE } from "../../logout/sign-out.js"; +import { BASE, buildAuth, type AuthInstance, type BuildOptions } from "../helpers/auth-instance.js"; +import { defined, records, TEST_CONFIG, testLogger, type TestLogger } from "../helpers/config.js"; +import { + FakeKeycloak, + fakeTransport, + loginThrough, + type NextLogout, +} from "../helpers/fake-keycloak.js"; +import { createMemoryAuthSetup } from "../helpers/memory-store.js"; + +const signOut = async (setup: AuthInstance, cookie: string, body: object = {}) => { + const res = await setup.post("sign-out", cookie, body); + return { res, body: (await res.json()) as Record }; +}; + +const getSession = async (setup: AuthInstance, cookie: string) => + ( + await setup.auth.handler(new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } })) + ).json() as Promise; + +const login = async (setup: AuthInstance) => { + const { cookie } = await setup.login(); + const { sessionId } = defined(setup.grants.rows.at(-1), "the newest grant"); + return { cookie, sessionId }; +}; + +/** Nothing a browser receives names a Keycloak URL or carries a token. */ +const expectNothingForTheBrowser = (res: Response, body: Record) => { + expect(res.headers.get("location")).toBeNull(); + expect(Object.keys(body)).not.toContain("url"); + expect(Object.keys(body)).not.toContain("redirect"); + const text = JSON.stringify(body); + expect(text).not.toContain("protocol/openid-connect"); + expect(text).not.toContain(".ey"); +}; + +const clearedCookies = (res: Response) => + res.headers.getSetCookie().filter((line) => /max-age=0/i.test(line)); + +/** + * Sign-out ends the local session, proves the deletion with a statement + * of its own, and ends the Keycloak session from the server with the grant's + * ID token. The browser gets `{ success, keycloakLogout }`, never a token, a + * Keycloak URL or a `Location`. The fake answers the logout POST as Keycloak + * 26.7 does (`FakeKeycloak.logout`). + */ +describe("sign-out", () => { + let logger: TestLogger; + let log: TestLogger["log"]; + let warn: TestLogger["warn"]; + let error: TestLogger["error"]; + + beforeEach(() => { + logger = testLogger(); + ({ log, warn, error } = logger); + }); + + afterEach(() => vi.restoreAllMocks()); + + const build = (options: BuildOptions = {}) => buildAuth({ logger, ...options }); + + /** No log record names or holds an ID token. */ + const expectNoTokenLogged = (setup: AuthInstance) => { + for (const record of [log, warn, error].flatMap(records)) { + expect(Object.keys(record).filter((key) => /token/i.test(key))).toEqual([]); + for (const token of setup.keycloak.idTokens) { + expect(JSON.stringify(record)).not.toContain(token); + } + } + }; + + it("should end the Keycloak session from the server and hand the browser nothing", async () => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + + const { res, body } = await signOut(setup, cookie); + + expect(res.status).toBe(200); + expect(body).toEqual({ success: true, keycloakLogout: "ended" }); + expectNothingForTheBrowser(res, body); + expect(clearedCookies(res)).toHaveLength(3); + expect(setup.keycloak.logouts).toEqual([ + { + hint: setup.keycloak.idTokens[0], + sid: "sid-1", + clientId: "interlock", + redirect: `${BASE}/`, + state: expect.stringMatching(/^[\w-]{22}$/), + cookie: false, + }, + ]); + expect(setup.keycloak.endedSids).toEqual(new Set(["sid-1"])); + expect(setup.tables.session).toEqual([]); + await expect(setup.grants.findBySessionId(sessionId)).resolves.toBeUndefined(); + expect(await getSession(setup, cookie)).toBeNull(); + expect(records(log)).toContainEqual({ + message: "Sign-out ended the Keycloak session", + sessionId, + keycloakSid: "sid-1", + }); + expectNoTokenLogged(setup); + }); + + // A trailing slash passes configuration; the redirect URI is still the root. + it("should send the root as the redirect URI when BETTER_AUTH_URL ends in a slash", async () => { + const keycloak = new FakeKeycloak(); + vi.spyOn(globalThis, "fetch").mockImplementation(keycloak.fetch); + const auth = betterAuth( + createMemoryAuthSetup( + { ...TEST_CONFIG, betterAuth: { ...TEST_CONFIG.betterAuth, url: `${BASE}/` } }, + undefined, + logger, + ).options, + ); + const { cookie } = await loginThrough(auth.handler, keycloak); + + const res = await auth.handler( + new Request(`${BASE}/api/auth/sign-out`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE, cookie }, + body: "{}", + }), + ); + + expect(await res.json()).toEqual({ success: true, keycloakLogout: "ended" }); + expect(keycloak.logouts.map((l) => l.redirect)).toEqual([`${BASE}/`]); + }); + + it("should send the latest ID token after a refresh", async () => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + const refresher = createGrantRefresher(setup.grants, TEST_CONFIG, { + logger, + transport: fakeTransport(setup.keycloak), + }); + setup.grants.advance(250_000); + expect(await refresher.refreshIfDue(sessionId, await setup.auth.$context)).toMatchObject({ + kind: "ok", + refreshed: true, + }); + + const { body } = await signOut(setup, cookie); + + expect(body.keycloakLogout).toBe("ended"); + expect(defined(setup.keycloak.logouts[0]).hint).toBe(setup.keycloak.idTokens[1]); + }); + + // The other Keycloak session of the same user is not touched; local + // sessions sharing the ended one's sid end through the back-channel + // delivery Keycloak makes before it answers. + it("should leave the user's other Keycloak session alone and end only this sid's", async () => { + const setup = build({ discovery: true }); + const a = await login(setup); + setup.keycloak.next = { sid: "sid-1" }; + const sameSid = await login(setup); + const other = await login(setup); + const delivered: number[] = []; + setup.keycloak.onLogout = async (sid) => { + const res = await setup.postForm("backchannel-logout", { + logout_token: setup.keycloak.logoutToken({ sid }), + }); + delivered.push(res.status); + }; + + const { body } = await signOut(setup, a.cookie); + + expect(body).toEqual({ success: true, keycloakLogout: "ended" }); + expect(delivered).toEqual([200]); + expect(setup.keycloak.endedSids).toEqual(new Set(["sid-1"])); + expect(await getSession(setup, sameSid.cookie)).toBeNull(); + expect(await getSession(setup, other.cookie)).toMatchObject({ + session: { id: other.sessionId }, + }); + }); + + describe("an unconfirmed Keycloak logout", () => { + const cases: [string, NextLogout, UnconfirmedReason, number | undefined][] = [ + ["the confirmation page", { status: 200 }, "unexpected_status", 200], + [ + "the logged-out page", + { status: 200, text: "You are logged out" }, + "unexpected_status", + 200, + ], + [ + "an error page", + { status: 400, text: "Invalid parameter: id_token_hint" }, + "unexpected_status", + 400, + ], + ["a server error", { status: 502 }, "unexpected_status", 502], + [ + "a redirect elsewhere", + { status: 302, location: "https://elsewhere.test/" }, + "unexpected_redirect", + 302, + ], + ["a redirect with another state", { wrongState: true }, "unexpected_redirect", 302], + ["a refused connection", { refuse: true }, "unreachable", undefined], + ]; + + it.each(cases)( + "should report %s as unconfirmed and still sign out locally", + async (_case, next, reason, status) => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + setup.keycloak.nextLogout = next; + + const { res, body } = await signOut(setup, cookie); + + expect(res.status).toBe(200); + expect(body).toEqual({ success: true, keycloakLogout: "unconfirmed" }); + expectNothingForTheBrowser(res, body); + expect(clearedCookies(res)).toHaveLength(3); + expect(setup.tables.session).toEqual([]); + expect(records(warn)).toContainEqual({ + message: "Sign-out could not confirm that the Keycloak session ended", + sessionId, + keycloakSid: "sid-1", + reason, + status, + }); + expectNoTokenLogged(setup); + }, + ); + + it("should give up at the deadline", async () => { + const setup = build(); + const { cookie } = await login(setup); + setup.keycloak.nextLogout = { hang: true }; + const started = Date.now(); + + const { res, body } = await signOut(setup, cookie); + + const took = Date.now() - started; + expect(took).toBeGreaterThanOrEqual(KEYCLOAK_LOGOUT_DEADLINE_MS - 50); + expect(took).toBeLessThan(KEYCLOAK_LOGOUT_DEADLINE_MS + 2_000); + expect(res.status).toBe(200); + expect(body).toEqual({ success: true, keycloakLogout: "unconfirmed" }); + expect(records(warn)).toContainEqual(expect.objectContaining({ reason: "timeout" })); + expect(setup.tables.session).toEqual([]); + }); + + it("should not attempt it for a session whose grant is gone", async () => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + await setup.grants.deleteBySessionId(sessionId); + + const { body } = await signOut(setup, cookie); + + expect(body).toEqual({ success: true, keycloakLogout: "unconfirmed" }); + expect(setup.keycloak.logouts).toEqual([]); + expect(records(warn)).toContainEqual(expect.objectContaining({ reason: "no_hint" })); + expect(setup.tables.session).toEqual([]); + }); + + it("should not attempt it when the grant cannot be read", async () => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + vi.spyOn(setup.grants, "findBySessionId").mockRejectedValue(new Error("connection lost")); + + const { body } = await signOut(setup, cookie); + + expect(body).toEqual({ success: true, keycloakLogout: "unconfirmed" }); + expect(setup.keycloak.logouts).toEqual([]); + expect(warn).toHaveBeenCalledWith({ + message: + "Sign-out could not read the session grant; the Keycloak logout will be unconfirmed", + sessionId, + }); + expect(setup.tables.session).toEqual([]); + }); + }); + + describe("no session", () => { + it("should answer no_session without a session cookie and call nothing", async () => { + const setup = build(); + await login(setup); + + const { res, body } = await signOut(setup, ""); + + expect(res.status).toBe(200); + expect(body).toEqual({ success: true, keycloakLogout: "no_session" }); + expectNothingForTheBrowser(res, body); + expect(setup.keycloak.logouts).toEqual([]); + expect(setup.tables.session).toHaveLength(1); + }); + + // A replayed cookie after an unconfirmed sign-out must not read as a + // global logout: no_session asserts nothing about Keycloak. + it("should answer no_session, not ended, when the cookie is replayed after sign-out", async () => { + const setup = build(); + const { cookie } = await login(setup); + setup.keycloak.nextLogout = { status: 502 }; + expect((await signOut(setup, cookie)).body.keycloakLogout).toBe("unconfirmed"); + + const { body } = await signOut(setup, cookie); + + expect(body).toEqual({ success: true, keycloakLogout: "no_session" }); + expect(setup.keycloak.logouts).toHaveLength(1); + }); + }); + + describe("the local deletion", () => { + // Better Auth's own delete swallows a failed lookup and still answers + // success, so its answer proves nothing; the grant store's statement does. + it("should establish the deletion itself when Better Auth's did nothing", async () => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + const ctx = await setup.auth.$context; + vi.spyOn(ctx.internalAdapter, "deleteSession").mockResolvedValue(undefined); + + const { res, body } = await signOut(setup, cookie); + + expect(res.status).toBe(200); + expect(body).toEqual({ success: true, keycloakLogout: "ended" }); + expect(setup.tables.session).toEqual([]); + await expect(setup.grants.findBySessionId(sessionId)).resolves.toBeUndefined(); + }); + + it("should answer 503 sign_out_incomplete, cookies cleared, when the session cannot be deleted", async () => { + const setup = build(); + const { cookie, sessionId } = await login(setup); + const ctx = await setup.auth.$context; + vi.spyOn(ctx.internalAdapter, "deleteSession").mockResolvedValue(undefined); + vi.spyOn(setup.grants, "endSessionByToken").mockRejectedValue(new Error("connection lost")); + + const { res, body } = await signOut(setup, cookie); + + expect(res.status).toBe(503); + expect(body).toMatchObject({ code: SIGN_OUT_INCOMPLETE, keycloakLogout: "ended" }); + expect(body).not.toHaveProperty("success"); + expectNothingForTheBrowser(res, body); + expect(clearedCookies(res)).toHaveLength(3); + expect(setup.tables.session).toHaveLength(1); + expect(records(error)).toContainEqual({ + message: "Sign-out could not end the session in the database", + sessionId, + }); + expectNoTokenLogged(setup); + }); + }); + + it("should leave a sign-out Better Auth refused alone", async () => { + const setup = build(); + const { cookie } = await login(setup); + + const res = await setup.auth.handler( + new Request(`${BASE}/api/auth/sign-out`, { + method: "POST", + headers: { "content-type": "application/json", origin: "https://evil.test", cookie }, + body: "{}", + }), + ); + + expect(res.status).toBe(403); + expect(setup.keycloak.logouts).toEqual([]); + expect(setup.tables.session).toHaveLength(1); + }); +}); diff --git a/src/core/auth.options.ts b/src/core/auth.options.ts new file mode 100644 index 0000000..135fb38 --- /dev/null +++ b/src/core/auth.options.ts @@ -0,0 +1,248 @@ +import type { BetterAuthOptions } from "better-auth"; +import { genericOAuth, keycloak } from "better-auth/plugins/generic-oauth"; +import type { Kysely } from "kysely"; + +import type { CookieNamePolicy, KeycloakAuthConfig } from "./config.js"; +import { AUTH_BASE_PATH, COOKIE_PREFIX } from "./constants.js"; +import { exposedRoutesFor, exposeRoutes, stripProviderTokens } from "./exposure.js"; +import { + composeAfterHooks, + composeBeforeHooks, + recordCallbackStart, +} from "./grants/callback-start.js"; +import { grantHooks } from "./grants/grant-hooks.js"; +import type { GrantStore } from "./grants/grant-store.js"; +import { keycloakAccountSubject, mapKeycloakProfile } from "./keycloak-profile.js"; +import type { AuthLogger } from "./logger.js"; +import { backchannelLogout } from "./logout/backchannel.js"; +import { captureSignOut, finishSignOut } from "./logout/sign-out.js"; + +/** + * `openid` is listed even though discovery would add it: when discovery fails + * at startup the provider runs as plain OAuth and would not request it. + * Roles, groups, tenants and audiences arrive as the realm's default client + * scopes, so they are not requested here. + */ +export const KEYCLOAK_SCOPES = ["openid", "profile", "email"]; + +/** What `createKeycloakAuthOptions` stores sessions, users and accounts in. */ +export type AuthDatabase = BetterAuthOptions["database"]; + +/** + * Better Auth's built-in Kysely support over the host's pool, the runtime + * store. `schemaName` qualifies every statement, so the runtime never depends + * on `search_path`. + * + * `transaction` must be set: Better Auth forwards this shape's flag as it is + * and treats unset as false (a raw `pg.Pool` would get it on by default), and + * without it `createOAuthUser` commits the `user` row before a failing + * `account` insert. + */ +export const createPostgresStore = (db: Kysely, schemaName: string) => + ({ db, type: "postgres", schemaName, transaction: true }) satisfies AuthDatabase; + +/** + * The cookie part of Better Auth's `advanced` options for a cookie-name policy + * and a cookie prefix (`COOKIE_PREFIX`, `interlock`, unless the host names + * its own). + * + * Better Auth names every cookie through one getter, as + * `${secureCookiePrefix}${cookiePrefix}.${kind}`, where the secure prefix is + * `__Secure-` while `useSecureCookies` is on and empty otherwise. The OAuth + * `state` cookie goes through the same getter, so `cookiePrefix` covers it + * without listing kinds. A plugin that calls `ctx.setCookie` with a name of + * its own bypasses this, so audit a plugin's cookies before adding it. + * + * - `host`: secure cookies off and `cookiePrefix` `__Host-`, so every + * name is literally `__Host-.` and browsers enforce `__Host-`. + * - `secure`: Better Auth's own `__Secure-.`. + * + * Both pin `Secure` rather than letting Better Auth infer it from the base + * URL. An `http` BETTER_AUTH_URL then yields cookies a browser drops, which + * fails closed instead of issuing an unprefixed, insecure session cookie. + */ +export function cookieSettings(policy: CookieNamePolicy, prefix = COOKIE_PREFIX) { + return { + useSecureCookies: policy === "secure", + cookiePrefix: policy === "host" ? `__Host-${prefix}` : prefix, + defaultCookieAttributes: { secure: true }, + } satisfies BetterAuthOptions["advanced"]; +} + +/** + * The receiver's route under the base path: the host's configuration holds + * the back-channel logout path under it, so the rest is the route. + */ +export const receiverRouteOf = (config: Pick) => + config.keycloak.backchannelLogoutPath.slice(AUTH_BASE_PATH.length); + +/** + * Better Auth's log calls, as records the host logger can redact. Better + * Auth's own arguments go under `args` as they are, and can hold tokens. + */ +const betterAuthLog = + (logger: AuthLogger): NonNullable["log"] => + (level, message, ...args) => { + // Some of Better Auth's own messages already carry the tag. + const record = { + message: `[better-auth] ${message.replace(/^\[better-auth\]\s*/i, "")}`, + args, + }; + switch (level) { + case "error": + logger.error(record); + break; + case "warn": + logger.warn(record); + break; + case "debug": + logger.debug(record); + break; + default: + logger.log(record); + } + }; + +/** What `createKeycloakAuthOptions` builds on besides the configuration. */ +export interface KeycloakAuthOptions { + /** + * Where sessions, users and accounts live: `createPostgresStore` at + * runtime, a memory store in unit tests. + */ + database: AuthDatabase; + /** Where the grant hooks write each session's provider tokens. */ + grants: GrantStore; + /** + * Where the hooks, the receiver, sign-out and Better Auth itself log. The + * host's logger redacts; see `AuthLogger`. + */ + logger: AuthLogger; + /** The base of every cookie name; `COOKIE_PREFIX` unless given. */ + cookiePrefix?: string; +} + +/** + * Builds the Better Auth options for a Keycloak login with session-scoped + * grants from validated configuration and the stores, logger and cookie + * prefix in `options`. Pure: nothing connects until Better Auth initializes. + */ +export function createKeycloakAuthOptions( + config: KeycloakAuthConfig, + { database, grants, logger, cookiePrefix = COOKIE_PREFIX }: KeycloakAuthOptions, +): BetterAuthOptions { + // Without a database Better Auth 1.7 silently switches to its stateless + // mode, with the session and the provider tokens in encrypted cookies. + if (!database) { + throw new Error("Better Auth needs a database; without one it keeps sessions in cookies"); + } + // Without the grant hooks the account row would keep the provider tokens + // and no session would have a grant. + if (!grants) { + throw new Error("Better Auth needs a grant store; without one the provider tokens are lost"); + } + // A missing logger would surface as a crash inside a hook, mid-request. + if (!logger) { + throw new Error("Better Auth needs a logger; the hooks and the receiver report through it"); + } + const issuer = config.keycloak.issuer.replace(/\/+$/, ""); + const endpoint = (name: string) => `${issuer}/protocol/openid-connect/${name}`; + const receiverRoute = receiverRouteOf(config); + + return { + baseURL: config.betterAuth.url, + basePath: AUTH_BASE_PATH, + // Passed explicitly so Better Auth never parses BETTER_AUTH_SECRETS itself; + // its parser echoes a malformed entry, secret included. `secret` is left + // unset: Better Auth takes the first, newest entry of this list. + secrets: config.betterAuth.secrets, + trustedOrigins: config.trustedOrigins, + telemetry: { enabled: false }, + logger: { level: "info", log: betterAuthLog(logger) }, + database, + // Stated off as well as implied by the database, so the stateless cookie + // session and the account cookie can never come back. The grant hooks + // strip the tokens from every account write, so the row is the identity + // link only and each session's tokens live on its `session_grant` row. + // + // The slide is off: the refresher owns `expiresAt` and moves it to the + // realm's refresh deadline on every refresh, so Better Auth's own slide + // could only write the clamped value back and reissue the cookie on every + // `get-session`. The session update hook still clamps any slide. + session: { cookieCache: { enabled: false }, disableSessionRefresh: true }, + account: { encryptOAuthTokens: true, storeAccountCookie: false }, + // The exposure hook runs first, so a route outside the allow-list, or an + // `idToken` sign-in, runs nothing else (`exposure.ts`). The callback's + // start then anchors the login's deadlines (`callback-start.ts`), and + // sign-out keeps the grant's ID token before the session goes. After the + // handler, sign-out establishes the local deletion and ends the Keycloak + // session from the server (`logout/sign-out.ts`), and provider token + // fields are stripped from every result. + hooks: { + before: composeBeforeHooks([ + exposeRoutes(exposedRoutesFor(receiverRoute)), + recordCallbackStart(), + captureSignOut(grants, logger), + ]), + after: composeAfterHooks([ + finishSignOut(grants, { + logger, + issuer, + clientId: config.keycloak.clientId, + // Never followed: Keycloak's redirect to it is the proof of logout. + // Resolved, not appended, so a trailing slash in BETTER_AUTH_URL + // cannot make it `//`. + postLogoutRedirectUri: new URL("/", config.betterAuth.url).href, + }), + stripProviderTokens(), + ]), + }, + // The limiter is on in production (100 per 10 s per address and path, in + // each replica's memory). Every logout token comes from Keycloak's one + // address and Keycloak never retries a refused delivery, so a burst of + // logouts must never meet a 429; the receiver bounds its own cost (one + // JWKS fetch per second at most, `logout/backchannel.ts`). + rateLimit: { customRules: { [receiverRoute]: false } }, + databaseHooks: grantHooks(grants, logger), + advanced: { + ...cookieSettings(config.cookieNamePolicy, cookiePrefix), + // Better Auth turns both checks off whenever NODE_ENV is `test` or TEST + // is set. Pinned on so that neither the e2e suite nor a misconfigured + // deployment runs without origin and callback URL validation. + disableOriginCheck: false, + disableCSRFCheck: false, + }, + plugins: [ + genericOAuth({ + config: [ + { + ...keycloak({ + clientId: config.keycloak.clientId, + clientSecret: config.keycloak.clientSecret, + issuer, + scopes: KEYCLOAK_SCOPES, + }), + // Set beside discovery, which still runs and still supplies the + // issuer and JWKS. A provider whose discovery fetch fails at + // startup is otherwise skipped until the process restarts. + authorizationUrl: endpoint("auth"), + tokenUrl: endpoint("token"), + userInfoUrl: endpoint("userinfo"), + // Better Auth would hand the browser an end-session URL built + // from the account row; sign-out ends the Keycloak session from + // the server instead (`logout/sign-out.ts`), so no token or + // Keycloak URL reaches the browser. + disableProviderLogout: true, + accountSubject: keycloakAccountSubject, + mapProfileToUser: mapKeycloakProfile, + }, + ], + }), + backchannelLogout(grants, { + logger, + route: receiverRoute, + issuer, + clientId: config.keycloak.clientId, + }), + ], + }; +} diff --git a/src/core/callback-path.ts b/src/core/callback-path.ts new file mode 100644 index 0000000..453e9f4 --- /dev/null +++ b/src/core/callback-path.ts @@ -0,0 +1,88 @@ +import { getOAuthCallbackPath } from "better-auth/oauth2"; + +import { KEYCLOAK_CALLBACK_PATH, KEYCLOAK_PROVIDER_ID } from "./constants.js"; +import type { AuthLogger } from "./logger.js"; + +/** A startup check failed. Messages carry paths and ids, never configuration values. */ +export class StartupError extends Error { + constructor(message: string) { + super(message); + this.name = "StartupError"; + } +} + +interface CallbackProvider { + id: string; + callbackPath?: string; + issuer?: string; +} + +/** The part of a Better Auth instance the check reads. */ +export interface CallbackPathSource { + $context: Promise<{ baseURL: string; socialProviders: CallbackProvider[] }>; + api: object; +} + +/** The core endpoint every social provider's callback is served by. */ +const CALLBACK_ENDPOINT = "/callback/:id"; + +/** + * The callback path Better Auth actually serves for a provider, worked out the + * way its sign-in endpoint builds the `redirect_uri`: the resolved base URL's + * path (which already includes `basePath`) plus the provider's callback path. + */ +export async function resolveCallbackPath(auth: CallbackPathSource, providerId: string) { + const endpoint: unknown = Reflect.get(auth.api, "callbackOAuth"); + const endpointPath: unknown = + endpoint && typeof endpoint === "function" ? Reflect.get(endpoint, "path") : undefined; + if (endpointPath !== CALLBACK_ENDPOINT) { + throw new StartupError( + `Better Auth no longer serves OAuth callbacks at ${CALLBACK_ENDPOINT} ` + + `(found ${JSON.stringify(endpointPath) ?? "nothing"}); the realm's redirect URI would not match`, + ); + } + + const ctx = await auth.$context; + const provider = ctx.socialProviders.find((p) => p.id === providerId); + if (!provider) { + throw new StartupError(`OAuth provider ${providerId} is not registered`); + } + + return { path: new URL(ctx.baseURL).pathname + getOAuthCallbackPath(provider), provider }; +} + +export interface CallbackPathOptions { + /** Where the resolved path, and a failed discovery, are reported. */ + logger: AuthLogger; + /** The realm's redirect URI path; `KEYCLOAK_CALLBACK_PATH` unless the host registers another. */ + expected?: string; +} + +/** + * Fails startup unless the Keycloak callback Better Auth serves is the one + * path the realm registers as a redirect URI. A dependency bump that moves + * the route, or a `BETTER_AUTH_URL` with a path, stops the app here instead + * of failing every login at Keycloak. + */ +export async function assertCallbackPath( + auth: CallbackPathSource, + { logger, expected = KEYCLOAK_CALLBACK_PATH }: CallbackPathOptions, +) { + const { path, provider } = await resolveCallbackPath(auth, KEYCLOAK_PROVIDER_ID); + if (path !== expected) { + throw new StartupError( + `Better Auth serves the ${KEYCLOAK_PROVIDER_ID} callback at ${path}, ` + + `but the realm's redirect URI is ${expected}`, + ); + } + + logger.log({ message: `Keycloak callback path is ${path}` }); + if (!provider.issuer) { + logger.warn({ + message: + "Keycloak discovery failed at startup; logins use the configured endpoints " + + "without ID-token verification until the next restart", + }); + } + return path; +} diff --git a/src/core/claims.ts b/src/core/claims.ts new file mode 100644 index 0000000..d15f534 --- /dev/null +++ b/src/core/claims.ts @@ -0,0 +1,46 @@ +import { isPlainObject } from "./plain-object.js"; + +/** The claim shape the APIs authorize on, as their previous OpenID provider issued it. */ +export interface NormalizedClaims { + /** Realm roles and every client's roles, without repeats. */ + roles: string[]; + /** Group names, bare rather than paths. */ + groups: string[]; + /** Tenant identifiers, from `tenant_ids`. */ + tenants: string[]; + /** Site identifiers. Always empty until the realm has a `sites` source (R3). */ + sites: string[]; + /** Names of groups under the internal root. */ + internal_groups: string[]; +} + +/** The strings of a claim that should be a string array; anything else is dropped. */ +const strings = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item !== "") + : []; + +/** `.roles` of an access claim such as `realm_access`. */ +const rolesOf = (access: unknown): string[] => (isPlainObject(access) ? strings(access.roles) : []); + +/** + * Maps Keycloak's claims onto `NormalizedClaims` (the `interlock` README, "Claims"): realm roles + * plus each `resource_access..roles` become `roles`, `groups` stays `groups`, + * `tenant_ids` becomes `tenants`, and `internal_groups` stays. A claim of the wrong shape, or an + * entry that is not a non-empty string, is dropped rather than thrown on. Pure. + * + * A stub until item 6.2, which ships the real mapping in the APIs' guards and keeps this function + * as the same code; `sites` stays empty until the realm issues it. + */ +export function normalizeClaims(claims: Record): NormalizedClaims { + const clients = isPlainObject(claims.resource_access) + ? Object.values(claims.resource_access) + : []; + return { + roles: [...new Set([...rolesOf(claims.realm_access), ...clients.flatMap(rolesOf)])], + groups: strings(claims.groups), + tenants: strings(claims.tenant_ids), + sites: [], + internal_groups: strings(claims.internal_groups), + }; +} diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..f190578 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,120 @@ +import { + COMPLETE_BUDGET_MS, + LEASE_MARGIN_MS, + POOL_CONNECTION_TIMEOUT_MS, + STATEMENT_TIMEOUT_MS, +} from "./constants.js"; + +export const COOKIE_NAME_POLICIES = ["host", "secure"] as const; + +export type CookieNamePolicy = (typeof COOKIE_NAME_POLICIES)[number]; + +/** + * One entry of `BETTER_AUTH_SECRETS`. The version travels with every value Better Auth encrypts + * or signs, so retiring an old secret never renumbers the ones still in use. + */ +export interface VersionedSecret { + version: number; + value: string; +} + +/** + * The configuration `createKeycloakAuthOptions` reads, already validated by the host. Interlock's + * own configuration extends it, so its loader's output passes through as it is. + */ +export interface KeycloakAuthConfig { + /** `url` is the public base URL; `secrets` are newest first. */ + betterAuth: { url: string; secrets: VersionedSecret[] }; + keycloak: { + /** The realm's issuer; a trailing slash is ignored. */ + issuer: string; + clientId: string; + clientSecret: string; + /** The back-channel logout receiver's full path, under `AUTH_BASE_PATH`. */ + backchannelLogoutPath: string; + }; + /** Exact origins, `scheme://host[:port]`, that Better Auth's origin check accepts. */ + trustedOrigins: string[]; + cookieNamePolicy: CookieNamePolicy; +} + +/** How the refresher bounds a token refresh; all milliseconds (`grants/refresh.ts`). */ +export interface RefreshConfig { + /** The deadline on the token request. */ + requestTimeoutMs: number; + /** How long a claimed refresh lease lasts; renewed when the request is dispatched. */ + leaseMs: number; + /** How long a caller that lost the claim waits for the holder. */ + waitMs: number; + /** How early before an expiry a refresh is due. */ + leewayMs: number; +} + +export const REFRESH_DEFAULTS: RefreshConfig = { + requestTimeoutMs: 5000, + leaseMs: 13000, + waitMs: 5000, + leewayMs: 60000, +}; + +/** The smallest leeway: a deadline judged outside it cannot pass before the caller answers. */ +const MIN_LEEWAY_MS = 1000; + +/** + * What a lease must cover from dispatch on: the token request, a wait for a pool connection, the + * `complete` transaction's budget, the one statement that may be executing when that budget runs + * out, and a margin. + */ +export const leaseBound = (requestTimeoutMs: number) => + requestTimeoutMs + + POOL_CONNECTION_TIMEOUT_MS + + COMPLETE_BUDGET_MS + + STATEMENT_TIMEOUT_MS + + LEASE_MARGIN_MS; + +/** + * The longest a waiter can take to give up: its wait plus the one read that may be in flight at + * the deadline (a pool wait and a statement). + */ +export const waitBound = (waitMs: number) => + waitMs + POOL_CONNECTION_TIMEOUT_MS + STATEMENT_TIMEOUT_MS; + +/** + * The longest one refresh run can take within its budgets: a wait for another holder's lease, + * then its own dispatch through to the stored tokens. The host's shutdown waits this long for runs + * still in flight before it ends the pool (the README's database host contract), so the + * termination grace must allow it. + */ +export const drainBound = ({ + waitMs, + requestTimeoutMs, +}: Pick) => + waitBound(waitMs) + leaseBound(requestTimeoutMs); + +/** + * Checks the refresh bounds; returns the problems. The messages name the `REFRESH_*` variables, + * the package's configuration vocabulary for a host that reads them from its environment. + */ +export function refreshBoundProblems({ + requestTimeoutMs, + leaseMs, + waitMs, + leewayMs, +}: RefreshConfig) { + const problems: string[] = []; + if (leaseMs < leaseBound(requestTimeoutMs)) { + problems.push( + `REFRESH_LEASE_MS must be at least REFRESH_REQUEST_TIMEOUT_MS + ${leaseBound(0)} ` + + `(the pool wait, the completion budget, one statement and a margin): ${leaseBound(requestTimeoutMs)}`, + ); + } + if (waitBound(waitMs) >= leaseMs) { + problems.push( + `REFRESH_WAIT_MS + ${waitBound(0)} (one pool wait and one statement) must be less than REFRESH_LEASE_MS`, + ); + } + if (leewayMs < MIN_LEEWAY_MS) { + problems.push(`REFRESH_LEEWAY_MS must be at least ${MIN_LEEWAY_MS}`); + } + return problems; +} diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..ad00994 --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,41 @@ +/** Where Better Auth's handler is mounted; its `basePath`. */ +export const AUTH_BASE_PATH = "/api/auth"; + +/** The `genericOAuth` provider id, and the last segment of the callback path. */ +export const KEYCLOAK_PROVIDER_ID = "keycloak"; + +/** + * The callback path the realm contract registers as the client's only redirect URI (the + * `interlock` README, "Client"). `assertCallbackPath` checks at startup that Better Auth really + * serves this path, because it has moved between releases. + */ +export const KEYCLOAK_CALLBACK_PATH = `${AUTH_BASE_PATH}/callback/${KEYCLOAK_PROVIDER_ID}`; + +/** + * The base of every cookie name, `.`. The cookie-name policy decides what precedes + * it: a literal `__Host-` under `host`, Better Auth's `__Secure-` under `secure` (see + * `cookieSettings` in `auth.options.ts`). + */ +export const COOKIE_PREFIX = "interlock"; + +/** + * How long the host's runtime pool waits for a free connection (`connectionTimeoutMillis`). The + * refresher's read race and the lease bound assume exactly this value, so the host must configure + * its pool with it (the README's database host contract). + */ +export const POOL_CONNECTION_TIMEOUT_MS = 2000; + +/** + * The server-side `statement_timeout` of every runtime connection. Like + * `POOL_CONNECTION_TIMEOUT_MS`, the host must set exactly this value. + */ +export const STATEMENT_TIMEOUT_MS = 2000; + +/** + * The budget of a refresh's `complete` transaction, checked before each of its statements; a + * completion that runs out rolls back instead of committing late. + */ +export const COMPLETE_BUDGET_MS = 3000; + +/** What the refresh lease keeps beyond the request, the pool wait and the write. */ +export const LEASE_MARGIN_MS = 1000; diff --git a/src/core/database/account-tokens.ts b/src/core/database/account-tokens.ts new file mode 100644 index 0000000..30c5c57 --- /dev/null +++ b/src/core/database/account-tokens.ts @@ -0,0 +1,122 @@ +import { sql, type Kysely } from "kysely"; + +/** The trigger, and the function it runs, that keep tokens off `account`. */ +export const ACCOUNT_TOKENS_TRIGGER = "account_tokens_null"; + +/** What a write that would put a token on `account` fails with. */ +export const ACCOUNT_TOKENS_MESSAGE = + "account rows hold no tokens; Interlock keeps them on session_grant"; + +export interface AccountTokensOptions { + /** Tests only: runs inside the transaction once the lock is held. */ + afterLock?: () => Promise; +} + +/** + * The account token invariant: no `account` row holds a provider token, + * because each session's tokens live on its `session_grant` row. The grant + * hooks keep the runtime from writing one; this restores and guards the + * invariant in the database, and the migration command runs it on every + * run, not as a Kysely migration. Migration history records a step once, + * and the invariant has to come back after any period in which an older + * image wrote tokens (a rollback to v5.4.x, then a redeploy). + * + * In one transaction: + * + * 1. Locks `account` against concurrent writes, so a legacy replica still + * serving a callback cannot commit between the scrub and the guard; once + * the lock is released, such a write meets the trigger. + * 2. Nulls the five token and expiry columns on every row that has any of + * them, and returns how many rows that was. + * 3. Creates or replaces the trigger function, and creates the trigger if the + * table has none of that name. + * + * A trigger and not a CHECK constraint: a CHECK violation's `detail` is the + * failing row, tokens included, and a legacy replica that hits it logs the + * error object through a logger this release cannot change. The trigger's + * exception carries its message only, with no row and no `detail`. The + * schema's owner can create both; `plpgsql` is usable by `PUBLIC`. + */ +export async function ensureAccountTokensInvariant( + db: Kysely, + schema: string, + { afterLock }: AccountTokensOptions = {}, +): Promise { + const account = sql.table(`${schema}.account`); + const fn = sql.id(schema, ACCOUNT_TOKENS_TRIGGER); + const trigger = sql.id(ACCOUNT_TOKENS_TRIGGER); + + return db.transaction().execute(async (trx) => { + await sql`LOCK TABLE ${account} IN SHARE ROW EXCLUSIVE MODE`.execute(trx); + await afterLock?.(); + + const { numAffectedRows } = await sql` + UPDATE ${account} + SET "accessToken" = NULL, "refreshToken" = NULL, "idToken" = NULL, + "accessTokenExpiresAt" = NULL, "refreshTokenExpiresAt" = NULL + WHERE "accessToken" IS NOT NULL OR "refreshToken" IS NOT NULL OR "idToken" IS NOT NULL + OR "accessTokenExpiresAt" IS NOT NULL OR "refreshTokenExpiresAt" IS NOT NULL + `.execute(trx); + + await sql` + CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW."accessToken" IS NOT NULL OR NEW."refreshToken" IS NOT NULL + OR NEW."idToken" IS NOT NULL THEN + RAISE EXCEPTION '${sql.raw(ACCOUNT_TOKENS_MESSAGE)}' USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; + END + $$ + `.execute(trx); + + const { rows } = await sql` + SELECT 1 + FROM pg_catalog.pg_trigger AS t + JOIN pg_catalog.pg_class AS c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace + WHERE n.nspname = ${schema} AND c.relname = 'account' AND t.tgname = ${ACCOUNT_TOKENS_TRIGGER} + `.execute(trx); + if (rows.length === 0) { + await sql` + CREATE TRIGGER ${trigger} BEFORE INSERT OR UPDATE ON ${account} + FOR EACH ROW EXECUTE FUNCTION ${fn}() + `.execute(trx); + } + + return Number(numAffectedRows ?? 0); + }); +} + +/** + * Why a schema fails the invariant: a missing or disabled trigger, and the + * number of rows holding a token. Empty when it holds. Counts, never values. + */ +export async function accountTokensProblems(db: Kysely, schema: string) { + const problems: string[] = []; + const { rows: triggers } = await sql<{ enabled: string }>` + SELECT t.tgenabled AS enabled + FROM pg_catalog.pg_trigger AS t + JOIN pg_catalog.pg_class AS c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace + WHERE n.nspname = ${schema} AND c.relname = 'account' AND t.tgname = ${ACCOUNT_TOKENS_TRIGGER} + `.execute(db); + const [trigger] = triggers; + if (trigger === undefined) { + problems.push(`trigger ${ACCOUNT_TOKENS_TRIGGER} is missing`); + } else if (!["O", "A"].includes(trigger.enabled)) { + problems.push(`trigger ${ACCOUNT_TOKENS_TRIGGER} is disabled`); + } + + const { rows } = await sql<{ count: string }>` + SELECT count(*) AS count FROM ${sql.table(`${schema}.account`)} + WHERE "accessToken" IS NOT NULL OR "refreshToken" IS NOT NULL OR "idToken" IS NOT NULL + `.execute(db); + const count = rows[0]?.count; + if (count === undefined) { + problems.push("the account rows could not be counted"); + } else if (Number(count) > 0) { + problems.push(`${count} account row(s) hold tokens`); + } + return problems; +} diff --git a/src/core/database/in-flight-work.ts b/src/core/database/in-flight-work.ts new file mode 100644 index 0000000..1925ae2 --- /dev/null +++ b/src/core/database/in-flight-work.ts @@ -0,0 +1,48 @@ +import { performance } from "node:perf_hooks"; +import { setTimeout as sleep } from "node:timers/promises"; + +/** + * Work the pool must outlive. Closing the HTTP server waits for connections, + * not for the work a request started: a request whose client has left is + * finished from the server's point of view while its refresh is still at + * Keycloak, and the rotated tokens it brings back must still be stored. The + * refresher counts each run in here, and the host's shutdown awaits + * `drained` before it ends the pool (the README's database host contract). + * Work the host drains earlier, such as Interlock's WebSocket handshakes, is + * counted but already settled by then. + */ +export class InFlightWork { + private readonly pending = new Set>(); + + /** How many tracked promises have not settled. */ + get size() { + return this.pending.size; + } + + /** Counts `work` until it settles, either way; settles as it does. */ + async track(work: Promise): Promise { + this.pending.add(work); + const settled = () => { + this.pending.delete(work); + }; + work.then(settled, settled); + return work; + } + + /** + * Resolves once every tracked promise has settled, work tracked meanwhile + * included, or after `maxMs`. Returns how many are still pending, so the + * caller can say so before it ends the pool under them. + */ + async drained(maxMs: number): Promise { + const deadline = performance.now() + maxMs; + while (this.pending.size > 0) { + const remaining = deadline - performance.now(); + if (remaining <= 0) { + break; + } + await Promise.race([Promise.allSettled(this.pending), sleep(remaining)]); + } + return this.pending.size; + } +} diff --git a/src/core/database/migrations/0001_session_grant.ts b/src/core/database/migrations/0001_session_grant.ts new file mode 100644 index 0000000..4c7ced4 --- /dev/null +++ b/src/core/database/migrations/0001_session_grant.ts @@ -0,0 +1,59 @@ +import { sql, type Kysely } from "kysely"; +import type { Migration } from "kysely/migration"; + +/** + * `session_grant`: one row per Better Auth session, holding that session's + * Keycloak tokens, so two logins of one user never share a refresh token. + * The session's id is the key, and deleting the session (sign-out, revoke, + * expiry cleanup, user deletion) cascades to its grant. + * + * Column names are camelCase and quoted, like Better Auth's tables. The + * access and refresh tokens are Better Auth's ciphertext as its callback + * produced it, and the ID token is encrypted the same way, under the + * versioned `BETTER_AUTH_SECRETS`. `refreshTokenExpiresAt` is the realm's + * refresh deadline from Keycloak's `refresh_expires_in` (null only on grants + * written before 2.5); the lease, `dispatched` and `version` columns + * coordinate the refresh (`grants/refresh.ts`). The table is not called + * `grant`, a reserved word. + */ +export const sessionGrant = (schema: string): Migration => ({ + up: async (db: Kysely) => { + const timestamp = sql`timestamptz`; + await db + .withSchema(schema) + .schema.createTable("session_grant") + .addColumn("sessionId", "text", (col) => + col.primaryKey().references(`${schema}.session.id`).onDelete("cascade"), + ) + .addColumn("keycloakSid", "text", (col) => col.notNull()) + .addColumn("keycloakSubject", "text", (col) => col.notNull()) + .addColumn("accessToken", "text", (col) => col.notNull()) + .addColumn("accessTokenExpiresAt", timestamp) + .addColumn("refreshToken", "text", (col) => col.notNull()) + .addColumn("refreshTokenExpiresAt", timestamp) + .addColumn("idToken", "text", (col) => col.notNull()) + .addColumn("leaseHolder", "text") + .addColumn("leaseExpiresAt", timestamp) + .addColumn("dispatched", "boolean", (col) => col.notNull().defaultTo(false)) + .addColumn("version", "integer", (col) => col.notNull().defaultTo(1)) + .addColumn("createdAt", timestamp, (col) => col.notNull()) + .addColumn("updatedAt", timestamp, (col) => col.notNull()) + .execute(); + // Back-channel logout (3.3) finds grants by Keycloak session and by subject. + await db + .withSchema(schema) + .schema.createIndex("session_grant_keycloakSid_idx") + .on("session_grant") + .column("keycloakSid") + .execute(); + await db + .withSchema(schema) + .schema.createIndex("session_grant_keycloakSubject_idx") + .on("session_grant") + .column("keycloakSubject") + .execute(); + }, + down: async (db: Kysely) => { + await db.withSchema(schema).schema.dropTable("session_grant").execute(); + }, +}); diff --git a/src/core/database/migrations/index.ts b/src/core/database/migrations/index.ts new file mode 100644 index 0000000..855fc42 --- /dev/null +++ b/src/core/database/migrations/index.ts @@ -0,0 +1,26 @@ +import type { Migration } from "kysely/migration"; + +import { sessionGrant } from "./0001_session_grant.js"; + +/** Migrations keyed by name, built for the schema they run in. */ +export type AppMigrations = (schema: string) => Record; + +/** + * The package's own migrations (the `session_grant` table), which the host's + * migration command runs with Kysely's `Migrator` after Better Auth's tables + * exist. Kysely applies them in the alphabetical order of their names, + * so name them `NNNN_`, and never rename or edit one that has shipped: + * `kysely_migration` records names, and a changed migration does not run again. + * Kysely also refuses to run when a recorded name is missing from this map or + * a new name sorts before a recorded one. + * + * Every migration pending in one run is applied in one transaction (Kysely + * 0.29's `migrateToLatest()` wraps the whole run, not each migration), so a + * failure rolls back that run's entries and earlier runs stay applied. The + * connection's `search_path` is the schema, but qualify statements with + * `db.withSchema(schema)` anyway, as Better Auth's runtime does through + * `database.schemaName`. + */ +export const appMigrations: AppMigrations = (schema) => ({ + "0001_session_grant": sessionGrant(schema), +}); diff --git a/src/core/encrypt-token.ts b/src/core/encrypt-token.ts new file mode 100644 index 0000000..fe2fa73 --- /dev/null +++ b/src/core/encrypt-token.ts @@ -0,0 +1,17 @@ +import { setTokenUtil } from "better-auth/oauth2"; + +/** The context Better Auth's token helpers take (`await auth.$context`, or a hook's `context`). */ +type TokenContext = Parameters[1]; + +/** + * Encrypts a token as Better Auth stores an OAuth token under `encryptOAuthTokens`. Better Auth's + * helper is typed for an absent token as well; given a non-empty string it returns one, and + * anything else is refused here rather than stored. + */ +export async function encryptToken(token: string, ctx: TokenContext): Promise { + const encrypted = await setTokenUtil(token, ctx); + if (typeof encrypted !== "string" || encrypted === "") { + throw new Error("Better Auth returned no ciphertext for a token"); + } + return encrypted; +} diff --git a/src/core/exposure.ts b/src/core/exposure.ts new file mode 100644 index 0000000..20557a7 --- /dev/null +++ b/src/core/exposure.ts @@ -0,0 +1,127 @@ +import { APIError, createAuthMiddleware } from "better-auth/api"; + +import { CALLBACK_ENDPOINT } from "./grants/callback-start.js"; +import { TOKEN_FIELDS } from "./grants/grant-hooks.js"; +import { BACKCHANNEL_LOGOUT_ROUTE } from "./logout/backchannel.js"; +import { isPlainObject } from "./plain-object.js"; + +/** + * The Better Auth routes reachable from outside, as `ctx.path` reports them: + * sign-in, the OAuth callback, sign-out, the error page and `get-session` for + * the browser, and the back-channel logout receiver for Keycloak. Every + * other route answers 404. A reverse proxy that repeats this list as a path + * list must be kept in step with it. `__tests__/helpers/auth-routes.ts` pins + * the full table the instance serves. + */ +export const EXPOSED_ROUTES: ReadonlySet = new Set([ + "/sign-in/social", + CALLBACK_ENDPOINT, + "/sign-out", + "/error", + "/get-session", + BACKCHANNEL_LOGOUT_ROUTE, +]); + +/** + * `EXPOSED_ROUTES` with the receiver at the route configured by + * `KEYCLOAK_BACKCHANNEL_LOGOUT_PATH`, when that is not the usual one. + */ +export const exposedRoutesFor = (receiverRoute: string): ReadonlySet => + receiverRoute === BACKCHANNEL_LOGOUT_ROUTE + ? EXPOSED_ROUTES + : new Set( + [...EXPOSED_ROUTES].map((route) => + route === BACKCHANNEL_LOGOUT_ROUTE ? receiverRoute : route, + ), + ); + +/** A Better Auth route outside `EXPOSED_ROUTES` was requested. */ +export const ROUTE_NOT_EXPOSED = "route_not_exposed"; + +/** A `sign-in/social` body carried `idToken`, the client-supplied token sign-in. */ +export const ID_TOKEN_FORBIDDEN = "id_token_forbidden"; + +/** + * Answers 404 for every Better Auth route but `routes` (`EXPOSED_ROUTES`, or + * `exposedRoutesFor` a configured receiver route), and 400 for a + * `sign-in/social` body carrying `idToken`, before any Keycloak or JWKS + * contact. It runs first in the composed before hooks, so a blocked request + * runs nothing else. + * + * Path only: Better Auth's router runs user hooks only for a matched route + * and method, so a method a route does not declare (`GET /sign-out`, any + * `HEAD`) is already a 404 before this runs. It also runs for `auth.api.*` + * server calls; a path-less endpoint (`setPassword`) reports `/` and is + * blocked too, which is fail-closed, since nothing calls one. + * + * It throws `APIError`, which the router answers as `{ code, message }`. A + * returned `Response` would be handed silently to an `auth.api.*` caller. + * Keep the messages free of `column`, `relation`, `table` and `does not + * exist`: Better Auth's error handler logs any such message at error level. + * + * The session hook's `grant_unsupported_login` refusal stays as the inner + * layer for an ID-token session (`grants/grant-hooks.ts`). + */ +export const exposeRoutes = (routes: ReadonlySet = EXPOSED_ROUTES) => + createAuthMiddleware(async (ctx) => { + if (!routes.has(ctx.path)) { + throw new APIError("NOT_FOUND", { code: ROUTE_NOT_EXPOSED, message: "Not Found" }); + } + const body: unknown = ctx.body; + if ( + ctx.path === "/sign-in/social" && + isPlainObject(body) && + Object.prototype.hasOwnProperty.call(body, "idToken") + ) { + throw new APIError("BAD_REQUEST", { + code: ID_TOKEN_FORBIDDEN, + message: "ID-token sign-in is not accepted", + }); + } + return Promise.resolve(); + }); + +/** + * Deletes every provider token field from a value in place, at any depth. + * It recurses into arrays and plain objects only, so a `Date`, an `APIError` + * or a `Response` is left as it is, and it stops at a cycle. A field that + * cannot be deleted (a frozen or non-configurable property) throws, so the + * value is never serialized with the token still on it. + */ +export function scrubProviderTokens(value: unknown, seen = new WeakSet()): void { + if (value === null || typeof value !== "object" || seen.has(value)) { + return; + } + if (Array.isArray(value)) { + seen.add(value); + for (const item of value) { + scrubProviderTokens(item, seen); + } + return; + } + if (!isPlainObject(value)) { + return; + } + seen.add(value); + for (const field of TOKEN_FIELDS) { + if (!Reflect.deleteProperty(value, field)) { + throw new Error(`Could not strip the provider token field ${field}`); + } + } + for (const item of Object.values(value)) { + scrubProviderTokens(item, seen); + } +} + +/** + * Strips the provider token fields from whatever a Better Auth route + * returned, so `get-session` is sanitized by construction rather than by the + * account row having no tokens. It edits the result in place, which is what + * Better Auth serializes, and returns nothing. `session.token` is Better + * Auth's own session token, not a provider token, and stays. + */ +export const stripProviderTokens = () => + createAuthMiddleware(async (ctx) => { + scrubProviderTokens(ctx.context.returned); + return Promise.resolve(); + }); diff --git a/src/core/grants/callback-start.ts b/src/core/grants/callback-start.ts new file mode 100644 index 0000000..7f98f89 --- /dev/null +++ b/src/core/grants/callback-start.ts @@ -0,0 +1,100 @@ +import { performance } from "node:perf_hooks"; + +import type { BetterAuthOptions } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; + +/** The core endpoint every OAuth callback is served by, as `ctx.path` reports it. */ +export const CALLBACK_ENDPOINT = "/callback/:id"; + +type BeforeHook = NonNullable["before"]>; +type AfterHook = NonNullable["after"]>; + +/** + * When each callback request started, on the monotonic clock, keyed by the + * request's auth context: Better Auth builds one per request, and the before + * hook and the database hooks of that request see the same object. + */ +const callbackStarts = new WeakMap(); + +/** + * The `performance.now()` at which the callback that owns this auth context + * started, or undefined outside a callback (or in a build without the hook). + */ +export const callbackStartOf = (authContext: object | null | undefined) => + authContext ? callbackStarts.get(authContext) : undefined; + +/** + * Records the start of every `/callback/:id` request, before the code + * exchange leaves the process, as the anchor of the login's deadlines: every + * later point (core stamps its expiry when the exchange response is parsed, + * `accountSubject` runs after the userinfo fetch) would credit time already + * spent to the session. Anchored here, a deadline can only err short. + * + * It answers nothing and blocks nothing; on every other path it returns at once. + */ +export const recordCallbackStart = () => + createAuthMiddleware(async (ctx) => { + if (ctx.path === CALLBACK_ENDPOINT) { + callbackStarts.set(ctx.context, performance.now()); + } + return Promise.resolve(); + }); + +/** + * Better Auth takes one user `before` hook; this runs several in order, so + * each hook (the exposure allow-list first) joins the list rather than + * replacing it. A hook that throws stops the list and answers with its error. + * Each runs as Better Auth runs a hook, with `returnHeaders`, so the headers + * it set are carried over; the first to return a value answers for all. + */ +export const composeBeforeHooks = (hooks: BeforeHook[]): BeforeHook => + createAuthMiddleware(async (ctx) => { + for (const hook of hooks) { + const { headers, response } = (await hook({ ...ctx, returnHeaders: true })) as { + headers?: Headers; + response?: unknown; + }; + for (const cookie of headers?.getSetCookie() ?? []) { + ctx.responseHeaders.append("set-cookie", cookie); + } + headers?.forEach((value, name) => { + if (name !== "set-cookie") { + ctx.setHeader(name, value); + } + }); + if (response !== undefined) { + return response; + } + } + return undefined; + }); + +/** + * The `after` counterpart of `composeBeforeHooks`: Better Auth takes one user + * `after` hook, so this runs several in order, carrying each one's headers + * over. A hook that returns a value replaces the result the next one sees, + * as Better Auth does between hooks. + */ +export const composeAfterHooks = (hooks: AfterHook[]): AfterHook => + createAuthMiddleware(async (ctx) => { + let replaced: unknown; + for (const hook of hooks) { + const { headers, response } = (await hook({ ...ctx, returnHeaders: true })) as { + headers?: Headers; + response?: unknown; + }; + for (const cookie of headers?.getSetCookie() ?? []) { + ctx.responseHeaders.append("set-cookie", cookie); + } + headers?.forEach((value, name) => { + if (name !== "set-cookie") { + ctx.setHeader(name, value); + } + }); + if (response !== undefined) { + ctx.context.returned = response; + replaced = response; + } + } + return replaced; + }); diff --git a/src/core/grants/grant-errors.ts b/src/core/grants/grant-errors.ts new file mode 100644 index 0000000..97a750b --- /dev/null +++ b/src/core/grants/grant-errors.ts @@ -0,0 +1,34 @@ +/** + * Why a login was refused for want of a session grant. Each surfaces as + * `?error=` on Better Auth's error page (the callback redirects there), + * or as the `code` of a 401 on a route that answers JSON. + */ + +/** The callback reached the session hook with nothing captured from the account write. */ +export const GRANT_NOT_CAPTURED = "grant_not_captured"; + +/** The grant insert failed; the session was deleted. */ +export const GRANT_NOT_CREATED = "grant_not_created"; + +/** Keycloak's token response carried no ID token. */ +export const GRANT_ID_TOKEN_MISSING = "grant_id_token_missing"; + +/** The ID token has no `sid` or `sub` claim, so back-channel logout could never match it. */ +export const GRANT_SID_MISSING = "grant_sid_missing"; + +/** + * Keycloak's token response carried no usable `refresh_expires_in` (or + * `expires_in`), so the session would have no realm bound. + */ +export const GRANT_REFRESH_EXPIRY_MISSING = "grant_refresh_expiry_missing"; + +/** A session was created by a route other than the Keycloak callback. */ +export const GRANT_UNSUPPORTED_LOGIN = "grant_unsupported_login"; + +export type GrantErrorCode = + | typeof GRANT_NOT_CAPTURED + | typeof GRANT_NOT_CREATED + | typeof GRANT_ID_TOKEN_MISSING + | typeof GRANT_SID_MISSING + | typeof GRANT_REFRESH_EXPIRY_MISSING + | typeof GRANT_UNSUPPORTED_LOGIN; diff --git a/src/core/grants/grant-hooks.ts b/src/core/grants/grant-hooks.ts new file mode 100644 index 0000000..07d93ed --- /dev/null +++ b/src/core/grants/grant-hooks.ts @@ -0,0 +1,308 @@ +import { performance } from "node:perf_hooks"; + +import type { Account, BetterAuthOptions, GenericEndpointContext } from "better-auth"; +import { APIError } from "better-auth/api"; + +import { encryptToken } from "../encrypt-token.js"; +import { loginLifetimesOf, type Lifetimes } from "../keycloak-token.js"; +import type { AuthLogger } from "../logger.js"; +import { CALLBACK_ENDPOINT, callbackStartOf } from "./callback-start.js"; +import { + GRANT_ID_TOKEN_MISSING, + GRANT_NOT_CAPTURED, + GRANT_NOT_CREATED, + GRANT_REFRESH_EXPIRY_MISSING, + GRANT_SID_MISSING, + GRANT_UNSUPPORTED_LOGIN, + type GrantErrorCode, +} from "./grant-errors.js"; +import type { GrantStore } from "./grant-store.js"; + +/** The account columns that never hold a value: the grant keeps them instead. */ +export const TOKEN_FIELDS = [ + "accessToken", + "refreshToken", + "idToken", + "accessTokenExpiresAt", + "refreshTokenExpiresAt", +] as const; + +const STRIPPED = { + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, +}; + +/** What the account hook of one callback hands the session hook of the same request. */ +type Captured = + | { + accessToken: string; + refreshToken: string; + /** The token response's lifetimes; null when it lacked a usable one. */ + lifetimes: Lifetimes | null; + /** Plaintext; encrypted by the session hook before it is stored. */ + idToken: string; + sid: string; + sub: string; + } + | { failure: GrantErrorCode }; + +/** A claim is usable only if it is a non-blank string. */ +const claim = (value: unknown) => + typeof value === "string" && value.trim() !== "" ? value : undefined; + +/** + * The `sid` and `sub` claims of an ID token, read without verifying it. + * Better Auth verified the signature against the discovery JWKS when + * discovery succeeded, and the token came from the token endpoint over TLS + * with client authentication; these claims are correlation keys for + * back-channel logout, not an authorization input. + */ +export function idTokenClaims(idToken: string): { sid?: string; sub?: string } { + try { + const payload: unknown = JSON.parse( + Buffer.from(idToken.split(".")[1] ?? "", "base64url").toString("utf8"), + ); + if (typeof payload !== "object" || payload === null) { + return {}; + } + const { sid, sub } = payload as Record; + return { sid: claim(sid), sub: claim(sub) }; + } catch { + return {}; + } +} + +/** + * The lifetimes `keycloakAccountSubject` registered under the write's + * `refreshTokenExpiresAt`, when both are usable. + */ +function lifetimesOf(data: Partial): Lifetimes | null { + const lifetimes = loginLifetimesOf(data.refreshTokenExpiresAt); + if (lifetimes === undefined) { + return null; + } + const { accessExpiresIn, refreshExpiresIn } = lifetimes; + return accessExpiresIn !== null && refreshExpiresIn !== null + ? { accessExpiresIn, refreshExpiresIn } + : null; +} + +function capture(data: Partial, accessToken: string, refreshToken: string): Captured { + const idToken = claim(data.idToken); + if (idToken === undefined) { + return { failure: GRANT_ID_TOKEN_MISSING }; + } + const { sid, sub } = idTokenClaims(idToken); + if (sid === undefined || sub === undefined) { + return { failure: GRANT_SID_MISSING }; + } + return { + accessToken, + refreshToken, + lifetimes: lifetimesOf(data), + idToken, + sid, + sub, + }; +} + +const MESSAGES: Record = { + [GRANT_NOT_CAPTURED]: "The login returned no tokens to keep", + [GRANT_NOT_CREATED]: "The session grant could not be stored", + [GRANT_ID_TOKEN_MISSING]: "The login returned no ID token", + [GRANT_SID_MISSING]: "The ID token has no sid or sub claim", + [GRANT_REFRESH_EXPIRY_MISSING]: "The login returned no refresh expiry to bound the session by", + [GRANT_UNSUPPORTED_LOGIN]: "Sessions are created only by the Keycloak callback", +}; + +/** + * The database hooks that keep every session's provider tokens on its own + * `session_grant` row and none on the `account` row, which stays the identity + * link only. + * + * - Both account `before` hooks (first login creates the row, a repeat login + * updates it, an existing user without one links it) take the tokens out of + * the write, on every path. Inside the Keycloak callback they also put what + * the grant needs in a `WeakMap` keyed by the request's endpoint context, + * which every hook of one request receives; anywhere else the tokens are + * dropped. + * - `session.create.before` bounds the new session's `expiresAt` by the + * realm's refresh lifetime (a provisional value on the application clock). + * - `session.create.after` writes the grant once the session has its id, with + * both deadlines anchored at the callback's start (`callback-start.ts`) and + * written as database time, then sets the session's `expiresAt` to the + * grant's refresh deadline, so the two carry one value. A session created + * by any other request is deleted and the request refused, as is a callback + * whose tokens could not be captured or stored, or whose token response + * carried no usable `refresh_expires_in` or `expires_in` (a session with no + * realm bound), so no session is ever issued without a bounded grant. + * Better Auth's callback turns the coded `APIError` into + * `/api/auth/error?error=` with no session cookie. + * - `session.update.before` clamps any later `expiresAt` to the grant's + * refresh deadline. Better Auth's own slide is off + * (`disableSessionRefresh`) and the refresher owns `expiresAt`, so this is + * the backstop for any route that still updates a session. + * + * A session made with no request context (the internal adapter called + * directly) is left alone. Ending a session needs no hook: the grant's + * foreign key cascades every delete of the session row. + */ +export function grantHooks( + store: GrantStore, + logger: AuthLogger, +): NonNullable { + const captured = new WeakMap(); + + const stripAccountTokens = async ( + data: Partial, + context: GenericEndpointContext | null | undefined, + ) => { + if (!TOKEN_FIELDS.some((field) => field in data)) { + return undefined; + } + if (context?.path === CALLBACK_ENDPOINT) { + const { accessToken, refreshToken } = data; + if (accessToken && refreshToken) { + captured.set(context, capture(data, accessToken, refreshToken)); + } + } else if (data.accessToken || data.refreshToken || data.idToken) { + logger.debug({ + message: `Dropped provider tokens from an account write outside the Keycloak callback (${context?.path ?? "no request"})`, + }); + } + return { data: { ...STRIPPED } }; + }; + + return { + account: { + create: { before: stripAccountTokens }, + update: { before: stripAccountTokens }, + }, + session: { + create: { + before: async (data, context) => { + const entry = context ? captured.get(context) : undefined; + const start = callbackStartOf(context?.context); + if ( + entry === undefined || + "failure" in entry || + entry.lifetimes === null || + start === undefined || + !(data.expiresAt instanceof Date) + ) { + // Incomplete: `create.after` is about to refuse the login. + return undefined; + } + const bound = + Date.now() + entry.lifetimes.refreshExpiresIn * 1000 - (performance.now() - start); + return { data: { expiresAt: new Date(Math.min(data.expiresAt.getTime(), bound)) } }; + }, + after: async (session, context) => { + if (!context) { + return; + } + const entry = captured.get(context); + captured.delete(context); + + // Deletes the session, then gives the error to throw. + const refusal = async ( + code: GrantErrorCode, + status: "UNAUTHORIZED" | "INTERNAL_SERVER_ERROR", + ) => { + await context.context.internalAdapter.deleteSession(session.token); + return new APIError(status, { code, message: MESSAGES[code] }); + }; + + if (context.path !== CALLBACK_ENDPOINT) { + throw await refusal(GRANT_UNSUPPORTED_LOGIN, "UNAUTHORIZED"); + } + if (entry === undefined) { + throw await refusal(GRANT_NOT_CAPTURED, "INTERNAL_SERVER_ERROR"); + } + if ("failure" in entry) { + throw await refusal(entry.failure, "UNAUTHORIZED"); + } + if (entry.lifetimes === null) { + logger.error({ + message: + "Keycloak answered a login without a positive refresh_expires_in or expires_in; " + + "the realm contract requires both, so the login was refused", + sessionId: session.id, + keycloakSid: entry.sid, + }); + throw await refusal(GRANT_REFRESH_EXPIRY_MISSING, "UNAUTHORIZED"); + } + const startedAt = callbackStartOf(context.context); + if (startedAt === undefined) { + logger.error({ + message: `No callback start was recorded for session ${session.id}; the before hook is missing`, + }); + throw await refusal(GRANT_NOT_CAPTURED, "INTERNAL_SERVER_ERROR"); + } + + const idToken = await encryptToken(entry.idToken, context.context); + let grant: Awaited>; + try { + grant = await store.insert( + { + sessionId: session.id, + keycloakSid: entry.sid, + keycloakSubject: entry.sub, + accessToken: entry.accessToken, + refreshToken: entry.refreshToken, + idToken, + }, + { startedAt, lifetimes: entry.lifetimes }, + ); + } catch (err) { + // The message and code only: a database error's `detail` can + // quote the failing row, tokens included. + const reason = err instanceof Error ? err.message : "unknown error"; + logger.error({ + message: `Could not store the session grant for session ${session.id}: ${reason}`, + code: (err as { code?: unknown } | undefined)?.code, + }); + throw await refusal(GRANT_NOT_CREATED, "INTERNAL_SERVER_ERROR"); + } + // The session and the grant carry one deadline, in database time. + // `insert` always writes it; a store that did not is refused. + const deadline = grant.refreshTokenExpiresAt; + if (deadline === null) { + throw await refusal(GRANT_NOT_CREATED, "INTERNAL_SERVER_ERROR"); + } + await context.context.internalAdapter.updateSession(session.token, { + expiresAt: deadline, + }); + logger.log({ + message: `Stored the session grant for session ${session.id}`, + sessionId: session.id, + keycloakSid: entry.sid, + accessExpiresAt: grant.accessTokenExpiresAt?.toISOString() ?? null, + refreshExpiresAt: grant.refreshTokenExpiresAt?.toISOString() ?? null, + }); + }, + }, + update: { + before: async (data, context) => { + if (!(data.expiresAt instanceof Date)) { + return undefined; + } + const sessionId = context?.context.session?.session.id; + if (sessionId === undefined) { + logger.debug({ + message: `Left a session expiry update unclamped: no session in its context (${context?.path ?? "no request"})`, + }); + return undefined; + } + const deadline = (await store.findBySessionId(sessionId))?.refreshTokenExpiresAt; + return deadline && data.expiresAt.getTime() > deadline.getTime() + ? { data: { expiresAt: deadline } } + : undefined; + }, + }, + }, + }; +} diff --git a/src/core/grants/grant-store.ts b/src/core/grants/grant-store.ts new file mode 100644 index 0000000..b20b3b9 --- /dev/null +++ b/src/core/grants/grant-store.ts @@ -0,0 +1,476 @@ +import { performance } from "node:perf_hooks"; + +import { sql, type Kysely, type Transaction } from "kysely"; + +import { COMPLETE_BUDGET_MS } from "../constants.js"; +import type { Lifetimes } from "../keycloak-token.js"; + +/** + * One session's provider tokens, a row of `session_grant`. The access and + * refresh tokens are Better Auth's ciphertext; the ID token is encrypted the + * same way. Nothing here decrypts. + */ +export interface Grant { + sessionId: string; + keycloakSid: string; + keycloakSubject: string; + accessToken: string; + accessTokenExpiresAt: Date | null; + refreshToken: string; + /** + * The realm's refresh deadline, from `refresh_expires_in`; the session's + * `expiresAt` never outlives it. Null only on grants from before 2.5, which + * the refresher refreshes before it ever reports them usable. + */ + refreshTokenExpiresAt: Date | null; + idToken: string; +} + +/** A stored grant, with the columns the refresh lease uses. */ +export interface StoredGrant extends Grant { + leaseHolder: string | null; + leaseExpiresAt: Date | null; + dispatched: boolean; + version: number; + createdAt: Date; + /** When the tokens were last written: only `insert` and `complete` set it. */ + updatedAt: Date; +} + +/** What a login's grant holds before its deadlines are worked out. */ +export type NewGrant = Omit; + +/** + * When a token response's lifetimes started, as `performance.now()` (the + * callback's start for a login, the moment before the request was written + * for a refresh), with the lifetimes. The store turns these into database + * time at the moment it reads the database's clock, so time spent before the + * write (a pool wait, encryption) shortens the stored remainder instead of + * extending the deadline. + */ +export interface LifetimeAnchor { + startedAt: number; + lifetimes: Lifetimes; +} + +/** The new tokens of a refresh, already encrypted. */ +export interface RefreshedGrantTokens { + accessToken: string; + refreshToken: string; + /** Replaced only when the response carried one. */ + idToken?: string; +} + +export interface CompleteInput extends LifetimeAnchor { + sessionId: string; + version: number; + holder: string; + tokens: RefreshedGrantTokens; +} + +/** + * - `stored`: the holder's compare-and-swap matched and the tokens, the + * deadlines and the session's `expiresAt` were written. + * - `exhausted`: the refresh deadline had already passed by the time the + * database read its clock, so nothing was stored and the session was ended. + * - `unmatched`: the session is gone or the holder lost its lease. + */ +export type CompleteResult = + | { status: "stored"; grant: StoredGrant; now: Date } + | { status: "exhausted" } + | { status: "unmatched" }; + +/** + * The conditions under which `endIf` ends a session, each a compare-and-swap + * on the version the caller read: + * + * - `expiredRefresh`: the refresh deadline passed, and no lease is live or + * dispatched (an expired, unmarked lease is a holder that died before + * sending, so it does not protect the row). + * - `expiredDispatched`: a dispatched lease expired (README step 6, by a successor). + * - `heldBy`: the holder's own dispatched lease (step 6, by the holder). + */ +export type EndPredicate = + | { kind: "expiredRefresh"; version: number } + | { kind: "expiredDispatched"; version: number } + | { kind: "heldBy"; version: number; holder: string }; + +/** `complete` ran out of `COMPLETE_BUDGET_MS` and rolled back; nothing was stored. */ +export class CompletionBudgetError extends Error { + constructor() { + super("the refresh completion ran out of its budget and rolled back"); + this.name = "CompletionBudgetError"; + } +} + +/** + * `complete` sent its COMMIT and never heard back: the write may or may not + * have landed, and only the database can say which. + */ +export class AmbiguousCommitError extends Error { + constructor(readonly reason: unknown) { + super("the refresh completion sent its commit and got no answer"); + this.name = "AmbiguousCommitError"; + } +} + +/** + * Where grants live. Every lease write is a compare-and-swap on + * `("sessionId", version)` that increments `version`, and every time it + * compares or writes is the database's `clock_timestamp()`, so replicas need + * no clock agreement. Transactions that touch both tables lock the session + * row first, as every `DELETE FROM session` (whose cascade then locks the + * grant) does, so the two orders never meet. + */ +export interface GrantStore { + /** A login's grant: deadlines anchored at the callback's start. Returns the stored row. */ + insert(grant: NewGrant, anchor: LifetimeAnchor): Promise; + findBySessionId(sessionId: string): Promise; + deleteBySessionId(sessionId: string): Promise; + /** The grant and the database's clock, read in one statement. */ + read(sessionId: string): Promise<{ grant: StoredGrant; now: Date } | undefined>; + /** Takes the lease (and clears any mark) if the version still matches. */ + claim( + sessionId: string, + version: number, + holder: string, + leaseMs: number, + ): Promise; + /** + * Marks the lease dispatched and renews it, if it is still the holder's and + * still live. Returns the new version. + */ + markDispatched( + sessionId: string, + version: number, + holder: string, + leaseMs: number, + ): Promise; + /** Stores a refresh's tokens under the holder's lease (see `CompleteResult`). */ + complete(input: CompleteInput): Promise; + /** Clears the holder's lease and mark; for a request that was never sent. */ + release(sessionId: string, version: number, holder: string): Promise; + /** Deletes the session (the grant cascades); idempotent. */ + endSession(sessionId: string): Promise; + /** + * Deletes the session that holds this Better Auth session token (the grant + * cascades). Idempotent, and it throws when the statement fails, so a + * normal return proves no such session remains. + */ + endSessionByToken(token: string): Promise; + /** Ends the session if its grant matches the predicate; whether this call ended it. */ + endIf(sessionId: string, predicate: EndPredicate): Promise; + /** + * Ends every session whose grant carries this Keycloak `sid` (back-channel + * logout); the ended session ids. Idempotent: no match is success. A + * session without a grant cannot match. + */ + endSessionsBySid(keycloakSid: string): Promise; + /** + * Ends every grant-backed session of this Keycloak subject (a logout token + * with no `sid`); the ended session ids. Idempotent, like `endSessionsBySid`. + */ + endSessionsBySubject(keycloakSubject: string): Promise; +} + +interface GrantTables { + session_grant: StoredGrant; + session: { id: string; token: string; expiresAt: Date }; +} + +/** An absolute deadline from the database's `now`, less the time since the anchor. */ +export const deadlineFrom = (now: Date, seconds: number, elapsedMs: number) => + new Date(now.getTime() + seconds * 1000 - elapsedMs); + +/** Test-only seams of `KyselyGrantStore`. */ +export interface KyselyGrantStoreOptions { + completeBudgetMs?: number; + /** Runs inside `complete`'s transaction before each statement after the lock. */ + beforeCompleteStep?: (trx: Transaction, step: "grant" | "session") => Promise; + /** Runs after `complete`'s last statement, just before Kysely sends COMMIT. */ + beforeCommit?: () => Promise; +} + +/** The database's clock, read when the statement that carries it runs. */ +const clock = sql`clock_timestamp()`; + +/** A lease deadline `leaseMs` after the database's now. */ +const leaseUntil = (leaseMs: number) => + sql`clock_timestamp() + ${leaseMs} * interval '1 millisecond'`; + +/** The runtime store: `session_grant` in the host's schema, over the host's one pool. */ +export class KyselyGrantStore implements GrantStore { + private readonly db: Kysely; + + constructor( + db: Kysely, + private readonly schema: string, + private readonly options: KyselyGrantStoreOptions = {}, + ) { + this.db = db as Kysely; + } + + private get table() { + return this.db.withSchema(this.schema); + } + + async insert(grant: NewGrant, { startedAt, lifetimes }: LifetimeAnchor) { + const { rows } = await sql<{ now: Date }>`SELECT clock_timestamp() AS now`.execute(this.db); + const now = rows[0]?.now; + if (now === undefined) { + throw new Error("clock_timestamp() returned no row"); + } + const elapsed = performance.now() - startedAt; + // Every column given, the column defaults' values included: the table's + // type marks none of them generated, so Kysely requires them all. + return this.table + .insertInto("session_grant") + .values({ + ...grant, + accessTokenExpiresAt: deadlineFrom(now, lifetimes.accessExpiresIn, elapsed), + refreshTokenExpiresAt: deadlineFrom(now, lifetimes.refreshExpiresIn, elapsed), + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: 1, + createdAt: now, + updatedAt: now, + }) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async findBySessionId(sessionId: string) { + return this.table + .selectFrom("session_grant") + .selectAll() + .where("sessionId", "=", sessionId) + .executeTakeFirst(); + } + + async deleteBySessionId(sessionId: string) { + await this.table.deleteFrom("session_grant").where("sessionId", "=", sessionId).execute(); + } + + async read(sessionId: string) { + const row = await this.table + .selectFrom("session_grant") + .selectAll() + .select(clock.as("now")) + .where("sessionId", "=", sessionId) + .executeTakeFirst(); + if (row === undefined) { + return undefined; + } + const { now, ...grant } = row; + return { grant, now }; + } + + async claim(sessionId: string, version: number, holder: string, leaseMs: number) { + return this.table + .updateTable("session_grant") + .set((eb) => ({ + leaseHolder: holder, + leaseExpiresAt: leaseUntil(leaseMs), + dispatched: false, + version: eb("version", "+", 1), + })) + .where("sessionId", "=", sessionId) + .where("version", "=", version) + .returningAll() + .executeTakeFirst(); + } + + async markDispatched(sessionId: string, version: number, holder: string, leaseMs: number) { + const row = await this.table + .updateTable("session_grant") + .set((eb) => ({ + dispatched: true, + leaseExpiresAt: leaseUntil(leaseMs), + version: eb("version", "+", 1), + })) + .where("sessionId", "=", sessionId) + .where("version", "=", version) + .where("leaseHolder", "=", holder) + .where("leaseExpiresAt", ">", clock) + .returning("version") + .executeTakeFirst(); + return row?.version; + } + + async complete({ sessionId, version, holder, tokens, startedAt, lifetimes }: CompleteInput) { + const budgetMs = this.options.completeBudgetMs ?? COMPLETE_BUDGET_MS; + let committing = false; + try { + return await this.db.transaction().execute(async (trx) => { + const begun = performance.now(); + // The budget cannot interrupt a statement in flight or Kysely's + // COMMIT; it stops the next statement, and Kysely then rolls back on + // the same connection. + const step = async (name: "grant" | "session") => { + await this.options.beforeCompleteStep?.(trx as Transaction, name); + if (performance.now() - begun > budgetMs) { + throw new CompletionBudgetError(); + } + }; + const table = trx.withSchema(this.schema); + + const locked = await table + .selectFrom("session") + .select(clock.as("now")) + .where("id", "=", sessionId) + .forUpdate() + .executeTakeFirst(); + if (locked === undefined) { + return { status: "unmatched" } as const; + } + const { now } = locked; + const elapsed = performance.now() - startedAt; + const accessTokenExpiresAt = deadlineFrom(now, lifetimes.accessExpiresIn, elapsed); + const refreshTokenExpiresAt = deadlineFrom(now, lifetimes.refreshExpiresIn, elapsed); + + await step("grant"); + if (refreshTokenExpiresAt.getTime() <= now.getTime()) { + // Keycloak built the response with seconds of realm lifetime left, + // and they ran out before it could be stored. + const ended = await this.endUnder(trx, sessionId, { kind: "heldBy", version, holder }); + committing = true; + return { status: ended ? "exhausted" : "unmatched" } as const; + } + const grant = await table + .updateTable("session_grant") + .set((eb) => ({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ...(tokens.idToken === undefined ? {} : { idToken: tokens.idToken }), + accessTokenExpiresAt, + refreshTokenExpiresAt, + updatedAt: now, + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: eb("version", "+", 1), + })) + .where("sessionId", "=", sessionId) + .where("version", "=", version) + .where("leaseHolder", "=", holder) + .returningAll() + .executeTakeFirst(); + if (grant === undefined) { + return { status: "unmatched" } as const; + } + + await step("session"); + await table + .updateTable("session") + .set({ expiresAt: refreshTokenExpiresAt }) + .where("id", "=", sessionId) + .execute(); + await this.options.beforeCommit?.(); + committing = true; + return { status: "stored", grant, now } as const; + }); + } catch (err) { + // The callback finished, so the error came from COMMIT itself. + if (committing) { + throw new AmbiguousCommitError(err); + } + throw err; + } + } + + async release(sessionId: string, version: number, holder: string) { + const { numUpdatedRows } = await this.table + .updateTable("session_grant") + .set((eb) => ({ + leaseHolder: null, + leaseExpiresAt: null, + dispatched: false, + version: eb("version", "+", 1), + })) + .where("sessionId", "=", sessionId) + .where("version", "=", version) + .where("leaseHolder", "=", holder) + .executeTakeFirst(); + return Number(numUpdatedRows) > 0; + } + + async endSession(sessionId: string) { + await this.table.deleteFrom("session").where("id", "=", sessionId).execute(); + } + + async endSessionByToken(token: string) { + await this.table.deleteFrom("session").where("token", "=", token).execute(); + } + + async endIf(sessionId: string, predicate: EndPredicate) { + return this.db.transaction().execute(async (trx) => this.endUnder(trx, sessionId, predicate)); + } + + async endSessionsBySid(keycloakSid: string) { + return this.endSessionsWhere("keycloakSid", keycloakSid); + } + + async endSessionsBySubject(keycloakSubject: string) { + return this.endSessionsWhere("keycloakSubject", keycloakSubject); + } + + /** + * One statement: deletes the sessions whose grant matches, so each session + * row is locked before its grant, as the cascade always does. + */ + private async endSessionsWhere(column: "keycloakSid" | "keycloakSubject", value: string) { + const rows = await this.table + .deleteFrom("session") + .where("id", "in", (eb) => + eb.selectFrom("session_grant").select("sessionId").where(column, "=", value), + ) + .returning("id") + .execute(); + return rows.map((row) => row.id); + } + + /** `endIf`'s statements, inside a transaction the caller owns. */ + private async endUnder( + trx: Transaction, + sessionId: string, + predicate: EndPredicate, + ) { + const table = trx.withSchema(this.schema); + const locked = await table + .selectFrom("session") + .select("id") + .where("id", "=", sessionId) + .forUpdate() + .executeTakeFirst(); + if (locked === undefined) { + return false; + } + const now = clock; + const { numDeletedRows } = await table + .deleteFrom("session_grant") + .where("sessionId", "=", sessionId) + .where("version", "=", predicate.version) + .where((eb) => { + if (predicate.kind === "expiredRefresh") { + return eb.and([ + eb("refreshTokenExpiresAt", "<=", now), + eb.or([ + eb("leaseHolder", "is", null), + eb.and([eb("dispatched", "=", false), eb("leaseExpiresAt", "<=", now)]), + ]), + ]); + } + if (predicate.kind === "expiredDispatched") { + return eb.and([eb("dispatched", "=", true), eb("leaseExpiresAt", "<=", now)]); + } + return eb.and([eb("leaseHolder", "=", predicate.holder), eb("dispatched", "=", true)]); + }) + .executeTakeFirst(); + if (Number(numDeletedRows) === 0) { + return false; + } + await table.deleteFrom("session").where("id", "=", sessionId).execute(); + return true; + } +} diff --git a/src/core/grants/refresh-events.ts b/src/core/grants/refresh-events.ts new file mode 100644 index 0000000..191833b --- /dev/null +++ b/src/core/grants/refresh-events.ts @@ -0,0 +1,95 @@ +import type { AuthLogger } from "../logger.js"; + +/** + * The structured log events of a refresh, one record each. R7's measurement + * is `refresh.indeterminate` divided by `refresh.dispatched` over a window. + * + * Every dispatched attempt gets exactly one terminal event, `succeeded`, + * `invalid_grant` or `indeterminate` (by the holder, or by a successor that + * ended the attempt after its lease expired, matched by `leaseHolder`), + * unless a sign-out ended the session during the request; `lease_lost` is + * never terminal. That holds for a live process; a crash between a commit + * and its log line can drop one. + */ +export type RefreshEventName = + /** The token request is about to be written, under a dispatched mark. */ + | "refresh.dispatched" + /** Keycloak rotated the tokens and they were stored (or arrived exhausted). */ + | "refresh.succeeded" + /** Keycloak refused the refresh token; the session ended. */ + | "refresh.invalid_grant" + /** The outcome of a dispatched request is unknown; the session ended without replay. */ + | "refresh.indeterminate" + /** The request provably never left; the lease was released. Never follows `dispatched`. */ + | "refresh.unsent" + /** A late holder found its attempt already ended by a successor or a sign-out. */ + | "refresh.lease_lost" + /** A caller took over an expired lease whose holder died before dispatch. */ + | "refresh.lease_recovered" + /** A caller claimed a grant whose token an upstream rejected, due or not. */ + | "refresh.forced" + /** A caller stopped waiting for another holder. */ + | "refresh.waited" + /** A Postgres error; the caller answered `unavailable(database)`. */ + | "refresh.error"; + +/** What every event carries. */ +export interface RefreshEventBase { + sessionId: string; + /** The attempt the event is about. */ + leaseHolder: string | null; + /** Since the first read of the caller's refresh. */ + durationMs: number; +} + +/** + * Extra fields, named clear of the logger's redacted fragments (`token`, + * `secret`, `password`, `credential`), so they reach the log as values; + * never a token or a ciphertext. + */ +export interface RefreshEventFields { + accessExpiresAt?: string; + refreshExpiresAt?: string; + remainingSeconds?: number; + by?: "holder" | "successor"; + cause?: string; + recoveredFrom?: string; + result?: "ok" | "ended" | "timed_out"; + code?: string; + /** An error's message, never its `detail`. */ + error?: string; +} + +const MESSAGES: Record = { + "refresh.dispatched": "Dispatched a token refresh", + "refresh.succeeded": "Stored a refreshed grant", + "refresh.invalid_grant": "Keycloak refused the refresh token; ended the session", + "refresh.indeterminate": "A token refresh had no known outcome; ended the session", + "refresh.unsent": "A token refresh was never sent; released the lease", + "refresh.lease_lost": "A token refresh outlived its lease; the attempt was already ended", + "refresh.lease_recovered": "Recovered a refresh lease whose holder never dispatched", + "refresh.forced": "Refreshing a token an upstream rejected", + "refresh.waited": "Stopped waiting for another token refresh", + "refresh.error": "A token refresh failed on the database", +}; + +/** Logs one refresh event; `refresh.error` at error level, the rest at `log`. */ +export function emitRefreshEvent( + logger: AuthLogger, + event: RefreshEventName, + base: RefreshEventBase, + fields: RefreshEventFields = {}, +) { + const record = { + message: `${MESSAGES[event]} for session ${base.sessionId}`, + event, + ...base, + durationMs: Math.round(base.durationMs), + ...fields, + }; + if (event === "refresh.error") { + logger.error(record); + } else { + logger.log(record); + } +} diff --git a/src/core/grants/refresh-responses.ts b/src/core/grants/refresh-responses.ts new file mode 100644 index 0000000..897417e --- /dev/null +++ b/src/core/grants/refresh-responses.ts @@ -0,0 +1,99 @@ +/** + * The responses a refresh outcome maps to, which a BFF (Interlock's session + * guard) sends rather than inventing its own, and the one body of every dependency + * failure. The contract every BFF and WebSocket refusal keeps: a 401 + * `session_invalid` means the session ended (its cookies cleared, and a + * `reason` only on the response that ended it); a 503 `dependency_failure` + * means the session is intact and a dependency is not, so it is never a + * logout; a 502 means the upstream answered with something Interlock will + * not relay. + */ + +/** Why a session ended, as the refresher saw it. */ +export type EndedReason = + /** The session had no grant (from before 2.4, or a crash between the two inserts). */ + | "no_grant" + /** Someone else ended it: a sign-out, or a concurrent successor that carries the reason. */ + | "ended_elsewhere" + /** This caller ended a dispatched refresh with no known outcome (README step 6). */ + | "refresh_indeterminate" + /** The realm's refresh deadline passed. */ + | "refresh_expired" + /** Keycloak refused the refresh token. */ + | "invalid_grant"; + +/** Why the session is intact but its token could not be produced now. */ +export type UnavailableReason = "refresh_in_progress" | "keycloak_unreachable" | "database"; + +/** + * Every reason a 503 `dependency_failure` carries: the refresher's, the + * upstream's (not reachable, past `UPSTREAM_TIMEOUT_MS`, or at its in-flight + * limit), and the WebSocket route's own `upstream_unavailable` (a server + * shutting down, or a socket whose upstream dropped). + */ +export type DependencyFailureReason = + | UnavailableReason + | "upstream_unreachable" + | "upstream_timeout" + | "upstream_busy" + | "upstream_unavailable"; + +/** The session 401. Only the response that ended a session at step 6 carries a reason. */ +export const SESSION_INVALID = { + status: 401, + code: "session_invalid", + message: "The session is no longer valid", +} as const; + +/** A dependency failed; the session is still valid, so this is not a logout. */ +export const DEPENDENCY_FAILURE = { + status: 503, + code: "dependency_failure", + message: "A dependency of the session is unavailable; retry shortly", +} as const; + +export interface SessionInvalidBody { + code: typeof SESSION_INVALID.code; + message: string; + reason?: "refresh_indeterminate"; +} + +export interface DependencyFailureBody { + code: typeof DEPENDENCY_FAILURE.code; + message: string; + reason: DependencyFailureReason; +} + +/** The one body of a dependency failure, whoever sends it. */ +export const dependencyFailureBody = (reason: DependencyFailureReason): DependencyFailureBody => ({ + code: DEPENDENCY_FAILURE.code, + message: DEPENDENCY_FAILURE.message, + reason, +}); + +export type RefreshResponse = + | { status: typeof SESSION_INVALID.status; body: SessionInvalidBody } + | { status: typeof DEPENDENCY_FAILURE.status; body: DependencyFailureBody }; + +/** + * The response for an outcome that is not `ok`. Every `ended` is the session + * 401 (with the session cookie cleared by the caller); only + * `refresh_indeterminate` adds the reason, which re-enters login once. + */ +export function refreshResponse( + outcome: + | { kind: "ended"; reason: EndedReason } + | { kind: "unavailable"; reason: UnavailableReason }, +): RefreshResponse { + if (outcome.kind === "unavailable") { + return { status: DEPENDENCY_FAILURE.status, body: dependencyFailureBody(outcome.reason) }; + } + return { + status: SESSION_INVALID.status, + body: { + code: SESSION_INVALID.code, + message: SESSION_INVALID.message, + ...(outcome.reason === "refresh_indeterminate" ? { reason: outcome.reason } : {}), + }, + }; +} diff --git a/src/core/grants/refresh.ts b/src/core/grants/refresh.ts new file mode 100644 index 0000000..89ca2b9 --- /dev/null +++ b/src/core/grants/refresh.ts @@ -0,0 +1,665 @@ +import { randomUUID } from "node:crypto"; +import { hostname } from "node:os"; +import { performance } from "node:perf_hooks"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { decryptOAuthToken } from "better-auth/oauth2"; + +import { leaseBound, type KeycloakAuthConfig, type RefreshConfig } from "../config.js"; +import { LEASE_MARGIN_MS, POOL_CONNECTION_TIMEOUT_MS, STATEMENT_TIMEOUT_MS } from "../constants.js"; +import { InFlightWork } from "../database/in-flight-work.js"; +import { encryptToken } from "../encrypt-token.js"; +import { + httpTransport, + refreshTokens, + type RefreshResult, + type TokenTransport, +} from "../keycloak-token.js"; +import type { AuthLogger } from "../logger.js"; +import { + AmbiguousCommitError, + CompletionBudgetError, + type CompleteResult, + type GrantStore, + type StoredGrant, +} from "./grant-store.js"; +import { + emitRefreshEvent, + type RefreshEventFields, + type RefreshEventName, +} from "./refresh-events.js"; +import type { EndedReason, UnavailableReason } from "./refresh-responses.js"; + +/** The resolved Better Auth context the token helpers need (`await auth.$context`). */ +export type AuthContext = Parameters[1]; + +/** + * What `refreshIfDue` concluded. `ok` carries the grant as read (or as + * stored), with the access token's and session's remaining lives in database time at + * `observedAt` (`performance.now()`, never later than the read it is from). + */ +export type RefreshOutcome = + | { + kind: "ok"; + refreshed: boolean; + grant: StoredGrant; + accessRemainingMs: number; + refreshRemainingMs: number; + observedAt: number; + } + | { kind: "ended"; reason: EndedReason } + | { kind: "unavailable"; reason: UnavailableReason }; + +/** + * What `resolveAccessToken` concluded; `ok` carries the plaintext token and + * its usable lifetime, capped by the session's remaining life. + */ +export type AccessTokenOutcome = + | { kind: "ok"; accessToken: string; refreshed: boolean; expiresInMs: number } + | { kind: "ended"; reason: EndedReason } + | { kind: "unavailable"; reason: UnavailableReason }; + +/** What a caller may tell `refreshIfDue`. */ +export interface RefreshOptions { + /** + * The plaintext access token an upstream just rejected. While the grant + * still holds this token it is due, whatever its deadlines say; once + * another caller has rotated it, the replacement is judged by the normal + * rule, so it is used as it is unless it is due itself. + */ + rejectedAccessToken?: string; +} + +/** What a caller may tell `resolveAccessToken`. */ +export interface ResolveOptions extends RefreshOptions { + /** + * How many refreshes the call may dispatch, across its attempts: 2 by + * default (a refresh whose result arrives with no life left is followed by + * one more), 1 for the recovery after an upstream rejected the token. + */ + refreshBudget?: number; +} + +/** The refreshes one call may still dispatch, shared by its attempts. */ +interface Budget { + left: number; +} + +/** At most this many reads per call; each round returns, waits, or sees `version` change. */ +export const MAX_ROUNDS = 6; + +/** How often a waiter re-reads the grant it waits on. */ +export const WAIT_POLL_MS = 250; + +const ended = (reason: EndedReason) => ({ kind: "ended", reason }) as const; +const unavailable = (reason: UnavailableReason) => ({ kind: "unavailable", reason }) as const; + +/** A read that lost its race against the caller's budget. */ +class ReadTimeoutError extends Error { + readonly code = "read_timeout"; + constructor() { + super("a grant read did not settle within its budget"); + this.name = "ReadTimeoutError"; + } +} + +/** One caller's refresh, for the event fields and the shared deadline. */ +class Run { + readonly start = performance.now(); + /** The wait budget; every read is raced against what is left of it. */ + readonly waitDeadline: number; + holder: string | null = null; + + constructor( + readonly sessionId: string, + waitMs: number, + readonly options: RefreshOptions, + readonly budget: Budget, + private readonly logger: AuthLogger, + ) { + this.waitDeadline = this.start + waitMs; + } + + emit(event: RefreshEventName, fields: RefreshEventFields = {}, leaseHolder = this.holder) { + emitRefreshEvent( + this.logger, + event, + { sessionId: this.sessionId, leaseHolder, durationMs: performance.now() - this.start }, + fields, + ); + } +} + +const holderId = () => `${hostname()}#${process.pid}#${randomUUID()}`; + +/** What is left of a deadline at `now`; a missing deadline has nothing left. */ +const msLeft = (deadline: Date | null, now: Date) => + deadline === null ? 0 : deadline.getTime() - now.getTime(); + +const errorCode = (err: unknown) => { + const code = (err as { code?: unknown } | undefined)?.code; + return typeof code === "string" ? code : err instanceof Error ? err.name : "error"; +}; + +const errorMessage = (err: unknown) => (err instanceof Error ? err.message : "unknown error"); + +/** The `ok` outcome of a stored refresh, after its `refresh.succeeded` event. */ +function succeeded(run: Run, grant: StoredGrant, now: Date, observedAt: number) { + run.emit("refresh.succeeded", { + accessExpiresAt: grant.accessTokenExpiresAt?.toISOString(), + refreshExpiresAt: grant.refreshTokenExpiresAt?.toISOString(), + remainingSeconds: Math.max(0, Math.floor(msLeft(grant.refreshTokenExpiresAt, now) / 1000)), + }); + return { + kind: "ok", + refreshed: true, + grant, + accessRemainingMs: msLeft(grant.accessTokenExpiresAt, now), + refreshRemainingMs: msLeft(grant.refreshTokenExpiresAt, now), + observedAt, + } as const; +} + +/** The Keycloak client the refresher authenticates as, and how it bounds a refresh. */ +export interface GrantRefresherConfig { + keycloak: Pick; + refresh: RefreshConfig; +} + +/** The refresher's collaborators besides the grant store. */ +export interface GrantRefresherOptions { + /** Where the refresh events (`refresh-events.ts`) go. */ + logger: AuthLogger; + /** The token request's transport; `httpTransport` unless a test passes its own. */ + transport?: TokenTransport; + /** + * Where each run is counted, so the host's shutdown ends the pool only + * after them. A refresher built without one counts its runs for no one. + */ + inFlight?: InFlightWork; +} + +/** + * The refresh lease state machine of the `interlock` README's "Session-scoped + * grants", per grant, in Postgres. Every write is a compare-and-swap on the version + * the caller read, and every lease comparison is database time, so replicas + * coordinate without agreeing on a clock. See `refreshIfDue` for the steps. + * + * In Interlock, the BFF's session resolution is its one request-time caller: + * it resolves access tokens through `resolveAccessToken`, and forces one + * refresh with `rejectedAccessToken` after an upstream rejected the token. + * Both paths map the outcomes to `refresh-responses.ts`. + */ +export class GrantRefresher { + private readonly keycloak: GrantRefresherConfig["keycloak"]; + private readonly settings: RefreshConfig; + private readonly logger: AuthLogger; + private readonly transport: TokenTransport; + private readonly inFlight: InFlightWork; + private readonly tokenUrl: string; + /** What must still be left of the lease when the mark is acknowledged. */ + private readonly dispatchBudgetMs: number; + + constructor( + private readonly grants: GrantStore, + { keycloak, refresh }: GrantRefresherConfig, + { logger, transport = httpTransport, inFlight = new InFlightWork() }: GrantRefresherOptions, + ) { + this.keycloak = keycloak; + this.settings = refresh; + this.logger = logger; + this.transport = transport; + this.inFlight = inFlight; + this.tokenUrl = `${keycloak.issuer.replace(/\/+$/, "")}/protocol/openid-connect/token`; + this.dispatchBudgetMs = leaseBound(refresh.requestTimeoutMs) - LEASE_MARGIN_MS; + } + + /** + * The decrypted access token, refreshed first if it is due, provided some + * of both its life and the session's life remains after decryption. If + * either deadline passed during the read, completion or decryption, run + * the refresher once more to revalidate the current row (never end it from + * a stale outcome). If that result is exhausted too, the caller gets + * `unavailable(keycloak_unreachable)` rather than a dead token. + * + * `refreshBudget` bounds the Keycloak requests across both attempts. Once + * it is spent, an attempt still revalidates the row (and ends the session + * or waits on a lease exactly as before) but never claims: a grant that is + * due then is `unavailable(keycloak_unreachable)`. + */ + async resolveAccessToken( + sessionId: string, + auth: AuthContext, + { refreshBudget = 2, ...options }: ResolveOptions = {}, + ): Promise { + const budget: Budget = { left: refreshBudget }; + for (let attempt = 0; attempt < 2; attempt++) { + const outcome = await this.inFlight.track(this.run(sessionId, auth, options, budget)); + if (outcome.kind !== "ok") { + return outcome; + } + const accessToken = await decryptOAuthToken(outcome.grant.accessToken, auth); + const remaining = + Math.min(outcome.accessRemainingMs, outcome.refreshRemainingMs) - + (performance.now() - outcome.observedAt); + if (remaining > 0) { + return { + kind: "ok", + accessToken, + refreshed: outcome.refreshed, + expiresInMs: remaining, + }; + } + } + return unavailable("keycloak_unreachable"); + } + + /** + * Refreshes the session's grant if it is due, coordinating with every + * other caller through the lease. Each round, in this order (README steps + * in parentheses): + * + * 1. Read. No row: the session has no grant and is ended. + * 2. A live lease: wait for it, whatever the deadlines say, because its + * holder may be extending the session at Keycloak right now. + * 3. An expired, dispatched lease (step 6, as a successor): end the session. + * 4. The refresh deadline passed: end the session. + * 5. Not due, and not the token an upstream rejected: `ok`. + * 6. An expired, unmarked lease (step 5): recover it by claiming. + * 7. No lease: claim. 8. Mark it dispatched. 9. Send, then store, end, or + * release, by what the response proves. + * + * A lost compare-and-swap anywhere starts the next round. The rejected + * token is compared as plaintext, never by `version`, which every lease + * write bumps without touching the tokens. + * + * The run is counted in `inFlight` until it settles: once dispatched, a + * refresh must store its rotated tokens whether or not its caller is still + * there, and shutdown ends the pool only after the count reaches zero. + */ + async refreshIfDue( + sessionId: string, + auth: AuthContext, + options: RefreshOptions = {}, + ): Promise { + return this.inFlight.track(this.run(sessionId, auth, options, { left: 1 })); + } + + private async run( + sessionId: string, + auth: AuthContext, + options: RefreshOptions, + budget: Budget, + ): Promise { + const run = new Run(sessionId, this.settings.waitMs, options, budget, this.logger); + try { + return await this.rounds(run, auth); + } catch (err) { + // The message and code only: a database error's `detail` can quote the row. + run.emit("refresh.error", { code: errorCode(err), error: errorMessage(err) }); + return unavailable("database"); + } + } + + private async rounds(run: Run, auth: AuthContext): Promise { + const { sessionId } = run; + for (let round = 0; round < MAX_ROUNDS; round++) { + // Taken before the read is sent, so it is never later than the + // database's `now`, and the remaining life is never overstated. + const observedAt = performance.now(); + const read = await this.read(run); + if (read === undefined) { + if (round > 0) { + return ended("ended_elsewhere"); + } + // A grant is inserted by the callback that created the session, + // before its cookie exists, so no caller sees a session that is + // still about to get one: no compare-and-swap is needed. + await this.grants.endSession(sessionId); + return ended("no_grant"); + } + const { grant, now } = read; + const leaseExpired = grant.leaseExpiresAt !== null && grant.leaseExpiresAt <= now; + + if (grant.leaseHolder !== null && !leaseExpired) { + const waited = await this.wait(run, grant); + if (waited === "gone") { + return ended("ended_elsewhere"); + } + if (waited === "timed_out") { + return unavailable("refresh_in_progress"); + } + continue; + } + + if (grant.leaseHolder !== null && grant.dispatched) { + if ( + await this.grants.endIf(sessionId, { kind: "expiredDispatched", version: grant.version }) + ) { + run.emit( + "refresh.indeterminate", + { by: "successor", cause: "lease_expired" }, + grant.leaseHolder, + ); + return ended("refresh_indeterminate"); + } + continue; + } + + if (grant.refreshTokenExpiresAt !== null && grant.refreshTokenExpiresAt <= now) { + if ( + await this.grants.endIf(sessionId, { kind: "expiredRefresh", version: grant.version }) + ) { + return ended("refresh_expired"); + } + continue; + } + + const { rejectedAccessToken } = run.options; + const forced = + rejectedAccessToken !== undefined && + (await decryptOAuthToken(grant.accessToken, auth)) === rejectedAccessToken; + if (!forced && !this.isDue(grant, now)) { + return { + kind: "ok", + refreshed: false, + grant, + accessRemainingMs: msLeft(grant.accessTokenExpiresAt, now), + refreshRemainingMs: msLeft(grant.refreshTokenExpiresAt, now), + observedAt, + }; + } + if (run.budget.left <= 0) { + // The call has made every request it may; the token is unusable as it is. + return unavailable("keycloak_unreachable"); + } + + // Decrypted before the claim, so a failure leaves no lease behind. + const refreshToken = await decryptOAuthToken(grant.refreshToken, auth); + const holder = holderId(); + const claimed = await this.grants.claim( + sessionId, + grant.version, + holder, + this.settings.leaseMs, + ); + if (claimed === undefined) { + continue; + } + run.holder = holder; + if (grant.leaseHolder !== null) { + run.emit("refresh.lease_recovered", { recoveredFrom: grant.leaseHolder }); + } + if (forced) { + run.emit("refresh.forced", { cause: "upstream_rejected" }); + } + + const outcome = await this.dispatch(run, claimed, holder, refreshToken, auth); + if (outcome !== "retry") { + return outcome; + } + } + return unavailable("refresh_in_progress"); + } + + /** + * Due when either deadline is missing (a grant from before 2.5 has no + * refresh deadline) or within the effective leeway of the database clock: + * `min(leewayMs, half the issued lifetime)`, so a leeway at or above a + * token's lifetime cannot make a fresh token due the moment it is issued. + */ + private isDue(grant: StoredGrant, now: Date) { + const { accessTokenExpiresAt, refreshTokenExpiresAt, updatedAt } = grant; + if (accessTokenExpiresAt === null || refreshTokenExpiresAt === null) { + return true; + } + const within = (expiresAt: Date) => + expiresAt.getTime() - now.getTime() <= + Math.min(this.settings.leewayMs, (expiresAt.getTime() - updatedAt.getTime()) / 2); + return within(accessTokenExpiresAt) || within(refreshTokenExpiresAt); + } + + /** + * Waits for another caller's live lease, polling on the database's clock, + * until the row is gone, it changed (cleared, or a new version), the lease + * expired, or the shared deadline passed with the lease still held. Each + * sleep is clipped to the time left, so the last poll runs at the deadline. + */ + private async wait(run: Run, seen: StoredGrant): Promise<"changed" | "gone" | "timed_out"> { + const result = (value: "changed" | "gone" | "timed_out") => { + run.emit( + "refresh.waited", + { result: value === "changed" ? "ok" : value === "gone" ? "ended" : "timed_out" }, + seen.leaseHolder, + ); + return value; + }; + for (;;) { + const left = run.waitDeadline - performance.now(); + if (left <= 0) { + return result("timed_out"); + } + await sleep(Math.min(WAIT_POLL_MS, left)); + const read = await this.read(run); + if (read === undefined) { + return result("gone"); + } + const { grant, now } = read; + if ( + grant.version !== seen.version || + grant.leaseHolder === null || + grant.leaseExpiresAt === null || + grant.leaseExpiresAt <= now + ) { + return result("changed"); + } + } + } + + /** + * A read raced against what is left of the wait budget plus one pool wait + * and one statement. Neither pool timeout bounds an established connection + * that stops answering; a read that loses the race is abandoned (a single + * autocommit SELECT, so its connection comes back clean whenever it does). + */ + private async read(run: Run, budgetMs = Math.max(0, run.waitDeadline - performance.now())) { + return raceRead( + this.grants.read(run.sessionId), + budgetMs + POOL_CONNECTION_TIMEOUT_MS + STATEMENT_TIMEOUT_MS, + ); + } + + /** Steps 8 and 9 for a grant `holder` claimed. `retry` starts the next round. */ + private async dispatch( + run: Run, + claimed: StoredGrant, + holder: string, + refreshToken: string, + auth: AuthContext, + ): Promise { + const { sessionId } = run; + const markStart = performance.now(); + const version = await this.grants.markDispatched( + sessionId, + claimed.version, + holder, + this.settings.leaseMs, + ); + if (version === undefined) { + // The lease expired before the mark (step 6 next round) or changed. + return "retry"; + } + // The renewed lease started when Postgres ran the mark; the round trip + // bounds how much of it is already gone. + if (this.settings.leaseMs - (performance.now() - markStart) < this.dispatchBudgetMs) { + if (await this.grants.release(sessionId, version, holder)) { + run.emit("refresh.unsent", { cause: "mark_latency" }); + } else { + run.emit("refresh.lease_lost"); + } + return unavailable("database"); + } + + run.budget.left -= 1; + const result = await refreshTokens({ + tokenUrl: this.tokenUrl, + clientId: this.keycloak.clientId, + clientSecret: this.keycloak.clientSecret, + refreshToken, + timeoutMs: this.settings.requestTimeoutMs, + transport: this.transport, + onSent: () => run.emit("refresh.dispatched"), + logger: this.logger, + }).catch((err: unknown): RefreshResult => ({ kind: "indeterminate", cause: errorCode(err) })); + + switch (result.kind) { + case "ok": + return this.store(run, version, holder, result, auth); + case "invalid_grant": + return this.endHeld(run, version, holder, () => { + run.emit("refresh.invalid_grant"); + return ended("invalid_grant"); + }); + case "unsent": + if (await this.grants.release(sessionId, version, holder)) { + run.emit("refresh.unsent", { cause: result.cause }); + return unavailable("keycloak_unreachable"); + } + run.emit("refresh.lease_lost"); + return ended("ended_elsewhere"); + } + return this.endIndeterminate(run, version, holder, result.cause); + } + + /** + * The holder ends its own dispatched attempt (README step 6, at once rather + * than for the next caller). Only a matching end is this caller's outcome; + * otherwise a successor or a sign-out already ended it. + */ + private async endHeld(run: Run, version: number, holder: string, matched: () => RefreshOutcome) { + if (await this.grants.endIf(run.sessionId, { kind: "heldBy", version, holder })) { + return matched(); + } + run.emit("refresh.lease_lost"); + return ended("ended_elsewhere"); + } + + private async endIndeterminate(run: Run, version: number, holder: string, cause: string) { + return this.endHeld(run, version, holder, () => { + run.emit("refresh.indeterminate", { by: "holder", cause }); + return ended("refresh_indeterminate"); + }); + } + + /** Step 9 on `ok`: encrypt, then `complete` with the anchor and the lifetimes. */ + private async store( + run: Run, + version: number, + holder: string, + result: Extract, + auth: AuthContext, + ): Promise { + const { tokens } = result; + const encrypted = { + accessToken: await encryptToken(tokens.accessToken, auth), + refreshToken: await encryptToken(tokens.refreshToken, auth), + ...(tokens.idToken === undefined + ? {} + : { idToken: await encryptToken(tokens.idToken, auth) }), + }; + const observedAt = performance.now(); + let completed: CompleteResult; + try { + completed = await this.grants.complete({ + sessionId: run.sessionId, + version, + holder, + tokens: encrypted, + startedAt: result.sentAt, + lifetimes: result.lifetimes, + }); + } catch (err) { + if (err instanceof CompletionBudgetError) { + // Keycloak rotated the token and nothing was persisted: the holder's + // own indeterminate outcome, so it carries the reason. + return this.endIndeterminate(run, version, holder, "completion_budget"); + } + if (err instanceof AmbiguousCommitError) { + return this.resolveCommit(run, version, holder); + } + throw err; + } + + switch (completed.status) { + case "stored": + return succeeded(run, completed.grant, completed.now, observedAt); + case "exhausted": + run.emit("refresh.succeeded", { remainingSeconds: 0 }); + return ended("refresh_expired"); + } + run.emit("refresh.lease_lost"); + return ended("ended_elsewhere"); + } + + /** + * `complete` sent COMMIT and heard nothing. Under Read Committed an + * unchanged row is inconclusive (the commit may still be finishing), so + * every conclusion is a compare-and-swap or a version the commit alone + * could have produced: nothing but that commit clears the holder's own + * mark, and a successor deletes the row rather than bumping it. + */ + private async resolveCommit(run: Run, version: number, holder: string): Promise { + const budget = this.settings.waitMs; + const reread = async () => { + const observedAt = performance.now(); + const read = await this.read(run, budget); + if (read === undefined) { + run.emit("refresh.lease_lost"); + return ended("ended_elsewhere"); + } + if (read.grant.version > version) { + return succeeded(run, read.grant, read.now, observedAt); + } + return undefined; + }; + + const first = await reread(); + if (first !== undefined) { + return first; + } + // The session lock makes this wait behind a commit still finishing. + if (await this.grants.endIf(run.sessionId, { kind: "heldBy", version, holder })) { + run.emit("refresh.indeterminate", { by: "holder", cause: "ambiguous_commit" }); + return ended("refresh_indeterminate"); + } + // Never answer from the unchanged read itself; leave the mark otherwise. + return (await reread()) ?? unavailable("database"); + } +} + +/** Races a read against a budget; a lost race rejects with `ReadTimeoutError`. */ +async function raceRead(read: Promise, budgetMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new ReadTimeoutError()), budgetMs); + }); + // An abandoned read that fails later must not become an unhandled rejection. + read.catch(() => undefined); + try { + return await Promise.race([read, timeout]); + } finally { + clearTimeout(timer); + } +} + +/** + * The refresher over a grant store, with the token endpoint, client, and + * refresh settings from the configuration, the logger, the real transport + * unless a test passes its own, and the host's `InFlightWork` when the + * pool's shutdown is to wait for its runs. + */ +export const createGrantRefresher = ( + grants: GrantStore, + config: GrantRefresherConfig, + options: GrantRefresherOptions, +) => new GrantRefresher(grants, config, options); diff --git a/src/core/keycloak-profile.ts b/src/core/keycloak-profile.ts new file mode 100644 index 0000000..a4f8e34 --- /dev/null +++ b/src/core/keycloak-profile.ts @@ -0,0 +1,100 @@ +/** + * Maps a Keycloak profile onto Better Auth's user, with two fallbacks: + * + * - **No `name` claim.** Keycloak emits `name` only when the user has both a + * given and a family name; `preferred_username` is always present. + * - **No `email` claim.** Better Auth would fail the callback with + * `email_not_found` and create no session. Email identifies nobody here: + * the account is keyed by `sub` and authorization comes from Keycloak + * groups, so the user gets a stable placeholder instead of being locked out. + * `.invalid` is reserved by RFC 2606 and never resolves or receives mail. + */ + +import { registerLoginLifetimes, tokenLifetimes } from "./keycloak-token.js"; + +/** The claims read here; Better Auth hands over the whole profile. */ +interface KeycloakProfile { + sub?: unknown; + name?: unknown; + preferred_username?: unknown; + email?: unknown; + email_verified?: unknown; + [claim: string]: unknown; +} + +/** Domain of the placeholder address. Reserved by RFC 2606. */ +const PLACEHOLDER_EMAIL_DOMAIN = "keycloak.invalid"; + +/** A claim is usable only if it is a non-blank string. */ +const claim = (value: unknown) => + typeof value === "string" && value.trim() !== "" ? value : undefined; + +/** Keycloak's `sub`, or undefined when it is missing or blank. */ +const subject = (profile: KeycloakProfile) => { + const { sub } = profile; + return typeof sub === "number" ? String(sub) : claim(sub); +}; + +export function mapKeycloakProfile(profile: KeycloakProfile) { + const email = claim(profile.email); + return { + name: claim(profile.name) ?? claim(profile.preferred_username), + email: email ?? `${subject(profile) ?? "unknown"}@${PLACEHOLDER_EMAIL_DOMAIN}`, + // Set every time: Better Auth spreads this over its own mapping, and a + // placeholder address must never count as verified. + emailVerified: email !== undefined && profile.email_verified === true, + }; +} + +/** The part of Better Auth's token object `keycloakAccountSubject` reads and marks. */ +interface LoginTokens { + refreshTokenExpiresAt?: Date; + raw?: unknown; +} + +/** + * The account key. Keycloak always identifies a user by `sub`; without a + * discovery document the generic provider would fall back to `id`, which + * Keycloak never sends. Throwing makes the callback log the failure and + * redirect with `unable_to_get_user_info` instead of keying an account on nothing. + * + * It also carries the token response's lifetimes to the grant. Better Auth + * reads `refresh_token_expires_in`, which Keycloak never sends (it sends + * `refresh_expires_in`), and copies only named fields into the account write, + * but it hands this function and the account hooks the same token object. So + * `tokens.refreshTokenExpiresAt` gets a Date of its own, and the raw lifetimes + * are registered under that Date, which the account hook receives as it is. + * A response without a usable `refresh_expires_in` gets no Date, and the + * session hook refuses the login. + */ +export function keycloakAccountSubject({ + tokens, + profile, +}: { + tokens?: LoginTokens; + profile: KeycloakProfile; +}) { + const sub = subject(profile); + if (sub === undefined) { + throw new Error("Keycloak profile has no sub claim"); + } + if (tokens) { + const lifetimes = tokenLifetimes(tokens.raw); + if (lifetimes.refreshExpiresIn === null) { + delete tokens.refreshTokenExpiresAt; + } else { + const marker = new Date(Date.now() + lifetimes.refreshExpiresIn * 1000); + registerLoginLifetimes(marker, lifetimes); + tokens.refreshTokenExpiresAt = marker; + } + } + return sub; +} + +/** + * Whether an address is the placeholder `mapKeycloakProfile` made up, so a UI + * can say "not provided" rather than show it. Nothing should authorize on it. + */ +export function isPlaceholderEmail(email: string | null | undefined): boolean { + return typeof email === "string" && email.endsWith(`@${PLACEHOLDER_EMAIL_DOMAIN}`); +} diff --git a/src/core/keycloak-token.ts b/src/core/keycloak-token.ts new file mode 100644 index 0000000..3cf63b5 --- /dev/null +++ b/src/core/keycloak-token.ts @@ -0,0 +1,303 @@ +import http from "node:http"; +import https from "node:https"; +import { performance } from "node:perf_hooks"; + +import type { AuthLogger } from "./logger.js"; + +/** A token response's two lifetimes, in seconds. */ +export interface Lifetimes { + accessExpiresIn: number; + refreshExpiresIn: number; +} + +/** A lifetime is usable only as a positive finite number of seconds. */ +const lifetime = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; + +/** + * `expires_in` and `refresh_expires_in` from a Keycloak token response, or + * null for one that is missing or not positive. Keycloak sends `0` for an + * offline token or an unlimited SSO session, neither of which the realm + * contract allows, so a zero is as unusable as a missing value. + */ +export function tokenLifetimes(raw: unknown): { + accessExpiresIn: number | null; + refreshExpiresIn: number | null; +} { + const body = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + return { + accessExpiresIn: lifetime(body.expires_in), + refreshExpiresIn: lifetime(body.refresh_expires_in), + }; +} + +/** + * The lifetimes of a login's token response, keyed by the `Date` that + * `keycloakAccountSubject` assigns to `tokens.refreshTokenExpiresAt`. Better + * Auth copies that same object into the account write, so the account hook + * finds the raw lifetimes by it (the raw response itself never reaches a hook). + */ +const loginLifetimes = new WeakMap>(); + +export const registerLoginLifetimes = ( + marker: Date, + lifetimes: ReturnType, +) => loginLifetimes.set(marker, lifetimes); + +/** The lifetimes registered for an account write's `refreshTokenExpiresAt`, if any. */ +export const loginLifetimesOf = (marker: unknown) => + marker instanceof Date ? loginLifetimes.get(marker) : undefined; + +/** One token request, as the transport sends it. */ +export interface TokenRequest { + url: string; + headers: Record; + body: string; + /** One deadline over both phases. */ + timeoutMs: number; + /** Called once, immediately before the first byte of the request is written. */ + onSent?: () => void; +} + +/** + * What a transport observed, by phase. `connecting` (DNS, TCP, TLS) ends + * before anything is written, so a failure there proves Keycloak never saw + * the request. Anything that goes wrong in `sent` may have reached it. + * `sentAt` is `performance.now()` immediately before the first write. + */ +export type TransportResult = + | { phase: "connecting"; cause: string } + | { phase: "sent"; sentAt: number; cause: string } + | { phase: "sent"; sentAt: number; status: number; body: string }; + +export type TokenTransport = (request: TokenRequest) => Promise; + +/** More than any token response; a larger body is a failure, not a buffer. */ +const MAX_RESPONSE_BYTES = 256 * 1024; + +const causeOf = (err: unknown) => { + const code = (err as { code?: unknown } | undefined)?.code; + return typeof code === "string" ? code : "error"; +}; + +/** + * The token request over Node's `https.request` (`http.request` for a plain + * `http:` URL, which only tests use). Not `fetch`: it reports every failure + * as one rejection, and no error code proves the request never left, because + * an established socket reports `EHOSTUNREACH` or `ECONNRESET` after the body + * was written just as a failed connect does. + * + * Nothing is written until the socket's `secureConnect` (`connect`), so the + * phase boundary is an event, not an inference. `agent: false` makes every + * request a fresh connection: a reused keep-alive socket never emits those + * events again. Redirects are never followed; a 3xx is just a status. + */ +export const httpTransport: TokenTransport = async (request) => + new Promise((resolve) => { + const url = new URL(request.url); + const secure = url.protocol === "https:"; + let phase: "connecting" | "sent" = "connecting"; + let sentAt = 0; + let settled = false; + let timer: NodeJS.Timeout | undefined; + + const finish = (result: TransportResult) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(result); + } + }; + const fail = (cause: string) => + finish(phase === "connecting" ? { phase, cause } : { phase, sentAt, cause }); + + const req = (secure ? https : http).request(url, { + method: "POST", + agent: false, + headers: { ...request.headers, "content-length": Buffer.byteLength(request.body) }, + }); + timer = setTimeout(() => { + fail("timeout"); + req.destroy(); + }, request.timeoutMs); + + req.on("error", (err) => fail(causeOf(err))); + req.on("socket", (socket) => { + socket.once(secure ? "secureConnect" : "connect", () => { + if (settled) { + return; + } + sentAt = performance.now(); + phase = "sent"; + request.onSent?.(); + req.end(request.body); + }); + }); + req.on("response", (res) => { + const chunks: Buffer[] = []; + let size = 0; + res.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_RESPONSE_BYTES) { + fail("response_too_large"); + req.destroy(); + return; + } + chunks.push(chunk); + }); + res.on("end", () => + finish({ + phase: "sent", + sentAt, + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + res.on("error", (err) => fail(causeOf(err))); + res.on("close", () => { + if (!res.complete) { + fail("response_aborted"); + } + }); + }); + }); + +/** A refresh's tokens, in plaintext; the refresher encrypts them before they are stored. */ +export interface RefreshedTokens { + accessToken: string; + refreshToken: string; + idToken?: string; +} + +/** + * How a refresh ended, by what it proves: + * + * - `ok`: Keycloak rotated the tokens. + * - `invalid_grant`: Keycloak refused the refresh token, so its session is gone. + * - `unsent`: the request provably never left (the connecting phase), so the + * stored refresh token is unused. + * - `indeterminate`: everything else. Keycloak may have rotated the token, so + * the stored one must never be replayed. + */ +export type RefreshResult = + | { kind: "ok"; tokens: RefreshedTokens; lifetimes: Lifetimes; sentAt: number } + | { kind: "invalid_grant" } + | { kind: "unsent"; cause: string } + | { kind: "indeterminate"; cause: string }; + +export interface RefreshRequest { + tokenUrl: string; + clientId: string; + clientSecret: string; + /** Plaintext, decrypted just before the call; never logged. */ + refreshToken: string; + timeoutMs: number; + transport?: TokenTransport; + onSent?: () => void; + /** Where a client-configuration error or a contract breach is reported. */ + logger: AuthLogger; +} + +const parseJson = (body: string): Record | undefined => { + try { + const value: unknown = JSON.parse(body); + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + } catch { + return undefined; + } +}; + +const nonBlank = (value: unknown) => + typeof value === "string" && value.trim() !== "" ? value : undefined; + +/** + * One `refresh_token` grant at Keycloak's token endpoint, authenticated as + * `client_secret_post` (what Better Auth's own exchange uses with a client + * secret), classified by what it proves (`RefreshResult`). The phase, not an + * error code, decides `unsent`: anything that cannot be shown to have + * happened before the first byte was written is indeterminate. + * + * A client-configuration error (`invalid_client` and friends) and a 200 that + * breaks the contract are logged at error level with Keycloak's `error` code + * only (`error_description` can quote the request), and still count as + * indeterminate. + */ +export async function refreshTokens({ + tokenUrl, + clientId, + clientSecret, + refreshToken, + timeoutMs, + transport = httpTransport, + onSent, + logger, +}: RefreshRequest): Promise { + const result = await transport({ + url: tokenUrl, + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }).toString(), + timeoutMs, + onSent, + }); + + if (result.phase === "connecting") { + return { kind: "unsent", cause: result.cause }; + } + if (!("status" in result)) { + return { kind: "indeterminate", cause: result.cause }; + } + + const body = parseJson(result.body); + if (result.status === 200 && body !== undefined) { + const accessToken = nonBlank(body.access_token); + const refreshed = nonBlank(body.refresh_token); + const { accessExpiresIn, refreshExpiresIn } = tokenLifetimes(body); + if (accessToken && refreshed && accessExpiresIn !== null && refreshExpiresIn !== null) { + return { + kind: "ok", + tokens: { accessToken, refreshToken: refreshed, idToken: nonBlank(body.id_token) }, + lifetimes: { accessExpiresIn, refreshExpiresIn }, + sentAt: result.sentAt, + }; + } + const missing = Object.entries({ + access_token: accessToken, + refresh_token: refreshed, + expires_in: accessExpiresIn, + refresh_expires_in: refreshExpiresIn, + }) + .filter(([, present]) => present === null || present === undefined) + .map(([field]) => field); + logger.error({ + message: `Keycloak answered a token refresh without ${missing.join(", ")}; the realm contract requires them`, + missing, + }); + return { kind: "indeterminate", cause: "malformed_response" }; + } + + const error = typeof body?.error === "string" ? body.error : undefined; + if (result.status === 400 && error === "invalid_grant") { + return { kind: "invalid_grant" }; + } + if (result.status >= 400 && result.status < 500) { + logger.error({ + message: `Keycloak refused a token refresh with ${result.status} ${error ?? "(no error code)"}; check the client configuration`, + status: result.status, + error, + }); + } + return { + kind: "indeterminate", + cause: result.status === 200 ? "malformed_response" : String(result.status), + }; +} diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 0000000..93b2c5f --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,22 @@ +/** + * One log record: a message and the structured fields beside it. The package names its fields + * clear of the fragments `token`, `secret`, `password` and `credential`, so a host logger that + * redacts by key keeps them as values. + */ +export type LogRecord = { message: string } & Record; + +/** + * Where the package logs. Every function that logs takes one explicitly; there is no default and + * no singleton, because a silent logger would drop refused logout tokens and indeterminate + * refreshes without a trace. + * + * Redaction is the host's job. The package never puts a token in a field of its own, but the + * Better Auth records `createKeycloakAuthOptions` forwards carry Better Auth's own arguments + * under `args`, and those can hold tokens, request bodies and database errors. + */ +export interface AuthLogger { + debug(record: LogRecord): void; + log(record: LogRecord): void; + warn(record: LogRecord): void; + error(record: LogRecord): void; +} diff --git a/src/core/logout/backchannel.ts b/src/core/logout/backchannel.ts new file mode 100644 index 0000000..7980a31 --- /dev/null +++ b/src/core/logout/backchannel.ts @@ -0,0 +1,516 @@ +import type { BetterAuthPlugin } from "better-auth"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import { + createLocalJWKSet, + createRemoteJWKSet, + jwtVerify, + type JWTVerifyGetKey, + type JWTVerifyResult, + type RemoteJWKSet, +} from "jose"; + +import { StartupError } from "../callback-path.js"; +import type { GrantStore } from "../grants/grant-store.js"; +import type { AuthLogger } from "../logger.js"; +import { isPlainObject } from "../plain-object.js"; + +/** The receiver's route under the base path, unless configured otherwise. */ +export const BACKCHANNEL_LOGOUT_ROUTE = "/backchannel-logout"; + +/** The `events` member that makes a JWT a logout token (OIDC Back-Channel Logout 1.0, 2.4). */ +export const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; + +/** 400: the logout token was refused; the logged `reason` says why. */ +export const LOGOUT_TOKEN_INVALID = "logout_token_invalid"; + +/** 503: the realm's keys could not be read, so the token could not be judged. */ +export const LOGOUT_TOKEN_UNVERIFIABLE = "logout_token_unverifiable"; + +/** Why a logout token was refused, as logged; the answer is always `logout_token_invalid`. */ +export type LogoutTokenRejection = + | "body_not_form" + | "token_missing" + | "malformed" + | "alg_not_allowed" + | "signature" + | "unknown_key" + | "issuer" + | "audience" + | "iat_missing" + | "iat_stale" + | "iat_future" + | "exp_missing" + | "expired" + | "events_missing" + | "nonce_present" + | "subject_missing" + | "jti_missing"; + +/** Why the realm's keys were not available, as logged; the answer is `logout_token_unverifiable`. */ +export type KeyResolutionReason = + | "key_refresh_cooling" + | "jwks_timeout" + | "jwks_status" + | "jwks_malformed" + | "jwks_unreachable" + | "jwks_backoff"; + +/** A verified logout token: at least one of `sid` and `sub`. */ +export interface LogoutClaims { + sid?: string; + sub?: string; + jti: string; + /** The JWS header's `alg`, `typ` and `kid`, for the debug log. */ + header: { alg?: string; typ?: string; kid?: string }; +} + +export type LogoutTokenResult = + | LogoutClaims + | { rejected: LogoutTokenRejection } + | { unverifiable: KeyResolutionReason }; + +/** + * How long after a successful JWKS fetch an unknown `kid` waits before it may + * trigger another. Keycloak signs with a new key the moment it becomes active + * and never retries a logout, so a long cooldown loses the first logouts after + * a rotation; one second bounds that loss, and bounds what random `kid`s on + * this unthrottled route can cost, to one fetch per second per replica. + */ +export const KEY_REFETCH_COOLDOWN_MS = 1_000; + +/** How long a fetched key set is used before it is fetched again. */ +export const KEY_CACHE_MAX_AGE_MS = 600_000; + +/** + * How long after a failed fetch the resolver uses only the keys it has. jose + * moves its cooldown only on a success, so without this an outage would cost + * one fetch per request. + */ +export const KEY_FETCH_BACKOFF_MS = 1_000; + +/** Keycloak's signature algorithms; never `none` or an HMAC. */ +const ALGORITHMS = [ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", +]; + +/** The realm's key set at its fixed JWKS endpoint, independent of startup discovery. */ +export const realmKeys = (issuer: string) => + createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`), { + cooldownDuration: KEY_REFETCH_COOLDOWN_MS, + cacheMaxAge: KEY_CACHE_MAX_AGE_MS, + }); + +/** The key set could not be read: a dependency failure, not a bad token. */ +export class KeyResolutionFailure extends Error { + constructor(readonly reason: KeyResolutionReason) { + super(`the realm keys could not be resolved (${reason})`); + this.name = "KeyResolutionFailure"; + } +} + +/** + * No key matched the token's header. `cooling` is whether the remote set was + * inside its cooldown when the lookup began, so it could not refetch. + */ +export class UnknownKey extends Error { + constructor(readonly cooling: boolean) { + super("no key of the realm matches the token"); + this.name = "UnknownKey"; + } +} + +/** + * jose's error code. Compared by value rather than class: a fetch error comes + * from another realm under Jest, and a jose bump keeps its codes stable. + */ +const codeOf = (err: unknown) => { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === "string" ? code : undefined; +}; + +/** Why a key set fetch failed, from what jose threw. */ +function failureReason(err: unknown): KeyResolutionReason { + switch (codeOf(err)) { + case "ERR_JWKS_TIMEOUT": + return "jwks_timeout"; + case "ERR_JWKS_INVALID": + case "ERR_JWK_INVALID": + case "ERR_JOSE_NOT_SUPPORTED": + return "jwks_malformed"; + case "ERR_JOSE_GENERIC": + // jose's two generic errors here: a non-200 answer, or a body that is not JSON. + return (err as Error).message.includes("200 OK") ? "jwks_status" : "jwks_malformed"; + default: + // A refused connection, a DNS failure or a TLS error, all fetch `TypeError`s. + return "jwks_unreachable"; + } +} + +/** + * The key resolver `jwtVerify` calls, around the remote set: + * + * - An unknown `kid` is an `UnknownKey` tagged with the remote set's cooldown + * as it was on entry. A refetch restarts the cooldown, so reading it after + * the miss would call every unknown key "cooling". + * - Any other failure is a `KeyResolutionFailure`, and starts a backoff + * during which only the cached set is consulted. The failing request itself + * also falls back to the cached set (jose keeps a stale set after its + * `cacheMaxAge`), so a known key keeps verifying through an outage; only a + * key this replica has never seen is lost. + */ +export function createKeyResolver( + remote: RemoteJWKSet, + backoffMs = KEY_FETCH_BACKOFF_MS, +): JWTVerifyGetKey { + let failedUntil = 0; + + const fromCache = async ( + header: Parameters[0], + token: Parameters[1], + reason: KeyResolutionReason, + ) => { + const jwks = remote.jwks(); + if (jwks === undefined) { + throw new KeyResolutionFailure(reason); + } + try { + return await createLocalJWKSet(jwks)(header, token); + } catch (err) { + if (codeOf(err) === "ERR_JWKS_MULTIPLE_MATCHING_KEYS") { + throw err; + } + throw new KeyResolutionFailure(reason); + } + }; + + return async (header, token) => { + if (Date.now() < failedUntil) { + return fromCache(header, token, "jwks_backoff"); + } + const cooling = remote.coolingDown; + try { + return await remote(header, token); + } catch (err) { + const code = codeOf(err); + if (code === "ERR_JWKS_NO_MATCHING_KEY") { + throw new UnknownKey(cooling); + } + if (code === "ERR_JWKS_MULTIPLE_MATCHING_KEYS") { + throw err; + } + failedUntil = Date.now() + backoffMs; + return fromCache(header, token, failureReason(err)); + } + }; +} + +/** A failed claim check, by the claim jose names and why it failed. */ +function claimRejection(err: unknown): LogoutTokenRejection { + const { claim, reason } = err as { claim?: string; reason?: string }; + if (claim === "iss") { + return "issuer"; + } + if (claim === "aud") { + return "audience"; + } + if (reason === "missing") { + switch (claim) { + case "iat": + return "iat_missing"; + case "exp": + return "exp_missing"; + case "jti": + return "jti_missing"; + } + } + if (claim === "iat" && reason === "check_failed") { + return "iat_future"; + } + // A claim of the wrong type, or a future `nbf`, which Keycloak never sends. + return "malformed"; +} + +/** What `jwtVerify` threw, as a refusal; undefined for anything that is not jose's. */ +function rejectionOf(err: unknown): LogoutTokenRejection | undefined { + const code = codeOf(err); + switch (code) { + case "ERR_JOSE_ALG_NOT_ALLOWED": + return "alg_not_allowed"; + case "ERR_JWS_SIGNATURE_VERIFICATION_FAILED": + return "signature"; + // Keycloak always sets `kid`, so a token that several keys could verify + // is not one it issued. + case "ERR_JWKS_MULTIPLE_MATCHING_KEYS": + return "unknown_key"; + case "ERR_JWT_EXPIRED": + return (err as { claim?: string }).claim === "iat" ? "iat_stale" : "expired"; + case "ERR_JWT_CLAIM_VALIDATION_FAILED": + return claimRejection(err); + } + return code?.startsWith("ERR_J") ? "malformed" : undefined; +} + +/** A claim that, when present, must be a non-blank string. */ +const presentClaim = (payload: Record, name: string) => { + if (!Object.prototype.hasOwnProperty.call(payload, name)) { + return { present: false } as const; + } + const value = payload[name]; + return typeof value === "string" && value.trim() !== "" + ? ({ present: true, value } as const) + : ({ present: true, value: undefined } as const); +}; + +export interface LogoutTokenValidatorOptions { + /** The realm's issuer, without a trailing slash. */ + issuer: string; + /** The audience: Interlock's client id. */ + clientId: string; + /** The realm's key set; `realmKeys(issuer)` unless a spec supplies one. */ + getKey?: RemoteJWKSet; +} + +/** + * Validates a logout token as OIDC Back-Channel Logout 1.0 section 2.6 asks: + * a JWT signed by the realm (an asymmetric algorithm only), for this client, + * issued within five minutes and not expired (60 s of skew either way), with + * a `jti`, the back-channel logout event, no `nonce`, and a `sid`, a `sub` or + * both. No `jti` is remembered: `exp` and the five-minute `iat` bound limit a + * replay instead. The JWS header `typ` is not enforced; `events` and the + * absent `nonce` already tell a logout token from an ID token. + */ +export function createLogoutTokenValidator({ + issuer, + clientId, + getKey = realmKeys(issuer), +}: LogoutTokenValidatorOptions) { + const resolve = createKeyResolver(getKey); + + return async (token: string): Promise => { + let verified: JWTVerifyResult; + try { + verified = await jwtVerify(token, resolve, { + issuer, + audience: clientId, + algorithms: ALGORITHMS, + clockTolerance: 60, + maxTokenAge: "5 minutes", + requiredClaims: ["iat", "exp", "jti"], + }); + } catch (err) { + if (err instanceof KeyResolutionFailure) { + return { unverifiable: err.reason }; + } + if (err instanceof UnknownKey) { + return err.cooling ? { unverifiable: "key_refresh_cooling" } : { rejected: "unknown_key" }; + } + const rejected = rejectionOf(err); + if (rejected === undefined) { + throw err; + } + return { rejected }; + } + + const payload = verified.payload as Record; + const { events } = payload; + if (!isPlainObject(events) || !isPlainObject(events[BACKCHANNEL_LOGOUT_EVENT])) { + return { rejected: "events_missing" }; + } + if (Object.prototype.hasOwnProperty.call(payload, "nonce")) { + return { rejected: "nonce_present" }; + } + const jti = presentClaim(payload, "jti"); + if (jti.value === undefined) { + return { rejected: "jti_missing" }; + } + const sid = presentClaim(payload, "sid"); + const sub = presentClaim(payload, "sub"); + if (!sid.present && !sub.present) { + return { rejected: "subject_missing" }; + } + if ((sid.present && sid.value === undefined) || (sub.present && sub.value === undefined)) { + return { rejected: "malformed" }; + } + const { alg, typ, kid } = verified.protectedHeader; + return { sid: sid.value, sub: sub.value, jti: jti.value, header: { alg, typ, kid } }; + }; +} + +export interface BackchannelLogoutOptions extends LogoutTokenValidatorOptions { + /** The route under Better Auth's base path, from `KEYCLOAK_BACKCHANNEL_LOGOUT_PATH`. */ + route: string; + /** Where every refusal and every ended session is reported; never the token. */ + logger: AuthLogger; +} + +/** + * Keycloak's back-channel logout receiver, as a Better Auth plugin route + * (`POST `), so every app that mounts Better Auth's handler + * serves it at the same shape of path. + * + * Keycloak posts `logout_token=` as a form, with no cookie and no + * `Origin`, treats any 2xx as delivered, and never retries. The route + * therefore accepts form bodies only (better-call answers 415 to anything + * else before the handler runs), answers 200 `{ ended }` for any valid token, + * including one that matched nothing (idempotent), 400 `logout_token_invalid` + * for a refused token, and 503 `logout_token_unverifiable` when the realm's + * keys could not be read, so a failed delivery never reads as a bad token. + * Every handler answer carries `Cache-Control: no-store`. + * + * A token with `sid` ends the sessions of that Keycloak session only; one + * with only `sub` ends every grant-backed session of the subject, which the + * contract does not expect (it asks for session-bound tokens) and is logged + * as a deviation. The token is never logged or echoed. + */ +export function backchannelLogout( + grants: GrantStore, + { route, logger, ...validatorOptions }: BackchannelLogoutOptions, +): BetterAuthPlugin { + const validate = createLogoutTokenValidator(validatorOptions); + + const refuse = (reason: LogoutTokenRejection): never => { + logger.warn({ message: "Refused a back-channel logout token", reason }); + throw new APIError("BAD_REQUEST", { + code: LOGOUT_TOKEN_INVALID, + message: "The logout token was not accepted", + }); + }; + + const unavailable = (reason: KeyResolutionReason): never => { + logger.error({ + message: "Could not verify a back-channel logout token: the realm keys were not available", + reason, + }); + throw new APIError("SERVICE_UNAVAILABLE", { + code: LOGOUT_TOKEN_UNVERIFIABLE, + message: "The logout token could not be verified", + }); + }; + + return { + id: "backchannel-logout", + endpoints: { + backchannelLogout: createAuthEndpoint( + route, + { + method: "POST", + metadata: { + // better-call lets an endpoint override the router's JSON-only list. + allowedMediaTypes: ["application/x-www-form-urlencoded"], + // `Cache-Control: no-store` on every answer the handler gives. + noStore: true, + openapi: { + operationId: "backchannelLogout", + description: "Receive an OpenID Connect back-channel logout token from Keycloak", + responses: { + "200": { description: "The token was valid; `ended` sessions were ended" }, + "400": { description: "The logout token was refused" }, + "503": { description: "The realm's keys could not be read" }, + }, + }, + }, + }, + async (ctx) => { + // No body schema: a schema failure would be better-call's own + // answer, before the handler and without `no-store`. An empty + // body reaches the handler as none at all: a form with no fields. + const body: unknown = ctx.body ?? {}; + if (!isPlainObject(body)) { + return refuse("body_not_form"); + } + const token = body.logout_token; + if (typeof token !== "string" || token === "") { + return refuse("token_missing"); + } + + const result = await validate(token); + if ("rejected" in result) { + return refuse(result.rejected); + } + if ("unverifiable" in result) { + return unavailable(result.unverifiable); + } + + const { sid, sub, jti, header } = result; + logger.debug({ + message: "Back-channel logout token header", + alg: header.alg, + typ: header.typ, + kid: header.kid, + }); + let ended: string[]; + if (sid !== undefined) { + ended = await grants.endSessionsBySid(sid); + logger.log({ + message: "Back-channel logout ended the sessions of a Keycloak session", + keycloakSid: sid, + subject: sub, + endedSessions: ended, + logoutJti: jti, + }); + } else if (sub !== undefined) { + ended = await grants.endSessionsBySubject(sub); + logger.warn({ + message: + "Back-channel logout token carried no sid; ended every session of the subject (contract deviation)", + subject: sub, + endedSessions: ended, + logoutJti: jti, + }); + } else { + // The validator refuses a token with neither claim; this keeps + // that true should it ever change. + return refuse("subject_missing"); + } + return ctx.json({ ended: ended.length }); + }, + ), + }, + }; +} + +/** An endpoint path as a matcher for concrete paths: a `:param` segment matches any one segment. */ +const matcherOf = (path: string) => + new RegExp( + `^${path + .split("/") + .map((segment) => + segment.startsWith(":") ? "[^/]+" : segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ) + .join("/")}$`, + ); + +/** + * Fails startup unless the receiver is the only endpoint at its route. The + * exposure hook allows a path whatever endpoint serves it, so a + * `KEYCLOAK_BACKCHANNEL_LOGOUT_PATH` of `/api/auth/list-sessions` would expose + * Better Auth's own `GET /list-sessions`, and one that a parameter route + * matches (`/callback/keycloak`) would shadow that route. A Better Auth bump + * that adds a route at the receiver's path stops here too. + */ +export function assertReceiverRoute(auth: { api: object }, route: string) { + const receiver: unknown = Reflect.get(auth.api, "backchannelLogout"); + const receiverPath: unknown = + receiver && typeof receiver === "function" ? Reflect.get(receiver, "path") : undefined; + if (receiverPath !== route) { + throw new StartupError(`The back-channel logout receiver is not served at ${route}`); + } + const others = Object.entries(auth.api) + .filter(([name]) => name !== "backchannelLogout") + .map(([, endpoint]): unknown => Reflect.get(endpoint as object, "path")) + .filter((path): path is string => typeof path === "string" && matcherOf(path).test(route)); + if (others.length > 0) { + throw new StartupError( + `KEYCLOAK_BACKCHANNEL_LOGOUT_PATH ends in ${route}, which Better Auth also serves ` + + `(${others.join(", ")}); choose a path no other auth route uses`, + ); + } +} diff --git a/src/core/logout/keycloak-logout.ts b/src/core/logout/keycloak-logout.ts new file mode 100644 index 0000000..b9e96c9 --- /dev/null +++ b/src/core/logout/keycloak-logout.ts @@ -0,0 +1,124 @@ +import { randomBytes } from "node:crypto"; + +/** + * The deadline on the whole outbound logout: connecting, sending, and + * Keycloak's answer, which includes its own back-channel deliveries to the + * realm's clients (its socket timeout for each is also 5 s). An answer that + * misses it is `unconfirmed`, never `ended`. + */ +export const KEYCLOAK_LOGOUT_DEADLINE_MS = 5_000; + +/** + * What sign-out reports about the Keycloak session, as `keycloakLogout`: + * + * - `ended`: Keycloak confirmed the targeted Keycloak session is gone (ended + * now, or already). It says nothing about whether every other client of + * that session received its back-channel logout. + * - `unconfirmed`: the outcome is unknown. The session may already be gone; + * if it survives, activity elsewhere can keep extending its idle lifetime. + * - `no_session`: there was no Interlock session to end, so nothing is + * asserted about Keycloak; a repeated sign-out never upgrades an earlier + * `unconfirmed`. + */ +export type KeycloakLogout = "ended" | "unconfirmed" | "no_session"; + +/** Why a logout was not confirmed, as logged. */ +export type UnconfirmedReason = + | "no_hint" + | "timeout" + | "unreachable" + | "unexpected_status" + | "unexpected_redirect"; + +export type KeycloakLogoutResult = + | { outcome: "ended" } + | { outcome: "unconfirmed"; reason: UnconfirmedReason; status?: number }; + +export interface KeycloakLogoutRequest { + /** The realm's issuer, without a trailing slash. */ + issuer: string; + clientId: string; + /** + * A URI the client's `post.logout.redirect.uris` accepts. Keycloak's + * redirect to it, carrying this request's `state`, is the only proof of a + * logout: its pages (the confirmation, "You are logged out", an error) all + * answer 200 or 400 with HTML. + */ + postLogoutRedirectUri: string; + /** The grant's ID token, decrypted. Sent in the form body only. */ + idTokenHint: string; + deadlineMs?: number; +} + +/** A 302 to the redirect URI with this request's `state` is a confirmed logout. */ +function classify( + status: number, + location: string | null, + expected: URL, + state: string, +): KeycloakLogoutResult { + if (status !== 302) { + return { outcome: "unconfirmed", reason: "unexpected_status", status }; + } + let url: URL; + try { + url = new URL(location ?? ""); + } catch { + return { outcome: "unconfirmed", reason: "unexpected_redirect", status }; + } + return url.origin === expected.origin && + url.pathname === expected.pathname && + url.searchParams.get("state") === state + ? { outcome: "ended" } + : { outcome: "unconfirmed", reason: "unexpected_redirect", status }; +} + +/** + * Ends a Keycloak session from the server: OIDC RP-initiated logout posted to + * the realm's end-session endpoint with the session's ID token as + * `id_token_hint`, and no browser cookies. Keycloak 26.7 then ends exactly the + * session the hint names (its `sid`), delivers back-channel logout to that + * session's clients, and redirects to `post_logout_redirect_uri` with the + * `state` it was given. It checks the hint's signature and type, not its + * `exp`, so the hint of a session idle past its ID token's lifetime still + * works. Redirects are never followed and the answer's body is never read. + * Never throws. + */ +export async function endKeycloakSession({ + issuer, + clientId, + postLogoutRedirectUri, + idTokenHint, + deadlineMs = KEYCLOAK_LOGOUT_DEADLINE_MS, +}: KeycloakLogoutRequest): Promise { + const state = randomBytes(16).toString("base64url"); + const expected = new URL(postLogoutRedirectUri); + const controller = new AbortController(); + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve({ outcome: "unconfirmed", reason: "timeout" }); + }, deadlineMs); + }); + const attempt = (async (): Promise => { + const response = await fetch(`${issuer}/protocol/openid-connect/logout`, { + method: "POST", + body: new URLSearchParams({ + id_token_hint: idTokenHint, + client_id: clientId, + post_logout_redirect_uri: postLogoutRedirectUri, + state, + }), + redirect: "manual", + signal: controller.signal, + }); + await response.body?.cancel().catch(() => undefined); + return classify(response.status, response.headers.get("location"), expected, state); + })().catch((): KeycloakLogoutResult => ({ outcome: "unconfirmed", reason: "unreachable" })); + try { + return await Promise.race([attempt, deadline]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/core/logout/sign-out.ts b/src/core/logout/sign-out.ts new file mode 100644 index 0000000..325658e --- /dev/null +++ b/src/core/logout/sign-out.ts @@ -0,0 +1,164 @@ +import { APIError, createAuthMiddleware } from "better-auth/api"; +import { decryptOAuthToken } from "better-auth/oauth2"; + +import type { GrantStore } from "../grants/grant-store.js"; +import type { AuthLogger } from "../logger.js"; +import { isPlainObject } from "../plain-object.js"; +import { + endKeycloakSession, + type KeycloakLogout, + type KeycloakLogoutRequest, + type UnconfirmedReason, +} from "./keycloak-logout.js"; + +/** Better Auth's native sign-out route, as `ctx.path` reports it. */ +export const SIGN_OUT_ENDPOINT = "/sign-out"; + +/** 503: the cookies were cleared, but the session could not be shown to be deleted. */ +export const SIGN_OUT_INCOMPLETE = "sign_out_incomplete"; + +/** What the `before` hook learned about the session being signed out. */ +interface Capture { + /** The Better Auth session token from the signed session cookie. */ + token: string; + /** Whether the session row was found; undefined when the lookup failed. */ + found?: boolean; + sessionId?: string; + keycloakSid?: string; + /** The grant's latest ID token, decrypted; in memory for this request only. */ + idTokenHint?: string; +} + +/** + * Each sign-out request's capture, keyed by the request's auth context, + * which its before hook, handler and after hook share (`callback-start.ts` + * keys its anchors the same way). + */ +const captures = new WeakMap(); + +/** + * Before Better Auth's sign-out deletes the session (the grant cascades), + * reads it as the session guard does, with the non-deleting `findSession`, + * and keeps the grant's latest ID token (the refresher stores every + * refreshed one) for `finishSignOut`. A failure is logged and sign-out goes + * on: ending the session matters more than the hint. + */ +export const captureSignOut = (grants: GrantStore, logger: AuthLogger) => + createAuthMiddleware(async (ctx) => { + if (ctx.path !== SIGN_OUT_ENDPOINT) { + return; + } + const token = await ctx.getSignedCookie( + ctx.context.authCookies.sessionToken.name, + ctx.context.secret, + ); + if (!token) { + return; + } + const capture: Capture = { token }; + captures.set(ctx.context, capture); + try { + const found = await ctx.context.internalAdapter.findSession(token); + capture.found = Boolean(found); + if (!found) { + return; + } + capture.sessionId = found.session.id; + const grant = await grants.findBySessionId(found.session.id); + if (grant === undefined) { + return; + } + capture.keycloakSid = grant.keycloakSid; + capture.idTokenHint = await decryptOAuthToken(grant.idToken, ctx.context); + } catch { + logger.warn({ + message: + "Sign-out could not read the session grant; the Keycloak logout will be unconfirmed", + sessionId: capture.sessionId, + }); + } + }); + +export type SignOutOptions = Omit & { + /** Where the outcome, and a failed local deletion, are reported. */ + logger: AuthLogger; +}; + +/** + * After Better Auth's sign-out has deleted the session and cleared the + * cookies, which it reports as success even when its delete failed (its + * adapter swallows a failed lookup), this: + * + * 1. Establishes the local deletion with a statement of its own that throws + * on failure. If that fails, the answer is 503 `sign_out_incomplete`: the + * cookies are still cleared, but sign-out is not reported as successful. + * 2. Ends the Keycloak session from the server with the grant's ID token + * (`keycloak-logout.ts`), so no token and no Keycloak URL reaches the + * browser, and the user never sees a Keycloak page. + * 3. Answers `{ success: true, keycloakLogout }`, with no `url` and no + * `Location` (Better Auth's own provider logout is disabled). + * + * The local session is gone before Keycloak is called and no lock is held, + * so Keycloak's back-channel delivery to Interlock, which it makes before it + * answers, never waits on this request. + */ +export const finishSignOut = (grants: GrantStore, { logger, ...logout }: SignOutOptions) => + createAuthMiddleware(async (ctx) => { + if (ctx.path !== SIGN_OUT_ENDPOINT) { + return; + } + const capture = captures.get(ctx.context); + captures.delete(ctx.context); + const returned: unknown = ctx.context.returned; + // Better Auth refused the request (an APIError) or answered otherwise. + if (!isPlainObject(returned) || returned.success !== true) { + return; + } + if (capture === undefined) { + returned.keycloakLogout = "no_session" satisfies KeycloakLogout; + return; + } + const { sessionId, keycloakSid } = capture; + + let localFailed = false; + try { + await grants.endSessionByToken(capture.token); + } catch { + localFailed = true; + logger.error({ message: "Sign-out could not end the session in the database", sessionId }); + } + + let keycloakLogout: KeycloakLogout; + let unconfirmed: { reason: UnconfirmedReason; status?: number } | undefined; + if (capture.idTokenHint !== undefined) { + const result = await endKeycloakSession({ ...logout, idTokenHint: capture.idTokenHint }); + keycloakLogout = result.outcome; + if (result.outcome === "unconfirmed") { + unconfirmed = { reason: result.reason, status: result.status }; + } + } else if (capture.found === false) { + keycloakLogout = "no_session"; + } else { + keycloakLogout = "unconfirmed"; + unconfirmed = { reason: "no_hint" }; + } + if (keycloakLogout === "ended") { + logger.log({ message: "Sign-out ended the Keycloak session", sessionId, keycloakSid }); + } else if (unconfirmed !== undefined) { + logger.warn({ + message: "Sign-out could not confirm that the Keycloak session ended", + sessionId, + keycloakSid, + ...unconfirmed, + }); + } + + if (localFailed) { + throw new APIError("SERVICE_UNAVAILABLE", { + code: SIGN_OUT_INCOMPLETE, + message: "The session could not be ended", + keycloakLogout, + }); + } + returned.keycloakLogout = keycloakLogout; + }); diff --git a/src/core/plain-object.ts b/src/core/plain-object.ts new file mode 100644 index 0000000..5fad317 --- /dev/null +++ b/src/core/plain-object.ts @@ -0,0 +1,14 @@ +/** + * An object literal or a null-prototype object, from any realm: its prototype + * is null or a root object. A parsed request body can come from another + * realm's `JSON.parse` (the fetch implementation's, under Jest), so a + * comparison with this realm's `Object.prototype` would miss it. A `Date`, + * an `APIError` or a `Response` has a longer chain and is not one. + */ +export const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const proto: object | null = Object.getPrototypeOf(value) as object | null; + return proto === null || Object.getPrototypeOf(proto) === null; +}; diff --git a/src/index.ts b/src/index.ts index fba2eef..a745fe0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,181 @@ -// Root entry: Better Auth wiring and claim normalization. Empty until item 3.5 fills it. -// `export {}` is the only thing that makes an empty file a module; both go when the entry is filled. -// oxlint-disable-next-line unicorn/require-module-specifiers -export {}; +// Root entry: the framework-agnostic core. Better Auth options for the Keycloak provider with +// session-scoped grants, the grant store and its refresher, the exposure allow-list, the +// back-channel receiver and RP-initiated logout, and claim normalization. Free of React and +// Next.js, so a server or a test process can import it without either. + +export { + cookieSettings, + createKeycloakAuthOptions, + createPostgresStore, + KEYCLOAK_SCOPES, + receiverRouteOf, + type AuthDatabase, + type KeycloakAuthOptions, +} from "./core/auth.options.js"; +export { + assertCallbackPath, + resolveCallbackPath, + StartupError, + type CallbackPathOptions, + type CallbackPathSource, +} from "./core/callback-path.js"; +export { normalizeClaims, type NormalizedClaims } from "./core/claims.js"; +export { + COOKIE_NAME_POLICIES, + drainBound, + leaseBound, + REFRESH_DEFAULTS, + refreshBoundProblems, + waitBound, + type CookieNamePolicy, + type KeycloakAuthConfig, + type RefreshConfig, + type VersionedSecret, +} from "./core/config.js"; +export { + AUTH_BASE_PATH, + COMPLETE_BUDGET_MS, + COOKIE_PREFIX, + KEYCLOAK_CALLBACK_PATH, + KEYCLOAK_PROVIDER_ID, + LEASE_MARGIN_MS, + POOL_CONNECTION_TIMEOUT_MS, + STATEMENT_TIMEOUT_MS, +} from "./core/constants.js"; +export { + ACCOUNT_TOKENS_MESSAGE, + ACCOUNT_TOKENS_TRIGGER, + accountTokensProblems, + ensureAccountTokensInvariant, + type AccountTokensOptions, +} from "./core/database/account-tokens.js"; +export { InFlightWork } from "./core/database/in-flight-work.js"; +export { sessionGrant } from "./core/database/migrations/0001_session_grant.js"; +export { appMigrations, type AppMigrations } from "./core/database/migrations/index.js"; +export { + EXPOSED_ROUTES, + exposedRoutesFor, + exposeRoutes, + ID_TOKEN_FORBIDDEN, + ROUTE_NOT_EXPOSED, + scrubProviderTokens, + stripProviderTokens, +} from "./core/exposure.js"; +export { + CALLBACK_ENDPOINT, + callbackStartOf, + composeAfterHooks, + composeBeforeHooks, + recordCallbackStart, +} from "./core/grants/callback-start.js"; +export { + GRANT_ID_TOKEN_MISSING, + GRANT_NOT_CAPTURED, + GRANT_NOT_CREATED, + GRANT_REFRESH_EXPIRY_MISSING, + GRANT_SID_MISSING, + GRANT_UNSUPPORTED_LOGIN, + type GrantErrorCode, +} from "./core/grants/grant-errors.js"; +export { grantHooks, idTokenClaims, TOKEN_FIELDS } from "./core/grants/grant-hooks.js"; +export { + AmbiguousCommitError, + CompletionBudgetError, + deadlineFrom, + KyselyGrantStore, + type CompleteInput, + type CompleteResult, + type EndPredicate, + type Grant, + type GrantStore, + type KyselyGrantStoreOptions, + type LifetimeAnchor, + type NewGrant, + type RefreshedGrantTokens, + type StoredGrant, +} from "./core/grants/grant-store.js"; +export { + emitRefreshEvent, + type RefreshEventBase, + type RefreshEventFields, + type RefreshEventName, +} from "./core/grants/refresh-events.js"; +export { + DEPENDENCY_FAILURE, + dependencyFailureBody, + refreshResponse, + SESSION_INVALID, + type DependencyFailureBody, + type DependencyFailureReason, + type EndedReason, + type RefreshResponse, + type SessionInvalidBody, + type UnavailableReason, +} from "./core/grants/refresh-responses.js"; +export { + createGrantRefresher, + GrantRefresher, + type AccessTokenOutcome, + type AuthContext, + type GrantRefresherConfig, + type GrantRefresherOptions, + type RefreshOptions, + type RefreshOutcome, + type ResolveOptions, +} from "./core/grants/refresh.js"; +export { + isPlaceholderEmail, + keycloakAccountSubject, + mapKeycloakProfile, +} from "./core/keycloak-profile.js"; +export { + httpTransport, + refreshTokens, + tokenLifetimes, + type Lifetimes, + type RefreshedTokens, + type RefreshRequest, + type RefreshResult, + type TokenRequest, + type TokenTransport, + type TransportResult, +} from "./core/keycloak-token.js"; +export type { AuthLogger, LogRecord } from "./core/logger.js"; +export { + assertReceiverRoute, + BACKCHANNEL_LOGOUT_EVENT, + BACKCHANNEL_LOGOUT_ROUTE, + backchannelLogout, + createKeyResolver, + createLogoutTokenValidator, + KEY_CACHE_MAX_AGE_MS, + KEY_FETCH_BACKOFF_MS, + KEY_REFETCH_COOLDOWN_MS, + KeyResolutionFailure, + LOGOUT_TOKEN_INVALID, + LOGOUT_TOKEN_UNVERIFIABLE, + realmKeys, + UnknownKey, + type BackchannelLogoutOptions, + type KeyResolutionReason, + type LogoutClaims, + type LogoutTokenRejection, + type LogoutTokenResult, + type LogoutTokenValidatorOptions, +} from "./core/logout/backchannel.js"; +export { + endKeycloakSession, + KEYCLOAK_LOGOUT_DEADLINE_MS, + type KeycloakLogout, + type KeycloakLogoutRequest, + type KeycloakLogoutResult, + type UnconfirmedReason, +} from "./core/logout/keycloak-logout.js"; +export { + captureSignOut, + finishSignOut, + SIGN_OUT_ENDPOINT, + SIGN_OUT_INCOMPLETE, + type SignOutOptions, +} from "./core/logout/sign-out.js"; +export { isPlainObject } from "./core/plain-object.js"; diff --git a/vitest.config.ts b/vitest.config.ts index d239af9..6dfcb37 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,10 +2,31 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [react()], test: { - environment: "jsdom", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + projects: [ + { + test: { + // The root entry's core runs on the server: Node's environment, and a timeout that + // covers the refresher's lease waits and the transport's local TLS servers. + name: "core", + environment: "node", + include: ["src/core/**/*.test.ts"], + testTimeout: 30_000, + }, + }, + { + plugins: [react()], + test: { + name: "dom", + environment: "jsdom", + include: [ + "src/__tests__/**/*.test.ts", + "src/react/**/*.test.{ts,tsx}", + "src/next/**/*.test.{ts,tsx}", + ], + }, + }, + ], coverage: { provider: "v8", // `lcov` is what the SonarQube scanner reads; `text` keeps the numbers @@ -14,8 +35,18 @@ export default defineConfig({ // Default `include` is only files touched by a test, which would hide an // entirely untested module from the coverage figure Sonar records. include: ["src/**/*.{ts,tsx}"], - // Barrel files re-export and nothing else, so their coverage is noise. - exclude: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/index.ts"], + // The specs and their helpers under `__tests__` are test code. The entry + // barrels re-export and nothing else, so their coverage is noise; they + // are named one by one, because `src/core/database/migrations/index.ts` + // is code. + exclude: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/__tests__/**", + "src/index.ts", + "src/next/index.ts", + "src/react/index.ts", + ], }, }, });