feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { isExternalUserAllowed } from "../../auth/allowed-users.js";
describe("isExternalUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isExternalUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isExternalUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isExternalUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isExternalUserAllowed("alice,bob", "alice")).toBe(true);
expect(isExternalUserAllowed("Alice", "alice")).toBe(true);
expect(isExternalUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isExternalUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(
isExternalUserAllowed("@company.com", "sub-1", "bob@company.com"),
).toBe(true);
expect(
isExternalUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM"),
).toBe(true);
});
it("denies users not on the list", () => {
expect(isExternalUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(
isExternalUserAllowed("@company.com", "sub-1", "bob@other.com"),
).toBe(false);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isExternalUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isExternalUserAllowed("alice", "sub-123", "alice@x.com")).toBe(
false,
);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isExternalUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isExternalUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(
isExternalUserAllowed("*@company.com", "sub-1", "user@other.com"),
).toBe(false);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isExternalUserAllowed("admin*", "admin_user")).toBe(true);
expect(isExternalUserAllowed("admin*", "user_admin")).toBe(false);
});
});
+7 -3
View File
@@ -94,6 +94,12 @@ export function fakeFactory(state: AuthState) {
email: input.email ?? null,
});
},
countUsersForProvider: async (providerId: string) =>
new Set(
state.identities
.filter((row) => row.providerId === providerId)
.map((row) => row.userId),
).size,
listSecondFactors: async (userId: string) =>
state.factors.filter((row) => row.userId === userId),
hasSecondFactor: async (userId: string) =>
@@ -179,9 +185,6 @@ export function fakeFactory(state: AuthState) {
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async () => {},
}),
createCurrentSsoProviderRepository: () => ({
listEnabledPublic: async () => [],
}),
};
}
@@ -202,6 +205,7 @@ export function fakeAuthManager(state: AuthState) {
}),
authenticateUser: vi.fn(async () => true),
authenticateOIDCUser: vi.fn(async () => true),
revokeSessionsByOidc: vi.fn(async () => 1),
unlockWithSystemKey: vi.fn(async () => true),
registerOIDCUser: vi.fn(async () => {}),
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
@@ -1,425 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
providerConfig: null as unknown,
providerType: "oidc",
idTokenClaims: null as unknown,
ldap: {
users: {} as Record<
string,
{ dn: string; password: string; attrs: Record<string, string> }
>,
adminDns: [] as string[],
},
ssoProvider: { id: 4, type: "ldap", enabled: true },
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = [
...Object.keys(helpers.fakeFactory(helpers.createAuthState())),
];
const mocked = Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
return {
...mocked,
createCurrentSsoProviderRepository: () => ({
findById: async () => h.ssoProvider,
listEnabledPublic: async () => [],
}),
};
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, databaseLogger: log, sshLogger: log, logger: log };
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../database/routes/user-oidc-utils.js")
>();
return {
...actual,
loadProviderConfig: async () =>
h.providerConfig
? {
config: h.providerConfig,
providerType: h.providerType,
providerDbId: 3,
}
: null,
verifyOIDCToken: async () => h.idTokenClaims,
};
});
vi.mock("ldapjs", () => {
function createClient() {
let bound: string | null = null;
return {
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
if (dn === "cn=service") {
bound = dn;
return cb();
}
const user = Object.values(h.ldap.users).find((u) => u.dn === dn);
if (user && user.password === password) {
bound = dn;
return cb();
}
cb(new Error("invalid credentials"));
},
search: (
base: string,
options: { filter: string },
cb: (err: Error | null, res: unknown) => void,
) => {
const listeners: Record<string, (value?: unknown) => void> = {};
const res = {
on: (event: string, fn: (value?: unknown) => void) => {
listeners[event] = fn;
},
};
cb(null, res);
queueMicrotask(() => {
if (base === "ou=groups") {
for (const dn of h.ldap.adminDns) {
if (options.filter.includes(dn)) {
listeners.searchEntry?.({
dn: { toString: () => "cn=admins,ou=groups" },
attributes: [{ type: "cn", values: ["admins"] }],
});
}
}
} else {
const match = /uid=([^)]+)/.exec(options.filter);
const user = match ? h.ldap.users[match[1]] : undefined;
if (user) {
listeners.searchEntry?.({
dn: { toString: () => user.dn },
attributes: Object.entries(user.attrs).map(([type, value]) => ({
type,
values: [value],
})),
});
}
}
listeners.end?.();
});
void bound;
},
unbind: () => {},
};
}
return { default: { createClient }, createClient };
});
const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js");
const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js");
const { respondWithLogin, respondWithRedirectLogin, runLogin } =
await import("../../auth/login-pipeline.js");
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.providerType = "oidc";
h.providerConfig = {
client_id: "client",
client_secret: "secret",
issuer_url: "https://idp.example",
authorization_url: "https://idp.example/auth",
token_url: "https://idp.example/token",
identifier_path: "sub",
name_path: "name",
scopes: "openid",
admin_group: "termix-admins",
group_claim: "groups",
};
h.idTokenClaims = {
sub: "sub-1",
sid: "sid-1",
name: "Alice",
email: "alice@example.com",
nonce: "nonce-1",
groups: ["termix-admins"],
};
for (const [key, value] of Object.entries({
oidc_backend_callback_state1: "https://termix.example/users/oidc/callback",
oidc_frontend_origin_state1: "https://termix.example",
oidc_remember_me_state1: "false",
oidc_state_state1: "nonce-1",
oidc_provider_state1: "3",
})) {
h.state.settings.set(key, value);
}
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(
JSON.stringify({ id_token: "jwt", access_token: "at" }),
);
}
return new Response("{}", { status: 404 });
}),
);
h.ldap.users = {
bob: {
dn: "uid=bob,ou=people",
password: "hunter2",
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
},
};
h.ldap.adminDns = [];
});
describe("OIDC through the pipeline", () => {
it("turns the callback into an identity with the old identifier and claims", async () => {
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
kind: "external",
provider: "3",
subject: "sub-1",
legacyIdentifier: "sub-1",
name: "Alice",
isAdmin: true,
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
returnTo: "https://termix.example",
});
// The one-time state is consumed.
expect(h.state.settings.has("oidc_state_state1")).toBe(false);
});
it("signs in and redirects with a cookie, like the old callback", async () => {
const req = fakeRequest({ query: { code: "c", state: "state1" } });
const identity = await handleOidcCallback(req as never);
const res = fakeResponse();
await respondWithRedirectLogin(
req as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cleared).toContain("jwt");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true });
expect(h.state.audits).toContainEqual(
expect.objectContaining({ action: "login", userId: user.id }),
);
});
it("rejects a nonce mismatch without a session", async () => {
h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" };
await expect(
handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
),
).rejects.toMatchObject({ status: 401 });
});
it("namespaces GitHub users the way they were stored", async () => {
h.providerType = "github";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(JSON.stringify({ access_token: "at" }));
}
if (url === "https://api.github.com/user") {
return new Response(JSON.stringify({ id: 42, login: "octo" }));
}
return new Response("[]");
}),
);
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
provider: "3",
subject: "42",
legacyIdentifier: "github:3:42",
name: "octo",
});
});
});
describe("LDAP through the pipeline", () => {
const providerConfig = {
host: "ldap.example",
bindDN: "cn=service",
bindPassword: "svc",
userSearchBase: "ou=people",
userSearchFilter: "(uid={{username}})",
groupSearchBase: "ou=groups",
adminGroup: "admins",
};
it("binds, finds the user and signs in with the old identifier", async () => {
h.providerConfig = providerConfig;
h.ldap.adminDns = ["uid=bob,ou=people"];
const req = fakeRequest({
body: { providerId: 4, username: "bob", password: "hunter2" },
headers: { "x-electron-app": "true" },
});
const identity = await verifyLdapLogin({
body: req.body as never,
ip: "10.0.0.1",
});
expect(identity).toMatchObject({
provider: "4",
subject: "bob",
legacyIdentifier: "ldap:4:bob",
isAdmin: true,
name: "Bob Builder",
});
const res = fakeResponse();
await respondWithLogin(req as never, res as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
// Native clients now get the token for LDAP too.
expect(res.body).toMatchObject({
success: true,
token: expect.any(String),
});
});
it("rejects a wrong password", async () => {
h.providerConfig = providerConfig;
await expect(
verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "wrong" },
ip: "10.0.0.2",
}),
).rejects.toMatchObject({ status: 401 });
});
it("skips second factors for an LDAP user who enrolled when the external-login setting is off", async () => {
h.providerConfig = providerConfig;
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob Builder",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("session");
});
it("runs second factors for an LDAP user who enrolled when the external-login setting is on", async () => {
h.providerConfig = providerConfig;
h.state.settings.set("second_factor_after_external_login", "true");
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob Builder",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const { registerSecondFactor } = await import("../../auth/registry.js");
const dispose = registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
try {
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
});
+14 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import {
createAuthState,
@@ -464,6 +464,19 @@ describe("external identities", () => {
ssoProviderId: 3,
};
// The sso plugin's method, which marks itself external.
let disposeOidc: () => void = () => {};
beforeEach(() => {
disposeOidc = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
});
afterEach(() => disposeOidc());
it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
@@ -1,104 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SsoProviderRepository } from "../../../database/repositories/sso-provider-repository.js";
describe("SsoProviderRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<SsoProviderRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
return new SsoProviderRepository(context, onWrite);
}
it("creates, lists, finds, updates, and deletes providers", async () => {
const repo = await createRepository();
const disabled = await repo.create({
name: "Disabled",
type: "oidc",
enabled: false,
displayOrder: 1,
config: "{}",
});
const enabled = await repo.create({
name: "GitHub",
type: "github",
enabled: true,
displayOrder: 0,
config: '{"client_id":"id"}',
});
expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([
enabled.id,
]);
expect((await repo.listAll()).map((row) => row.id)).toEqual([
enabled.id,
disabled.id,
]);
expect((await repo.findById(enabled.id))?.name).toBe("GitHub");
expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id);
const updated = await repo.update(enabled.id, {
name: "GitHub SSO",
config: '{"client_id":"updated"}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated?.name).toBe("GitHub SSO");
expect(updated?.config).toContain("updated");
expect(await repo.delete(enabled.id)).toBe(true);
expect(await repo.findById(enabled.id)).toBeNull();
expect(await repo.delete(enabled.id)).toBe(false);
});
it("counts users associated with a provider", async () => {
const repo = await createRepository();
const provider = await repo.create({
name: "LDAP",
type: "ldap",
enabled: true,
displayOrder: 0,
config: "{}",
});
await adapter!.exec(`
INSERT INTO users (id, username, password_hash, sso_provider_id)
VALUES ('user-1', 'u1', 'hash', ${provider.id}),
('user-2', 'u2', 'hash', ${provider.id}),
('user-3', 'u3', 'hash', NULL);
`);
expect(await repo.countUsersByProviderId(provider.id)).toBe(2);
});
it("runs the write hook after provider writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const provider = await repo.create({
name: "OIDC",
type: "oidc",
enabled: true,
displayOrder: 0,
config: "{}",
});
await repo.update(provider.id, { enabled: false });
await repo.delete(provider.id);
await repo.delete(provider.id);
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,134 @@
/**
* The 2.8 login URLs identity providers and 2.8 clients still call. They
* forward to whatever replaced them and, with the method's plugin gone, sign
* nobody in.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { authLogger: log, databaseLogger: log, logger: log };
});
vi.mock("../../../auth/core-auth.js", () => ({
ensureCoreLoginProviders: () => {},
}));
vi.mock("../../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => false,
}));
const { registerAuthCompatRoutes } =
await import("../../../database/routes/auth-compat-routes.js");
const { registerLoginMethod, resetAuthRegistryForTests } =
await import("../../../auth/registry.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
resetAuthRegistryForTests();
const router = express.Router();
router.use(express.json());
registerAuthCompatRoutes(router);
const app = express();
app.use("/users", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function get(path: string) {
return fetch(`${base}${path}`, { redirect: "manual" });
}
function registerSso() {
return registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
describe: async () => [
{ id: "3", label: "Keycloak", enabled: true, type: "oidc" },
],
start: async (_request, instanceId) => ({
redirectUrl: `https://idp.example/auth?provider=${instanceId}`,
}),
});
}
describe("the old identity provider URLs", () => {
it("forwards the callback to the sso plugin, query intact", async () => {
const response = await get("/users/oidc/callback?code=abc&state=xyz");
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(
"/plugin-api/sso/callback?code=abc&state=xyz",
);
});
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
const response = await fetch(`${base}/users/oidc/callback`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
});
it("forwards back-channel logout to the sso plugin", async () => {
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/backchannel-logout$/,
);
});
it("forwards the public config to the sso plugin", async () => {
const response = await get("/users/oidc-config");
expect(response.status).toBe(307);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/config$/,
);
});
});
describe("the old client routes", () => {
it("starts an SSO login through the oidc method", async () => {
registerSso();
const response = await get("/users/oidc/authorize?providerId=3");
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
auth_url: "https://idp.example/auth?provider=3",
});
});
it("lists providers in the 2.8 shape", async () => {
registerSso();
const response = await get("/users/sso-providers");
expect(await response.json()).toEqual([
{ id: 3, name: "Keycloak", type: "oidc", displayOrder: 0 },
]);
});
it("answers 404 once the plugins are gone", async () => {
const dispose = registerSso();
dispose();
expect((await get("/users/oidc/authorize?providerId=3")).status).toBe(404);
expect(await (await get("/users/sso-providers")).json()).toEqual([]);
const ldap = await fetch(`${base}/users/ldap/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId: 4, username: "bob", password: "x" }),
});
expect(ldap.status).toBe(404);
});
});
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({ createAdminMiddleware: vi.fn() }),
},
}));
const { isValidOidcIssuer } =
await import("../../../database/routes/sso-provider-routes.js");
describe("isValidOidcIssuer", () => {
it("rejects userinfo endpoints used as issuer URLs", () => {
expect(
isValidOidcIssuer("https://auth.example/application/o/userinfo/"),
).toBe(false);
});
it("accepts an Authentik application issuer", () => {
expect(isValidOidcIssuer("https://auth.example/application/o/termix")).toBe(
true,
);
});
});
@@ -0,0 +1,210 @@
/**
* Linking an external (SSO or LDAP) account to a password account and back.
* The linked account keeps its sign-in identities, so it signs in through the
* same provider afterwards; unlinking drops them.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface User {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string;
oidcIdentifier?: string | null;
clientId?: string;
}
const h = vi.hoisted(() => ({
users: new Map<string, User>(),
identities: [] as Array<{
userId: string;
providerId: string;
subject: string;
}>,
deleted: [] as string[],
revoked: [] as string[],
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => h.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...h.users.values()].find((user) => user.username === username) ?? null,
update: async (id: string, changes: Partial<User>) => {
Object.assign(h.users.get(id)!, changes);
},
}),
createCurrentUserAuthRepository: () => ({
moveIdentities: async (from: string, to: string) => {
for (const row of h.identities) if (row.userId === from) row.userId = to;
},
unlinkIdentitiesForUser: async (userId: string) => {
h.identities = h.identities.filter((row) => row.userId !== userId);
},
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
h.deleted.push(userId);
h.users.delete(userId);
},
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: {} }));
vi.mock("../../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { authLogger: log };
});
const { registerUserOidcAccountRoutes } =
await import("../../../database/routes/user-oidc-account-routes.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
h.users = new Map([
[
"admin",
{
id: "admin",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "x",
},
],
[
"sso",
{
id: "sso",
username: "alice-sso",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:alice",
},
],
[
"local",
{
id: "local",
username: "alice",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
},
],
]);
h.identities = [{ userId: "sso", providerId: "ldap:4", subject: "alice" }];
h.deleted = [];
h.revoked = [];
const router = express.Router();
router.use(express.json());
registerUserOidcAccountRoutes(router, {
authenticateJWT: (req, _res, next) => {
(req as unknown as { userId: string }).userId =
req.header("x-user") ?? "";
next();
},
authManager: {
revokeAllUserSessions: async (userId: string) => {
h.revoked.push(userId);
},
logoutUser: () => {},
} as never,
});
const app = express();
app.use("/users", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function post(path: string, body: unknown, user = "admin") {
return fetch(`${base}/users${path}`, {
method: "POST",
headers: { "content-type": "application/json", "x-user": user },
body: JSON.stringify(body),
});
}
describe("linking an external account to a password account", () => {
it("moves the identities and the old identifier, then removes the external account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: true,
oidcIdentifier: "ldap:4:alice",
});
expect(h.identities).toEqual([
{ userId: "local", providerId: "ldap:4", subject: "alice" },
]);
expect(h.revoked).toEqual(["sso"]);
expect(h.deleted).toEqual(["sso"]);
});
it("is for admins only", async () => {
const response = await post(
"/link-oidc-to-password",
{ oidcUserId: "sso", targetUsername: "alice" },
"local",
);
expect(response.status).toBe(403);
expect(h.identities[0].userId).toBe("sso");
});
it("refuses a target that is not a password account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice-sso",
});
expect(response.status).toBe(400);
});
});
describe("unlinking", () => {
it("drops the identities so the provider no longer signs in as the user", async () => {
await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: false,
oidcIdentifier: null,
});
expect(h.identities).toEqual([]);
});
it("refuses to leave an account with no way in", async () => {
const response = await post("/unlink-oidc-from-password", {
userId: "sso",
});
expect(response.status).toBe(400);
expect(h.identities).toHaveLength(1);
});
});
@@ -1,609 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const {
isOIDCUserAllowed,
getOIDCConfigFromEnv,
extractOidcGroups,
extractOidcGroupsFromSources,
validateLogoutTokenClaims,
parseOidcRoleMap,
resolveOidcMappedRoles,
verifyOIDCToken,
describeFetchFailure,
isOIDCEnvOverrideEnabled,
} = await import("../../../database/routes/user-oidc-utils.js");
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
afterEach(() => {
vi.restoreAllMocks();
});
describe("describeFetchFailure", () => {
it("unwraps the undici cause, which carries the reason that matters", () => {
// Every transport failure surfaces as this same outer message.
const error = new TypeError("fetch failed", {
cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
code: "ENOTFOUND",
}),
});
expect(describeFetchFailure(error)).toBe(
"fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
);
});
it("falls back to the outer message when there is no cause", () => {
expect(describeFetchFailure(new Error("boom"))).toBe("boom");
});
it("handles a non-Error throw", () => {
expect(describeFetchFailure("nope")).toBe("nope");
});
});
describe("verifyOIDCToken JWKS diagnostics", () => {
const issuer = "https://login.microsoftonline.com/example/v2.0";
const token = "header.payload.signature";
it("reports every attempted URL and why it failed", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("not found", { status: 404 }),
);
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
expect(error.message).toContain(
`${issuer}/.well-known/openid-configuration: HTTP 404`,
);
expect(error.message).toContain(
`${issuer}/.well-known/jwks.json: HTTP 404`,
);
expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
});
it("reports a transport failure with its underlying cause", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(
new TypeError("fetch failed", {
cause: Object.assign(new Error("self-signed certificate"), {
code: "SELF_SIGNED_CERT_IN_CHAIN",
}),
}),
);
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toContain(
"self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
);
});
it("says so when discovery succeeds but advertises no jwks_uri", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ issuer }), { status: 200 }),
)
.mockResolvedValue(new Response("not found", { status: 404 }));
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toContain("no jwks_uri in the discovery document");
});
it("says so when a JWKS response carries no keys array", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
status: 200,
}),
)
.mockResolvedValue(
new Response(JSON.stringify({ error: "unauthorized" }), {
status: 200,
}),
);
const error = await verifyOIDCToken(token, issuer, "client").catch(
(e) => e as Error,
);
expect(error.message).toContain(
'https://idp.example/keys: response contains no "keys" array',
);
});
});
describe("verifyOIDCToken", () => {
it("accepts a discovery document URL as the configured issuer", async () => {
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
jwk.kid = "google-key";
const issuer = "https://accounts.google.com";
const clientId = "termix-client";
const token = await new SignJWT({ sub: "user-1" })
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
.setIssuer(issuer)
.setAudience(clientId)
.setExpirationTime("5m")
.sign(privateKey);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
);
const payload = await verifyOIDCToken(
token,
`${issuer}/.well-known/openid-configuration`,
clientId,
);
expect(payload.sub).toBe("user-1");
expect(fetchMock).toHaveBeenNthCalledWith(
1,
`${issuer}/.well-known/openid-configuration`,
{},
);
});
it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
jwk.kid = "entra-key";
const issuer = "https://login.microsoftonline.com/example/v2.0";
const clientId = "termix-client";
const token = await new SignJWT({ sub: "user-1" })
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
.setIssuer(issuer)
.setAudience(clientId)
.setExpirationTime("5m")
.sign(privateKey);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
);
const payload = await verifyOIDCToken(token, issuer, clientId);
expect(payload.sub).toBe("user-1");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("isOIDCUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
true,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
true,
);
});
it("denies users not on the list", () => {
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
false,
);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
false,
);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
});
});
describe("getOIDCConfigFromEnv", () => {
const REQUIRED = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
];
const OPTIONAL = [
"OIDC_USERINFO_URL",
"OIDC_IDENTIFIER_PATH",
"OIDC_NAME_PATH",
"OIDC_SCOPES",
"OIDC_ALLOWED_USERS",
"OIDC_ADMIN_GROUP",
"OIDC_ENV_OVERRIDE",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
it("returns null when any required variable is missing", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
// issuer/authorization/token urls intentionally missing
expect(getOIDCConfigFromEnv()).toBeNull();
});
it("builds a config with defaults when all required vars are present", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
const config = getOIDCConfigFromEnv();
expect(config).not.toBeNull();
expect(config?.client_id).toBe("id");
expect(config?.identifier_path).toBe("sub");
expect(config?.name_path).toBe("name");
expect(config?.scopes).toBe("openid email profile");
expect(config?.userinfo_url).toBe("");
});
it("honors overrides for optional vars", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
process.env.OIDC_IDENTIFIER_PATH = "email";
process.env.OIDC_SCOPES = "openid";
const config = getOIDCConfigFromEnv();
expect(config?.identifier_path).toBe("email");
expect(config?.scopes).toBe("openid");
});
it("only enables database recovery override when explicitly requested", () => {
expect(isOIDCEnvOverrideEnabled()).toBe(false);
process.env.OIDC_ENV_OVERRIDE = "true";
expect(isOIDCEnvOverrideEnabled()).toBe(true);
});
});
describe("extractOidcGroups", () => {
it("reads the standard groups claim as an array", () => {
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
"admin",
"user",
]);
});
it("splits a comma-separated string claim", () => {
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
"admin",
"user",
]);
});
it("falls back through groups, roles, then group", () => {
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
});
it("reads a custom claim path when provided", () => {
const userInfo = {
"zitadel:grants:groups:123": ["user", "admin"],
groups: ["ignored"],
};
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
"user",
"admin",
]);
});
it("uses object keys as group names (Zitadel roles object)", () => {
const userInfo = {
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
};
expect(
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
).toEqual(["admin", "user"]);
});
it("falls back to defaults when the custom claim is absent", () => {
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
"admin",
]);
});
it("returns an empty array when no groups are present", () => {
expect(extractOidcGroups({})).toEqual([]);
});
});
describe("extractOidcGroupsFromSources", () => {
it("preserves ID token groups when userinfo omits them", () => {
expect(
extractOidcGroupsFromSources([
{ groups: ["admins", "users"] },
{ sub: "user-1", name: "Example User" },
]),
).toEqual(["admins", "users"]);
});
it("combines and deduplicates groups from both verified sources", () => {
expect(
extractOidcGroupsFromSources([
{ roles: ["users", "operators"] },
{ roles: ["operators", "admins"] },
]),
).toEqual(["users", "operators", "admins"]);
});
it("supports a configured group claim across sources", () => {
expect(
extractOidcGroupsFromSources(
[{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
"custom_groups",
),
).toEqual(["admins", "users"]);
});
});
describe("validateLogoutTokenClaims", () => {
const validClaims = {
sub: "subject-1",
sid: "session-1",
iat: 1_783_641_600,
jti: "logout-1",
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
};
it("accepts a spec-compliant back-channel logout payload", () => {
expect(validateLogoutTokenClaims(validClaims)).toEqual({
sub: "subject-1",
sid: "session-1",
jti: "logout-1",
});
});
it("requires the logout event to contain an object", () => {
expect(() =>
validateLogoutTokenClaims({
...validClaims,
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
}),
).toThrow("missing back-channel logout event");
});
it("requires iat and jti claims", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
).toThrow("missing iat claim");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
).toThrow("missing jti claim");
});
it("rejects nonce and requires sub or sid", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
).toThrow("must not contain a nonce");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
).toThrow("must contain sub and/or sid");
});
});
describe("parseOidcRoleMap", () => {
it("returns an empty map for blank input", () => {
expect(parseOidcRoleMap(undefined).size).toBe(0);
expect(parseOidcRoleMap(null).size).toBe(0);
expect(parseOidcRoleMap(" ").size).toBe(0);
});
it("parses comma-separated group:role pairs", () => {
const map = parseOidcRoleMap(
"devops-interns:devops-intern,devops-seniors:devops-senior",
);
expect(map.get("devops-interns")).toBe("devops-intern");
expect(map.get("devops-seniors")).toBe("devops-senior");
expect(map.size).toBe(2);
});
it("parses newline-separated pairs and trims whitespace", () => {
const map = parseOidcRoleMap(" a : role-a \n b:role-b \n");
expect(map.get("a")).toBe("role-a");
expect(map.get("b")).toBe("role-b");
});
it("normalizes leading slashes and case in group names", () => {
const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
expect(map.get("devops-interns")).toBe("devops-intern");
});
it("skips malformed entries instead of throwing", () => {
const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
expect(map.size).toBe(1);
expect(map.get("ok")).toBe("r");
});
it("splits on the last colon so group names may contain colons", () => {
const map = parseOidcRoleMap("ns:team:role-x");
expect(map.get("ns:team")).toBe("role-x");
});
it("preserves role-name case verbatim", () => {
// Role names must match roles.name exactly, so they are not lowercased.
expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
});
});
describe("resolveOidcMappedRoles", () => {
const roleMap = parseOidcRoleMap(
"devops-interns:devops-intern,devops-seniors:devops-senior",
);
it("reports every mapped role as managed regardless of membership", () => {
const { managed } = resolveOidcMappedRoles([], roleMap);
expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
});
it("desires only the roles whose groups the user is in", () => {
const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
expect([...desired]).toEqual(["devops-intern"]);
});
it("matches full group paths emitted by Keycloak", () => {
const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
expect([...desired]).toEqual(["devops-senior"]);
});
it("ignores groups that are not mapped", () => {
const { desired } = resolveOidcMappedRoles(
["finance", "devops-interns"],
roleMap,
);
expect([...desired]).toEqual(["devops-intern"]);
});
it("supports a user in multiple mapped groups", () => {
const { desired } = resolveOidcMappedRoles(
["devops-interns", "devops-seniors"],
roleMap,
);
expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
});
it("desires nothing when the map is empty", () => {
const { desired, managed } = resolveOidcMappedRoles(
["devops-interns"],
new Map(),
);
expect(desired.size).toBe(0);
expect(managed.size).toBe(0);
});
});
// Imported as a namespace rather than destructured into the shared block at the
// top of the file, so this suite stays independent of what that block binds.
const oidcUtils = await import("../../../database/routes/user-oidc-utils.js");
describe("verifyOIDCToken token shape", () => {
const issuer = "https://idp.example.com/application/o/termix";
// The shape check runs before any network call, so no fetch stub is needed.
const fetchSpy = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchSpy.mockReset();
});
it("reports an encrypted (JWE) token as a format error", async () => {
const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");
await expect(
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
await expect(
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
).rejects.toThrow(/JWE \(encrypted\)/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("reports any other non-JWS segment count as a format error", async () => {
await expect(
oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
).rejects.toThrow(/expected 3 segments, got 2/);
await expect(
oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
).rejects.toThrow(/expected 3 segments, got 1/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("lets a three-segment token through to key resolution", async () => {
fetchSpy.mockResolvedValue({ ok: false });
// Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
await expect(
oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
expect(fetchSpy).toHaveBeenCalled();
});
});
@@ -110,8 +110,10 @@ describe("expandOidcUsername", () => {
ssoProviderId: 1,
}),
}),
createCurrentSsoProviderRepository: () => ({
findById: async () => ({ type: "ldap" }),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:1", subject: "jdoe" },
],
}),
}));
@@ -128,8 +130,10 @@ describe("expandOidcUsername", () => {
ssoProviderId: 1,
}),
}),
createCurrentSsoProviderRepository: () => ({
findById: async () => ({ type: "oidc" }),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "1", subject: "ldap:1:admin" },
],
}),
}));
@@ -148,8 +152,10 @@ describe("expandOidcUsername", () => {
ssoProviderId: 5,
}),
}),
createCurrentSsoProviderRepository: () => ({
findById: async () => ({ type: "ldap" }),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:5", subject: "admin" },
],
}),
}));
@@ -0,0 +1,450 @@
/**
* A plugin's login method through core: what its identity may carry, how its
* own callback route hands off to core, and that a disabled plugin leaves no
* way in, including through the 2.8 wrapper routes.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("../auth/auth-test-helpers.js").fakeAuthManager
>,
granted: new Set<string>(),
trustedProxy: false,
audits: [] as string[],
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => h.trustedProxy,
}));
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return {
authLogger: log,
databaseLogger: log,
sshLogger: log,
pluginLogger: log,
logger: log,
};
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
const { createPluginAuth } = await import("../../plugins/ctx-ssh-auth.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const {
listPublicLoginMethods,
listLegacySsoProviders,
startRedirectLogin,
verifyFormLogin,
} = await import("../../database/routes/auth-routes.js");
const { runLogin } = await import("../../auth/login-pipeline.js");
const { resetAuthRegistryForTests, getLoginMethod } =
await import("../../auth/registry.js");
const { resetCoreLoginProvidersForTests } =
await import("../../auth/core-auth.js");
const manifest = {
id: "corp",
capabilities: ["auth:provide"],
contributes: { auth: { loginMethods: ["corp-sso", "corp-dir"] } },
} as unknown as PluginManifest;
function setup() {
const bag = new DisposableBag();
const auth = createPluginAuth({
manifest,
bag,
audit: async (action) => {
h.audits.push(action);
},
});
auth.registerLoginMethod({
id: "corp-sso",
labelKey: "signIn",
kind: "redirect",
external: true,
describe: async () => [
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
],
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
callback: async () => ({
kind: "external",
provider: "3",
subject: "sub-1",
name: "Alice",
email: "alice@example.com",
isAdmin: true,
roles: { desired: ["ops"], managed: ["ops", "dev"] },
logoutClaims: { providerId: 3, sub: "sub-1", sid: "sid-1" },
legacy: { identifier: "sub-1", providerRowId: 3 },
returnTo: "https://termix.example",
}),
});
auth.registerLoginMethod({
id: "corp-dir",
labelKey: "signIn",
kind: "form",
external: true,
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
verify: async (request) => {
if (request.body.password !== "hunter2") {
await auth.loginRateLimit.recordFailure(
request.ip ?? "unknown",
"4:bob",
);
throw new (await import("@termix/plugin-sdk/backend")).LoginMethodError(
"Invalid username or password",
401,
);
}
return {
kind: "external",
provider: "ldap:4",
subject: "bob",
name: "Bob",
legacy: { identifier: "ldap:4:bob", providerRowId: 4 },
rateLimitKey: "4:bob",
};
},
});
return { auth, bag };
}
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.granted = new Set(["auth:provide"]);
h.trustedProxy = false;
h.audits = [];
resetAuthRegistryForTests();
resetCoreLoginProvidersForTests();
});
describe("a plugin's external identity", () => {
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
);
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "corp-sso",
rememberMe: false,
});
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
expect(h.state.identities).toContainEqual(
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
);
expect(h.state.roles).toContainEqual({ userId: user.id, roleName: "ops" });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
user.id,
expect.objectContaining({
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
}),
);
});
it("cannot set core-only fields", async () => {
const { toCoreIdentity } = await import("../../plugins/ctx-ssh-auth.js");
const mapped = toCoreIdentity("corp", {
kind: "external",
provider: "3",
subject: "x",
unlockError: "spoofed",
rateLimitUsername: "someone-else",
} as never);
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
expect(mapped.rateLimitUsername).toBeUndefined();
});
it("finds a 2.8 user by the old identifier and links the identity", async () => {
h.state.users.set("u-1", {
id: "u-1",
username: "Alice",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "sub-1",
});
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
);
await runLogin(fakeRequest() as never, identity, {
methodId: "corp-sso",
rememberMe: false,
});
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toContainEqual(
expect.objectContaining({ userId: "u-1", providerId: "3" }),
);
});
});
describe("ctx.auth.completeRedirectLogin", () => {
it("runs the method's callback and redirects back with a session", async () => {
const { auth } = setup();
const res = fakeResponse();
await auth.completeRedirectLogin(
"corp-sso",
fakeRequest({ query: { code: "c", state: "s" } }),
res,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("refuses a method the plugin did not declare", async () => {
const { auth } = setup();
await expect(
auth.completeRedirectLogin("oidc", fakeRequest(), fakeResponse()),
).rejects.toThrow(/not listed/);
});
it("refuses without auth:provide", async () => {
const { auth } = setup();
h.granted.clear();
await expect(
auth.completeRedirectLogin("corp-sso", fakeRequest(), fakeResponse()),
).rejects.toMatchObject({ name: "PluginCapabilityError" });
});
it("is refused while trusted proxy login is on", async () => {
const { auth } = setup();
h.trustedProxy = true;
const res = fakeResponse();
await auth.completeRedirectLogin("corp-sso", fakeRequest(), res);
expect(res.statusCode).toBe(409);
expect(res.cookies).toHaveLength(0);
});
});
describe("the other ctx.auth helpers", () => {
it("revokes sessions by logout claims and audits it", async () => {
const { auth } = setup();
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
ssoProviderId: 3,
sub: null,
sid: "sid-1",
});
expect(h.audits).toContain("auth_sessions_revoked");
expect(await auth.revokeSessions({ providerId: 3 })).toBe(0);
});
it("keeps rate limit keys apart per plugin and clears them on success", async () => {
const { auth } = setup();
for (let i = 0; i < 10; i++) {
await auth.loginRateLimit.recordFailure("10.9.9.9", "4:bob");
}
expect(
(await auth.loginRateLimit.isLocked("10.9.9.9", "4:bob")).locked,
).toBe(true);
// Core's own key for the same name is untouched.
expect(loginRateLimiter.isLocked("10.9.9.8", "4:bob").locked).toBe(false);
loginRateLimiter.resetAttempts("10.9.9.9", "plugin:corp:4:bob");
});
it("counts linked users for a provider", async () => {
const { auth } = setup();
h.state.identities.push({
id: 1,
userId: "u-1",
providerId: "ldap:4",
subject: "bob",
email: null,
});
expect(await auth.countLinkedUsers("ldap:4")).toBe(1);
expect(await auth.countLinkedUsers("3")).toBe(0);
});
});
describe("a disabled plugin", () => {
it("lists its instances while active, for the login screen and 2.8 clients", async () => {
setup();
const methods = await listPublicLoginMethods();
expect(methods.map((method) => method.id)).toEqual(
expect.arrayContaining(["corp-sso", "corp-dir"]),
);
expect(await listLegacySsoProviders()).toEqual([
{ id: 3, name: "Corp", type: "oidc", displayOrder: 0 },
{ id: 4, name: "Directory", type: "corp-dir", displayOrder: 1 },
]);
});
it("hides its buttons and leaves no route that signs anyone in", async () => {
const { bag } = setup();
await bag.disposeAll();
const methods = await listPublicLoginMethods();
expect(methods.map((method) => method.id)).toEqual(["password"]);
expect(await listLegacySsoProviders()).toEqual([]);
const start = fakeResponse();
await startRedirectLogin(
fakeRequest() as never,
start as never,
"corp-sso",
"3",
);
expect(start.statusCode).toBe(404);
const verify = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { username: "bob", password: "hunter2" } }) as never,
verify as never,
"corp-dir",
"4",
);
expect(verify.statusCode).toBe(404);
expect(verify.cookies).toHaveLength(0);
expect(h.state.users.size).toBe(0);
});
});
describe("form methods and the external second factor setting", () => {
function enrolledBob() {
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.factors.push({
userId: "u-bob",
pluginId: "fixture",
factorId: "pin",
});
}
it("skips second factors when the setting is off", async () => {
enrolledBob();
setup();
const res = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { password: "hunter2" } }) as never,
res as never,
"corp-dir",
"4",
);
expect(res.body).toMatchObject({ success: true });
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("asks for them when the setting is on", async () => {
enrolledBob();
h.state.settings.set("second_factor_after_external_login", "true");
setup();
const { registerSecondFactor } = await import("../../auth/registry.js");
registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
const res = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { password: "hunter2" } }) as never,
res as never,
"corp-dir",
"4",
);
expect(res.body).toMatchObject({ requires_second_factor: true });
});
it("refuses a wrong password without a session", async () => {
setup();
const res = fakeResponse();
await verifyFormLogin(
fakeRequest({ body: { password: "nope" } }) as never,
res as never,
"corp-dir",
"4",
);
expect(res.statusCode).toBe(401);
expect(res.cookies).toHaveLength(0);
});
});
@@ -47,7 +47,7 @@ beforeEach(() => {
describe("parseLegacyIdentifier", () => {
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
providerId: "4",
providerId: "ldap:4",
subject: "bob",
});
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
@@ -55,7 +55,7 @@ describe("parseLegacyIdentifier", () => {
subject: "42",
});
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
providerId: "4",
providerId: "ldap:4",
subject: "cn=a:b",
});
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
@@ -94,7 +94,7 @@ describe("runExternalIdentityMigration", () => {
subject,
})),
).toEqual([
{ userId: "ldap-user", providerId: "4", subject: "bob" },
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
{ userId: "gh-user", providerId: "7", subject: "42" },
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
{
@@ -0,0 +1,179 @@
/**
* The 2.8 to 2.9 LDAP split. LDAP rows in sso_providers (adopted or not yet)
* move into the ldap plugin's table with their ids, their identities carry
* the "ldap:" prefix, the SSO rows stay, and running it twice changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({ db: null as unknown }));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runLdapProviderMigration } =
await import("../../utils/crypto-migration/ldap-provider-migration.js");
let sqlite: Database.Database;
const LEGACY_DDL = `
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
const TARGET_DDL = `
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL,
"enabled" integer NOT NULL DEFAULT 1,
"display_order" integer NOT NULL DEFAULT 0,
"config" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
function setup(options: { adopted: boolean; target: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
${LEGACY_DDL}
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
);
insert.run(3, "Keycloak", "oidc", 1, '{"client_id":"termix"}');
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
insert.run(5, "Old LDAP", "ldap", 0, '{"host":"old.example"}');
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-oidc", "3", "sub-1");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-ldap", "ldap:4", "bob");
if (options.adopted) {
sqlite.exec(`ALTER TABLE sso_providers RENAME TO p_sso_providers;`);
}
if (options.target) sqlite.exec(TARGET_DDL);
h.db = drizzle(sqlite);
}
const all = (query: string) => sqlite.prepare(query).all();
describe("runLdapProviderMigration", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("does nothing until the ldap plugin's table exists", async () => {
setup({ adopted: false, target: false });
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM sso_providers")).toHaveLength(3);
});
it.each([
["the legacy table", false],
["the adopted table", true],
])("moves LDAP rows out of %s with their ids", async (_label, adopted) => {
setup({ adopted, target: true });
const source = adopted ? "p_sso_providers" : "sso_providers";
expect(await runLdapProviderMigration()).toEqual({
moved: 2,
renumbered: 0,
});
expect(
all("SELECT id, name, enabled, config FROM p_ldap_providers ORDER BY id"),
).toEqual([
{
id: 4,
name: "Corp LDAP",
enabled: 1,
config: '{"host":"ldap.example"}',
},
{ id: 5, name: "Old LDAP", enabled: 0, config: '{"host":"old.example"}' },
]);
expect(all(`SELECT id, type FROM ${source}`)).toEqual([
{ id: 3, type: "oidc" },
]);
expect(
all("SELECT user_id, provider_id FROM user_external_identities"),
).toEqual([
{ user_id: "u-oidc", provider_id: "3" },
{ user_id: "u-ldap", provider_id: "ldap:4" },
]);
});
it("renames bare identities left from before the prefix", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
"UPDATE user_external_identities SET provider_id = '4' WHERE user_id = 'u-ldap'",
);
await runLdapProviderMigration();
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-ldap'",
),
).toEqual([{ provider_id: "ldap:4" }]);
});
it("gives a row a new id when the plugin already used it", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
`INSERT INTO p_ldap_providers (id, name, config) VALUES (4, 'New', '{}')`,
);
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-bare", "4", "carol");
const result = await runLdapProviderMigration();
expect(result).toEqual({ moved: 2, renumbered: 1 });
const moved = all(
"SELECT id FROM p_ldap_providers WHERE name = 'Corp LDAP'",
) as Array<{ id: number }>;
expect(moved[0].id).not.toBe(4);
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-bare'",
),
).toEqual([{ provider_id: `ldap:${moved[0].id}` }]);
});
it("is idempotent", async () => {
setup({ adopted: true, target: true });
await runLdapProviderMigration();
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM p_ldap_providers")).toHaveLength(2);
});
});
@@ -1,23 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getDesktopOidcCallbackUrl,
isOidcTokenCallback,
} from "../../utils/oidc-desktop-callback.js";
describe("getDesktopOidcCallbackUrl", () => {
it("uses localhost so browsers do not upgrade the loopback callback", () => {
expect(getDesktopOidcCallbackUrl("17850")).toBe(
"http://localhost:17850/oidc-callback",
);
});
it.each(["", "0", "65536", "17850/path", ["17850"]])(
"rejects invalid callback port %j",
(port) => {
expect(getDesktopOidcCallbackUrl(port)).toBeNull();
},
);
});
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
describe("isOidcTokenCallback", () => {
it.each([
@@ -8,14 +8,8 @@ vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: { getInstance: () => ({ getEncryptionKey }) },
}));
const {
decryptSsoConfigSecrets,
decryptSystemSecret,
encryptSsoConfigSecrets,
encryptSystemSecret,
isSystemEncrypted,
SSO_SECRET_FIELDS,
} = await import("../../utils/system-secret-crypto.js");
const { decryptSystemSecret, encryptSystemSecret, isSystemEncrypted } =
await import("../../utils/system-secret-crypto.js");
beforeEach(() => {
getEncryptionKey.mockReset();
@@ -103,55 +97,3 @@ describe("legacy compatibility", () => {
await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret");
});
});
describe("SSO provider config", () => {
it("seals only the secret fields", async () => {
const sealed = await encryptSsoConfigSecrets({
client_id: "termix",
client_secret: "shhh",
bindPassword: "ldap-pw",
issuer_url: "https://idp.example",
});
expect(sealed.client_id).toBe("termix");
expect(sealed.issuer_url).toBe("https://idp.example");
expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true);
expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true);
});
it("round-trips a whole config", async () => {
const original = {
client_id: "termix",
client_secret: "shhh",
bindPassword: "ldap-pw",
};
const restored = await decryptSsoConfigSecrets(
await encryptSsoConfigSecrets(original),
);
expect(restored).toEqual(original);
});
it("covers both secret fields", () => {
expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]);
});
it("leaves a config without secrets untouched", async () => {
const config = { client_id: "termix", scopes: "openid" };
await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config);
await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config);
});
it("does not let one unreadable secret take down the provider", async () => {
const restored = await decryptSsoConfigSecrets({
client_id: "termix",
client_secret: "sysenc:v1:bad",
});
// The rest of the config survives; login fails later with a clearer error.
expect(restored.client_id).toBe("termix");
expect(restored.client_secret).toBe("sysenc:v1:bad");
});
});