-
Notifications
You must be signed in to change notification settings - Fork 2
feat(service): add oauth2_token_endpoint outbound auth provider #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
816e957
c52d48c
86723c2
8e6c2b9
8d17a05
4db4568
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,125 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Copyright (c) 2026 Lerian Studio. All rights reserved. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Use of this source code is governed by the Elastic License 2.0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // that can be found in the LICENSE file. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package auth | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // reservedOAuth2ExtraParams holds keys that cannot appear in extra_params. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Prevents extra_params from silently overriding canonical fields (client_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // client_secret) or smuggling alternate scope/audience values that don't match | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the validated config. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var reservedOAuth2ExtraParams = map[string]bool{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "client_id": true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "client_secret": true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "scope": true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "audience": true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // OAuth2TokenEndpointProvider provides OAuth2 authentication against a custom | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // token endpoint (no OIDC discovery). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type OAuth2TokenEndpointProvider struct { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config *OAuth2TokenEndpointConfig | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cacheConfig *CacheCfg | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tokenFetcher *TokenFetcher | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cacheKey string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // NewOAuth2TokenEndpointProvider creates a new OAuth2 token endpoint provider. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Returns an error if cfg is nil, required fields are missing, credentials_location | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // is invalid, or extra_params contains reserved keys. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func NewOAuth2TokenEndpointProvider(cfg *OAuth2TokenEndpointConfig, cacheCfg *CacheCfg, httpClient *http.Client) (*OAuth2TokenEndpointProvider, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cfg == nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, ErrOAuth2TokenEndpointConfigRequired | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cfg.TokenURL == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, ErrOAuth2TokenEndpointURLRequired | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cfg.ClientID == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, ErrOAuth2TokenEndpointClientRequired | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cfg.ClientSecret == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, ErrOAuth2TokenEndpointSecretRequired | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cfg.CredentialsLocation != "" && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cfg.CredentialsLocation != "body" && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cfg.CredentialsLocation != "basic_header" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, ErrOAuth2TokenEndpointLocationInvalid | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for k := range cfg.ExtraParams { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if reservedOAuth2ExtraParams[k] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, ErrOAuth2TokenEndpointReservedExtraParam | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // NOTE: credentials_location default ("body") is NOT mutated on the caller's | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // *cfg here — that would surprise callers who reuse the struct or whose | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // config originates from a shared/cached source. Instead, the empty value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // is treated as "body" wherever it is consumed (buildOAuth2TokenEndpointData | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // and fetchNewOAuth2TokenEndpointToken). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cacheCfg == nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cacheCfg = &CacheCfg{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Enabled: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| RefreshBeforeExpirySeconds: 60, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Distinct prefix "oauth2te" to avoid collision with OIDC ("cc", "user"). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cacheKey := generateCacheKey("oauth2te", cfg.TokenURL, cfg.ClientID, cfg.Scopes) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+79
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache key is missing auth-shaping fields, causing token reuse collisions. At Line [80], the key excludes Suggested fix- cacheKey := generateCacheKey("oauth2te", cfg.TokenURL, cfg.ClientID, cfg.Scopes)
+ cacheKey := generateCacheKey(
+ "oauth2te",
+ cfg.TokenURL,
+ cfg.ClientID,
+ cfg.CredentialsLocation,
+ cfg.Scopes,
+ cfg.Audience,
+ cfg.ExtraParams,
+ )🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return &OAuth2TokenEndpointProvider{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config: cfg, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cacheConfig: cacheCfg, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tokenFetcher: NewTokenFetcher(httpClient, nil), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cacheKey: cacheKey, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+82
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Provider keeps a mutable caller config pointer; clone it to avoid races and drift. At Lines [82]-[87], Suggested fix+func cloneOAuth2TokenEndpointConfig(in *OAuth2TokenEndpointConfig) *OAuth2TokenEndpointConfig {
+ if in == nil {
+ return nil
+ }
+ out := *in
+ if in.Scopes != nil {
+ out.Scopes = append([]string(nil), in.Scopes...)
+ }
+ if in.ExtraParams != nil {
+ out.ExtraParams = make(map[string]string, len(in.ExtraParams))
+ for k, v := range in.ExtraParams {
+ out.ExtraParams[k] = v
+ }
+ }
+ return &out
+}
+
func NewOAuth2TokenEndpointProvider(cfg *OAuth2TokenEndpointConfig, cacheCfg *CacheCfg, httpClient *http.Client) (*OAuth2TokenEndpointProvider, error) {
@@
return &OAuth2TokenEndpointProvider{
- config: cfg,
+ config: cloneOAuth2TokenEndpointConfig(cfg),
cacheConfig: cacheCfg,
tokenFetcher: NewTokenFetcher(httpClient, nil),
cacheKey: cacheKey,
}, nil
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Apply implements Provider interface. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (p *OAuth2TokenEndpointProvider) Apply(ctx context.Context, req *http.Request) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| token, err := p.tokenFetcher.FetchOAuth2TokenEndpointToken(ctx, p.config, p.cacheKey, p.cacheConfig) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("fetch oauth2 token endpoint token: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // token_type allowlist: Bearer only (case-insensitive). Rejects Basic/MAC/DPoP | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // to prevent a malicious token endpoint from forcing a non-Bearer scheme that | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // would expose the access token via an unintended Authorization header form. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if token.TokenType != "" && !strings.EqualFold(token.TokenType, "Bearer") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ErrOAuth2TokenEndpointUnsupportedTokenType | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Empty access_token guard: a token endpoint returning 200 OK with an | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // empty access_token would otherwise produce the literal header | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // "Authorization: Bearer " on the downstream request, which most servers | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // either accept as an unauthenticated call or surface as a misleading 401. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Fail fast at fetch time so the caller sees the actual contract | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // violation. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if token.AccessToken == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ErrOAuth2TokenEndpointEmptyAccessToken | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| req.Header.Set("Authorization", "Bearer "+token.AccessToken) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Type implements Provider interface. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (p *OAuth2TokenEndpointProvider) Type() Type { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return TypeOAuth2TokenEndpoint | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Verify OAuth2TokenEndpointProvider implements Provider interface. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var _ Provider = (*OAuth2TokenEndpointProvider)(nil) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Use
pkg/errors.govalidation types for parser failures.parseOAuth2TokenEndpointConfigcurrently returns auth-local sentinel errors; this diverges from the repository’s typed error contract. Please map these validation failures toValidationError(with field-level context) for consistent downstream handling.As per coding guidelines, Use custom error types from
pkg/errors.go(EntityNotFoundError, ValidationError) for error handling.🤖 Prompt for AI Agents