Skip to content

Commit ed66726

Browse files
committed
fix: preserve reauthentication through consent
Add multi-account regression coverage for prompt=login consent, max_age, OP logout, cross-client account-selection policy, and pending TOTP promotion. Rebind trusted reauthentication fingerprints across authorize/consent continuation prompt transitions while keeping the session bound to the selected user and authenticated_at.
1 parent 07326d2 commit ed66726

7 files changed

Lines changed: 608 additions & 19 deletions

File tree

packages/server/src/routes/api/auth/accounts.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { testClient } from 'hono/testing';
22
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
33
import type { AppType } from '../../../entrypoints/app.ts';
4+
import type { TinyAuthRuntimeConfigInput } from '../../../lib/config/index.ts';
45
import type { ServiceContainer } from '../../../services/container.ts';
56
import {
67
assertJsonBody,
@@ -19,6 +20,10 @@ let app: AppType;
1920
let services: ServiceContainer;
2021
let cleanup: () => Promise<void>;
2122

23+
type TestClientConfig = NonNullable<
24+
TinyAuthRuntimeConfigInput['clients']
25+
>[number];
26+
2227
beforeAll(async () => {
2328
const server = await createTestApp({
2429
...MINIMAL_TEST_CONFIG,
@@ -172,6 +177,122 @@ describe('remembered account APIs', () => {
172177
}
173178
});
174179

180+
test('applies each requesting client policy without corrupting the shared remembered roster', async () => {
181+
const restrictedRedirectUri = 'http://localhost:8080/restricted-callback';
182+
const normalRedirectUri = 'http://localhost:8080/normal-callback';
183+
const restrictedClient = {
184+
...TEST_OAUTH_CLIENT_CONFIG,
185+
id: 'restricted-account-selection-client-config',
186+
name: 'Restricted Account Selection Client',
187+
client_id: 'restricted-account-selection-client',
188+
redirect_uris: [restrictedRedirectUri],
189+
account_selection: {
190+
mode: 'never',
191+
allow_add_account: false,
192+
},
193+
} satisfies TestClientConfig;
194+
const normalClient = {
195+
...TEST_OAUTH_CLIENT_CONFIG,
196+
id: 'normal-account-selection-client-config',
197+
name: 'Normal Account Selection Client',
198+
client_id: 'normal-account-selection-client',
199+
redirect_uris: [normalRedirectUri],
200+
} satisfies TestClientConfig;
201+
const scopedServer = await createTestApp({
202+
...MINIMAL_TEST_CONFIG,
203+
auth: {
204+
account_selection: {
205+
enabled: true,
206+
mode: 'smart',
207+
allow_add_account: true,
208+
},
209+
},
210+
clients: [restrictedClient, normalClient],
211+
users: [TEST_USER_CONFIG],
212+
});
213+
214+
try {
215+
const scopedClient = testClient(scopedServer.app);
216+
const secondUser = await createPasswordUser(
217+
undefined,
218+
scopedServer.services,
219+
);
220+
const firstLogin = await scopedClient.api.auth.login.$post({
221+
json: { email: TEST_USER.email, password: TEST_USER.password },
222+
});
223+
const firstCookie = extractCookie(firstLogin, 'session');
224+
const secondLogin = await scopedClient.api.auth.login.$post(
225+
{ json: { email: secondUser.email, password: secondUser.password } },
226+
{ headers: { Cookie: `session=${firstCookie}` } },
227+
);
228+
const sessionCookie = extractCookie(secondLogin, 'session');
229+
230+
const restrictedListRes = await scopedClient.api.auth.accounts.$get(
231+
{ query: { client_id: restrictedClient.client_id } },
232+
{ headers: { Cookie: `session=${sessionCookie}` } },
233+
);
234+
const restrictedList = await assertJsonBody(restrictedListRes);
235+
expect(restrictedList.allow_add_account).toBe(false);
236+
expect(restrictedList.accounts.map((account) => account.sub)).toEqual([
237+
TEST_USER_CONFIG.sub,
238+
secondUser.sub,
239+
]);
240+
241+
const restrictedAuthorizeRes = await scopedClient.oauth.authorize.$get(
242+
{
243+
query: {
244+
response_type: 'code',
245+
client_id: restrictedClient.client_id,
246+
redirect_uri: restrictedRedirectUri,
247+
scope: 'openid profile email',
248+
state: 'restricted-client-selection-state',
249+
code_challenge: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ',
250+
code_challenge_method: 'S256',
251+
prompt: 'select_account',
252+
},
253+
},
254+
{ headers: { Cookie: `session=${sessionCookie}` } },
255+
);
256+
expect(restrictedAuthorizeRes.status).toBe(302);
257+
expect(
258+
new URL(restrictedAuthorizeRes.headers.get('location') ?? '').pathname,
259+
).toBe('/login');
260+
261+
const normalListRes = await scopedClient.api.auth.accounts.$get(
262+
{ query: { client_id: normalClient.client_id } },
263+
{ headers: { Cookie: `session=${sessionCookie}` } },
264+
);
265+
const normalList = await assertJsonBody(normalListRes);
266+
expect(normalList.allow_add_account).toBe(true);
267+
expect(normalList.accounts.map((account) => account.sub)).toEqual([
268+
TEST_USER_CONFIG.sub,
269+
secondUser.sub,
270+
]);
271+
272+
const normalAuthorizeRes = await scopedClient.oauth.authorize.$get(
273+
{
274+
query: {
275+
response_type: 'code',
276+
client_id: normalClient.client_id,
277+
redirect_uri: normalRedirectUri,
278+
scope: 'openid profile email',
279+
state: 'normal-client-selection-state',
280+
code_challenge: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ',
281+
code_challenge_method: 'S256',
282+
prompt: 'select_account',
283+
},
284+
},
285+
{ headers: { Cookie: `session=${sessionCookie}` } },
286+
);
287+
expect(normalAuthorizeRes.status).toBe(302);
288+
expect(
289+
new URL(normalAuthorizeRes.headers.get('location') ?? '').pathname,
290+
).toBe('/account/select');
291+
} finally {
292+
await scopedServer.cleanup();
293+
}
294+
});
295+
175296
test('returns disabled affordances and no remembered roster when global account selection is disabled', async () => {
176297
const scopedServer = await createTestApp({
177298
...MINIMAL_TEST_CONFIG,

packages/server/src/routes/api/auth/stateful-flow.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { ServiceContainer } from '../../../services/container.ts';
1818
import {
1919
assertJsonBody,
2020
createTestApp,
21+
enableTotpForUser,
2122
expectError,
2223
extractCookie,
2324
generateUniqueEmail,
@@ -666,6 +667,99 @@ describe('Stateful auth flows', () => {
666667
}
667668
});
668669

670+
test('keeps the current account active while a different account waits for TOTP verification', async () => {
671+
const scopedServer = await createTestApp({
672+
...MINIMAL_TEST_CONFIG,
673+
auth: {
674+
password: {
675+
enabled: true,
676+
totp: {
677+
enabled: true,
678+
issuer: 'TinyAuthPendingTotpSwitchTest',
679+
},
680+
},
681+
account_selection: {
682+
enabled: true,
683+
mode: 'smart',
684+
},
685+
},
686+
});
687+
688+
try {
689+
const password = 'stateful-switch-totp-password-123';
690+
const userAEmail = generateUniqueEmail('stateful-switch-totp-a');
691+
const userBEmail = generateUniqueEmail('stateful-switch-totp-b');
692+
693+
const { userA, userB } = await withMikroContext(
694+
scopedServer.services,
695+
async () => {
696+
const passwordHash =
697+
await scopedServer.services.securityService.hashPassword(password);
698+
const userAEntity = scopedServer.services.mikro.user.create({
699+
email: userAEmail,
700+
password_hash: passwordHash,
701+
});
702+
userAEntity.email_verified = true;
703+
const userBEntity = scopedServer.services.mikro.user.create({
704+
email: userBEmail,
705+
password_hash: passwordHash,
706+
});
707+
userBEntity.email_verified = true;
708+
scopedServer.services.mikro.em.persist(userAEntity);
709+
scopedServer.services.mikro.em.persist(userBEntity);
710+
await scopedServer.services.mikro.em.flush();
711+
return { userA: userAEntity, userB: userBEntity };
712+
},
713+
);
714+
const totpSecret = await enableTotpForUser(
715+
scopedServer.services,
716+
userB.sub,
717+
);
718+
719+
const client = testClient(scopedServer.app);
720+
const loginARes = await client.api.auth.login.$post({
721+
json: { email: userAEmail, password },
722+
});
723+
expect(loginARes.status).toBe(200);
724+
const userACookie = extractCookie(loginARes, 'session');
725+
726+
const loginBRes = await client.api.auth.login.$post(
727+
{ json: { email: userBEmail, password } },
728+
{ headers: { Cookie: `session=${userACookie}` } },
729+
);
730+
const loginBBody = await assertJsonBody(loginBRes);
731+
expect(loginBBody.user.sub).toBe(userB.sub);
732+
expect(loginBBody.user.totp_registered).toBe(true);
733+
const pendingTotpCookie = extractCookie(loginBRes, 'session');
734+
735+
const pendingSessionRes = await client.api.user.session.$get(
736+
{},
737+
{ headers: { Cookie: `session=${pendingTotpCookie}` } },
738+
);
739+
const pendingSessionBody = await assertJsonBody(pendingSessionRes);
740+
expect(pendingSessionBody.user?.sub).toBe(userA.sub);
741+
742+
const verifyRes = await client.api.auth.totp.verify.$post(
743+
{
744+
json: {
745+
code: scopedServer.services.totpService.generateToken(totpSecret),
746+
},
747+
},
748+
{ headers: { Cookie: `session=${pendingTotpCookie}` } },
749+
);
750+
const verifyBody = await assertJsonBody(verifyRes);
751+
expect(verifyBody.user.sub).toBe(userB.sub);
752+
const verifiedCookie = extractCookie(verifyRes, 'session');
753+
const verifiedSession = await readEncryptedSessionCookie(verifiedCookie);
754+
expect(verifiedSession).toMatchObject({
755+
user: { sub: userB.sub },
756+
accounts: [{ sub: userA.sub }, { sub: userB.sub }],
757+
});
758+
} finally {
759+
await scopedServer.cleanup();
760+
}
761+
});
762+
669763
test('keeps active account when stale pending 2FA login user is missing', async () => {
670764
const scopedServer = await createTestApp({
671765
...MINIMAL_TEST_CONFIG,

packages/server/src/routes/api/consent/post.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function buildReauthenticationRequestFingerprint(params: {
5151
ui_locales?: string | undefined;
5252
id_token_hint?: string | undefined;
5353
acr_values?: string | undefined;
54+
account_selected?: '1' | undefined;
5455
}): string {
5556
return JSON.stringify(
5657
[
@@ -70,6 +71,7 @@ function buildReauthenticationRequestFingerprint(params: {
7071
['ui_locales', params.ui_locales],
7172
['id_token_hint', params.id_token_hint],
7273
['acr_values', params.acr_values],
74+
['account_selected', params.account_selected],
7375
].filter(([, value]) => value !== undefined),
7476
);
7577
}
@@ -233,6 +235,44 @@ describe('POST /api/consent', () => {
233235
expect(redirect.searchParams.get('state')).toBe('state-login-consent');
234236
});
235237

238+
test('should preserve reauthentication when consent submits after prompt=login was stripped', async () => {
239+
const originalBody: ConsentAllowBody = {
240+
client_id: TEST_OAUTH_CLIENT.clientId,
241+
redirect_uri: TEST_OAUTH_CLIENT.redirectUri,
242+
response_type: 'code',
243+
scope: 'openid profile email',
244+
state: 'state-stripped-login-consent',
245+
nonce: 'nonce-stripped-login-consent',
246+
code_challenge: TEST_PKCE.codeChallenge,
247+
code_challenge_method: TEST_PKCE.codeChallengeMethod,
248+
prompt: 'login consent',
249+
max_age: 0,
250+
login_hint: TEST_USER_CONFIG.email,
251+
account_selected: '1',
252+
reauthenticated: '1',
253+
decision: 'allow',
254+
};
255+
const sessionCookie =
256+
await createBoundReauthenticationSessionCookie(originalBody);
257+
const client = testClient(app);
258+
259+
const res = await client.api.consent.$post(
260+
{
261+
json: {
262+
...originalBody,
263+
prompt: 'consent',
264+
},
265+
},
266+
{ headers: { Cookie: `session=${sessionCookie}` } },
267+
);
268+
269+
const responseBody = await assertJsonBody(res, 200);
270+
const redirect = new URL(responseBody.redirect_url);
271+
expect(redirect.searchParams.get('reauthenticated')).toBe('1');
272+
expect(redirect.searchParams.get('account_selected')).toBe('1');
273+
expect(redirect.searchParams.has('prompt')).toBe(false);
274+
});
275+
236276
test('should not consume prompt=login based on forged reauthenticated body value', async () => {
237277
const authenticatedAt = Math.floor(Date.now() / 1000);
238278
const sessionCookie = await encrypt(

0 commit comments

Comments
 (0)