fix oauth

This commit is contained in:
lendry
2026-06-26 09:43:15 +03:00
parent c3b2eb4a50
commit dd4323ba51
7 changed files with 229 additions and 21 deletions

View File

@@ -266,6 +266,7 @@ model OAuthAuthorizationCode {
clientId String
redirectUri String
scopes String[]
nonce String?
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())

View File

@@ -773,7 +773,14 @@ export class AuthGrpcController {
}
@GrpcMethod('OAuthCoreService', 'Authorize')
authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
authorize(command: {
userId: string;
clientId: string;
redirectUri: string;
scope: string;
state?: string;
nonce?: string;
}) {
return this.oauthCore.authorize(command);
}

View File

@@ -6,6 +6,28 @@ 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(
@@ -15,7 +37,14 @@ export class OAuthCoreService {
private readonly oauthIssuer: OAuthIssuerService
) {}
async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
async authorize(command: {
userId: string;
clientId: string;
redirectUri: string;
scope: string;
state?: string;
nonce?: string;
}) {
const user = await this.prisma.user.findFirst({
where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
});
@@ -37,6 +66,7 @@ export class OAuthCoreService {
clientId: client.id,
redirectUri: command.redirectUri,
scopes: command.scope.split(' ').filter(Boolean),
nonce: command.nonce ?? null,
expiresAt: new Date(Date.now() + 5 * 60_000)
}
});
@@ -47,26 +77,50 @@ export class OAuthCoreService {
return { redirectUrl: url.toString(), code, state: command.state };
}
async token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) {
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 (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) {
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);
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 payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(command.refreshToken, { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') });
if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) throw new UnauthorizedException('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 ?? []);
}
@@ -74,21 +128,106 @@ export class OAuthCoreService {
}
async userInfo(accessToken: string) {
const payload = await this.jwt.verifyAsync<{ sub: string }>(accessToken, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET') });
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
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 });
return { sub: user.id, email: user.email, phone: user.phone, name: user.displayName, picture: user.avatarUrl };
const scopes = this.parseScopes(payload.scopes ?? ['openid', 'profile']);
return this.buildUserClaims(user, scopes);
}
private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) {
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 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 idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer });
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 };
}
}