feat: drop outdated session (#14373)

#### PR Dependency Tree


* **PR #14373** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added client version tracking and validation to ensure application
compatibility across authentication flows and sessions.
* Enhanced OAuth authentication with improved version handling during
sign-in and refresh operations.

* **Bug Fixes**
* Improved payment callback URL handling with safer defaults for
redirect links.

* **Tests**
* Expanded test coverage for client version enforcement and session
management.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-05 21:35:36 +08:00
committed by GitHub
parent 161eb302fd
commit 944fab36ac
29 changed files with 845 additions and 125 deletions
@@ -53,6 +53,34 @@ test('should be able to sign in with credential', async t => {
t.is(session?.id, u1.id);
});
test('should record sign in client version when header is provided', async t => {
const { app, db } = t.context;
const u1 = await app.createUser('u1@affine.pro');
await app
.POST('/api/auth/sign-in')
.set('x-affine-version', '0.25.1')
.send({ email: u1.email, password: u1.password })
.expect(200);
const userSession1 = await db.userSession.findFirst({
where: { userId: u1.id },
});
t.is(userSession1?.signInClientVersion, '0.25.1');
// should not overwrite existing value with null/undefined
await app
.POST('/api/auth/sign-in')
.send({ email: u1.email, password: u1.password })
.expect(200);
const userSession2 = await db.userSession.findFirst({
where: { userId: u1.id },
});
t.is(userSession2?.signInClientVersion, '0.25.1');
});
test('should be able to sign in with email', async t => {
const { app } = t.context;
@@ -1,13 +1,14 @@
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
import { Controller, Get, HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { ConfigFactory } from '../../base';
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { Models } from '../../models';
import { createTestingApp } from '../utils';
import { createTestingApp, TestingApp } from '../utils';
@Controller('/')
class TestController {
@@ -29,31 +30,46 @@ class TestController {
}
const test = ava as TestFn<{
app: INestApplication;
app: TestingApp;
server: any;
auth: AuthService;
models: Models;
db: PrismaClient;
config: ConfigFactory;
u1: CurrentUser;
sessionId: string;
}>;
let server!: any;
let auth!: AuthService;
let u1!: CurrentUser;
let sessionId = '';
test.before(async t => {
const app = await createTestingApp({
imports: [AuthModule],
controllers: [TestController],
});
auth = app.get(AuthService);
u1 = await auth.signUp('u1@affine.pro', '1');
const models = app.get(Models);
const session = await models.session.createSession();
sessionId = session.id;
await auth.createUserSession(u1.id, sessionId);
server = app.getHttpServer();
t.context.app = app;
t.context.server = app.getHttpServer();
t.context.auth = app.get(AuthService);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
});
test.beforeEach(async t => {
Sinon.restore();
await t.context.app.initTestingDB();
t.context.config.override({
client: {
versionControl: {
enabled: false,
requiredVersion: '>=0.25.0',
},
},
});
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
const session = await t.context.models.session.createSession();
t.context.sessionId = session.id;
await t.context.auth.createUserSession(t.context.u1.id, t.context.sessionId);
});
test.after.always(async t => {
@@ -61,92 +77,95 @@ test.after.always(async t => {
});
test('should be able to visit public api if not signed in', async t => {
const res = await request(server).get('/public').expect(200);
const res = await request(t.context.server).get('/public').expect(200);
t.is(res.body.user, undefined);
});
test('should be able to visit public api if signed in', async t => {
const res = await request(server)
const res = await request(t.context.server)
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=${sessionId}`)
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, u1.id);
t.is(res.body.user.id, t.context.u1.id);
});
test('should not be able to visit private api if not signed in', async t => {
await request(server).get('/private').expect(HttpStatus.UNAUTHORIZED).expect({
status: 401,
code: 'Unauthorized',
type: 'AUTHENTICATION_REQUIRED',
name: 'AUTHENTICATION_REQUIRED',
message: 'You must sign in first to access this resource.',
});
await request(t.context.server)
.get('/private')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
status: 401,
code: 'Unauthorized',
type: 'AUTHENTICATION_REQUIRED',
name: 'AUTHENTICATION_REQUIRED',
message: 'You must sign in first to access this resource.',
});
t.assert(true);
});
test('should be able to visit private api if signed in', async t => {
const res = await request(server)
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${sessionId}`)
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, u1.id);
t.is(res.body.user.id, t.context.u1.id);
});
test('should be able to visit private api with access token', async t => {
const models = t.context.app.get(Models);
const token = await models.accessToken.create({
userId: u1.id,
userId: t.context.u1.id,
name: 'test',
});
const res = await request(server)
const res = await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${token.token}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, u1.id);
t.is(res.body.user.id, t.context.u1.id);
});
test('should be able to parse session cookie', async t => {
const spy = Sinon.spy(auth, 'getUserSession');
await request(server)
const spy = Sinon.spy(t.context.auth, 'getUserSession');
await request(t.context.server)
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=${sessionId}`)
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(200);
t.deepEqual(spy.firstCall.args, [sessionId, undefined]);
t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]);
spy.restore();
});
test('should be able to parse bearer token', async t => {
const spy = Sinon.spy(auth, 'getUserSession');
const spy = Sinon.spy(t.context.auth, 'getUserSession');
await request(server)
await request(t.context.server)
.get('/public')
.auth(sessionId, { type: 'bearer' })
.auth(t.context.sessionId, { type: 'bearer' })
.expect(200);
t.deepEqual(spy.firstCall.args, [sessionId, undefined]);
t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]);
spy.restore();
});
test('should be able to refresh session if needed', async t => {
await t.context.app.get(PrismaClient).userSession.updateMany({
where: {
sessionId,
sessionId: t.context.sessionId,
},
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
},
});
const res = await request(server)
const res = await request(t.context.server)
.get('/session')
.set('cookie', `${AuthService.sessionCookieName}=${sessionId}`)
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(200);
const cookie = res
@@ -155,3 +174,101 @@ test('should be able to refresh session if needed', async t => {
t.truthy(cookie);
});
test('should record refresh client version when refreshed', async t => {
await t.context.db.userSession.updateMany({
where: { sessionId: t.context.sessionId },
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
},
});
await request(t.context.server)
.get('/session')
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', '0.25.2')
.expect(200);
const userSession = await t.context.db.userSession.findFirst({
where: { sessionId: t.context.sessionId, userId: t.context.u1.id },
});
t.is(userSession?.refreshClientVersion, '0.25.2');
});
test('should allow auth when header is missing but stored version is valid', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
await t.context.db.userSession.updateMany({
where: { sessionId: t.context.sessionId },
data: { signInClientVersion: '0.25.0' },
});
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(200);
t.is(res.body.user.id, t.context.u1.id);
});
test('should kick out unsupported client version on non-public handler', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', '0.24.0')
.expect(403);
const setCookies = res.get('Set-Cookie') ?? [];
t.true(
setCookies.some(c => c.startsWith(`${AuthService.sessionCookieName}=`))
);
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
const session = await t.context.db.session.findFirst({
where: { id: t.context.sessionId },
});
t.is(session, null);
});
test('should not block public handler when client version is unsupported', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const res = await request(t.context.server)
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', '0.24.0')
.expect(200);
t.is(res.body.user, undefined);
const setCookies = res.get('Set-Cookie') ?? [];
t.true(
setCookies.some(c => c.startsWith(`${AuthService.sessionCookieName}=`))
);
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
});
@@ -122,6 +122,64 @@ test('should refresh exists userSession', async t => {
);
});
test('should record sign-in client version on create and update', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
const session = await t.context.session.createSession();
const userSession1 = await t.context.session.createOrRefreshUserSession(
user.id,
session.id,
undefined,
'0.25.0'
);
t.is(userSession1.signInClientVersion, '0.25.0');
const userSession2 = await t.context.session.createOrRefreshUserSession(
user.id,
session.id
);
t.is(userSession2.signInClientVersion, '0.25.0');
const userSession3 = await t.context.session.createOrRefreshUserSession(
user.id,
session.id,
undefined,
'0.26.0'
);
t.is(userSession3.signInClientVersion, '0.26.0');
});
test('should record refresh client version only when refreshed', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
const session = await t.context.session.createSession();
const userSession = await t.context.session.createOrRefreshUserSession(
user.id,
session.id
);
// force refresh
userSession.expiresAt = new Date(
userSession.expiresAt!.getTime() -
t.context.config.auth.session.ttr * 2 * 1000
);
const newExpiresAt = await t.context.session.refreshUserSessionIfNeeded(
userSession,
undefined,
'0.25.0'
);
t.truthy(newExpiresAt);
const refreshed = await t.context.db.userSession.findFirst({
where: { id: userSession.id },
});
t.is(refreshed?.refreshClientVersion, '0.25.0');
});
test('should not refresh userSession when expires time not hit ttr', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
@@ -1,4 +1,5 @@
import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { type Response } from 'supertest';
@@ -144,6 +145,72 @@ test('should be able to prevent requests if limit is reached', async t => {
stub.restore();
});
test('should use session id as tracker when available', async t => {
const { app } = t.context;
const user = await app.signupV1('u1@affine.pro');
const userSession = await app.get(PrismaClient).userSession.findFirst({
where: { userId: user.id },
});
t.truthy(userSession);
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
timeToExpire: 10,
totalHits: 1,
isBlocked: false,
timeToBlockExpire: 0,
});
await app.GET('/throttled/default').expect(200);
const key = stub.firstCall.args[0] as string;
t.true(key.startsWith(`throttler:${userSession!.sessionId};default`));
stub.restore();
});
test('should use CF-Connecting-IP as tracker when present', async t => {
const { app } = t.context;
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
timeToExpire: 10,
totalHits: 1,
isBlocked: false,
timeToBlockExpire: 0,
});
await app
.GET('/nonthrottled/default')
.set('CF-Connecting-IP', '1.2.3.4')
.expect(200);
const key = stub.firstCall.args[0] as string;
t.true(key.startsWith('throttler:1.2.3.4;default'));
stub.restore();
});
test('should use X-Forwarded-For as tracker when present', async t => {
const { app } = t.context;
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
timeToExpire: 10,
totalHits: 1,
isBlocked: false,
timeToBlockExpire: 0,
});
await app
.GET('/nonthrottled/default')
.set('X-Forwarded-For', '5.6.7.8, 9.9.9.9')
.expect(200);
const key = stub.firstCall.args[0] as string;
t.true(key.startsWith('throttler:5.6.7.8;default'));
stub.restore();
});
// ====== unauthenticated user visits ======
test('should use default throttler for unauthenticated user when not specified', async t => {
const { app } = t.context;
@@ -6,7 +6,7 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../../app.module';
import { URLHelper } from '../../base';
import { ConfigFactory, URLHelper } from '../../base';
import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
@@ -56,6 +56,14 @@ test.before(async t => {
test.beforeEach(async t => {
Sinon.restore();
await t.context.app.initTestingDB();
t.context.app.get(ConfigFactory).override({
client: {
versionControl: {
enabled: false,
requiredVersion: '>=0.25.0',
},
},
});
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
});
@@ -156,6 +164,56 @@ test('should be able to redirect to oauth provider with client_nonce', async t =
t.truthy(state.state);
});
test('should record sign in client version from oauth preflight state', async t => {
const { app, db } = t.context;
const config = app.get(ConfigFactory);
config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const preflightRes = await app
.POST('/api/oauth/preflight')
.set('x-affine-version', '0.25.3')
.send({ provider: 'Google', client_nonce: 'test-nonce' })
.expect(HttpStatus.OK);
const redirect = new URL(preflightRes.body.url as string);
const stateParam = redirect.searchParams.get('state');
t.truthy(stateParam);
// state should be a json string
const rawState = JSON.parse(stateParam!);
const provider = app.get(GoogleOAuthProvider);
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
Sinon.stub(provider, 'getUser').resolves({
id: '1',
email: 'oauth-version@affine.pro',
avatarUrl: 'avatar',
});
const callbackRes = await app
.POST('/api/oauth/callback')
.send({ code: '1', state: stateParam, client_nonce: 'test-nonce' })
.expect(HttpStatus.OK);
const userId = callbackRes.body.id as string;
t.truthy(userId);
const userSession = await db.userSession.findFirst({
where: { userId },
});
t.is(userSession?.signInClientVersion, '0.25.3');
t.is(userSession?.refreshClientVersion, null);
t.truthy(rawState.state);
});
test('should forbid preflight with untrusted redirect_uri', async t => {
const { app } = t.context;
@@ -73,7 +73,7 @@ test('should passthrough if version check is not enabled', async t => {
spy.restore();
});
test('should passthrough is version range is invalid', async t => {
test('should enforce hard required version when version range is invalid', async t => {
config.override({
client: {
versionControl: {
@@ -82,9 +82,17 @@ test('should passthrough is version range is invalid', async t => {
},
});
let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
let res = await app.GET('/guarded/test').set('x-affine-version', '0.25.0');
t.is(res.status, 200);
res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [invalid], required version is [>=0.25.0].'
);
});
test('should pass if client version is allowed', async t => {