Пошук уроків, статей та іншого контенту
Розглянемо OAuth 2.0 та OpenID Connect і навчимося підключати зовнішнього провайдера для входу користувачів.
OAuth 2.0 та OpenID Connect вирішують дві різні, але пов’язані задачі:
OAuth 2.0 — делегування доступу до ресурсів.
OpenID Connect (OIDC) — автентифікація користувача поверх OAuth 2.0.
Наприклад, застосунок може попросити зовнішнього провайдера підтвердити особу користувача, а потім отримати його ідентифікатор, ім’я та адресу електронної пошти.
OAuth 2.0 описує процес, за якого користувач дозволяє застосунку діяти від свого імені.
Типовий приклад:
Користувач відкриває застосунок.
Застосунок перенаправляє його до провайдера.
Користувач входить у провайдера та підтверджує доступ.
Провайдер повертає застосунку код авторизації.
Сервер застосунку обмінює код на токени.
Access token використовується для доступу до API провайдера.
OAuth 2.0 сам по собі не визначає, хто саме увійшов у систему. Access token призначений для доступу до ресурсів і не повинен автоматично трактуватися як ідентифікатор користувача.
OpenID Connect додає до OAuth 2.0 стандартизовану автентифікацію.
Основний токен OIDC — ID token. Це JWT, який містить твердження про автентифікацію користувача, зокрема:
sub — стабільний ідентифікатор користувача у провайдера;
iss — видавець токена;
aud — клієнт, для якого видано токен;
exp — час завершення дії;
nonce — значення для захисту від повторного використання відповіді;
додаткові claims, наприклад name або email.
Access token використовується для API, а ID token — для підтвердження особи користувача в клієнтському застосунку.
У сценарії входу беруть участь такі сторони:
Resource owner — користувач.
Client — наш Node.js-застосунок.
Authorization server — сервер, який автентифікує користувача та видає токени.
Resource server — API, до якого можна звертатися з access token.
У багатьох провайдерів authorization server і resource server є частинами однієї платформи.
Для серверного Node.js-застосунку використовується Authorization Code Flow.
Послідовність:
Застосунок генерує state, nonce і пару PKCE-значень.
Користувач переходить на authorization endpoint провайдера.
Провайдер автентифікує користувача.
Провайдер перенаправляє користувача назад із параметром code.
Застосунок перевіряє state.
Сервер обмінює code на токени.
Застосунок перевіряє ID token.
Застосунок створює власну сесію користувача.
statestate захищає від CSRF-атак під час авторизації.
Застосунок генерує випадкове значення перед перенаправленням і зберігає його на сервері. Після повернення від провайдера значення з callback має збігатися зі збереженим.
Не слід приймати будь-який state, який надійшов від клієнта.
noncenonce додається до запиту авторизації та має повернутися всередині ID token.
Перевірка nonce допомагає переконатися, що отриманий ID token належить саме поточному процесу входу, а не був повторно використаний з іншого запиту.
PKCE захищає authorization code від перехоплення.
Процес складається з двох значень:
code_verifier — випадковий секрет, який зберігає застосунок;
code_challenge — SHA-256-хеш від code_verifier, який надсилається провайдеру.
Під час обміну коду на токени сервер передає початковий code_verifier. Провайдер перевіряє, що він відповідає початковому challenge.
Для серверних застосунків PKCE є корисним додатковим захистом навіть тоді, коли використовується client_secret.
OIDC-провайдер зазвичай публікує конфігурацію за адресою:
<issuer>/.well-known/openid-configurationУ документі містяться endpoint-и:
authorization_endpoint;
token_endpoint;
userinfo_endpoint;
jwks_uri;
підтримувані scopes і типи відповідей.
Застосунок не повинен жорстко припускати, що всі endpoint-и мають однакові адреси. Їх потрібно отримувати з discovery-документа.
Нижче наведено мінімальний сервер без Express. Він демонструє:
перенаправлення до OIDC-провайдера;
state;
nonce;
PKCE;
обмін authorization code на токени;
перевірку ID token;
створення простої серверної сесії.
Для перевірки JWT використовується пакет jose.
Встановлення залежності:
npm init -y
npm install joseПриклад розрахований на Node.js 20 або новішу версію.
Перед запуском потрібно встановити змінні середовища:
export OIDC_ISSUER="https://your-provider.example"
export OIDC_CLIENT_ID="your-client-id"
export OIDC_CLIENT_SECRET="your-client-secret"
export OIDC_REDIRECT_URI="http://localhost:3000/callback"Реалізація:
import http from "node:http";
import crypto from "node:crypto";
import { URL } from "node:url";
import { createRemoteJWKSet, jwtVerify } from "jose";
const port = 3000;
const issuer = process.env.OIDC_ISSUER;
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
const redirectUri =
process.env.OIDC_REDIRECT_URI ?? "http://localhost:3000/callback";
if (!issuer || !clientId || !clientSecret) {
throw new Error(
"Потрібно встановити OIDC_ISSUER, OIDC_CLIENT_ID та OIDC_CLIENT_SECRET"
);
}
const sessions = new Map();
const loginTransactions = new Map();
function randomValue(bytes = 32) {
return crypto.randomBytes(bytes).toString("base64url");
}
function createCodeChallenge(codeVerifier) {
return crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
}
function parseCookies(request) {
const header = request.headers.cookie;
if (!header) {
return {};
}
return Object.fromEntries(
header.split(";").map((part) => {
const index = part.indexOf("=");
const name = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
return [name, decodeURIComponent(value)];
})
);
}
function sendHtml(response, statusCode, html, headers = {}) {
response.writeHead(statusCode, {
"content-type": "text/html; charset=utf-8",
...headers,
});
response.end(html);
}
function redirect(response, location, headers = {}) {
response.writeHead(302, {
location,
...headers,
});
response.end();
}
async function loadProviderConfiguration() {
const discoveryUrl = new URL(
".well-known/openid-configuration",
issuer.endsWith("/") ? issuer : `${issuer}/`
);
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new Error(`Discovery-запит завершився з кодом ${response.status}`);
}
return response.json();
}
const provider = await loadProviderConfiguration();
const jwks = createRemoteJWKSet(new URL(provider.jwks_uri));
const server = http.createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url, `http://${request.headers.host}`);
if (request.method === "GET" && requestUrl.pathname === "/") {
const cookies = parseCookies(request);
const session = cookies.session
? sessions.get(cookies.session)
: undefined;
if (!session) {
sendHtml(
response,
200,
`
<h1>Вхід</h1>
<p><a href="/login">Увійти через зовнішнього провайдера</a></p>
`
);
return;
}
sendHtml(
response,
200,
`
<h1>Профіль</h1>
<pre>${escapeHtml(JSON.stringify(session.user, null, 2))}</pre>
<p><a href="/logout">Вийти</a></p>
`
);
return;
}
if (request.method === "GET" && requestUrl.pathname === "/login") {
const state = randomValue();
const nonce = randomValue();
const codeVerifier = randomValue(48);
const codeChallenge = createCodeChallenge(codeVerifier);
loginTransactions.set(state, {
nonce,
codeVerifier,
createdAt: Date.now(),
});
const authorizationUrl = new URL(provider.authorization_endpoint);
authorizationUrl.search = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid profile email",
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
redirect(response, authorizationUrl);
return;
}
if (request.method === "GET" && requestUrl.pathname === "/callback") {
const error = requestUrl.searchParams.get("error");
if (error) {
sendHtml(
response,
400,
`<h1>Авторизацію відхилено</h1><p>${escapeHtml(error)}</p>`
);
return;
}
const code = requestUrl.searchParams.get("code");
const state = requestUrl.searchParams.get("state");
if (!code || !state) {
sendHtml(response, 400, "<h1>Відсутні code або state</h1>");
return;
}
const transaction = loginTransactions.get(state);
loginTransactions.delete(state);
if (!transaction) {
sendHtml(response, 400, "<h1>Недійсний або прострочений state</h1>");
return;
}
// Обмежуємо час життя транзакції входу.
if (Date.now() - transaction.createdAt > 5 * 60 * 1000) {
sendHtml(response, 400, "<h1>Транзакція входу прострочена</h1>");
return;
}
const tokenResponse = await fetch(provider.token_endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
code_verifier: transaction.codeVerifier,
}),
});
if (!tokenResponse.ok) {
const details = await tokenResponse.text();
throw new Error(`Помилка token endpoint: ${details}`);
}
const tokens = await tokenResponse.json();
if (!tokens.id_token) {
throw new Error("Провайдер не повернув id_token");
}
const { payload } = await jwtVerify(tokens.id_token, jwks, {
issuer,
audience: clientId,
});
if (payload.nonce !== transaction.nonce) {
throw new Error("Nonce у ID token не збігається");
}
if (typeof payload.sub !== "string") {
throw new Error("ID token не містить коректного sub");
}
let user = {
subject: payload.sub,
issuer: payload.iss,
name: payload.name,
email: payload.email,
};
// UserInfo запит потрібен лише тоді, коли застосунку потрібні
// додаткові дані профілю.
if (tokens.access_token && provider.userinfo_endpoint) {
const userInfoResponse = await fetch(provider.userinfo_endpoint, {
headers: {
authorization: `Bearer ${tokens.access_token}`,
},
});
if (userInfoResponse.ok) {
const userInfo = await userInfoResponse.json();
if (userInfo.sub !== payload.sub) {
throw new Error("sub у UserInfo не збігається з sub у ID token");
}
user = {
...user,
name: userInfo.name ?? user.name,
email: userInfo.email ?? user.email,
};
}
}
const sessionId = randomValue();
sessions.set(sessionId, {
user,
createdAt: Date.now(),
});
redirect(response, "/", {
"set-cookie": [
`session=${encodeURIComponent(sessionId)}; HttpOnly; Path=/; SameSite=Lax`,
],
});
return;
}
if (request.method === "GET" && requestUrl.pathname === "/logout") {
const cookies = parseCookies(request);
if (cookies.session) {
sessions.delete(cookies.session);
}
redirect(response, "/", {
"set-cookie": [
"session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
],
});
return;
}
sendHtml(response, 404, "<h1>Сторінку не знайдено</h1>");
} catch (error) {
console.error(error);
sendHtml(response, 500, "<h1>Внутрішня помилка сервера</h1>");
}
});
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
server.listen(port, () => {
console.log(`Сервер запущено: http://localhost:${port}`);
});Отримання JWT недостатньо. Сервер повинен перевірити його підпис і стандартні claims.
У прикладі перевіряються:
підпис за ключами з jwks_uri;
iss — токен виданий очікуваним провайдером;
aud — токен призначений нашому клієнту;
exp — токен не прострочений;
nonce — відповідає поточній транзакції;
sub — присутній і має очікуваний тип.
Публічні ключі провайдера можуть змінюватися. Саме тому застосунок отримує їх із JWKS endpoint, а не зберігає назавжди в коді.
Для зв’язування зовнішнього облікового запису з локальним користувачем потрібно використовувати комбінацію:
iss + subЗначення sub у різних провайдерів не є глобально унікальним. Email не варто використовувати як єдиний ідентифікатор:
користувач може змінити email;
різні провайдери можуть мати однакові email;
email може бути непідтвердженим.
Практична модель локального користувача може містити:
provider_issuer
provider_subject
email
display_nameУнікальним ключем зовнішнього облікового запису буде пара provider_issuer і provider_subject.
Після обміну коду провайдер може повернути:
access_token;
id_token;
refresh_token;
expires_in;
тип токена, зазвичай Bearer.
Access token можна використати для запиту UserInfo endpoint:
GET /userinfo
Authorization: Bearer ACCESS_TOKENUserInfo має повертати sub. Це значення потрібно порівняти з sub у вже перевіреному ID token.
Не слід без перевірки довіряти даним UserInfo. Запит до UserInfo автентифікований access token, але застосунок усе одно повинен переконатися, що відповідь належить тому самому користувачеві.
У прикладі сесії зберігаються в Map, тому вони зникають після перезапуску процесу. Для навчального прикладу цього достатньо, але в реальному застосунку потрібне зовнішнє сховище сесій.
Cookie сесії повинна мати такі атрибути:
HttpOnly — JavaScript у браузері не може прочитати cookie;
Secure — cookie надсилається лише через HTTPS;
SameSite=Lax або більш суворе значення, якщо це сумісно зі сценарієм;
Path=/;
обмежений час життя через Max-Age або Expires.
Для локального HTTP-розроблення Secure часто тимчасово не використовують. У production застосунок повинен працювати через HTTPS.
Сесію потрібно знищувати під час виходу. Якщо провайдер підтримує глобальний logout, його можна реалізувати окремо, але локальну сесію застосунок повинен завершити в будь-якому разі.
Під час реєстрації застосунку у провайдера потрібно вказати:
тип клієнта;
дозволений redirect URI;
дозволені scopes;
client ID;
client secret для конфіденційного серверного застосунку.
Redirect URI повинен точно збігатися із зареєстрованим значенням. Наприклад, ці адреси можуть вважатися різними:
http://localhost:3000/callback
http://localhost:3000/callback/Також не слід використовувати довільний redirect URI, отриманий із параметра запиту. Він має походити з конфігурації застосунку.
У прикладі використовується:
openid profile emailScope openid повідомляє провайдеру, що запит є OIDC-запитом.
Поширені scopes:
openid — обов’язковий для OIDC;
profile — базові дані профілю;
email — адреса та, залежно від провайдера, ознака її підтвердження.
Потрібно запитувати лише необхідні scopes. Надмірні дозволи погіршують приватність і можуть вимагати додаткової згоди користувача.
ID token призначений для клієнтського застосунку. Не потрібно надсилати його до API замість access token, якщо API очікує OAuth access token.
stateЯкщо застосунок не перевіряє state, зловмисник може спробувати підмінити результат авторизації.
Authorization code може бути перехоплений. PKCE зменшує ризик використання такого коду стороннім учасником.
Не можна декодувати JWT через JSON.parse і вважати отримані claims достовірними. Спочатку потрібно перевірити підпис, iss, aud та термін дії.
Для зовнішньої ідентифікації використовуйте iss і sub. Email є атрибутом користувача, а не надійним глобальним ідентифікатором.
client_secret призначений лише для серверної частини. Його не можна включати до frontend-коду або передавати браузеру.
Код авторизації має короткий час життя та одноразове використання. Після обміну його не потрібно зберігати.
redirect_uriЗначення redirect_uri під час обміну коду має відповідати значенню, використаному під час авторизації та зареєстрованому у провайдера.
OAuth 2.0 відповідає за делегований доступ до ресурсів.
OpenID Connect додає стандартну автентифікацію поверх OAuth 2.0.
Для серверного Node.js-застосунку підходить Authorization Code Flow.
state захищає авторизаційний запит від CSRF.
nonce зв’язує ID token із конкретною транзакцією входу.
PKCE захищає authorization code від перехоплення.
ID token потрібно перевіряти за підписом і claims.
Користувача слід ідентифікувати за парою iss і sub.
Access token призначений для API, а ID token — для підтвердження особи.
У production необхідні HTTPS, захищені cookie та надійне сховище сесій.