golang

Разработчики всё ещё путают JWT, JWKS, OAuth2 и OpenID Connect — разбираем на примерах. Часть 2

  • пятница, 23 января 2026 г. в 00:00:12
https://habr.com/ru/companies/ozontech/articles/987012/

Мы продолжаем наше погружение в мир аутентификации и будем разбирать всё на простых примерах с практикой на Go. В первой части статьи мы поговорили о том, как устроен JWT, зачем нам refresh- и access-токены и почему в распределённых системах нам необходимо использовать асимметричные алгоритмы подписи. 

Схема проверки подписи токена в распределённой системе
Схема проверки подписи токена в распределённой системе

Теперь пришло время двигаться дальше и познакомиться с тем, что стоит поверх JWT: JWKS, OAuth 2.0, OIDC.

JWKS

Вспомним наш пример с отелем: у нас есть консьерж (AuthService), который выдаёт всем постояльцам отеля браслеты (подписанный JWT-токен). Консьерж использует приватный ключ для подписи браслетов, который он держит от всех в секрете. Все остальные работники отеля (бармены, охрана и сотрудники бассейна) проверяют браслеты гостей, используя публичный ключ, который им дал консьерж для проверки. 

Сейчас консьерж вручную печатает публичный ключ на бумажке и вручает его каждому работнику отеля. Наш отель продолжает расти, в нём появляется всё больше удобств: спортивный зал, спа, рестораны и прочее. Поэтому нам пришлось нанять больше сотрудников в отель, и их стало слишком много, чтобы каждому раздать по ключу. В одиночку консьерж уже не справляется — ему нужны помощники, которые могли бы работать посменно и тоже выдавать подписанный браслет, но уже своим секретным ключом.

И тут встаёт вопрос: а как нам быстро раздать всем публичные ключи? И как нам их периодически ещё и менять?  

Решение: электронная витрина с ключами (JWKS)

Представим, что у нас теперь есть отдельный электронный терминал, где лежат все актуальные публичные ключи (важно: мы ему доверяем). В этом терминале сотрудники могут:

  • отсканировать QR-код браслета клиента;

  • получить нужный публичный ключ из общей базы ключей отеля;

  • проверить подпись с помощью данного ключа.

Этот стенд в реальном мире называется JWKS endpoint. Он:

  • хранит набор публичных ключей;

  • позволяет всем микросервисам самостоятельно загружать ключи;

  • обновляется автоматически, если консьерж (AuthService) меняет ключ.

JWK и JWKS. Представление и обмен криптографическими ключами

Немного определений терминов:

JWK (JSON Web Key) — это способ представления криптографического ключа в формате JSON. Он используется в веб-приложениях для представления открытых ключей в системах, обеспечивая безопасность данных. Формат JWK применяется для таких задач как подпись, проверка, шифрование и дешифрование данных

JWKS (JSON Web Key Set) — это массив, содержащий несколько ключей JWK, который позволяет выполнять ротацию ключей или поддерживать разные алгоритмы подписи.

Обычно JWK состоит из следующих полей: 

  • kid (идентификатор ключа) — идентификатор ключа, позволяющий ссылаться на него;

  • use (назначение ключа) — указывает, как предполагается использовать ключ, например, для подписи (sig) или шифрования (enc);

  • kty (тип ключа) — тип алгоритма или ключа (например, RSA, EC, ...);

  • alg (алгоритм) — алгоритм, для которого предназначен данный ключ, например, RS256 для RSA с SHA-256;

  • специфичные служебные поля для алгоритмов.

Подробнее о структуре полей в RFC 7517

Пример: наш RSA-открытый ключ в формате PEM выглядит так:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CVTPVrufUOfjPvdfzRe
JY9lEknYc0rARYIO2kCDrFvTrQHLwmh11nVmHodxDWJqkzkqRWWoyp5Uy7EG9e/x
y5P4cYtvr+myg1V3RUrYnwvcso0q1LjQSeFVnDH0t1uoCf38aP/jE9xPwNpliqEx
G8gbdoX5xQbk6hox9QOWaNYF0iMJt+As/3BhmgDD0grIzPy/md14KFjxEW8pj5/A
NoGEhsKozHni+yJkxWwgWXb0DLt8XjinpKDbI/e5pcGr6QqCvsH3bstNz8Ke7sft
6tHeKVR2PfcBHYn2fcSeCwN6aOUFhJ30A6T4RIUwbOgX+JGR85d8YUt+28p5leo2
1wIDAQAB
-----END PUBLIC KEY-----

В формате JWK этот же ключ будет выглядеть так: 

{
    "alg": "RSA256",
    "e": "AQAB",
    "kid": "1",
    "kty": "RSA",
    "n": "0CVTPVrufUOfjPvdfzReJY9lEknYc0rARYIO2kCDrFvTrQHLwmh11nVmHodxDWJqkzkqRWWoyp5Uy7EG9e_xy5P4cYtvr-myg1V3RUrYnwvcso0q1LjQSeFVnDH0t1uoCf38aP_jE9xPwNpliqExG8gbdoX5xQbk6hox9QOWaNYF0iMJt-As_3BhmgDD0grIzPy_md14KFjxEW8pj5_ANoGEhsKozHni-yJkxWwgWXb0DLt8XjinpKDbI_e5pcGr6QqCvsH3bstNz8Ke7sft6tHeKVR2PfcBHYn2fcSeCwN6aOUFhJ30A6T4RIUwbOgX-JGR85d8YUt-28p5leo21w",
    "use": "sig"
}

Наборы из таких ключей отдаются сервером аутентификации в endpoint (обычно GET /.well-known/jwks.json). 

Пример данных, возвращаемых с JWKS endpoint

Скрытый текст
{
    "keys": [
        {
            "alg": "RSA256",
            "e": "AQAB",
            "kid": "2",
            "kty": "RSA",
            "n": "vR14JnoiMvqnKuNPLx62vXBPT6OKTK61E9jm-4asIZKbEYwuAKEVCK1r_IYyK0Ok-VuXUwUr5PXbiMZ_S-MN576deJVrIx434NpjacHbL1DXcCpzE600w99hwXk1HlajKZd19XTL9osSOhvzJlyUeeClL0OjXDPT8VfZQIl_w-chvBaQL3gNR3TEzevfXPJ2yHStf-P8w4FRlXv-RQFh1X05don8qqLeWC2iqBhgv1GY_nZttrxL-u6FwLhoP3R8BM2vKY2T1lCtM88sP85q50JdQmHxX8cEZPnuKUuxLVNy3ec9FM-Lv2fzsmEti61aGlkLDKNiXl12EgvNXLz5Iw",
            "use": "sig"
        },
        {
            "alg": "RSA256",
            "e": "AQAB",
            "kid": "1",
            "kty": "RSA",
            "n": "0CVTPVrufUOfjPvdfzReJY9lEknYc0rARYIO2kCDrFvTrQHLwmh11nVmHodxDWJqkzkqRWWoyp5Uy7EG9e_xy5P4cYtvr-myg1V3RUrYnwvcso0q1LjQSeFVnDH0t1uoCf38aP_jE9xPwNpliqExG8gbdoX5xQbk6hox9QOWaNYF0iMJt-As_3BhmgDD0grIzPy_md14KFjxEW8pj5_ANoGEhsKozHni-yJkxWwgWXb0DLt8XjinpKDbI_e5pcGr6QqCvsH3bstNz8Ke7sft6tHeKVR2PfcBHYn2fcSeCwN6aOUFhJ30A6T4RIUwbOgX-JGR85d8YUt-28p5leo21w",
            "use": "sig"
        }
    ]
}

Когда сервис авторизации подписывает токен, он указывает идентификатор ключа kid в заголовке JWT-токена. Сервис, который проверяет JWT, извлекает kid, запрашивает соответствующий публичный ключ из JWKS endpoint (или берёт его из локального кеша) и использует его для проверки подписи. Благодаря этому механизму ключи могут ротироваться прозрачно для всех сервисов: новые токены подписываются новым ключом, старые продолжают валидироваться до истечения срока жизни, а сервисам не требуются ни перезапуск, ни обновление конфигурации.

Схема использования JWKS
Схема использования JWKS

Попробуем доработать сервисы AuthService и GreetingService, чтобы они стали поддерживать JWKS.

Для работы с JWKS можно всё так же использовать библиотеку github.com/golang-jwt/jwt/v5, реализовав собственный keyfunc, который будет предоставлять приватный и публичный ключи для подписи и проверки токенов. Кроме того, уже существует готовая реализация keyfunc для работы с JWKS в связке с github.com/golang-jwt/jwt/v5 — github.com/MicahParks/keyfunc. Эта библиотека хорошо подходит в том случае, если вы хотите только валидировать токены, используя JWKS. Однако в нашем примере придётся поработать с JWKS более основательно: нам нужна не только валидация токена, но ещё и подпись, загрузка и анонсирование ключей. Для этого придётся установить более функциональную библиотеку github.com/lestrrat-go/jwx/v3 для работы с JWx технологиями в Go.

go get github.com/lestrrat-go/jwx/v3

В сервисе AuthService cоздадим набор ключей (set):

var jwks = jwk.NewSet()

и добавим в него 2 разных RSA ключа:

Скрытый текст
type Key struct {
	id    string
	path  string
	usage string
	alg   string
}

var keys = []Key{
	{
		id:    "k1",            // идентификатор ключа
		path:  "private_1.pem", //
		usage: "sig",           // используем ключ для подписи
		alg:   "RS256",
	},
	{
		id:    "k2",            // идентификатор ключа
		path:  "private_2.pem", //
		usage: "sig",           // используем ключ для подписи
		alg:   "RS256",
	},
}

func loadKeys(keys []Key) error {
	for _, k := range keys {
		key, err := loadPrivateKey(k.path)
		if err != nil {
			return err
		}

		jwkKey, err := jwk.Import(key)
		if err != nil {
			return err
		}

		// Указываем дополнительные поля
		_ = jwkKey.Set(jwk.KeyIDKey, k.id)       // идентификатор
		_ = jwkKey.Set(jwk.KeyUsageKey, k.usage) // назначение
		_ = jwkKey.Set(jwk.AlgorithmKey, k.alg)  // алгоритм
		jwks.AddKey(jwkKey) // добавляем в набор ключей
	}
	return nil
}

func init() {
	if err := loadKeys(keys); err != nil {
		log.Fatal(err)
	}
}

Теперь добавим в AuthService endpoint отдающий наши ключи:

mux.HandleFunc("GET /.well-known/jwks.json", jwksEndpoint) 

Его реализация будет довольно простая:

func jwksEndpoint(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(jwks)
}

Попробуем вызвать этот метод и посмотреть, что получим: 

curl --location 'http://localhost:8080/.well-known/jwks.json' 

Получили набор из двух ключей. Это значит, что наш сервис может подписывать токен либо ключом k1, либо ключом k2:

Скрытый текст
{
    "keys": [
        {
            "alg": "RS256",
            "d": "CFnOAedIQkngKH8887gp4dqbtLWVYBwirl6u8TK16DRjJMFfZVZshAznVf6liOGmVPych591elnpwgQ1EjXpZeZZDNYQaQEi0YD233RMu_0z4ulNVv55jiaQScojeVYrGFAHuTPK1N07z_dcNjW6qK4TcjsrP2ECaaBzf8-Mbjy-PR4crpLtlbzyQ-bBk6rwvN3DUdxjq5dmgrrRvUmF8A2WDyQ0nX-dU2NOpWpu8PP3fzyS8vTHNH_4gFJQv0K34kKLgzwqVZsOMGOUpxyY5b4rqchWA_nzEfcbSPcdZ_9WBWoPTBQDElebvhkzOUJVvCfxoZ_E7VJE2EIPiZqvxQ",
            "dp": "CCsYJqFKF6t41FCJmOFoVmaJn-GrsWmngWQRa-QSsyhuVuIXEGS80SjKS82Qag2blbc7ZrRaXgKroPzI4aNbTOkt82iP_evhM1WeidmdGDl2HZi5cKh3aAyIH__d0PO7AO-5ZWfELCPRcqNONn22TQ0-3JkjhD9gUUOMrmMi3wc",
            "dq": "np6t8UukX06je3pBZcIWbozvgx-ABEsCIQk6kxgtQmyl3L8GtmmgGbIKfk1cABzRvmKjjlh_O8B8V7TlMSJY6ymOGFdicFo-04XbjGrL_g5AUFZfKNQ_HmsGi5HbFGQ4D1Xmuu2tAvsFRIy4To01V2rr_Zk5l2j9Ccwu8XudJGE",
            "e": "AQAB",
            "kid": "k1",
            "kty": "RSA",
            "n": "l2Snq_Y9J5h_owvtzBmbcUUDtHWzx7zH4PAEhhna9Qta8BpaseCMG5O_538U23jTyPjQNzxXqCUdhnop8-OlV5TaDj37QpsTGEZGtD0yy6mYeZrANzkv_Jv3z3NOo7OMmpmsYfmhAuh3VtUrPPfq7QClcrHaXVqPF-0Z3cyayeo0tW798nJ9zHjh2kBFWkxnobj-_JODYgrpxjHzjZ2dYht_DdyGkDC5G8f9sXwD5NnD1N-5ZO6asw0gM9injjgwlFKl7gDZtSWw70IvqIzcR4HuYyTd9sOzN2msTilFCDah3MkQAYClA-kszx2jLNl6LkdyqM9dzABxxSX1sinaLQ",
            "p": "0oKzKYUOSnNqliI36cCzksMZG9BxDyKfKVTyAMEkQ4_N_9eGpHjsj4OE12qhO39PKHNG9wQIsa8GtdlUgOEwcs92v1JjG0SrDq3xP70MxMMhOfgNBlUsC0TGK0leaKZMxsr7LlLoiOiAs0QJcT6YSaTJHrQ1SuL2s8fSSkZkr1s",
            "q": "uBufcl6MM0z6wBSWc6W9oIzeDPsCRBgc7R464yQODHMWv4hSXA6cRSRcyFLRIGTE21BQBJ_xlVy-v-DwJAPRWCZFgO5-ZyOv1MlMnUyka3sYldRBSLPLa1XW5hFwr4xHnWgNbIrhiRFf-ImGZgTOL0xm5yRNsfTr2JQ-Fc92mxc",
            "qi": "Ao31cUIdpQHdSpqCgn-ekvvfPnAprnvG0geBhfG-n67N_7hFXpPTeo8eAcenZaYeLolpqlt6TAFGcWXw1dboRpHmntnX7Oldh0LePj6o3bZF17AvJ9GB1xCIVN1PuKnanvygZGSletKNinuEL6U1lMYicJGHsqtrJLtxowvSvcQ",
            "use": "sig"
        },
        {
            "alg": "RS256",
            "d": "CFnOAedIQkngKH8887gp4dqbtLWVYBwirl6u8TK16DRjJMFfZVZshAznVf6liOGmVPych591elnpwgQ1EjXpZeZZDNYQaQEi0YD233RMu_0z4ulNVv55jiaQScojeVYrGFAHuTPK1N07z_dcNjW6qK4TcjsrP2ECaaBzf8-Mbjy-PR4crpLtlbzyQ-bBk6rwvN3DUdxjq5dmgrrRvUmF8A2WDyQ0nX-dU2NOpWpu8PP3fzyS8vTHNH_4gFJQv0K34kKLgzwqVZsOMGOUpxyY5b4rqchWA_nzEfcbSPcdZ_9WBWoPTBQDElebvhkzOUJVvCfxoZ_E7VJE2EIPiZqvxQ",
            "dp": "CCsYJqFKF6t41FCJmOFoVmaJn-GrsWmngWQRa-QSsyhuVuIXEGS80SjKS82Qag2blbc7ZrRaXgKroPzI4aNbTOkt82iP_evhM1WeidmdGDl2HZi5cKh3aAyIH__d0PO7AO-5ZWfELCPRcqNONn22TQ0-3JkjhD9gUUOMrmMi3wc",
            "dq": "np6t8UukX06je3pBZcIWbozvgx-ABEsCIQk6kxgtQmyl3L8GtmmgGbIKfk1cABzRvmKjjlh_O8B8V7TlMSJY6ymOGFdicFo-04XbjGrL_g5AUFZfKNQ_HmsGi5HbFGQ4D1Xmuu2tAvsFRIy4To01V2rr_Zk5l2j9Ccwu8XudJGE",
            "e": "AQAB",
            "kid": "k2",
            "kty": "RSA",
            "n": "l2Snq_Y9J5h_owvtzBmbcUUDtHWzx7zH4PAEhhna9Qta8BpaseCMG5O_538U23jTyPjQNzxXqCUdhnop8-OlV5TaDj37QpsTGEZGtD0yy6mYeZrANzkv_Jv3z3NOo7OMmpmsYfmhAuh3VtUrPPfq7QClcrHaXVqPF-0Z3cyayeo0tW798nJ9zHjh2kBFWkxnobj-_JODYgrpxjHzjZ2dYht_DdyGkDC5G8f9sXwD5NnD1N-5ZO6asw0gM9injjgwlFKl7gDZtSWw70IvqIzcR4HuYyTd9sOzN2msTilFCDah3MkQAYClA-kszx2jLNl6LkdyqM9dzABxxSX1sinaLQ",
            "p": "0oKzKYUOSnNqliI36cCzksMZG9BxDyKfKVTyAMEkQ4_N_9eGpHjsj4OE12qhO39PKHNG9wQIsa8GtdlUgOEwcs92v1JjG0SrDq3xP70MxMMhOfgNBlUsC0TGK0leaKZMxsr7LlLoiOiAs0QJcT6YSaTJHrQ1SuL2s8fSSkZkr1s",
            "q": "uBufcl6MM0z6wBSWc6W9oIzeDPsCRBgc7R464yQODHMWv4hSXA6cRSRcyFLRIGTE21BQBJ_xlVy-v-DwJAPRWCZFgO5-ZyOv1MlMnUyka3sYldRBSLPLa1XW5hFwr4xHnWgNbIrhiRFf-ImGZgTOL0xm5yRNsfTr2JQ-Fc92mxc",
            "qi": "Ao31cUIdpQHdSpqCgn-ekvvfPnAprnvG0geBhfG-n67N_7hFXpPTeo8eAcenZaYeLolpqlt6TAFGcWXw1dboRpHmntnX7Oldh0LePj6o3bZF17AvJ9GB1xCIVN1PuKnanvygZGSletKNinuEL6U1lMYicJGHsqtrJLtxowvSvcQ",
            "use": "sig"
        }
    ]
}

Теперь необходимо реализовать динамическое получение ключа для подписи по его id. Для этого напишем вспомогательную функцию getPrivateKey, которая позволит нам получать ключи по его id из набора JWKS:

// getPrivateKey - отдаёт приватный ключ по kid
func getPrivateKey(kid string) (crypto.PrivateKey, error) {
	// Ищем ключ с нужным нам идентификатором
	k, ok := jwks.LookupKeyID(kid)
	if !ok {
		return nil, fmt.Errorf("kid %s: not found", kid)
	}

	// Получаем тип ключа
	alg, ok := k.Algorithm()
	if !ok {
		return nil, fmt.Errorf("kid %s: unknown alg", kid)
	}

	sa, ok := jwa.LookupSignatureAlgorithm(alg.String())
	if !ok {
		return nil, fmt.Errorf("kid %s: unknown alg", kid)
	}

	// Отдаём ключ для подписи
	switch sa {
	case jwa.RS256():
		var rawKey rsa.PrivateKey
		if err := jwk.Export(k, &rawKey); err != nil {
			return nil, fmt.Errorf("kid %s: не удалось достать RS256-ключ: %s", kid, err)
		}
		return &rawKey, nil
	default:
		return nil, fmt.Errorf("kid %s: unsupported alg", kid)
	}
}

И сл��гка перепишем создание access- и refresh-токенов.

  1. Достанем необходимый ключ для подписи:

const kid = "k1" // например, у нас в конфиге сервиса

privateKey, err := getPrivateKey(kid) 

2. Добавим в заголовок JWT-токена id ключа (kid), которым мы будем подписывать токен:

// NOTE: мы можем реализовать поддержку не только ключей RSA 256
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) 
token.Header["kid"] = kid 

3. И подпишем токен:

return token.SignedString(privateKey)

Теперь нам осталось доработать нашу функцию предоставления публичного ключа для верификации refresh-токена.

В нашем примере мы используем RS256 для подписи refresh-токена. Однако мы могли оставить HS256, так как только AuthService подписывает и проверяет refresh-токен.

В keyFunc нам необходимо найти id ключа в заголовке и достать его из нашего набора ключей:

// функция-провайдер ключа для верификации подписи
func keyFunc() jwt.Keyfunc {
	return func(token *jwt.Token) (interface{}, error) {
		kid, ok := token.Header["kid"].(string)
		if !ok {
			return nil, errors.New("JWT missing 'kid' header")
		}

		return getPublicKey(kid)
	}
}

В getPublicKey мы достаём из JWKS ключ по kid:

Скрытый текст
// getPublicKey — отдаёт публичный ключ по kid
func getPublicKey(kid string) (crypto.PublicKey, error) {
	k, ok := jwks.LookupKeyID(kid)
	if !ok {
		return nil, errors.New("pk not found")
	}

	pk, err := k.PublicKey()
	if err != nil {
		return nil, err
	}

	alg, ok := pk.Algorithm()
	if !ok {
		return nil, fmt.Errorf("kid %s: unknown alg", kid)
	}

	sa, ok := jwa.LookupSignatureAlgorithm(alg.String())
	if !ok {
		return nil, fmt.Errorf("kid %s: unknown alg", kid)
	}

	switch sa {
	case jwa.RS256():
		var rawKey rsa.PublicKey
		if err := jwk.Export(k, &rawKey); err != nil {
			return nil, fmt.Errorf("kid %s: не удалось достать RS256-ключ: %s", kid, err)
		}
		return &rawKey, nil
	default:
		return nil, fmt.Errorf("kid %s: unsupported alg", kid)
	}
}

Готово! AuthService теперь может подписывать токены разными ключами.  

Нам осталось доработать наш GreetingService: в нём необходимо получать публичные ключи из AuthService и таким же образом проверять токены. В библиотеке github.com/lestrrat-go/jwx/v3/jwk уже есть готовый пример того, как реализовать постоянное обновление JWKS:

Скрытый текст
package main

import (
	"context"
	"fmt"
	"log"
	"sync"
	"time"

	"github.com/lestrrat-go/httprc/v3"
	"github.com/lestrrat-go/jwx/v3/jwk"
)

const certs = `http://localhost:8080/.well-known/jwks.json`

var jwks jwk.Set

// Не забудем его запустить в main
func RunJWKSRefresher(ctx context.Context) error {
	cache, err := jwk.NewCache(ctx, httprc.NewClient())
	if err != nil {
		return fmt.Errorf("failed to create cache: %w", err)
	}

	if err := cache.Register(ctx, certs); err != nil {
		return fmt.Errorf("failed to register JWKS: %w", err)
	}

	init := make(chan struct{})
	once := sync.Once{}
	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			default:
			}

			keyset, err := cache.Lookup(ctx, certs)
			if err != nil {
				fmt.Printf("failed to fetch JWKS: %s\n", err)
                return err
			}

			jwks = keyset
			once.Do(func() { close(init) })
			time.Sleep(time.Minute)
		}
	}()

	<-init
	return nil
}

В GreetingService полностью перейдём на библиотеку github.com/lestrrat-go/jwx/v3 для удобства верификации токенов c помощью JWKS. Перепишем verifyAccessToken:

Скрытый текст
package main

import (
	"errors"
	"fmt"

	"github.com/lestrrat-go/jwx/v3/jwt"
)

const issuer = "best.hotel.com"

var ErrInvalidToken = errors.New("invalid token")

func verifyAccessToken(accessToken string) (user, error) {
	token, err := jwt.Parse([]byte(accessToken),
		jwt.WithKeySet(jwks),
		jwt.WithIssuer(issuer),
		jwt.WithRequiredClaim("user_email"),
		jwt.WithRequiredClaim("user_name"),
	)
	if err != nil {
		return user{}, fmt.Errorf("failed to verify JWS: %s", err)
	}

	if err := jwt.Validate(token); err != nil {
		return user{}, ErrInvalidToken
	}

	var userEmail string
	token.Get("user_email", &userEmail)

	var userName string
	token.Get("user_name", &userName)

	return user{
		Name:  userName,
		Email: userEmail,
	}, nil
}

Ключевое здесь — вот эта строка: 

token, err := jwt.Parse([]byte(accessToken), jwt.WithKeySet(jwks),

jwt.WithKeySet(jwks) по сути всё та же keyFunc, которая по заголовку токена подбирает нужный нам ключ из набора ключей JWKS.

Проверим, как всё работает:  

  • получаем access-токен в AuthService:

curl --location --request POST 'http://localhost:8080/login' --header 'Authorization: Basic Ym9iQGdvb2dsZS5jb206Ym9icGFzc3dvcmQ='
  • убеждаемся в появлении заголовка kid:

JWT токен с kid в заголовке
JWT токен с kid в заголовке
  • делаем запрос в GreetingService с этим токеном:

curl --location 'http://localhost:8081/hello' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIiwidHlwIjoiSldUIn0.eyJleHAiOjE3NTM1NTQ2NzEsImlhdCI6MTc1MzU1Mzc3MSwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfZW1haWwiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfbmFtZSI6ImJvYiJ9.dyB4OMsKNO2uuwV9byOygvYL7Pk4ob8Zyj-3eid9gI5RqrWrARdIi91gQRBEAzqOEbwkwaGaapPb-6y46wLXRQ-TGZ0fBkkiEYf_OrUetJUTkwIJ9vhsyWb1SF5QRhHkoN2gGlB22fKtfNaq8iCR7Qh8uMzzu7SbIQ_0P6iJzB3A_ve-y5EnGV0uUahKUioSsgywhHRcb8rSlhw4MgZ15jdZl2o_PPM0h9cw6JpZzUG9IEyXeRigUti-KlV7Fkz8Zm7uw7btRUfoA3H0YWkCKdJCAQcVztJx1Cb5mwxNHFf5pszCP5C2wLfueL_TehWDB09VrX7xEHKRxcaYiXI4cA'
  • получаем успешный ответ:

{
    "message": "Hello bob!"
}
  • всё то же самое можно повторить, используя для подписи уже ключ k2 в AuthService — результат будет тем же.

JWKS — это удобный механизм автоматической дистрибуции публичных ключей. Он позволяет разным сервисам проверять подписи токенов без необходимости каждый раз обращаться к Auth-серверу или настраивать ключи вручную. При этом важно учитывать задержку в обновлении ключей на стороне клиентов: после добавления нового публичного ключа в JWKS следует подождать, пока все пользователи его загрузят. Только затем можно начать использовать соответствующий приватный ключ для подписи новых токенов.

SSO 

Представьте, что наш отель стал частью целой сети, охватывающей разные города одной страны. Теперь в каждом городе есть наш филиал, и для гостей действует особое предложение: достаточно один раз пройти регистрацию в любом из отелей и получить универсальный браслет — с ним можно останавливаться в любом другом отеле сети без повторной регистрации и проверки документов на время действия брони.

Это и есть принцип SSO (Single Sign-On) — единая точка входа, после которой доступ открывается сразу ко всем доверенным сервисам (в нашем случае — к отелям сети). В контексте IT SSO означает, что пользователь проходит аутентификацию в одной точке (в нашем примере сервис —AuthService), а затем может использовать доступ ко множеству других сервисов без повторного входа.

Принцип SSO
Принцип SSO

OpenID Authentication 

Представьте себе огромный отель в центре города, куда каждый день стекаются сотни гостей. У входа огромная очередь на ресепшн, гости ворчат, а администрация едва справляется: ведь у каждого пользователя нужно проверить паспорт, записать его данные, убедиться, что это действительно тот самый Иван Иванов, а не самозванец с чужой бронью. Очереди тянутся бесконечно, нервы на пределе, ведь каждый хочет попасть в свой номер побыстрее! 

Чтобы навести порядок и избавиться от этого бюрократического ада, отель идёт на хитрый шаг. 

Он заключает соглашения с крупнейшими и самыми надёжными банками. Эти банки и так отлично умеют проверять людей: у них серьёзная служба безопасности, свои базы и фото каждого клиента. Теперь каждый гость, который уже прошёл проверку в банке, получает особую «карту доверия» с фото и чипом. Всё гениальное просто: подносишь карту к сканеру на стойке — пик! — и тебя сразу пропускают без лишних вопросов. Отелю не надо разбираться в документах и сверять фотографии — всё это давно сделал банк, которому отель полностью доверяет.

Как это работает в интернете? Да почти так же.

Ваш сайт не хочет хранить пароли, пересматривать паспорта и бояться, что их украдут. Ведь это огромная ответственность и куча мороки. Зачем заниматься этой рутиной, если можно переложить всю головную боль на большие компании вроде Google? 

Когда на ваш сайт заходит новый пользователь, вы просто говорите: «Не хотите долго возиться с регистрацией? Войдите через Google!» 

Человек кликает, и происходит магия: его на пару секунд перекидывает на сайт Google (или другого популярного сервиса), где он вводит свой логин и пароль, но уже на стороне Google, а не на вашем сайте. 

Через пару секунд Google возвращает вашего гостя обратно на сайт и шлёт подтверждение: «Всё в порядке! Это действительно Иван Иванов, email такой-то, фото прилагается!» 

Вуаля! Пользователь заходит к вам без пароля, без ожидания, без лишней проверки, а вы уверены, что он именно тот, за кого себя выдаёт.

Главная мысль: вы не собираете пароли, не занимаетесь муторной проверкой документов, а просто доверяете этот процесс сторонней компании, которая знает своих клиентов вдоль и поперёк (важный нюанс: она должна быть популярной). Ваша задача — лишь принять гостя и начать работать. Имя, email и фото вы получаете, а всю скучную бюрократию оставляете профессионалам.

Это и есть OpenID Authentication: аутентификация пользователя с помощью стороннего, проверенного сервиса. Протокол OpenID Authentication (1.0 и 2.0) был первой массовой попыткой «единого логина для интернета». Сейчас он считается устаревшим и более не используется (не путать с OpenID Connect).

OAuth2.0 

В нашем отеле запускают доставку еды из города прямо в номер. И тут сразу возникает важный вопрос: как пустить курьера в отель в номер гостя, не раскрывая, кто именно там живёт, и не создавая угрозу безопасности?

Курьеру совершенно не нужно знать имя постояльца, видеть его документы или иметь доступ к его данным. Его задача предельно проста — один раз зайти в конкретный номер и оставить заказ. Никакой информации о личности гостя для этого не требуется.

Гость, лежащий на диване в своём номере, решает заказать пиццу. В приложении доставки он выбирает номер, подтверждает заказ и тем самым принимает решение: разрешить этому сервису принести еду в его комнату. В этот момент происходит ключевая вещь — гость делегирует право действия, но не передаёт свою личность и уж точно не отдаёт собственный ключ от номера.

Дальше сервис доставки обращается к ресепшену. Он говорит «Мне разрешили доставить заказ в этот номер». Ресепшен проверяет разрешение и выдаёт курьеру специальный пропуск. Это не универсальный ключ от отеля и не копия ключа гостя. Это строго ограниченный доступ, созданный под конкретную задачу.

С таким пропуском курьер может открыть ровно одну дверь в течение ограниченного времени. Он не сможет зайти в другой номер, вернуться позже или использовать этот доступ для чего-то ещё. Более того, он так и не узнает, кто живёт в комнате. Для него это не имеет значения — важно лишь то, что у него есть право выполнить доставку.

Именно в этом и заключается суть OAuth 2.0. Он нужен не для того, чтобы доказать, кто перед нами, а для того, чтобы определить, что именно разрешено сделать и на каких условиях. OAuth позволяет владельцу ресурса осознанно и безопасно передать ограниченные права стороннему сервису, не раскрывая свои данные и не делясь паролями.

Разберём кратко устройство OAuth. В рамках протокола выделяют 4 роли: 

  1. Владелец ресурса — пользователь, который авторизует приложение для доступа к своему аккаунту. Доступ приложения к пользовательскому аккаунту ограничен «областью видимости» (scope) предоставленных прав авторизации (например, доступ на чтение или запись).
    В нашем примере владелец ресурса — это постоялец отеля, а scope — право зайти в номер постояльца.

  2. Ресурсный сервер — это система, которая обеспечивает доступ к данным пользователя. В нашем случае — отель, он сообщает в каком мы номере.

  3. Сервер авторизации — это сервер, который управляет процессом авторизации и выдачей разрешений. Он проверяет логины и пароли пользователей, а также выдаёт авторизационные токены, которые приложения используют для доступа к данным пользователя. Допустим, это ресепшн. 

  4. Клиент (приложение) — это приложение или программа, которая запрашивает доступ к данным пользователя. В нашем примере — это курьер, который хочет зайти получить доступ к нашему номеру. 

Вот как связаны эти 4 роли:

Роли OAuth 2.0
Роли OAuth 2.0

В OAuth есть несколько сценариев получения access-токена (flow):  

  1. Authorization Code Flow — подходит для веб-приложений с бэкендом. В этом случае токены идут через сервер и не светятся в браузере — самый безопасный и распространённый способ.

    Leonid Moguchev > Разработчики всё ещё путают JWT, JWKS, OAuth2 и OpenID Connect — разбираем на примерах. Часть 2 > Схема_3.jpg
    OAuth 2.0 Authorization Code Flow
  2. Client Credentials Flow — подходит для внутрисервисного общения, когда нет пользователя, и доступ нужен самому сервису (backend ↔ backend).

    Leonid Moguchev > Разработчики всё ещё путают JWT, JWKS, OAuth2 и OpenID Connect — разбираем на примерах. Часть 2 > Схема_3.jpg
    OAuth 2.0 Client Credentials Flow
  3. Implicit Flow — использовался в одностраничных приложений без бэкенда (SPA). Похож на Authorization Code Flow, но без промежуточного шага обмена кода на токен.

    Leonid Moguchev > Разработчики всё ещё путают JWT, JWKS, OAuth2 и OpenID Connect — разбираем на примерах. Часть 2 > Схема_3.jpg
    OAuth 2.0 Implicit Flow

    Один из главных минусов данного способа: access token напрямую попадает в браузер (URL и JS-контекст), где он неизбежно утекает через XSS, логи, расширения и историю, поэтому его официально признали небезопасным и убрали из OAuth 2.1.

Примечание: В чём кратко разница между OAuth 2.1 и OAuth 2.0

OAuth 2.1 — это доработанная версия OAuth 2.0, объединяющая лучшие практики и рекомендации по безопасности, накопленные за время существования OAuth 2.0.

Основные отличия:

  • исключены небезопасные или устаревшие подходы (Implicit Flow, Password Grant);

  • введение обязательного использования PKCE (Proof Key for Code Exchange); 

  • более строгие и однозначные рекомендации для реализации потоков авторизации.

OAuth 2.0 изначально создавался как протокол делегирования доступа. Он отвечал на простой вопрос: можно ли позволить стороннему сервису выполнить ограниченное действие от имени пользователя, не передавая ему пароль. И с этой задачей OAuth справился отлично. Но очень быстро выяснилось, что реальный мир хочет большего. Разработчикам было удобно использовать один и тот же механизм редиректов, согласий и токенов не только для доступа к ресурсам, но и для входа пользователей в приложения. Так OAuth начали использовать не по назначению, но очень массово.

Сценарий выглядел примерно так:

  • пользователь нажимает кнопку «Войти через Google»;

  • приложение отправляет его на сервер Google по OAuth-флоу;

  • пользователь успешно логинится;

  • приложение получает access token и делает запрос к API Google, например, к /userinfo;

  • если Google возвращает профиль пользователя, приложение делает вывод: раз Google выдал токен и разрешил доступ к данным, значит, пользователь аутентифицирован.

Формально OAuth здесь не нарушен. Всё работает. Но логически происходит подмена понятий (и именно из-за этого создаётся путаница у ��азработчиков). Access token говорит лишь о том, что разрешён доступ к ресурсу, а не о том, что «этот конкретный пользователь сейчас вошёл в систему». Тем не менее, на практике этого оказалось достаточно, и такой подход стал де-факто стандартом.

OAuth2.0 на практике

Доработаем наш пример и реализуем OAuth с Authorization Code Flow. Чтобы его продемонстрировать, нам нужен третий участник — приложение-клиент. В нашем примере это будет Swagger UI, который:

  • открывает страницу логина пользователя (через Auth Service);

  • получает authorization code;

  • обменивает code на access token;

  • и дальше вызывает методы GreetingService уже с Bearer access_token.

Схема OAuth-авторизации в нашем примере
Схема OAuth-авторизации в нашем примере

Описание процесса.

  • Пользователь заходит в приложение (Swagger UI) и нажимает «Войти через OAuth». Приложение перенаправляет пользователя на сервер авторизации (AuthServer) на Authorization Endpoint с такими параметрами:

GET http://localhost:8080/oauth/authorize?
  response_type=code
 &client_id=greeting_service
 &redirect_uri=http://localhost:8081/swagger/oauth2-redirect.html
 &scope=read:hello
 &state=xyz
  • На стороне авторизационного сервера (AuthServer) пользователь проходит аутентификацию (ввод логина и пароля в форме);

  • После успешной аутентификации пользователя авторизационный сервер делает редирект на redirect_uri, возвращая code и state:

GET http://localhost:8081/swagger/oauth2-redirect.html?code=abc123&state=xyz

Зачем нужен временный код? Временный авторизационный код нужен для безопасного обмена на токены. Он передаётся серверу по защищённому каналу и исключает риск перехвата токенов в браузере. 

  • Клиент (Swagger UI) обменивает полученный code на токен доступа. Для этого он отправляет POST-запрос на Token Endpoint:

POST http://localhost:8080/oauth/token

grant_type=authorization_code
&code=abc123
&redirect_uri=http://localhost:8081/swagger/oauth2-redirect.html
&client_id=greeting_service
  • Авторизационный сервер проверяет код и полученные параметры, создаёт токены и отдаёт ответ:

{
    "access_token": "...",
    "token_type": "Bearer",
    "expires_in": "...",
    "scope": "..."
}
  • Клиент (Swagger UI) использует access_token для доступа к защищённым ресурсам (к GreetingService)

GET http://localhost:8081/hello
  -H 'accept: application/json'
  -H 'authorization: Bearer ...'

Чтобы поднять Swagger UI, создадим спецификацию Swagger 2.0 swagger/doc.json с описанным API GreetingService и укажем параметры для OAuth-авторизации. Это нужно для того, чтобы Swagger UI мог показать кнопку Authorize, сам запустить redirect на authorizationUrl и сам ходить на tokenUrl за токеном.

     "securityDefinitions": {
        "oauth2": {
            "type": "oauth2",
            "description": "Auth Service OAuth 2.0",
            "flow": "accessCode",
            "authorizationUrl": "http://localhost:8080/oauth2/authorize",
            "tokenUrl": "http://localhost:8080/oauth2/token",
            "scopes": {
                "read": "Access GET /*",
                "read:hello": "Access GET /hello"
            }
        }
    }, 
  • flow: "accessCode" — в Swagger 2.0 так называется Authorization Code Flow;

  • authorizationUrl — куда браузер должен отправить пользователя для логина и выдачи authorization code;

  • tokenUrl — куда клиент (Swagger UI) отправляет code, чтобы обменять его на access_token;

  • scopes — список  «имён прав», которые клиент может запросить. Названия scope не стандартизированы и являются кастомными, их смысл определяется соглашением между Auth Service и защищаемым API. В нашем примере пусть будет два scope:read — даёт право на любые GET запросы в GreetingService и read:hello — даёт права только на GET /hello

Добавим в GreetingService обработчик, возвращающий спецификацию swagger:

//go:embed swagger/doc.json
var swaggerJSON []byte

func swagger(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	w.Write(swaggerJSON)
}

и с помощью библиотеки github.com/swaggo/http-swagger поднимем Swagger UI:

import httpSwagger "github.com/swaggo/http-swagger"

mux := http.NewServeMux()

// Swagger UI
mux.Handle("/swagger/doc.json", http.HandlerFunc(swagger))
mux.Handle("/swagger/", httpSwagger.WrapHandler)

Запустим GreetingService и перейдём по адресу http://localhost:8081/swagger/index.html:

Swagger UI
Swagger UI

Нас тут интересует кнопка авторизации:

Swagger UI OAuth2.0 authorization
Swagger UI OAuth2.0 authorization

client_id — это публичный идентификатор приложения-клиента в OAuth, который сообщает Authorization Server, какое именно приложение запрашивает доступ и используется для проверки разрешённых redirect URI, flow и scope для этого клиента. В примере будем использовать greeting_service.

Перейдём к реализации OAuth-методов в AuthService. Сначала нам нужно реализовать конфигурацию приложений-клиентов, в которой будут храниться известные нам client_id и их настройки (для простоты зададим все параметры в коде):

type OAuthClient struct {
	id            string
	redirectURIs  map[string]bool
	allowedScopes map[string]bool
}

// Проверяет, является ли redirect_uri разрешённым
func (c *OAuthClient) IsAllowedRedirectURI(uri string) bool {
	return c.redirectURIs[uri]
}

// Проверяет, является ли scope валидными
func (c *OAuthClient) IsValidScope(scope string) bool {
	scopes := strings.Fields(scope)
	for _, s := range scopes {
		if !c.allowedScopes[s] {
			return false
		}
	}

	return true
}

var clients = map[string]OAuthClient{
	"greeting_service": {
		id: "greeting_service",
		redirectURIs: map[string]bool{
            // Адрес для редиректа обратно в наше приложение (Swagger) после авторизации
			"http://localhost:8081/swagger/oauth2-redirect.html": true,
		},
		allowedScopes: map[string]bool{
			"read":       true,
			"read:hello": true,
		},
	},
}

Далее напишем реализацию GET /oauth2/authorize, которая будет возвращать нам WEB-форму для входа пользователя по логину и паролю:

Скрытый текст
// наша формочка входа
var loginTmpl = template.Must(template.New("login").Parse(`
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>OAuth Login</title>
</head>
<body>
  <h3>OAuth Login</h3>
  <form method="post" action="/oauth2/authorize">
    <input type="hidden" name="client_id" value="{{.ClientID}}">
    <input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
    <input type="hidden" name="scope" value="{{.Scope}}">
    <input type="hidden" name="state" value="{{.State}}">
    <label>Email:    <input name="email" autocomplete="username"></label><br/>
    <label>Password: <input type="password" name="password" autocomplete="current-password"></label><br/>
    <button type="submit">Login</button>
  </form>
</body>
</html>
`))

type loginViewModel struct {
	ClientID    string
	RedirectURI string
	Scope       string
	State       string
}

func authorizeLogin(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	// Проверяем client_id
	clientID := q.Get("client_id")
	client, ok := clients[clientID]
	if !ok {
		http.Error(w, "unknown_client_id", http.StatusBadRequest)
		return
	}

	// Проверяем, что redirect_uri в whitelist для этого client
	redirectURI := q.Get("redirect_uri")
	if !client.IsAllowedRedirectURI(redirectURI) {
		http.Error(w, "invalid_redirect_uri", http.StatusBadRequest)
		return
	}

	// Проверяем, что scope в whitelist для этого client
	scope := q.Get("scope")
	if !client.IsValidScope(scope) {
		http.Error(w, "invalid_scope", http.StatusBadRequest)
		return
	}

	// state — это строка, которую клиент генерирует сам, отправляет в /oauth2/authorize,
	// а Authorization Server обязан вернуть без изменений при редиректе назад.
	// Защита от CSRF
	state := q.Get("state")

	vm := loginViewModel{
		ClientID:    clientID,    // добавляем в форму client_id
		RedirectURI: redirectURI, // добавляем в форму redirect_uri
		Scope:       scope,       // добавляем в форму scope
		State:       state,       // добавляем в форму state
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := loginTmpl.Execute(w, vm); err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
}

Важно: redirect_uri и scope необходимо валидировать для каждого client_id, потому что именно они определяют, куда будет отправлен authorization code и какие права получит клиент. Без этой проверки злоумышленник может подменить redirect_uri и украсть code или запросить scope, на которые конкретному приложению доступ запрещён.

state — это строка, которую клиент генерирует сам, передаёт в /oauth2/authorize, а Authorization Server обязан вернуть без изменений при редиректе обратно, чтобы клиент мог связать ответ с исходным запросом. Параметр state формально необязателен, но настоятельно рекомендуется, так как он защищает OAuth-флоу от CSRF и подмены ответа.

Теперь нам необходимо реализовать метод аутентификации пользователя по логину и паролю из формы и выдачи codeGET /oauth2/authorize :

Скрытый текст
func authorizeCode(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid_request", http.StatusBadRequest)
		return
	}

	// Проверяем client_id
	clientID := r.Form.Get("client_id")
	client, ok := clients[clientID]
	if !ok {
		http.Error(w, "unknown_client_id", http.StatusBadRequest)
		return
	}

	// Проверяем, что redirect_uri в whitelist для этого client
	redirectURI := r.Form.Get("redirect_uri")
	if !client.IsAllowedRedirectURI(redirectURI) {
		http.Error(w, "invalid_redirect_uri", http.StatusBadRequest)
		return
	}

	// Проверяем, что scope в whitelist для этого client
	scope := r.Form.Get("scope")
	if !client.IsValidScope(scope) {
		http.Error(w, "invalid_scope", http.StatusBadRequest)
		return
	}

    // Аутентификация пользователя по логину и паролю
	var (
		email    = r.Form.Get("email")
		password = r.Form.Get("password")
	)
	usr, err := authUser(email, password)
	if err != nil {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	// генерируем access code для обмена на токен
	code := generateCode()

	// сохраняем code с параметрами аутентификации
	codeMx.Lock()
	codeStore[code] = authCode{
		Code:        code,
		ClientID:    clientID,
		User:        usr,
		Scope:       scope,
		RedirectURI: redirectURI,
		ExpiresAt:   time.Now().Add(2 * time.Minute),
	}
	codeMx.Unlock()

	// Редиректим пользователя обратно на указанный URI
	redirect := fmt.Sprintf("%s?code=%s", redirectURI, code)

	// Authorization Server обязан вернуть без изменений при редиректе назад
	state := r.Form.Get("state")
	if state != "" {
		redirect += "&state=" + state
	}

	http.Redirect(w, r, redirect, http.StatusFound)
}

Здесь мы снова проверяем redirect_uri и scope в authorizeCode, чтобы убедиться, что конкретный client_id запрашивает только разрешённые для него права и получает ответ только на заранее зарегистрированный адрес, иначе authorization code можно украсть или использовать не по назначению. Authorization code мы сохраняем вместе с client_id, redirect_uri, scope и другими параметрами, чтобы потом при обмене code на токен можно было гарантировать целостность OAuth-флоу и исключить подмену клиента, прав или точки возврата.

Нам осталось реализовать последний метод POST /oauth2/token — выдача токенов по code. Поскольку в OAuth появляются scope, нам необходимо указать их в access-токене. Для этого слегка перепишем функцию выдачи access-токена:

type createAccessTokenParams struct {
	User     user
	ClientID string
	Scope    string
	Audience string
}

func createAccessToken(p createAccessTokenParams) (tokenStr string, expiresIn int, err error) {
	const ttl = 15 * time.Minute
	now := time.Now()

	claims := jwt.MapClaims{
		// стандартные JWT claims
		"iss": issuer,              // кто выдал токен
		"sub": p.User.Email,        // кому выдан токен
		"aud": p.Audience,          // ресурс-сервер, для которого предназначен токен
		"iat": now.Unix(),          // время создания токена
		"nbf": now.Unix(),          // не раньше, чем сейчас
		"exp": now.Add(ttl).Unix(), // время жизни токена

		// OAuth-совместимые поля
		"client_id": p.ClientID,
		"scope":     p.Scope,

		// наши произвольные claims
		"user_email": p.User.Email,
		"user_name":  p.User.Name,
	}

	privateKey, err := getPrivateKey(kid)
	if err != nil {
		return "", 0, err
	}

	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	token.Header["kid"] = kid

	signed, err := token.SignedString(privateKey)
	if err != nil {
		return "", 0, err
	}

	return signed, int(ttl.Seconds()), nil
}

И финальный аккорд — метод обмена кода на токены:

Скрытый текст
func token(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid_request", http.StatusBadRequest)
		return
	}

	// Разрешаем только authorization_code
	if r.Form.Get("grant_type") != "authorization_code" {
		http.Error(w, "unsupported grant_type", http.StatusBadRequest)
		return
	}

	// 1) опять проверяем client_id
	clientID := r.Form.Get("client_id")
	client, ok := clients[clientID]
	if !ok {
		http.Error(w, "invalid_client", http.StatusUnauthorized)
		return
	}

	// 2) валидируем redirect_uri
	redirectURI := r.Form.Get("redirect_uri")
	if !client.IsAllowedRedirectURI(redirectURI) {
		http.Error(w, "invalid_grant", http.StatusBadRequest)
		return
	}

	// 3) проверяем наличие кода и сразу его удаляем (одноразовый)
	code := r.Form.Get("code")
	codeMx.Lock()
	ac, ok := codeStore[code]
	if ok {
		delete(codeStore, code)
	}
	codeMx.Unlock()

	// 4) валидируем code
	if !ok || time.Now().After(ac.ExpiresAt) || ac.ClientID != clientID || ac.RedirectURI != redirectURI {
		http.Error(w, "invalid_grant", http.StatusBadRequest)
		return
	}

	// 5) выписываем токены
	access, expiresIn, err := createAccessToken(createAccessTokenParams{
		User:     ac.User,
		ClientID: clientID,
		Scope:    ac.Scope,
		Audience: clientID,
	})
	if err != nil {
		http.Error(w, "token error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"access_token": access,
		"token_type":   "Bearer",
		"expires_in":   expiresIn,
		"scope":        ac.Scope,
	})
}

Здесь мы тоже валидируем все параметры и проверяем, что ничего не подменили. После успешной проверки наконец-то отдаём access-токен.

Вы можете заметить, что в примере Authorization Code Flow я не возвращаю refresh_token. Refresh-токен — это необязательная часть OAuth, хотя в настоя��ее время без неё никуда. В учебном примере я сознательно его не выдаю, чтобы не усложнять код.

Не забываем в AuthService зарегистрировать наши OAuth-обработчики:

	mux.HandleFunc("GET /oauth2/authorize", authorizeLogin)
	mux.HandleFunc("POST /oauth2/authorize", authorizeCode)
	mux.HandleFunc("POST /oauth2/token", token)

Последний штрих — нам нужно в GreetingService проверять scope и aud в токене, а также в обработчике hello проверять, есть ли у пользователя необходимый scope для этой операции. 

Обновлённый verifyAccessToken:

func verifyAccessToken(accessToken string) (user, error) {
	token, err := jwt.Parse([]byte(accessToken),
		jwt.WithKeySet(jwks),
		jwt.WithIssuer(issuer),
		jwt.WithAudience("greeting_service"), // new
		jwt.WithRequiredClaim("scope"),       // new
		jwt.WithRequiredClaim("user_email"),
		jwt.WithRequiredClaim("user_name"),
	)
	if err != nil {
		return user{}, fmt.Errorf("failed to verify JWS: %s", err)
	}

	if err := jwt.Validate(token); err != nil {
		return user{}, ErrInvalidToken
	}

	var userEmail string
	token.Get("user_email", &userEmail)
	var userName string
	token.Get("user_name", &userName)
	var scope string // new
	token.Get("scope", &scope)

	return user{
		Name:   userName,
		Email:  userEmail,
		Scopes: strings.Fields(scope), // new
	}, nil
}

и метод hello:

func hello(w http.ResponseWriter, r *http.Request) {
	user, ok := getUserFromContext(r.Context())
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
    
    // проверка наличия необходимых scope
	helloAllowedScopes := func(s string) bool { return s == "read" || s == "read:hello" }
	hasScope := slices.ContainsFunc(user.Scopes, helloAllowedScopes)
	if !hasScope {
		http.Error(w, "invalid scope", http.StatusForbidden)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	type response struct {
		Message string `json:"message"`
	}
	json.NewEncoder(w).Encode(response{Message: fmt.Sprintf("Hello %s!", user.Name)})
}

Проверим, что всё работает корректно. Запустим сервисы AuthService и GreetingService + SwaggerUI на портах 8080 и 8081 соответственно. Перейдём по адресу http://localhost:8081/swagger/index.html и нажмём кнопку Authorize. В качестве client_id указываем наш greeting_service (client_secret оставляем пустым) и выбираем нужные нам scope

Swagger UI OAuth2.0
Swagger UI OAuth2.0

Нажимаем Authorize, и нас перебрасывает на нашу страницу входа с формочкой. Вводим логин, пароль и логинимся. После этого нас вернёт обратно в Swagger UI.

Далее можем спокойно выполнить запрос в GET /hello и получим успешный ответ (так как Swagger получил наш access-токен и теперь может делать запросы к API GreetingService):

Swagger UI
Swagger UI

Поздравляю, у вас работает OAuth-авторизация!

PKСE (Proof Key for Code Exchange) в OAuth2.0

Вы, наверное, могли заметить, что в нашем примере выше при авторизации через OAuth2.0 мы не передали client_secret. client_secret в OAuth нужен для того, чтобы Authorization Server мог убедиться, что authorization code или токен запрашивает именно доверенное приложение, а не кто-то, кто просто узнал client_id. И тут важно понять, что в OAuth все клиенты изначально делятся на два типа — и от этого зависит модель безопасности проверки клиента.

OAuth различает confidential clients и public clients. Confidential client — это серверное приложение, которое может безопасно хранить секреты: например, backend-сервис или классическое web-приложение с серверной частью. Такой клиент получает не только client_id, но и client_secret, и при обмене authorization code на access token он обязан предъявить оба — тем самым доказывая, что код обменивает именно доверенный сервер.

Public client — это приложения, в которых секрет хранить невозможно: браузерные SPA, мобильные приложения, Swagger UI. Любой client_secret, встроенный в такой клиент, немедленно становится публичным. Поэтому для public clients OAuth изначально запрещает использование client_secret — и долгое время Authorization Code Flow для них оставался уязвимым. 

Именно здесь и появляется PKCE. Он заменяет client_secret для public clients, добавляя криптографическое доказательство того, что authorization code обменивает тот же клиент, который инициировал флоу. Благодаря этому Authorization Code Flow стал безопасным не только для серверных приложений, но и для браузеров, мобильных клиентов и Swagger UI, и сегодня считается стандартным выбором для пользовательской авторизации.

Я не стал добавлять в пример PKCE, но для полной безопасности вашего приложения он необходим.

OpenID Connect

В нашей сети отелей появляется новая привилегия для VIP-постояльцев — золотой браслет. Он даёт право пользоваться любыми услугами отеля бесплатно: прокат машины, SPA, водные скутеры, рестораны. На ресепшене, как и раньше, проводится аутентификация: гость показывает документы, его проверяют в системе и выдают золотой браслет.

Дальше всё работает просто и удобно. Гость приходит в SPA или в прокат авто, сотрудники не задают лишних вопросов: есть браслет — доступ разрешён. Никто не проверяет личность, не спрашивает имя, не сверяет фото. Браслет сам по себе достаточное основание для доступа.

И вроде бы всё идеально. Ровно до того момента, пока один из VIP-постояльцев не снимает браслет и не отдаёт его своему другу, который вообще не является VIP-клиентом.

Формально, с точки зрения OAuth, всё корректно. VIP-гость делегировал свои права другому человеку: «Я разрешаю ему пользоваться тем, чем могу пользоваться сам». Браслет — это access token, а access token не обязан быть привязан к личности. Он лишь говорит: разрешено пользоваться услугами.

Но с точки зрения отеля начинается проблема. Администрации важно не просто, чтобы кто-то пользовался услугами, а чтобы это делал именно тот постоялец, которому эти привилегии были выданы. Не любой предъявитель браслета, а конкретный человек: с подтверждённой личностью, статусом и историей.

И вот здесь становится понятно ограничение OAuth. OAuth прекрасно отвечает на вопрос «Что можно делать?», но он вообще не отвечает на вопрос «Кто именно это делает?».

В реальности OAuth-флоу использовался как механизм входа, хотя спецификация OAuth вообще не описывала, как подтверждать личность пользователя. Каждая платформа делала это по-своему, возвращая профиль, email или id пользователя через API. Это работало, но единых гарантий не было. Проблема стала очевидной, когда начали задаваться вопросом: а что именно мы проверяем? Кто гарантирует, что access token действительно выдан для этого клиента? Что пользователь аутентифицирован именно сейчас, а не просто разрешил доступ к какому-то ресурсу? Где зафиксировано, что этот токен — про личность, а не просто про доступ?

Чтобы решить эту проблему, отель вводит новое правило: теперь одного золотого браслета недостаточно. Вместе с ним у VIP-постояльца есть персональная карта с именем, фотографией и уникальным идентификатором (выдаётся на ресепшене при заселении). Сотрудники больше не смотрят только на браслет, они ещё и сверяют, кому он принадлежит.

Именно эту роль в реальных системах играет OpenID Connect. Он не отменяет OAuth и не заменяет access token. Он добавляет недостающий слой — подтверждённую идентичность пользователя (id_token). Теперь система знает не только, что разрешено, но и кому именно это разрешено.

Структура протокола OIDC
Структура протокола OIDC

В реализации различие между OAuth 2.0 и OpenID Connect проявляется в следующем: 

Категория 

OAuth 2.0 

OpenID Connect (OIDC) 

Scope 

Использует произвольные scope для доступа к ресурсам, например: read:fileswrite:files — «Хочу доступ к файлам»

Обязательно требует scope=openid для аутентификации.

Дополнительные scope: profileemail — «Хочу узнать, кто пользователь»

Типы токенов 

access_token — используется для доступа к API.

refresh_token — опционально. 

access_token — токен доступа, который используется для обращения к защищённым API и описывает, какие действия разрешено выполнять.

id_token — токен идентификации, который подтверждает личность пользователя и содержит проверяемые сведения о нём (id, email, имя и т.д.).

refresh_token — опциональный долгоживущий токен, который используется для получения новых access token без повторной аутентификации пользователя.

Endpoints 

/oauth/authorize — запрос авторизации.

/oauth/token — обмен кода на токены. 

/oauth/authorize — запрос авторизации.

/oauth/token — обмен кода на токены. 

/userinfo — получить профиль пользователя по access_token

/.well-known/openid-configuration — discovery-документ для автоматической конфигурации клиента. 

Валидация 

Проверка access_token на стороне API. 

Проверка access_token и обязательная валидация id_token (подпись, issaudexp). 

Как и в OAuth, в OIDC есть несколько flow получения токенов.

Подробнее обо всех OIDC flow очень хорошо написано в этой статье.

Выбор flow определяется параметром response_type при запросе в провайдера (сервиса авторизации) OIDC. Как и в OAuth самым распространённым является authorization code flow.

OIDC authorization code flow
OIDC authorization code flow

Главное отличие в OIDC от OAuth authorization code flow лишь в том, что вместе с access_token клиент получает id_token, который подтверждает личность пользователя и факт аутентификации. 

На самом деле, есть ещё пара тонких деталей, которые я опущу. Подробности можно найти в спецификации протокола OIDC.

Технически id_token — это обычный подписанный JWT-токен. Он выдаётся только в момент логина и адресован конкретному клиенту.

{
  "exp": 1768362250,
  "iat": 1768361950,
  "auth_time": 1768361506,
  "jti": "00b39b51-5e56-4cd1-9cdf-d6bfccee9c88",
  "iss": "http://localhost:8080/realms/example",
  "aud": "example-client",
  "sub": "ecc23044-bd44-4f39-a6f8-cfe5ceceefda",
  "typ": "ID",
  "azp": "example-client",
  "session_state": "b4604634-3808-47c4-8ed1-39d9d784ff31",
  "at_hash": "cRF6N0lou9K2Z2OW4bDbsg",
  "acr": "0",
  "sid": "b4604634-3808-47c4-8ed1-39d9d784ff31",
  "email_verified": true,
  "name": "Bob Bob",
  "preferred_username": "bob",
  "given_name": "Bob",
  "family_name": "Bob",
  "email": "bob@gmail.com"
}

Проверяя подпись, iss и aud, приложение убеждается, что токен выдан доверенным Identity Provider именно для него. А поля вроде sub, email и name дают однозначную идентичность пользователя, на основе которой можно создать сессию.

Важно и то, что id_token фиксирует сам факт аутентификации: в нём есть время входа (auth_time), срок действия (exp) и nonce(опциональный), связывающий токен с конкретным логин-запросом. Это позволяет клиенту быть уверенным, что пользователь аутентифицировался прямо сейчас, а не просто когда-то разрешил доступ к ресурсу. Поэтому id_token не используется для вызова API — он используется, чтобы один раз сказать приложению: «Да, этот пользователь настоящий, вот его личность».

Пример 

Хотелось бы привести пример реализации входа через OpenID Connect (OIDC) от Google, однако для pet-проекта воспроизвести этот процесс в реальности может оказаться непросто. К слову, Google OAuth 2.0 APIs полностью соответствуют спецификации OpenID Connect. Если интересно изучить подробнее, можно поэкспериментировать с Google OAuth Playground

Если же говорить о российских сервисах, поддерживающих OAuth, то вот наиболее популярные: 

  • ВКонтакте (VK ID) 

    • Поддерживает OAuth 2.1

    • Документация: VK ID OAuth 2.1

Стоит отметить, что OAuth API Яндекса и ВКонтакте также позволяют выполнять как авторизацию, так и аутентификацию, однако их реализации отличаются от стандартной спецификации OpenID Connect. В частности, они не предоставляют явно описанного ID Token (JWT), характерного для OIDC, и чаще всего требуют отдельного запроса к API для получения данных о пользователе.

В информационных системах компаний также активно применяется OIDC для организации централизованного входа сотрудников в различные сервисы и приложения. Использование OpenID Connect позволяет реализовать единую точку аутентификации (Single Sign-On, SSO), когда сотруднику достаточно авторизоваться один раз, чтобы получить доступ сразу ко всем необходимым ресурсам, обеспечивая удобство и повышая уровень безопасности.

OIDC на практике

Реализация собственного авторизационного OIDC сервера очень трудная задача, поскольку протокол OIDC довольно сложный и имеет множество разных нюансов. Поэтому для демонстрации работы с OIDC нам придётся воспользоваться готовым решением — Keycloak

Для адаптации нашего примера из OAuth Authorization Code Flow под OIDC поменяем роли участников.

  • AuthService теперь будет выступать в качестве клиента (confidential client) вместо Swagger.

  • Keycloak теперь будет выступать в роли Authorization Service (а точнее — уже и Identity Provider).

  • GreetingService так и останется в роли ресурсного сервиса (Resource Service).

Схема OIDC-аутентификации в нашем примере
Схема OIDC-аутентификации в нашем примере

Быстрая настройка Keycloak для OpenID Connect

Для демонстрации будем использовать стандартный образ Keycloak, запущенный локально на 8080 порте. 

Пример docker-compose:

version: "3.8"
services:
  keycloak:
    image: quay.io/keycloak/keycloak:24.0.1
    command: start-dev
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"

Сначала откроем в браузере http://localhost:8080/ и войдём под администратором.

Создадим новый Realm, назовём его example (Realm — это изолированное пространство, где будут свои пользователи, клиенты, настройки безопасности).

Теперь создадим клиента — его роль в том, чтобы представлять ваше приложение в экосистеме авторизации. Пусть это будет example-client

Включаем у клиента: 

  • client authentication — включено;

  • authorization — включено;

  • в поле Valid redirect URIs указываем http://localhost:3000/oidc/callback — сюда Keycloak вернёт пользователя после успешной авторизации;

  • в качестве Login theme можно оставить keycloak — стандартная форма входа.

После создания клиента не забудьте сохранить его секрет, он понадобится для конфигурации приложения. Перейдите в Credentials → Client Secret и скопируйте значение. 

Настройте переменные окружения в своём приложении: 

export OIDC_CLIENT_ID=example-client 
export OIDC_CLIENT_SECRET=<значение_секрета> 

Теперь создадим пользователя, под которым мы будем логиниться. Перейдите в раздел Users и нажмите Create.

Пример пользователя: 

  • Username: bob 

  • Email: bob@gmail.com 

  • First name: Bob 

  • Last name: Bob

  • Email verified: true

После сохранения перейдите в Credentials и установите пароль (cнимите галочку Temporary, иначе Keycloak попросит сменить пароль при первом входе).

Теперь у нас есть готовое окружение для работы с OpenID Connect.

Реализация OIDC-аутентификации

Следующим шагом нам потребуется реализовать на клиенте (сервисе AuthService) аутентификацию через OIDC. Для этого воспользуемся библиотеками golang.org/x/oauth2 и github.com/coreos/go-oidc/v3.

Настроим oidc.Provider и oauth2.Config:

package main

import (
	"context"
	"log"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
	oauth2 "golang.org/x/oauth2"
)

var (
	// Конфиг OAuth2.0 для клиента, который отправляет запросы на авторизацию и получение токенов.
	oauth2Config *oauth2.Config

	// OpenID-провайдера (в нашем случае — Keycloak)
	oidcProvider *oidc.Provider
)

func init() {
	var err error

	const realm = "http://localhost:8080/realms/example"

	oidcProvider, err = oidc.NewProvider(context.Background(), realm)
	if err != nil {
		log.Fatal(err)
	}

	oauth2Config = &oauth2.Config{
		ClientID:     os.Getenv("OIDC_CLIENT_ID"),     // id клиента в Keycloak
		ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), // секрет клиента в Keycloak
		Endpoint:     oidcProvider.Endpoint(),
		RedirectURL:  "http://localhost:3000/oidc/callback", // Адрес, куда Keycloak вернёт пользователя после логина.
		Scopes: []string{
			oidc.ScopeOpenID,        // обязательно для OIDC
			oidc.ScopeOfflineAccess, // чтобы получить Refresh
			"profile", "email",      // чтобы получить имя и email в ID Token.
		},
	}
}

Теперь реализуем метод login, который будет перенаправлять нас на страницу аутентификации Keycloak.

func login(w http.ResponseWriter, r *http.Request) {
	const state = "some random state"

	redirectURL := oauth2Config.AuthCodeURL(state)
	http.Redirect(w, r, redirectURL, http.StatusFound)
}

Также реализуем метод callback, в который нам передадут временный код для обмена на токен: 

func callback(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	// защита от CSRF-атаки
	if r.URL.Query().Get("state") != "some random state" {
		http.Error(w, "invalid state", http.StatusBadRequest)
		return
	}

	// Получаем временный authorization code, который отправил Keycloak
	code := r.URL.Query().Get("code")

Нам необходимо обменять этот временный код на токены. Для этого воспользуемся уже готовым методом oauth2.Config.Excahnge

	// Обмениваем authorization code на настоящие токены
	token, err := oauth2Config.Exchange(ctx, code)
	if err != nil {
		http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
		return
	}

Затем для аутентификации пользователя нам необходимо провалидировать полученный id_token:

	// id_token - это JWT, который содержит данные о пользователе (имя, email и прочее)
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		http.Error(w, "No id_token field", http.StatusInternalServerError)
		return
	}

	verifier := oidcProvider.Verifier(&oidc.Config{ClientID: os.Getenv("OIDC_CLIENT_ID")})

	idToken, err := verifier.Verify(ctx, rawIDToken)
	if err != nil {
		http.Error(w, "Failed to verify ID token", http.StatusInternalServerError)
		return
	}

Метод Verify выполняет проверку id_token, который мы получили от Identity Provider (в нашем случае — Keycloak).

Что включает в себя эта проверка:

  • подпись токена — проверяет, что токен действительно подписан Keycloak-ом (через те самые JWKS, только это всё сделано за нас и спрятано в библиотеке);  

  • поле aud (аудитория) — проверяет, что токен предназначен именно для нашего клиента (clientID);

  • поле exp (время жизни) — не истёк ли токен;

  • поле iss (issuer) — что токен выпущен.

После успешной валидации id_token мы можем достать из него всю необходимую информацию о пользователе и считать, что процесс аутентификации завершён.

 	var claims map[string]any
	if err := idToken.Claims(&claims); err != nil {
		http.Error(w, "Failed to parse claims", http.StatusInternalServerError)
		return
	}

	log.Printf("Пользователь %s аутентифицирован", claims["name"]) 

Как правило, после аутентификации через OpenID Connect приложение чаще всего создаёт собственные сессионные токены (внутренние JWT или другие токены) на основе данных из id_token (токены Identity Provider служат лишь для подтверждения личности пользователя при входе). Однако в архитектурах, в которых Identity Provider является центральной точкой доверия и безопасности для всей системы, допускается и считается нормальной практикой использовать access_token Identity Provider напрямую как сессионный токен (при условии, что все сервисы умеют его корректно валидировать и доверяют этому провайдеру).

В нашем примере Keycloak можно рассматривать именно как центральную точку доверия, поэтому вернём токены как есть: 

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	resp := struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
	}{
		AccessToken:  token.AccessToken,
		RefreshToken: token.RefreshToken,
        // Для демонстрации можем вернуть ещё id токен
	}
	json.NewEncoder(w).Encode(resp)

Если мы используем access- и refresh-токены от Keycloak, то нам тоже необходимо иметь возможность их обновлять. Для этого реализуем ещё refresh-метод:  

func refresh(w http.ResponseWriter, r *http.Request) {
	type request struct {
		RefreshToken string `json:"refresh_token"`
	}

	var req request
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RefreshToken == "" {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	ctx := r.Context()

	ts := oauth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: req.RefreshToken})

	newToken, err := ts.Token()
	if err != nil {
		http.Error(w, "Failed to refresh token", http.StatusUnauthorized)
		return

	}

	w.Header().Set("Content-Type", "application/json")
	type response struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
	}

	json.NewEncoder(w).Encode(response{
		AccessToken:  newToken.AccessToken,
		RefreshToken: newToken.RefreshToken,
	})
}

Соберём всё вместе и поднимем сервис AuthService

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /login/oidc/authorize", login)
	mux.HandleFunc("GET /oidc/callback", callback)
	mux.HandleFunc("POST /refresh", refresh)

	srv := http.Server{
		Addr:    ":3000",
		Handler: mux,
	}

	fmt.Println("Server running at http://localhost:3000")
	if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

Заходим по адресу http://localhost:3000/login/oidc/authorize. Нас перебросит на страницу авторизации в Keycloak. Авторизируемся под Bob:

Получим в ответ access- и refresh-токены.

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ1ZzVKX0dGVG9MdWYzc0hlR29scDcwV09DUm54UHJvN2xPY2dicjRicmxrIn0.eyJleHAiOjE3NTQyMjE3MjcsImlhdCI6MTc1NDIyMTQyNywiYXV0aF90aW1lIjoxNzU0MjIxNDI3LCJqdGkiOiJhZmE5NmQ3Ny01NWMzLTQ1MzctOTNiOS1mZjM5NDk3NmU2NGMiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL2V4YW1wbGUiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiYTRiMTUxOTYtZmEzYi00MTBkLWI4NmYtYzRjOWFiMDg5NmY5IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZXhhbXBsZS1jbGllbnQiLCJzZXNzaW9uX3N0YXRlIjoiNDZjZjIwZWYtYTUwMy00YWY2LTg5YjYtOWE1YWE2N2VhYTQ3IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vbG9jYWxob3N0OjMwMDAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwiZGVmYXVsdC1yb2xlcy1leGFtcGxlIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIiwic2lkIjoiNDZjZjIwZWYtYTUwMy00YWY2LTg5YjYtOWE1YWE2N2VhYTQ3IiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJCb2IgQm9iIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYm9iIiwiZ2l2ZW5fbmFtZSI6IkJvYiIsImZhbWlseV9uYW1lIjoiQm9iIiwiZW1haWwiOiJib2JAZ21haWwuY29tIn0.OHBAZgJk_0ASgMe2lQqy7QAd8kKHvG_BDd1_0xXREP7P_d0tzW2Zh9Ie3-UhNobQXmDSc6HfRk50CIjzc26MzadcQix2qEdueKa0gjZ-8fm8w9DQm1CEDHaBVqtToLfgT_0Jkra7fWqCRbXM-rPEpWte6HkL-5AOMYjgxx_Ztv7W4CTYHUefIF-PYP92q_xKxYMBsnxbiUoo3GMTA_4HKu2U9kHadrXbgWi5fRVsZoFuiN58OMl21D4TbqPg6CO7Rh39BPg8HEUSr3ua_BBdM1otIUcGmsHFR1Ef5_uC1yHKyOlCO1Qa90G5TdZA_ak0yyUKnP_XVNO2glo9n3HLog",
    "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkNTVjZmQ5Mi0xMzI4LTQ3MWMtYTczMy00YjU2ZTk3NjU4MzEifQ.eyJpYXQiOjE3NTQyMjE0MjcsImp0aSI6ImM0YzJmOTQ0LTBkOGYtNGQzMC04NWU4LWY2MDU2NjgyMTZlYiIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvZXhhbXBsZSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvZXhhbXBsZSIsInN1YiI6ImE0YjE1MTk2LWZhM2ItNDEwZC1iODZmLWM0YzlhYjA4OTZmOSIsInR5cCI6Ik9mZmxpbmUiLCJhenAiOiJleGFtcGxlLWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiI0NmNmMjBlZi1hNTAzLTRhZjYtODliNi05YTVhYTY3ZWFhNDciLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIiwic2lkIjoiNDZjZjIwZWYtYTUwMy00YWY2LTg5YjYtOWE1YWE2N2VhYTQ3In0.H3zyc9lISEb_hryllR-hNAx-f_fJvfm2o8PMr2h00yZzWJyVzSCatfPlVRkNXZSkbx6T4rafl4Q-YupKtX9FHQ"
}

У нас получилось реализовать аутентификацию через OIDC. Но мы забыли изменить валидацию access-токена в GreetingService. Воспользуемся уже знакомым нам методом oidc.Provider.Verify.

Скрытый текст
var oidcProvider *oidc.Provider

func init() {
	var err error
	oidcProvider, err = oidc.NewProvider(context.Background(), "http://localhost:8080/realms/example")

	if err != nil {
		log.Fatal(err)
	}
}

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		authHeader := r.Header.Get("Authorization")

		const bearerPrefix = "Bearer "

		if !strings.HasPrefix(authHeader, bearerPrefix) {
			http.Error(w, "Missing access token", http.StatusUnauthorized)
			return
		}

		accessToken := strings.TrimPrefix(authHeader, bearerPrefix)

		verifier := oidcProvider.Verifier(&oidc.Config{
			ClientID:          os.Getenv("OIDC_CLIENT_ID"),
			SkipClientIDCheck: true,
		})

		// Проверяем токен
		token, err := verifier.Verify(ctx, accessToken)
		if err != nil {
			http.Error(w, "Invalid token", http.StatusUnauthorized)
			return
		}

		var claims struct {
			Name  string `json:"name"`
			Email string `json:"email"`
		}
		if err := token.Claims(&claims); err != nil {
			http.Error(w, "Failed to parse claims", http.StatusInternalServerError)
			return
		}

		user := user{
			Name:  claims.Name,
			Email: claims.Email,
		}

		next.ServeHTTP(w, r.WithContext(putUserToContext(ctx, user)))
	})
}

Запустим сервис GreetingService и сделаем запрос с access-токеном, который получили в /login.

curl --location 'http://localhost:8081/hello' \ 
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ1ZzVKX0dGVG9MdWYzc0hlR29scDcwV09DUm54UHJvN2xPY2dicjRicmxrIn0.eyJleHAiOjE3NTQyMjE3MjcsImlhdCI6MTc1NDIyMTQyNywiYXV0aF90aW1lIjoxNzU0MjIxNDI3LCJqdGkiOiJhZmE5NmQ3Ny01NWMzLTQ1MzctOTNiOS1mZjM5NDk3NmU2NGMiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL2V4YW1wbGUiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiYTRiMTUxOTYtZmEzYi00MTBkLWI4NmYtYzRjOWFiMDg5NmY5IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZXhhbXBsZS1jbGllbnQiLCJzZXNzaW9uX3N0YXRlIjoiNDZjZjIwZWYtYTUwMy00YWY2LTg5YjYtOWE1YWE2N2VhYTQ3IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vbG9jYWxob3N0OjMwMDAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwiZGVmYXVsdC1yb2xlcy1leGFtcGxlIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIiwic2lkIjoiNDZjZjIwZWYtYTUwMy00YWY2LTg5YjYtOWE1YWE2N2VhYTQ3IiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJCb2IgQm9iIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYm9iIiwiZ2l2ZW5fbmFtZSI6IkJvYiIsImZhbWlseV9uYW1lIjoiQm9iIiwiZW1haWwiOiJib2JAZ21haWwuY29tIn0.OHBAZgJk_0ASgMe2lQqy7QAd8kKHvG_BDd1_0xXREP7P_d0tzW2Zh9Ie3-UhNobQXmDSc6HfRk50CIjzc26MzadcQix2qEdueKa0gjZ-8fm8w9DQm1CEDHaBVqtToLfgT_0Jkra7fWqCRbXM-rPEpWte6HkL-5AOMYjgxx_Ztv7W4CTYHUefIF-PYP92q_xKxYMBsnxbiUoo3GMTA_4HKu2U9kHadrXbgWi5fRVsZoFuiN58OMl21D4TbqPg6CO7Rh39BPg8HEUSr3ua_BBdM1otIUcGmsHFR1Ef5_uC1yHKyOlCO1Qa90G5TdZA_ak0yyUKnP_XVNO2glo9n3HLog' 

В ответе увидим всё то же наше приветствие:

{
    "message": "Hello bob!"
}

Что важно учесть в OIDC и OAuth

  1. Используйте HTTPS везде (в примерах я его опустил для простоты).

  2. Используйте и проверяйте параметр state, чтобы защититься от CSRF-атак. 

  3. Используйте PKCE (Proof Key for Code Exchange), он предотвращает подмену кода при редиректе (в статье я опустил данную часть). 

  4. Регистрируйте точные redirect_uri в (не полагайтесь на wildcard’ы) и проверяйте их на сервере, чтобы избежать атак с подменой адреса. 

  5. Минимизируйте scope. Запрашивайте только необходимые разрешения. Чем шире запрашиваемый scope — тем выше риски при компрометации. 

  6. Храните client_secret в секретах тщательно. Он только для серверных приложений. Никогда не передавайте его в браузер или мобильное приложение.


На этом мы завершаем наше путешествие по ключевым технологиям, которые лежат в основе аутентификации и авторизации в современных системах. Исходный код всех примеров доступен в моём репозитории — вы можете сами локально поэкспериментировать и закрепить примеры на практике.

Если вам был полезен этот разбор и хочется больше материалов про Go, архитектуру и современные практики разработки — буду рад видеть вас в своём Telegram-канале, в котором я делюсь новыми статьями и заметками.

Дополнительные ссылки: