feat(auth): add generic OIDC login with mandatory groups

This commit is contained in:
2026-08-17 05:44:10 +02:00
parent 202822f3ba
commit 4fe51cbeb1
20 changed files with 1144 additions and 40 deletions
+19 -16
View File
@@ -69,6 +69,10 @@ const base = new Date("2030-01-02T03:04:05.000Z");
const revision = "a".repeat(64);
const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as const };
function oidcInput(nonce: string, codeVerifier: string) {
return { nonce, codeVerifier, returnTo: "/" as const, authConfigRevision: revision, issuer: "https://issuer.example.test" };
}
afterEach(() => {
fsHooks.afterRead = undefined;
fsHooks.afterWrite = undefined;
@@ -258,7 +262,7 @@ describe("file-backed auth session store", () => {
const store = validStore(storageRoot);
const revoked = await create(store);
const expired = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const oidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
await store.revoke(revoked.token);
await expect(store.resolve(revoked.token)).resolves.toBeUndefined();
@@ -272,20 +276,19 @@ describe("file-backed auth session store", () => {
test("creates bounded OIDC state records that expire and are single-use", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({
nonce: "n".repeat(43),
codeVerifier: "v".repeat(43),
returnTo: "/",
}, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const path = digestPath(storageRoot, "oidc", created.state);
expect(created.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(readFileSync(path, "utf8")).not.toContain(created.state);
await expect(store.consumeOidcState(created.state, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" });
.resolves.toMatchObject({
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
authConfigRevision: revision, issuer: "https://issuer.example.test",
});
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
const expired = await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base);
const expired = await store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base);
await expect(store.consumeOidcState(expired.state, new Date(base.getTime() + 10 * 60_000)))
.resolves.toBeUndefined();
});
@@ -293,7 +296,7 @@ describe("file-backed auth session store", () => {
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
linkSync(statePath, claimPath(storageRoot, created.state));
@@ -304,7 +307,7 @@ describe("file-backed auth session store", () => {
test("treats a competing OIDC claim installed between availability and state checks as unavailable", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
fsHooks.beforeLstat = (observed) => {
@@ -321,7 +324,7 @@ describe("file-backed auth session store", () => {
test("prunes an expired OIDC state abandoned after an atomic claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
linkSync(statePath, stateClaimPath);
@@ -334,7 +337,7 @@ describe("file-backed auth session store", () => {
test("retains an in-flight orphan claim but removes it after the bounded recovery window", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
linkSync(statePath, stateClaimPath);
@@ -349,7 +352,7 @@ describe("file-backed auth session store", () => {
test("allows exactly one separate Node isolate to consume an OIDC state", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
const [first, second] = await Promise.all([
isolatedOidcConsumer(storageRoot, created.state),
isolatedOidcConsumer(storageRoot, created.state),
@@ -587,12 +590,12 @@ describe("file-backed auth session store", () => {
await store.revoke(session.token);
await expect(store.resolve(session.token)).resolves.toBeUndefined();
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const oidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
await expect(store.consumeOidcState(oidc.state, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ nonce: "n".repeat(43) });
await expect(store.consumeOidcState(oidc.state)).resolves.toBeUndefined();
await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base);
await store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(calls).toEqual(expect.arrayContaining(["create", "read", "replace", "remove", "claim-consume", "list"]));
expect(records).toHaveLength(0);
@@ -651,7 +654,7 @@ describe("file-backed auth session store", () => {
findLocalUser: async () => validLocalUser,
}, { windowsStorageBridge: bridge });
await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(records).toHaveLength(0);