.NET Tutorial
Middle8 мин чтенияВопрос 4 из 8

OAuth 2.0 и OpenID Connect: в чём разница, какие flow использовать?

Проблема, которую решает OAuth

До OAuth приложения просили у пользователя пароль от чужого сервиса («введите пароль от Gmail, чтобы мы нашли ваших друзей»). Приложение получало полный доступ навсегда, отозвать можно было только сменой пароля.

OAuth заменяет пароль на токен с ограниченными правами (scope) и сроком жизни, который пользователь выдаёт сам и может отозвать.

даёт согласие

получает токены

Bearer access token

проверяет подпись
по JWKS

Resource Owner
(пользователь)

Authorization Server
(Keycloak, Entra ID)

Client
(веб-приложение, SPA, мобильное)

Resource Server
(ваш API)

даёт согласие

получает токены

Bearer access token

проверяет подпись
по JWKS

Resource Owner
(пользователь)

Authorization Server
(Keycloak, Entra ID)

Client
(веб-приложение, SPA, мобильное)

Resource Server
(ваш API)

Роли OAuth 2.0

OAuth 2.0 против OpenID Connect

OAuth 2.0 OpenID Connect
Назначение Авторизация: что приложению можно делать Аутентификация: кто пользователь
Главный артефакт Access token ID token + access token
Формат токена Не определён (часто JWT, может быть непрозрачным) ID token — всегда JWT
Информация о пользователе Нет стандарта Стандартные claims, эндпоинт /userinfo
Scope Любые (orders.read) Обязательный openid + profile, email
Discovery Нет (RFC 8414 — расширение) /.well-known/openid-configuration

Authorization Code + PKCE

Основной flow для любого приложения с пользователем.

APIAuthorizationServerКлиент(веб-приложение)Браузерcode_verifier =random,code_challenge =SHA256(verifier)302 на/authorize?response_type=code,client_id, scope, state,code_challenge/authorize — вход и согласие302 на redirect_uri?code=abc, statecallback с codePOST /token — code +code_verifier (+ client_secret)SHA256(verifier) ==challenge?access_token, id_token,refresh_tokenAuthorization Bearer access_tokenданные
APIAuthorizationServerКлиент(веб-приложение)Браузерcode_verifier =random,code_challenge =SHA256(verifier)302 на/authorize?response_type=code,client_id, scope, state,code_challenge/authorize — вход и согласие302 на redirect_uri?code=abc, statecallback с codePOST /token — code +code_verifier (+ client_secret)SHA256(verifier) ==challenge?access_token, id_token,refresh_tokenAuthorization Bearer access_tokenданные
Authorization Code Flow с PKCE

Ключевые идеи:

  • Через браузер (front channel) передаётся только одноразовый код, а не токены. Токены клиент получает напрямую от сервера (back channel).
  • PKCE (Proof Key for Code Exchange): даже если код перехвачен (вредоносное приложение на телефоне, логи, Referer), без code_verifier его нельзя обменять на токены. Обязателен для публичных клиентов, рекомендуется для всех.
  • state — защита от CSRF в callback; nonce — привязка ID token к запросу (защита от replay).
  • redirect_uri должен точно совпадать с зарегистрированным.

Client Credentials: сервис-сервис

payments-apiAuthorizationServerorders-workerPOST /tokengrant_type=client_credentials,client_id, client_secret,scope=payments.writeaccess_token (без refresh)Bearer access_tokenпроверка aud, scope
payments-apiAuthorizationServerorders-workerPOST /tokengrant_type=client_credentials,client_id, client_secret,scope=payments.writeaccess_token (без refresh)Bearer access_tokenпроверка aud, scope
Client Credentials — без пользователя
Автоматическое получение и кэширование токена (Duende.AccessTokenManagement)
builder.Services.AddClientCredentialsTokenManagement()
    .AddClient("payments", c =>
    {
        c.TokenEndpoint = "https://auth.example.uz/connect/token";
        c.ClientId = "orders-worker";
        c.ClientSecret = builder.Configuration["Auth:ClientSecret"];
        c.Scope = "payments.write";
    });

builder.Services.AddHttpClient<PaymentsClient>(c => c.BaseAddress = new("https://payments.internal"))
    .AddClientCredentialsTokenHandler("payments");    // токен кэшируется до истечения и обновляется сам

Вместо секрета надёжнее private_key_jwt (клиент подписывает assertion своим ключом) или mTLS, а в облаке — workload identity.

Какой flow выбрать

нет

да

нет (ТВ, CLI)

да

серверное веб-приложение

SPA

мобильное

Есть пользователь?

Client Credentials

Есть браузер
на устройстве?

Device Authorization Flow

Authorization Code + PKCE

Тип клиента

Confidential client:
секрет на сервере, токены в сессии

Лучше BFF:
токены на сервере, браузеру — cookie

Системный браузер + PKCE,
refresh в Keychain/Keystore

Не использовать:
Implicit, Password grant

нет

да

нет (ТВ, CLI)

да

серверное веб-приложение

SPA

мобильное

Есть пользователь?

Client Credentials

Есть браузер
на устройстве?

Device Authorization Flow

Authorization Code + PKCE

Тип клиента

Confidential client:
секрет на сервере, токены в сессии

Лучше BFF:
токены на сервере, браузеру — cookie

Системный браузер + PKCE,
refresh в Keychain/Keystore

Не использовать:
Implicit, Password grant

Выбор flow
Flow Когда Статус
Authorization Code + PKCE Веб, SPA, мобильные, десктоп Рекомендован
Client Credentials Сервис-сервис, фоновые задачи Рекомендован
Device Code Устройства без браузера или клавиатуры Рекомендован
Refresh Token Продление сессии Используется со всеми выше
Implicit Раньше для SPA (токен в URL-фрагменте) Устарел — утечки через историю и Referer
Resource Owner Password Приложение собирает пароль Устарел — ломает саму идею OAuth, не работает с MFA

ID token против access token

ID token Access token
Для кого Для клиента Для API (resource server)
aud client_id приложения Идентификатор API (orders-api)
Содержит Кто вошёл, когда, как (sub, auth_time, amr) Права: scope, роли
Отправлять в API Никогда Да, в заголовке Authorization
Формат Всегда JWT JWT или непрозрачный

ASP.NET Core: веб-приложение (OIDC-клиент)

C#
builder.Services.AddAuthentication(o =>
    {
        o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(o =>
    {
        o.Cookie.HttpOnly = true;
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.Cookie.SameSite = SameSiteMode.Lax;
    })
    .AddOpenIdConnect(o =>
    {
        o.Authority = "https://auth.example.uz";
        o.ClientId = "shop-web";
        o.ClientSecret = builder.Configuration["Auth:ClientSecret"];
        o.ResponseType = "code";                        // Authorization Code; PKCE включён по умолчанию
        o.Scope.Add("openid"); o.Scope.Add("profile"); o.Scope.Add("orders.read");
        o.Scope.Add("offline_access");                  // refresh token
        o.SaveTokens = true;                            // токены сохраняются в зашифрованной cookie-сессии
        o.MapInboundClaims = false;
        o.GetClaimsFromUserInfoEndpoint = true;
    });

// в коде: var accessToken = await HttpContext.GetTokenAsync("access_token");

ASP.NET Core: API (resource server)

C#
builder.Services.AddAuthentication().AddJwtBearer(o =>
{
    o.Authority = "https://auth.example.uz";
    o.Audience = "orders-api";                          // принимать только токены для этого API
    o.MapInboundClaims = false;
});

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("orders:write", p => p.RequireAssertion(ctx =>
        ctx.User.FindFirst("scope")?.Value.Split(' ').Contains("orders.write") == true));

app.MapPost("/orders", CreateOrder).RequireAuthorization("orders:write");

SPA: почему BFF

HttpOnly cookie
(без токенов)

OIDC code flow

Bearer access token
(YARP-прокси)

SPA в браузере

BFF (ASP.NET Core)
confidential client

Authorization Server

API

HttpOnly cookie
(без токенов)

OIDC code flow

Bearer access token
(YARP-прокси)

SPA в браузере

BFF (ASP.NET Core)
confidential client

Authorization Server

API

Backend for Frontend

Токены в браузере (localStorage, память) доступны любому XSS. В схеме BFF браузер хранит только сессионную cookie, а токены живут на сервере; BFF проксирует вызовы API и подставляет access token (Duende BFF, YARP). Это рекомендация спецификации OAuth 2.0 for Browser-Based Applications.

Вопросы на засыпку

Зачем PKCE, если у клиента есть client_secret?

PKCE защищает от перехвата и внедрения authorization code (например, код попал в логи или его подсунул атакующий в callback). Секрет подтверждает клиента, PKCE — что код обменивает тот же экземпляр, который начал вход. OAuth 2.1 требует PKCE для всех клиентов.

Что такое scope и чем он отличается от роли?

Scope — право, которое пользователь делегировал приложению (orders.read): приложение не может больше, чем разрешено. Роль — что пользователю разрешено в системе (manager). Итоговое право = пересечение: приложение с orders.read не удалит заказ, даже если пользователь — администратор.

Можно ли свой IdP не писать?

Почти всегда нужно не писать: используйте Keycloak, Entra ID / Azure AD B2C, Auth0, Okta, Duende IdentityServer или OpenIddict (библиотека для своего сервера авторизации на ASP.NET Core). Реализация протокола — сложная область с множеством тонкостей безопасности.

Что происходит при выходе (logout)?

Локальный выход удаляет cookie приложения, но сессия у IdP остаётся — следующий вход пройдёт без пароля. Полный выход: SignOutAsync для cookie и OIDC-схемы → редирект на end_session_endpoint IdP. Для выхода из всех приложений — front-channel/back-channel logout.

Итог

OAuth 2.0 — про делегированный доступ (access token для API), OpenID Connect — про вход (ID token для клиента). Для пользователей используйте Authorization Code + PKCE, для сервисов — Client Credentials, забудьте Implicit и Password grant. В ASP.NET Core веб-клиент — это AddOpenIdConnect + cookie, API — AddJwtBearer с проверкой audience и scope, а SPA лучше подключать через BFF.