372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { PrismaService } from '../infra/prisma.service';
|
|
import { OAuthIssuerService } from './oauth-issuer.service';
|
|
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
|
|
|
|
type OAuthUser = {
|
|
id: string;
|
|
email: string | null;
|
|
phone: string | null;
|
|
displayName: string;
|
|
username: string | null;
|
|
avatarUrl: string | null;
|
|
isVerified: boolean;
|
|
};
|
|
|
|
type OidcScopeClaims = {
|
|
sub: string;
|
|
name?: string;
|
|
picture?: string;
|
|
preferredUsername?: string;
|
|
email?: string;
|
|
emailVerified?: boolean;
|
|
phone?: string;
|
|
phoneNumber?: string;
|
|
phoneNumberVerified?: boolean;
|
|
};
|
|
|
|
@Injectable()
|
|
export class OAuthCoreService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwt: JwtService,
|
|
private readonly config: ConfigService,
|
|
private readonly oauthIssuer: OAuthIssuerService
|
|
) {}
|
|
|
|
async authorize(command: {
|
|
userId: string;
|
|
clientId: string;
|
|
redirectUri: string;
|
|
scope: string;
|
|
state?: string;
|
|
nonce?: string;
|
|
grantConsent?: boolean;
|
|
}) {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
|
|
});
|
|
if (!user) {
|
|
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
|
}
|
|
assertHumanAccount(user, { asAuth: true });
|
|
|
|
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
|
if (!client?.isActive || !client.redirectUris.includes(command.redirectUri)) {
|
|
throw new BadRequestException('OAuth приложение не найдено или redirect_uri запрещен');
|
|
}
|
|
|
|
if (command.grantConsent) {
|
|
await this.persistConsent(command.userId, client.id, command.scope);
|
|
} else {
|
|
const consent = await this.checkConsent(command.userId, command.clientId, command.scope);
|
|
if (!consent.granted) {
|
|
throw new BadRequestException('Согласие пользователя на доступ не получено');
|
|
}
|
|
}
|
|
|
|
const code = randomBytes(32).toString('base64url');
|
|
await this.prisma.oAuthAuthorizationCode.create({
|
|
data: {
|
|
code,
|
|
userId: command.userId,
|
|
clientId: client.id,
|
|
redirectUri: command.redirectUri,
|
|
scopes: command.scope.split(' ').filter(Boolean),
|
|
nonce: command.nonce ?? null,
|
|
expiresAt: new Date(Date.now() + 5 * 60_000)
|
|
}
|
|
});
|
|
|
|
const url = new URL(command.redirectUri);
|
|
url.searchParams.set('code', code);
|
|
if (command.state) url.searchParams.set('state', command.state);
|
|
return { redirectUrl: url.toString(), code, state: command.state };
|
|
}
|
|
|
|
async token(command: {
|
|
grantType: string;
|
|
code?: string;
|
|
refreshToken?: string;
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
redirectUri?: string;
|
|
}) {
|
|
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
|
if (!client?.isActive) throw new UnauthorizedException('OAuth приложение не найдено');
|
|
if (client.clientSecret && client.clientSecret !== command.clientSecret) {
|
|
throw new UnauthorizedException('Неверный client_secret');
|
|
}
|
|
|
|
if (command.grantType === 'authorization_code') {
|
|
if (!command.code || !command.redirectUri) throw new BadRequestException('Передайте code и redirect_uri');
|
|
const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({
|
|
where: { code: command.code },
|
|
include: { user: true }
|
|
});
|
|
if (
|
|
!authCode ||
|
|
authCode.consumedAt ||
|
|
authCode.expiresAt < new Date() ||
|
|
authCode.redirectUri !== command.redirectUri ||
|
|
authCode.clientId !== client.id
|
|
) {
|
|
throw new UnauthorizedException('Код авторизации недействителен');
|
|
}
|
|
await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } });
|
|
assertHumanAccount(authCode.user, { asAuth: true });
|
|
return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes, authCode.nonce ?? undefined);
|
|
}
|
|
|
|
if (command.grantType === 'refresh_token') {
|
|
if (!command.refreshToken) throw new BadRequestException('Передайте refresh_token');
|
|
const issuer = await this.oauthIssuer.resolveIssuer();
|
|
const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(
|
|
command.refreshToken,
|
|
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), issuer }
|
|
);
|
|
if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) {
|
|
throw new UnauthorizedException('Refresh token недействителен');
|
|
}
|
|
return this.issueOAuthTokens(payload.sub, client.clientId, payload.scopes ?? []);
|
|
}
|
|
|
|
throw new BadRequestException('Неподдерживаемый grant_type');
|
|
}
|
|
|
|
async userInfo(accessToken: string) {
|
|
const issuer = await this.oauthIssuer.resolveIssuer();
|
|
let payload: { sub: string; scopes?: string[]; typ?: string };
|
|
try {
|
|
payload = await this.jwt.verifyAsync(accessToken, {
|
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
|
issuer
|
|
});
|
|
} catch {
|
|
throw new UnauthorizedException('Токен доступа недействителен');
|
|
}
|
|
|
|
if (payload.typ && payload.typ !== 'access_token') {
|
|
throw new UnauthorizedException('Неверный тип токена');
|
|
}
|
|
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: payload.sub, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
|
|
});
|
|
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
|
assertHumanAccount(user, { asAuth: true });
|
|
|
|
const scopes = this.parseScopes(payload.scopes ?? ['openid', 'profile']);
|
|
return this.buildUserClaims(user, scopes);
|
|
}
|
|
|
|
private parseScopes(scopes: string[] | undefined) {
|
|
return new Set((scopes ?? []).filter(Boolean));
|
|
}
|
|
|
|
private buildUserClaims(user: OAuthUser, scopes: Set<string>): OidcScopeClaims {
|
|
const claims: OidcScopeClaims = { sub: user.id };
|
|
|
|
if (scopes.has('profile')) {
|
|
claims.name = user.displayName;
|
|
claims.preferredUsername = user.username ?? user.displayName;
|
|
if (user.avatarUrl) {
|
|
claims.picture = user.avatarUrl;
|
|
}
|
|
}
|
|
|
|
if (scopes.has('email') && user.email) {
|
|
claims.email = user.email;
|
|
claims.emailVerified = user.isVerified;
|
|
}
|
|
|
|
if (scopes.has('phone') && user.phone) {
|
|
claims.phone = user.phone;
|
|
claims.phoneNumber = user.phone;
|
|
claims.phoneNumberVerified = user.isVerified;
|
|
}
|
|
|
|
return claims;
|
|
}
|
|
|
|
private async issueOAuthTokens(userId: string, clientId: string, scopes: string[], nonce?: string) {
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
|
assertHumanAccount(user, { asAuth: true });
|
|
|
|
const issuer = await this.oauthIssuer.resolveIssuer();
|
|
const scopeSet = this.parseScopes(scopes);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const profileClaims = this.buildUserClaims(user, scopeSet);
|
|
|
|
const accessToken = await this.jwt.signAsync(
|
|
{ sub: userId, aud: clientId, scopes, typ: 'access_token' },
|
|
{ secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer }
|
|
);
|
|
const refreshToken = await this.jwt.signAsync(
|
|
{ sub: userId, aud: clientId, scopes, typ: 'refresh_token' },
|
|
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer }
|
|
);
|
|
|
|
const idTokenClaims: Record<string, unknown> = {
|
|
sub: userId,
|
|
aud: clientId,
|
|
iat: now,
|
|
auth_time: now
|
|
};
|
|
if (nonce) {
|
|
idTokenClaims.nonce = nonce;
|
|
}
|
|
if (profileClaims.name) idTokenClaims.name = profileClaims.name;
|
|
if (profileClaims.preferredUsername) idTokenClaims.preferred_username = profileClaims.preferredUsername;
|
|
if (profileClaims.picture) idTokenClaims.picture = profileClaims.picture;
|
|
if (profileClaims.email) {
|
|
idTokenClaims.email = profileClaims.email;
|
|
idTokenClaims.email_verified = profileClaims.emailVerified ?? false;
|
|
}
|
|
if (profileClaims.phoneNumber) {
|
|
idTokenClaims.phone_number = profileClaims.phoneNumber;
|
|
idTokenClaims.phone_number_verified = profileClaims.phoneNumberVerified ?? false;
|
|
}
|
|
|
|
const idToken = await this.jwt.signAsync(idTokenClaims, {
|
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
|
expiresIn: '15m',
|
|
issuer
|
|
});
|
|
|
|
return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken };
|
|
}
|
|
|
|
async checkConsent(userId: string, clientId: string, scope: string) {
|
|
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
|
|
if (!client?.isActive) {
|
|
throw new BadRequestException('OAuth приложение не найдено');
|
|
}
|
|
|
|
const requestedSlugs = this.parseScopeSlugs(scope);
|
|
const requestedScopes = await this.resolveScopeInfos(requestedSlugs);
|
|
const grantedSlugs = await this.loadGrantedScopeSlugs(userId, client.id);
|
|
const granted = requestedSlugs.every((slug) => grantedSlugs.has(slug));
|
|
|
|
return {
|
|
granted,
|
|
client: { clientId: client.clientId, name: client.name },
|
|
requestedScopes
|
|
};
|
|
}
|
|
|
|
async grantConsent(userId: string, clientId: string, scope: string) {
|
|
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
|
|
if (!client?.isActive) {
|
|
throw new BadRequestException('OAuth приложение не найдено');
|
|
}
|
|
|
|
await this.persistConsent(userId, client.id, scope);
|
|
return { granted: true };
|
|
}
|
|
|
|
async listUserConsents(userId: string) {
|
|
const consents = await this.prisma.oAuthConsent.findMany({
|
|
where: { userId },
|
|
include: {
|
|
client: true,
|
|
scopes: { include: { scope: true } }
|
|
},
|
|
orderBy: { updatedAt: 'desc' }
|
|
});
|
|
|
|
return {
|
|
consents: consents.map((consent) => ({
|
|
id: consent.id,
|
|
clientId: consent.client.clientId,
|
|
clientName: consent.client.name,
|
|
scopes: consent.scopes.map((item) => ({
|
|
slug: item.scope.slug,
|
|
name: item.scope.name,
|
|
description: item.scope.description ?? undefined
|
|
})),
|
|
grantedAt: consent.createdAt.toISOString(),
|
|
updatedAt: consent.updatedAt.toISOString()
|
|
}))
|
|
};
|
|
}
|
|
|
|
async revokeConsent(userId: string, consentId: string) {
|
|
const consent = await this.prisma.oAuthConsent.findFirst({
|
|
where: { id: consentId, userId }
|
|
});
|
|
if (!consent) {
|
|
throw new BadRequestException('Согласие не найдено');
|
|
}
|
|
|
|
await this.prisma.oAuthConsent.delete({ where: { id: consent.id } });
|
|
return { count: 1 };
|
|
}
|
|
|
|
async getClientPublicInfo(clientId: string) {
|
|
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
|
|
if (!client?.isActive) {
|
|
throw new BadRequestException('OAuth приложение не найдено');
|
|
}
|
|
|
|
return { clientId: client.clientId, name: client.name };
|
|
}
|
|
|
|
async issueFedcmTokens(userId: string, clientId: string, scopes: string[]) {
|
|
return this.issueOAuthTokens(userId, clientId, scopes);
|
|
}
|
|
|
|
private parseScopeSlugs(scope: string) {
|
|
return [...new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean))];
|
|
}
|
|
|
|
private async resolveScopeInfos(slugs: string[]) {
|
|
if (slugs.length === 0) {
|
|
return [{ slug: 'openid', name: 'OpenID', description: 'Базовая идентификация OpenID Connect' }];
|
|
}
|
|
|
|
const records = await this.prisma.oAuthScope.findMany({ where: { slug: { in: slugs } } });
|
|
const bySlug = new Map(records.map((item) => [item.slug, item]));
|
|
|
|
return slugs.map((slug) => {
|
|
const record = bySlug.get(slug);
|
|
return {
|
|
slug,
|
|
name: record?.name ?? slug,
|
|
description: record?.description ?? undefined
|
|
};
|
|
});
|
|
}
|
|
|
|
private async loadGrantedScopeSlugs(userId: string, clientInternalId: string) {
|
|
const consent = await this.prisma.oAuthConsent.findUnique({
|
|
where: { userId_clientId: { userId, clientId: clientInternalId } },
|
|
include: { scopes: { include: { scope: true } } }
|
|
});
|
|
return new Set(consent?.scopes.map((item) => item.scope.slug) ?? []);
|
|
}
|
|
|
|
private async persistConsent(userId: string, clientInternalId: string, scope: string) {
|
|
const slugs = this.parseScopeSlugs(scope);
|
|
const scopeRecords = await this.prisma.oAuthScope.findMany({ where: { slug: { in: slugs } } });
|
|
|
|
const consent = await this.prisma.oAuthConsent.upsert({
|
|
where: { userId_clientId: { userId, clientId: clientInternalId } },
|
|
create: { userId, clientId: clientInternalId },
|
|
update: { updatedAt: new Date() }
|
|
});
|
|
|
|
for (const scopeRecord of scopeRecords) {
|
|
await this.prisma.oAuthConsentScope.upsert({
|
|
where: { consentId_scopeId: { consentId: consent.id, scopeId: scopeRecord.id } },
|
|
create: { consentId: consent.id, scopeId: scopeRecord.id },
|
|
update: {}
|
|
});
|
|
}
|
|
}
|
|
}
|