Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 4 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ services:
build:
context: ..
dockerfile: Dockerfile
target: dev
user: root
env_file:
- ./.env
Expand All @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions src/routes/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
if (
!principal.email ||
(config.emailVerificationEnabled && !principal.emailVerified)
requestedScopes.has("email") &&
(!principal.email ||
(config.emailVerificationEnabled && !principal.emailVerified))
) {
await store.saveInteractionLogin(uid, {
principal,
Expand Down
79 changes: 51 additions & 28 deletions test/oidc-op.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down Expand Up @@ -696,12 +704,13 @@ async function openLoginInteraction(
agent: any,
state = "login-state-1",
headers?: Record<string, string>,
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",
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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: "[email protected]",
});
const sentCode = emailSender.latestCode(
extractInteractionUid(interactionLocation),
"[email protected]",
);
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")
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/providers/access-control-provider.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +20,7 @@ export const accessControlProvider: AccessControlProvider = {
if (!currentUser) {
try {
const data = await request<AuthContext>("/auth/context");
setCsrfToken(data.csrfToken);
if (data.authenticated) {
currentUser = data.user;
} else {
Expand Down
40 changes: 40 additions & 0 deletions web/src/app/providers/auth-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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" }),
});
});
5 changes: 5 additions & 0 deletions web/src/app/providers/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthContext>("/auth/context");
setCsrfToken(context.csrfToken);
const data = await request<AuthContext>("/auth/login", {
method: "POST",
body: JSON.stringify({ account, password }),
Expand Down
2 changes: 1 addition & 1 deletion web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading