Перейти к основному содержимому

OAuth Authorization Server

Канонический DDL находится в ../database-schema.md. SQL ниже является концептуальным примером OAuth-модели и не используется для миграций.

Зачем нужно

Документ фиксирует целевую архитектуру Systematika ID как OIDC-провайдера для сервисов экосистемы.

1. Назначение

Systematika ID — центральный identity provider экосистемы. Все внешние сервисы подключаются через стандартный OIDC (OpenID Connect). Сторонний сервис вводит один URL — всё настраивается автоматически.


2. Поддерживаемые flows

FlowНазначениеКлиенты
Authorization Code + PKCEОсновной flowSPA, мобильные, серверные приложения
Refresh TokenОбновление access tokenВсе клиенты
Client CredentialsService-to-service accessConfidential internal backend services

Implicit и Device Authorization — не поддерживаются.


3. OIDC Discovery

3.1. GET /.well-known/openid-configuration

Автоматическое обнаружение всех эндпоинтов. Возвращает JSON:

{
"issuer": "https://id.example.com",
"authorization_endpoint": "https://id.example.com/oauth/authorize",
"token_endpoint": "https://id.example.com/oauth/token",
"userinfo_endpoint": "https://id.example.com/oauth/userinfo",
"jwks_uri": "https://id.example.com/.well-known/jwks.json",
"end_session_endpoint": "https://id.example.com/oauth/end_session",
"introspection_endpoint": "https://id.example.com/oauth/introspect",
"revocation_endpoint": "https://id.example.com/oauth/revoke",
"registration_endpoint": "https://id.example.com/oauth/clients/register",
"scopes_supported": ["openid", "email", "profile", "phone", "organizations", "roles"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}

3.2. GET /.well-known/jwks.json

Публичные ключи для проверки подписей токенов. Клиенты используют JWKS для верификации id_token без обращения к серверу.

Токены подписываются RSA (RS256): приватный ключ на сервере, публичный в JWKS. Клиенты проверяют подпись публичным ключом без обращения к серверу.

Управление RSA-ключами:

RSA-2048, PEM формат.
Приватный ключ: env `OIDC_PRIVATE_KEY` (PEM string).
Публичный ключ: извлекается из приватного при старте.
JWKS endpoint (`/.well-known/jwks.json`) отдаёт публичный ключ с `kid`.
Ротация: новый ключ добавляется в JWKS, старый остаётся 24 часа для валидации существующих токенов.

4. Эндпоинты

ЭндпоинтМетодAuthНазначение
GET /oauth/authorizeGETИнициация Authorization Code Flow. Редирект на страницу входа если не авторизован.
POST /oauth/tokenPOSTclient_secret/PKCEОбмен code → tokens, refresh → tokens или client_credentials → service access token
GET /oauth/userinfoGETBearerOIDC UserInfo — данные пользователя по scopes
POST /oauth/introspectPOSTclient_secretRFC 7662 Token Introspection
POST /oauth/revokePOSTclient_secretRFC 7009 Token Revocation
GET /oauth/end_sessionGETOIDC RP-Initiated Logout
POST /oauth/clients/registerPOSTecosystem access token + identity.oauth_clients.manage + re-authРегистрация нового клиента
POST /oauth/sso/otcPOSTJWTГенерирует OTC для inbound SSO

5. PKCE (RFC 7636)

Обязателен для всех клиентов. Метод: S256.

Клиент генерирует:
code_verifier = random(43-128 символов)
code_challenge = BASE64URL(SHA256(code_verifier))

GET /oauth/authorize?
client_id=...
redirect_uri=...
code_challenge=<challenge>
code_challenge_method=S256
scope=openid profile email
state=<random>

POST /oauth/token
grant_type=authorization_code
code=<authorization_code>
code_verifier=<verifier>
client_id=...
redirect_uri=...

6. User OAuth scopes и claims

ScopeClaims в userinfo / id_token
openidsub (обязательный)
profilegiven_name, family_name, preferred_username, picture
emailemail, email_verified
phonephone_number, phone_number_verified
organizationsorganization_memberships[]
rolesglobal_roles[], permissions[]

UserInfo возвращает только запрошенные scopes, не всё подряд.

6.1. Service scopes

Service scopes use only service:<catalog-permission>. Custom free-form service scopes are forbidden.

Examples:

  • service:crm.entitlements.read
  • service:platform.reference-data.read
  • service:competitions.results.read

7. Токены

7.1. Access token (JWT)

  • Подпись: RS256 (ассиметричная)
  • TTL user token: 15 минут
  • TTL service token: 5 минут
  • Claims: sub, iss, aud, exp, iat, scope, client_id, roles, permissions, sessionId для user tokens; sub = oauth_client.client_id и serviceClient для service tokens
  • Все access tokens, которые принимают не-identity домены, являются ecosystem access tokens: RS256/JWKS, issuer identity, контракт из ../../../platform/auth-integration.md и ADR-033.

Main Auth может использовать внутренний identity-only session artifact, но он не является ecosystem access token и не выходит за пределы identity.

7.2. ID token (JWT)

  • Подпись: RS256
  • Claims: sub, iss, aud, exp, iat, nonce, auth_time + claims по scopes
  • Выдаётся вместе с access token при scope openid

7.3. Refresh token

  • UUID, хранится как SHA-256 hash в oauth_refresh_tokens
  • TTL: 30 дней
  • Ротация: при refresh старый отзывается, выдаётся новый
  • replacedByTokenHash — цепочка ротации для детекции кражи

8. Клиенты (OAuth Clients)

8.1. Entity

CREATE TABLE oauth_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id VARCHAR(100) NOT NULL UNIQUE,
client_secret_hash VARCHAR(255), -- bcrypt hash, НЕ plaintext; null for public clients
type VARCHAR(30) NOT NULL CHECK (type IN ('public','confidential')),
name VARCHAR(255) NOT NULL,
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}',
allowed_scopes TEXT[] NOT NULL, -- whitelist scopes
grant_types TEXT[] NOT NULL DEFAULT '{"authorization_code","refresh_token"}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

8.2. Правила

  • public clients не имеют client_secret_hash и обязаны использовать PKCE;
  • confidential clients обязаны иметь client_secret_hash; plaintext возвращается один раз при регистрации;
  • redirect_uris — whitelist, проверяется при authorize
  • allowed_scopes — whitelist, scopes запроса фильтруются против разрешённых
  • Управление клиентами — через admin UI или API (require identity.oauth_clients.manage + re-auth)
  • Confidential service clients регистрируются с grant_types = '{"client_credentials"}', без redirect_uris, с audience-limited service scopes service:<permission>.

9. Authorization Codes

CREATE TABLE authorization_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(64) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id VARCHAR(100) NOT NULL,
redirect_uri TEXT NOT NULL,
scopes TEXT[] NOT NULL,
state VARCHAR(255),
code_challenge VARCHAR(128), -- PKCE
code_challenge_method VARCHAR(10), -- "S256"
is_used BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL, -- TTL 10 минут
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

10. Inbound SSO (OTC)

Одноразовые коды для SSO между сервисами экосистемы.

Пользователь авторизован в Systematika
→ переходит на внешний сервис
→ Systematika генерирует OTC (6 символов, 60 секунд)
→ внешний сервис обменивает OTC на токены через API

OTC хранится в verification_codes с purpose: sso_otc и ttl: 60 (секунды), переопределяя дефолтный TTL таблицы. Токены не хранятся в metadata — генерируются при обмене.

Consent screen: отображается при первой авторизации OAuth-клиента. Пользователь видит запрашиваемые scopes и подтверждает доступ. Повторные авторизации с теми же scopes проходят автоматически.


11.1. Flow

Пользователь авторизован → GET /oauth/authorize
→ первый раз для этого client_id + запрошенные scopes?
→ да → показать consent screen: "Приложение X запрашивает доступ к: email, профиль, организации"
→ пользователь подтверждает → сохранить consent → redirect с code
→ пользователь отклоняет → redirect с error=access_denied
→ нет (consent уже дан для этих scopes) → сразу redirect с code

11.2. Entity

CREATE TABLE oauth_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id VARCHAR(100) NOT NULL,
scopes TEXT[] NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, client_id)
);

При запросе новых scopes (расширение) — показать consent screen снова с новыми scopes.

DELETE /oauth/consents/:clientId — отзыв consent. При этом:

  • Удалить запись из oauth_consents
  • Отозвать все oauth_refresh_tokens для этого user + client
  • При следующем authorize — consent screen покажется снова

12. Token Introspection (RFC 7662)

POST /oauth/introspect — проверка валидности токена. Используется resource server'ами.

Request:

POST /oauth/introspect
Authorization: Basic <client_id:client_secret>
Content-Type: application/x-www-form-urlencoded

token=<access_token>&token_type_hint=access_token

Response (активный токен):

{
"active": true,
"sub": "user-uuid",
"client_id": "my-app",
"scope": "openid profile email",
"exp": 1714000000,
"iat": 1713996400,
"iss": "https://id.example.com",
"token_type": "Bearer"
}

Response (невалидный/expired токен):

{ "active": false }

13. Token Revocation (RFC 7009)

POST /oauth/revoke — отзыв токена.

  • Принимает token + token_type_hint (access_token | refresh_token)
  • Refresh token: помечается is_revoked = true в БД
  • Access token (JWT): нельзя отозвать напрямую (stateless). Короткий TTL (15 минут user / 5 минут service) — de facto expiration. Для немедленного отзыва — introspection на resource server.
  • Всегда возвращает 200 OK (даже если токен не найден — RFC 7009 §2.2)

14. RP-Initiated Logout (OIDC)

GET /oauth/end_session — logout, инициированный клиентом.

Параметры:

  • id_token_hint — последний id_token (для идентификации сессии)
  • post_logout_redirect_uri — куда редиректить после logout (должен быть в oauth_clients.post_logout_redirect_uris)
  • state — pass-through параметр

Flow:

GET /oauth/end_session?id_token_hint=xxx&post_logout_redirect_uri=https://app.example.com/logged-out&state=abc
→ валидация id_token_hint (извлечь sub, проверить подпись)
→ отозвать все OAuth refresh tokens для этого user + client
→ redirect на post_logout_redirect_uri?state=abc

15. Rate limiting на OAuth endpoints

EndpointЛимитОкно
POST /oauth/token20 запросов60 секунд
POST /oauth/introspect100 запросов60 секунд
POST /oauth/revoke30 запросов60 секунд
GET /oauth/authorize30 запросов60 секунд

Лимит per client_id (не per IP). При превышении — 429 Too Many Requests + Retry-After header.


16. Разделение Main Auth и OAuth токенов

Main AuthOAuth
НазначениеВход в Systematika IDДоступ сторонних приложений
Access token TTL15 минут15 минут user / 5 минут service
Подписьinternal identity-only artifact если нуженRS256 (приватный ключ, JWKS)
Refresh storagerefresh_tokensoauth_refresh_tokens (hash)
Claimsroles, permissions, sessionIdscopes, client_id, aud

Разные ключи подписи — main auth token нельзя использовать как OAuth token и наоборот.


17. Серверные сервисы

oauth-server/
├── oauth.controller.ts — HTTP эндпоинты (/oauth/*)
├── discovery.controller.ts — /.well-known/openid-configuration, /.well-known/jwks.json
├── oauth.service.ts — Authorization Code, token exchange, refresh, introspect, revoke
├── oauth-client.service.ts — CRUD клиентов
├── oauth-token.service.ts — Генерация JWT (RS256), id_token, JWKS
├── inbound-sso.service.ts — OTC генерация и обмен
└── entities/
├── oauth-client.entity.ts
├── authorization-code.entity.ts
└── oauth-refresh-token.entity.ts
СервисОтветственность
OAuthServiceОркестратор: authorize, token exchange, refresh, introspect, revoke
OAuthClientServiceCRUD клиентов, валидация redirect_uri, scopes
OAuthTokenServiceГенерация access/id/refresh tokens (RS256), JWKS endpoint, проверка PKCE
InboundSsoServiceOTC: генерация (6 символов, 60 сек), обмен на токены

18. oauth_refresh_tokens DDL

CREATE TABLE oauth_refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(64) NOT NULL UNIQUE,
token_family_id UUID NOT NULL,
replaced_by_token_hash VARCHAR(64),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id VARCHAR(100) NOT NULL,
scopes TEXT[] NOT NULL,
revoked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_oauth_refresh_tokens_user ON oauth_refresh_tokens (user_id);

19. UX/UI

Экран согласия отображается при первой авторизации OAuth-клиента:

  • Лейаут: AuthLayout (центрированная карточка)
  • Заголовок: "Приложение [Название клиента] запрашивает доступ"
  • Список запрашиваемых scopes с human-readable описаниями:
    • profile → "Имя и фото профиля"
    • email → "Адрес электронной почты"
    • phone → "Номер телефона"
    • organizations → "Членство в организациях"
    • roles → "Роли и права"
  • Две кнопки: "Разрешить" (primary) / "Отказать" (ghost)
  • При отказе: redirect с error=access_denied

19.2. SingleLogoutPage

Обработчик RP-Initiated Logout (/:lang/auth/single-logout):

  • Показывает спиннер (LoadingState) во время выполнения logout
  • После завершения: redirect на post_logout_redirect_uri

19.3. Admin: OAuthClientsManagementPage

Страница /:lang/admin/oauth-clients:

  • Таблица клиентов: название, client_id, статус (активен/отключён), дата создания
  • Кнопка "Создать клиент" → модалка с полями: название, redirect_uris, scopes, grant_types
  • При создании: отображается client_secret один раз (копируемый, предупреждение)
  • Действия: редактировать, отключить, удалить