diff --git a/Dockerfile b/Dockerfile index 5e804de..9f7bed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,15 @@ +# Dev target: full install (incl. devDependencies) for `pnpm dev` hot-reload. +# Source is bind-mounted at runtime; only node_modules is baked in so the +# compose anonymous volume can seed a Linux-native install over the Windows host. +FROM public.ecr.aws/docker/library/node:24-alpine AS dev +WORKDIR /app +ENV NODE_ENV=development +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod=false +EXPOSE 3003 +CMD ["pnpm", "dev"] + FROM public.ecr.aws/docker/library/node:24-alpine AS builder WORKDIR /app diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b8b9f11..dbd1fd3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -5,6 +5,7 @@ services: build: context: .. dockerfile: Dockerfile + target: dev user: root env_file: - ./.env @@ -23,6 +24,9 @@ services: - "${OIDC_APP_PORT:-3003}:3003" volumes: - ../:/app + # Anonymous volume keeps the container's Linux-native node_modules from + # being shadowed by the host bind mount (host is Windows, container is alpine). + - /app/node_modules - ./oidc-clients.json:${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json}:ro command: sh -c "corepack enable && pnpm dev" depends_on: diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index 4a92369..939e75c 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -1551,9 +1551,14 @@ export function createInteractionRouter( throw error; } }); + const requestedScopes = + typeof loginDetails.params?.scope === "string" + ? new Set(loginDetails.params.scope.split(/\s+/).filter(Boolean)) + : new Set(); if ( - !principal.email || - (config.emailVerificationEnabled && !principal.emailVerified) + requestedScopes.has("email") && + (!principal.email || + (config.emailVerificationEnabled && !principal.emailVerified)) ) { await store.saveInteractionLogin(uid, { principal, diff --git a/test/oidc-op.test.ts b/test/oidc-op.test.ts index 6c3f5eb..c4bf3bb 100644 --- a/test/oidc-op.test.ts +++ b/test/oidc-op.test.ts @@ -576,7 +576,15 @@ async function authorizeThroughProfile( account: TEST_LOGIN_ACCOUNT, password: TEST_LOGIN_PASSWORD, }); - assert.equal(login.status, 302); + assert.ok(login.status === 302 || login.status === 303); + if (!scope.split(/\s+/).includes("email")) { + return { + response: login, + codeVerifier: verifier, + profileLocation: undefined, + interactionUid: extractInteractionUid(interactionLocation), + }; + } assert.match( login.headers["location"] as string, /\/interaction\/.+\/profile/, @@ -696,12 +704,13 @@ async function openLoginInteraction( agent: any, state = "login-state-1", headers?: Record, + scope = "openid profile email", ) { const authorize = await withHeaders(agent.get("/auth"), headers).query({ client_id: "demo-site", redirect_uri: TEST_REDIRECT_URI, response_type: "code", - scope: "openid profile", + scope, prompt: "consent", state, nonce: "nonce-login-1", @@ -1157,7 +1166,7 @@ test("seeded demo client is confidential web client", async () => { }); test("public client without explicit refresh confirmation does not receive refresh token", async () => { - const { app, state, emailSender } = await createTestApp(); + const { app, state } = await createTestApp(); await upsertPublicNoneClient(state, "public-unconfirmed", { allowRefreshTokenForPublicClient: false, }); @@ -1187,31 +1196,9 @@ test("public client without explicit refresh confirmation does not receive refre account: TEST_LOGIN_ACCOUNT, password: TEST_LOGIN_PASSWORD, }); - assert.equal(login.status, 302); - const profileLocation = login.headers["location"] as string; - const profilePage = await agent.get(profileLocation); - const sendCode = await agent - .post(profileLocation) - .type("form") - .send({ - csrf: extractCsrf(profilePage.text), - action: "send_code", - email: "demo@example.com", - }); - const sentCode = emailSender.latestCode( - extractInteractionUid(interactionLocation), - "demo@example.com", - ); - assert.equal(typeof sentCode, "string"); - const profile = await agent - .post(profileLocation) - .type("form") - .send({ - csrf: extractCsrf(sendCode.text), - action: "verify_code", - code: sentCode, - }); - const consentPageHtml = await followToConsentPage(agent, profile); + assert.ok(login.status === 302 || login.status === 303); + assert.doesNotMatch(login.headers["location"] as string, /\/profile/); + const consentPageHtml = await followToConsentPage(agent, login); const consent = await agent .post(normalizeActionPath(extractConsentAction(consentPageHtml))) .type("form") @@ -2004,6 +1991,42 @@ test("profile routes reject requests without the interaction session cookie", as await state.store.close(); }); +test("login without email scope skips profile completion", async () => { + const { app, state, emailSender } = await createTestApp(); + const agent = request.agent(app); + const { interactionLocation, loginPage } = await openLoginInteraction( + agent, + "login-without-email-scope", + undefined, + "openid profile", + ); + const login = await agent + .post(`${interactionLocation}/login`) + .type("form") + .send({ + csrf: extractCsrf(loginPage.text), + account: TEST_LOGIN_ACCOUNT, + password: TEST_LOGIN_PASSWORD, + }); + + assert.ok(login.status === 302 || login.status === 303); + assert.doesNotMatch(login.headers["location"] as string, /\/profile/); + const callback = await followToRedirectUriOrigin( + agent, + login, + TEST_REDIRECT_URI, + ); + const callbackUrl = new URL(callback); + assert.equal( + callbackUrl.searchParams.get("state"), + "login-without-email-scope", + ); + assert.equal(typeof callbackUrl.searchParams.get("code"), "string"); + assert.equal(emailSender.sentVerifications.length, 0); + + await state.store.close(); +}); + test("interactive login treats upstream outages as retryable 503 without consuming the failure budget", async () => { const { app, state } = await createTestApp({ // Isolate the failure-bucket behavior from the separate attempt limiter. diff --git a/web/src/app/providers/access-control-provider.ts b/web/src/app/providers/access-control-provider.ts index 3fd44ec..2a5e15d 100644 --- a/web/src/app/providers/access-control-provider.ts +++ b/web/src/app/providers/access-control-provider.ts @@ -1,5 +1,5 @@ import type { AccessControlProvider } from "@refinedev/core"; -import { request } from "../../api/client"; +import { request, setCsrfToken } from "../../api/client"; import type { AuthContext, Project, ProjectAction } from "../../api/types"; // Dynamic active project reference for global access control @@ -20,6 +20,7 @@ export const accessControlProvider: AccessControlProvider = { if (!currentUser) { try { const data = await request("/auth/context"); + setCsrfToken(data.csrfToken); if (data.authenticated) { currentUser = data.user; } else { diff --git a/web/src/app/providers/auth-provider.test.ts b/web/src/app/providers/auth-provider.test.ts new file mode 100644 index 0000000..570e648 --- /dev/null +++ b/web/src/app/providers/auth-provider.test.ts @@ -0,0 +1,40 @@ +import { expect, test, vi } from "vitest"; + +vi.mock("../../api/client", () => ({ + request: vi.fn(), + setCsrfToken: vi.fn(), +})); + +import { request, setCsrfToken } from "../../api/client"; +import { authProvider } from "./auth-provider"; + +test("refreshes the anonymous CSRF context before submitting a login", async () => { + const context = { authenticated: false as const, csrfToken: "fresh-csrf" }; + const authenticated = { + authenticated: true as const, + csrfToken: "session-csrf", + user: { + subjectId: "subj_test", + preferredUsername: "test", + displayName: "Test", + isAdmin: false, + }, + clientSecretPolicy: { defaultGraceSeconds: 3600, maxGraceSeconds: 7200 }, + }; + vi.mocked(request) + .mockResolvedValueOnce(context) + .mockResolvedValueOnce(authenticated); + + const result = await authProvider.login?.({ + account: "account", + password: "password", + }); + + expect(result).toMatchObject({ success: true, redirectTo: "/projects" }); + expect(request).toHaveBeenNthCalledWith(1, "/auth/context"); + expect(setCsrfToken).toHaveBeenNthCalledWith(1, "fresh-csrf"); + expect(request).toHaveBeenNthCalledWith(2, "/auth/login", { + method: "POST", + body: JSON.stringify({ account: "account", password: "password" }), + }); +}); diff --git a/web/src/app/providers/auth-provider.ts b/web/src/app/providers/auth-provider.ts index 503536c..c19c6f4 100644 --- a/web/src/app/providers/auth-provider.ts +++ b/web/src/app/providers/auth-provider.ts @@ -5,6 +5,11 @@ import type { AuthContext } from "../../api/types"; export const authProvider: AuthProvider = { login: async ({ account, password }) => { try { + // The login CSRF token is bound to the anonymous nonce cookie. Refresh it + // immediately before submitting so a stale page or an earlier provider + // call cannot submit without the matching header. + const context = await request("/auth/context"); + setCsrfToken(context.csrfToken); const data = await request("/auth/login", { method: "POST", body: JSON.stringify({ account, password }), diff --git a/web/vite.config.ts b/web/vite.config.ts index 2efee50..b5aa853 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; export default defineConfig({ root: resolve(import.meta.dirname), base: "/manage/", - publicDir: resolve(import.meta.dirname, "src/assets"), + publicDir: resolve(import.meta.dirname, "public"), build: { outDir: resolve(import.meta.dirname, "../dist/management"), emptyOutDir: true,