215 lines
5.7 KiB
TypeScript
215 lines
5.7 KiB
TypeScript
import crypto from 'node:crypto';
|
|
|
|
import type { QueryResultRow } from 'pg';
|
|
|
|
import type { AppDatabase } from '../db.js';
|
|
|
|
export type UserSessionRecord = {
|
|
id: string;
|
|
userId: string;
|
|
refreshTokenHash: string;
|
|
clientType: string;
|
|
userAgent: string | null;
|
|
ip: string | null;
|
|
expiresAt: string;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastSeenAt: string;
|
|
};
|
|
|
|
type UserSessionRow = QueryResultRow & {
|
|
id: string;
|
|
user_id: string;
|
|
refresh_token_hash: string;
|
|
client_type: string;
|
|
user_agent: string | null;
|
|
ip: string | null;
|
|
expires_at: string;
|
|
revoked_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
last_seen_at: string;
|
|
};
|
|
|
|
function toUserSessionRecord(
|
|
row: UserSessionRow | undefined,
|
|
): UserSessionRecord | null {
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
refreshTokenHash: row.refresh_token_hash,
|
|
clientType: row.client_type,
|
|
userAgent: row.user_agent,
|
|
ip: row.ip,
|
|
expiresAt: row.expires_at,
|
|
revokedAt: row.revoked_at,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
lastSeenAt: row.last_seen_at,
|
|
};
|
|
}
|
|
|
|
export type CreateUserSessionInput = {
|
|
userId: string;
|
|
refreshTokenHash: string;
|
|
clientType: string;
|
|
userAgent: string | null;
|
|
ip: string | null;
|
|
expiresAt: string;
|
|
};
|
|
|
|
export class UserSessionRepository {
|
|
constructor(private readonly db: AppDatabase) {}
|
|
|
|
async create(input: CreateUserSessionInput) {
|
|
const now = new Date().toISOString();
|
|
const sessionId = `usess_${crypto.randomBytes(16).toString('hex')}`;
|
|
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`INSERT INTO user_sessions (
|
|
id,
|
|
user_id,
|
|
refresh_token_hash,
|
|
client_type,
|
|
user_agent,
|
|
ip,
|
|
expires_at,
|
|
revoked_at,
|
|
created_at,
|
|
updated_at,
|
|
last_seen_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10)
|
|
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
|
|
[
|
|
sessionId,
|
|
input.userId,
|
|
input.refreshTokenHash,
|
|
input.clientType,
|
|
input.userAgent,
|
|
input.ip,
|
|
input.expiresAt,
|
|
now,
|
|
now,
|
|
now,
|
|
],
|
|
);
|
|
|
|
return toUserSessionRecord(result.rows[0]);
|
|
}
|
|
|
|
async findActiveByRefreshTokenHash(refreshTokenHash: string) {
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
|
|
FROM user_sessions
|
|
WHERE refresh_token_hash = $1
|
|
LIMIT 1`,
|
|
[refreshTokenHash],
|
|
);
|
|
|
|
return toUserSessionRecord(result.rows[0]);
|
|
}
|
|
|
|
async rotate(
|
|
sessionId: string,
|
|
input: {
|
|
refreshTokenHash: string;
|
|
expiresAt: string;
|
|
lastSeenAt: string;
|
|
},
|
|
) {
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`UPDATE user_sessions
|
|
SET refresh_token_hash = $1,
|
|
expires_at = $2,
|
|
last_seen_at = $3,
|
|
updated_at = $4
|
|
WHERE id = $5
|
|
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
|
|
[
|
|
input.refreshTokenHash,
|
|
input.expiresAt,
|
|
input.lastSeenAt,
|
|
new Date().toISOString(),
|
|
sessionId,
|
|
],
|
|
);
|
|
|
|
return toUserSessionRecord(result.rows[0]);
|
|
}
|
|
|
|
async revoke(sessionId: string) {
|
|
const now = new Date().toISOString();
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`UPDATE user_sessions
|
|
SET revoked_at = $1,
|
|
updated_at = $2
|
|
WHERE id = $3
|
|
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
|
|
[now, now, sessionId],
|
|
);
|
|
|
|
return toUserSessionRecord(result.rows[0]);
|
|
}
|
|
|
|
async findById(sessionId: string) {
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
|
|
FROM user_sessions
|
|
WHERE id = $1
|
|
LIMIT 1`,
|
|
[sessionId],
|
|
);
|
|
|
|
return toUserSessionRecord(result.rows[0]);
|
|
}
|
|
|
|
async listActiveByUserId(userId: string) {
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
|
|
FROM user_sessions
|
|
WHERE user_id = $1
|
|
AND revoked_at IS NULL
|
|
ORDER BY last_seen_at DESC, created_at DESC`,
|
|
[userId],
|
|
);
|
|
|
|
return result.rows
|
|
.map((row) => toUserSessionRecord(row))
|
|
.filter((row): row is UserSessionRecord => Boolean(row));
|
|
}
|
|
|
|
async revokeAllByUserId(userId: string) {
|
|
const now = new Date().toISOString();
|
|
await this.db.query(
|
|
`UPDATE user_sessions
|
|
SET revoked_at = $1,
|
|
updated_at = $2
|
|
WHERE user_id = $3
|
|
AND revoked_at IS NULL`,
|
|
[now, now, userId],
|
|
);
|
|
}
|
|
|
|
async revokeByUserIdAndSessionId(userId: string, sessionId: string) {
|
|
const now = new Date().toISOString();
|
|
const result = await this.db.query<UserSessionRow>(
|
|
`UPDATE user_sessions
|
|
SET revoked_at = $1,
|
|
updated_at = $2
|
|
WHERE user_id = $3
|
|
AND id = $4
|
|
AND revoked_at IS NULL
|
|
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
|
|
[now, now, userId, sessionId],
|
|
);
|
|
|
|
return toUserSessionRecord(result.rows[0]);
|
|
}
|
|
}
|