850 lines
23 KiB
TypeScript
850 lines
23 KiB
TypeScript
import { config } from '../config'
|
|
import { prisma } from '../prisma'
|
|
import {
|
|
deletePublicFile,
|
|
isR2PublicFilesConfigured,
|
|
publicFileUrl,
|
|
uploadPublicFile,
|
|
userAvatarKey
|
|
} from '../storage/r2-public-files'
|
|
import { hashPassword, verifyPassword } from '../security/password'
|
|
import { redis } from '../redis'
|
|
import { randomBytes } from 'crypto'
|
|
import { createOpaqueToken, hashToken } from '../security/tokens'
|
|
import type { AuthUser } from '../types'
|
|
|
|
type UserWithAffiliation = {
|
|
id: string
|
|
email: string
|
|
name: string | null
|
|
role: string
|
|
createdAt: Date
|
|
lastLoginAt: Date | null
|
|
appRoles?: Array<{
|
|
appKey: string
|
|
roleCode: string
|
|
}>
|
|
affiliations?: Array<{
|
|
id: string
|
|
organizationId: string
|
|
department: string | null
|
|
position: string | null
|
|
organization: {
|
|
name: string
|
|
type: string
|
|
}
|
|
}>
|
|
userLicenses?: Array<{
|
|
id: string
|
|
startsAt: Date
|
|
expiresAt: Date | null
|
|
license: {
|
|
code: string
|
|
name: string
|
|
}
|
|
}>
|
|
}
|
|
|
|
const activeLicenseWhere = () => {
|
|
const now = new Date()
|
|
return {
|
|
startsAt: { lte: now },
|
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
|
license: { isActive: true }
|
|
}
|
|
}
|
|
|
|
const currentLicenseRelation = () => ({
|
|
where: activeLicenseWhere(),
|
|
orderBy: { startsAt: 'desc' },
|
|
take: 1,
|
|
select: {
|
|
id: true,
|
|
startsAt: true,
|
|
expiresAt: true,
|
|
license: {
|
|
select: {
|
|
code: true,
|
|
name: true
|
|
}
|
|
}
|
|
}
|
|
}) as const
|
|
|
|
const userSelect = () => {
|
|
const now = new Date()
|
|
return {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
role: true,
|
|
createdAt: true,
|
|
lastLoginAt: true,
|
|
affiliations: {
|
|
where: { isPrimary: true, endsAt: null },
|
|
take: 1,
|
|
select: {
|
|
id: true,
|
|
organizationId: true,
|
|
department: true,
|
|
position: true,
|
|
organization: {
|
|
select: {
|
|
name: true,
|
|
type: true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
userLicenses: currentLicenseRelation(),
|
|
appRoles: {
|
|
where: {
|
|
AND: [
|
|
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
|
|
{ OR: [{ endsAt: null }, { endsAt: { gt: now } }] }
|
|
]
|
|
},
|
|
select: {
|
|
appKey: true,
|
|
roleCode: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const toAuthUser = (user: UserWithAffiliation): AuthUser => {
|
|
const primaryAffiliation = user.affiliations?.[0]
|
|
const currentLicense = user.userLicenses?.[0]
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
appRoles: user.appRoles ?? [],
|
|
createdAt: user.createdAt,
|
|
lastLoginAt: user.lastLoginAt,
|
|
primaryAffiliation: primaryAffiliation
|
|
? {
|
|
id: primaryAffiliation.id,
|
|
organizationId: primaryAffiliation.organizationId,
|
|
organizationName: primaryAffiliation.organization.name,
|
|
organizationType: primaryAffiliation.organization.type,
|
|
department: primaryAffiliation.department,
|
|
position: primaryAffiliation.position
|
|
}
|
|
: null,
|
|
currentLicense: currentLicense
|
|
? {
|
|
id: currentLicense.id,
|
|
code: currentLicense.license.code,
|
|
name: currentLicense.license.name,
|
|
startsAt: currentLicense.startsAt,
|
|
expiresAt: currentLicense.expiresAt
|
|
}
|
|
: null
|
|
}
|
|
}
|
|
|
|
export async function findUserByEmail(email: string) {
|
|
return prisma.user.findUnique({
|
|
where: { email: email.toLowerCase() },
|
|
include: {
|
|
affiliations: {
|
|
where: { isPrimary: true, endsAt: null },
|
|
take: 1,
|
|
include: { organization: true }
|
|
},
|
|
userLicenses: {
|
|
where: activeLicenseWhere(),
|
|
orderBy: { startsAt: 'desc' },
|
|
take: 1,
|
|
include: { license: true }
|
|
},
|
|
appRoles: {
|
|
where: {
|
|
AND: [
|
|
{ OR: [{ startsAt: null }, { startsAt: { lte: new Date() } }] },
|
|
{ OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }] }
|
|
]
|
|
},
|
|
select: {
|
|
appKey: true,
|
|
roleCode: true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function createUser(input: { email: string; password: string; name?: string | null }): Promise<AuthUser> {
|
|
const freeLicense = await prisma.license.findUnique({
|
|
where: { code: 'FREE' },
|
|
select: { defaultDurationDays: true }
|
|
})
|
|
const licenseStartsAt = new Date()
|
|
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: input.email.toLowerCase(),
|
|
passwordHash: await hashPassword(input.password),
|
|
name: input.name?.trim() || null,
|
|
userLicenses: {
|
|
create: {
|
|
startsAt: licenseStartsAt,
|
|
expiresAt: licenseExpiresAt,
|
|
license: {
|
|
connect: { code: 'FREE' }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
select: userSelect()
|
|
})
|
|
|
|
return toAuthUser(user)
|
|
}
|
|
|
|
export async function createInstructorUser(input: {
|
|
email: string
|
|
password: string
|
|
name?: string | null
|
|
instructorInviteCode: string
|
|
}): Promise<AuthUser | null> {
|
|
return prisma.$transaction(async (tx) => {
|
|
const organization = await tx.organization.findFirst({
|
|
where: {
|
|
isActive: true,
|
|
instructorInviteCode: input.instructorInviteCode.trim().toUpperCase()
|
|
},
|
|
select: { id: true, defaultLicenseCode: true }
|
|
})
|
|
if (!organization) return null
|
|
|
|
const requestedLicenseCode = organization.defaultLicenseCode ?? 'FREE'
|
|
const license = await tx.license.findFirst({
|
|
where: { code: requestedLicenseCode as 'FREE' | 'EDU' | 'PRO', isActive: true },
|
|
select: { code: true }
|
|
})
|
|
|
|
const user = await tx.user.create({
|
|
data: {
|
|
email: input.email.toLowerCase(),
|
|
passwordHash: await hashPassword(input.password),
|
|
name: input.name?.trim() || null,
|
|
affiliations: {
|
|
create: { organizationId: organization.id, isPrimary: true }
|
|
},
|
|
appRoles: {
|
|
create: { appKey: 'LMS', roleCode: 'INSTRUCTOR' }
|
|
},
|
|
userLicenses: {
|
|
create: {
|
|
license: { connect: { code: license?.code ?? 'FREE' } }
|
|
}
|
|
}
|
|
},
|
|
select: userSelect()
|
|
})
|
|
|
|
return toAuthUser(user)
|
|
})
|
|
}
|
|
|
|
export async function findOrCreateOAuthUser(input: {
|
|
provider: string
|
|
providerUserId: string
|
|
email: string
|
|
name?: string | null
|
|
}): Promise<AuthUser> {
|
|
const provider = input.provider.toLowerCase()
|
|
const email = input.email.toLowerCase()
|
|
|
|
const oauthAccount = await prisma.userOauthAccount.findUnique({
|
|
where: {
|
|
provider_providerUserId: {
|
|
provider,
|
|
providerUserId: input.providerUserId
|
|
}
|
|
},
|
|
include: {
|
|
user: {
|
|
select: userSelect()
|
|
}
|
|
}
|
|
})
|
|
|
|
if (oauthAccount?.user) return toAuthUserEnsuringFreeLicense(oauthAccount.user)
|
|
|
|
const existingUser = await prisma.user.findUnique({
|
|
where: { email },
|
|
select: { id: true }
|
|
})
|
|
|
|
if (existingUser) {
|
|
const user = await prisma.user.update({
|
|
where: { id: existingUser.id },
|
|
data: {
|
|
oauthAccounts: {
|
|
create: {
|
|
provider,
|
|
providerUserId: input.providerUserId,
|
|
email
|
|
}
|
|
}
|
|
},
|
|
select: userSelect()
|
|
})
|
|
|
|
return toAuthUserEnsuringFreeLicense(user)
|
|
}
|
|
|
|
const freeLicense = await prisma.license.findUnique({
|
|
where: { code: 'FREE' },
|
|
select: { defaultDurationDays: true }
|
|
})
|
|
const licenseStartsAt = new Date()
|
|
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await hashPassword(createOpaqueToken()),
|
|
name: input.name?.trim() || null,
|
|
oauthAccounts: {
|
|
create: {
|
|
provider,
|
|
providerUserId: input.providerUserId,
|
|
email
|
|
}
|
|
},
|
|
userLicenses: {
|
|
create: {
|
|
startsAt: licenseStartsAt,
|
|
expiresAt: licenseExpiresAt,
|
|
license: {
|
|
connect: { code: 'FREE' }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
select: userSelect()
|
|
})
|
|
|
|
return toAuthUser(user)
|
|
}
|
|
|
|
async function toAuthUserEnsuringFreeLicense(user: UserWithAffiliation): Promise<AuthUser> {
|
|
if (user.userLicenses?.[0]) return toAuthUser(user)
|
|
|
|
await ensureFreeLicense(user.id)
|
|
return (await findUserById(user.id)) ?? toAuthUser(user)
|
|
}
|
|
|
|
async function ensureFreeLicense(userId: string): Promise<void> {
|
|
const activeLicense = await prisma.userLicense.findFirst({
|
|
where: {
|
|
userId,
|
|
...activeLicenseWhere()
|
|
},
|
|
select: { id: true }
|
|
})
|
|
|
|
if (activeLicense) return
|
|
|
|
const freeLicense = await prisma.license.findUnique({
|
|
where: { code: 'FREE' },
|
|
select: { id: true, defaultDurationDays: true }
|
|
})
|
|
if (!freeLicense) throw new Error('FREE license is not configured')
|
|
|
|
const licenseStartsAt = new Date()
|
|
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense.defaultDurationDays)
|
|
|
|
await prisma.userLicense.create({
|
|
data: {
|
|
userId,
|
|
licenseId: freeLicense.id,
|
|
startsAt: licenseStartsAt,
|
|
expiresAt: licenseExpiresAt
|
|
}
|
|
})
|
|
}
|
|
|
|
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
|
|
if (!durationDays) return null
|
|
|
|
const expiresAt = new Date(startsAt)
|
|
expiresAt.setDate(expiresAt.getDate() + durationDays)
|
|
return expiresAt
|
|
}
|
|
|
|
export async function createManagedUser(input: {
|
|
email: string
|
|
password: string
|
|
name?: string | null
|
|
organizationId: string
|
|
department?: string | null
|
|
position?: string | null
|
|
licenseCode?: 'FREE' | 'EDU' | 'PRO'
|
|
}): Promise<AuthUser> {
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: input.email.toLowerCase(),
|
|
passwordHash: await hashPassword(input.password),
|
|
name: input.name?.trim() || null,
|
|
role: 'USER',
|
|
affiliations: {
|
|
create: {
|
|
organizationId: input.organizationId,
|
|
department: input.department?.trim() || null,
|
|
position: input.position?.trim() || null,
|
|
isPrimary: true
|
|
}
|
|
},
|
|
userLicenses: {
|
|
create: {
|
|
license: {
|
|
connect: { code: input.licenseCode ?? 'EDU' }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
select: userSelect()
|
|
})
|
|
|
|
return toAuthUser(user)
|
|
}
|
|
|
|
export async function updateUserPassword(userId: string, password: string): Promise<void> {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { passwordHash: await hashPassword(password) }
|
|
})
|
|
}
|
|
|
|
export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
|
const user = await prisma.user.findFirst({
|
|
where: { id: userId, isActive: true },
|
|
select: { passwordHash: true }
|
|
})
|
|
|
|
if (!user) return false
|
|
|
|
const passwordOk = await verifyPassword(currentPassword, user.passwordHash)
|
|
if (!passwordOk) return false
|
|
|
|
await updateUserPassword(userId, newPassword)
|
|
return true
|
|
}
|
|
|
|
export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> {
|
|
if (isR2PublicFilesConfigured()) {
|
|
await uploadPublicFile({
|
|
key: userAvatarKey(userId),
|
|
body: imageData,
|
|
contentType: mimeType
|
|
})
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
profileImageData: null,
|
|
profileImageMime: mimeType,
|
|
profileImageUpdatedAt: new Date()
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
profileImageData: Buffer.from(imageData),
|
|
profileImageMime: mimeType,
|
|
profileImageUpdatedAt: new Date()
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function deleteUserProfileImage(userId: string): Promise<void> {
|
|
await deletePublicFile(userAvatarKey(userId)).catch(() => undefined)
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
profileImageData: null,
|
|
profileImageMime: null,
|
|
profileImageUpdatedAt: null
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function findUserProfileImage(userId: string): Promise<{ data?: Buffer; url?: string; mimeType: string; updatedAt: Date | null } | null> {
|
|
const user = await prisma.user.findFirst({
|
|
where: { id: userId, isActive: true },
|
|
select: {
|
|
profileImageData: true,
|
|
profileImageMime: true,
|
|
profileImageUpdatedAt: true
|
|
}
|
|
})
|
|
|
|
if (!user?.profileImageMime) return null
|
|
|
|
const storedUrl = publicFileUrl(userAvatarKey(userId))
|
|
if (!user.profileImageData && storedUrl) {
|
|
return {
|
|
url: storedUrl,
|
|
mimeType: user.profileImageMime,
|
|
updatedAt: user.profileImageUpdatedAt
|
|
}
|
|
}
|
|
|
|
if (!user.profileImageData) return null
|
|
|
|
return {
|
|
data: Buffer.from(user.profileImageData),
|
|
mimeType: user.profileImageMime,
|
|
updatedAt: user.profileImageUpdatedAt
|
|
}
|
|
}
|
|
|
|
export async function findUserById(id: string): Promise<AuthUser | null> {
|
|
const user = await prisma.user.findFirst({
|
|
where: { id, isActive: true },
|
|
select: userSelect()
|
|
})
|
|
|
|
return user ? toAuthUser(user) : null
|
|
}
|
|
|
|
export async function listOrganizations() {
|
|
return prisma.organization.findMany({
|
|
where: { isActive: true },
|
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
domain: true,
|
|
createdAt: true
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
|
|
const instructorInviteCode = await createInstructorInviteCode()
|
|
return prisma.organization.create({
|
|
data: {
|
|
name: input.name.trim(),
|
|
type: input.type,
|
|
domain: input.domain?.trim() || null,
|
|
instructorInviteCode
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
domain: true,
|
|
createdAt: true
|
|
}
|
|
})
|
|
}
|
|
|
|
async function createInstructorInviteCode(): Promise<string> {
|
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const code = Array.from(randomBytes(6), (byte) => alphabet[byte % alphabet.length]).join('')
|
|
const existing = await prisma.organization.findUnique({
|
|
where: { instructorInviteCode: code },
|
|
select: { id: true }
|
|
})
|
|
if (!existing) return code
|
|
}
|
|
|
|
throw new Error('failed_to_generate_instructor_invite_code')
|
|
}
|
|
|
|
export async function assignUserAffiliation(input: {
|
|
userId: string
|
|
organizationId: string
|
|
department?: string | null
|
|
position?: string | null
|
|
isPrimary?: boolean
|
|
}) {
|
|
return prisma.$transaction(async (tx) => {
|
|
if (input.isPrimary) {
|
|
await tx.userAffiliation.updateMany({
|
|
where: { userId: input.userId, endsAt: null },
|
|
data: { isPrimary: false }
|
|
})
|
|
}
|
|
|
|
return tx.userAffiliation.create({
|
|
data: {
|
|
userId: input.userId,
|
|
organizationId: input.organizationId,
|
|
department: input.department?.trim() || null,
|
|
position: input.position?.trim() || null,
|
|
isPrimary: input.isPrimary ?? true
|
|
},
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
organizationId: true,
|
|
department: true,
|
|
position: true,
|
|
isPrimary: true,
|
|
organization: {
|
|
select: {
|
|
name: true,
|
|
type: true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function markLastLogin(userId: string, loggedInAt = new Date()): Promise<void> {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { lastLoginAt: loggedInAt }
|
|
})
|
|
}
|
|
|
|
export async function isLoginBlocked(email: string): Promise<boolean> {
|
|
const attempts = Number(await redis.get(loginAttemptKey(email)))
|
|
return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts
|
|
}
|
|
|
|
export async function recordFailedLogin(email: string): Promise<void> {
|
|
const attempts = await redis.incr(loginAttemptKey(email))
|
|
if (attempts === 1) {
|
|
await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds)
|
|
}
|
|
}
|
|
|
|
export async function clearLoginAttempts(email: string): Promise<void> {
|
|
await redis.del(loginAttemptKey(email))
|
|
}
|
|
|
|
export async function storeEmailVerificationCode(email: string, code: string): Promise<void> {
|
|
await redis.set(
|
|
emailVerificationKey(email),
|
|
JSON.stringify({ code, email: email.toLowerCase() }),
|
|
'EX',
|
|
config.emailVerificationTtlSeconds
|
|
)
|
|
}
|
|
|
|
export async function verifyEmailVerificationCode(email: string, code: string): Promise<boolean> {
|
|
const key = emailVerificationKey(email)
|
|
const tokenJson = await redis.get(key)
|
|
if (typeof tokenJson !== 'string') return false
|
|
|
|
const token = parseStoredEmailVerification(tokenJson)
|
|
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
|
|
|
|
await redis.del(key)
|
|
return true
|
|
}
|
|
|
|
export async function storePasswordResetCode(email: string, code: string): Promise<void> {
|
|
await redis.set(
|
|
passwordResetKey(email),
|
|
JSON.stringify({ code, email: email.toLowerCase() }),
|
|
'EX',
|
|
config.emailVerificationTtlSeconds
|
|
)
|
|
}
|
|
|
|
export async function verifyPasswordResetCode(email: string, code: string): Promise<boolean> {
|
|
const key = passwordResetKey(email)
|
|
const tokenJson = await redis.get(key)
|
|
if (typeof tokenJson !== 'string') return false
|
|
|
|
const token = parseStoredEmailVerification(tokenJson)
|
|
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
|
|
|
|
await redis.del(key)
|
|
return true
|
|
}
|
|
|
|
export async function createRefreshToken(userId: string): Promise<string> {
|
|
const token = createOpaqueToken()
|
|
const expiresAt = new Date(Date.now() + config.refreshTokenTtlSeconds * 1000)
|
|
|
|
await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds)
|
|
|
|
await prisma.$transaction([
|
|
prisma.refreshToken.updateMany({
|
|
where: {
|
|
userId,
|
|
revokedAt: null,
|
|
expiresAt: { gt: new Date() }
|
|
},
|
|
data: { revokedAt: new Date() }
|
|
}),
|
|
prisma.refreshToken.create({
|
|
data: {
|
|
userId,
|
|
tokenHash: hashToken(token),
|
|
expiresAt
|
|
}
|
|
})
|
|
])
|
|
|
|
return token
|
|
}
|
|
|
|
export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser | null> {
|
|
const tokenHash = hashToken(refreshToken)
|
|
const tokenJson = await redis.send('GETDEL', [refreshTokenKey(refreshToken)])
|
|
if (typeof tokenJson !== 'string') return null
|
|
|
|
const token = parseStoredRefreshToken(tokenJson)
|
|
if (!token) return null
|
|
|
|
const storedToken = await prisma.refreshToken.findUnique({
|
|
where: { tokenHash },
|
|
select: { id: true, userId: true, revokedAt: true, expiresAt: true }
|
|
})
|
|
|
|
if (!storedToken || storedToken.userId !== token.userId || storedToken.revokedAt || storedToken.expiresAt <= new Date()) {
|
|
return null
|
|
}
|
|
|
|
await prisma.refreshToken.update({
|
|
where: { id: storedToken.id },
|
|
data: { revokedAt: new Date() }
|
|
})
|
|
|
|
return findUserById(token.userId)
|
|
}
|
|
|
|
export async function createOAuthState(redirectUri: string): Promise<string> {
|
|
const state = createOpaqueToken()
|
|
await redis.set(oauthStateKey(state), JSON.stringify({ redirectUri }), 'EX', 10 * 60)
|
|
return state
|
|
}
|
|
|
|
export async function consumeOAuthState(state: string): Promise<{ redirectUri: string } | null> {
|
|
const tokenJson = await redis.send('GETDEL', [oauthStateKey(state)])
|
|
if (!tokenJson) return null
|
|
|
|
try {
|
|
const payload = JSON.parse(String(tokenJson)) as { redirectUri?: unknown }
|
|
return typeof payload.redirectUri === 'string' ? { redirectUri: payload.redirectUri } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function createOAuthLoginToken(userId: string): Promise<string> {
|
|
const token = createOpaqueToken()
|
|
await redis.set(oauthLoginTokenKey(token), JSON.stringify({ userId }), 'EX', config.oauthLoginTokenTtlSeconds)
|
|
return token
|
|
}
|
|
|
|
export async function exchangeOAuthLoginToken(loginToken: string): Promise<AuthUser | null> {
|
|
const tokenJson = await redis.send('GETDEL', [oauthLoginTokenKey(loginToken)])
|
|
if (!tokenJson) return null
|
|
|
|
try {
|
|
const payload = JSON.parse(String(tokenJson)) as { userId?: unknown }
|
|
return typeof payload.userId === 'string' ? findUserById(payload.userId) : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function revokeRefreshToken(refreshToken: string): Promise<void> {
|
|
await Promise.all([
|
|
redis.del(refreshTokenKey(refreshToken)),
|
|
prisma.refreshToken.updateMany({
|
|
where: {
|
|
tokenHash: hashToken(refreshToken),
|
|
revokedAt: null
|
|
},
|
|
data: { revokedAt: new Date() }
|
|
})
|
|
])
|
|
}
|
|
|
|
export async function revokeRefreshTokensForUser(userId: string): Promise<void> {
|
|
await prisma.refreshToken.updateMany({
|
|
where: {
|
|
userId,
|
|
revokedAt: null,
|
|
expiresAt: { gt: new Date() }
|
|
},
|
|
data: { revokedAt: new Date() }
|
|
})
|
|
}
|
|
|
|
export async function createHandoffToken(userId: string, audienceApp: string): Promise<string> {
|
|
const token = createOpaqueToken()
|
|
await redis.set(
|
|
handoffTokenKey(token),
|
|
JSON.stringify({ userId, audienceApp }),
|
|
'EX',
|
|
config.handoffTokenTtlSeconds
|
|
)
|
|
return token
|
|
}
|
|
|
|
export async function exchangeHandoffToken(handoffToken: string, audienceApp?: string): Promise<AuthUser | null> {
|
|
const tokenJson = await redis.send('GETDEL', [handoffTokenKey(handoffToken)])
|
|
if (typeof tokenJson !== 'string') return null
|
|
|
|
const token = parseStoredHandoffToken(tokenJson)
|
|
if (!token || (audienceApp && token.audienceApp !== audienceApp)) return null
|
|
|
|
return findUserById(token.userId)
|
|
}
|
|
|
|
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
|
|
const oauthStateKey = (state: string): string => `auth:oauth:state:${hashToken(state)}`
|
|
const oauthLoginTokenKey = (token: string): string => `auth:oauth:login:${hashToken(token)}`
|
|
|
|
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
|
|
|
|
const loginAttemptKey = (email: string): string => `auth:login_attempt:${hashToken(email.toLowerCase())}`
|
|
|
|
const emailVerificationKey = (email: string): string => `auth:email_verify:${hashToken(email.toLowerCase())}`
|
|
|
|
const passwordResetKey = (email: string): string => `auth:password_reset:${hashToken(email.toLowerCase())}`
|
|
|
|
const parseStoredRefreshToken = (value: string): { userId: string } | null => {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
return typeof parsed.userId === 'string' ? { userId: parsed.userId } : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const parseStoredHandoffToken = (value: string): { userId: string; audienceApp: string } | null => {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
return typeof parsed.userId === 'string' && typeof parsed.audienceApp === 'string'
|
|
? { userId: parsed.userId, audienceApp: parsed.audienceApp }
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const parseStoredEmailVerification = (value: string): { email: string; code: string } | null => {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
return typeof parsed.email === 'string' && typeof parsed.code === 'string'
|
|
? { email: parsed.email, code: parsed.code }
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|