refactor: 使用标准库实现 OIDC 客户端流程

This commit is contained in:
2026-07-14 11:11:48 +08:00
parent 8ca68eb3cd
commit 053bc260c7
8 changed files with 237 additions and 112 deletions
+178 -90
View File
@@ -8,12 +8,18 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid")
var errOIDCResponseTooLarge = errors.New("OIDC response exceeds size limit")
type OIDCPublicClientConfig struct {
Issuer string
ClientID string
@@ -32,16 +38,18 @@ type OIDCTokenResponse struct {
}
type OIDCPublicClient struct {
config OIDCPublicClientConfig
client *http.Client
mu sync.Mutex
metadata oidcClientDiscovery
config OIDCPublicClientConfig
client *http.Client
mu sync.Mutex
oauth2Config *oauth2.Config
metadata oidcClientDiscovery
}
type oidcClientDiscovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
RevocationEndpoint string `json:"revocation_endpoint"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
@@ -53,71 +61,62 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro
config.PostLogoutRedirectURI = strings.TrimSpace(config.PostLogoutRedirectURI)
config.Scopes = normalizedScopes(config.Scopes)
for _, scope := range config.Scopes {
if strings.EqualFold(scope, "offline_access") {
if strings.EqualFold(scope, oidc.ScopeOfflineAccess) {
return nil, errors.New("offline_access is not allowed for Gateway browser sessions")
}
}
if validatePublicURL(config.Issuer) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI) != nil || validatePublicURL(config.PostLogoutRedirectURI) != nil {
return nil, errors.New("issuer, public client id and exact redirect URLs are required")
}
client := config.HTTPClient
if client == nil {
client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}
return &OIDCPublicClient{config: config, client: client}, nil
return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil
}
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, codeChallenge string) (string, error) {
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || strings.TrimSpace(codeChallenge) == "" {
return "", errors.New("state, nonce and PKCE challenge are required")
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) {
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
return "", errors.New("state, nonce and PKCE verifier are required")
}
metadata, err := c.discovery(ctx)
config, _, err := c.configuration(ctx)
if err != nil {
return "", err
}
parsed, err := url.Parse(metadata.AuthorizationEndpoint)
if err != nil {
return "", errors.New("OIDC authorization endpoint is invalid")
}
query := parsed.Query()
query.Set("response_type", "code")
query.Set("client_id", c.config.ClientID)
query.Set("redirect_uri", c.config.RedirectURI)
query.Set("scope", strings.Join(c.config.Scopes, " "))
query.Set("state", state)
query.Set("nonce", nonce)
query.Set("code_challenge", codeChallenge)
query.Set("code_challenge_method", "S256")
parsed.RawQuery = query.Encode()
return parsed.String(), nil
return config.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)), nil
}
func (c *OIDCPublicClient) ExchangeCode(ctx context.Context, code, verifier string) (OIDCTokenResponse, error) {
if strings.TrimSpace(code) == "" || strings.TrimSpace(verifier) == "" {
if strings.TrimSpace(code) == "" || !validPKCEVerifier(verifier) {
return OIDCTokenResponse{}, errors.New("authorization code and PKCE verifier are required")
}
return c.token(ctx, url.Values{
"grant_type": {"authorization_code"}, "client_id": {c.config.ClientID},
"redirect_uri": {c.config.RedirectURI}, "code": {code}, "code_verifier": {verifier},
})
config, _, err := c.configuration(ctx)
if err != nil {
return OIDCTokenResponse{}, err
}
token, err := config.Exchange(c.requestContext(ctx), code, oauth2.VerifierOption(verifier))
if err != nil {
return OIDCTokenResponse{}, oidcTokenError(err)
}
return oidcTokenResponse(token)
}
func (c *OIDCPublicClient) Refresh(ctx context.Context, refreshToken string) (OIDCTokenResponse, error) {
if strings.TrimSpace(refreshToken) == "" {
return OIDCTokenResponse{}, ErrOIDCInvalidGrant
}
return c.token(ctx, url.Values{
"grant_type": {"refresh_token"}, "client_id": {c.config.ClientID}, "refresh_token": {refreshToken},
})
config, _, err := c.configuration(ctx)
if err != nil {
return OIDCTokenResponse{}, err
}
token, err := config.TokenSource(c.requestContext(ctx), &oauth2.Token{RefreshToken: refreshToken}).Token()
if err != nil {
return OIDCTokenResponse{}, oidcTokenError(err)
}
return oidcTokenResponse(token)
}
func (c *OIDCPublicClient) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
if strings.TrimSpace(refreshToken) == "" {
return nil
}
metadata, err := c.discovery(ctx)
_, metadata, err := c.configuration(ctx)
if err != nil {
return err
}
@@ -139,7 +138,7 @@ func (c *OIDCPublicClient) RevokeRefreshToken(ctx context.Context, refreshToken
}
func (c *OIDCPublicClient) EndSessionURL(ctx context.Context, idTokenHint string) (string, error) {
metadata, err := c.discovery(ctx)
_, metadata, err := c.configuration(ctx)
if err != nil {
return "", err
}
@@ -160,33 +159,6 @@ func (c *OIDCPublicClient) EndSessionURL(ctx context.Context, idTokenHint string
return parsed.String(), nil
}
func (c *OIDCPublicClient) token(ctx context.Context, form url.Values) (OIDCTokenResponse, error) {
metadata, err := c.discovery(ctx)
if err != nil {
return OIDCTokenResponse{}, err
}
response, err := c.postForm(ctx, metadata.TokenEndpoint, form)
if err != nil {
return OIDCTokenResponse{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
var oauthError struct {
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes)).Decode(&oauthError)
if oauthError.Error == "invalid_grant" {
return OIDCTokenResponse{}, ErrOIDCInvalidGrant
}
return OIDCTokenResponse{}, fmt.Errorf("OIDC token endpoint returned HTTP %d", response.StatusCode)
}
var result OIDCTokenResponse
if err := json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes)).Decode(&result); err != nil || strings.TrimSpace(result.AccessToken) == "" {
return OIDCTokenResponse{}, errors.New("OIDC token response is invalid")
}
return result, nil
}
func (c *OIDCPublicClient) postForm(ctx context.Context, endpoint string, form url.Values) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -197,44 +169,160 @@ func (c *OIDCPublicClient) postForm(ctx context.Context, endpoint string, form u
return c.client.Do(request)
}
func (c *OIDCPublicClient) discovery(ctx context.Context) (oidcClientDiscovery, error) {
func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, oidcClientDiscovery, error) {
c.mu.Lock()
if c.metadata.Issuer != "" {
metadata := c.metadata
if c.oauth2Config != nil {
config, metadata := c.oauth2Config, c.metadata
c.mu.Unlock()
return metadata, nil
return config, metadata, nil
}
c.mu.Unlock()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.Issuer+"/.well-known/openid-configuration", nil)
provider, err := oidc.NewProvider(c.requestContext(ctx), c.config.Issuer)
if err != nil {
return oidcClientDiscovery{}, err
}
request.Header.Set("Accept", "application/json")
response, err := c.client.Do(request)
if err != nil {
return oidcClientDiscovery{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return oidcClientDiscovery{}, fmt.Errorf("OIDC discovery returned HTTP %d", response.StatusCode)
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery failed")
}
var metadata oidcClientDiscovery
if err := json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes)).Decode(&metadata); err != nil ||
metadata.Issuer != c.config.Issuer || validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil ||
if err := provider.Claims(&metadata); err != nil || metadata.Issuer != c.config.Issuer ||
validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil || validatePublicURL(metadata.JWKSURI) != nil ||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint) != nil ||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint) != nil {
return oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
}
endpoint := provider.Endpoint()
// Gateway is a public client. Force client_id into the form and never probe
// HTTP Basic authentication with an empty client secret.
endpoint.AuthStyle = oauth2.AuthStyleInParams
config := &oauth2.Config{
ClientID: c.config.ClientID, RedirectURL: c.config.RedirectURI,
Endpoint: endpoint, Scopes: append([]string(nil), c.config.Scopes...),
}
c.mu.Lock()
c.metadata = metadata
c.mu.Unlock()
return metadata, nil
defer c.mu.Unlock()
if c.oauth2Config == nil {
c.oauth2Config = config
c.metadata = metadata
}
return c.oauth2Config, c.metadata, nil
}
func (c *OIDCPublicClient) requestContext(ctx context.Context) context.Context {
return oidc.ClientContext(ctx, c.client)
}
func oidcTokenError(err error) error {
var retrieveError *oauth2.RetrieveError
if errors.As(err, &retrieveError) {
if retrieveError.ErrorCode == "invalid_grant" {
return ErrOIDCInvalidGrant
}
if retrieveError.Response != nil {
return fmt.Errorf("OIDC token endpoint returned HTTP %d", retrieveError.Response.StatusCode)
}
}
return errors.New("OIDC token endpoint request failed")
}
func oidcTokenResponse(token *oauth2.Token) (OIDCTokenResponse, error) {
if token == nil || strings.TrimSpace(token.AccessToken) == "" {
return OIDCTokenResponse{}, errors.New("OIDC token response is invalid")
}
idToken, _ := token.Extra("id_token").(string)
return OIDCTokenResponse{
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: idToken,
TokenType: token.TokenType, ExpiresIn: integerTokenExtra(token.Extra("expires_in")),
}, nil
}
func integerTokenExtra(value any) int {
switch typed := value.(type) {
case int:
return typed
case int64:
return int(typed)
case float64:
return int(typed)
case json.Number:
result, _ := strconv.Atoi(typed.String())
return result
case string:
result, _ := strconv.Atoi(typed)
return result
default:
return 0
}
}
func validPKCEVerifier(value string) bool {
if len(value) < 43 || len(value) > 128 {
return false
}
for _, char := range value {
if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || strings.ContainsRune("-._~", char) {
continue
}
return false
}
return true
}
func newOIDCHTTPClient(base *http.Client) *http.Client {
if base == nil {
base = &http.Client{Timeout: defaultOIDCHTTPTimeout}
}
client := *base
transport := client.Transport
if transport == nil {
transport = http.DefaultTransport
}
client.Transport = oidcResponseLimitTransport{base: transport}
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
return &client
}
type oidcResponseLimitTransport struct {
base http.RoundTripper
}
func (t oidcResponseLimitTransport) RoundTrip(request *http.Request) (*http.Response, error) {
response, err := t.base.RoundTrip(request)
if err != nil {
return nil, err
}
response.Body = &oidcLimitedReadCloser{body: response.Body, remaining: maxOIDCResponseBytes}
return response, nil
}
type oidcLimitedReadCloser struct {
body io.ReadCloser
remaining int64
}
func (r *oidcLimitedReadCloser) Read(buffer []byte) (int, error) {
if r.remaining == 0 {
var extra [1]byte
if count, err := r.body.Read(extra[:]); count > 0 {
return 0, errOIDCResponseTooLarge
} else {
return 0, err
}
}
if int64(len(buffer)) > r.remaining {
buffer = buffer[:r.remaining]
}
count, err := r.body.Read(buffer)
r.remaining -= int64(count)
return count, err
}
func (r *oidcLimitedReadCloser) Close() error {
return r.body.Close()
}
func normalizedScopes(scopes []string) []string {
result := make([]string, 0, len(scopes)+1)
seen := map[string]struct{}{}
for _, scope := range append([]string{"openid"}, scopes...) {
for _, scope := range append([]string{oidc.ScopeOpenID}, scopes...) {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
+27 -10
View File
@@ -2,6 +2,8 @@ package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
@@ -12,24 +14,31 @@ import (
)
func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) {
const pkceVerifier = "test-pkce-verifier-with-at-least-43-characters-1234"
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "revocation_endpoint": issuer + "/revoke",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
})
case "/token":
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("client_secret") != "" || strings.Contains(r.Header.Get("Authorization"), "Basic") {
t.Fatal("public client token request must not contain client credentials")
t.Error("public client token request must not contain client credentials")
http.Error(w, "invalid client authentication", http.StatusBadRequest)
return
}
if values.Get("grant_type") != "authorization_code" || values.Get("client_id") != "gateway-public" || values.Get("code_verifier") != "verifier" {
t.Fatalf("unexpected token request: %v", values)
if values.Get("grant_type") != "authorization_code" || values.Get("client_id") != "gateway-public" || values.Get("code_verifier") != pkceVerifier {
t.Error("token request omitted authorization code, public client id, or PKCE verifier")
http.Error(w, "invalid token request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-token", "refresh_token": "refresh-token",
"id_token": "id-token", "expires_in": 300, "token_type": "Bearer",
@@ -48,16 +57,18 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
if err != nil {
t.Fatal(err)
}
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", "challenge")
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier)
if err != nil {
t.Fatal(err)
}
parsed, _ := url.Parse(authorizationURL)
query := parsed.Query()
if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != "challenge" {
digest := sha256.Sum256([]byte(pkceVerifier))
expectedChallenge := base64.RawURLEncoding.EncodeToString(digest[:])
if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != expectedChallenge {
t.Fatalf("authorization request is not PKCE S256: %v", query)
}
if _, err := client.ExchangeCode(context.Background(), "authorization-code", "verifier"); err != nil {
if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil {
t.Fatal(err)
}
}
@@ -82,7 +93,8 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "revocation_endpoint": issuer + "/revoke",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
})
case "/token", "/revoke":
@@ -90,12 +102,17 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
t.Fatal("public client request contained client authentication")
t.Error("public client request contained client authentication")
http.Error(w, "invalid client authentication", http.StatusBadRequest)
return
}
if r.URL.Path == "/token" {
if values.Get("grant_type") != "refresh_token" || values.Get("refresh_token") != "old-refresh" {
t.Fatalf("unexpected refresh request: %v", values)
t.Error("refresh request omitted grant type or refresh token")
http.Error(w, "invalid refresh request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 300})
return
}
@@ -86,6 +86,7 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
// The nonce is retained from the authorization request by this test issuer.
claims["nonce"] = currentOIDCTestNonce
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": accessToken, "refresh_token": "opaque-test-refresh-token", "id_token": idToken,
"token_type": "Bearer", "expires_in": 300,
+2 -2
View File
@@ -48,7 +48,7 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if returnTo == "" {
returnTo = "/"
}
transaction, challenge, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
transaction, _, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return
@@ -59,7 +59,7 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, challenge)
authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier)
if err != nil {
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")