OAuth 2.0 — протокол делегирования доступа: пользователь разрешает приложению действовать от его имени, не отдавая пароль, а приложение получает access token с ограниченным scope. OpenID Connect добавляет поверх него вход: ID token сообщает, кто вошёл, и его нужно проверить — iss, aud, exp, nonce и подпись. Основной поток сегодня — Authorization Code с PKCE; implicit и передача пароля через password grant по RFC 9700 не используются.
После материала вы сможете
- объяснить роли OAuth 2.0 и шаги потока Authorization Code с PKCE, включая назначение
state,nonceиcode_verifier - отличить access token, refresh token и ID token по адресату, сроку жизни и способу проверки
- поднять локальный провайдер на
oidc-providerи пройти полный вход клиентом наopenid-clientдо ID token и UserInfo - проверить ID token вручную через
jose: подпись,iss,aud,exp,iat,nonce— и показать тестом, что подделка отклоняется
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)openid-client 6.8oidc-provider 9.12jose 6.2
Сверено с документацией: 11 октября 2026 г.
Содержание
Зачем OAuth и чем он не является
Классическая задача: сервис отчётов хочет читать календарь пользователя в чужой системе. Старое решение — попросить у пользователя пароль от этой системы — плохо со всех сторон: сервис получает полный доступ, пароль хранится в двух местах, а отозвать доступ можно только сменой пароля. OAuth 2.0 (RFC 6749) решает это делегированием: пользователь подтверждает доступ на стороне владельца данных, а приложение получает токен доступа — access token — с ограниченными правами и сроком жизни.
Важно, чем OAuth не является. Это протокол авторизации доступа к API, а не протокол входа. Access token предназначен ресурсному серверу, и приложение не обязано и часто не может понять из него, кто перед ним. Чтобы «войти через чужой аккаунт», нужен OpenID Connect — слой поверх OAuth, который добавляет ID token: подписанное утверждение провайдера о том, какой пользователь прошёл аутентификацию, когда и для какого приложения. Как устроены сессии, cookie и сам формат JWT, разобрано в уроке об аутентификации; здесь — протокол вокруг них.
Владелец ресурса (resource owner)
- Кто это
- Тот, кто может разрешить доступ, обычно пользователь
- Пример
- Айгерим, у которой есть аккаунт у провайдера
Клиент (client)
- Кто это
- Приложение, которое просит доступ от имени пользователя
- Пример
- Ваш сайт или мобильное приложение
Сервер авторизации (authorization server)
- Кто это
- Проверяет пользователя и выдаёт токены
- Пример
- Google, корпоративный Keycloak,
oidc-providerиз примера
Ресурсный сервер (resource server)
- Кто это
- API, который принимает access token
- Пример
- API календаря, UserInfo-эндпоинт провайдера
В OpenID Connect сервер авторизации называют OpenID Provider (OP), а клиент — Relying Party (RP). Ещё одно различие, которое влияет на выбор потока: конфиденциальный клиент (backend) может хранить секрет, публичный (SPA, мобильное приложение) — нет, потому что его код доступен пользователю.
Authorization Code + PKCE шаг за шагом
Authorization Code — основной поток для всего, где есть пользователь и браузер. Токены не проходят через адресную строку: в браузер возвращается только короткоживущий одноразовый код, а обмен кода на токены идёт напрямую между сервером клиента и провайдером. PKCE (Proof Key for Code Exchange, RFC 7636) привязывает этот код к тому, кто начал вход: перехваченный код без секрета-«верификатора» бесполезен.
Клиент готовит одноразовые значения
code_verifier— случайная строка 43–128 символов,code_challenge= BASE64URL(SHA-256(verifier)), а такжеstateиnonce. Всё это сохраняется в серверной сессии пользователя.Редирект на authorization endpoint
В адресе —
client_id,redirect_uri,scope,response_type=code,code_challenge,code_challenge_method=S256,state,nonce. Сам verifier не передаётся.Пользователь входит у провайдера
Пароль, второй фактор и согласие на доступ вводятся на стороне провайдера. Клиент пароль не видит.
Возврат на redirect_uri с кодом
Провайдер перенаправляет браузер на зарегистрированный адрес с
code,stateиiss(RFC 9207). Клиент сверяетstateиiss.Обмен кода на токены
Сервер клиента отправляет на token endpoint
code,redirect_uri,code_verifierи аутентифицирует себя. Провайдер пересчитывает хеш verifier и сравнивает с challenge из шага 2.Проверка ответа
Клиент получает access token и ID token (и при запросе — refresh token), проверяет ID token и создаёт собственную сессию.
- 01Браузер
- 02Ваш сервер (RP)
- 03Провайдер (OP)
- 04API / UserInfo
Почему PKCE нужен даже конфиденциальному серверному клиенту. RFC 9700 — действующая рекомендация IETF по безопасности OAuth (BCP 240, январь 2025) — требует от публичных клиентов PKCE обязательно, а для конфиденциальных называет его рекомендуемым: он защищает от внедрения украденного кода в чужую сессию (authorization code injection) и попутно закрывает CSRF на callback. Метод только S256: при plain challenge равен verifier и виден любому, кто прочитал запрос авторизации. Вот вся математика PKCE на встроенном node:crypto, сверенная с тестовым вектором из приложения B RFC 7636:
// PKCE S256 by hand (RFC 7636, section 4): verifier -> SHA-256 -> base64url.
import { createHash, randomBytes } from "node:crypto";
export function createVerifier() {
return randomBytes(32).toString("base64url"); // 43 characters, allowed range is 43–128
}
export function challengeS256(verifier) {
return createHash("sha256").update(verifier, "ascii").digest("base64url");
}
// Test vector from RFC 7636, Appendix B
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
console.log(challengeS256(verifier));
console.log(challengeS256(verifier) === "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
console.log(createVerifier().length);openid-client (randomPKCECodeVerifier, calculatePKCECodeChallenge)$ node pkce.mjs
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
true
43Токены и scope: access, refresh, ID token
В ответе token endpoint может быть три разных токена, и путать их — частая причина уязвимостей. Главный вопрос к любому токену — кому он адресован.
Access token
- Кому адресован
- Ресурсному серверу (API)
- Что в нём
- Права: scope, срок, иногда
audресурса. Формат не стандартизирован: непрозрачная строка или JWT (RFC 9068) - Как проверяют
- API — интроспекцией или проверкой подписи JWT. Клиент его не разбирает
Refresh token
- Кому адресован
- Серверу авторизации
- Что в нём
- Право получить новый access token без участия пользователя
- Как проверяют
- Только сервер авторизации; клиент хранит как секрет
ID token
- Кому адресован
- Клиенту (RP)
- Что в нём
- JWT:
iss,sub,aud,exp,iat,nonce, иногдаauth_time - Как проверяют
- Клиент: подпись, издатель, аудитория, срок, nonce
Scope — список запрашиваемых прав через пробел: openid profile email, reports:read. RFC 9700 советует ограничивать access token минимально необходимыми правами и, где возможно, конкретным ресурсным сервером (параметр resource из RFC 8707 или aud). Тогда утечка токена от одной интеграции не открывает остальные API.
Refresh token живёт дольше и поэтому опаснее. Для публичных клиентов RFC 9700 требует либо привязки к отправителю (mTLS, DPoP), либо ротации: при каждом обновлении выдаётся новый refresh token, а старый становится недействительным; если старый предъявили повторно, сервер отзывает всю цепочку. В OpenID Connect refresh token обычно выдаётся по scope offline_access, и провайдер должен получить на него явное согласие (prompt=consent) — поэтому в примере ниже, где этот scope не запрашивается, has_refresh_token: false.
Когда пользователя нет вовсе — ночной обмен с CRM, фоновый воркер отчётов, — используется client credentials grant (RFC 6749, раздел 4.4): клиент аутентифицируется своим секретом и получает токен на собственные права. ID token здесь не выдаётся и не нужен: входить некому. Для каждой такой интеграции заводится отдельный клиент со своим секретом и узким scope.
// Server-to-server: a background worker gets its own token, no user involved.
import * as client from "openid-client";
const config = await client.discovery(
new URL(process.env.OIDC_ISSUER),
process.env.WORKER_CLIENT_ID,
process.env.WORKER_CLIENT_SECRET,
undefined,
{ execute: [client.allowInsecureRequests] }, // test IdP on http://localhost only
);
const tokens = await client.clientCredentialsGrant(config, { scope: "reports:read" });
console.log({ token_type: tokens.token_type, expires_in: tokens.expiresIn(), scope: tokens.scope, id_token: tokens.id_token });reports-worker с единственным scope reports:read$ node --env-file=.env client-credentials.mjs
{
token_type: 'bearer',
expires_in: 600,
scope: 'reports:read',
id_token: undefined
}reports:write или неверный секрет провайдер отклоняет — см. тесты нижеOpenID Connect: discovery, ID token и UserInfo
Провайдер OpenID Connect публикует свои настройки по адресу /.well-known/openid-configuration (OpenID Connect Discovery 1.0): адреса эндпоинтов, JWKS с публичными ключами подписи, поддерживаемые потоки и алгоритмы. Клиенту достаточно знать issuer — остальное он читает отсюда. RFC 9700 рекомендует именно так настраивать клиентов: меньше ошибок конфигурации, проще ротация ключей.
$ curl -s http://localhost:4100/.well-known/openid-configuration
{
"issuer": "http://localhost:4100",
"authorization_endpoint": "http://localhost:4100/auth",
"token_endpoint": "http://localhost:4100/token",
"userinfo_endpoint": "http://localhost:4100/me",
"jwks_uri": "http://localhost:4100/jwks",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["implicit", "authorization_code", "refresh_token", "client_credentials"],
"id_token_signing_alg_values_supported": ["RS256"],
"authorization_response_iss_parameter_supported": true,
…
}code_challenge_methods_supported показывает поддержку PKCE, authorization_response_iss_parameter_supported — параметр iss в ответеЗапрос становится запросом OpenID Connect, когда в scope есть openid. Тогда в ответе token endpoint появляется ID token. Профильные данные по scope profile, email, address, phone в потоке Authorization Code по спецификации отдаёт UserInfo-эндпоинт по access token, поэтому в выводе ниже имя и e-mail приходят из UserInfo, а не из ID token.
- Стабильный идентификатор пользователя — только пара
iss+sub. OpenID Connect Core (раздел 5.7) прямо запрещает использовать как уникальный ключemail,phone_number,preferred_usernameиname: провайдер может переназначить e-mail другому человеку. subиз ответа UserInfo обязан совпасть сsubиз ID token, иначе данные UserInfo не используются (раздел 5.3.2). Вopenid-clientэто третий аргументfetchUserInfo.emailбезemail_verified: trueне подтверждает владение адресом; связывать по нему существующий аккаунт опасно.
Стенд: локальный провайдер и клиент на openid-client
Чтобы увидеть поток целиком, не регистрируя приложение у внешнего провайдера, поднимем локальный OpenID Provider на oidc-provider — сертифицированной реализации сервера на Node.js — и клиент на openid-client того же автора. Провайдер — только для разработки: данные в памяти, встроенная форма входа принимает любой пароль, ключ подписи создаётся при каждом запуске. Секреты в примере — тестовые строки.
mkdir oidc-stand && cd oidc-stand
npm init -y
npm install openid-client@6.8.8 oidc-provider@9.12.2 jose@6.2.12// Test identity provider for local development only: in-memory storage,
// built-in dev login form, keys generated on every start.
import { generateKeyPairSync } from "node:crypto";
import Provider from "oidc-provider";
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const users = {
"aigerim": { sub: "aigerim", name: "Aigerim Test", email: "aigerim@example.test", email_verified: true },
};
const provider = new Provider("http://localhost:4100", {
clients: [
{
client_id: "interlab-web",
client_secret: "test-secret-change-me",
redirect_uris: ["http://localhost:4200/callback"],
grant_types: ["authorization_code", "refresh_token"],
},
{
client_id: "reports-worker",
client_secret: "test-worker-secret",
grant_types: ["client_credentials"],
redirect_uris: [],
response_types: [],
scope: "reports:read",
},
],
jwks: { keys: [{ ...privateKey.export({ format: "jwk" }), kid: "test-key-1", use: "sig", alg: "RS256" }] },
cookies: { keys: ["test-cookie-key-change-me"] },
pkce: { required: () => true },
scopes: ["openid", "profile", "email", "offline_access", "reports:read", "reports:write"],
claims: { openid: ["sub"], profile: ["name"], email: ["email", "email_verified"] },
features: { clientCredentials: { enabled: true } },
ttl: { AccessToken: 600, IdToken: 600, AuthorizationCode: 60, ClientCredentials: 600, RefreshToken: 86400, Interaction: 600, Session: 3600, Grant: 86400 },
async findAccount(ctx, id) {
const user = users[id];
if (!user) return undefined;
return { accountId: id, async claims() { return user; } };
},
});
provider.listen(4100, () => {
console.log("IdP: http://localhost:4100/.well-known/openid-configuration");
});findAccount в реальном провайдере ходит в вашу базуOIDC_ISSUER=http://localhost:4100
OIDC_CLIENT_ID=interlab-web
OIDC_CLIENT_SECRET=test-secret-change-me
WORKER_CLIENT_ID=reports-worker
WORKER_CLIENT_SECRET=test-worker-secretКлиент — минимальный сервер на node:http, чтобы не отвлекаться на фреймворк. /login создаёт verifier, state и nonce, запоминает их под случайным идентификатором попытки и уводит браузер к провайдеру. /callback достаёт значения по cookie, однократно использует их и вызывает authorizationCodeGrant, который проверяет iss и state в ответе, обменивает код и проверяет ID token.
// Relying party (the client): sign-in through an external provider.
// Run: node --env-file=.env app.mjs, then open http://localhost:4200/login
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import * as client from "openid-client";
const REDIRECT_URI = "http://localhost:4200/callback";
// Discovery: endpoints and keys come from /.well-known/openid-configuration.
// allowInsecureRequests only because the test IdP runs on http://localhost.
const config = await client.discovery(
new URL(process.env.OIDC_ISSUER),
process.env.OIDC_CLIENT_ID,
process.env.OIDC_CLIENT_SECRET,
undefined,
{ execute: [client.allowInsecureRequests] },
);
// Pending sign-ins: transaction id -> PKCE verifier, state, nonce.
// In production keep this in the server-side session store, not in memory.
const pending = new Map();
function readCookie(req, name) {
const pair = (req.headers.cookie ?? "").split("; ").find((c) => c.startsWith(`${name}=`));
return pair?.slice(name.length + 1);
}
async function login(res) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
const tx = randomBytes(16).toString("base64url");
pending.set(tx, { codeVerifier, state, nonce });
const url = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
});
// SameSite=Lax: the cookie is sent when the provider redirects the browser back (top-level GET).
res.writeHead(302, {
location: url.href,
"set-cookie": `oidc_tx=${tx}; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=600`,
});
res.end();
}
async function callback(req, res, currentUrl) {
const tx = readCookie(req, "oidc_tx");
const checks = tx && pending.get(tx);
if (!checks) {
res.writeHead(400).end("Unknown or expired sign-in attempt");
return;
}
pending.delete(tx); // one attempt — one use
// Checks iss in the response, state, exchanges code + code_verifier,
// validates the ID token: iss, aud, exp, iat, nonce.
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: checks.codeVerifier,
expectedState: checks.state,
expectedNonce: checks.nonce,
});
const idToken = tokens.claims();
// UserInfo is accepted only if its sub matches the ID token's sub.
const profile = await client.fetchUserInfo(config, tokens.access_token, idToken.sub);
console.log("token response:", {
token_type: tokens.token_type,
expires_in: tokens.expiresIn(),
scope: tokens.scope,
has_refresh_token: Boolean(tokens.refresh_token),
});
console.log("ID token claims:", idToken);
console.log("UserInfo:", profile);
// Stable user key = iss + sub (not email). Next step: create your own session.
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "set-cookie": "oidc_tx=; Path=/callback; Max-Age=0" });
res.end(`Signed in: ${idToken.iss} / ${idToken.sub} (${profile.name})`);
}
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost:4200");
try {
if (url.pathname === "/login") return await login(res);
if (url.pathname === "/callback") return await callback(req, res, url);
res.writeHead(404).end();
} catch (err) {
console.error("sign-in failed:", err.name, err.code ?? "", err.message);
res.writeHead(400).end("Sign-in failed");
}
}).listen(4200, () => console.log("App: http://localhost:4200/login"));allowInsecureRequests нужен только потому, что тестовый провайдер работает по http на localhost — с настоящим провайдером его не должно бытьЗапустите провайдер и клиент в двух терминалах, откройте http://localhost:4200/login, введите логин aigerim и любой пароль, затем подтвердите доступ. Так выглядит путь браузера — каждый редирект виден:
302 GET http://localhost:4200/login
303 GET http://localhost:4100/auth?redirect_uri=http%3A%2F%2Flocalhost%3A4200%2Fcallback&scope=openid+profile+email&code_challenge=…&code_challenge_method=S256&state=…&nonce=…&client_id=interlab-web&response_type=code
200 GET http://localhost:4100/interaction/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
303 POST http://localhost:4100/interaction/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
303 GET http://localhost:4100/auth/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
200 GET http://localhost:4100/interaction/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
303 POST http://localhost:4100/interaction/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
303 GET http://localhost:4100/auth/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
200 GET http://localhost:4200/callback?code=…&state=…&iss=http%3A%2F%2Flocalhost%3A4100code, state и iss$ node provider.mjs
oidc-provider WARNING: a quick start development-only in-memory adapter is used, you MUST change it in order to not lose all stateful provider data upon restart and to be able to share these between processes
oidc-provider WARNING: a quick start development-only feature devInteractions is enabled, you are expected to disable these interactions and provide your own
IdP: http://localhost:4100/.well-known/openid-configuration
$ node --env-file=.env app.mjs
App: http://localhost:4200/login
token response: {
token_type: 'bearer',
expires_in: 599,
scope: 'openid profile email',
has_refresh_token: false
}
ID token claims: {
sub: 'aigerim',
nonce: '-LzAqMKyjMJKsyDq5VUnxw_j__msNftg7EGno4Fsg-k',
aud: 'interlab-web',
exp: 1791774176,
iat: 1791773576,
iss: 'http://localhost:4100'
}
UserInfo: {
sub: 'aigerim',
name: 'Aigerim Test',
email: 'aigerim@example.test',
email_verified: true
}Проверка ID token
OpenID Connect Core (раздел 3.1.3.7) перечисляет, что клиент обязан проверить в ID token. openid-client делает это в authorizationCodeGrant, но полезно понимать список — он понадобится, когда ID token приходит не из token endpoint, а, например, от мобильного приложения на ваш API.
issточно совпадает с issuer из discoveryaudсодержит вашclient_id, и в нём нет посторонних аудиторий- подпись проверена ключом из
jwks_uriпровайдера, алгоритм — из ожидаемого списка,alg: noneотклоняется - текущее время раньше
exp;iatне слишком далеко в прошлом nonceсовпадает со значением, сохранённым при начале входа- если запрашивали
max_age— проверенauth_time
Тонкость, о которой стоит знать: если ID token получен напрямую от token endpoint по TLS, спецификация разрешает считать проверку TLS-сертификата провайдера заменой проверки подписи, и openid-client по умолчанию подпись в этом случае не проверяет (включается enableNonRepudiationChecks). Остальные пункты — iss, aud, exp, iat, nonce — библиотека проверяет всегда. Когда токен пришёл любым другим путём, подпись проверяется обязательно. Так выглядит полная проверка на jose:
// Full ID token check when the token did NOT come straight from the token endpoint,
// e.g. a mobile app sends it to your API. OpenID Connect Core 1.0, section 3.1.3.7.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwksCache = new Map();
export async function verifyIdToken(idToken, { issuer, jwksUri, clientId, nonce }) {
if (!jwksCache.has(jwksUri)) jwksCache.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
const { payload } = await jwtVerify(idToken, jwksCache.get(jwksUri), {
issuer, // iss must match exactly
audience: clientId, // aud must contain our client_id
algorithms: ["RS256"], // only what we expect; never "none"
requiredClaims: ["sub", "iat", "exp"],
maxTokenAge: "10m", // reject tokens issued too long ago (iat)
clockTolerance: 30, // seconds of clock skew allowed for exp/iat
});
if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId) {
throw new Error("unexpected extra audiences");
}
if (payload.nonce !== nonce) throw new Error("nonce mismatch");
return payload;
}Тесты стенда прогоняют полный поток с подменой по одному параметру за раз: чужой verifier, чужой state, чужой nonce, повторное использование кода, запрос без PKCE, незарегистрированный redirect_uri, а для ручной проверки — подделанный sub, чужая аудитория и alg: none.
$ node --test negative.test.mjs idtoken.test.mjs
valid -> aigerim interlab-web
forged sub -> ERR_JWS_SIGNATURE_VERIFICATION_FAILED
other aud -> ERR_JWT_CLAIM_VALIDATION_FAILED aud
nonce -> nonce mismatch
alg none -> ERR_JOSE_ALG_NOT_ALLOWED
✔ ID token: valid passes, tampered / foreign aud / wrong nonce fail (119.2465ms)
-> invalid_scope requested scope is not allowed
-> invalid_client client authentication failed
✔ client credentials: scope outside the client's allowance is refused (28.9577ms)
✔ happy path returns an ID token for aigerim (86.1297ms)
-> ResponseBodyError invalid_grant grant request is invalid
✔ wrong code_verifier: the IdP refuses the code (PKCE) (114.0837ms)
-> ClientError OAUTH_INVALID_RESPONSE invalid response encountered
✔ state from another attempt is rejected before the token request (95.1324ms)
-> ClientError OAUTH_JWT_CLAIM_COMPARISON_FAILED unexpected JWT claim value encountered
✔ unexpected nonce is rejected (109.1164ms)
-> ResponseBodyError invalid_grant grant request is invalid
✔ the same code cannot be used twice (122.6895ms)
-> 303 invalid_request Authorization Server policy requires PKCE to be used for this request
✔ authorization request without PKCE is refused by the IdP (2.7214ms)
-> 400 null
✔ redirect_uri not registered: the IdP does not redirect at all (11.6301ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0node --test на стенде (код тестов не приводим: он повторяет поток из app.mjs с подменёнными значениями)Ошибки безопасности: redirect_uri, state, implicit flow
Сравнение redirect_uri по префиксу или шаблону
- Чем опасна
- Код уходит на адрес атакующего, например через open redirect на вашем же домене
- Как правильно
- Сервер авторизации сравнивает строку целиком (RFC 9700, раздел 2.1); в тестах выше провайдер отвечает 400 и никуда не перенаправляет
Нет state/PKCE или они постоянные
- Чем опасна
- CSRF на callback: пользователя «логинят» в аккаунт атакующего
- Как правильно
- Одноразовые значения на каждую попытку, привязанные к сессии браузера
Implicit flow в SPA
- Чем опасна
- Токен в адресе попадает в историю, логи и
Referer, его нельзя привязать к клиенту - Как правильно
- Authorization Code + PKCE, токены — через backend (паттерн BFF) или в памяти SPA
Password grant «для своего же приложения»
- Чем опасна
- Пароль проходит через клиента, не работает 2FA, пользователь привыкает вводить пароль где угодно
- Как правильно
- Вход только на странице провайдера
Вход по access token вместо ID token
- Чем опасна
- Токен, выданный другому приложению, принимается как доказательство личности
- Как правильно
- Личность — только из проверенного ID token с вашим
aud
Пользователь ищется по email
- Чем опасна
- Захват аккаунта при переназначении адреса у провайдера
- Как правильно
- Ключ —
iss+sub
Клиент работает с несколькими провайдерами без проверки iss
- Чем опасна
- Mix-up атака: код одного провайдера отправляется другому
- Как правильно
- Проверять
issв ответе (RFC 9207) или в ID token;openid-clientделает это автоматически
По нашему опыту, большинство проблем возникает не в протоколе, а на стыке с собственным кодом: callback без проверки, что попытка входа вообще начиналась в этом браузере; сессия, созданная до проверки ID token; секрет клиента в репозитории или во фронтенде. Как хранить секреты клиентов и раздавать интеграциям отдельные ключи с минимальными правами, разобрано в уроке о безопасности интеграций.
Как проверено7 · пройденосверено 11 октября 2026 г.
- запущено, работает
pkce.mjs
node 24.18: вывод совпал с тестовым вектором RFC 7636 приложение B (true), длина verifier 43
- запущено, работает
provider.mjs + .env + app.mjs (полный поток Authorization Code + PKCE + nonce до ID token и UserInfo)
oidc-provider 9.12.2 на :4100, openid-client 6.8.8 на :4200, Node 24.18; браузер сыгран скриптом с cookie-jar (вход aigerim через dev-форму, согласие); вывод в статье — реальный (app.log, provider.log)
- запущено, работает
client-credentials.mjs
реальный токен reports:read без id_token; reports:write → invalid_scope, неверный секрет → invalid_client (node:test)
- запущено, работает
verify-id-token.mjs (jose 6.2.12)
node:test на реальном ID token стенда: валидный проходит; подделанный sub → ERR_JWS_SIGNATURE_VERIFICATION_FAILED, чужой aud → ERR_JWT_CLAIM_VALIDATION_FAILED, неверный nonce, alg none → ERR_JOSE_ALG_NOT_ALLOWED
- запущено, работает
негативные тесты потока (вывод в статье, код не приводится)
9/9 node --test: чужой verifier → invalid_grant, чужой state и nonce отклонены клиентом, повторный код → invalid_grant, запрос без PKCE → invalid_request, незарегистрированный redirect_uri → 400 без редиректа
- запущено, работает
curl discovery
реальный ответ стенда, сокращён
- запущено, работает
упражнение «Добавьте refresh token» (решение не приводится)
offline_access + prompt=consent → refresh token выдан; refreshTokenGrant вернул новый access token (для конфиденциального клиента oidc-provider по умолчанию refresh token не ротирует)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.