fix and update
This commit is contained in:
@@ -29,7 +29,7 @@ DOMAIN_MINIO=
|
|||||||
DOMAIN_MINIO_CONSOLE=
|
DOMAIN_MINIO_CONSOLE=
|
||||||
|
|
||||||
# Публичные URL (генерируются install.sh)
|
# Публичные URL (генерируются install.sh)
|
||||||
PUBLIC_API_URL=http://localhost:3000
|
PUBLIC_API_URL=http://localhost:3002/idp-api
|
||||||
PUBLIC_FRONTEND_URL=http://localhost:3002
|
PUBLIC_FRONTEND_URL=http://localhost:3002
|
||||||
PUBLIC_DOCS_URL=http://localhost:3003
|
PUBLIC_DOCS_URL=http://localhost:3003
|
||||||
PUBLIC_WS_URL=ws://localhost:8085/ws
|
PUBLIC_WS_URL=ws://localhost:8085/ws
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
NotFoundException,
|
||||||
Options,
|
Options,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -15,9 +17,20 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { applyFedcmCorsHeaders, applyFedcmPreflightHeaders } from '../lib/fedcm-cors';
|
import {
|
||||||
|
applyFedcmCorsHeaders,
|
||||||
|
applyFedcmLoginStatus,
|
||||||
|
applyFedcmPreflightHeaders,
|
||||||
|
assertFedcmWebIdentityRequest,
|
||||||
|
requireFedcmCorsOrigin
|
||||||
|
} from '../lib/fedcm-cors';
|
||||||
|
import {
|
||||||
|
buildFedcmDiscoverPayload,
|
||||||
|
buildFedcmProviderConfig,
|
||||||
|
readOneTapEnabled,
|
||||||
|
resolveFedcmEndpoints
|
||||||
|
} from '../lib/fedcm-config';
|
||||||
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
||||||
import { resolveFrontendUrl, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
|
||||||
import { assertSessionUnlocked, verifyAccessToken } from '../session-auth';
|
import { assertSessionUnlocked, verifyAccessToken } from '../session-auth';
|
||||||
|
|
||||||
type FedcmAccountsResponse = {
|
type FedcmAccountsResponse = {
|
||||||
@@ -39,6 +52,12 @@ export class FedcmController {
|
|||||||
private readonly jwt: JwtService
|
private readonly jwt: JwtService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Options('config.json')
|
||||||
|
@HttpCode(204)
|
||||||
|
configPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
applyFedcmPreflightHeaders(res, origin);
|
||||||
|
}
|
||||||
|
|
||||||
@Options('accounts')
|
@Options('accounts')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
@@ -59,40 +78,33 @@ export class FedcmController {
|
|||||||
|
|
||||||
@Get('config.json')
|
@Get('config.json')
|
||||||
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
|
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
|
||||||
async config(@Res({ passthrough: true }) res: Response) {
|
async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
assertFedcmWebIdentityRequest(req);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
|
||||||
const issuer = await resolveOAuthIssuer(this.core);
|
const enabled = await readOneTapEnabled(this.core);
|
||||||
const frontendUrl = await resolveFrontendUrl(this.core);
|
if (!enabled) {
|
||||||
let projectName = 'MVK ID';
|
throw new NotFoundException('One Tap Login отключён');
|
||||||
|
|
||||||
try {
|
|
||||||
const setting = (await firstValueFrom(this.core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string };
|
|
||||||
if (setting.value?.trim()) {
|
|
||||||
projectName = setting.value.trim();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||||
accounts_endpoint: `${issuer}/fedcm/accounts`,
|
return buildFedcmProviderConfig(endpoints);
|
||||||
client_metadata_endpoint: `${issuer}/fedcm/client_metadata`,
|
}
|
||||||
id_assertion_endpoint: `${issuer}/fedcm/id_assertion`,
|
|
||||||
login_url: `${frontendUrl}/auth/login`,
|
@Get('discover.json')
|
||||||
signup_url: `${frontendUrl}/auth/register`,
|
@ApiOperation({
|
||||||
branding: {
|
summary: 'FedCM discovery',
|
||||||
background_color: '#ffffff',
|
description: 'Публичные URL FedCM для виджета и диагностики (apiBase, configUrl, webIdentityUrl).'
|
||||||
color: '#3390ec',
|
})
|
||||||
icons: [{ url: `${frontendUrl}/favicon.ico`, size: 32 }]
|
async discover(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
},
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
accounts: {
|
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||||
include_anonymous: false
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
},
|
|
||||||
fields: ['name', 'email', 'picture'],
|
const enabled = await readOneTapEnabled(this.core);
|
||||||
display: projectName
|
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||||
};
|
return buildFedcmDiscoverPayload(endpoints, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@@ -102,33 +114,44 @@ export class FedcmController {
|
|||||||
@Headers('origin') origin: string | undefined,
|
@Headers('origin') origin: string | undefined,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
): Promise<FedcmAccountsResponse> {
|
): Promise<FedcmAccountsResponse> {
|
||||||
|
assertFedcmWebIdentityRequest(req);
|
||||||
applyFedcmCorsHeaders(res, origin);
|
applyFedcmCorsHeaders(res, origin);
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
return { accounts: [] };
|
return { accounts: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = (await firstValueFrom(
|
const result = (await firstValueFrom(
|
||||||
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
||||||
)) as FedcmAccountsResponse;
|
)) as FedcmAccountsResponse;
|
||||||
|
const accounts = result?.accounts ?? [];
|
||||||
return result ?? { accounts: [] };
|
applyFedcmLoginStatus(res, accounts.length > 0);
|
||||||
|
return { accounts };
|
||||||
|
} catch {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
return { accounts: [] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('client_metadata')
|
@Get('client_metadata')
|
||||||
@ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' })
|
@ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' })
|
||||||
async clientMetadata(
|
async clientMetadata(
|
||||||
|
@Req() req: Request,
|
||||||
@Query('client_id') clientId: string | undefined,
|
@Query('client_id') clientId: string | undefined,
|
||||||
@Headers('origin') origin: string | undefined,
|
@Headers('origin') origin: string | undefined,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
applyFedcmCorsHeaders(res, origin);
|
assertFedcmWebIdentityRequest(req);
|
||||||
res.setHeader('Content-Type', 'application/json');
|
const rpOrigin = requireFedcmCorsOrigin(origin);
|
||||||
|
applyFedcmCorsHeaders(res, rpOrigin);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
if (!clientId?.trim()) {
|
if (!clientId?.trim()) {
|
||||||
throw new UnauthorizedException('Передайте client_id');
|
throw new BadRequestException('Передайте client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() }));
|
return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() }));
|
||||||
@@ -142,8 +165,10 @@ export class FedcmController {
|
|||||||
@Headers('origin') origin: string | undefined,
|
@Headers('origin') origin: string | undefined,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
applyFedcmCorsHeaders(res, origin);
|
assertFedcmWebIdentityRequest(req);
|
||||||
res.setHeader('Content-Type', 'application/json');
|
const rpOrigin = requireFedcmCorsOrigin(origin);
|
||||||
|
applyFedcmCorsHeaders(res, rpOrigin);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -154,9 +179,11 @@ export class FedcmController {
|
|||||||
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
||||||
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
||||||
if (!clientId || !accountId) {
|
if (!clientId || !accountId) {
|
||||||
throw new UnauthorizedException('Передайте client_id и account_id');
|
throw new BadRequestException('Передайте client_id и account_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyFedcmLoginStatus(res, true);
|
||||||
|
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.core.fedcm.IssueIdAssertion({
|
this.core.fedcm.IssueIdAssertion({
|
||||||
userId: session.sub,
|
userId: session.sub,
|
||||||
@@ -187,6 +214,7 @@ export class FedcmController {
|
|||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
pinVerified: true
|
pinVerified: true
|
||||||
});
|
});
|
||||||
|
applyFedcmLoginStatus(res, true);
|
||||||
return { synced: true };
|
return { synced: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Controller, Get, Res } from '@nestjs/common';
|
import { Controller, Get, Req, Res } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { buildFedcmWebIdentityManifest, resolveFedcmEndpoints } from '../lib/fedcm-config';
|
||||||
|
import { assertFedcmWebIdentityRequest } from '../lib/fedcm-cors';
|
||||||
import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
||||||
|
|
||||||
@ApiTags('OpenID Connect')
|
@ApiTags('OpenID Connect')
|
||||||
@@ -15,13 +16,12 @@ export class WellKnownController {
|
|||||||
summary: 'FedCM web identity manifest',
|
summary: 'FedCM web identity manifest',
|
||||||
description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.'
|
description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.'
|
||||||
})
|
})
|
||||||
async webIdentity(@Res({ passthrough: true }) res: Response) {
|
async webIdentity(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
assertFedcmWebIdentityRequest(req);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
const issuer = await resolveOAuthIssuer(this.core);
|
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||||
return {
|
return buildFedcmWebIdentityManifest(endpoints);
|
||||||
provider_urls: [`${issuer}/fedcm/config.json`]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('openid-configuration')
|
@Get('openid-configuration')
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
import { status as GrpcStatus } from '@grpc/grpc-js';
|
import { status as GrpcStatus } from '@grpc/grpc-js';
|
||||||
|
import { applyFedcmCorsHeaders } from './lib/fedcm-cors';
|
||||||
|
|
||||||
interface HttpResponseLike {
|
interface HttpResponseLike {
|
||||||
status(code: number): { json(body: unknown): unknown };
|
status(code: number): { json(body: unknown): unknown };
|
||||||
|
setHeader?(name: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpRequestLike {
|
||||||
|
path?: string;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function grpcToHttp(code?: number): number {
|
function grpcToHttp(code?: number): number {
|
||||||
@@ -29,7 +37,16 @@ function grpcToHttp(code?: number): number {
|
|||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const response = host.switchToHttp().getResponse<HttpResponseLike>();
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<HttpResponseLike>();
|
||||||
|
const request = ctx.getRequest<HttpRequestLike>();
|
||||||
|
const requestPath = request.path ?? request.url ?? '';
|
||||||
|
const originHeader = request.headers?.origin;
|
||||||
|
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
||||||
|
|
||||||
|
if ((requestPath.includes('/fedcm/') || requestPath.includes('/.well-known/')) && origin && response.setHeader) {
|
||||||
|
applyFedcmCorsHeaders(response as never, origin);
|
||||||
|
}
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
const statusCode = exception.getStatus();
|
const statusCode = exception.getStatus();
|
||||||
|
|||||||
196
apps/api-gateway/src/lib/fedcm-config.ts
Normal file
196
apps/api-gateway/src/lib/fedcm-config.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer';
|
||||||
|
|
||||||
|
export interface FedcmEndpoints {
|
||||||
|
issuer: string;
|
||||||
|
frontendUrl: string;
|
||||||
|
projectName: string;
|
||||||
|
configUrl: string;
|
||||||
|
accountsEndpoint: string;
|
||||||
|
clientMetadataEndpoint: string;
|
||||||
|
idAssertionEndpoint: string;
|
||||||
|
loginUrl: string;
|
||||||
|
signupUrl: string;
|
||||||
|
webIdentityUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string) {
|
||||||
|
return url.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestHost(req?: Request): string | undefined {
|
||||||
|
if (!req) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = req.headers['x-forwarded-host'];
|
||||||
|
const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host;
|
||||||
|
return host?.split(',')[0]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestProto(req?: Request): string {
|
||||||
|
if (!req) {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = req.headers['x-forwarded-proto'];
|
||||||
|
const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.protocol;
|
||||||
|
return proto?.split(',')[0]?.trim() || 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostsMatch(a: string, b: string) {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a === 'localhost' && b === 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSameOriginFrontend(storedFrontend: string, req?: Request): string {
|
||||||
|
const host = readRequestHost(req);
|
||||||
|
if (!host) {
|
||||||
|
return storedFrontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontendUrl = new URL(storedFrontend);
|
||||||
|
const requestUrl = new URL(requestOrigin);
|
||||||
|
|
||||||
|
if (hostsMatch(frontendUrl.hostname, requestUrl.hostname)) {
|
||||||
|
return requestOrigin;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return storedFrontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedFrontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSameOriginIssuer(storedIssuer: string, storedFrontend: string, req?: Request): string {
|
||||||
|
const host = readRequestHost(req);
|
||||||
|
if (!host) {
|
||||||
|
return storedIssuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||||
|
const sameOriginApi = `${requestOrigin}/idp-api`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issuerUrl = new URL(storedIssuer);
|
||||||
|
const frontendUrl = new URL(storedFrontend);
|
||||||
|
const requestUrl = new URL(requestOrigin);
|
||||||
|
|
||||||
|
const hostMatches =
|
||||||
|
hostsMatch(issuerUrl.hostname, requestUrl.hostname) || hostsMatch(frontendUrl.hostname, requestUrl.hostname);
|
||||||
|
|
||||||
|
if (!hostMatches) {
|
||||||
|
return storedIssuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeBaseUrl(storedIssuer) !== sameOriginApi) {
|
||||||
|
return sameOriginApi;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return storedIssuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedIssuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) {
|
||||||
|
const base = normalizeBaseUrl(issuer);
|
||||||
|
const front = normalizeBaseUrl(frontendUrl);
|
||||||
|
|
||||||
|
let webIdentityOrigin = front;
|
||||||
|
try {
|
||||||
|
webIdentityOrigin = new URL(base).origin;
|
||||||
|
} catch {
|
||||||
|
// keep frontend origin
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUrl: `${base}/fedcm/config.json`,
|
||||||
|
accountsEndpoint: `${base}/fedcm/accounts`,
|
||||||
|
clientMetadataEndpoint: `${base}/fedcm/client_metadata`,
|
||||||
|
idAssertionEndpoint: `${base}/fedcm/id_assertion`,
|
||||||
|
loginUrl: `${front}/auth/login`,
|
||||||
|
signupUrl: `${front}/auth/register`,
|
||||||
|
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise<FedcmEndpoints> {
|
||||||
|
const storedIssuer = await resolveOAuthIssuer(core);
|
||||||
|
const storedFrontend = await resolveFrontendUrl(core);
|
||||||
|
const frontendUrl = resolveSameOriginFrontend(storedFrontend, req);
|
||||||
|
const issuer = resolveSameOriginIssuer(storedIssuer, storedFrontend, req);
|
||||||
|
let projectName = 'MVK ID';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string };
|
||||||
|
if (setting.value?.trim()) {
|
||||||
|
projectName = setting.value.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
frontendUrl,
|
||||||
|
projectName,
|
||||||
|
...buildFedcmEndpointUrls(issuer, frontendUrl)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFedcmProviderConfig(endpoints: FedcmEndpoints) {
|
||||||
|
return {
|
||||||
|
accounts_endpoint: endpoints.accountsEndpoint,
|
||||||
|
client_metadata_endpoint: endpoints.clientMetadataEndpoint,
|
||||||
|
id_assertion_endpoint: endpoints.idAssertionEndpoint,
|
||||||
|
login_url: endpoints.loginUrl,
|
||||||
|
branding: {
|
||||||
|
background_color: '#ffffff',
|
||||||
|
color: '#3390ec',
|
||||||
|
icons: [{ url: `${endpoints.frontendUrl}/favicon.ico`, size: 32 }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFedcmWebIdentityManifest(endpoints: FedcmEndpoints) {
|
||||||
|
return {
|
||||||
|
provider_urls: [endpoints.configUrl],
|
||||||
|
accounts_endpoint: endpoints.accountsEndpoint,
|
||||||
|
login_url: endpoints.loginUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFedcmDiscoverPayload(endpoints: FedcmEndpoints, enabled = true) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
apiBase: endpoints.issuer,
|
||||||
|
frontendUrl: endpoints.frontendUrl,
|
||||||
|
projectName: endpoints.projectName,
|
||||||
|
configUrl: endpoints.configUrl,
|
||||||
|
webIdentityUrl: endpoints.webIdentityUrl,
|
||||||
|
accountsEndpoint: endpoints.accountsEndpoint,
|
||||||
|
loginUrl: endpoints.loginUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOneTapEnabled(core: CoreGrpcService): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'ONE_TAP_ENABLED' }))) as { value?: string };
|
||||||
|
const raw = setting.value?.trim().toLowerCase();
|
||||||
|
if (!raw) return true;
|
||||||
|
return ['true', '1', 'yes'].includes(raw);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { readOneTapEnabled };
|
||||||
@@ -1,16 +1,45 @@
|
|||||||
import type { Response } from 'express';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
export function normalizeFedcmOrigin(origin?: string) {
|
||||||
|
return origin?.trim().replace(/\/+$/, '') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyFedcmCorsHeaders(res: Response, origin?: string) {
|
export function applyFedcmCorsHeaders(res: Response, origin?: string) {
|
||||||
if (origin) {
|
const normalized = normalizeFedcmOrigin(origin);
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', normalized);
|
||||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
res.setHeader('Vary', 'Origin');
|
res.setHeader('Vary', 'Origin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireFedcmCorsOrigin(origin?: string) {
|
||||||
|
const normalized = normalizeFedcmOrigin(origin);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new BadRequestException('FedCM требует заголовок Origin');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyFedcmPreflightHeaders(res: Response, origin?: string) {
|
export function applyFedcmPreflightHeaders(res: Response, origin?: string) {
|
||||||
applyFedcmCorsHeaders(res, origin);
|
applyFedcmCorsHeaders(res, origin);
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site');
|
res.setHeader(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
'Content-Type, Authorization, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site'
|
||||||
|
);
|
||||||
res.setHeader('Access-Control-Max-Age', '86400');
|
res.setHeader('Access-Control-Max-Age', '86400');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertFedcmWebIdentityRequest(req: Request) {
|
||||||
|
const dest = String(req.headers['sec-fetch-dest'] ?? '').toLowerCase();
|
||||||
|
if (dest && dest !== 'webidentity') {
|
||||||
|
throw new BadRequestException('Недопустимый Sec-Fetch-Dest для FedCM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFedcmLoginStatus(res: Response, loggedIn: boolean) {
|
||||||
|
res.setHeader('Set-Login', loggedIn ? 'logged-in' : 'logged-out');
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
|
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
|
||||||
import { getAdminLandingPath } from '@/lib/admin-access';
|
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
import { buildAuthorizeUrl, buildOAuthEndpoints, resolveOAuthApiBase } from '@/lib/oauth-url';
|
import { buildAuthorizeUrl, buildOAuthEndpoints, resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
|
||||||
|
|
||||||
export default function AdminOAuthPage() {
|
export default function AdminOAuthPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -26,16 +26,22 @@ export default function AdminOAuthPage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedClient, setSelectedClient] = useState<OAuthClient | null>(null);
|
const [selectedClient, setSelectedClient] = useState<OAuthClient | null>(null);
|
||||||
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
|
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
|
||||||
const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3000');
|
const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3002/idp-api');
|
||||||
|
const [frontendBase, setFrontendBase] = useState('http://localhost:3002');
|
||||||
|
const [projectName, setProjectName] = useState('MVK ID');
|
||||||
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
|
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
|
||||||
|
|
||||||
const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase), [oauthApiBase]);
|
const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase, frontendBase), [oauthApiBase, frontendBase]);
|
||||||
|
|
||||||
const loadPublicSettings = useCallback(async () => {
|
const loadPublicSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
|
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
|
||||||
const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
|
const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
|
||||||
setOauthApiBase(resolveOAuthApiBase(settings));
|
setOauthApiBase(resolveOAuthApiBase(settings));
|
||||||
|
setFrontendBase(resolveFrontendBase(settings));
|
||||||
|
if (settings.PROJECT_NAME?.trim()) {
|
||||||
|
setProjectName(settings.PROJECT_NAME.trim());
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// оставляем fallback
|
// оставляем fallback
|
||||||
}
|
}
|
||||||
@@ -182,11 +188,20 @@ export default function AdminOAuthPage() {
|
|||||||
<p className="text-sm text-[#667085]">
|
<p className="text-sm text-[#667085]">
|
||||||
Issuer: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{oauthEndpoints.issuer}</code>
|
Issuer: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{oauthEndpoints.issuer}</code>
|
||||||
{' · '}
|
{' · '}
|
||||||
|
Frontend: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{frontendBase}</code>
|
||||||
|
{' · '}
|
||||||
<a href={oauthEndpoints.openIdConfigurationUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[#1d4ed8] hover:underline">
|
<a href={oauthEndpoints.openIdConfigurationUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[#1d4ed8] hover:underline">
|
||||||
OpenID Configuration
|
OpenID Configuration
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#667085]">
|
||||||
|
One Tap Login настраивается в{' '}
|
||||||
|
<a href="/admin/settings" className="text-[#1d4ed8] hover:underline">
|
||||||
|
системных настройках
|
||||||
|
</a>{' '}
|
||||||
|
(PUBLIC_API_URL, PUBLIC_FRONTEND_URL, ONE_TAP_ENABLED).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogOpen(true)} disabled={!user?.canManageOAuth}>
|
<Button onClick={() => setDialogOpen(true)} disabled={!user?.canManageOAuth}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -244,6 +259,8 @@ export default function AdminOAuthPage() {
|
|||||||
<OAuthClientDetailDialog
|
<OAuthClientDetailDialog
|
||||||
client={selectedClient}
|
client={selectedClient}
|
||||||
endpoints={oauthEndpoints}
|
endpoints={oauthEndpoints}
|
||||||
|
frontendBase={frontendBase}
|
||||||
|
projectName={projectName}
|
||||||
open={Boolean(selectedClient)}
|
open={Boolean(selectedClient)}
|
||||||
onOpenChange={(open) => !open && setSelectedClient(null)}
|
onOpenChange={(open) => !open && setSelectedClient(null)}
|
||||||
onCopy={copyText}
|
onCopy={copyText}
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
'messaging-sms': 'SMS'
|
'messaging-sms': 'SMS'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PUBLIC_SETTINGS_REFRESH_KEYS = new Set(['PROJECT_NAME', 'PROJECT_TAGLINE', 'LDAP_ENABLED', 'LDAP_USE_LDAPS']);
|
const PUBLIC_SETTINGS_REFRESH_KEYS = new Set([
|
||||||
|
'PROJECT_NAME',
|
||||||
|
'PROJECT_TAGLINE',
|
||||||
|
'PUBLIC_API_URL',
|
||||||
|
'PUBLIC_FRONTEND_URL',
|
||||||
|
'ONE_TAP_ENABLED',
|
||||||
|
'LDAP_ENABLED',
|
||||||
|
'LDAP_USE_LDAPS'
|
||||||
|
]);
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
fetchOAuthClientPublicInfo,
|
fetchOAuthClientPublicInfo,
|
||||||
type OAuthConsentCheckResponse
|
type OAuthConsentCheckResponse
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import {
|
||||||
|
parseAuthorizationRedirect,
|
||||||
|
parsePopupOAuthParams,
|
||||||
|
postOneTapResult
|
||||||
|
} from '@/lib/oauth-popup-bridge';
|
||||||
|
|
||||||
function buildOAuthQuery(searchParams: URLSearchParams) {
|
function buildOAuthQuery(searchParams: URLSearchParams) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -38,6 +43,8 @@ function OAuthAuthorizeContent() {
|
|||||||
const autoApproveStartedRef = useRef(false);
|
const autoApproveStartedRef = useRef(false);
|
||||||
|
|
||||||
const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]);
|
const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]);
|
||||||
|
const popupContext = useMemo(() => parsePopupOAuthParams(searchParams), [searchParams]);
|
||||||
|
const isPopupMode = Boolean(popupContext);
|
||||||
|
|
||||||
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
|
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
|
||||||
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
|
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
|
||||||
@@ -99,6 +106,22 @@ function OAuthAuthorizeContent() {
|
|||||||
};
|
};
|
||||||
}, [isLoading, isPinLocked, returnUrl, router, token, user]);
|
}, [isLoading, isPinLocked, returnUrl, router, token, user]);
|
||||||
|
|
||||||
|
const finishPopupFlow = useCallback(
|
||||||
|
(redirectUrl: string) => {
|
||||||
|
if (!popupContext) return false;
|
||||||
|
const parsed = parseAuthorizationRedirect(redirectUrl);
|
||||||
|
postOneTapResult(popupContext.popupOrigin, {
|
||||||
|
code: parsed.code ?? undefined,
|
||||||
|
state: parsed.state ?? undefined,
|
||||||
|
token: parsed.code ?? undefined,
|
||||||
|
error: parsed.error ?? undefined,
|
||||||
|
errorDescription: parsed.errorDescription ?? undefined
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[popupContext]
|
||||||
|
);
|
||||||
|
|
||||||
const approve = useCallback(async () => {
|
const approve = useCallback(async () => {
|
||||||
if (!token || !user || isPinLocked) return;
|
if (!token || !user || isPinLocked) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -108,6 +131,9 @@ function OAuthAuthorizeContent() {
|
|||||||
if (!data.redirectUrl) {
|
if (!data.redirectUrl) {
|
||||||
throw new Error('Сервер не вернул redirect URL');
|
throw new Error('Сервер не вернул redirect URL');
|
||||||
}
|
}
|
||||||
|
if (finishPopupFlow(data.redirectUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.location.href = data.redirectUrl;
|
window.location.href = data.redirectUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
|
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
|
||||||
@@ -115,7 +141,7 @@ function OAuthAuthorizeContent() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [isPinLocked, oauthQuery, token, user]);
|
}, [finishPopupFlow, isPinLocked, oauthQuery, token, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !clientId || !sessionChecked || isPinLocked) return;
|
if (!token || !clientId || !sessionChecked || isPinLocked) return;
|
||||||
@@ -149,6 +175,14 @@ function OAuthAuthorizeContent() {
|
|||||||
}, [approve, clientId, isPinLocked, oauthQuery, sessionChecked, token]);
|
}, [approve, clientId, isPinLocked, oauthQuery, sessionChecked, token]);
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
|
if (popupContext) {
|
||||||
|
postOneTapResult(popupContext.popupOrigin, {
|
||||||
|
error: 'access_denied',
|
||||||
|
errorDescription: 'Пользователь отклонил запрос'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!redirectUri) {
|
if (!redirectUri) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
return;
|
return;
|
||||||
@@ -162,7 +196,7 @@ function OAuthAuthorizeContent() {
|
|||||||
} catch {
|
} catch {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
}, [redirectUri, router, state]);
|
}, [popupContext, redirectUri, router, state]);
|
||||||
|
|
||||||
const authReady = Boolean(user && token && !isPinLocked && sessionChecked);
|
const authReady = Boolean(user && token && !isPinLocked && sessionChecked);
|
||||||
const scopeItems = consentInfo?.requestedScopes ?? [];
|
const scopeItems = consentInfo?.requestedScopes ?? [];
|
||||||
@@ -205,10 +239,12 @@ function OAuthAuthorizeContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12">
|
<div className={`mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 ${isPopupMode ? 'py-6' : 'py-12'}`}>
|
||||||
|
{!isPopupMode ? (
|
||||||
<div className="mb-8 flex justify-center">
|
<div className="mb-8 flex justify-center">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||||
<ShieldCheck className="h-6 w-6" />
|
<ShieldCheck className="h-6 w-6" />
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
PublicUser,
|
PublicUser,
|
||||||
refreshAuthSession,
|
refreshAuthSession,
|
||||||
resetPinRequiredNotification,
|
resetPinRequiredNotification,
|
||||||
setPinRequiredHandler
|
setPinRequiredHandler,
|
||||||
|
syncFedcmSession
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { useToast } from './toast-provider';
|
import { useToast } from './toast-provider';
|
||||||
import { PinLockModal } from './pin-lock-modal';
|
import { PinLockModal } from './pin-lock-modal';
|
||||||
@@ -89,6 +90,10 @@ function applySessionState(
|
|||||||
setters.activatePinLock(session.sessionId ?? '');
|
setters.activatePinLock(session.sessionId ?? '');
|
||||||
} else {
|
} else {
|
||||||
setters.clearPinLock();
|
setters.clearPinLock();
|
||||||
|
const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
if (accessToken) {
|
||||||
|
void syncFedcmSession(accessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +159,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cacheUserProfile(auth.user);
|
cacheUserProfile(auth.user);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
clearPinLock();
|
clearPinLock();
|
||||||
|
if (auth.pinVerified !== false) {
|
||||||
|
void syncFedcmSession(auth.accessToken);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[clearPinLock]
|
[clearPinLock]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { OAuthClient } from '@/lib/api';
|
import { OAuthClient } from '@/lib/api';
|
||||||
import { OAuthEndpoints, buildAuthorizeUrl } from '@/lib/oauth-url';
|
import { OAuthEndpoints, buildAuthorizeUrl } from '@/lib/oauth-url';
|
||||||
|
import { OAuthClientOneTapSection } from '@/components/id/oauth-client-one-tap-section';
|
||||||
|
|
||||||
function CopyRow({ label, value, onCopy }: { label: string; value: string; onCopy: (value: string, label: string) => void }) {
|
function CopyRow({ label, value, onCopy }: { label: string; value: string; onCopy: (value: string, label: string) => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +25,8 @@ function CopyRow({ label, value, onCopy }: { label: string; value: string; onCop
|
|||||||
export function OAuthClientDetailDialog({
|
export function OAuthClientDetailDialog({
|
||||||
client,
|
client,
|
||||||
endpoints,
|
endpoints,
|
||||||
|
frontendBase,
|
||||||
|
projectName,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCopy,
|
onCopy,
|
||||||
@@ -34,6 +37,8 @@ export function OAuthClientDetailDialog({
|
|||||||
}: {
|
}: {
|
||||||
client: OAuthClient | null;
|
client: OAuthClient | null;
|
||||||
endpoints: OAuthEndpoints;
|
endpoints: OAuthEndpoints;
|
||||||
|
frontendBase: string;
|
||||||
|
projectName: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCopy: (value: string, label: string) => void;
|
onCopy: (value: string, label: string) => void;
|
||||||
@@ -136,7 +141,7 @@ export function OAuthClientDetailDialog({
|
|||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="font-medium">Подключение OAuth 2.0 / OpenID Connect</h3>
|
<h3 className="font-medium">Подключение OAuth 2.0 / OpenID Connect</h3>
|
||||||
<p className="text-xs text-[#667085]">
|
<p className="text-xs text-[#667085]">
|
||||||
Issuer и endpoints берутся из настройки <strong>PUBLIC_API_URL</strong> в админке. В стороннем сервисе укажите issuer = базовый URL API, не authorization endpoint.
|
Issuer и endpoints берутся из настроек <strong>PUBLIC_API_URL</strong> и <strong>PUBLIC_FRONTEND_URL</strong> в админке.
|
||||||
</p>
|
</p>
|
||||||
<CopyRow label="Issuer (PUBLIC_API_URL)" value={endpoints.issuer} onCopy={onCopy} />
|
<CopyRow label="Issuer (PUBLIC_API_URL)" value={endpoints.issuer} onCopy={onCopy} />
|
||||||
<CopyRow label="Authorization endpoint" value={endpoints.authorizationEndpoint} onCopy={onCopy} />
|
<CopyRow label="Authorization endpoint" value={endpoints.authorizationEndpoint} onCopy={onCopy} />
|
||||||
@@ -146,6 +151,14 @@ export function OAuthClientDetailDialog({
|
|||||||
<CopyRow label="Пример authorize URL" value={sampleAuthorizeUrl} onCopy={onCopy} />
|
<CopyRow label="Пример authorize URL" value={sampleAuthorizeUrl} onCopy={onCopy} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<OAuthClientOneTapSection
|
||||||
|
client={client}
|
||||||
|
endpoints={endpoints}
|
||||||
|
frontendBase={frontendBase}
|
||||||
|
projectName={projectName}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="rounded-2xl border border-[#eceef4] p-4">
|
<section className="rounded-2xl border border-[#eceef4] p-4">
|
||||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||||
<KeyRound className="h-4 w-4" />
|
<KeyRound className="h-4 w-4" />
|
||||||
|
|||||||
137
apps/frontend/components/id/oauth-client-one-tap-section.tsx
Normal file
137
apps/frontend/components/id/oauth-client-one-tap-section.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Copy, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { OAuthClient } from '@/lib/api';
|
||||||
|
import { buildOneTapExamples } from '@/lib/one-tap-examples';
|
||||||
|
import { OAuthEndpoints } from '@/lib/oauth-url';
|
||||||
|
|
||||||
|
function SnippetBlock({ code, onCopy }: { code: string; onCopy: (value: string, label: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-2xl bg-[#111827] p-4 text-xs text-[#e5e7eb]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-2 h-8 w-8 text-white hover:bg-white/10 hover:text-white"
|
||||||
|
aria-label="Скопировать код"
|
||||||
|
onClick={() => onCopy(code, 'Код интеграции')}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all pr-10">{code}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OAuthClientOneTapSection({
|
||||||
|
client,
|
||||||
|
endpoints,
|
||||||
|
frontendBase,
|
||||||
|
projectName,
|
||||||
|
onCopy
|
||||||
|
}: {
|
||||||
|
client: OAuthClient;
|
||||||
|
endpoints: OAuthEndpoints;
|
||||||
|
frontendBase: string;
|
||||||
|
projectName: string;
|
||||||
|
onCopy: (value: string, label: string) => void;
|
||||||
|
}) {
|
||||||
|
const primaryRedirect = client.redirectUris[0] ?? `${frontendBase}/auth/callback`;
|
||||||
|
const examples = useMemo(
|
||||||
|
() =>
|
||||||
|
buildOneTapExamples(
|
||||||
|
{
|
||||||
|
apiBase: endpoints.issuer,
|
||||||
|
frontendBase,
|
||||||
|
widgetUrl: endpoints.widgetUrl,
|
||||||
|
fedcmConfigUrl: endpoints.fedcmConfigUrl,
|
||||||
|
webIdentityUrl: endpoints.webIdentityUrl,
|
||||||
|
fedcmDiscoverUrl: endpoints.fedcmDiscoverUrl,
|
||||||
|
projectName
|
||||||
|
},
|
||||||
|
client.clientId,
|
||||||
|
primaryRedirect
|
||||||
|
),
|
||||||
|
[client.clientId, endpoints, frontendBase, primaryRedirect, projectName]
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState(examples[0]?.id ?? 'widget-script');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 rounded-2xl border border-[#eceef4] p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">One Tap Login</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-[#667085]">
|
||||||
|
FedCM (Chrome/Edge) и виджет <code className="rounded bg-[#f4f5f8] px-1 py-0.5">sso-widget.js</code> с popup fallback.
|
||||||
|
Убедитесь, что в настройках указаны <strong>PUBLIC_API_URL</strong> и <strong>PUBLIC_FRONTEND_URL</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<CopyRowCompact label="FedCM config" value={endpoints.fedcmConfigUrl} onCopy={onCopy} />
|
||||||
|
<CopyRowCompact label="Web identity" value={endpoints.webIdentityUrl} onCopy={onCopy} />
|
||||||
|
<CopyRowCompact label="Виджет" value={endpoints.widgetUrl} onCopy={onCopy} />
|
||||||
|
<CopyRowCompact label="Discover" value={endpoints.fedcmDiscoverUrl} onCopy={onCopy} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="mb-2 flex h-auto max-w-full flex-wrap gap-1">
|
||||||
|
{examples.map((example) => (
|
||||||
|
<TabsTrigger key={example.id} value={example.id} className="text-xs">
|
||||||
|
{example.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{examples.map((example) => (
|
||||||
|
<TabsContent key={example.id} value={example.id}>
|
||||||
|
<SnippetBlock code={example.code} onCopy={onCopy} />
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-[#eef4ff] px-4 py-3 text-xs text-[#1d4ed8]">
|
||||||
|
<p className="font-medium">Проверка FedCM</p>
|
||||||
|
<ol className="mt-2 list-decimal space-y-1 pl-4">
|
||||||
|
<li>Войдите на IdP ({frontendBase}) в этом браузере.</li>
|
||||||
|
<li>Откройте сайт клиента с виджетом — Chrome покажет нативный диалог или плашку One Tap.</li>
|
||||||
|
<li>
|
||||||
|
При ошибке NetworkError проверьте, что <code>PUBLIC_API_URL</code> = <code>{endpoints.issuer}</code>.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<a
|
||||||
|
href={`${frontendBase.replace(/\/$/, '')}/sso-widget.js`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-1 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Открыть sso-widget.js
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyRowCompact({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onCopy
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onCopy: (value: string, label: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-[#f4f5f8] p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label={`Скопировать ${label}`} onClick={() => onCopy(value, label)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<code className="block break-all text-[11px] text-[#667085]">{value}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -494,6 +494,19 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
|
|||||||
return response.json() as Promise<RefreshSessionResponse>;
|
return response.json() as Promise<RefreshSessionResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function syncFedcmSession(accessToken: string) {
|
||||||
|
if (typeof window === 'undefined' || !accessToken.trim()) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${getApiUrl()}/fedcm/session/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken.trim()}` },
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// фоновая синхронизация FedCM cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
|
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
|
||||||
try {
|
try {
|
||||||
const refreshed = await refreshAuthSession();
|
const refreshed = await refreshAuthSession();
|
||||||
|
|||||||
35
apps/frontend/lib/oauth-popup-bridge.ts
Normal file
35
apps/frontend/lib/oauth-popup-bridge.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export const ONE_TAP_MESSAGE_TYPE = 'lendry-sso-onetap';
|
||||||
|
|
||||||
|
export function parsePopupOAuthParams(searchParams: URLSearchParams) {
|
||||||
|
const display = searchParams.get('display');
|
||||||
|
const popupOrigin = searchParams.get('popup_origin');
|
||||||
|
if (display !== 'popup' || !popupOrigin?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { popupOrigin: new URL(popupOrigin.trim()).origin };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postOneTapResult(popupOrigin: string, payload: Record<string, unknown>) {
|
||||||
|
if (!window.opener) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.opener.postMessage({ type: ONE_TAP_MESSAGE_TYPE, ...payload }, popupOrigin);
|
||||||
|
window.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAuthorizationRedirect(redirectUrl: string) {
|
||||||
|
const url = new URL(redirectUrl);
|
||||||
|
return {
|
||||||
|
code: url.searchParams.get('code'),
|
||||||
|
state: url.searchParams.get('state'),
|
||||||
|
error: url.searchParams.get('error'),
|
||||||
|
errorDescription: url.searchParams.get('error_description')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,12 +2,29 @@ export function normalizeBaseUrl(url: string) {
|
|||||||
return url.trim().replace(/\/+$/, '');
|
return url.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveOAuthApiBase(settings: Record<string, string>, fallback = 'http://localhost:3000') {
|
export function resolveOAuthApiBase(settings: Record<string, string>, fallback = 'http://localhost:3002/idp-api') {
|
||||||
const publicApi = settings.PUBLIC_API_URL?.trim();
|
const publicApi = settings.PUBLIC_API_URL?.trim();
|
||||||
if (publicApi) {
|
if (publicApi) {
|
||||||
return normalizeBaseUrl(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(/^\/+/, '')}/idp-api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeBaseUrl(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFrontendBase(settings: Record<string, string>, fallback = 'http://localhost:3002') {
|
||||||
|
const publicFrontend = settings.PUBLIC_FRONTEND_URL?.trim();
|
||||||
|
if (publicFrontend) {
|
||||||
|
return normalizeBaseUrl(publicFrontend);
|
||||||
|
}
|
||||||
|
|
||||||
const domain = settings.PROJECT_DOMAIN?.trim();
|
const domain = settings.PROJECT_DOMAIN?.trim();
|
||||||
if (domain) {
|
if (domain) {
|
||||||
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
||||||
@@ -26,20 +43,48 @@ export interface OAuthEndpoints {
|
|||||||
userInfoEndpoint: string;
|
userInfoEndpoint: string;
|
||||||
openIdConfigurationUrl: string;
|
openIdConfigurationUrl: string;
|
||||||
jwksUrl: string;
|
jwksUrl: string;
|
||||||
|
webIdentityUrl: string;
|
||||||
|
fedcmConfigUrl: string;
|
||||||
|
fedcmDiscoverUrl: string;
|
||||||
|
fedcmAccountsUrl: string;
|
||||||
|
fedcmIdAssertionUrl: string;
|
||||||
|
widgetUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints {
|
export function buildOAuthEndpoints(apiBase: string, frontendBase?: string): OAuthEndpoints {
|
||||||
const base = normalizeBaseUrl(apiBase);
|
const base = normalizeBaseUrl(apiBase);
|
||||||
|
const front = normalizeBaseUrl(frontendBase ?? deriveFrontendBaseFromApi(base));
|
||||||
|
let webIdentityOrigin = front;
|
||||||
|
|
||||||
|
try {
|
||||||
|
webIdentityOrigin = new URL(base).origin;
|
||||||
|
} catch {
|
||||||
|
// keep frontend origin
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issuer: base,
|
issuer: base,
|
||||||
authorizationEndpoint: `${base}/oauth/authorize`,
|
authorizationEndpoint: `${base}/oauth/authorize`,
|
||||||
tokenEndpoint: `${base}/oauth/token`,
|
tokenEndpoint: `${base}/oauth/token`,
|
||||||
userInfoEndpoint: `${base}/oauth/userinfo`,
|
userInfoEndpoint: `${base}/oauth/userinfo`,
|
||||||
openIdConfigurationUrl: `${base}/.well-known/openid-configuration`,
|
openIdConfigurationUrl: `${base}/.well-known/openid-configuration`,
|
||||||
jwksUrl: `${base}/.well-known/jwks.json`
|
jwksUrl: `${base}/.well-known/jwks.json`,
|
||||||
|
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`,
|
||||||
|
fedcmConfigUrl: `${base}/fedcm/config.json`,
|
||||||
|
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
|
||||||
|
fedcmAccountsUrl: `${base}/fedcm/accounts`,
|
||||||
|
fedcmIdAssertionUrl: `${base}/fedcm/id_assertion`,
|
||||||
|
widgetUrl: `${front}/sso-widget.js`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveFrontendBaseFromApi(apiBase: string) {
|
||||||
|
if (apiBase.endsWith('/idp-api')) {
|
||||||
|
return apiBase.replace(/\/idp-api$/, '');
|
||||||
|
}
|
||||||
|
return apiBase;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAuthorizeUrl(
|
export function buildAuthorizeUrl(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string; useStandardParams?: boolean }
|
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string; useStandardParams?: boolean }
|
||||||
|
|||||||
130
apps/frontend/lib/one-tap-examples.ts
Normal file
130
apps/frontend/lib/one-tap-examples.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
export interface OneTapUrls {
|
||||||
|
apiBase: string;
|
||||||
|
frontendBase: string;
|
||||||
|
widgetUrl: string;
|
||||||
|
fedcmConfigUrl: string;
|
||||||
|
webIdentityUrl: string;
|
||||||
|
fedcmDiscoverUrl: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneTapExample {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
language: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOneTapUrls(
|
||||||
|
apiBase: string,
|
||||||
|
frontendBase: string,
|
||||||
|
projectName = 'MVK ID'
|
||||||
|
): OneTapUrls {
|
||||||
|
const base = apiBase.replace(/\/+$/, '');
|
||||||
|
const front = frontendBase.replace(/\/+$/, '');
|
||||||
|
let webIdentityOrigin = front;
|
||||||
|
|
||||||
|
try {
|
||||||
|
webIdentityOrigin = new URL(base).origin;
|
||||||
|
} catch {
|
||||||
|
// keep frontend origin
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiBase: base,
|
||||||
|
frontendBase: front,
|
||||||
|
widgetUrl: `${front}/sso-widget.js`,
|
||||||
|
fedcmConfigUrl: `${base}/fedcm/config.json`,
|
||||||
|
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
|
||||||
|
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`,
|
||||||
|
projectName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOneTapExamples(urls: OneTapUrls, clientId: string, redirectUri?: string): OneTapExample[] {
|
||||||
|
const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, webIdentityUrl, projectName } = urls;
|
||||||
|
const callbackUri = redirectUri?.trim() || 'https://app.example.com/auth/callback';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'widget-script',
|
||||||
|
label: 'Виджет (script)',
|
||||||
|
language: 'html',
|
||||||
|
code: `<script
|
||||||
|
src="${widgetUrl}"
|
||||||
|
data-client-id="${clientId}"
|
||||||
|
data-idp-url="${apiBase}"
|
||||||
|
data-idp-frontend-url="${frontendBase}"
|
||||||
|
data-provider-name="${projectName}"
|
||||||
|
data-redirect-uri="${callbackUri}"
|
||||||
|
data-on-success="handleLendryLogin"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function handleLendryLogin(payload) {
|
||||||
|
// payload.method — 'fedcm' | 'popup'
|
||||||
|
// payload.token / payload.idToken / payload.code
|
||||||
|
console.log('Вход через', payload.method, payload);
|
||||||
|
}
|
||||||
|
</script>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sdk-manual',
|
||||||
|
label: 'SDK вручную',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `<script src="${widgetUrl}" data-auto-init="false"></script>
|
||||||
|
<script>
|
||||||
|
LendryIdOneTap.init({
|
||||||
|
clientId: '${clientId}',
|
||||||
|
idpUrl: '${apiBase}',
|
||||||
|
frontendUrl: '${frontendBase}',
|
||||||
|
providerName: '${projectName}',
|
||||||
|
redirectUri: '${callbackUri}'
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('lendry-sso-onetap-success', (event) => {
|
||||||
|
console.log(event.detail);
|
||||||
|
});
|
||||||
|
</script>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fedcm-native',
|
||||||
|
label: 'FedCM API',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `const credential = await navigator.credentials.get({
|
||||||
|
identity: {
|
||||||
|
providers: [{
|
||||||
|
configURL: '${fedcmConfigUrl}',
|
||||||
|
clientId: '${clientId}'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
mediation: 'optional'
|
||||||
|
});
|
||||||
|
|
||||||
|
const idToken = credential?.token; // JWT — проверьте на backend`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'postmessage',
|
||||||
|
label: 'Popup postMessage',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `window.addEventListener('message', (event) => {
|
||||||
|
if (event.origin !== '${frontendBase}') return;
|
||||||
|
if (event.data?.type !== 'lendry-sso-onetap') return;
|
||||||
|
|
||||||
|
const { code, idToken, token, error } = event.data;
|
||||||
|
if (error) return console.error(error);
|
||||||
|
|
||||||
|
// code — обменяйте на POST ${apiBase}/oauth/token
|
||||||
|
// idToken / token — проверьте JWT (issuer = ${apiBase})
|
||||||
|
});`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'curl-fedcm',
|
||||||
|
label: 'Диагностика (curl)',
|
||||||
|
language: 'bash',
|
||||||
|
code: `curl -sH "Sec-Fetch-Dest: webidentity" ${webIdentityUrl}
|
||||||
|
curl -sH "Sec-Fetch-Dest: webidentity" ${fedcmConfigUrl}
|
||||||
|
curl -s ${apiBase}/fedcm/discover.json`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface SystemSettingMeta {
|
|||||||
|
|
||||||
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
||||||
general: 'Проект и брендинг',
|
general: 'Проект и брендинг',
|
||||||
|
onetap: 'One Tap Login / FedCM',
|
||||||
pin: 'PIN-код и блокировка сессии',
|
pin: 'PIN-код и блокировка сессии',
|
||||||
auth: 'Аутентификация и регистрация',
|
auth: 'Аутентификация и регистрация',
|
||||||
ldap: 'LDAP / Active Directory',
|
ldap: 'LDAP / Active Directory',
|
||||||
@@ -30,7 +31,37 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
|||||||
label: 'URL API (OAuth issuer)',
|
label: 'URL API (OAuth issuer)',
|
||||||
group: 'general',
|
group: 'general',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
hint: 'Базовый URL API для OAuth/OIDC. Пример: https://sso.example.ru/idp-api или https://api.example.ru'
|
hint: 'Базовый URL API для OAuth/OIDC и FedCM. При same-origin деплое: https://ваш-домен/idp-api'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PUBLIC_FRONTEND_URL',
|
||||||
|
label: 'URL Frontend IdP',
|
||||||
|
group: 'general',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'Публичный адрес интерфейса IdP. Используется для login_url FedCM и popup OAuth'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ONE_TAP_ENABLED',
|
||||||
|
label: 'One Tap Login включён',
|
||||||
|
group: 'onetap',
|
||||||
|
type: 'boolean',
|
||||||
|
hint: 'FedCM и виджет sso-widget.js. При отключении остаётся только стандартный OAuth redirect'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FEDCM_PRIVACY_POLICY_URL',
|
||||||
|
label: 'FedCM — политика конфиденциальности',
|
||||||
|
group: 'onetap',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'URL для UI FedCM. Пусто — используется страница «Данные» IdP',
|
||||||
|
showWhen: { key: 'ONE_TAP_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FEDCM_TERMS_URL',
|
||||||
|
label: 'FedCM — условия использования',
|
||||||
|
group: 'onetap',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'URL для UI FedCM. Пусто — используется страница «Данные» IdP',
|
||||||
|
showWhen: { key: 'ONE_TAP_ENABLED', equals: 'true' }
|
||||||
},
|
},
|
||||||
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' },
|
{ 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_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' },
|
||||||
|
|||||||
@@ -71,12 +71,14 @@
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestFedCM(configBase) {
|
||||||
return global.navigator.credentials
|
return global.navigator.credentials
|
||||||
.get({
|
.get({
|
||||||
identity: {
|
identity: {
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
configURL: idpApiBase + '/fedcm/config.json',
|
configURL: configBase + '/fedcm/config.json',
|
||||||
|
configUrl: configBase + '/fedcm/config.json',
|
||||||
clientId: clientId
|
clientId: clientId
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -91,10 +93,44 @@
|
|||||||
token: token,
|
token: token,
|
||||||
method: 'fedcm'
|
method: 'fedcm'
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return global.fetch(idpApiBase + '/fedcm/discover.json', { credentials: 'omit' })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return idpApiBase;
|
||||||
|
}
|
||||||
|
return response.json().then(function (payload) {
|
||||||
|
if (payload && payload.enabled === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (payload && payload.apiBase) {
|
||||||
|
return trimSlash(payload.apiBase);
|
||||||
|
}
|
||||||
|
return idpApiBase;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
return idpApiBase;
|
||||||
|
})
|
||||||
|
.then(function (resolvedBase) {
|
||||||
|
if (!resolvedBase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return requestFedCM(resolvedBase);
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
if (global.console && typeof global.console.warn === 'function') {
|
if (global.console && typeof global.console.warn === 'function') {
|
||||||
global.console.warn('[MVK ID] FedCM недоступен:', error);
|
global.console.warn(
|
||||||
|
'[MVK ID] FedCM недоступен:',
|
||||||
|
error,
|
||||||
|
'Проверьте доступность',
|
||||||
|
idpApiBase + '/fedcm/config.json',
|
||||||
|
'и',
|
||||||
|
idpApiBase.replace(/\/idp-api$/, '') + '/.well-known/web-identity',
|
||||||
|
'(PUBLIC_API_URL должен совпадать с data-idp-url виджета)'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -232,10 +268,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportsFedCM()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createWidget(providerName, function () {
|
createWidget(providerName, function () {
|
||||||
var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope);
|
var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope);
|
||||||
openPopup(popupUrl, frontendBase, function (payload) {
|
openPopup(popupUrl, frontendBase, function (payload) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/
|
|||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { OAuthCoreService } from './oauth-core.service';
|
import { OAuthCoreService } from './oauth-core.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
|
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,7 +10,8 @@ export class FedcmService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly session: SessionService,
|
private readonly session: SessionService,
|
||||||
private readonly oauthCore: OAuthCoreService
|
private readonly oauthCore: OAuthCoreService,
|
||||||
|
private readonly settings: SettingsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAccounts(userId: string, sessionId: string) {
|
async getAccounts(userId: string, sessionId: string) {
|
||||||
@@ -81,12 +83,21 @@ export class FedcmService {
|
|||||||
throw new BadRequestException('OAuth-приложение не найдено или отключено');
|
throw new BadRequestException('OAuth-приложение не найдено или отключено');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const frontendUrl = (await this.settings.getValue('PUBLIC_FRONTEND_URL', 'http://localhost:3002')).replace(/\/+$/, '');
|
||||||
|
const privacyFallback = `${frontendUrl}/data`;
|
||||||
|
const privacy = (await this.settings.getValue('FEDCM_PRIVACY_POLICY_URL', '')).trim();
|
||||||
|
const terms = (await this.settings.getValue('FEDCM_TERMS_URL', '')).trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
privacy_policy_url: 'https://id.lendry.ru/data',
|
privacy_policy_url: privacy || privacyFallback,
|
||||||
terms_of_service_url: 'https://id.lendry.ru/data'
|
terms_of_service_url: terms || privacyFallback
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isOneTapEnabled() {
|
||||||
|
return this.settings.getBoolean('ONE_TAP_ENABLED', true);
|
||||||
|
}
|
||||||
|
|
||||||
private async assertUnlockedSession(userId: string, sessionId: string) {
|
private async assertUnlockedSession(userId: string, sessionId: string) {
|
||||||
const state = await this.session.resolveSessionState(sessionId);
|
const state = await this.session.resolveSessionState(sessionId);
|
||||||
if (!state || state.userId !== userId) {
|
if (!state || state.userId !== userId) {
|
||||||
|
|||||||
@@ -7,9 +7,29 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
|||||||
{ key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP (без пути)' },
|
{ key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP (без пути)' },
|
||||||
{
|
{
|
||||||
key: 'PUBLIC_API_URL',
|
key: 'PUBLIC_API_URL',
|
||||||
value: 'http://localhost:3000',
|
value: 'http://localhost:3002/idp-api',
|
||||||
description: 'Публичный базовый URL API для OAuth и OpenID Connect (issuer). Пример: https://sso.example.ru/idp-api'
|
description: 'Публичный базовый URL API для OAuth и OpenID Connect (issuer). Пример: https://sso.example.ru/idp-api'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'PUBLIC_FRONTEND_URL',
|
||||||
|
value: 'http://localhost:3002',
|
||||||
|
description: 'Публичный URL frontend IdP. Используется для login_url FedCM, popup OAuth и виджета sso-widget.js'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ONE_TAP_ENABLED',
|
||||||
|
value: 'true',
|
||||||
|
description: 'Разрешить One Tap Login (FedCM и виджет sso-widget.js) для OAuth-клиентов'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FEDCM_PRIVACY_POLICY_URL',
|
||||||
|
value: '',
|
||||||
|
description: 'URL политики конфиденциальности для UI FedCM. Пусто — страница «Данные» IdP'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FEDCM_TERMS_URL',
|
||||||
|
value: '',
|
||||||
|
description: 'URL условий использования для UI FedCM. Пусто — страница «Данные» IdP'
|
||||||
|
},
|
||||||
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', value: '15', description: 'Через сколько минут неактивности сессия блокируется PIN-кодом' },
|
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', value: '15', description: 'Через сколько минут неактивности сессия блокируется PIN-кодом' },
|
||||||
{ key: 'PIN_DELETE_GRACE_MINUTES', value: '1440', description: 'Задержка перед окончательным удалением PIN-кода после запроса (минуты)' },
|
{ key: 'PIN_DELETE_GRACE_MINUTES', value: '1440', description: 'Задержка перед окончательным удалением PIN-кода после запроса (минуты)' },
|
||||||
{ key: 'PIN_REQUIRE_ON_DELETE', value: 'true', description: 'Требовать текущий PIN-код при запросе удаления защиты' },
|
{ key: 'PIN_REQUIRE_ON_DELETE', value: 'true', description: 'Требовать текущий PIN-код при запросе удаления защиты' },
|
||||||
@@ -102,6 +122,8 @@ export const PUBLIC_SETTING_KEYS = [
|
|||||||
'PROJECT_TAGLINE',
|
'PROJECT_TAGLINE',
|
||||||
'PROJECT_DOMAIN',
|
'PROJECT_DOMAIN',
|
||||||
'PUBLIC_API_URL',
|
'PUBLIC_API_URL',
|
||||||
|
'PUBLIC_FRONTEND_URL',
|
||||||
|
'ONE_TAP_ENABLED',
|
||||||
'REGISTRATION_ENABLED',
|
'REGISTRATION_ENABLED',
|
||||||
'MAINTENANCE_MODE',
|
'MAINTENANCE_MODE',
|
||||||
'LDAP_ENABLED',
|
'LDAP_ENABLED',
|
||||||
@@ -133,5 +155,18 @@ export class SystemSettingsSeedService implements OnModuleInit {
|
|||||||
update: { value: envPublicApiUrl }
|
update: { value: envPublicApiUrl }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const envPublicFrontendUrl = process.env.PUBLIC_FRONTEND_URL?.trim();
|
||||||
|
if (envPublicFrontendUrl) {
|
||||||
|
await this.prisma.systemSetting.upsert({
|
||||||
|
where: { key: 'PUBLIC_FRONTEND_URL' },
|
||||||
|
create: {
|
||||||
|
key: 'PUBLIC_FRONTEND_URL',
|
||||||
|
value: envPublicFrontendUrl,
|
||||||
|
description: 'Публичный URL frontend IdP. Используется для login_url FedCM, popup OAuth и виджета sso-widget.js'
|
||||||
|
},
|
||||||
|
update: { value: envPublicFrontendUrl }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ services:
|
|||||||
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id}
|
MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id}
|
||||||
MINIO_USE_SSL: ${MINIO_USE_SSL:-false}
|
MINIO_USE_SSL: ${MINIO_USE_SSL:-false}
|
||||||
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
RABBITMQ_URL: amqp://${RABBITMQ_DEFAULT_USER:-lendry}:${RABBITMQ_DEFAULT_PASS:-lendry_password}@rabbitmq:5672/
|
RABBITMQ_URL: amqp://${RABBITMQ_DEFAULT_USER:-lendry}:${RABBITMQ_DEFAULT_PASS:-lendry_password}@rabbitmq:5672/
|
||||||
LDAP_AUTH_URL: http://ldap-auth:8086
|
LDAP_AUTH_URL: http://ldap-auth:8086
|
||||||
ports:
|
ports:
|
||||||
@@ -135,7 +135,7 @@ services:
|
|||||||
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id}
|
MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id}
|
||||||
MINIO_USE_SSL: ${MINIO_USE_SSL:-false}
|
MINIO_USE_SSL: ${MINIO_USE_SSL:-false}
|
||||||
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -201,7 +201,7 @@ services:
|
|||||||
dockerfile: apps/frontend/Dockerfile
|
dockerfile: apps/frontend/Dockerfile
|
||||||
args:
|
args:
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}
|
||||||
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:8085/ws}
|
NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:8085/ws}
|
||||||
INTERNAL_API_URL: ${INTERNAL_API_URL:-http://api-gateway:3000}
|
INTERNAL_API_URL: ${INTERNAL_API_URL:-http://api-gateway:3000}
|
||||||
INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085}
|
INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085}
|
||||||
@@ -211,7 +211,7 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_TELEMETRY_DISABLED: 1
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:8085/ws}
|
NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:8085/ws}
|
||||||
INTERNAL_API_URL: ${INTERNAL_API_URL:-http://api-gateway:3000}
|
INTERNAL_API_URL: ${INTERNAL_API_URL:-http://api-gateway:3000}
|
||||||
INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085}
|
INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085}
|
||||||
@@ -238,9 +238,9 @@ services:
|
|||||||
container_name: lendry-id-tauri-apk-builder
|
container_name: lendry-id-tauri-apk-builder
|
||||||
restart: "no"
|
restart: "no"
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
VITE_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
VITE_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
|
VITE_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
|
||||||
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
|
PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/frontend/public/downloads:/out
|
- ./apps/frontend/public/downloads:/out
|
||||||
@@ -252,7 +252,7 @@ services:
|
|||||||
dockerfile: apps/docs/Dockerfile
|
dockerfile: apps/docs/Dockerfile
|
||||||
args:
|
args:
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}
|
||||||
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
container_name: lendry-id-docs
|
container_name: lendry-id-docs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -262,7 +262,7 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_TELEMETRY_DISABLED: 1
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
|
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
INTERNAL_API_URL: http://api-gateway:3000
|
INTERNAL_API_URL: http://api-gateway:3000
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3003:3000"
|
- "127.0.0.1:3003:3000"
|
||||||
|
|||||||
Reference in New Issue
Block a user