Как делать11 октября 2026 г.10 мин чтения
API и интеграцииШаг 4 из 10

OAuth 2.0 и OpenID Connect: вход через чужой аккаунт

Коротко

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 и OpenID Connect

После материала вы сможете

  • объяснить роли 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 г.

Содержание
  1. 01Зачем OAuth и чем он не является
  2. 02Authorization Code + PKCE шаг за шагом
  3. 03Токены и scope: access, refresh, ID token
  4. 04OpenID Connect: discovery, ID token и UserInfo
  5. 05Стенд: локальный провайдер и клиент на openid-client
  6. 06Проверка ID token
  7. 07Ошибки безопасности: redirect_uri, state, implicit flow

Зачем 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-эндпоинт провайдера
Четыре роли из RFC 6749, раздел 1.1

В OpenID Connect сервер авторизации называют OpenID Provider (OP), а клиент — Relying Party (RP). Ещё одно различие, которое влияет на выбор потока: конфиденциальный клиент (backend) может хранить секрет, публичный (SPA, мобильное приложение) — нет, потому что его код доступен пользователю.

Authorization Code + PKCE шаг за шагом

Authorization Code — основной поток для всего, где есть пользователь и браузер. Токены не проходят через адресную строку: в браузер возвращается только короткоживущий одноразовый код, а обмен кода на токены идёт напрямую между сервером клиента и провайдером. PKCE (Proof Key for Code Exchange, RFC 7636) привязывает этот код к тому, кто начал вход: перехваченный код без секрета-«верификатора» бесполезен.

  1. Клиент готовит одноразовые значения

    code_verifier — случайная строка 43–128 символов, code_challenge = BASE64URL(SHA-256(verifier)), а также state и nonce. Всё это сохраняется в серверной сессии пользователя.

  2. Редирект на authorization endpoint

    В адресе — client_id, redirect_uri, scope, response_type=code, code_challenge, code_challenge_method=S256, state, nonce. Сам verifier не передаётся.

  3. Пользователь входит у провайдера

    Пароль, второй фактор и согласие на доступ вводятся на стороне провайдера. Клиент пароль не видит.

  4. Возврат на redirect_uri с кодом

    Провайдер перенаправляет браузер на зарегистрированный адрес с code, state и iss (RFC 9207). Клиент сверяет state и iss.

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

    Сервер клиента отправляет на token endpoint code, redirect_uri, code_verifier и аутентифицирует себя. Провайдер пересчитывает хеш verifier и сравнивает с challenge из шага 2.

  6. Проверка ответа

    Клиент получает access token и ID token (и при запросе — refresh token), проверяет ID token и создаёт собственную сессию.

  1. 01Браузер
  2. 02Ваш сервер (RP)
  3. 03Провайдер (OP)
  4. 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.mjs
// 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);
PKCE S256 вручную: в продакшне то же делает openid-client (randomPKCECodeVerifier, calculatePKCECodeChallenge)
terminal
$ node pkce.mjs
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
true
43
Реальный вывод: хеш совпал с вектором из RFC 7636, длина случайного verifier — 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.

client-credentials.mjs
// 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
terminal
$ node --env-file=.env client-credentials.mjs
{
  token_type: 'bearer',
  expires_in: 600,
  scope: 'reports:read',
  id_token: undefined
}
Реальный вывод на тестовом провайдере: access token без ID token. Запрос reports:write или неверный секрет провайдер отклоняет — см. тесты ниже

OpenID Connect: discovery, ID token и UserInfo

Провайдер OpenID Connect публикует свои настройки по адресу /.well-known/openid-configuration (OpenID Connect Discovery 1.0): адреса эндпоинтов, JWKS с публичными ключами подписи, поддерживаемые потоки и алгоритмы. Клиенту достаточно знать issuer — остальное он читает отсюда. RFC 9700 рекомендует именно так настраивать клиентов: меньше ошибок конфигурации, проще ротация ключей.

terminal
$ 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 того же автора. Провайдер — только для разработки: данные в памяти, встроенная форма входа принимает любой пароль, ключ подписи создаётся при каждом запуске. Секреты в примере — тестовые строки.

bash
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
Временный проект; версии из реестра npm на дату проверки
provider.mjs
// 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");
});
Тестовый провайдер на порту 4100: два клиента, PKCE обязателен для всех, один пользователь. findAccount в реальном провайдере ходит в вашу базу
.env
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.

app.mjs
// 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"));
Relying Party на порту 4200. allowInsecureRequests нужен только потому, что тестовый провайдер работает по http на localhost — с настоящим провайдером его не должно быть

Запустите провайдер и клиент в двух терминалах, откройте http://localhost:4200/login, введите логин aigerim и любой пароль, затем подтвердите доступ. Так выглядит путь браузера — каждый редирект виден:

terminal
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%3A4100
Реальная цепочка запросов, записанная скриптом в роли браузера (значения code, state, nonce и challenge скрыты «…»): вход, согласие, возврат с code, state и iss
terminal
$ 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
}
Реальный вывод провайдера и клиента. Предупреждения oidc-provider напоминают, что хранилище в памяти и форма входа годятся только для разработки

Проверка ID token

OpenID Connect Core (раздел 3.1.3.7) перечисляет, что клиент обязан проверить в ID token. openid-client делает это в authorizationCodeGrant, но полезно понимать список — он понадобится, когда ID token приходит не из token endpoint, а, например, от мобильного приложения на ваш API.

  • iss точно совпадает с issuer из discovery
  • aud содержит ваш 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:

verify-id-token.mjs
// 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;
}
Проверка ID token, пришедшего не из token endpoint: ключи из JWKS кешируются, алгоритм фиксирован, nonce сверяется отдельно

Тесты стенда прогоняют полный поток с подменой по одному параметру за раз: чужой verifier, чужой state, чужой nonce, повторное использование кода, запрос без PKCE, незарегистрированный redirect_uri, а для ручной проверки — подделанный sub, чужая аудитория и alg: none.

terminal
$ 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 0
Реальный вывод node --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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.