Skip to content

Commit 3988ec2

Browse files
committed
feat: provide unique session id for each generaed session jwt
AdminForth/1900/image
1 parent 736ea01 commit 3988ec2

6 files changed

Lines changed: 100 additions & 6 deletions

File tree

‎adminforth/auth.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,22 @@ class AdminForthAuth implements IAdminForthAuth {
8686
response.setHeader('Set-Cookie', `adminforth_${brandSlug}_jwt=; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
8787
}
8888

89-
setAuthCookie({ expireInDuration, response, username, pk}: {
89+
setAuthCookie({ expireInDuration, response, username, pk, sessionId = crypto.randomUUID()}: {
9090
expireInDuration?: string,
9191
response: any,
9292
username: string,
93-
pk: string | null
94-
}) {
93+
pk: string | null,
94+
sessionId?: string
95+
}): string {
9596
const expiresIn: string = expireInDuration || (process.env.ADMINFORTH_AUTH_EXPIRESIN || '24h');
9697
// might be h,m,d in string
9798
const expiresInSec = parseTimeToSeconds(expiresIn);
9899

99-
const token = this.issueJWT({ username, pk}, 'auth', expiresInSec);
100+
const token = this.issueJWT({ username, pk, sessionId }, 'auth', expiresInSec);
100101
const expiresCookieFormat = new Date(Date.now() + expiresInSec * 1000).toUTCString();
101102
const brandSlug = this.adminforth.config.customization.brandNameSlug;
102103
response.setHeader('Set-Cookie', `adminforth_${brandSlug}_jwt=${token}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${expiresCookieFormat}`);
104+
return sessionId;
103105
}
104106

105107
removeCustomCookie({response, name}) {

‎adminforth/documentation/docs/tutorial/03-Customization/12-security.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export const admin = new AdminForth({
340340
auth: {
341341
beforeLogout: [
342342
async ({ adminUser, adminforth, extra }) => {
343-
await revokeExternalSession(adminUser.pk);
343+
await revokeExternalSession(adminUser.sessionId);
344344
}
345345
]
346346
}

‎adminforth/types/Back.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,11 @@ export interface IAdminForthAuth {
563563

564564
getCustomCookie({cookies, name}: {cookies: {key: string, value: string}[], name: string}): string | null;
565565

566-
setAuthCookie({expireInDuration, response, username, pk,}: {expireInDuration?: string, response: any, username: string, pk: string}): void;
566+
/**
567+
* Issues auth jwt and sets it as auth cookie. Returns session id which is put into the token,
568+
* generated one if `sessionId` was not passed.
569+
*/
570+
setAuthCookie({expireInDuration, response, username, pk, sessionId}: {expireInDuration?: string, response: any, username: string, pk: string, sessionId?: string}): string;
567571

568572
removeAuthCookie(response: any): void;
569573

‎adminforth/types/Common.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ export interface AdminUser {
105105
*/
106106
dbUser: any,
107107

108+
/**
109+
* Unique id of login session, generated when auth cookie is issued and stored in auth JWT.
110+
* Same for all requests done with one login, changes on next login.
111+
* Use it to track or revoke individual sessions, e.g. in {@link AdminForthConfig.auth.beforeLogout} hook.
112+
*
113+
* Not defined for sessions which were issued before session ids were introduced, and for
114+
* external users ({@link AdminUser.isExternalUser}).
115+
*/
116+
sessionId?: string,
117+
108118
/**
109119
* Optional software actor executing an action on behalf of this user.
110120
* For example, `af-agent` or `codex@1.2.3 | Production Codex`.

‎tests/jest_tests/logout_hook.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const loginCookie = async (): Promise<string> => {
1515
return res.headers['set-cookie'][0].split(';')[0];
1616
};
1717

18+
const sessionIdOf = (cookie: string) =>
19+
JSON.parse(Buffer.from(cookie.split('.')[1], 'base64url').toString()).sessionId;
20+
1821
afterAll(async () => {
1922
await closeApplication();
2023
});
@@ -44,6 +47,19 @@ describe('auth.beforeLogout', () => {
4447
expect(calls[0].translated).toEqual('Invalid username or password');
4548
});
4649

50+
it('receives session id of session being closed, so it can be revoked', async () => {
51+
const seen: string[] = [];
52+
hooks.push(async ({ adminUser }) => { seen.push(adminUser.sessionId); });
53+
54+
const firstCookie = await loginCookie();
55+
const secondCookie = await loginCookie();
56+
await logout(firstCookie);
57+
await logout(secondCookie);
58+
59+
expect(seen).toEqual([sessionIdOf(firstCookie), sessionIdOf(secondCookie)]);
60+
expect(seen[0]).not.toEqual(seen[1]);
61+
});
62+
4763
it('is called before auth cookie is removed and does not block logout', async () => {
4864
const called: string[] = [];
4965
hooks.push(
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import request from 'supertest';
2+
import { admin, app, closeApplication } from './authTestApp';
3+
4+
type AuthorizeHook = (params: { adminUser: any }) => Promise<{ allowed?: boolean, error?: string }>;
5+
6+
const authorizeHooks = admin.config.auth.adminUserAuthorize as AuthorizeHook[];
7+
8+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9+
10+
const login = async (): Promise<string> => {
11+
const res = await request(app).post('/adminapi/v1/login').send({ username: 'adminforth', password: 'adminforth' });
12+
return res.headers['set-cookie'][0].split(';')[0];
13+
};
14+
15+
const tokenPayload = (cookie: string) =>
16+
JSON.parse(Buffer.from(cookie.split('.')[1], 'base64url').toString());
17+
18+
const checkAuth = (cookie: string) =>
19+
request(app).post('/adminapi/v1/check_auth').set('Cookie', cookie).send({});
20+
21+
afterAll(async () => {
22+
await closeApplication();
23+
});
24+
25+
afterEach(() => {
26+
authorizeHooks.length = 0;
27+
});
28+
29+
describe('auth session id', () => {
30+
it('puts unique session id into auth token on every login', async () => {
31+
const first = tokenPayload(await login());
32+
const second = tokenPayload(await login());
33+
34+
expect(first.sessionId).toMatch(UUID_RE);
35+
expect(second.sessionId).toMatch(UUID_RE);
36+
expect(first.sessionId).not.toEqual(second.sessionId);
37+
});
38+
39+
it('exposes same session id to every request done with one login', async () => {
40+
const seen: string[] = [];
41+
authorizeHooks.push(async ({ adminUser }) => { seen.push(adminUser.sessionId); return { allowed: true }; });
42+
43+
const cookie = await login();
44+
await checkAuth(cookie);
45+
await checkAuth(cookie);
46+
47+
expect(seen).toEqual([tokenPayload(cookie).sessionId, tokenPayload(cookie).sessionId]);
48+
});
49+
50+
it('keeps sessions of parallel logins apart', async () => {
51+
const seen: string[] = [];
52+
authorizeHooks.push(async ({ adminUser }) => { seen.push(adminUser.sessionId); return { allowed: true }; });
53+
54+
const firstCookie = await login();
55+
const secondCookie = await login();
56+
await checkAuth(firstCookie);
57+
await checkAuth(secondCookie);
58+
59+
expect(seen).toEqual([tokenPayload(firstCookie).sessionId, tokenPayload(secondCookie).sessionId]);
60+
expect(seen[0]).not.toEqual(seen[1]);
61+
});
62+
});

0 commit comments

Comments
 (0)