feat(server): support refresh token (#15218)

This commit is contained in:
DarkSky
2026-07-12 02:39:42 +08:00
committed by GitHub
parent aea128f0b9
commit 02b25e05d8
80 changed files with 5866 additions and 745 deletions
@@ -0,0 +1,328 @@
import { timingSafeEqual } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { BaseModel } from './base';
export interface CreateAuthSessionInput {
userSessionId: string;
installationId: string;
platform: string;
deviceName?: string;
appVersion?: string;
idleExpiresAt: Date;
absoluteExpiresAt: Date;
refreshToken: {
id: string;
secretHash: string;
expiresAt: Date;
};
}
export interface RotateAuthRefreshTokenInput {
id: string;
secretHash: string;
now: Date;
idleExpiresAt: Date;
graceMs: number;
appVersion?: string;
next: {
id: string;
secretHash: string;
expiresAt: Date;
};
}
export type RotateAuthRefreshTokenResult =
| {
status: 'rotated' | 'grace';
authSessionId: string;
userSessionId: string;
platform: string;
}
| { status: 'reused'; authSessionId: string; platform: string }
| { status: 'invalid' }
| { status: 'expired' }
| { status: 'revoked' }
| { status: 'temporarily_unavailable' };
@Injectable()
export class AuthSessionModel extends BaseModel {
async hasUserInstallation(userSessionId: string, installationId: string) {
const userSession = await this.db.userSession.findUnique({
where: { id: userSessionId },
select: { userId: true },
});
if (!userSession) return false;
await this.db
.$queryRaw`SELECT id FROM users WHERE id = ${userSession.userId} FOR UPDATE`;
return (
(await this.db.authSession.count({
where: {
installationId,
userSession: { userId: userSession.userId },
},
})) > 0
);
}
@Transactional()
async create(input: CreateAuthSessionInput) {
const session = await this.db.authSession.create({
data: {
userSessionId: input.userSessionId,
installationId: input.installationId,
platform: input.platform,
deviceName: input.deviceName,
appVersion: input.appVersion,
idleExpiresAt: input.idleExpiresAt,
absoluteExpiresAt: input.absoluteExpiresAt,
refreshTokens: {
create: {
id: input.refreshToken.id,
generation: 0,
secretHash: input.refreshToken.secretHash,
expiresAt: input.refreshToken.expiresAt,
},
},
},
});
await this.db.userSession.update({
where: { id: input.userSessionId },
data: {
expiresAt: input.idleExpiresAt,
refreshClientVersion: input.appVersion,
},
});
return session;
}
async get(id: string) {
return await this.db.authSession.findUnique({
where: { id },
include: { userSession: true },
});
}
async list(userId: string) {
const now = new Date();
return await this.db.authSession.findMany({
where: {
userSession: {
userId,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
revokedAt: null,
idleExpiresAt: { gt: now },
absoluteExpiresAt: { gt: now },
},
orderBy: { lastSeenAt: 'desc' },
});
}
async revokeUserSessions(userId: string, reason: string) {
return await this.db.authSession.updateMany({
where: {
userSession: { userId },
revokedAt: null,
},
data: {
revokedAt: new Date(),
revokeReason: reason,
},
});
}
async revoke(id: string, reason: string, userId?: string) {
const now = new Date();
const where = {
id,
revokedAt: null,
...(userId ? { userSession: { userId } } : {}),
};
const { count } = await this.db.authSession.updateMany({
where,
data: { revokedAt: now, revokeReason: reason },
});
if (count) {
await this.db.authRefreshToken.updateMany({
where: { authSessionId: id, revokedAt: null },
data: { revokedAt: now },
});
}
return count > 0;
}
async findByRefreshToken(id: string, secretHash: string) {
const token = await this.db.authRefreshToken.findUnique({
where: { id },
select: { secretHash: true, authSessionId: true },
});
return token && this.secretHashesEqual(token.secretHash, secretHash)
? token.authSessionId
: undefined;
}
@Transactional()
async rotate(
input: RotateAuthRefreshTokenInput
): Promise<RotateAuthRefreshTokenResult> {
await this.db.$queryRaw`
SELECT id
FROM auth_refresh_tokens
WHERE id = ${input.id}
FOR UPDATE
`;
let token = await this.db.authRefreshToken.findUnique({
where: { id: input.id },
include: {
authSession: {
include: { userSession: { include: { user: true } } },
},
},
});
if (!token || !this.secretHashesEqual(token.secretHash, input.secretHash)) {
return { status: 'invalid' };
}
const session = token.authSession;
if (token.revokedAt || session.revokedAt) return { status: 'revoked' };
if (session.userSession.user.disabled) {
await this.revoke(session.id, 'user_disabled');
return { status: 'revoked' };
}
if (
token.expiresAt <= input.now ||
session.idleExpiresAt <= input.now ||
session.absoluteExpiresAt <= input.now ||
(session.userSession.expiresAt &&
session.userSession.expiresAt <= input.now)
) {
return { status: 'expired' };
}
if (token.usedAt) {
const replacementId = token.replacedById;
if (
token.graceUsedAt ||
!replacementId ||
input.now.getTime() - token.usedAt.getTime() > input.graceMs
) {
await this.revoke(session.id, 'refresh_token_reused');
return {
status: 'reused',
authSessionId: session.id,
platform: session.platform,
};
}
const claimed = await this.db.authRefreshToken.updateMany({
where: {
id: token.id,
graceUsedAt: null,
replacedById: input.next.id,
replacedBy: { secretHash: input.next.secretHash },
},
data: { graceUsedAt: input.now },
});
if (!claimed.count) {
return { status: 'temporarily_unavailable' };
}
return {
status: 'grace',
authSessionId: session.id,
userSessionId: session.userSessionId,
platform: session.platform,
};
}
await this.db.authRefreshToken.create({
data: {
id: input.next.id,
authSessionId: session.id,
generation: token.generation + 1,
secretHash: input.next.secretHash,
expiresAt: input.next.expiresAt,
},
});
await this.db.authRefreshToken.update({
where: { id: token.id },
data: { usedAt: input.now, replacedById: input.next.id },
});
await this.db.authSession.update({
where: { id: session.id },
data: {
lastSeenAt: input.now,
idleExpiresAt:
input.idleExpiresAt < session.absoluteExpiresAt
? input.idleExpiresAt
: session.absoluteExpiresAt,
appVersion: input.appVersion,
},
});
await this.db.userSession.update({
where: { id: session.userSessionId },
data: {
expiresAt:
input.idleExpiresAt < session.absoluteExpiresAt
? input.idleExpiresAt
: session.absoluteExpiresAt,
refreshClientVersion: input.appVersion,
},
});
return {
status: 'rotated',
authSessionId: session.id,
userSessionId: session.userSessionId,
platform: session.platform,
};
}
async deleteExpiredRefreshTokens(before: Date, limit: number) {
const rows = await this.db.authRefreshToken.findMany({
where: {
OR: [
{ expiresAt: { lt: before } },
{ authSession: { revokedAt: { lt: before } } },
],
},
select: { id: true },
take: limit,
});
if (!rows.length) return 0;
const { count } = await this.db.authRefreshToken.deleteMany({
where: { id: { in: rows.map(row => row.id) } },
});
return count;
}
async deleteExpiredSessions(before: Date, limit: number) {
const rows = await this.db.authSession.findMany({
where: {
OR: [
{ absoluteExpiresAt: { lt: before } },
{ revokedAt: { lt: before } },
],
},
select: { id: true },
take: limit,
});
if (!rows.length) return 0;
const { count } = await this.db.authSession.deleteMany({
where: { id: { in: rows.map(row => row.id) } },
});
return count;
}
private secretHashesEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
return (
leftBuffer.length === rightBuffer.length &&
timingSafeEqual(leftBuffer, rightBuffer)
);
}
}
+45 -2
View File
@@ -1,12 +1,15 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
@Injectable()
export class AppConfigModel extends BaseModel {
async load() {
return this.db.appConfig.findMany();
async load(excludedKeys: string[] = []) {
return this.db.appConfig.findMany({
where: excludedKeys.length ? { id: { notIn: excludedKeys } } : undefined,
});
}
@Transactional()
@@ -21,4 +24,44 @@ export class AppConfigModel extends BaseModel {
})
);
}
async get(key: string) {
return await this.db.appConfig.findUnique({ where: { id: key } });
}
async createIfAbsent(key: string, value: Prisma.InputJsonValue) {
try {
return await this.db.appConfig.create({
data: { id: key, value, lastUpdatedBy: null },
});
} catch (error) {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== 'P2002'
) {
throw error;
}
return await this.db.appConfig.findUniqueOrThrow({ where: { id: key } });
}
}
@Transactional()
async mutate(
key: string,
actorId: string,
mutator: (value: Prisma.JsonValue) => Prisma.InputJsonValue
) {
await this.db
.$queryRaw`SELECT id FROM app_configs WHERE id = ${key} FOR UPDATE`;
const current = await this.db.appConfig.findUniqueOrThrow({
where: { id: key },
});
return await this.db.appConfig.update({
where: { id: key },
data: {
value: mutator(current.value),
lastUpdatedBy: actorId,
},
});
}
}
@@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { AccessTokenModel } from './access-token';
import { AuthSessionModel } from './auth-session';
import { BlobModel } from './blob';
import { CalendarAccountModel } from './calendar-account';
import { CalendarEventModel } from './calendar-event';
@@ -59,6 +60,7 @@ const MODELS = {
verificationToken: VerificationTokenModel,
magicLinkOtp: MagicLinkOtpModel,
mailDelivery: MailDeliveryModel,
authSession: AuthSessionModel,
feature: FeatureModel,
workspace: WorkspaceModel,
userFeature: UserFeatureModel,
@@ -147,6 +149,7 @@ const ModelsSymbolProvider: ExistingProvider = {
})
export class ModelsModule {}
export * from './auth-session';
export * from './blob';
export * from './calendar-account';
export * from './calendar-event';
@@ -35,6 +35,7 @@ type UpdateConnectedAccountInput = Omit<
declare global {
interface Events {
'user.preDelete': { id: string };
'user.created': User;
'user.updated': User;
'user.deleted': User & {
@@ -60,6 +61,16 @@ export type { ConnectedAccount, User };
@Injectable()
export class UserModel extends BaseModel {
async lockForAuthIssuance(id: string) {
const users = await this.db.$queryRaw<Array<Pick<User, 'id' | 'disabled'>>>`
SELECT id, disabled
FROM users
WHERE id = ${id}
FOR UPDATE
`;
return users[0];
}
constructor(
private readonly crypto: CryptoHelper,
private readonly event: EventBus
@@ -284,6 +295,8 @@ export class UserModel extends BaseModel {
}
}
await this.event.emitAsync('user.preDelete', { id });
await this.db.workspaceInvitation.deleteMany({
where: { inviteeUserId: id },
});