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:
Whitewater
2026-07-11 06:40:58 +08:00
committed by GitHub
parent a868f54eeb
commit b22bc17bd0
2 changed files with 101 additions and 35 deletions
@@ -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,
}