From 489b4d4a23e751fe640a24da33e3fb4b43a7bc25 Mon Sep 17 00:00:00 2001 From: lendry Date: Fri, 26 Jun 2026 11:00:48 +0300 Subject: [PATCH] change public app name for oauth --- .../src/controllers/oauth.controller.ts | 25 ++++++-- apps/api-gateway/src/lib/oauth-params.ts | 60 +++++++++++++++++++ .../app/auth/oauth/authorize/page.tsx | 26 +++++++- apps/frontend/app/data/consents/page.tsx | 4 +- apps/frontend/lib/api.ts | 4 ++ .../src/domain/auth-grpc.controller.ts | 5 ++ .../sso-core/src/domain/oauth-core.service.ts | 9 +++ shared/proto/identity.proto | 5 ++ 8 files changed, 130 insertions(+), 8 deletions(-) diff --git a/apps/api-gateway/src/controllers/oauth.controller.ts b/apps/api-gateway/src/controllers/oauth.controller.ts index c96df00..070fc9d 100644 --- a/apps/api-gateway/src/controllers/oauth.controller.ts +++ b/apps/api-gateway/src/controllers/oauth.controller.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { OAuthAuthorizeIncomingDto, OAuthConsentActionDto, OAuthTokenIncomingDto } from '../dto/oauth.dto'; import { extractBearerToken } from '../auth-token'; -import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeConsentQuery, normalizeTokenBody } from '../lib/oauth-params'; +import { appendQueryParams, mapOAuthClientPublicInfo, mapOAuthConsentCheckResponse, mapUserInfoToOidc, mapUserOAuthConsentsResponse, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeConsentQuery, normalizeTokenBody } from '../lib/oauth-params'; import { resolveFrontendUrl } from '../lib/oauth-issuer'; import { verifyAccessToken } from '../session-auth'; @@ -87,13 +87,14 @@ export class OAuthController { if (!consentCheck.granted) { if (wantsJson) { - return (await firstValueFrom( + const pendingConsent = (await firstValueFrom( this.core.oauth.CheckOAuthConsent({ userId, clientId: normalized.clientId, scope: normalized.scope }) )) as Record; + return mapOAuthConsentCheckResponse(pendingConsent); } const frontendUrl = await resolveFrontendUrl(this.core); const consentQuery = { ...(query as Record) }; @@ -139,13 +140,26 @@ export class OAuthController { ) { const payload = await verifyAccessToken(this.jwt, authorization); const normalized = normalizeConsentQuery(query as Record); - return firstValueFrom( + const result = (await firstValueFrom( this.core.oauth.CheckOAuthConsent({ userId: payload.sub, clientId: normalized.clientId, scope: normalized.scope }) - ); + )) as Record; + return mapOAuthConsentCheckResponse(result); + } + + @Get('clients/:clientId/public') + @ApiOperation({ + summary: 'Публичная информация об OAuth-приложении', + description: 'Возвращает человекочитаемое название приложения для экрана согласия.' + }) + async getClientPublicInfo(@Param('clientId') clientId: string) { + const result = (await firstValueFrom( + this.core.oauth.GetOAuthClientPublicInfo({ clientId }) + )) as Record; + return mapOAuthClientPublicInfo(result); } @Post('consent/approve') @@ -186,7 +200,8 @@ export class OAuthController { if (payload.sub !== userId) { throw new UnauthorizedException('Можно просматривать только свои согласия'); } - return firstValueFrom(this.core.oauth.ListUserOAuthConsents({ userId })); + const result = (await firstValueFrom(this.core.oauth.ListUserOAuthConsents({ userId }))) as Record; + return mapUserOAuthConsentsResponse(result); } @Delete('consents/users/:userId/:consentId') diff --git a/apps/api-gateway/src/lib/oauth-params.ts b/apps/api-gateway/src/lib/oauth-params.ts index 04a4444..7758d21 100644 --- a/apps/api-gateway/src/lib/oauth-params.ts +++ b/apps/api-gateway/src/lib/oauth-params.ts @@ -186,3 +186,63 @@ export function mergeTokenCredentials( clientSecret: body.clientSecret || basic.clientSecret }; } + +function readRecord(value: unknown) { + return value && typeof value === 'object' ? (value as Record) : null; +} + +export function mapOAuthClientPublicInfo(raw: Record) { + return { + clientId: String(raw.clientId ?? raw.client_id ?? ''), + name: String(raw.name ?? '') + }; +} + +export function mapOAuthScopeInfo(raw: Record) { + const slug = String(raw.slug ?? ''); + return { + slug, + name: String(raw.name ?? slug), + description: raw.description ? String(raw.description) : undefined + }; +} + +export function mapOAuthConsentCheckResponse(raw: Record) { + const clientRaw = readRecord(raw.client); + const scopesRaw = Array.isArray(raw.requestedScopes) + ? raw.requestedScopes + : Array.isArray(raw.requested_scopes) + ? raw.requested_scopes + : []; + + return { + granted: Boolean(raw.granted), + client: clientRaw + ? mapOAuthClientPublicInfo(clientRaw) + : undefined, + requestedScopes: scopesRaw + .map((item) => readRecord(item)) + .filter((item): item is Record => Boolean(item)) + .map(mapOAuthScopeInfo) + }; +} + +export function mapUserOAuthConsentsResponse(raw: Record) { + const consentsRaw = Array.isArray(raw.consents) ? raw.consents : []; + return { + consents: consentsRaw + .map((item) => readRecord(item)) + .filter((item): item is Record => Boolean(item)) + .map((consent) => ({ + id: String(consent.id ?? ''), + clientId: String(consent.clientId ?? consent.client_id ?? ''), + clientName: String(consent.clientName ?? consent.client_name ?? consent.clientId ?? consent.client_id ?? ''), + scopes: (Array.isArray(consent.scopes) ? consent.scopes : []) + .map((item) => readRecord(item)) + .filter((item): item is Record => Boolean(item)) + .map(mapOAuthScopeInfo), + grantedAt: String(consent.grantedAt ?? consent.granted_at ?? ''), + updatedAt: String(consent.updatedAt ?? consent.updated_at ?? '') + })) + }; +} diff --git a/apps/frontend/app/auth/oauth/authorize/page.tsx b/apps/frontend/app/auth/oauth/authorize/page.tsx index 6d93515..86c6a5e 100644 --- a/apps/frontend/app/auth/oauth/authorize/page.tsx +++ b/apps/frontend/app/auth/oauth/authorize/page.tsx @@ -6,11 +6,13 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { CheckCircle2, Loader2, ShieldCheck } from 'lucide-react'; import { BrandLogo } from '@/components/id/brand-logo'; import { useAuth } from '@/components/id/auth-provider'; +import { usePublicSettings } from '@/components/id/public-settings-provider'; import { Button } from '@/components/ui/button'; import { approveOAuthAuthorization, checkOAuthConsent, fetchAuthSession, + fetchOAuthClientPublicInfo, type OAuthConsentCheckResponse } from '@/lib/api'; @@ -26,11 +28,13 @@ function OAuthAuthorizeContent() { const searchParams = useSearchParams(); const router = useRouter(); const { user, token, isPinLocked, isLoading } = useAuth(); + const { projectName } = usePublicSettings(); const [submitting, setSubmitting] = useState(false); const [checkingConsent, setCheckingConsent] = useState(false); const [error, setError] = useState(null); const [sessionChecked, setSessionChecked] = useState(false); const [consentInfo, setConsentInfo] = useState(null); + const [clientName, setClientName] = useState(null); const autoApproveStartedRef = useRef(false); const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]); @@ -42,7 +46,22 @@ function OAuthAuthorizeContent() { const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]); - const clientLabel = consentInfo?.client?.name ?? clientId ?? 'Приложение'; + const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение'; + + useEffect(() => { + if (!clientId) return; + let cancelled = false; + void fetchOAuthClientPublicInfo(clientId) + .then((info) => { + if (!cancelled && info.name.trim()) { + setClientName(info.name.trim()); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [clientId]); useEffect(() => { if (!searchParams.has('userId')) return; @@ -107,6 +126,9 @@ function OAuthAuthorizeContent() { .then((result) => { if (cancelled) return; setConsentInfo(result); + if (result.client?.name?.trim()) { + setClientName(result.client.name.trim()); + } if (result.granted && !autoApproveStartedRef.current) { autoApproveStartedRef.current = true; void approve(); @@ -193,7 +215,7 @@ function OAuthAuthorizeContent() {

Разрешить доступ?

- Приложение {clientLabel} запрашивает доступ к данным вашего аккаунта Lendry ID. + Приложение {clientLabel} запрашивает доступ к данным вашего аккаунта {projectName}.

diff --git a/apps/frontend/app/data/consents/page.tsx b/apps/frontend/app/data/consents/page.tsx index 42927fe..6e36a4c 100644 --- a/apps/frontend/app/data/consents/page.tsx +++ b/apps/frontend/app/data/consents/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { Ban, CheckCircle2, ChevronRight, FileKey2, Loader2 } from 'lucide-react'; import { useAuth } from '@/components/id/auth-provider'; import { IdShell } from '@/components/id/shell'; +import { usePublicSettings } from '@/components/id/public-settings-provider'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -29,6 +30,7 @@ function formatGrantedAt(value: string) { export default function DataConsentsPage() { const router = useRouter(); const { user, token } = useAuth(); + const { projectName } = usePublicSettings(); const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); const [consents, setConsents] = useState([]); @@ -126,7 +128,7 @@ export default function DataConsentsPage() { Выданы {formatGrantedAt(selectedConsent.grantedAt)}

-

API Lendry ID

+

API {projectName}

    {selectedConsent.scopes.map((scope) => (
  • diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 139e24f..e91bc46 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -458,6 +458,10 @@ export async function checkOAuthConsent(params: URLSearchParams, token: string) return apiFetch(`/oauth/consent/check?${query.toString()}`, {}, token); } +export async function fetchOAuthClientPublicInfo(clientId: string) { + return apiFetch<{ clientId: string; name: string }>(`/oauth/clients/${encodeURIComponent(clientId)}/public`); +} + export async function fetchUserOAuthConsents(userId: string, token: string) { return apiFetch<{ consents?: OAuthUserConsent[] }>(`/oauth/consents/users/${userId}`, {}, token); } diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 82ca1f5..b3afe4c 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -826,6 +826,11 @@ export class AuthGrpcController { return this.oauthCore.revokeConsent(command.userId, command.consentId); } + @GrpcMethod('OAuthCoreService', 'GetOAuthClientPublicInfo') + getOAuthClientPublicInfo(command: { clientId: string }) { + return this.oauthCore.getClientPublicInfo(command.clientId); + } + @GrpcMethod('OtpService', 'SendOtp') sendOtp(command: { target: string; channel: string; purpose: string; userId?: string }) { return this.otp.send(command); diff --git a/apps/sso-core/src/domain/oauth-core.service.ts b/apps/sso-core/src/domain/oauth-core.service.ts index 5ff134a..f8b019e 100644 --- a/apps/sso-core/src/domain/oauth-core.service.ts +++ b/apps/sso-core/src/domain/oauth-core.service.ts @@ -307,6 +307,15 @@ export class OAuthCoreService { 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 }; + } + private parseScopeSlugs(scope: string) { return [...new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean))]; } diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index 3efee29..70e1f27 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -10,6 +10,7 @@ service OAuthCoreService { rpc GrantOAuthConsent (GrantOAuthConsentRequest) returns (GrantOAuthConsentResponse); rpc ListUserOAuthConsents (UserIdRequest) returns (ListUserOAuthConsentsResponse); rpc RevokeOAuthConsent (RevokeOAuthConsentRequest) returns (MutationResponse); + rpc GetOAuthClientPublicInfo (GetOAuthClientPublicInfoRequest) returns (OAuthClientPublicInfo); } service OtpService { @@ -102,6 +103,10 @@ message OAuthClientPublicInfo { string name = 2; } +message GetOAuthClientPublicInfoRequest { + string clientId = 1; +} + message CheckOAuthConsentRequest { string userId = 1; string clientId = 2;