mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-14 08:36:22 +08:00
fix(server): align native JWT session TTL with auth session TTL (#15210)
## Summary This is a backend-only workaround for native/mobile sessions expiring much earlier than the normal AFFiNE auth session. Native JWT-backed sessions now use the existing `auth.session.ttl` instead of the hard-coded 15-minute TTL. No database migration, no new config, and no mobile client changes are required. ## Background / Investigation PR #15060 introduced native session exchange and JWT-backed native sessions for mobile/desktop clients. Relevant code introduced there: - `packages/backend/server/src/core/auth/session-issuer.ts` - Native clients no longer receive normal auth cookies. - `SessionIssuer` clears cookies for native clients and returns a native session exchange code instead. - `packages/backend/server/src/core/auth/native-exchange.ts` - Native clients exchange the one-time code for a JWT-backed session token. - `packages/backend/server/src/core/auth/jwt-session.ts` - The JWT session implementation was introduced with: - `const JWT_SESSION_TTL = 15 * 60` - `expiresIn: JWT_SESSION_TTL` - normal `jwt.verify(...)` expiration enforcement That means a native/mobile client that does not reconnect and exchange/sign in again within 15 minutes can lose backend auth even though the DB-backed AFFiNE user session is still valid for the normal app session TTL, currently 15 days by default. Web does not hit this path because web keeps using the normal cookie-backed session issued by `AuthService`, whose expiration is based on `auth.session.ttl`. ## Related Issues / PRs Checked - Source PR: #15060 - Introduced native session exchange, JWT-backed sessions, native token storage, and websocket JWT support. - This PR appears to be the source of the hard-coded 15-minute native JWT TTL behavior. I did not find a public issue that directly reports "native JWT expires after 15 minutes". ## What Changed - Removed the hard-coded 15-minute native JWT TTL. - Updated `JwtSessionService.sign()` to issue native JWTs with `auth.session.ttl`. - Kept `JwtSessionService.verify()` on normal JWT expiration verification, so existing JWTs that already expired under the previous 15-minute `exp` need to be refreshed by signing in again. - Kept the existing DB-backed session lookup: - sign-out still revokes the native JWT by deleting the backing session - expired/deleted DB sessions still reject the JWT ## Why This Is A Workaround A more complete long-term design would add a true refresh-token flow for native clients, likely with persisted refresh tokens, rotation/revocation semantics, and database changes. This PR intentionally avoids that larger design. It restores the expected mobile login lifetime for newly issued native JWTs by aligning native JWT lifetime with the existing app session lifetime, while preserving the current native exchange model, Keychain/Keystore storage, and revoke-by-session behavior. ## Compatibility - Backend only - No DB schema change - No mobile app change - No new config - Existing native JWTs that already expired under the old 15-minute `exp` will still require signing in again. - Newly issued native JWTs use `auth.session.ttl`. ## Verification Latest verification after removing the extra native JWT TTL config: - JWT session tests: 4 passed - `yarn typecheck`: passed - `lint-staged`: passed via `corepack yarn` - `lint:ox`: passed via `corepack yarn` Earlier broader checks on this branch also passed selected auth tests and the websocket JWT auth subset. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Session login tokens now follow the configured session lifetime, so expiration behavior can be adjusted through app settings. * **Bug Fixes** * Improved consistency between token expiration and session expiry checks. * Added coverage for cases where a session is missing, expired, or the session lifetime changes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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.',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user