diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 5d658f5..aba81cc 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -14,6 +14,7 @@ import { ProfileController } from './controllers/profile.controller'; import { DocumentsController } from './controllers/documents.controller'; import { AddressesController } from './controllers/addresses.controller'; import { OAuthController } from './controllers/oauth.controller'; +import { WellKnownController, OAuthAuthorizeDiscoveryController } from './controllers/well-known.controller'; import { AdvancedAuthController } from './controllers/advanced-auth.controller'; import { FamilyController } from './controllers/family.controller'; import { ChatController } from './controllers/chat.controller'; @@ -54,7 +55,7 @@ import { AdminGuard, SuperAdminGuard } from './guards/admin.guard'; } ]) ], - controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController], + controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController], providers: [CoreGrpcService, AdminGuard, SuperAdminGuard] }) export class AppModule {} diff --git a/apps/api-gateway/src/controllers/well-known.controller.ts b/apps/api-gateway/src/controllers/well-known.controller.ts new file mode 100644 index 0000000..a2f2fdc --- /dev/null +++ b/apps/api-gateway/src/controllers/well-known.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer'; + +@ApiTags('OpenID Connect') +@Controller('.well-known') +export class WellKnownController { + constructor(private readonly core: CoreGrpcService) {} + + @Get('openid-configuration') + @ApiOperation({ + summary: 'OpenID Connect Discovery', + description: 'Метаданные OIDC-провайдера. Issuer и endpoints берутся из PUBLIC_API_URL в настройках.' + }) + async openIdConfiguration() { + const issuer = await resolveOAuthIssuer(this.core); + return buildOpenIdConfiguration(issuer); + } + + @Get('jwks.json') + @ApiOperation({ + summary: 'JWKS', + description: 'Пустой набор ключей: токены подписываются HS256 на стороне IdP. Для проверки id_token используйте userinfo.' + }) + jwks() { + return { keys: [] }; + } +} + +@ApiTags('OpenID Connect') +@Controller('oauth/authorize') +export class OAuthAuthorizeDiscoveryController { + constructor(private readonly core: CoreGrpcService) {} + + @Get('.well-known/openid-configuration') + @ApiOperation({ + summary: 'OpenID Discovery (alias)', + description: 'Совместимость с клиентами, ошибочно использующими authorization endpoint как issuer.' + }) + async openIdConfigurationAlias() { + const issuer = await resolveOAuthIssuer(this.core); + return buildOpenIdConfiguration(issuer); + } +} diff --git a/apps/api-gateway/src/lib/oauth-issuer.ts b/apps/api-gateway/src/lib/oauth-issuer.ts new file mode 100644 index 0000000..52d1815 --- /dev/null +++ b/apps/api-gateway/src/lib/oauth-issuer.ts @@ -0,0 +1,56 @@ +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; + +function normalizeBaseUrl(url: string) { + return url.trim().replace(/\/+$/, ''); +} + +export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http://localhost:3000'): Promise { + const envIssuer = process.env.PUBLIC_API_URL?.trim(); + try { + const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_API_URL' }))) as { value?: string }; + const fromDb = response.value?.trim(); + if (fromDb) { + return normalizeBaseUrl(fromDb); + } + } catch { + // fallback ниже + } + + if (envIssuer) { + return normalizeBaseUrl(envIssuer); + } + + try { + const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string }; + const domain = response.value?.trim(); + if (domain) { + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return normalizeBaseUrl(domain); + } + return `https://${domain.replace(/^\/+/, '')}`; + } + } catch { + // fallback ниже + } + + return normalizeBaseUrl(fallback); +} + +export function buildOpenIdConfiguration(issuer: string) { + const base = normalizeBaseUrl(issuer); + return { + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + userinfo_endpoint: `${base}/oauth/userinfo`, + jwks_uri: `${base}/.well-known/jwks.json`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['HS256'], + scopes_supported: ['openid', 'profile', 'email', 'phone', 'address', 'documents'], + token_endpoint_auth_methods_supported: ['client_secret_post'], + grant_types_supported: ['authorization_code', 'refresh_token'], + claims_supported: ['sub', 'email', 'phone', 'name', 'picture'] + }; +} diff --git a/apps/docs/components/oauth-code-tabs.tsx b/apps/docs/components/oauth-code-tabs.tsx index 07e9327..324ac57 100644 --- a/apps/docs/components/oauth-code-tabs.tsx +++ b/apps/docs/components/oauth-code-tabs.tsx @@ -1,8 +1,30 @@ 'use client'; -import { oauthExamples } from '@/lib/oauth-examples'; +import { useEffect, useMemo, useState } from 'react'; +import { buildOAuthExamples } from '@/lib/oauth-examples'; +import { fetchPublicSettingsClient } from '@/lib/api'; +import { resolveOAuthApiBase } from '@/lib/oauth-url'; import { CodeExampleTabs } from '@/components/code-example-tabs'; export function OAuthCodeTabs() { - return ; + const [apiBase, setApiBase] = useState('http://localhost:3000'); + + useEffect(() => { + void fetchPublicSettingsClient() + .then((settings) => setApiBase(resolveOAuthApiBase(settings))) + .catch(() => undefined); + }, []); + + const examples = useMemo(() => buildOAuthExamples(apiBase), [apiBase]); + + return ( +
+

+ Базовый URL API (issuer): {apiBase} + {' — '} + берётся из настройки PUBLIC_API_URL или Домен IdP в админ-панели. +

+ +
+ ); } diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index dd798aa..717ca3e 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -33,6 +33,7 @@ export const apiReference: ApiTagGroup[] = [ { tag: 'OAuth 2.0', endpoints: [ + { method: 'GET', path: '/.well-known/openid-configuration', summary: 'OpenID Connect Discovery' }, { method: 'GET', path: '/oauth/authorize', summary: 'Создать authorization code' }, { method: 'POST', path: '/oauth/token', summary: 'Выдать OAuth токены (code / refresh_token)' }, { method: 'GET', path: '/oauth/userinfo', summary: 'Профиль по OAuth access token', auth: true } diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 9538b63..7a3e30f 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -40,7 +40,7 @@ export const docPages: DocPage[] = [ type: 'callout', variant: 'info', title: 'Домен', - text: 'Production-домен: id.lendry.ru. Локально API доступен на порту 3000, frontend — 3002, документация — 3003.' + text: 'Production-домен задаётся в админке (PROJECT_DOMAIN и PUBLIC_API_URL). Локально API — порт 3000, frontend — 3002, документация — 3003. При same-origin деплое PUBLIC_API_URL обычно https://ваш-домен/idp-api.' } ] }, @@ -767,7 +767,13 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ blocks: [ { type: 'paragraph', - text: '1) Пользователь авторизуется в Lendry ID. 2) Ваше приложение перенаправляет на GET /oauth/authorize с clientId, redirectUri, scope, userId. 3) IdP возвращает redirectUrl с authorization code. 4) Backend обменивает code на токены через POST /oauth/token. 5) GET /oauth/userinfo возвращает профиль.' + text: '1) Пользователь авторизуется в IdP. 2) Ваше приложение перенаправляет на GET /oauth/authorize с clientId, redirectUri, scope, userId. 3) IdP возвращает redirectUrl с authorization code. 4) Backend обменивает code на токены через POST /oauth/token. 5) GET /oauth/userinfo возвращает профиль. Базовый URL (issuer) берётся из настройки PUBLIC_API_URL в админ-панели — примеры кода ниже подставляют его автоматически.' + }, + { + type: 'callout', + variant: 'info', + title: 'OpenID Connect Discovery', + text: 'Метаданные провайдера: GET {PUBLIC_API_URL}/.well-known/openid-configuration. В стороннем сервисе укажите issuer = PUBLIC_API_URL (например https://sso.example.ru/idp-api), а не URL authorization endpoint. Если API доступен через /idp-api на домене frontend, issuer тоже должен включать этот путь.' }, { type: 'callout', diff --git a/apps/docs/lib/oauth-examples.ts b/apps/docs/lib/oauth-examples.ts index 39635ed..80f4dfd 100644 --- a/apps/docs/lib/oauth-examples.ts +++ b/apps/docs/lib/oauth-examples.ts @@ -5,14 +5,15 @@ export interface OAuthExample { code: string; } -const API_BASE = 'https://id.lendry.ru'; +export function buildOAuthExamples(apiBase: string): OAuthExample[] { + const API_BASE = apiBase.replace(/\/+$/, ''); -export const oauthExamples: OAuthExample[] = [ - { - id: 'javascript', - label: 'JavaScript', - language: 'javascript', - code: `// Authorization Code Flow (Node.js / браузер) + return [ + { + id: 'javascript', + label: 'JavaScript', + language: 'javascript', + code: `// Authorization Code Flow (Node.js / браузер) const clientId = 'YOUR_CLIENT_ID'; const redirectUri = 'https://app.example.com/oauth/callback'; const scope = 'openid profile email'; @@ -27,6 +28,9 @@ authorizeUrl.searchParams.set('scope', scope); authorizeUrl.searchParams.set('state', state); window.location.href = authorizeUrl.toString(); +// OIDC Discovery (issuer = PUBLIC_API_URL из настроек админки) +// GET ${API_BASE}/.well-known/openid-configuration + // Шаг 2: обменять code на токены (на backend!) const tokenResponse = await fetch('${API_BASE}/oauth/token', { method: 'POST', @@ -45,20 +49,21 @@ const tokens = await tokenResponse.json(); const profile = await fetch('${API_BASE}/oauth/userinfo', { headers: { Authorization: \`Bearer \${tokens.accessToken}\` } }).then((r) => r.json());` - }, - { - id: 'typescript-next', - label: 'Next.js', - language: 'typescript', - code: `// app/api/oauth/callback/route.ts + }, + { + id: 'typescript-next', + label: 'Next.js', + language: 'typescript', + code: `// app/api/oauth/callback/route.ts import { NextRequest, NextResponse } from 'next/server'; +const ISSUER = '${API_BASE}'; + export async function GET(request: NextRequest) { const code = request.nextUrl.searchParams.get('code'); - const state = request.nextUrl.searchParams.get('state'); if (!code) return NextResponse.redirect('/login?error=oauth'); - const tokenRes = await fetch('${API_BASE}/oauth/token', { + const tokenRes = await fetch(\`\${ISSUER}/oauth/token\`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -75,12 +80,12 @@ export async function GET(request: NextRequest) { response.cookies.set('access_token', tokens.accessToken, { httpOnly: true, secure: true }); return response; }` - }, - { - id: 'python', - label: 'Python', - language: 'python', - code: `import requests + }, + { + id: 'python', + label: 'Python', + language: 'python', + code: `import requests from urllib.parse import urlencode API_BASE = '${API_BASE}' @@ -88,7 +93,9 @@ CLIENT_ID = 'YOUR_CLIENT_ID' CLIENT_SECRET = 'YOUR_CLIENT_SECRET' REDIRECT_URI = 'https://app.example.com/oauth/callback' -# Ссылка для входа пользователя +# OIDC Discovery +discovery = requests.get(f'{API_BASE}/.well-known/openid-configuration', timeout=15).json() + params = urlencode({ 'userId': 'USER_ID', 'clientId': CLIENT_ID, @@ -98,7 +105,6 @@ params = urlencode({ }) authorize_url = f'{API_BASE}/oauth/authorize?{params}' -# Обмен authorization code на токены token_response = requests.post(f'{API_BASE}/oauth/token', json={ 'grantType': 'authorization_code', 'code': 'AUTHORIZATION_CODE', @@ -108,24 +114,25 @@ token_response = requests.post(f'{API_BASE}/oauth/token', json={ }, timeout=15) tokens = token_response.json() -# UserInfo profile = requests.get( f'{API_BASE}/oauth/userinfo', headers={'Authorization': f"Bearer {tokens['accessToken']}"}, timeout=15 ).json()` - }, - { - id: 'php', - label: 'PHP', - language: 'php', - code: ` 'USER_ID', 'clientId' => $clientId, @@ -134,41 +141,13 @@ $params = http_build_query([ 'state' => bin2hex(random_bytes(16)), ]); header('Location: ' . $apiBase . '/oauth/authorize?' . $params); -exit; - -// Callback: обмен code -> token -$payload = json_encode([ - 'grantType' => 'authorization_code', - 'code' => $_GET['code'], - 'clientId' => $clientId, - 'clientSecret' => $clientSecret, - 'redirectUri' => $redirectUri, -]); - -$ch = curl_init($apiBase . '/oauth/token'); -curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_POSTFIELDS => $payload, - CURLOPT_RETURNTRANSFER => true, -]); -$tokens = json_decode(curl_exec($ch), true); -curl_close($ch); - -// UserInfo -$ch = curl_init($apiBase . '/oauth/userinfo'); -curl_setopt_array($ch, [ - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tokens['accessToken']], - CURLOPT_RETURNTRANSFER => true, -]); -$profile = json_decode(curl_exec($ch), true); -curl_close($ch);` - }, - { - id: 'go', - label: 'Go', - language: 'go', - code: `package main +exit;` + }, + { + id: 'go', + label: 'Go', + language: 'go', + code: `package main import ( "bytes" @@ -187,63 +166,33 @@ func buildAuthorizeURL(userID, clientID, redirectURI, scope, state string) strin q.Set("scope", scope) q.Set("state", state) return apiBase + "/oauth/authorize?" + q.Encode() -} - -func exchangeCode(code, clientID, clientSecret, redirectURI string) (map[string]any, error) { - body, _ := json.Marshal(map[string]string{ - "grantType": "authorization_code", - "code": code, - "clientId": clientID, - "clientSecret": clientSecret, - "redirectUri": redirectURI, - }) - resp, err := http.Post(apiBase+"/oauth/token", "application/json", bytes.NewReader(body)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var tokens map[string]any - return tokens, json.NewDecoder(resp.Body).Decode(&tokens) }` - }, - { - id: 'csharp', - label: 'C#', - language: 'csharp', - code: `using System.Net.Http.Json; + }, + { + id: 'csharp', + label: 'C#', + language: 'csharp', + code: `using System.Net.Http.Json; var apiBase = "${API_BASE}"; var clientId = Environment.GetEnvironmentVariable("OAUTH_CLIENT_ID"); -var clientSecret = Environment.GetEnvironmentVariable("OAUTH_CLIENT_SECRET"); var redirectUri = "https://app.example.com/oauth/callback"; -// Authorization URL +var discovery = await new HttpClient().GetFromJsonAsync>( + $"{apiBase}/.well-known/openid-configuration"); + var authorizeUrl = $"{apiBase}/oauth/authorize?userId=USER_ID&clientId={clientId}" + - $"&redirectUri={Uri.EscapeDataString(redirectUri)}&scope=openid profile email&state=xyz"; + $"&redirectUri={Uri.EscapeDataString(redirectUri)}&scope=openid profile email&state=xyz";` + }, + { + id: 'curl', + label: 'cURL', + language: 'bash', + code: `# OIDC Discovery (issuer = ${API_BASE}) +curl ${API_BASE}/.well-known/openid-configuration -using var http = new HttpClient(); - -// Token exchange -var tokenResponse = await http.PostAsJsonAsync($"{apiBase}/oauth/token", new { - grantType = "authorization_code", - code = "AUTHORIZATION_CODE", - clientId, - clientSecret, - redirectUri -}); -var tokens = await tokenResponse.Content.ReadFromJsonAsync>(); - -// UserInfo -http.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokens!["accessToken"].ToString()); -var profile = await http.GetFromJsonAsync($"{apiBase}/oauth/userinfo");` - }, - { - id: 'curl', - label: 'cURL', - language: 'bash', - code: `# Authorization (браузер пользователя) +# Authorization (браузер пользователя) open "${API_BASE}/oauth/authorize?userId=USER_ID&clientId=CLIENT_ID&redirectUri=https%3A%2F%2Fapp.example.com%2Fcallback&scope=openid%20profile%20email&state=xyz" # Обмен code на токены @@ -259,15 +208,10 @@ curl -X POST ${API_BASE}/oauth/token \\ # UserInfo curl ${API_BASE}/oauth/userinfo \\ - -H "Authorization: Bearer ACCESS_TOKEN" + -H "Authorization: Bearer ACCESS_TOKEN"` + } + ]; +} -# Refresh token -curl -X POST ${API_BASE}/oauth/token \\ - -H "Content-Type: application/json" \\ - -d '{ - "grantType": "refresh_token", - "refreshToken": "REFRESH_TOKEN", - "clientId": "CLIENT_ID" - }'` - } -]; +/** @deprecated Используйте buildOAuthExamples(apiBase) */ +export const oauthExamples = buildOAuthExamples('http://localhost:3000'); diff --git a/apps/docs/lib/oauth-url.ts b/apps/docs/lib/oauth-url.ts new file mode 100644 index 0000000..5312efe --- /dev/null +++ b/apps/docs/lib/oauth-url.ts @@ -0,0 +1,41 @@ +export function normalizeBaseUrl(url: string) { + return url.trim().replace(/\/+$/, ''); +} + +export function resolveOAuthApiBase(settings: Record, fallback = 'http://localhost:3000') { + const publicApi = settings.PUBLIC_API_URL?.trim(); + if (publicApi) { + return normalizeBaseUrl(publicApi); + } + + const domain = settings.PROJECT_DOMAIN?.trim(); + if (domain) { + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return normalizeBaseUrl(domain); + } + return `https://${domain.replace(/^\/+/, '')}`; + } + + return normalizeBaseUrl(fallback); +} + +export interface OAuthEndpoints { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + openIdConfigurationUrl: string; + jwksUrl: string; +} + +export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints { + const base = normalizeBaseUrl(apiBase); + return { + issuer: base, + authorizationEndpoint: `${base}/oauth/authorize`, + tokenEndpoint: `${base}/oauth/token`, + userInfoEndpoint: `${base}/oauth/userinfo`, + openIdConfigurationUrl: `${base}/.well-known/openid-configuration`, + jwksUrl: `${base}/.well-known/jwks.json` + }; +} diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index b22af96..5171bdb 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -1,5 +1,28 @@ import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {}; +const internalApiUrl = ( + process.env.INTERNAL_API_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + 'http://localhost:3000' +).replace(/\/$/, ''); + +const nextConfig: NextConfig = { + async rewrites() { + return [ + { + source: '/idp-api/:path*', + destination: `${internalApiUrl}/:path*` + }, + { + source: '/oauth/:path*', + destination: `${internalApiUrl}/oauth/:path*` + }, + { + source: '/.well-known/:path*', + destination: `${internalApiUrl}/.well-known/:path*` + } + ]; + } +}; export default nextConfig; diff --git a/apps/frontend/app/admin/oauth/page.tsx b/apps/frontend/app/admin/oauth/page.tsx index 76fe882..5ea9a3e 100644 --- a/apps/frontend/app/admin/oauth/page.tsx +++ b/apps/frontend/app/admin/oauth/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw, UserRound } from 'lucide-react'; +import { Copy, ExternalLink, Loader2, LockKeyhole, Plus, UserRound } from 'lucide-react'; import { AdminShell } from '@/components/id/admin-shell'; +import { OAuthClientDetailDialog } from '@/components/id/oauth-client-detail-dialog'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; @@ -12,6 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Input } from '@/components/ui/input'; import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; +import { buildAuthorizeUrl, buildOAuthEndpoints, resolveOAuthApiBase } from '@/lib/oauth-url'; export default function AdminOAuthPage() { const router = useRouter(); @@ -22,9 +24,23 @@ export default function AdminOAuthPage() { const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState(null); const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null); + const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3000'); const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] }); + const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase), [oauthApiBase]); + + const loadPublicSettings = useCallback(async () => { + try { + const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public'); + const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value])); + setOauthApiBase(resolveOAuthApiBase(settings)); + } catch { + // оставляем fallback + } + }, []); + const loadData = useCallback(async () => { if (!token || !user?.canViewOAuth) return; setLoading(true); @@ -42,6 +58,10 @@ export default function AdminOAuthPage() { } }, [showToast, token, user?.canManageOAuth, user?.canViewOAuth]); + useEffect(() => { + void loadPublicSettings(); + }, [loadPublicSettings]); + useEffect(() => { if (!user) return; if (!user.canViewOAuth && !user.canManageOAuth) { @@ -106,6 +126,7 @@ export default function AdminOAuthPage() { body: JSON.stringify({ isActive: !client.isActive }) }, token); showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено'); + setSelectedClient((current) => (current?.id === client.id ? { ...client, isActive: !client.isActive } : current)); await loadData(); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось обновить приложение'); @@ -122,7 +143,14 @@ export default function AdminOAuthPage() {

OAuth-приложения

-

Создание клиентов как на oauth.yandex.ru: client_id, secret, redirect URI и scopes

+

+ Issuer: {oauthEndpoints.issuer} + {' · '} + + OpenID Configuration + + +

) : (
- {clients.map((client) => ( - - - - {client.name} - - {client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'} - {client.createdByDisplayName ? ( - - - Создал: {client.createdByDisplayName} - - ) : client.createdById ? null : ( - Создатель не указан - )} - - - -
-
- Client ID - -
- {client.clientId} -
-
-
Redirect URI
- {client.redirectUris.map((uri) => ( -
- {uri} -
- ))} -
-
-
- - Scopes -
-

{client.scopes.join(', ')}

-
- {client.canManage ? ( -
- {client.type !== 'PUBLIC' ? ( - + {clients.map((client) => { + const authorizePreview = buildAuthorizeUrl(oauthEndpoints.issuer, { + clientId: client.clientId, + redirectUri: client.redirectUris[0] ?? 'https://app.example.com/oauth/callback', + scope: client.scopes.join(' ') + }); + + return ( + setSelectedClient(client)} + > + + + {client.name} + + {client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'} + {client.createdByDisplayName ? ( + + + Создал: {client.createdByDisplayName} + ) : null} - + + + +
+
Authorization URL
+ {authorizePreview}
- ) : ( -

Только просмотр — управление доступно создателю или администратору с полными правами

- )} -
-
- ))} +

Нажмите на карточку — все endpoints и данные для интеграции

+ + + ); + })} {!clients.length ?

OAuth-приложения ещё не созданы

: null}
)} + !open && setSelectedClient(null)} + onCopy={copyText} + onRotateSecret={(clientId) => void handleRotateSecret(clientId)} + onToggleActive={(client) => void handleToggleActive(client)} + /> + diff --git a/apps/frontend/components/id/oauth-client-detail-dialog.tsx b/apps/frontend/components/id/oauth-client-detail-dialog.tsx new file mode 100644 index 0000000..550e38e --- /dev/null +++ b/apps/frontend/components/id/oauth-client-detail-dialog.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { Copy, ExternalLink, KeyRound, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { OAuthClient } from '@/lib/api'; +import { OAuthEndpoints, buildAuthorizeUrl } from '@/lib/oauth-url'; + +function CopyRow({ label, value, onCopy }: { label: string; value: string; onCopy: (value: string, label: string) => void }) { + return ( +
+
+ {label} + +
+ {value} +
+ ); +} + +export function OAuthClientDetailDialog({ + client, + endpoints, + open, + onOpenChange, + onCopy, + onRotateSecret, + onToggleActive +}: { + client: OAuthClient | null; + endpoints: OAuthEndpoints; + open: boolean; + onOpenChange: (open: boolean) => void; + onCopy: (value: string, label: string) => void; + onRotateSecret: (clientId: string) => void; + onToggleActive: (client: OAuthClient) => void; +}) { + if (!client) return null; + + const primaryRedirect = client.redirectUris[0] ?? 'https://app.example.com/oauth/callback'; + const scope = client.scopes.join(' '); + const sampleAuthorizeUrl = buildAuthorizeUrl(endpoints.issuer, { + clientId: client.clientId, + redirectUri: primaryRedirect, + scope + }); + + return ( + + + + {client.name} + + +
+
+ + {client.type === 'PUBLIC' ? 'Public' : 'Confidential'} + + + {client.isActive ? 'Активно' : 'Отключено'} + + {client.createdByDisplayName ? ( + Создал: {client.createdByDisplayName} + ) : null} +
+ +
+

Данные клиента

+ + {client.type !== 'PUBLIC' ? ( +

+ Client Secret выдаётся один раз при создании или после «Новый secret». Храните только на backend. +

+ ) : null} + + +
+ +
+

Подключение OAuth 2.0 / OpenID Connect

+

+ Issuer и endpoints берутся из настройки PUBLIC_API_URL в админке. В стороннем сервисе укажите issuer = базовый URL API, не authorization endpoint. +

+ + + + + + +
+ +
+
+ + Быстрый старт +
+
    +
  1. Авторизуйте пользователя в IdP и получите userId.
  2. +
  3. Перенаправьте на authorization endpoint с clientId, redirectUri, scope, userId.
  4. +
  5. На backend обменяйте code через POST /oauth/token с client_secret.
  6. +
  7. Запросите профиль через GET /oauth/userinfo с access token.
  8. +
+ + Открыть OpenID Configuration + + +
+ + {client.canManage ? ( +
+ {client.type !== 'PUBLIC' ? ( + + ) : null} + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/frontend/lib/oauth-url.ts b/apps/frontend/lib/oauth-url.ts new file mode 100644 index 0000000..a048434 --- /dev/null +++ b/apps/frontend/lib/oauth-url.ts @@ -0,0 +1,56 @@ +export function normalizeBaseUrl(url: string) { + return url.trim().replace(/\/+$/, ''); +} + +export function resolveOAuthApiBase(settings: Record, fallback = 'http://localhost:3000') { + const publicApi = settings.PUBLIC_API_URL?.trim(); + if (publicApi) { + return normalizeBaseUrl(publicApi); + } + + const domain = settings.PROJECT_DOMAIN?.trim(); + if (domain) { + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return normalizeBaseUrl(domain); + } + return `https://${domain.replace(/^\/+/, '')}`; + } + + return normalizeBaseUrl(fallback); +} + +export interface OAuthEndpoints { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + openIdConfigurationUrl: string; + jwksUrl: string; +} + +export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints { + const base = normalizeBaseUrl(apiBase); + return { + issuer: base, + authorizationEndpoint: `${base}/oauth/authorize`, + tokenEndpoint: `${base}/oauth/token`, + userInfoEndpoint: `${base}/oauth/userinfo`, + openIdConfigurationUrl: `${base}/.well-known/openid-configuration`, + jwksUrl: `${base}/.well-known/jwks.json` + }; +} + +export function buildAuthorizeUrl( + apiBase: string, + params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string } +) { + const url = new URL(`${normalizeBaseUrl(apiBase)}/oauth/authorize`); + url.searchParams.set('clientId', params.clientId); + url.searchParams.set('redirectUri', params.redirectUri); + url.searchParams.set('scope', params.scope); + url.searchParams.set('userId', params.userId ?? 'USER_ID_AFTER_LOGIN'); + if (params.state) { + url.searchParams.set('state', params.state); + } + return url.toString(); +} diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index e80d12d..01a0c85 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -24,7 +24,14 @@ export const SYSTEM_SETTING_GROUPS: Record = { export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [ { key: 'PROJECT_NAME', label: 'Название проекта', group: 'general', type: 'text', hint: 'Отображается в меню, заголовке и боковой панели' }, { key: 'PROJECT_TAGLINE', label: 'Слоган проекта', group: 'general', type: 'text' }, - { key: 'PROJECT_DOMAIN', label: 'Домен IdP', group: 'general', type: 'text' }, + { key: 'PROJECT_DOMAIN', label: 'Домен IdP', group: 'general', type: 'text', hint: 'Домен без пути, например sso.example.ru' }, + { + key: 'PUBLIC_API_URL', + label: 'URL API (OAuth issuer)', + group: 'general', + type: 'text', + hint: 'Базовый URL API для OAuth/OIDC. Пример: https://sso.example.ru/idp-api или https://api.example.ru' + }, { key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' }, { key: 'PIN_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' }, { key: 'PIN_REQUIRE_ON_DELETE', label: 'Требовать PIN при удалении', group: 'pin', type: 'boolean' }, diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 9be15d2..b7eeda9 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -19,6 +19,14 @@ const nextConfig: NextConfig = { { source: '/idp-api/:path*', destination: `${internalApiUrl}/:path*` + }, + { + source: '/oauth/:path*', + destination: `${internalApiUrl}/oauth/:path*` + }, + { + source: '/.well-known/:path*', + destination: `${internalApiUrl}/.well-known/:path*` } ]; } diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index de3092c..7313419 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -20,6 +20,7 @@ import { ProfileService } from './domain/profile.service'; import { DocumentsService } from './domain/documents.service'; import { AddressesService } from './domain/addresses.service'; import { OAuthCoreService } from './domain/oauth-core.service'; +import { OAuthIssuerService } from './domain/oauth-issuer.service'; import { OtpService } from './domain/otp.service'; import { AdvancedAuthService } from './domain/advanced-auth.service'; import { FamilyService } from './domain/family.service'; @@ -59,6 +60,7 @@ import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.serv DocumentsService, AddressesService, OAuthCoreService, + OAuthIssuerService, OtpService, AdvancedAuthService, TotpService, diff --git a/apps/sso-core/src/domain/oauth-core.service.ts b/apps/sso-core/src/domain/oauth-core.service.ts index 67f977f..d725a1c 100644 --- a/apps/sso-core/src/domain/oauth-core.service.ts +++ b/apps/sso-core/src/domain/oauth-core.service.ts @@ -1,16 +1,17 @@ import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import * as bcrypt from 'bcryptjs'; import { randomBytes } from 'node:crypto'; import { PrismaService } from '../infra/prisma.service'; +import { OAuthIssuerService } from './oauth-issuer.service'; @Injectable() export class OAuthCoreService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, - private readonly config: ConfigService + private readonly config: ConfigService, + private readonly oauthIssuer: OAuthIssuerService ) {} async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) { @@ -70,9 +71,10 @@ export class OAuthCoreService { } private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) { - const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' }); - const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer: 'id.lendry.ru' }); - const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' }); + const issuer = await this.oauthIssuer.resolveIssuer(); + const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer }); + const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer }); + const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer }); return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken }; } } diff --git a/apps/sso-core/src/domain/oauth-issuer.service.ts b/apps/sso-core/src/domain/oauth-issuer.service.ts new file mode 100644 index 0000000..03c3cd3 --- /dev/null +++ b/apps/sso-core/src/domain/oauth-issuer.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SettingsService } from './settings.service'; + +@Injectable() +export class OAuthIssuerService { + constructor( + private readonly settings: SettingsService, + private readonly config: ConfigService + ) {} + + async resolveIssuer(): Promise { + const fromDb = (await this.settings.getValue('PUBLIC_API_URL', '')).trim(); + if (fromDb) { + return fromDb.replace(/\/+$/, ''); + } + + const fromEnv = this.config.get('PUBLIC_API_URL')?.trim(); + if (fromEnv) { + return fromEnv.replace(/\/+$/, ''); + } + + const domain = (await this.settings.getValue('PROJECT_DOMAIN', 'id.lendry.ru')).trim(); + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return domain.replace(/\/+$/, ''); + } + + return `https://${domain.replace(/^\/+/, '')}`; + } +} diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts index 10ad595..c7367da 100644 --- a/apps/sso-core/src/domain/system-settings.seed.ts +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -4,7 +4,12 @@ import { PrismaService } from '../infra/prisma.service'; export const DEFAULT_SYSTEM_SETTINGS = [ { key: 'PROJECT_NAME', value: 'MVK ID', description: 'Название проекта в интерфейсе и заголовке страницы' }, { key: 'PROJECT_TAGLINE', value: 'Единый аккаунт для сервисов Lendry', description: 'Краткий слоган под названием проекта' }, - { key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP' }, + { key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP (без пути)' }, + { + key: 'PUBLIC_API_URL', + value: 'http://localhost:3000', + description: 'Публичный базовый URL API для OAuth и OpenID Connect (issuer). Пример: https://sso.example.ru/idp-api' + }, { key: 'PIN_LOCK_TIMEOUT_MINUTES', value: '15', description: 'Через сколько минут неактивности сессия блокируется PIN-кодом' }, { key: 'PIN_DELETE_GRACE_MINUTES', value: '1440', description: 'Задержка перед окончательным удалением PIN-кода после запроса (минуты)' }, { key: 'PIN_REQUIRE_ON_DELETE', value: 'true', description: 'Требовать текущий PIN-код при запросе удаления защиты' }, @@ -91,6 +96,7 @@ export const PUBLIC_SETTING_KEYS = [ 'PROJECT_NAME', 'PROJECT_TAGLINE', 'PROJECT_DOMAIN', + 'PUBLIC_API_URL', 'REGISTRATION_ENABLED', 'MAINTENANCE_MODE', 'LDAP_ENABLED', @@ -109,5 +115,18 @@ export class SystemSettingsSeedService implements OnModuleInit { update: { description: setting.description, isSecret: SECRET_SETTING_KEYS.has(setting.key) } }); } + + const envPublicApiUrl = process.env.PUBLIC_API_URL?.trim(); + if (envPublicApiUrl) { + await this.prisma.systemSetting.upsert({ + where: { key: 'PUBLIC_API_URL' }, + create: { + key: 'PUBLIC_API_URL', + value: envPublicApiUrl, + description: 'Публичный базовый URL API для OAuth и OpenID Connect (issuer). Пример: https://sso.example.ru/idp-api' + }, + update: { value: envPublicApiUrl } + }); + } } } diff --git a/install.sh b/install.sh index 9836773..866d80c 100644 --- a/install.sh +++ b/install.sh @@ -1595,6 +1595,18 @@ build_nginx_idp_api_proxy_block() { ${nginx_proxy_headers} } + location /oauth/ { + proxy_pass ${api_upstream}/oauth/; + proxy_http_version 1.1; +${nginx_proxy_headers} + } + + location /.well-known/ { + proxy_pass ${api_upstream}/.well-known/; + proxy_http_version 1.1; +${nginx_proxy_headers} + } + EOF }