Skip to content

Commit 4832a2d

Browse files
BoxBoxJasonZimengXiong
authored andcommitted
fix: rebase issues
Signed-off-by: BoxBoxJason <contact@boxboxjason.dev>
1 parent 9a2ea1d commit 4832a2d

2 files changed

Lines changed: 210 additions & 46 deletions

File tree

backend/src/auth/oidcRoutes.callbackAlgFallback.test.ts

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,22 @@ const discoverMock = vi.fn();
77
const callbackMock = vi.fn();
88
const clientConfigs: Record<string, unknown>[] = [];
99
const issuerMetadata = {
10+
issuer: "https://issuer.example",
1011
token_endpoint_auth_methods_supported: ["client_secret_basic"],
1112
id_token_signing_alg_values_supported: ["HS256", "RS256"],
1213
};
14+
const discoveredIssuer = {
15+
metadata: issuerMetadata,
16+
} as any;
1317

14-
vi.mock("openid-client", () => {
15-
const issuer = {
16-
issuer: "https://issuer.example",
17-
metadata: issuerMetadata,
18-
} as any;
18+
Object.defineProperty(discoveredIssuer, "issuer", {
19+
get: () => issuerMetadata.issuer,
20+
enumerable: true,
21+
});
1922

23+
vi.mock("openid-client", () => {
2024
class MockClient {
21-
issuer = issuer;
25+
issuer = discoveredIssuer;
2226

2327
constructor(config: Record<string, unknown>) {
2428
clientConfigs.push({ ...config });
@@ -37,8 +41,8 @@ vi.mock("openid-client", () => {
3741
}
3842
}
3943

40-
issuer.Client = MockClient;
41-
discoverMock.mockResolvedValue(issuer);
44+
discoveredIssuer.Client = MockClient;
45+
discoverMock.mockResolvedValue(discoveredIssuer);
4246

4347
return {
4448
Issuer: {
@@ -64,7 +68,7 @@ const base64UrlEncode = (value: Buffer | string): string => {
6468

6569
const signFlowPayload = (encodedPayload: string, secret: string): string =>
6670
base64UrlEncode(
67-
crypto.createHmac("sha256", secret).update(encodedPayload, "utf8").digest()
71+
crypto.createHmac("sha256", secret).update(encodedPayload, "utf8").digest(),
6872
);
6973

7074
const makeFlowCookie = (
@@ -75,7 +79,7 @@ const makeFlowCookie = (
7579
codeVerifier: string;
7680
returnTo: string;
7781
expiresAt: number;
78-
}> = {}
82+
}> = {},
7983
) => {
8084
const payload = {
8185
state: "state-fixed",
@@ -90,7 +94,9 @@ const makeFlowCookie = (
9094
return `${encodedPayload}.${signature}`;
9195
};
9296

93-
const createPrismaMock = () => {
97+
const createPrismaMock = (options?: {
98+
providerIdentity?: { id: string; issuer: string; subject: string } | null;
99+
}) => {
94100
const user = {
95101
id: "user-1",
96102
username: null,
@@ -101,9 +107,20 @@ const createPrismaMock = () => {
101107
isActive: true,
102108
};
103109

110+
const providerIdentity = options?.providerIdentity ?? null;
111+
104112
const tx = {
105113
authIdentity: {
106-
findUnique: vi.fn(async () => null),
114+
findUnique: vi.fn(async (args?: Record<string, unknown>) => {
115+
const where = (args?.where || {}) as Record<string, unknown>;
116+
if (where.issuer_subject) {
117+
return null;
118+
}
119+
if (where.provider_userId) {
120+
return providerIdentity;
121+
}
122+
return null;
123+
}),
107124
update: vi.fn(async () => ({})),
108125
create: vi.fn(async () => ({})),
109126
},
@@ -120,22 +137,27 @@ const createPrismaMock = () => {
120137

121138
return {
122139
$transaction: vi.fn(async (runner: (arg: typeof tx) => Promise<unknown>) =>
123-
runner(tx)
140+
runner(tx),
124141
),
125142
refreshToken: {
126143
create: vi.fn(async () => ({})),
127144
},
145+
__tx: tx,
128146
};
129147
};
130148

131-
const createApp = async (idTokenAlgOverride: string | null) => {
149+
const createApp = async (
150+
idTokenAlgOverride: string | null,
151+
issuerUrlOverride: string | null = null,
152+
prismaOverride?: Record<string, unknown>,
153+
) => {
132154
const { registerOidcRoutes } = await import("./oidcRoutes");
133155
const app = express();
134156
const router = express.Router();
135157
app.use(router);
136158
registerOidcRoutes({
137159
router,
138-
prisma: createPrismaMock() as any,
160+
prisma: (prismaOverride || createPrismaMock()) as any,
139161
ensureAuthEnabled: vi.fn(async () => true),
140162
ensureSystemConfig: vi.fn(async () => ({
141163
id: "default",
@@ -158,7 +180,7 @@ const createApp = async (idTokenAlgOverride: string | null) => {
158180
enabled: true,
159181
enforced: true,
160182
providerName: "Test OIDC",
161-
issuerUrl: "https://issuer.example",
183+
issuerUrl: issuerUrlOverride || "https://issuer.example",
162184
discoveryUrl: null,
163185
clientId: "client-id",
164186
clientSecret: "client-secret",
@@ -183,15 +205,38 @@ describe("OIDC callback alg mismatch fallback", () => {
183205
beforeEach(() => {
184206
vi.clearAllMocks();
185207
clientConfigs.length = 0;
208+
issuerMetadata.issuer = "https://issuer.example";
186209
issuerMetadata.id_token_signing_alg_values_supported = ["HS256", "RS256"];
187210
});
188211

212+
it("uses configured issuer when discovery issuer differs in split-horizon setups", async () => {
213+
issuerMetadata.issuer = "http://keycloak:8080/realms/excalidash";
214+
215+
const app = await createApp(null);
216+
const response = await request(app).get("/oidc/start");
217+
218+
expect(response.status).toBe(302);
219+
expect(discoveredIssuer.metadata.issuer).toBe("https://issuer.example");
220+
});
221+
222+
it("treats trailing slash issuer differences as equivalent", async () => {
223+
issuerMetadata.issuer = "https://issuer.example";
224+
225+
const app = await createApp(null, "https://issuer.example/");
226+
const response = await request(app).get("/oidc/start");
227+
228+
expect(response.status).toBe(302);
229+
expect(discoveredIssuer.metadata.issuer).toBe("https://issuer.example");
230+
});
231+
189232
it("retries once with observed HS alg when default expected alg mismatches", async () => {
190233
let callCount = 0;
191234
callbackMock.mockImplementation(async () => {
192235
callCount += 1;
193236
if (callCount === 1) {
194-
throw new Error("unexpected JWT alg received, expected RS256, got: HS256");
237+
throw new Error(
238+
"unexpected JWT alg received, expected RS256, got: HS256",
239+
);
195240
}
196241
return {
197242
claims: () => ({
@@ -205,9 +250,7 @@ describe("OIDC callback alg mismatch fallback", () => {
205250
const app = await createApp(null);
206251
const response = await request(app)
207252
.get("/oidc/callback?code=test-code&state=state-fixed")
208-
.set("Cookie", [
209-
`excalidash-oidc-flow=${makeFlowCookie("test-secret")}`,
210-
]);
253+
.set("Cookie", [`excalidash-oidc-flow=${makeFlowCookie("test-secret")}`]);
211254

212255
expect(response.status).toBe(302);
213256
expect(response.headers.location).toBe("/");
@@ -218,19 +261,45 @@ describe("OIDC callback alg mismatch fallback", () => {
218261

219262
it("does not retry when id token alg is explicitly configured", async () => {
220263
callbackMock.mockRejectedValue(
221-
new Error("unexpected JWT alg received, expected RS256, got: HS256")
264+
new Error("unexpected JWT alg received, expected RS256, got: HS256"),
222265
);
223266

224267
const app = await createApp("RS256");
225268
const response = await request(app)
226269
.get("/oidc/callback?code=test-code&state=state-fixed")
227-
.set("Cookie", [
228-
`excalidash-oidc-flow=${makeFlowCookie("test-secret")}`,
229-
]);
270+
.set("Cookie", [`excalidash-oidc-flow=${makeFlowCookie("test-secret")}`]);
230271

231272
expect(response.status).toBe(302);
232273
expect(response.headers.location).toContain("oidcError=callback_failed");
233274
expect(callbackMock).toHaveBeenCalledTimes(1);
234275
expect(clientConfigs).toHaveLength(1);
235276
});
277+
278+
it("relinks existing provider identity when same email logs in with a new subject", async () => {
279+
const prisma = createPrismaMock({
280+
providerIdentity: {
281+
id: "identity-1",
282+
issuer: "https://issuer.example",
283+
subject: "subject-old",
284+
},
285+
});
286+
287+
callbackMock.mockResolvedValue({
288+
claims: () => ({
289+
sub: "subject-new",
290+
email: "alice@example.com",
291+
email_verified: true,
292+
}),
293+
});
294+
295+
const app = await createApp(null, null, prisma as any);
296+
const response = await request(app)
297+
.get("/oidc/callback?code=test-code&state=state-fixed")
298+
.set("Cookie", [`excalidash-oidc-flow=${makeFlowCookie("test-secret")}`]);
299+
300+
expect(response.status).toBe(302);
301+
expect(response.headers.location).toBe("/");
302+
expect(prisma.__tx.authIdentity.update).toHaveBeenCalledTimes(1);
303+
expect(prisma.__tx.authIdentity.create).toHaveBeenCalledTimes(0);
304+
});
236305
});

0 commit comments

Comments
 (0)