Skip to content

Commit b231f07

Browse files
UE-DNDDemoJustLuGuo
andcommitted
🐛 修复联调认证与开发环境问题
Co-Authored-By: ShimaRin <shimarin3915@gmail.com>
1 parent 7030f37 commit b231f07

9 files changed

Lines changed: 125 additions & 33 deletions

File tree

‎Dockerfile‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
# Dev target: full install (incl. devDependencies) for `pnpm dev` hot-reload.
2+
# Source is bind-mounted at runtime; only node_modules is baked in so the
3+
# compose anonymous volume can seed a Linux-native install over the Windows host.
4+
FROM public.ecr.aws/docker/library/node:24-alpine AS dev
5+
WORKDIR /app
6+
ENV NODE_ENV=development
7+
RUN corepack enable
8+
COPY package.json pnpm-lock.yaml ./
9+
RUN pnpm install --frozen-lockfile --prod=false
10+
EXPOSE 3003
11+
CMD ["pnpm", "dev"]
12+
113
FROM public.ecr.aws/docker/library/node:24-alpine AS builder
214
WORKDIR /app
315

‎deploy/docker-compose.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ services:
55
build:
66
context: ..
77
dockerfile: Dockerfile
8+
target: dev
89
user: root
910
env_file:
1011
- ./.env
@@ -23,6 +24,9 @@ services:
2324
- "${OIDC_APP_PORT:-3003}:3003"
2425
volumes:
2526
- ../:/app
27+
# Anonymous volume keeps the container's Linux-native node_modules from
28+
# being shadowed by the host bind mount (host is Windows, container is alpine).
29+
- /app/node_modules
2630
- ./oidc-clients.json:${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json}:ro
2731
command: sh -c "corepack enable && pnpm dev"
2832
depends_on:

‎src/routes/interactions.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,9 +1551,14 @@ export function createInteractionRouter(
15511551
throw error;
15521552
}
15531553
});
1554+
const requestedScopes =
1555+
typeof loginDetails.params?.scope === "string"
1556+
? new Set(loginDetails.params.scope.split(/\s+/).filter(Boolean))
1557+
: new Set<string>();
15541558
if (
1555-
!principal.email ||
1556-
(config.emailVerificationEnabled && !principal.emailVerified)
1559+
requestedScopes.has("email") &&
1560+
(!principal.email ||
1561+
(config.emailVerificationEnabled && !principal.emailVerified))
15571562
) {
15581563
await store.saveInteractionLogin(uid, {
15591564
principal,

‎test/oidc-op.test.ts‎

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,15 @@ async function authorizeThroughProfile(
576576
account: TEST_LOGIN_ACCOUNT,
577577
password: TEST_LOGIN_PASSWORD,
578578
});
579-
assert.equal(login.status, 302);
579+
assert.ok(login.status === 302 || login.status === 303);
580+
if (!scope.split(/\s+/).includes("email")) {
581+
return {
582+
response: login,
583+
codeVerifier: verifier,
584+
profileLocation: undefined,
585+
interactionUid: extractInteractionUid(interactionLocation),
586+
};
587+
}
580588
assert.match(
581589
login.headers["location"] as string,
582590
/\/interaction\/.+\/profile/,
@@ -696,12 +704,13 @@ async function openLoginInteraction(
696704
agent: any,
697705
state = "login-state-1",
698706
headers?: Record<string, string>,
707+
scope = "openid profile email",
699708
) {
700709
const authorize = await withHeaders(agent.get("/auth"), headers).query({
701710
client_id: "demo-site",
702711
redirect_uri: TEST_REDIRECT_URI,
703712
response_type: "code",
704-
scope: "openid profile",
713+
scope,
705714
prompt: "consent",
706715
state,
707716
nonce: "nonce-login-1",
@@ -1157,7 +1166,7 @@ test("seeded demo client is confidential web client", async () => {
11571166
});
11581167

11591168
test("public client without explicit refresh confirmation does not receive refresh token", async () => {
1160-
const { app, state, emailSender } = await createTestApp();
1169+
const { app, state } = await createTestApp();
11611170
await upsertPublicNoneClient(state, "public-unconfirmed", {
11621171
allowRefreshTokenForPublicClient: false,
11631172
});
@@ -1187,31 +1196,9 @@ test("public client without explicit refresh confirmation does not receive refre
11871196
account: TEST_LOGIN_ACCOUNT,
11881197
password: TEST_LOGIN_PASSWORD,
11891198
});
1190-
assert.equal(login.status, 302);
1191-
const profileLocation = login.headers["location"] as string;
1192-
const profilePage = await agent.get(profileLocation);
1193-
const sendCode = await agent
1194-
.post(profileLocation)
1195-
.type("form")
1196-
.send({
1197-
csrf: extractCsrf(profilePage.text),
1198-
action: "send_code",
1199-
email: "demo@example.com",
1200-
});
1201-
const sentCode = emailSender.latestCode(
1202-
extractInteractionUid(interactionLocation),
1203-
"demo@example.com",
1204-
);
1205-
assert.equal(typeof sentCode, "string");
1206-
const profile = await agent
1207-
.post(profileLocation)
1208-
.type("form")
1209-
.send({
1210-
csrf: extractCsrf(sendCode.text),
1211-
action: "verify_code",
1212-
code: sentCode,
1213-
});
1214-
const consentPageHtml = await followToConsentPage(agent, profile);
1199+
assert.ok(login.status === 302 || login.status === 303);
1200+
assert.doesNotMatch(login.headers["location"] as string, /\/profile/);
1201+
const consentPageHtml = await followToConsentPage(agent, login);
12151202
const consent = await agent
12161203
.post(normalizeActionPath(extractConsentAction(consentPageHtml)))
12171204
.type("form")
@@ -2004,6 +1991,42 @@ test("profile routes reject requests without the interaction session cookie", as
20041991
await state.store.close();
20051992
});
20061993

1994+
test("login without email scope skips profile completion", async () => {
1995+
const { app, state, emailSender } = await createTestApp();
1996+
const agent = request.agent(app);
1997+
const { interactionLocation, loginPage } = await openLoginInteraction(
1998+
agent,
1999+
"login-without-email-scope",
2000+
undefined,
2001+
"openid profile",
2002+
);
2003+
const login = await agent
2004+
.post(`${interactionLocation}/login`)
2005+
.type("form")
2006+
.send({
2007+
csrf: extractCsrf(loginPage.text),
2008+
account: TEST_LOGIN_ACCOUNT,
2009+
password: TEST_LOGIN_PASSWORD,
2010+
});
2011+
2012+
assert.ok(login.status === 302 || login.status === 303);
2013+
assert.doesNotMatch(login.headers["location"] as string, /\/profile/);
2014+
const callback = await followToRedirectUriOrigin(
2015+
agent,
2016+
login,
2017+
TEST_REDIRECT_URI,
2018+
);
2019+
const callbackUrl = new URL(callback);
2020+
assert.equal(
2021+
callbackUrl.searchParams.get("state"),
2022+
"login-without-email-scope",
2023+
);
2024+
assert.equal(typeof callbackUrl.searchParams.get("code"), "string");
2025+
assert.equal(emailSender.sentVerifications.length, 0);
2026+
2027+
await state.store.close();
2028+
});
2029+
20072030
test("interactive login treats upstream outages as retryable 503 without consuming the failure budget", async () => {
20082031
const { app, state } = await createTestApp({
20092032
// Isolate the failure-bucket behavior from the separate attempt limiter.

‎web/src/app/providers/access-control-provider.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AccessControlProvider } from "@refinedev/core";
2-
import { request } from "../../api/client";
2+
import { request, setCsrfToken } from "../../api/client";
33
import type { AuthContext, Project, ProjectAction } from "../../api/types";
44

55
// Dynamic active project reference for global access control
@@ -20,6 +20,7 @@ export const accessControlProvider: AccessControlProvider = {
2020
if (!currentUser) {
2121
try {
2222
const data = await request<AuthContext>("/auth/context");
23+
setCsrfToken(data.csrfToken);
2324
if (data.authenticated) {
2425
currentUser = data.user;
2526
} else {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test, vi } from "vitest";
2+
3+
vi.mock("../../api/client", () => ({
4+
request: vi.fn(),
5+
setCsrfToken: vi.fn(),
6+
}));
7+
8+
import { request, setCsrfToken } from "../../api/client";
9+
import { authProvider } from "./auth-provider";
10+
11+
test("refreshes the anonymous CSRF context before submitting a login", async () => {
12+
const context = { authenticated: false as const, csrfToken: "fresh-csrf" };
13+
const authenticated = {
14+
authenticated: true as const,
15+
csrfToken: "session-csrf",
16+
user: {
17+
subjectId: "subj_test",
18+
preferredUsername: "test",
19+
displayName: "Test",
20+
isAdmin: false,
21+
},
22+
clientSecretPolicy: { defaultGraceSeconds: 3600, maxGraceSeconds: 7200 },
23+
};
24+
vi.mocked(request)
25+
.mockResolvedValueOnce(context)
26+
.mockResolvedValueOnce(authenticated);
27+
28+
const result = await authProvider.login?.({
29+
account: "account",
30+
password: "password",
31+
});
32+
33+
expect(result).toMatchObject({ success: true, redirectTo: "/projects" });
34+
expect(request).toHaveBeenNthCalledWith(1, "/auth/context");
35+
expect(setCsrfToken).toHaveBeenNthCalledWith(1, "fresh-csrf");
36+
expect(request).toHaveBeenNthCalledWith(2, "/auth/login", {
37+
method: "POST",
38+
body: JSON.stringify({ account: "account", password: "password" }),
39+
});
40+
});

‎web/src/app/providers/auth-provider.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { AuthContext } from "../../api/types";
55
export const authProvider: AuthProvider = {
66
login: async ({ account, password }) => {
77
try {
8+
// The login CSRF token is bound to the anonymous nonce cookie. Refresh it
9+
// immediately before submitting so a stale page or an earlier provider
10+
// call cannot submit without the matching header.
11+
const context = await request<AuthContext>("/auth/context");
12+
setCsrfToken(context.csrfToken);
813
const data = await request<AuthContext>("/auth/login", {
914
method: "POST",
1015
body: JSON.stringify({ account, password }),

‎web/vite.config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
44
export default defineConfig({
55
root: resolve(import.meta.dirname),
66
base: "/manage/",
7-
publicDir: resolve(import.meta.dirname, "src/assets"),
7+
publicDir: resolve(import.meta.dirname, "public"),
88
build: {
99
outDir: resolve(import.meta.dirname, "../dist/management"),
1010
emptyOutDir: true,

‎web/vitest.config.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export default defineConfig({
77
test: {
88
environment: "jsdom",
99
env: { NODE_ENV: "development" },
10-
testTimeout: 15_000,
10+
// The client-creation flow exercises several Ant Design modal transitions.
11+
// GitHub-hosted runners can exceed the default 15 seconds without a failure.
12+
testTimeout: 30_000,
1113
},
1214
});

0 commit comments

Comments
 (0)