refactor: 使用标准库实现 OIDC 客户端流程
This commit is contained in:
parent
8ca68eb3cd
commit
053bc260c7
@ -1,21 +1,24 @@
|
||||
module github.com/easyai/easyai-ai-gateway/apps/api
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/riverqueue/river v0.24.0
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0
|
||||
github.com/riverqueue/river/rivertype v0.24.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -7,6 +11,8 @@ github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwW
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
@ -63,6 +69,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
@ -70,6 +78,8 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
18
go.work.sum
18
go.work.sum
@ -1,12 +1,18 @@
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
Loading…
Reference in New Issue
Block a user