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
+104 -7
View File
@@ -7,6 +7,7 @@ import type {
import { Injectable, SetMetadata } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import type { Request, Response } from 'express';
import semver from 'semver';
import { Socket } from 'socket.io';
import {
@@ -15,8 +16,10 @@ import {
Cache,
Config,
CryptoHelper,
getClientVersionFromRequest,
getRequestResponseFromContext,
parseCookies,
UnsupportedClientVersion,
} from '../../base';
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
import { AuthService } from './service';
@@ -30,10 +33,13 @@ const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
@Injectable()
export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
private readonly cachedVersionRange = new Map<string, semver.Range | null>();
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
constructor(
private readonly crypto: CryptoHelper,
private readonly cache: Cache,
private readonly config: Config,
private readonly ref: ModuleRef,
private readonly reflector: Reflector
) {}
@@ -78,14 +84,14 @@ export class AuthGuard implements CanActivate, OnModuleInit {
throw new AccessDenied('Invalid internal request');
}
const authedUser = await this.signIn(req, res);
// api is public
const isPublic = this.reflector.getAllAndOverride<boolean>(
PUBLIC_ENTRYPOINT_SYMBOL,
[clazz, handler]
);
const authedUser = await this.signIn(req, res, isPublic);
if (isPublic) {
return true;
}
@@ -99,9 +105,10 @@ export class AuthGuard implements CanActivate, OnModuleInit {
async signIn(
req: Request,
res?: Response
res?: Response,
isPublic = false
): Promise<Session | TokenSession | null> {
const userSession = await this.signInWithCookie(req, res);
const userSession = await this.signInWithCookie(req, res, isPublic);
if (userSession) {
return userSession;
}
@@ -111,7 +118,8 @@ export class AuthGuard implements CanActivate, OnModuleInit {
async signInWithCookie(
req: Request,
res?: Response
res?: Response,
isPublic = false
): Promise<Session | null> {
if (req.session) {
return req.session;
@@ -121,8 +129,38 @@ export class AuthGuard implements CanActivate, OnModuleInit {
const userSession = await this.auth.getUserSessionFromRequest(req, res);
if (userSession) {
const headerClientVersion = getClientVersionFromRequest(req);
if (this.config.client.versionControl.enabled) {
const clientVersion =
headerClientVersion ??
userSession.session.refreshClientVersion ??
userSession.session.signInClientVersion;
const versionCheckResult = this.checkClientVersion(clientVersion);
if (!versionCheckResult.ok) {
await this.auth.signOut(userSession.session.sessionId);
if (res) {
await this.auth.refreshCookies(res, userSession.session.sessionId);
}
if (isPublic) {
return null;
}
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion: versionCheckResult.requiredVersion,
});
}
}
if (res) {
await this.auth.refreshUserSessionIfNeeded(res, userSession.session);
await this.auth.refreshUserSessionIfNeeded(
res,
userSession.session,
undefined,
headerClientVersion
);
}
req.session = {
@@ -154,6 +192,59 @@ export class AuthGuard implements CanActivate, OnModuleInit {
return null;
}
private getVersionRange(versionRange: string): semver.Range | null {
if (this.cachedVersionRange.has(versionRange)) {
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.cachedVersionRange.get(versionRange)!;
}
let range: semver.Range | null = null;
try {
range = new semver.Range(versionRange, { loose: false });
if (!semver.validRange(range)) {
range = null;
}
} catch {
range = null;
}
this.cachedVersionRange.set(versionRange, range);
return range;
}
private checkClientVersion(
clientVersion?: string | null
): { ok: true } | { ok: false; requiredVersion: string } {
const requiredVersion = this.config.client.versionControl.requiredVersion;
const configRange = this.getVersionRange(requiredVersion);
if (
configRange &&
(!clientVersion ||
!semver.satisfies(clientVersion, configRange, {
includePrerelease: true,
}))
) {
return { ok: false, requiredVersion };
}
const hardRange = this.getVersionRange(AuthGuard.HARD_REQUIRED_VERSION);
if (!hardRange) {
return { ok: true };
}
if (
!clientVersion ||
!semver.satisfies(clientVersion, hardRange, {
includePrerelease: true,
})
) {
return { ok: false, requiredVersion: AuthGuard.HARD_REQUIRED_VERSION };
}
return { ok: true };
}
}
/**
@@ -184,7 +275,13 @@ export const AuthWebsocketOptionsProvider: FactoryProvider = {
...upgradeReq.cookies,
};
const session = await guard.signIn(upgradeReq);
const session = await (async () => {
try {
return await guard.signIn(upgradeReq);
} catch {
return null;
}
})();
return !!session;
},
@@ -4,7 +4,11 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { CookieOptions, Request, Response } from 'express';
import { assign, pick } from 'lodash-es';
import { Config, SignUpForbidden } from '../../base';
import {
Config,
getClientVersionFromRequest,
SignUpForbidden,
} from '../../base';
import { Models, type User, type UserSession } from '../../models';
import { Mailer } from '../mail/mailer';
import { createDevUsers } from './dev';
@@ -130,11 +134,17 @@ export class AuthService implements OnApplicationBootstrap {
return await this.models.session.findUserSessionsBySessionId(sessionId);
}
async createUserSession(userId: string, sessionId?: string, ttl?: number) {
async createUserSession(
userId: string,
sessionId?: string,
ttl?: number,
signInClientVersion?: string
) {
return await this.models.session.createOrRefreshUserSession(
userId,
sessionId,
ttl
ttl,
signInClientVersion
);
}
@@ -159,11 +169,13 @@ export class AuthService implements OnApplicationBootstrap {
async refreshUserSessionIfNeeded(
res: Response,
userSession: UserSession,
ttr?: number
ttr?: number,
refreshClientVersion?: string
): Promise<boolean> {
const newExpiresAt = await this.models.session.refreshUserSessionIfNeeded(
userSession,
ttr
ttr,
refreshClientVersion
);
if (!newExpiresAt) {
// no need to refresh
@@ -205,10 +217,22 @@ export class AuthService implements OnApplicationBootstrap {
};
}
async setCookies(req: Request, res: Response, userId: string) {
async setCookies(
req: Request,
res: Response,
userId: string,
clientVersion?: string
) {
const { sessionId } = this.getSessionOptionsFromRequest(req);
const userSession = await this.createUserSession(userId, sessionId);
const signInClientVersion =
clientVersion ?? getClientVersionFromRequest(req);
const userSession = await this.createUserSession(
userId,
sessionId,
undefined,
signInClientVersion
);
res.cookie(AuthService.sessionCookieName, userSession.sessionId, {
...this.cookieOptions,
@@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common';
import {
Config,
getClientVersionFromRequest,
getRequestResponseFromContext,
GuardProvider,
} from '../../base';
@@ -33,7 +34,7 @@ export class VersionGuardProvider
const { req } = getRequestResponseFromContext(context);
const version = req.headers['x-affine-version'] as string | undefined;
const version = getClientVersionFromRequest(req);
return this.version.checkVersion(version);
}
@@ -6,23 +6,24 @@ import { Config, UnsupportedClientVersion } from '../../base';
@Injectable()
export class VersionService {
private readonly logger = new Logger(VersionService.name);
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
constructor(private readonly config: Config) {}
async checkVersion(clientVersion?: string) {
const requiredVersion = this.config.client.versionControl.requiredVersion;
const range = await this.getVersionRange(requiredVersion);
if (!range) {
// ignore invalid allowed version config
return true;
}
const hardRange = await this.getVersionRange(
VersionService.HARD_REQUIRED_VERSION
);
const configRange = await this.getVersionRange(requiredVersion);
if (
!clientVersion ||
!semver.satisfies(clientVersion, range, {
includePrerelease: true,
})
configRange &&
(!clientVersion ||
!semver.satisfies(clientVersion, configRange, {
includePrerelease: true,
}))
) {
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
@@ -30,6 +31,19 @@ export class VersionService {
});
}
if (
hardRange &&
(!clientVersion ||
!semver.satisfies(clientVersion, hardRange, {
includePrerelease: true,
}))
) {
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion: VersionService.HARD_REQUIRED_VERSION,
});
}
return true;
}