feat(auth): add generic OIDC login with mandatory groups
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { registerAuthRoutes } from "../src/auth/routes.js";
|
||||
import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.js";
|
||||
import type { AuthSessionStore } from "../src/auth/session-store.js";
|
||||
import type { OidcProtocol } from "../src/auth/oidc-client.js";
|
||||
|
||||
const revision = "a".repeat(64);
|
||||
const issuer = "https://issuer.example.test";
|
||||
const state = "s".repeat(43);
|
||||
const nonce = "n".repeat(43);
|
||||
const verifier = "v".repeat(43);
|
||||
const createdApps: Array<ReturnType<typeof Fastify>> = [];
|
||||
|
||||
function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig {
|
||||
return {
|
||||
revision,
|
||||
sourcePath: "/private/auth.yaml",
|
||||
value: {
|
||||
version: 1,
|
||||
mode: "oidc",
|
||||
publicUrl: "https://thothii.example.test",
|
||||
session: {
|
||||
regularTtlSeconds: 3600, regularIdleSeconds: 300,
|
||||
rememberTtlSeconds: 3600, rememberIdleSeconds: 300, oidcTtlSeconds: 3600,
|
||||
},
|
||||
oidc: {
|
||||
issuer, clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET",
|
||||
scopes: ["openid", "profile"], groupsClaim: "groups",
|
||||
},
|
||||
groupCatalog: { driver: "authentik", baseUrl: issuer, apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
||||
authorization: { groupRoles: { Users: ["user"], Admins: ["admin"] } },
|
||||
...overrides,
|
||||
},
|
||||
} as LoadedAuthConfig;
|
||||
}
|
||||
|
||||
function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
|
||||
return {
|
||||
version: 1, nonce, codeVerifier: verifier, returnTo: "/",
|
||||
authConfigRevision: revision, issuer,
|
||||
createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z",
|
||||
...extra,
|
||||
} as OidcStateRecord;
|
||||
}
|
||||
|
||||
function fixture(options: {
|
||||
loaded?: LoadedAuthConfig;
|
||||
identity?: Awaited<ReturnType<OidcProtocol["callback"]>>;
|
||||
callbackFailure?: boolean;
|
||||
stateReturnTo?: string;
|
||||
} = {}) {
|
||||
let loaded = options.loaded ?? config();
|
||||
let protocolAvailable = true;
|
||||
let storedState: OidcStateRecord | undefined;
|
||||
const stateInputs: Array<Record<string, unknown>> = [];
|
||||
const creates: Array<Record<string, unknown>> = [];
|
||||
const callbacks: URL[] = [];
|
||||
const protocol: OidcProtocol = {
|
||||
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
||||
expect(received).toBe(state);
|
||||
expect(receivedNonce).toHaveLength(43);
|
||||
expect(codeVerifier).toHaveLength(43);
|
||||
return new URL(`https://issuer.example.test/authorize?state=${received}`);
|
||||
},
|
||||
callback: async ({ currentUrl }) => {
|
||||
callbacks.push(currentUrl);
|
||||
if (options.callbackFailure) throw new Error("provider failure with access-token-must-not-leak");
|
||||
return options.identity ?? {
|
||||
issuer, subject: "user-123", displayName: "Ada", groups: ["Users", "Admins", "Unmapped"],
|
||||
tokenExpiresAt: new Date(Date.now() + 120_000),
|
||||
};
|
||||
},
|
||||
diagnose: async () => undefined,
|
||||
};
|
||||
const store = {
|
||||
createOidcState: async (input: Record<string, unknown>) => {
|
||||
stateInputs.push(input);
|
||||
const record = stateRecord({
|
||||
nonce: input.nonce as string,
|
||||
codeVerifier: input.codeVerifier as string,
|
||||
authConfigRevision: input.authConfigRevision as string,
|
||||
issuer: input.issuer as string,
|
||||
});
|
||||
if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo;
|
||||
storedState = record;
|
||||
return { state, record: storedState };
|
||||
},
|
||||
consumeOidcState: async (received: string) => {
|
||||
if (received !== state) return undefined;
|
||||
const consumed = storedState;
|
||||
storedState = undefined;
|
||||
return consumed;
|
||||
},
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
creates.push(input);
|
||||
return { token: "opaque-session-token", csrfToken: "c".repeat(43), record: {} };
|
||||
},
|
||||
} as unknown as AuthSessionStore;
|
||||
const app = Fastify();
|
||||
app.decorateRequest("authConfigSnapshot", undefined);
|
||||
app.decorateRequest("authConfigSnapshotCaptured", false);
|
||||
app.decorateRequest("authConfigSnapshotUnavailable", false);
|
||||
app.register(cookie);
|
||||
registerAuthRoutes(app, {
|
||||
authMode: "oidc",
|
||||
authentication: { current: () => loaded },
|
||||
sessionStore: store,
|
||||
resolveOidcProtocol: () => protocolAvailable ? protocol : undefined,
|
||||
});
|
||||
createdApps.push(app);
|
||||
return {
|
||||
app, creates, callbacks, stateInputs,
|
||||
setConfig(next: LoadedAuthConfig) { loaded = next; },
|
||||
setProtocolAvailable(available: boolean) { protocolAvailable = available; },
|
||||
stateWasConsumed: () => storedState === undefined,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(createdApps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => {
|
||||
const subject = fixture();
|
||||
const start = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
expect(start.statusCode).toBe(302);
|
||||
expect(new URL(start.headers.location ?? "").searchParams.get("state")).toBe(state);
|
||||
expect(subject.stateInputs[0]).toMatchObject({ returnTo: "/", authConfigRevision: revision, issuer });
|
||||
|
||||
const callback = await subject.app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/oidc/callback?code=good&state=${state}`,
|
||||
headers: { host: "attacker.example.test" },
|
||||
});
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(callback.headers.location).toBe("/");
|
||||
expect(callback.headers["set-cookie"]).toContain("HttpOnly");
|
||||
expect(callback.headers["set-cookie"]).toContain("SameSite=Lax");
|
||||
expect(callback.headers["set-cookie"]).toContain("Secure");
|
||||
expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`);
|
||||
expect(subject.creates).toHaveLength(1);
|
||||
expect(subject.creates[0]).toMatchObject({
|
||||
method: "oidc", remembered: false, authConfigRevision: revision,
|
||||
principal: { issuer, subject: "user-123", roles: ["user", "admin"] },
|
||||
idleTtlMs: 300_000,
|
||||
});
|
||||
const absoluteTtlMs = subject.creates[0]?.absoluteTtlMs;
|
||||
expect(typeof absoluteTtlMs).toBe("number");
|
||||
expect(absoluteTtlMs as number).toBeGreaterThan(0);
|
||||
expect(absoluteTtlMs as number).toBeLessThanOrEqual(120_000);
|
||||
expect(JSON.stringify(subject.creates)).not.toContain("access-token-must-not-leak");
|
||||
expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak");
|
||||
});
|
||||
|
||||
test("consumes state on callback failure and refuses replay", async () => {
|
||||
const subject = fixture({ callbackFailure: true });
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(failed.statusCode).toBe(401);
|
||||
const replay = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("consumes state when the protocol becomes unavailable before callback", async () => {
|
||||
const subject = fixture();
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
subject.setProtocolAvailable(false);
|
||||
const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a consumed state with a non-root return target", async () => {
|
||||
const subject = fixture({ stateReturnTo: "https://attacker.example.test" });
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect(subject.creates).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects an OIDC state when its configuration revision changes before callback", async () => {
|
||||
const subject = fixture();
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
subject.setConfig({ ...config(), revision: "b".repeat(64) });
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects an OIDC state when its issuer changes before callback", async () => {
|
||||
const subject = fixture();
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const previous = config();
|
||||
subject.setConfig({
|
||||
...previous,
|
||||
value: { ...previous.value, oidc: { ...previous.value.oidc, issuer: "https://other.example.test" } },
|
||||
} as LoadedAuthConfig);
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
});
|
||||
|
||||
test("creates an authenticated but forbidden principal for extra unmapped groups", async () => {
|
||||
const subject = fixture({ identity: {
|
||||
issuer, subject: "user-123", groups: ["Unmapped"], tokenExpiresAt: new Date(Date.now() + 60_000),
|
||||
} });
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(subject.creates[0]).toMatchObject({ principal: { roles: [], permissions: [], isAdmin: false } });
|
||||
});
|
||||
Reference in New Issue
Block a user