diff --git a/docker-compose.yml b/docker-compose.yml index cf68f57..ee2308d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,10 +9,13 @@ services: - ./api:/usr/src/app - ./api/.env:/usr/src/app/.env - api-deps:/usr/src/app/node_modules + - ./keycloak:/keycloak:ro ports: - 4000:4000 depends_on: + - rabbitmq - mongodb + - redis restart: unless-stopped stdin_open: true tty: true @@ -57,6 +60,7 @@ services: - ./collector/.env.docker:/app/.env depends_on: - rabbitmq + - redis restart: unless-stopped prom-pushgateway: @@ -118,9 +122,34 @@ services: - 3900:80 restart: unless-stopped + keycloak: + image: quay.io/keycloak/keycloak:23.0 + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - KC_HTTP_PORT=8180 + - KC_HOSTNAME_STRICT=false + - KC_HOSTNAME_STRICT_HTTPS=false + - KC_HTTP_ENABLED=true + - KC_HEALTH_ENABLED=true + ports: + - 8180:8180 + command: + - start-dev + volumes: + - keycloak-data:/opt/keycloak/data + - ./keycloak:/opt/keycloak/config + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 10s + timeout: 5s + retries: 10 + volumes: mongodata: mongo-config: redis-data: api-deps: garage-deps: + keycloak-data: diff --git a/docs/sso-implementation.md b/docs/sso-implementation.md new file mode 100644 index 0000000..498bfdd --- /dev/null +++ b/docs/sso-implementation.md @@ -0,0 +1,1993 @@ +# План имплементации SSO (Single Sign-On) для Hawk + +## Обзор + +Этот документ описывает пошаговый план имплементации SSO функциональности согласно спецификации `docs/sso.md`. План разбит на этапы с конкретными задачами для разработчика. + +**Технологии:** +- API: Node.js, Express, GraphQL (Apollo Server), MongoDB +- Frontend: Vue 3, Vuex, Vue Router +- SAML библиотека: `@node-saml/node-saml` + +**Подход:** TDD для критичных частей (SAML endpoints), тесты по ходу разработки. + +--- + +## Этап 1: Подготовка окружения и зависимостей + +### 1.1 Установка зависимостей + +**Файл:** [`api/package.json`](../api/package.json) + +**Действия:** +1. Добавить зависимость `@node-saml/node-saml` в `dependencies` +2. Добавить типы `@types/node-saml` в `devDependencies` (если доступны) +3. Выполнить `yarn install` + +### 1.2 Обновление env переменных + +**Файл:** [`api/src/types/env.d.ts`](../api/src/types/env.d.ts) + +**Действия:** +1. Добавить типы для новых env переменных: + - `SSO_SP_ENTITY_ID` — уникальный идентификатор SP (например, `urn:hawk:tracker:saml`) + +**Код:** +```typescript +declare namespace NodeJS { + export interface ProcessEnv { + // ... существующие переменные + + /** + * SSO Service Provider Entity ID + * Unique identifier for Hawk in SAML IdP configuration + */ + SSO_SP_ENTITY_ID: string; + } +} +``` + +**Файл:** [`api/.env.sample`](../api/.env.sample) (обновить существующий) + +**Действия:** +1. Добавить примеры значений для новых переменных. С описанием. + +**Код:** +``` +SSO_SP_ENTITY_ID=urn:hawk:tracker:saml +``` + +--- + +## Этап 2: Создание структуры SSO модуля в API + +### 2.1 Создание директории и базовых файлов + +**Структура:** +``` +api/src/sso/ + ├── index.ts # Главный экспорт модуля + ├── saml/ + │ ├── index.ts # SAML роутер + │ ├── controller.ts # Обработчики SAML endpoints + │ ├── service.ts # Бизнес-логика SAML + │ ├── types.ts # TypeScript типы для SAML + │ └── utils.ts # Утилиты (парсинг, валидация) + └── types.ts # Общие типы SSO +``` + +**Действия:** +1. Создать директорию `api/src/sso/` +2. Создать поддиректорию `api/src/sso/saml/` +3. Создать пустые файлы для будущей реализации + +### 2.2 Создание типов для SSO + +**Файл:** [`api/src/sso/types.ts`](../api/src/sso/types.ts) + +**Действия:** +1. Определить TypeScript интерфейсы для SSO конфигурации: + - `WorkspaceSsoConfig` — общая конфигурация SSO + - `SamlConfig` — SAML-специфичная конфигурация + - `SamlAttributeMapping` — маппинг атрибутов + - `SamlResponseData` — данные из SAML Response + +**Код:** +```typescript +/** + * SAML attribute mapping configuration + */ +export interface SamlAttributeMapping { + /** + * Attribute name for email in SAML Assertion + * + * @example "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + * to get email from XML like this: + * + * alice@company.com + * + */ + email: string; + + /** + * Attribute name for user name in SAML Assertion + */ + name?: string; +} + +/** + * SAML SSO configuration + */ +export interface SamlConfig { + /** + * IdP Entity ID. + * Used to validate "this response is intended for Hawk" + * @example "urn:hawk:tracker:saml" + */ + idpEntityId: string; + + /** + * SSO URL for redirecting user to IdP + * Used to redirect user to IdP for authentication + * @example "https://idp.example.com/sso" + */ + ssoUrl: string; + + /** + * X.509 certificate for signature verification + * @example "-----BEGIN CERTIFICATE-----\nMIIDYjCCAkqgAwIBAgI...END CERTIFICATE-----" + */ + x509Cert: string; + + /** + * Desired NameID format + * @example "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + */ + nameIdFormat?: string; + + /** + * Attribute mapping configuration + * Used to extract user attributes from SAML Response + */ + attributeMapping: SamlAttributeMapping; +} + +/** + * SSO configuration for workspace + */ +export interface WorkspaceSsoConfig { + /** + * Is SSO enabled + */ + enabled: boolean; + + /** + * Is SSO enforced (only SSO login allowed) + * If true, login via email/password is not allowed + */ + enforced: boolean; + + /** + * SSO provider type + * Currently only SAML is supported. In future we can add other providers (OAuth 2, etc.) + */ + type: 'saml'; + + /** + * SAML-specific configuration. + * Got from IdP metadata. + */ + saml: SamlConfig; +} + +/** + * Data extracted from SAML Response + */ +export interface SamlResponseData { + /** + * NameID value (user identifier in IdP) + */ + nameId: string; + + /** + * User email + */ + email: string; + + /** + * User name (optional) + */ + name?: string; + + /** + * Identifier that should match AuthnRequest ID + * + * @example "_a8f7c3..." + */ + inResponseTo?: string; +} +``` + +**Файл:** [api/src/sso/saml/types.ts](../api/src/sso/saml/types.ts) + +**Действия:** +1. Определить внутренние типы для работы SAML модуля (не экспортируются наружу): + - Типы для промежуточных данных при парсинге SAML Response + - Типы для ошибок валидации SAML + - Типы для работы с библиотекой `@node-saml/node-saml` (если нужны обёртки) + +**Примечание:** Основные SAML типы (`SamlConfig`, `SamlAttributeMapping`, `SamlResponseData`) уже определены в `api/src/sso/types.ts`, так как они используются в общем интерфейсе `WorkspaceSsoConfig` и экспортируются наружу. Файл `saml/types.ts` предназначен для внутренних типов, используемых только внутри SAML модуля. + +--- + +## Этап 3: Обновление моделей данных + +### 3.1 Обновление типов Workspace + +**Файл:** [`types/src/dbScheme/workspace.ts`](../types/src/dbScheme/workspace.ts) + +**Действия:** +1. Добавить опциональное поле `sso` в интерфейс `WorkspaceDBScheme` +2. Использовать типы из `api/src/sso/types.ts` (или вынести в общий пакет [@hawk.so/types](../types/)) + +**Код:** +```typescript +import { WorkspaceSsoConfig } from '../../api/src/sso/types'; // или из общего пакета + +export interface WorkspaceDBScheme { + // ... существующие поля + + /** + * SSO configuration (optional, only for workspaces with SSO enabled) + */ + sso?: WorkspaceSsoConfig; +} +``` + +**Примечание:** Поля в MongoDB появятся автоматически при первом обновлении workspace с SSO конфигурацией. + +### 3.2 Обновление типов User + +**Файл:** [`types/src/dbScheme/user.ts`](../types/src/dbScheme/user.ts) + +**Действия:** +1. Добавить опциональное поле `identities` в интерфейс `UserDBScheme` + +**Код:** +```typescript +export interface UserDBScheme { + // ... существующие поля + + /** + * External identities for SSO (keyed by workspaceId) + */ + identities?: { + [workspaceId: string]: { + saml: { + /** + * NameID value from IdP (stable identifier) + */ + id: string; + + /** + * Email at the time of linking (for audit) + */ + email: string; + }; + }; + }; +} +``` + +### 3.3 Обновление `WorkspaceModel` + +**Файл:** [`api/src/models/workspace.ts`](../api/src/models/workspace.ts) + +**Действия:** +1. Добавить поле `sso` в класс `WorkspaceModel` +2. Добавить методы для работы с SSO (опционально, если нужна бизнес-логика на уровне модели) + +**Код:** +```typescript +import { WorkspaceSsoConfig } from '../sso/types'; + +export default class WorkspaceModel extends AbstractModel { + // ... существующие поля + + /** + * SSO configuration + */ + public sso?: WorkspaceSsoConfig; + + // ... существующие методы +} +``` + +### 3.4 Обновление `UserModel` + +**Файл:** [`api/src/models/user.ts`](../api/src/models/user.ts) + +**Действия:** +1. Добавить поле `identities` в класс `UserModel` +2. Добавить методы для работы с SSO identities: + - `linkSamlIdentity(workspaceId: string, samlId: string, email: string): Promise` + - `findBySamlIdentity(workspaceId: string, samlId: string): Promise` + - `getSamlIdentity(workspaceId: string): { id: string; email: string } | null` + +**Код:** +```typescript +export default class UserModel extends AbstractModel { + // ... существующие поля + + /** + * External identities for SSO + */ + public identities?: UserDBScheme['identities']; + + /** + * Link SAML identity to user for specific workspace + */ + public async linkSamlIdentity( + workspaceId: string, + samlId: string, + email: string + ): Promise { + const updateData: Partial = { + [`identities.${workspaceId}.saml.id`]: samlId, + [`identities.${workspaceId}.saml.email`]: email, + }; + + await this.update( + { _id: new ObjectId(this._id) }, + { $set: updateData } + ); + + // Обновить локальное состояние + if (!this.identities) { + this.identities = {}; + } + if (!this.identities[workspaceId]) { + this.identities[workspaceId] = { saml: { id: samlId, email } }; + } else { + this.identities[workspaceId].saml = { id: samlId, email }; + } + } + + /** + * Find user by SAML identity + */ + public static async findBySamlIdentity( + collection: Collection, + workspaceId: string, + samlId: string + ): Promise { + const userData = await collection.findOne({ + [`identities.${workspaceId}.saml.id`]: samlId, + }); + + return userData ? new UserModel(userData) : null; + } + + /** + * Get SAML identity for workspace + */ + public getSamlIdentity(workspaceId: string): { id: string; email: string } | null { + return this.identities?.[workspaceId]?.saml || null; + } +} +``` + +### 3.5 Обновление `UsersFactory` + +**Файл:** [`api/src/models/usersFactory.ts`](../api/src/models/usersFactory.ts) + +**Действия:** +1. Добавить метод `findBySamlIdentity(workspaceId: string, samlId: string): Promise` + +**Код:** +```typescript +export default class UsersFactory extends AbstractModelFactory { + // ... существующие методы + + /** + * Find user by SAML identity + */ + public async findBySamlIdentity(workspaceId: string, samlId: string): Promise { + const userData = await this.collection.findOne({ + [`identities.${workspaceId}.saml.id`]: samlId, + }); + + return userData ? new UserModel(userData) : null; + } +} +``` + +### 3.6 Тесты для моделей + +**Файл:** [`api/test/models/user.test.ts`](../api/test/models/user.test.ts) + +**Действия:** +1. Добавить тесты для методов работы с SSO identities: + - `linkSamlIdentity` — проверка привязки SAML identity к пользователю + - `findBySamlIdentity` — проверка поиска пользователя по SAML identity + - Проверка обновления поля `identities` в базе данных + +**Примечание:** Тесты пишутся сразу после реализации методов, следуя подходу TDD. + +--- + +## Этап 4: Реализация SAML Service (бизнес-логика) + +### 4.1 Создание SAML Service + +**Файл:** [`api/src/sso/saml/service.ts`](../api/src/sso/saml/service.ts) + +**Действия:** +1. Создать класс `SamlService` с методами: + - `generateAuthnRequest(workspaceId: string, acsUrl: string, relayState: string): Promise` + - `validateAndParseResponse(samlResponse: string, workspaceId: string, acsUrl: string): Promise` +2. Создать утилиты для валидации + - `validateAudience(audience: string): boolean` + - `validateRecipient(recipient: string, expectedAcsUrl: string): boolean` + - `validateInResponseTo(inResponseTo: string, workspaceId: string): Promise` + - `validateTimeConditions(notBefore: Date, notOnOrAfter: Date): boolean` + +**Код (структура):** +```typescript +import { SamlConfig, SamlResponseData } from '../types'; +import { SamlOptions, SAML } from '@node-saml/node-saml'; +import { ObjectId } from 'mongodb'; + +/** + * Service for SAML SSO operations + */ +export default class SamlService { + /** + * Generate SAML AuthnRequest + */ + public async generateAuthnRequest( + workspaceId: string, + acsUrl: string, + relayState: string, + samlConfig: SamlConfig + ): Promise { + // Реализация через @node-saml/node-saml + } + + /** + * Validate and parse SAML Response + */ + public async validateAndParseResponse( + samlResponse: string, + workspaceId: string, + acsUrl: string, + samlConfig: SamlConfig + ): Promise { + // Реализация валидации и парсинга + } + + // ... остальные методы +} +``` + +#### Валидация: модели ответственности + +Ответственность за валидацию (`validateAndParseResponse()`) можно формально разделить на + +1. Protocol validation (node-saml): + - подпись + - audience/recipient + - inResponseTo + - временные рамки + - извлечение NameID + attributes + +2. Business validation (Hawk API): + - доступ к workspace + - provisioning policy + - enforcement + - session TTL + - security/audit logging + +### 4.2 Unit тесты для SAML Service + +**Файл:** [`api/test/sso/saml/service.test.ts`](../api/test/sso/saml/service.test.ts) + +**Действия:** +1. Написать unit-тесты для методов `SamlService` (TDD подход): + - Генерация AuthnRequest — проверка корректности формирования запроса + - Валидация SAML Response — проверка парсинга и валидации + - Проверка Audience — валидация соответствия `SSO_SP_ENTITY_ID` + - Проверка Recipient — валидация соответствия ACS URL + - Проверка InResponseTo — валидация соответствия AuthnRequest ID + - Проверка временных условий — валидация `NotBefore` и `NotOnOrAfter` + +**Примечание:** Тесты пишутся параллельно с реализацией методов, следуя подходу TDD. + +### 4.3 Создание хранилища для RelayState и InResponseTo + +**Файл:** [`api/src/sso/saml/store.ts`](../api/src/sso/saml/store.ts) + +**Действия:** +1. Создать простой in-memory store (или использовать Redis, если доступен) +2. Реализовать методы: + - `saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void` + - `getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null` + - `saveAuthnRequest(requestId: string, workspaceId: string): void` + - `validateAndConsumeAuthnRequest(requestId: string, workspaceId: string): boolean` + +**Код (in-memory версия):** +```typescript +/** + * In-memory store for SAML state + * TODO: Replace with Redis for production + */ +class SamlStateStore { + private relayStates: Map = new Map(); + private authnRequests: Map = new Map(); + + private readonly TTL = 5 * 60 * 1000; // 5 minutes + + public saveRelayState(stateId: string, data: { returnUrl: string; workspaceId: string }): void { + this.relayStates.set(stateId, { + ...data, + expiresAt: Date.now() + this.TTL, + }); + } + + public getRelayState(stateId: string): { returnUrl: string; workspaceId: string } | null { + const state = this.relayStates.get(stateId); + + if (!state) { + return null; + } + + if (Date.now() > state.expiresAt) { + this.relayStates.delete(stateId); + return null; + } + + return { returnUrl: state.returnUrl, workspaceId: state.workspaceId }; + } + + // ... остальные методы +} + +export default new SamlStateStore(); +``` + +### 4.4 Replace in-memory implementation of SamlStateStore with Redis + +--- + +## Этап 5: Реализация SAML HTTP Endpoints + +### 5.1 Создание SAML Controller + +**Файл:** [`api/src/sso/saml/controller.ts`](../api/src/sso/saml/controller.ts) + +**Действия:** +1. Создать класс `SamlController` с методами: + - `getAcsUrl(workspaceId: string): string` — приватный метод для формирования ACS URL + - `initiateLogin(req: express.Request, res: express.Response): Promise` + - `handleAcs(req: express.Request, res: express.Response): Promise` + +**Код (структура):** +```typescript +import express from 'express'; +import { ObjectId } from 'mongodb'; +import SamlService from './service'; +import samlStore from './store'; +import { ContextFactories } from '../../types/graphql'; +import { AuthenticationError, UserInputError } from 'apollo-server-express'; +import jwt, { Secret } from 'jsonwebtoken'; + +/** + * Controller for SAML SSO endpoints + */ +export default class SamlController { + private samlService: SamlService; + private factories: ContextFactories; + + constructor(factories: ContextFactories) { + this.samlService = new SamlService(); + this.factories = factories; + } + + /** + * Compose Assertion Consumer Service URL for workspace + * @param workspaceId - workspace ID + * @returns ACS URL + */ + private getAcsUrl(workspaceId: string): string { + const apiUrl = process.env.API_URL || 'http://localhost:4000'; + return `${apiUrl}/auth/sso/saml/${workspaceId}/acs`; + } + + /** + * Initiate SSO login (GET /auth/sso/saml/:workspaceId) + */ + public async initiateLogin(req: express.Request, res: express.Response): Promise { + const { workspaceId } = req.params; + const returnUrl = req.query.returnUrl as string || `/workspace/${workspaceId}`; + + /** + * 1. Check if workspace has SSO enabled + */ + const workspace = await this.factories.workspacesFactory.findById(workspaceId); + + if (!workspace || !workspace.sso?.enabled) { + res.status(400).json({ error: 'SSO is not enabled for this workspace' }); + return; + } + + /** + * 2. Compose Assertion Consumer Service URL + */ + const acsUrl = this.getAcsUrl(workspaceId); + const relayStateId = crypto.randomUUID(); + + /** + * 3. Save RelayState to temporary storage + */ + samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId }); + + /** + * 4. Generate AuthnRequest + */ + const authnRequest = await this.samlService.generateAuthnRequest( + workspaceId, + acsUrl, + relayStateId, + workspace.sso.saml + ); + + /** + * 5. Redirect to IdP + */ + const redirectUrl = new URL(workspace.sso.saml.ssoUrl); + redirectUrl.searchParams.set('SAMLRequest', authnRequest); + redirectUrl.searchParams.set('RelayState', relayStateId); + + res.redirect(redirectUrl.toString()); + } + + /** + * Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs) + */ + public async handleAcs(req: express.Request, res: express.Response): Promise { + const { workspaceId } = req.params; + const samlResponse = req.body.SAMLResponse as string; + const relayStateId = req.body.RelayState as string; + + /** + * 1. Get workspace SSO configuration and check if SSO is enabled + */ + const workspace = await this.factories.workspacesFactory.findById(workspaceId); + + if (!workspace || !workspace.sso?.enabled) { + res.status(400).json({ error: 'SSO is not enabled' }); + return; + } + + /** + * 2. Validate and parse SAML Response + */ + const acsUrl = this.getAcsUrl(workspaceId); + + let samlData: SamlResponseData; + try { + samlData = await this.samlService.validateAndParseResponse( + samlResponse, + workspaceId, + acsUrl, + workspace.sso.saml + ); + } catch (error) { + console.error('SAML validation error:', error); + res.status(400).json({ error: 'Invalid SAML response' }); + return; + } + + /** + * 3. Find or create user + */ + let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId); + + if (!user) { + /** + * JIT provisioning or invite-only policy + * @todo Implement access policy + */ + user = await this.handleUserProvisioning(workspaceId, samlData, workspace); + } + + /** + * 4. Create Hawk session + */ + const tokens = await user.generateTokensPair(); + + /** + * 5. Get RelayState and redirect + */ + const relayState = samlStore.getRelayState(relayStateId); + const returnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`; + + /** + * 6. Redirect to Garage with tokens + */ + const frontendUrl = new URL(returnUrl, process.env.GARAGE_URL); + frontendUrl.searchParams.set('access_token', tokens.accessToken); + frontendUrl.searchParams.set('refresh_token', tokens.refreshToken); + + res.redirect(frontendUrl.toString()); + } + + /** + * Handle user provisioning (JIT or invite-only) + */ + private async handleUserProvisioning( + workspaceId: string, + samlData: SamlResponseData, + workspace: WorkspaceModel + ): Promise { + /** + * @todo Implement access policy + * + * Right now we create user if it doesn't exist + * In the future: check invite-only policy + */ + let user = await this.factories.usersFactory.findByEmail(samlData.email); + + if (!user) { + /** + * Create new user + * @todo Implement user creation + */ + throw new Error('User provisioning not implemented'); + } + + /** + * Link SAML identity + * @todo Implement SAML identity linking + */ + await user.linkSamlIdentity(workspaceId, samlData.nameId, samlData.email); + + /** + * Check if user is a member of the workspace + * @todo Implement workspace membership check. Add user to workspace if it's not a member. + */ + + return user; + } +} +``` + +### 5.2 Unit тесты для SAML Controller + +**Файл:** [`api/test/sso/saml/controller.test.ts`](../api/test/sso/saml/controller.test.ts) + +**Действия:** +1. Написать unit-тесты для endpoints (TDD подход): + - `initiateLogin` — проверка редиректа на IdP, формирования AuthnRequest, сохранения RelayState + - `handleAcs` — проверка обработки SAML Response, создания сессии, редиректа на фронтенд + - Проверка ошибок: + - SSO не включён для workspace + - Невалидный SAML Response + - Ошибки валидации (подпись, Audience, Recipient и т.д.) + +**Примечание:** Тесты пишутся параллельно с реализацией endpoints, следуя подходу TDD. + +### 5.3 Создание SAML Router + +**Файл:** [`api/src/sso/saml/index.ts`](../api/src/sso/saml/index.ts) + +**Действия:** +1. Создать Express router для SAML endpoints +2. Подключить контроллер + +**Код:** +```typescript +import express from 'express'; +import SamlController from './controller'; +import { ContextFactories } from '../../types/graphql'; + +/** + * Create SAML router + */ +export function createSamlRouter(factories: ContextFactories): express.Router { + const router = express.Router(); + const controller = new SamlController(factories); + + /** + * SSO login initiation + * GET /auth/sso/saml/:workspaceId + */ + router.get('/:workspaceId', async (req, res, next) => { + try { + await controller.initiateLogin(req, res); + } catch (error) { + next(error); + } + }); + + /** + * ACS callback + * POST /auth/sso/saml/:workspaceId/acs + */ + router.post('/:workspaceId/acs', async (req, res, next) => { + try { + await controller.handleAcs(req, res); + } catch (error) { + next(error); + } + }); + + return router; +} +``` + +### 5.4 Интеграция SSO модуля в главный сервер + +**Файл:** [`api/src/sso/index.ts`](../api/src/sso/index.ts) + +**Действия:** +1. Создать главный экспорт SSO модуля +2. Экспортировать функцию для подключения роутов + +**Код:** +```typescript +import express from 'express'; +import { createSamlRouter } from './saml'; +import { ContextFactories } from '../types/graphql'; + +/** + * Append SSO routes to Express app + */ +export function appendSsoRoutes(app: express.Application, factories: ContextFactories): void { + const samlRouter = createSamlRouter(factories); + app.use('/auth/sso/saml', samlRouter); +} +``` + +**Файл:** [`api/src/index.ts`](../api/src/index.ts) + +**Действия:** +1. Импортировать `appendSsoRoutes` +2. Вызвать после инициализации factories в методе `start()` + +**Код:** +```typescript +import { appendSsoRoutes } from './sso'; + +public async start(): Promise { + await mongo.setupConnections(); + await rabbitmq.setupConnections(); + + const dataLoaders = new DataLoaders(mongo.databases.hawk!); + const factories = HawkAPI.setupFactories(dataLoaders); + + appendSsoRoutes(this.app, factories); + + await this.server.start(); + // ... remaining code +} +``` + +--- + +## Этап 6: GraphQL API для управления SSO настройками + +### 6.1 Добавление GraphQL типов + +**Файл:** [`api/src/typeDefs/workspace.ts`](../api/src/typeDefs/workspace.ts) + +**Действия:** +1. Добавить типы для SSO конфигурации в GraphQL схему +2. Добавить поле `sso` в тип `Workspace` (только для админов) +3. Добавить input типы для обновления SSO +4. Добавить мутацию `updateWorkspaceSso` + +**Код:** +```graphql +""" +SAML attribute mapping configuration +""" +input SamlAttributeMappingInput { + """ + Attribute name for email in SAML Assertion + Used to map the email attribute from the SAML response to the email attribute in the Hawk database + """ + email: String! + + """ + Attribute name for user name in SAML Assertion + Used to map the name attribute from the SAML response to the name attribute in the Hawk database + """ + name: String +} + +""" +SAML SSO configuration input +""" +input SamlConfigInput { + """ + IdP Entity ID + Used to ensure that the SAML response is coming from the correct IdP + """ + idpEntityId: String! + + """ + SSO URL for redirecting user to IdP + Used to redirect user to the correct IdP + """ + ssoUrl: String! + + """ + X.509 certificate for signature verification (PEM format) + Used to verify the signature of the SAML response + """ + x509Cert: String! + + """ + Desired NameID format + Used to specify the format of the NameID in the SAML response + """ + nameIdFormat: String + + """ + Attribute mapping configuration + Used to map the attributes from the SAML response to the attributes in the Hawk database + """ + attributeMapping: SamlAttributeMappingInput! +} + +""" +SSO configuration input +""" +input WorkspaceSsoConfigInput { + """ + Is SSO enabled + Used to enable or disable SSO for the workspace + """ + enabled: Boolean! + + """ + Is SSO enforced (only SSO login allowed) + Used to enforce SSO login for the workspace. If true, only SSO login is allowed. + """ + enforced: Boolean! + + """ + SAML-specific configuration + Used to configure the SAML-specific settings for the workspace + """ + saml: SamlConfigInput! +} + +""" +SSO configuration (admin only) +""" +type WorkspaceSsoConfig { + """ + Is SSO enabled + Used to enable or disable SSO for the workspace + """ + enabled: Boolean! + + """ + Is SSO enforced + Used to enforce SSO login for the workspace. If true, only SSO login is allowed. + """ + enforced: Boolean! + + """ + SSO provider type + Used to specify the type of the SSO provider for the workspace + """ + type: String! + + """ + SAML-specific configuration + Used to configure the SAML-specific settings for the workspace + """ + saml: SamlConfig! +} + +""" +SAML configuration +""" +type SamlConfig { + """ + IdP Entity ID + Used to ensure that the SAML response is coming from the correct IdP + """ + idpEntityId: String! + + """ + SSO URL + Used to redirect user to the correct IdP + """ + ssoUrl: String! + + """ + X.509 certificate (masked for security) + Used to verify the signature of the SAML response + """ + x509Cert: String! + + """ + NameID format + Used to specify the format of the NameID in the SAML response + """ + nameIdFormat: String + + """ + Attribute mapping + Used to map the attributes from the SAML response to the attributes in the Hawk database + """ + attributeMapping: SamlAttributeMapping! +} + +""" +SAML attribute mapping +""" +type SamlAttributeMapping { + """ + Email attribute name + Used to map the email attribute from the SAML response to the email attribute in the Hawk database + """ + email: String! + + """ + Name attribute name + Used to map the name attribute from the SAML response to the name attribute in the Hawk database + """ + name: String +} + +extend type Workspace { + """ + SSO configuration (admin only, not returned in regular queries) + """ + sso: WorkspaceSsoConfig +} + +extend type Query { + """ + Get SSO settings for workspace (admin only) + """ + workspaceSsoSettings(workspaceId: ID!): WorkspaceSsoConfig @requireAdmin +} + +extend type Mutation { + """ + Update workspace SSO configuration (admin only) + """ + updateWorkspaceSso( + workspaceId: ID! + config: WorkspaceSsoConfigInput! + ): Boolean! @requireAdmin +} +``` + +### 6.2 Реализация резолверов + +**Файл:** [`api/src/resolvers/workspace.js`](../api/src/resolvers/workspace.js) + +**Действия:** +1. Добавить резолвер для `workspace.sso` (только для админов, не возвращать в обычных запросах) +2. Добавить резолвер для `Mutation.updateWorkspaceSso` + +**Код:** +```javascript +/** + * In Workspace resolvers: + */ +Workspace: { + /** + * ... existing resolvers + */ + + /** + * SSO configuration (admin only) + * Not returned in regular workspaces queries + * Only available through workspaceSsoSettings query + */ + async sso(workspace, args, { user, factories }) { + /** + * Check if user is admin + */ + const member = await workspace.getMemberInfo(user.id); + + if (!member || !member.isAdmin) { + return null; + /** + * Throw ForbiddenError if user is not admin + */ + throw new ForbiddenError('Not enough permissions'); + } + + return workspace.sso || null; + }, +}, + +Mutation: { + /** + * ... existing mutations + */ + + /** + * Update workspace SSO configuration + */ + async updateWorkspaceSso(_obj, { workspaceId, config }, { user, factories }) { + const workspace = await factories.workspacesFactory.findById(workspaceId); + + if (!workspace) { + throw new UserInputError('Workspace not found'); + } + + const member = await workspace.getMemberInfo(user.id); + + if (!member || !member.isAdmin) { + throw new ForbiddenError('Not enough permissions'); + } + + /** + * Validate configuration + */ + if (config.enabled && !config.saml) { + throw new UserInputError('SAML configuration is required when SSO is enabled'); + } + + await workspace.updateWorkspace({ + ...workspace, + sso: config.enabled ? { + enabled: config.enabled, + enforced: config.enforced || false, + type: 'saml', + saml: { + idpEntityId: config.saml.idpEntityId, + ssoUrl: config.saml.ssoUrl, + x509Cert: config.saml.x509Cert, + nameIdFormat: config.saml.nameIdFormat, + attributeMapping: { + email: config.saml.attributeMapping.email, + name: config.saml.attributeMapping.name, + }, + }, + } : undefined, + }); + + return true; + }, +}, +``` + +**Примечание:** Для безопасности поле `sso` не должно возвращаться в обычных запросах `workspaces`. Можно использовать projection в резолвере или фильтровать на уровне модели. + +--- + +## Этап 7: Фронтенд - SSO Login страница + +### 7.1 Создание SSO Login компонента + +**Файл:** [`garage/src/components/auth/SsoLogin.vue`](../garage/src/components/auth/SsoLogin.vue) + +**Действия:** +1. Создать компонент для SSO логина +2. Реализовать форму для ввода `workspaceId` (или использовать из URL) +3. Добавить кнопку "Continue with SSO" +4. Редирект на API endpoint для инициации SSO + +**Код (структура):** +```vue + + + +``` + +### 7.2 Добавление роута для SSO Login + +**Файл:** [`garage/src/router.ts`](../garage/src/router.ts) + +**Действия:** +1. Добавить роут для `/login/sso/:workspaceId?` + +**Код:** +```typescript +{ + path: '/login/sso/:workspaceId?', + name: 'sso-login', + component: () => import(/* webpackChunkName: 'auth-pages' */ './components/auth/SsoLogin.vue'), + props: route => ({ + workspaceId: route.params.workspaceId, + }), +}, +``` + +### 7.3 Обновление обычной страницы Login + +**Файл:** [`garage/src/components/auth/Login.vue`](../garage/src/components/auth/Login.vue) + +**Действия:** +1. Добавить кнопку "Continue with SSO" на страницу логина +2. При клике открывать форму для ввода `workspaceId` или редирект на `/login/sso` + +**Код (добавить в template):** +```vue +
+ +
+``` + +**Код (добавить в methods):** +```javascript +goToSsoLogin() { + // Можно показать модальное окно для ввода workspaceId + // Или редирект на /login/sso + this.$router.push({ name: 'sso-login' }); +}, +``` + +--- + +## Этап 8: Фронтенд - Настройки SSO в Workspace + +### 8.1 Создание компонента настроек SSO + +**Файл:** [`garage/src/components/workspace/settings/Sso.vue`](../garage/src/components/workspace/settings/Sso.vue) + +**Действия:** +1. Создать компонент для настройки SSO +2. Форма с полями: + - Checkbox "Enable SSO" + - Checkbox "Enforce SSO" (только если SSO enabled) + - IdP Entity ID + - SSO URL + - X.509 Certificate (textarea) + - NameID Format (select) + - Attribute Mapping (email, name) +3. Кнопка "Save" +4. Показывать информацию о SP Entity ID и ACS URL для администратора IdP + +**Код (структура):** +```vue +