SSO и OIDC

Roxera ID — вход во все проекты Roxera. Пароли хранятся как Argon2id-хэши, администраторам обязателен TOTP, а сессии можно отозвать в любой момент.

Поддерживается стандартный OpenID Connect поверх OAuth 2.0:

Discovery

GET https://oauth.roxera.net/.well-known/openid-configuration
GET https://oauth.roxera.net/oauth/jwks.json
{
  "issuer": "https://oauth.roxera.net",
  "authorization_endpoint": "https://oauth.roxera.net/oauth/authorize",
  "token_endpoint": "https://oauth.roxera.net/oauth/token",
  "userinfo_endpoint": "https://oauth.roxera.net/oauth/userinfo",
  "jwks_uri": "https://oauth.roxera.net/oauth/jwks.json",
  "response_types_supported": ["code"],
  "id_token_signing_alg_values_supported": ["EdDSA"],
  "code_challenge_methods_supported": ["S256"]
}

1. Создайте OAuth-клиент

Откройте админку: https://mail.roxera.net/admin/#oauth → Новый клиент.

Поле Что указать
Название Название проекта, видно на экране согласия
Redirect URIs Точные URL callback, по одному на строку
Scopes Минимум openid; для Premium — premium
Public (PKCE) Включить для SPA, мобильных и popup-виджета
Trusted Пропускать экран согласия для first-party проекта

Секрет показывается один раз. Для confidential-клиента храните его только на сервере.

2. Authorization Code + PKCE

Редирект на вход

import crypto from 'node:crypto';

const SSO = 'https://oauth.roxera.net';
const CLIENT_ID = 'rp_xxxxxxxxx';
const REDIRECT_URI = 'https://new.roxera.net/auth/callback';

const verifier = crypto.randomBytes(48).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const state = crypto.randomBytes(24).toString('base64url');

const url = new URL(`${SSO}/oauth/authorize`);
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid profile email premium');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');

// verifier и state положите в HttpOnly cookie или серверную сессию
response.redirect(url.toString());

Обмен кода на токены

const tokenResponse = await fetch(`${SSO}/oauth/token`, {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: verifier,
    // client_secret: SECRET, // только для confidential-клиента
  }),
});

const tokens = await tokenResponse.json();

Ответ:

{
  "access_token": "rx_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "rx_rt_...",
  "scope": "openid profile email premium",
  "id_token": "eyJhbGciOiJFZERTQSIs..."
}

Access token живёт 1 час. Refresh token можно обменять тем же endpoint с grant_type=refresh_token; при обмене старый refresh token отзывается.

3. Проверка ID token

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://oauth.roxera.net/oauth/jwks.json'));

const { payload } = await jwtVerify(tokens.id_token, JWKS, {
  issuer: 'https://oauth.roxera.net',
  audience: CLIENT_ID,
});

// payload.sub, payload.email, payload.name, payload.premium, payload.premium_until

Обязательно проверяйте iss, aud, срок действия и nonce, если передавали его в authorize.

Claims

Claim Описание
sub Стабильный ID пользователя (usr_...)
email, email_verified Почта и признак подтверждения
name, picture Профиль
premium true, если Premium активен
premium_until ISO-дата окончания подписки
plan free или premium
nonce Эхо переданного nonce

4. Userinfo

GET https://oauth.roxera.net/oauth/userinfo
Authorization: Bearer <access_token>
{
  "sub": "usr_hello_mail_roxera_net",
  "email": "hello@mail.roxera.net",
  "email_verified": true,
  "name": "Roxera",
  "picture": null,
  "tier": "premium",
  "active": true,
  "until": "2029-06-24T12:58:14.476Z",
  "entitlements": {
    "mail": { "boxes": 3, "storageGb": 10, "tempTtlMin": 1440 },
    "uptime": { "monitors": 200, "intervalSec": 60 },
    "link": { "activeLinks": 5000 }
  }
}

entitlements — тот же объект, что и в @roxera/shared. Проект должен читать свою секцию (entitlements.uptime, entitlements.link, …), а не хардкодить лимиты.

5. Popup-виджет «как Google»

Для публичных SPA и статических сайтов удобнее попап. Виджет сам генерирует PKCE, открывает окно oauth.roxera.net, проверяет state и обменивает код на токены.

Живой пример без интеграции и настройки: premium.roxera.net/demo — кнопка, попап входа и объект, который получает сайт.

Попап запоминает аккаунты в браузере и при повторном входе показывает выбор аккаунта (как у Google): текущий аккаунт входит без пароля, «Другой аккаунт» открывает форму. Если пользователь уже в системе, виджет показывает почту и кнопку «Сменить аккаунт».

<div
  data-roxera-login
  data-client-id="rp_xxxxxxxxx"
  data-redirect-uri="https://new.roxera.net/auth/callback"
  data-scope="openid profile email premium"
  data-label="Войти с Roxera"
></div>

<script src="https://oauth.roxera.net/widget.js" defer></script>
<script>
  window.addEventListener('roxera:login', (event) => {
    const { user, tokens } = event.detail;
    console.log('Вошёл', user.email, 'Premium:', user.active);
    // user — ответ /oauth/userinfo, tokens — access_token, id_token, refresh_token
  });

  window.addEventListener('roxera:error', (event) => {
    console.error('OAuth error', event.detail.error);
  });
</script>

Требования:

Виджет отправляет код через postMessage только на origin зарегистрированного redirect_uri. Если попап открыт без родительского окна, он покажет кнопку продолжить вручную.

6. Сессия для поддоменов .roxera.net

First-party проекты могут не использовать OIDC: cookie rx_session уже стоит на .roxera.net (HttpOnly, Secure, SameSite=Lax). Достаточно запросить сессию:

const response = await fetch('https://mail.roxera.net/api/v1/auth/session', {
  credentials: 'include',
});
const session = await response.json();

if (session.user?.premium.active) {
  // включить premium-функции
}

Ответ содержит user, stepUp, impersonatedBy и полный объект premium с entitlements. Для запросов с изменением состояния API проверяет Origin: он должен быть *.roxera.net или localhost.

7. Выход и отзыв

GET https://oauth.roxera.net/oauth/logout?post_logout_redirect_uri=https://new.roxera.net/
POST https://oauth.roxera.net/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=<access_or_refresh_token>

Logout отзывает текущую сессию и очищает cookie. Revoke отзывает access и refresh токены, связанные с переданным значением.

8. Passkeys (WebAuthn)

Roxera ID поддерживает passkeys: вход без пароля и как второй фактор. RP ID — roxera.net, поэтому ключ, созданный на mail.roxera.net, работает на oauth.roxera.net и premium.roxera.net. Пароль и TOTP остаются запасными.

Метод Назначение
GET /api/v1/auth/passkeys список ключей текущего пользователя
POST /api/v1/auth/passkeys/options опции регистрации (нужна сессия)
POST /api/v1/auth/passkeys проверка аттестации и сохранение ключа
DELETE /api/v1/auth/passkeys/:id удаление (нужен свежий step-up)
POST /api/v1/auth/passkeys/login/options опции входа; email опционален
POST /api/v1/auth/passkeys/login/verify проверка assertion, создаёт сессию
POST /api/v1/auth/step-up подтверждение: TOTP-код или пароль

Готовый клиентский пример — apps/mail/src/passkeys.ts.

Ошибки

Код Значение
invalid_client Неверный client_id или секрет
invalid_grant Код/refresh истёк, использован или не совпал redirect URI / PKCE
invalid_scope Запрошен scope, не разрешённый клиенту
invalid_redirect_uri URI не зарегистрирован
access_denied Пользователь отклонил согласие

При ошибке authorize редиректит на redirect_uri с error и state. Всегда сверяйте state перед обменом кода.