first commit
Some checks failed
Publish / Publish Job (push) Failing after 37s

This commit is contained in:
Дмитрий
2026-03-27 10:38:23 +03:00
commit be1e21e699
21 changed files with 1002 additions and 0 deletions

1
lib/constants/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './passport.constants'

View File

@@ -0,0 +1 @@
export const PASSPORT_OPTIONS = Symbol('PassportOptions')

3
lib/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './interfaces'
export * from './passport.module'
export * from './passport.service'

3
lib/interfaces/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './passport-async-options.interface'
export * from './passport-options.interface'
export * from './token.interface'

View File

@@ -0,0 +1,8 @@
import type { FactoryProvider, ModuleMetadata } from '@nestjs/common'
import { PassportOptions } from './passport-options.interface'
export interface PassportAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useFactory: (...args: any[]) => Promise<PassportOptions> | PassportOptions
inject?: FactoryProvider['inject']
}

View File

@@ -0,0 +1,3 @@
export interface PassportOptions {
secretKey: string
}

View File

@@ -0,0 +1,13 @@
export interface TokenPayload {
sub: string
}
// роли храним в токене
export interface TokenRolePayload {
id: string | number
name: string
}
export type VerifyResult =
| { valid: true; userId: string; jti: string; role: TokenRolePayload }
| { valid: false; reason: string }

34
lib/passport.module.ts Normal file
View File

@@ -0,0 +1,34 @@
import { type DynamicModule, Global, Module } from '@nestjs/common'
import { PASSPORT_OPTIONS } from './constants'
import type { PassportAsyncOptions, PassportOptions } from './interfaces'
import {
createPassportAsyncOptionsProvider,
createPassportOptionsProvider
} from './passport.provider'
import { PassportService } from './passport.service'
@Global()
@Module({})
export class PassportModule {
public static register(options: PassportOptions): DynamicModule {
const optionsProvider = createPassportOptionsProvider(options)
return {
module: PassportModule,
providers: [optionsProvider, PassportService],
exports: [PassportService, PASSPORT_OPTIONS]
}
}
public static registerAsync(options: PassportAsyncOptions): DynamicModule {
const optionsProvider = createPassportAsyncOptionsProvider(options)
return {
module: PassportModule,
imports: options.imports ?? [],
providers: [optionsProvider, PassportService],
exports: [PassportService, PASSPORT_OPTIONS]
}
}
}

33
lib/passport.provider.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { Provider } from '@nestjs/common'
import { PASSPORT_OPTIONS } from './constants'
import type { PassportAsyncOptions, PassportOptions } from './interfaces'
export function createPassportOptionsProvider(
options: PassportOptions
): Provider {
return {
provide: PASSPORT_OPTIONS,
useValue: Object.freeze({ ...options })
}
}
export function createPassportAsyncOptionsProvider(
options: PassportAsyncOptions
): Provider {
return {
provide: PASSPORT_OPTIONS,
useFactory: async (...args: any[]) => {
const resolved = await options.useFactory!(...args)
if (!resolved || typeof resolved.secretKey !== 'string') {
throw new Error(
'[Passport Module] "secretKey" обязателен для заполнения и должен быть строкой.'
)
}
return Object.freeze({ ...resolved })
},
inject: options.inject ?? []
}
}

124
lib/passport.service.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Inject, Injectable } from '@nestjs/common'
import { createHmac, randomUUID } from 'crypto'
import { PASSPORT_OPTIONS } from './constants'
import { PassportOptions, TokenRolePayload } from './interfaces'
import { base64UrlDecode, base64UrlEncode, constantTimeEqual } from './utils'
const TOKEN_TYPES = {
AUTH: 'PassportToken/v1',
TEMP_2FA: 'PassportToken/v1/2fa'
} as const
type TokenType = (typeof TOKEN_TYPES)[keyof typeof TOKEN_TYPES]
@Injectable()
export class PassportService {
private readonly SECRET_KEY: string
private static readonly INTERNAL_SEPARATOR = '|'
public constructor(
@Inject(PASSPORT_OPTIONS) private readonly options: PassportOptions
) {
this.SECRET_KEY = options.secretKey
}
public generate(
userId: string,
role: TokenRolePayload,
ttl: number,
tokenType: TokenType = TOKEN_TYPES.AUTH
) {
const issuedAt = this.now()
const expiresAt = issuedAt + ttl
const jti = randomUUID()
const userPart = base64UrlEncode(userId)
const rolePart = base64UrlEncode(JSON.stringify(role))
const iatPart = base64UrlEncode(String(issuedAt))
const expPart = base64UrlEncode(String(expiresAt))
const jtiPart = base64UrlEncode(jti)
const serialized = this.serialize(
tokenType,
userPart,
rolePart,
iatPart,
expPart,
jtiPart
)
const mac = this.computeHmac(this.SECRET_KEY, serialized)
return `${userPart}.${rolePart}.${iatPart}.${expPart}.${jtiPart}.${mac}`
}
public verify(token: string, expectedType: TokenType = TOKEN_TYPES.AUTH) {
try {
const parts = token.split('.')
if (parts.length !== 6)
return { valid: false, reason: 'Не верный формат токена.' }
const [userPart, rolePart, iatPart, expPart, jtiPart, mac] = parts
const serialized = this.serialize(
expectedType,
userPart,
rolePart,
iatPart,
expPart,
jtiPart
)
const expectedMac = this.computeHmac(this.SECRET_KEY, serialized)
if (!constantTimeEqual(expectedMac, mac))
return {
valid: false,
reason: 'Невалидная подпись токена или неверный формат.'
}
const expNumber = Number(base64UrlDecode(expPart))
if (!Number.isFinite(expNumber))
return { valid: false, reason: 'Ошибка формата даты.' }
if (this.now() > expNumber)
return { valid: false, reason: 'Срок действия токена истёк.' }
const decodedRole = JSON.parse(
base64UrlDecode(rolePart)
) as TokenRolePayload
return {
valid: true,
userId: base64UrlDecode(userPart),
role: decodedRole,
jti: base64UrlDecode(jtiPart)
}
} catch (error) {
return { valid: false, reason: 'Поврежденный токен.' }
}
}
private now() {
return Math.floor(Date.now() / 1000)
}
private serialize(
domain: string,
user: string,
role: string,
iat: string,
exp: string,
jti: string
) {
return [domain, user, role, iat, exp, jti].join(
PassportService.INTERNAL_SEPARATOR
)
}
private computeHmac(secretKey: string, data: string) {
return createHmac('sha256', secretKey).update(data).digest('hex')
}
}

17
lib/utils/base64.ts Normal file
View File

@@ -0,0 +1,17 @@
export function base64UrlEncode(buf: Buffer | string) {
const s = typeof buf === 'string' ? Buffer.from(buf) : buf
return s
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
export function base64UrlDecode(str: string) {
str = str.replace(/-/g, '+').replace(/_/g, '/')
while (str.length % 4) str += '='
return Buffer.from(str, 'base64').toString()
}

10
lib/utils/crypto.ts Normal file
View File

@@ -0,0 +1,10 @@
import { timingSafeEqual } from 'crypto'
export function constantTimeEqual(a: string, b: string) {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}

2
lib/utils/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './base64'
export * from './crypto'