diff --git a/apps/api/go.mod b/apps/api/go.mod index 79cc835..e4b659b 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -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 diff --git a/apps/api/go.sum b/apps/api/go.sum index 5ae9733..7db61cb 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -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= diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index ff08794..149c135 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -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 diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index 8e0aae9..9b0d564 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -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 } diff --git a/apps/api/internal/httpapi/oidc_jit_integration_test.go b/apps/api/internal/httpapi/oidc_jit_integration_test.go index 5e7e988..2aa7b37 100644 --- a/apps/api/internal/httpapi/oidc_jit_integration_test.go +++ b/apps/api/internal/httpapi/oidc_jit_integration_test.go @@ -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, diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index e4b86e5..c777be8 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -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") diff --git a/go.work b/go.work index 12fa579..a8031bc 100644 --- a/go.work +++ b/go.work @@ -1,3 +1,3 @@ -go 1.23 +go 1.25.0 use ./apps/api diff --git a/go.work.sum b/go.work.sum index ed3515e..197da72 100644 --- a/go.work.sum +++ b/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=