149 lines
4.2 KiB
TypeScript
149 lines
4.2 KiB
TypeScript
import { isSessionActive, resolveSessionDeadlines } from './session-policy'
|
|
import type { TokenDigester } from './token-digest'
|
|
|
|
export interface AuthUserRecord {
|
|
id: string
|
|
email: string
|
|
displayName: string
|
|
passwordHash: string
|
|
emailVerified: boolean
|
|
image: string | null
|
|
status: 'active' | 'disabled' | 'pending_deletion'
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
export interface AuthSessionRecord {
|
|
id: string
|
|
userId: string
|
|
tokenHash: string
|
|
createdAt: Date
|
|
lastSeenAt: Date
|
|
idleExpiresAt: Date
|
|
absoluteExpiresAt: Date
|
|
revokedAt: Date | null
|
|
sourceIpHash: string | null
|
|
userAgentSummary: string | null
|
|
}
|
|
|
|
export interface CreateAuthSessionRecord {
|
|
userId: string
|
|
tokenHash: string
|
|
createdAt: Date
|
|
lastSeenAt: Date
|
|
idleExpiresAt: Date
|
|
absoluteExpiresAt: Date
|
|
sourceIpHash: string | null
|
|
userAgentSummary: string | null
|
|
}
|
|
|
|
export interface AuthPersistence {
|
|
findUserById(id: string): Promise<AuthUserRecord | null>
|
|
findUserByEmail(email: string): Promise<AuthUserRecord | null>
|
|
updateUser(
|
|
id: string,
|
|
update: Partial<
|
|
Pick<
|
|
AuthUserRecord,
|
|
'displayName' | 'email' | 'emailVerified' | 'image' | 'passwordHash'
|
|
>
|
|
>,
|
|
): Promise<AuthUserRecord | null>
|
|
createSession(input: CreateAuthSessionRecord): Promise<AuthSessionRecord>
|
|
findSessionByTokenHash(tokenHash: string): Promise<AuthSessionRecord | null>
|
|
touchSession(
|
|
id: string,
|
|
input: { lastSeenAt: Date; idleExpiresAt: Date },
|
|
): Promise<AuthSessionRecord | null>
|
|
revokeSessionByTokenHash(tokenHash: string, revokedAt: Date): Promise<boolean>
|
|
revokeSessionsForUser(userId: string, revokedAt: Date): Promise<number>
|
|
}
|
|
|
|
export interface ActiveAuthSession {
|
|
session: AuthSessionRecord
|
|
user: AuthUserRecord
|
|
rawToken: string
|
|
}
|
|
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly persistence: AuthPersistence,
|
|
private readonly digester: TokenDigester,
|
|
private readonly now: () => Date = () => new Date(),
|
|
) {}
|
|
|
|
findUserById(id: string) {
|
|
return this.persistence.findUserById(id)
|
|
}
|
|
|
|
findUserByEmail(email: string) {
|
|
return this.persistence.findUserByEmail(email.trim().toLowerCase())
|
|
}
|
|
|
|
updateUser(
|
|
id: string,
|
|
update: Partial<
|
|
Pick<
|
|
AuthUserRecord,
|
|
'displayName' | 'email' | 'emailVerified' | 'image' | 'passwordHash'
|
|
>
|
|
>,
|
|
) {
|
|
return this.persistence.updateUser(id, update)
|
|
}
|
|
|
|
async createSession(input: {
|
|
userId: string
|
|
rawToken: string
|
|
sourceIpHash?: string | null
|
|
userAgentSummary?: string | null
|
|
}): Promise<ActiveAuthSession | null> {
|
|
const user = await this.persistence.findUserById(input.userId)
|
|
if (!user || user.status !== 'active') return null
|
|
const now = this.now()
|
|
const deadlines = resolveSessionDeadlines({ createdAt: now, now })
|
|
const session = await this.persistence.createSession({
|
|
userId: user.id,
|
|
tokenHash: this.digester.digest(input.rawToken),
|
|
createdAt: now,
|
|
lastSeenAt: now,
|
|
...deadlines,
|
|
sourceIpHash: input.sourceIpHash ?? null,
|
|
userAgentSummary: input.userAgentSummary ?? null,
|
|
})
|
|
return { session, user, rawToken: input.rawToken }
|
|
}
|
|
|
|
async findActiveSession(rawToken: string): Promise<ActiveAuthSession | null> {
|
|
const now = this.now()
|
|
const session = await this.persistence.findSessionByTokenHash(
|
|
this.digester.digest(rawToken),
|
|
)
|
|
if (!session || !isSessionActive(now, session)) return null
|
|
const user = await this.persistence.findUserById(session.userId)
|
|
if (!user || user.status !== 'active') return null
|
|
const deadlines = resolveSessionDeadlines({
|
|
createdAt: session.createdAt,
|
|
now,
|
|
absoluteExpiresAt: session.absoluteExpiresAt,
|
|
})
|
|
const touched = await this.persistence.touchSession(session.id, {
|
|
lastSeenAt: now,
|
|
idleExpiresAt: deadlines.idleExpiresAt,
|
|
})
|
|
if (!touched) return null
|
|
return { session: touched, user, rawToken }
|
|
}
|
|
|
|
revokeSession(rawToken: string): Promise<boolean> {
|
|
return this.persistence.revokeSessionByTokenHash(
|
|
this.digester.digest(rawToken),
|
|
this.now(),
|
|
)
|
|
}
|
|
|
|
revokeSessionsForUser(userId: string): Promise<number> {
|
|
return this.persistence.revokeSessionsForUser(userId, this.now())
|
|
}
|
|
}
|