SSO и OIDC
Roxera ID — вход во все проекты Roxera. Пароли хранятся как Argon2id-хэши, администраторам обязателен TOTP, а сессии можно отозвать в любой момент.
Поддерживается стандартный OpenID Connect поверх OAuth 2.0:
- Authorization Code;
- PKCE (
S256) — обязателен для публичных клиентов; - refresh token;
- подпись ID token — EdDSA (Ed25519), алгоритм
EdDSA; - scope
openid,profile,email,premium,offline_access.
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>
Требования:
- клиент создан как Public (PKCE);
redirect_uriзарегистрирован, даже если попап не переходит по нему;- сайт открыт по HTTPS (кроме
localhost).
Виджет отправляет код через 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 перед обменом кода.