OAuth2 и OIDC вглубь: authorization code, PKCE, device flow

Не «какой провайдер подключить», а как устроены сами флоу: authorization code + PKCE (и почему implicit умер), client credentials для сервис-сервис, device flow для TV/CLI, token introspection (opaque) vs локальная валидация JWT по JWKS, OIDC discovery. Разница между OAuth2 (делегирование доступа) и OIDC (аутентификация поверх)

Подключить «Войти через Google» несложно — SDK прячет всю механику. Но как только нужно понять, почему implicit flow объявили небезопасным, зачем мобильному приложению PKCE, как авторизовать сервис без пользователя или как валидировать чужой JWT без обращения к провайдеру на каждый запрос, — абстракция протекает, и нужно знать флоу. Эта статья разбирает OAuth2 и OIDC как протоколы: какие бывают потоки, зачем каждый и где подводные камни.

Дополняет мультипровайдерный OAuthСкоро («какой провайдер») механикой самих флоу; часть серии «Авторизация: токены, сессии, refresh».

Ретрофутуристская схема четырёх OAuth2/OIDC-потоков в стиле «Полдень. XXI век»: authorization code + PKCE (implicit перечёркнут как мёртвый), client credentials (поток машина-машина без пользователя), device flow (user_code 7Q9X-K2LP на экране устройства, подтверждение с телефона, poll authorization_pending до успеха), валидация токена — локально по JWKS (быстро, офлайн) против introspection (медленнее, свежее); внизу OIDC discovery

В статье

OAuth2 vs OIDC: две разные задачи одним протоколом

Путаница между OAuth2 и OIDC настолько частая, что стоит сразу зафиксировать словами, а не диаграммой. OAuth2 (RFC 6749) отвечает на вопрос «может ли этот клиент действовать от имени пользователя с такими-то правами» — это протокол делегирования доступа. Он ничего не говорит о том, кто именно вошёл: access_token авторизует запрос к API, но сам по себе не обязан нести читаемую личность. OIDC — надстройка над OAuth2, которая отвечает на другой вопрос: «кто перед нами». Она добавляет ровно один новый артефакт — id_token, подписанный JWT с claims об акте аутентификации (sub, iss, aud, exp, дальше — email/name и что угодно ещё), и стандартизирует пару вспомогательных вещей (/userinfo, discovery), о которых дальше в статье.

Четыре роли, вокруг которых строится любой флоу что в чистом OAuth2, что в OIDC:

  • Resource owner — пользователь, которому принадлежит защищаемый ресурс (или, в client credentials, роли просто нет — см. ниже).
  • Client — приложение, которому нужен доступ (мобильное приложение, SPA, серверный сервис).
  • Authorization server — тот, кто аутентифицирует resource owner и выпускает токены. В стенде эту роль играет mockidp (security/auth/login-methods/mockidp) — учебный, но протокольно честный OIDC-провайдер.
  • Resource server — тот, кто принимает access_token и отдаёт защищённые данные (в стенде это login-methods при обработке OAuth-callback, а в четвёртой статье серии — конкретно тот код, что дальше превращает профиль в сессию).

Разница OAuth2/OIDC видна прямо в коде mockidp: /token выдаёт id_token+access_token вместе для authorization_code и device_code grant — там есть resource owner, есть личность, которую нужно подтвердить. А для client_credentials — только access_token, без id_token вовсе:

// tokenClientCredentials — id_token НЕ выдаётся (client_credentials —
// чистый OAuth2-grant, без пользовательского контекста, вне OIDC id_token).

Это не недоработка демо, а прямое следствие модели: client_credentials — про сервис, действующий от своего собственного имени, там физически некого аутентифицировать как «пользователя», значит и id_token там взяться неоткуда. Каждый следующий раздел статьи — это один grant OAuth2, и для двух из них (authorization code, device code) он попутно устраивает и OIDC-аутентификацию через id_token.

Authorization code + PKCE: полный поток

Authorization code — основной флоу для любого клиента с браузером (веб-приложение, SPA, мобильное приложение с deep link). Идея в том, что сам токен браузеру никогда не передаётся напрямую — вместо него клиент получает короткоживущий одноразовый код, который потом обменивает на токен уже на прямом канале клиент→authorization server, минуя browser redirect.

Полный поток на живом коде mockidp (security/auth/login-methods/mockidp, тот же провайдер, что использует ст.4 серии для OAuth-входа):

  1. Клиент редиректит пользователя на GET /authorize?client_id&redirect_uri&state&code_challenge&code_challenge_method=S256. mockidp требует code_challenge с method=S256 — без PKCE запрос отклоняется ещё на этом шаге:
if challenge == "" || method != "S256" {
    http.Error(w, "invalid_request: code_challenge with method=S256 required", http.StatusBadRequest)
    return
}
  1. Пользователь аутентифицируется у провайдера (в mock-варианте — заглушка, в реальном IdP — форма логина), провайдер редиректит обратно на redirect_uri с code+state в query.
  2. Клиент обменивает код на токены прямым POST-запросом — /token с grant_type=authorization_code, code, code_verifier. Именно на этом шаге впервые всплывает code_verifier — секрет, который клиент сгенерировал ещё до шага 1 и никогда не передавал по каналу редиректа.

Вот в чём смысл PKCE (Proof Key for Code Exchange, RFC 7636): на шаге 1 клиент отправляет не сам секрет, а его производную — challenge = base64url(sha256(verifier)). На шаге 3 он предъявляет уже сам verifier, и сервер пересчитывает хеш и сверяет:

// verifyPKCE проверяет, что base64url(sha256(verifier)) (без padding)
// совпадает с challenge, полученным на /authorize (S256-метод из RFC 7636).
func verifyPKCE(verifier, challenge string) bool {
    sum := sha256.Sum256([]byte(verifier))
    computed := base64.RawURLEncoding.EncodeToString(sum[:])
    return computed == challenge
}

Зачем это нужно, если код и так одноразовый? Проблема в том, где код может быть перехвачен. Шаг 1→2 идёт через браузер и redirect — URL с кодом виден истории браузера, логам прокси, а на мобильных платформах — ещё и другим приложениям, зарегистрировавшим тот же custom URL scheme (classic attack: вредоносное приложение перехватывает redirect с кодом раньше легитимного клиента). PKCE делает перехваченный код бесполезным: у атакующего есть code, но нет verifier — а без него mockidp откажет с invalid_grant. Именно поэтому PKCE обязателен для публичных клиентов (SPA, мобильные приложения — тех, что не могут хранить секрет), и современная практика (см. ниже RFC 9700) рекомендует его и конфиденциальным клиентам тоже, просто как защиту-по-умолчанию, а не «только если нельзя иначе».

Код в mockidp одноразовый в буквальном смысле — удаляется из состояния сразу при обмене, до проверки PKCE, независимо от исхода:

h.mu.Lock()
pending, ok := h.codes[code]
if ok {
    delete(h.codes, code)
}
h.mu.Unlock()

Повторное предъявление того же кода — уже invalid_grant, даже с правильным verifier: код тратится один раз, попытка это сделать дважды сигнализирует либо о баге клиента, либо о попытке replay.

Почему implicit и password flow мертвы

Implicit flow (response_type=token) исторически существовал для JS-приложений без бэкенда: вместо кода /authorize сразу редиректил обратно с access_token прямо во фрагменте URL (#access_token=...), без промежуточного обмена. Звучит проще — а на практике это оказалось системной уязвимостью: токен во фрагменте URL попадает в историю браузера, в Referer-заголовки прокси-серверов (в старых браузерах), в логи любого промежуточного слоя, который логирует полный URL. И главное — implicit не даёт возможности для PKCE-подобной защиты в принципе: там просто нет шага обмена, к которому можно было бы что-то привязать. OAuth 2.0 Security Best Current Practice (RFC 9700) формально не рекомендует implicit ни для каких клиентов — используйте authorization code + PKCE везде, включая SPA.

Resource Owner Password Credentials (ROPC) — второй мёртвый грант: клиентское приложение само собирает логин/пароль пользователя и напрямую обменивает их на токен, минуя authorization server как отдельную сторону. Проблема на уровне модели доверия: смысл делегирования — в том, что пароль пользователя никогда не видит никто, кроме authorization server. ROPC ломает это прямым текстом, а заодно структурно несовместим с MFA, SSO и passkeys — там просто нет места, куда встроить второй фактор или биометрию, если весь флоу — это два текстовых поля и один POST.

Показательно, что mockidp эти два гранта не реализует вообще — только authorization_code (с обязательным PKCE), client_credentials и device_code. Это не пробел демо, а отражение текущей практики: у современных IdP implicit и ROPC часто просто физически отсутствуют в списке поддерживаемых grant’ов, а не «не рекомендуются, но работают» — то же самое видно в grant_types_supported discovery-документа mockidp (раздел ниже).

Client credentials: сервис-сервис без пользователя

Client credentials (RFC 6749 §4.4) — грант для случая, когда делегировать нечего: сервис обращается к другому сервису от своего собственного имени, без пользователя за штурвалом (батч-джоба, межсервисный вызов, интеграция с внешним партнёром). Клиент аутентифицируется сам собой напрямую — client_id/client_secret — и сразу получает access_token:

func (h *Handler) tokenClientCredentials(w http.ResponseWriter, r *http.Request) {
    clientID, clientSecret, ok := clientCredentialsFromRequest(r)
    if !ok || clientID != demoClientCredentialsID || clientSecret != demoClientCredentialsSecret {
        writeTokenError(w, http.StatusUnauthorized, "invalid_client", "")
        return
    }
    // ...
    h.tokens[accessToken] = issuedProfile{
        Sub: clientID, Scope: scope, ClientID: clientID,
        ExpiresAt: time.Now().Add(accessTokenTTL),
    }

clientCredentialsFromRequest принимает креды либо через HTTP Basic Auth, либо через form-поля client_id/client_secret — оба способа допустимы по RFC 6749, mockidp поддерживает оба с приоритетом Basic Auth. Sub в выданном профиле — сам clientID: «личность» здесь — это сервис, а не человек, что ещё раз подчёркивает разницу с OIDC-флоу выше.

Честная оговорка из README стенда: сравнение секрета в mockidp — обычное != над строками Go, не constant-time (subtle.ConstantTimeCompare). Практическая эксплуатируемость такой timing-атаки по сети мала (сетевой джиттер обычно перекрывает разницу в наносекундах), но это осознанное упрощение учебного провайдера, а не паттерн для переноса в прод: там — константное по времени сравнение или сравнение хешей.

Device authorization flow: TV, CLI, устройства без браузера

Есть класс устройств, для которых весь описанный выше поток с редиректами физически неудобен или невозможен: smart TV с пультом вместо клавиатуры, CLI-утилита без встроенного браузера, IoT-устройство без экрана вообще. Device authorization flow (RFC 8628) решает это разделением ролей на два экрана: устройство показывает короткий код, а подтверждение проходит на другом устройстве — телефоне или ноутбуке пользователя, где ввести данные удобно.

Клиентская часть в стенде — oidc.RunDeviceFlow (security/auth/oidc), серверная — mockidp (/device_authorization → /device/approve → поллинг /token). Первый шаг — запрос кода:

// deviceAuthorization — POST /device_authorization (RFC 8628 §3.1/§3.2).
deviceCode, _ := token.OpaqueHex(16)   // видит только устройство, для поллинга
userCode, _ := token.Code()            // видит пользователь, вводит вручную

writeJSON(w, http.StatusOK, map[string]any{
    "device_code":               deviceCode,
    "user_code":                 userCode,
    "verification_uri":          verificationURI,
    "verification_uri_complete": verificationURI + "?user_code=" + url.QueryEscape(userCode),
    "expires_in":                int(deviceCodeTTL.Seconds()),
    "interval":                  deviceInterval,
})

Разделение device_code/user_code не случайно: device_code — длинный непредсказуемый токен, который остаётся только у устройства и используется для поллинга; user_code — короткий, удобный для ручного набора код, который человек видит на экране устройства и вводит на verification_uri с телефона. RunDeviceFlow дальше запускает подтверждение (в реальном приложении это точка, где user_code рисуется на экране TV) и входит в цикл поллинга:

for {
    if time.Now().After(deadline) {
        return "", ErrDeviceFlowTimedOut
    }
    time.Sleep(interval)

    tok, pollErr := pollDeviceToken(base, auth.DeviceCode)
    switch {
    case pollErr == nil:
        return tok, nil
    case errors.Is(pollErr, errAuthorizationPending):
        continue
    case errors.Is(pollErr, errSlowDown):
        interval += time.Second
        continue
    default:
        return "", pollErr
    }
}

Три протокольных сигнала, которые обязан уважать любой корректный клиент device flow: authorization_pending — пользователь ещё не подтвердил, поллить дальше с тем же интервалом; slow_down — клиент опрашивает слишком часто, увеличить интервал (в RunDeviceFlow — на секунду) и продолжить, а не считать это ошибкой; истечение expires_in — прекратить попытки (ErrDeviceFlowTimedOut), а не поллить бесконечно. Игнорирование slow_down — частая ошибка наивных реализаций device flow: провайдер вправе просто перестать отвечать успехом (или начать банить) клиента, который не снижает частоту после явного сигнала.

На серверной стороне подтверждение — это одноразовое событие: deviceApprove находит device_code по введённому user_code и помечает его approved, а tokenDeviceCode при выдаче токена сразу же удаляет запись под тем же локом, что и проверку статуса:

// approved — claim атомарно здесь же, под тем же Lock, до генерации
// токенов: второй конкурентный запрос с этим device_code уже не найдёт
// его в h.devices и получит invalid_grant.
delete(h.devices, deviceCode)
delete(h.deviceUserCodes, token.NormalizeCode(dev.userCode))
h.mu.Unlock()

Это защита от гонки: если бы проверка статуса и удаление были раздельными операциями, два конкурентных poll’а с одним и тем же (только что подтверждённым) device_code теоретически могли бы оба увидеть approved и оба получить токен. Атомарный claim под одним локом гарантирует, что второй запрос физически не найдёт запись — она уже удалена первым — и получит invalid_grant, тот же код ошибки, что и для несуществующего device_code вообще (RFC 8628 §3.5 не предписывает различать эти случаи).

Честная оговорка из README: deviceInterval в mockidp — 1 секунда, а не типичные 5 у реальных провайдеров (Google, GitHub и т.д.) — чтобы demo и e2e-прогоны не ждали лишнего. RFC 8628 не запрещает короткий интервал, но в проде значение подбирают с учётом нагрузки на /token от множества одновременно поллящих устройств — при коротком интервале и большом парке устройств /token превращается в постоянный источник трафика.

Валидация токена: introspection против локальной JWKS-проверки

Это тот самый вопрос из первой статьи серии — opaque против самодостаточного токена — только теперь на стороне OAuth2/OIDC, а не собственной сессионной модели. Токен, который клиент получил от authorization server, нужно как-то проверить на стороне resource server. Два принципиально разных пути.

Introspection (RFC 7662) — resource server отдаёт токен обратно authorization server на /introspect и спрашивает «этот токен ещё активен?». mockidp реализует это так:

// introspect требует аутентификации клиента (RFC 7662 §2.1) — те же демо-креды,
// что и client_credentials, через HTTP Basic Auth или form-поля.
// Без валидных креденшелов — 401, ДО чтения token= вовсе.
clientID, clientSecret, ok := clientCredentialsFromRequest(r)
if !ok || clientID != demoClientCredentialsID || clientSecret != demoClientCredentialsSecret {
    http.Error(w, "invalid_client", http.StatusUnauthorized)
    return
}

if !ok || time.Now().After(profile.ExpiresAt) {
    writeJSON(w, http.StatusOK, map[string]any{"active": false})
    return
}

Два момента здесь заслуживают отдельного внимания. Во-первых, introspection endpoint сам защищён — это protected resource, а не открытый lookup: без клиентской аутентификации любой сторонний клиент мог бы интроспектировать чужие токены и узнавать sub/scope/exp. Во-вторых, для несуществующего и для просроченного токена mockidp отдаёт один и тот же ответ — {"active": false}, не раскрывая, какой именно из двух случаев произошёл. Это намеренно: если бы ответ различался («не найден» / «истёк»), атакующий, перебирающий токены, получил бы дополнительный бит информации о том, существовал ли когда-либо конкретный токен вообще.

Локальная валидация по JWKS — противоположный подход: токен самодостаточен (это JWT), и resource server проверяет его сам, без похода к authorization server вовсе. oidc.ValidateIDToken (security/auth/oidc) делает это для id_token:

func ValidateIDToken(jwksURL, issuer, audience, raw string) (map[string]any, error) {
    doc, err := fetchJWKS(jwksURL)
    // ...
    claims := jwt.MapClaims{}
    _, err = jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (interface{}, error) {
        // Явная проверка метода подписи ДО использования какого-либо ключа.
        if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, ErrUnexpectedAlg
        }
        kid, _ := t.Header["kid"].(string)
        return doc.findRSAPublicKey(kid)
    },
        jwt.WithValidMethods([]string{"RS256"}),
        jwt.WithIssuer(issuer),
        jwt.WithAudience(audience),
        jwt.WithExpirationRequired(),
    )
    // ...
}

JWKS (JSON Web Key Set, RFC 7517) — это просто набор публичных ключей, опубликованных authorization server’ом по стандартному адресу (jwks_uri из discovery, см. ниже): каждый ключ — kid (идентификатор), kty (тип, здесь "RSA"), n/e (модуль и экспонента RSA-ключа в base64url). Ничего специфичного для описания API или контрактов запросов — только материал для проверки подписей, публикуемый именно потому, что публичный ключ можно раздавать открыто: он ничего не компрометирует, в отличие от приватного. mockidp.jwks публикует ровно такой документ:

func (h *Handler) jwks(w http.ResponseWriter, r *http.Request) {
    pub := h.signingKey.PublicKey
    writeJSON(w, http.StatusOK, map[string]any{
        "keys": []map[string]any{{
            "kty": "RSA", "use": "sig", "alg": "RS256", "kid": h.kid,
            "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
            "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
        }},
    })
}

ValidateIDToken принимает только RS256 — любой другой алгоритм в заголовке токена, включая alg=none и alg=HS256, отклоняется ещё на этапе выбора ключа, до какой-либо проверки подписи (ErrUnexpectedAlg). Это тот же класс защиты от alg confusion, что разбирала третья статья, только зеркально: там jwtclaims.Parse отклоняет всё, кроме HMAC, здесь ValidateIDToken отклоняет всё, кроме RSA. Конкретная атака, которую это закрывает: если бы проверка метода шла после выбора ключа (или если бы вызывающий код просто доверял alg из заголовка), атакующий мог бы взять настоящий id_token, переподписать его тем же payload’ом алгоритмом HS256, используя публичный RSA-модуль как HMAC-секрет (публичный ключ по определению известен всем, кто ходил на jwks_uri) — и такой поддельный токен прошёл бы проверку, если бы код наивно смотрел на alg=HS256 в заголовке и брал первый попавшийся «секрет». Тест стенда (TestValidateIDTokenAcceptsValidRejectsForged) воспроизводит эту атаку буквально — берёт claims настоящего токена, переподписывает HS256 с произвольным атакующим секретом, и проверяет, что ValidateIDToken отказывает.

После выбора метода и ключа по kid (findRSAPublicKey, ErrKeyNotFound если такого kid нет в JWKS — например, ключ уже ротировали) идёт обычная проверка claims: iss должен совпасть с ожидаемым issuer, aud — содержать ожидаемую audience (в стенде это client_id, с которым клиент инициировал флоу), exp — обязателен и не истёк (jwt.WithExpirationRequired() — без этого флага JWT без поля exp вообще прошёл бы как «бессрочный», что почти всегда не то, что имелось в виду).

Здесь важна честная оговорка о доверии каналу: JWKS раздаётся по HTTP(S), и вся конструкция «публичный ключ можно публиковать открыто» держится на том, что клиент действительно получил ключи от настоящего IdP, а не от того, кто подменил ответ по пути — это зона ответственности TLS, а не самого OIDC-протокола. fetchJWKS в стенде тянет ключи заново при каждом вызове — для demo этого достаточно, но в проде JWKS кэшируют с уважением к Cache-Control и обновляют по незнакомому kid (о ротации ключей и её отсутствии в mockidp — ниже).

Что выбрать. Introspection даёт мгновенную свежесть — отозванный на IdP токен тут же вернёт active: false, но платит сетевым round-trip и нагрузкой на IdP на каждый запрос. Локальная JWKS-валидация не ходит в сеть вовсе, но токен остаётся «валидным» для проверяющего до своего exp, даже если IdP уже считает его отозванным — тот же компромисс stateless-токенов, который разобран в ст.3 про цену JWT. Этот стенд показывает механику обеих проверок на живом коде, но не измеряет их относительную цену — сравнивать искусственно замедленный httptest.Server из multi-service/bench с introspection было бы нечестно. Реальный замер обоих режимов на настоящем IdP — отдельный стенд security/keycloak: 40 000 запросов на /me, p50 локальной JWKS-валидации — 1.18 мс при 0 обращений к Keycloak, p50 introspection — 4.19 мс при ровно 1 обращении на запрос (разбор в статье о валидации токенов Keycloak). Порядок цифр — тот же, что и в бенче ст.3 (локальная проверка на порядок дешевле похода к IdP), только здесь это не эмуляция, а измерение на реальном провайдере.

OIDC discovery: .well-known/openid-configuration

Последний кусок механики — как клиент вообще узнаёт, где у провайдера /authorize, /token, jwks_uri и что он вообще умеет, не хардкодя URL-ы для каждого IdP отдельно. OIDC Discovery 1.0 стандартизирует один адрес — /.well-known/openid-configuration, документ по которому описывает всю топологию провайдера:

func (h *Handler) discovery(w http.ResponseWriter, r *http.Request) {
    base := baseURL(r)
    writeJSON(w, http.StatusOK, map[string]any{
        "issuer":                        base,
        "authorization_endpoint":        base + "/authorize",
        "token_endpoint":                base + "/token",
        "userinfo_endpoint":             base + "/userinfo",
        "jwks_uri":                      base + "/.well-known/jwks.json",
        "introspection_endpoint":        base + "/introspect",
        "device_authorization_endpoint": base + "/device_authorization",
        "grant_types_supported": []string{
            "authorization_code", "client_credentials",
            "urn:ietf:params:oauth:grant-type:device_code",
        },
        "code_challenge_methods_supported":      []string{"S256"},
        "id_token_signing_alg_values_supported": []string{"RS256"},
    })
}

Практический смысл: клиентская библиотека делает один GET на /.well-known/openid-configuration, получает jwks_uri/token_endpoint/authorization_endpoint и дальше работает с ними, не зная заранее ничего специфичного про конкретного провайдера. grant_types_supported и code_challenge_methods_supported в ответе mockidp буквально перечисляют то, что разобрано выше — этим документом провайдер сам заявляет, что implicit и ROPC он не поддерживает, а PKCE (S256) — обязателен. id_token_signing_alg_values_supported: ["RS256"] — важный, но обманчивый по надёжности пункт: он говорит клиенту, чего ожидать, но не заменяет собой проверку. ValidateIDToken не читает этот список из discovery и не доверяет ему вслепую — она жёстко проверяет фактический алгоритм в заголовке каждого конкретного токена (jwt.WithValidMethods([]string{"RS256"})), потому что discovery-документ описывает намерение провайдера, а не гарантию для каждого отдельного байта, который однажды придёт по сети. Один заявляет, другой проверяет — и заявление без проверки было бы ровно той же дырой, что и alg confusion, только на уровень выше.

Честная оговорка из README стенда: mockidp — учебный OIDC-провайдер, «не полноценный OIDC» — discovery упрощён (нет полного набора обязательных для спеки полей вроде response_modes_supported, subject_types_supported и т.д.), не проверяются все обязательные claims, а главное — нет ротации ключей: RSA-ключ подписи генерируется один раз при старте процесса (rsa.GenerateKey в New()) и живёт, пока жив процесс. При рестарте login-methods ключ меняется целиком, и все ранее выданные id_token вместе с их kid в JWKS мгновенно становятся невалидными — для учебного провайдера это ожидаемо (демо не обязано переживать рестарт с валидными старыми токенами), но в проде управляемая ротация ключей держит старый и новый kid активными параллельно на переходный период, чтобы токены, подписанные до ротации, не отваливались разом.

Куда дальше по серии

Эта статья — первое из трёх углублений поверх ядра серии, разбирающее OAuth2/OIDC не как «подключить провайдера», а как протокол:

  • Ст.1 — модели сессий: opaque vs JWT, система координат, на которую опирается весь разговор про introspection vs JWKS выше.
  • Ст.3 — сложный путь: HS256 vs RS256, цена stateless-валидации — эта статья продолжает именно ту врезку на живом RS256/JWKS-коде.
  • Ст.4 — вход и регистрация: тот же mockidp, но с фокусом на то, как OAuth-профиль превращается в сессию сайта.

Дальше по углублениям:

  • Ст.7 — MFA и TOTP: второй фактор поверх любого из разобранных здесь способов входа.
  • Ст.8 — Passkeys и WebAuthn: passwordless-альтернатива всей схеме authorization code + пароль.

Если нужен реальный, не эмулированный замер введения introspection против JWKS-валидации на настоящем IdP — это серия про Keycloak, в частности интеграция бэкенда с валидацией токенов.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии