diff --git a/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts b/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts index 627f6774a4..310cb6728f 100644 --- a/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts +++ b/packages/backend/server/src/__tests__/auth/jwt-session.spec.ts @@ -1,7 +1,8 @@ import { PrismaClient } from '@prisma/client'; -import ava, { TestFn } from 'ava'; +import ava, { ExecutionContext, TestFn } from 'ava'; import jwt from 'jsonwebtoken'; +import { ConfigFactory } from '../../base'; import { CryptoHelper } from '../../base/helpers'; import { AuthModule, @@ -17,6 +18,7 @@ const test = ava as TestFn<{ auth: AuthService; jwtSession: JwtSessionService; crypto: CryptoHelper; + config: ConfigFactory; models: Models; db: PrismaClient; user: CurrentUser; @@ -32,12 +34,14 @@ test.before(async t => { t.context.auth = app.get(AuthService); t.context.jwtSession = app.get(JwtSessionService); t.context.crypto = app.get(CryptoHelper); + t.context.config = app.get(ConfigFactory); t.context.models = app.get(Models); t.context.db = app.get(PrismaClient); }); test.beforeEach(async t => { await t.context.app.initTestingDB(); + resetAuthSessionConfig(t.context.config); t.context.user = await t.context.auth.signUp('u1@affine.pro', '1'); const session = await t.context.auth.createUserSession(t.context.user.id); @@ -48,6 +52,20 @@ test.after.always(async t => { await t.context.app.close(); }); +const DEFAULT_SESSION_TTL = 60 * 60 * 24 * 15; +const DEFAULT_SESSION_TTR = 60 * 60 * 24 * 7; + +function resetAuthSessionConfig(config: ConfigFactory) { + config.override({ + auth: { + session: { + ttl: DEFAULT_SESSION_TTL, + ttr: DEFAULT_SESSION_TTR, + }, + }, + }); +} + function currentJwtKey(crypto: CryptoHelper) { return Buffer.concat([ Buffer.from('affine:user-session-jwt:v1:'), @@ -55,7 +73,28 @@ function currentJwtKey(crypto: CryptoHelper) { ]); } -test('should sign and verify a user session jwt', async t => { +function assertSignedTtl( + t: ExecutionContext, + signed: { token: string; expiresAt: Date }, + expectedTtl: number +) { + const ttlMs = signed.expiresAt.getTime() - Date.now(); + t.true(ttlMs > (expectedTtl - 5) * 1000); + t.true(ttlMs <= (expectedTtl + 1) * 1000); + + const payload = jwt.decode(signed.token); + t.truthy(payload); + t.true(typeof payload !== 'string'); + if (!payload || typeof payload === 'string') return; + t.is(typeof payload.iat, 'number'); + t.is(typeof payload.exp, 'number'); + if (typeof payload.iat !== 'number' || typeof payload.exp !== 'number') { + return; + } + t.is(payload.exp - payload.iat, expectedTtl); +} + +test.serial('should sign and verify a user session jwt', async t => { const signed = t.context.jwtSession.sign( t.context.user.id, t.context.sessionId @@ -68,7 +107,30 @@ test('should sign and verify a user session jwt', async t => { t.true(signed.expiresAt.getTime() > Date.now()); }); -test('should reject invalid jwt cases', async t => { +test.serial('should use application session ttl for jwt expiration', async t => { + const defaultSigned = t.context.jwtSession.sign( + t.context.user.id, + t.context.sessionId + ); + assertSignedTtl(t, defaultSigned, DEFAULT_SESSION_TTL); + + const ttl = 120; + t.context.config.override({ + auth: { + session: { + ttl, + }, + }, + }); + + const configuredSigned = t.context.jwtSession.sign( + t.context.user.id, + t.context.sessionId + ); + assertSignedTtl(t, configuredSigned, ttl); +}); + +test.serial('should reject invalid jwt cases', async t => { const cases: Array<{ name: string; token: string }> = [ { name: 'expired token', @@ -149,34 +211,37 @@ test('should reject invalid jwt cases', async t => { } }); -test('should reject jwt when its user session is missing or expired', async t => { - const signed = t.context.jwtSession.sign( - t.context.user.id, - t.context.sessionId - ); +test.serial( + 'should reject jwt when its user session is missing or expired', + async t => { + const signed = t.context.jwtSession.sign( + t.context.user.id, + t.context.sessionId + ); - await t.context.auth.signOut(t.context.sessionId, t.context.user.id); + await t.context.auth.signOut(t.context.sessionId, t.context.user.id); - await t.throwsAsync(() => t.context.jwtSession.verify(signed.token), { - message: 'You must sign in first to access this resource.', - }); + await t.throwsAsync(() => t.context.jwtSession.verify(signed.token), { + message: 'You must sign in first to access this resource.', + }); - const refreshed = await t.context.auth.createUserSession(t.context.user.id); - const expired = t.context.jwtSession.sign( - t.context.user.id, - refreshed.sessionId - ); - await t.context.db.userSession.updateMany({ - where: { - userId: t.context.user.id, - sessionId: refreshed.sessionId, - }, - data: { - expiresAt: new Date(Date.now() - 1000), - }, - }); + const refreshed = await t.context.auth.createUserSession(t.context.user.id); + const expired = t.context.jwtSession.sign( + t.context.user.id, + refreshed.sessionId + ); + await t.context.db.userSession.updateMany({ + where: { + userId: t.context.user.id, + sessionId: refreshed.sessionId, + }, + data: { + expiresAt: new Date(Date.now() - 1000), + }, + }); - await t.throwsAsync(() => t.context.jwtSession.verify(expired.token), { - message: 'You must sign in first to access this resource.', - }); -}); + await t.throwsAsync(() => t.context.jwtSession.verify(expired.token), { + message: 'You must sign in first to access this resource.', + }); + } +); diff --git a/packages/backend/server/src/core/auth/jwt-session.ts b/packages/backend/server/src/core/auth/jwt-session.ts index c8118e65c1..9659e29b55 100644 --- a/packages/backend/server/src/core/auth/jwt-session.ts +++ b/packages/backend/server/src/core/auth/jwt-session.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import jwt, { type JwtPayload } from 'jsonwebtoken'; -import { AuthenticationRequired, CryptoHelper } from '../../base'; +import { AuthenticationRequired, Config, CryptoHelper } from '../../base'; import { Models } from '../../models'; import { sessionUser } from './service'; import type { CurrentUser, Session } from './session'; @@ -9,7 +9,6 @@ import type { CurrentUser, Session } from './session'; const JWT_SESSION_TYPE = 'user_session'; const JWT_SESSION_ISSUER = 'affine'; const JWT_SESSION_AUDIENCE = 'affine-client'; -const JWT_SESSION_TTL = 15 * 60; export interface SignedJwtSession { token: string; @@ -37,7 +36,8 @@ function isUserSessionJwtPayload( export class JwtSessionService { constructor( private readonly crypto: CryptoHelper, - private readonly models: Models + private readonly models: Models, + private readonly config: Config ) {} private get currentKey() { @@ -48,14 +48,15 @@ export class JwtSessionService { } sign(userId: string, sessionId: string): SignedJwtSession { - const expiresAt = new Date(Date.now() + JWT_SESSION_TTL * 1000); + const ttl = this.config.auth.session.ttl; + const expiresAt = new Date(Date.now() + ttl * 1000); const token = jwt.sign( { sid: sessionId, typ: JWT_SESSION_TYPE }, this.currentKey, { algorithm: 'HS256', audience: JWT_SESSION_AUDIENCE, - expiresIn: JWT_SESSION_TTL, + expiresIn: ttl, issuer: JWT_SESSION_ISSUER, subject: userId, }