diff --git a/pkg/iam/oauth2/cimd.go b/pkg/iam/oauth2/cimd.go index 59cd7cf3c..9e8e34998 100644 --- a/pkg/iam/oauth2/cimd.go +++ b/pkg/iam/oauth2/cimd.go @@ -58,6 +58,7 @@ type ( GrantTypes []string `json:"grant_types"` ResponseTypes []string `json:"response_types"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope,omitempty"` } cimdCacheEntry struct { @@ -70,17 +71,57 @@ type ( logger *log.Logger cache sync.Map } + + CIMDAllowance string + + CIMDAllowFunc func(ctx context.Context, clientIDURL string) (CIMDAllowance, error) ) -func cimdClientIDAllowed(clientID string, allowed []string) bool { - if len(allowed) == 0 { - return false - } +const ( + CIMDAllowanceDenied CIMDAllowance = "denied" + CIMDAllowanceAllowed CIMDAllowance = "allowed" + CIMDAllowanceAllowedSkipConsent CIMDAllowance = "allowed_skip_consent" +) - return slices.Contains(allowed, clientID) +func (a CIMDAllowance) Allowed() bool { + return a != CIMDAllowanceDenied } -func isCIMDClientID(raw string) bool { +func (a CIMDAllowance) SkipsConsent() bool { + return a == CIMDAllowanceAllowedSkipConsent +} + +func CIMDAllowFromClientIDs(clientIDs []string) CIMDAllowFunc { + allowed := slices.Clone(clientIDs) + + return func(_ context.Context, clientIDURL string) (CIMDAllowance, error) { + if slices.Contains(allowed, clientIDURL) { + return CIMDAllowanceAllowed, nil + } + + return CIMDAllowanceDenied, nil + } +} + +func CIMDClientIDHost(raw string) (string, bool) { + if !IsCIMDClientID(raw) { + return "", false + } + + parsed, err := url.Parse(raw) + if err != nil { + return "", false + } + + host := parsed.Hostname() + if host == "" { + return "", false + } + + return host, true +} + +func IsCIMDClientID(raw string) bool { parsed, err := url.Parse(raw) if err != nil { return false @@ -110,10 +151,10 @@ func isCIMDClientID(raw string) bool { } func newCIMDFetcher(logger *log.Logger) *cimdFetcher { + // CIMD URLs are allowlisted in resolveClient before fetch runs. return &cimdFetcher{ httpClient: httpclient.DefaultClient( httpclient.WithLogger(logger), - httpclient.WithSSRFProtection(), ), logger: logger, } @@ -171,13 +212,21 @@ func (f *cimdFetcher) fetch(ctx context.Context, clientIDURL string) (*ClientMet ) } - if err := validateClientMetadataDocument(clientIDURL, &doc); err != nil { + return f.finishFetch(clientIDURL, &doc, resp.Header.Get("Cache-Control")) +} + +func (f *cimdFetcher) finishFetch( + clientIDURL string, + doc *ClientMetadataDocument, + cacheControl string, +) (*ClientMetadataDocument, error) { + if err := validateClientMetadataDocument(clientIDURL, doc); err != nil { return nil, err } - f.storeCache(clientIDURL, &doc, resp.Header.Get("Cache-Control")) + f.storeCache(clientIDURL, doc, cacheControl) - return &doc, nil + return doc, nil } func validateClientMetadataDocument(clientIDURL string, doc *ClientMetadataDocument) error { @@ -321,11 +370,15 @@ func (s *Service) resolveClient( return s.GetClientByID(ctx, clientID) } - if !isCIMDClientID(clientIDRaw) { + if !IsCIMDClientID(clientIDRaw) { return nil, NewError(ErrInvalidClient, WithDescription("invalid client_id")) } - if !cimdClientIDAllowed(clientIDRaw, s.cimdAllowedClientIDs) { + if allowance, err := s.cimdAllowance(ctx, clientIDRaw); err != nil || !allowance.Allowed() { + if err != nil { + s.logger.WarnCtx(ctx, "cannot check cimd client allowance", log.Error(err)) + } + return nil, NewError( ErrInvalidClient, WithDescription("client_id is not allowed for client metadata documents"), @@ -337,7 +390,12 @@ func (s *Service) resolveClient( return nil, err } - client, err := s.upsertCIMDClient(ctx, tx, clientIDRaw, doc) + scopes, err := s.cimdScopes(doc) + if err != nil { + return nil, err + } + + client, err := s.upsertCIMDClient(ctx, tx, clientIDRaw, doc, scopes) if err != nil { return nil, err } @@ -350,6 +408,7 @@ func (s *Service) upsertCIMDClient( tx pg.Tx, externalClientID string, doc *ClientMetadataDocument, + scopes coredata.OAuth2Scopes, ) (*coredata.OAuth2Client, error) { var logoURI, clientURI *string if doc.LogoURI != "" { @@ -360,8 +419,6 @@ func (s *Service) upsertCIMDClient( clientURI = &doc.ClientURI } - scopes := coredata.OAuth2Scopes(authorizationServerScopes(s.registry.AllWriteScopes())) - now := time.Now() candidate, err := coredata.NewCIMDClient( @@ -409,3 +466,63 @@ func (s *Service) upsertCIMDClient( return &client, nil } + +func (s *Service) cimdAllowance(ctx context.Context, clientIDRaw string) (CIMDAllowance, error) { + if !IsCIMDClientID(clientIDRaw) { + return CIMDAllowanceDenied, nil + } + + if s.cimdAllow == nil { + return CIMDAllowanceDenied, nil + } + + return s.cimdAllow(ctx, clientIDRaw) +} + +func (s *Service) cimdScopes(doc *ClientMetadataDocument) (coredata.OAuth2Scopes, error) { + if strings.TrimSpace(doc.Scope) == "" { + return coredata.OAuth2Scopes(authorizationServerScopes(s.registry.AllWriteScopes())), nil + } + + scopes, err := parseCIMDMetadataScopes(doc.Scope) + if err != nil { + return nil, NewError(ErrInvalidScope, WithDescription(err.Error())) + } + + if err := s.validateCIMDScopes(scopes); err != nil { + return nil, err + } + + return scopes, nil +} + +func parseCIMDMetadataScopes(raw string) (coredata.OAuth2Scopes, error) { + fields := strings.Fields(strings.TrimSpace(raw)) + if len(fields) == 0 { + return nil, nil + } + + scopes := make(coredata.OAuth2Scopes, len(fields)) + for i, field := range fields { + scopes[i] = coredata.OAuth2Scope(field) + } + + return scopes, nil +} + +func (s *Service) validateCIMDScopes(scopes coredata.OAuth2Scopes) error { + for _, scope := range scopes { + if IsStandardScope(scope) { + continue + } + + if err := s.registry.ValidateScopes(coredata.OAuth2Scopes{scope}); err != nil { + return NewError( + ErrInvalidScope, + WithDescription(fmt.Sprintf("invalid scope in client metadata document: %s", scope)), + ) + } + } + + return nil +} diff --git a/pkg/iam/oauth2/cimd_allowance_test.go b/pkg/iam/oauth2/cimd_allowance_test.go new file mode 100644 index 000000000..030ccf5c7 --- /dev/null +++ b/pkg/iam/oauth2/cimd_allowance_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package oauth2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCIMDAllowance(t *testing.T) { + t.Parallel() + + t.Run( + "denied is not allowed and does not skip consent", + func(t *testing.T) { + t.Parallel() + + assert.False(t, CIMDAllowanceDenied.Allowed()) + assert.False(t, CIMDAllowanceDenied.SkipsConsent()) + }, + ) + + t.Run( + "allowed permits client use but still requires consent", + func(t *testing.T) { + t.Parallel() + + assert.True(t, CIMDAllowanceAllowed.Allowed()) + assert.False(t, CIMDAllowanceAllowed.SkipsConsent()) + }, + ) + + t.Run( + "allowed skip consent is the only first-party bypass", + func(t *testing.T) { + t.Parallel() + + assert.True(t, CIMDAllowanceAllowedSkipConsent.Allowed()) + assert.True(t, CIMDAllowanceAllowedSkipConsent.SkipsConsent()) + }, + ) +} diff --git a/pkg/iam/oauth2/cimd_compliance_portal_test.go b/pkg/iam/oauth2/cimd_compliance_portal_test.go new file mode 100644 index 000000000..bbf6f82ea --- /dev/null +++ b/pkg/iam/oauth2/cimd_compliance_portal_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package oauth2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCIMDClientIDHost(t *testing.T) { + t.Parallel() + + host, ok := CIMDClientIDHost("https://portal.example.com/.well-known/oauth-client-metadata") + assert.True(t, ok) + assert.Equal(t, "portal.example.com", host) + + _, ok = CIMDClientIDHost("https://chatgpt.com/oauth/client.json") + assert.True(t, ok) + + _, ok = CIMDClientIDHost("not-a-url") + assert.False(t, ok) +} diff --git a/pkg/iam/oauth2/cimd_test.go b/pkg/iam/oauth2/cimd_test.go index 5ecbcdfa0..2c565c3ca 100644 --- a/pkg/iam/oauth2/cimd_test.go +++ b/pkg/iam/oauth2/cimd_test.go @@ -29,6 +29,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2scope" ) func TestIsCIMDClientID(t *testing.T) { @@ -72,27 +74,39 @@ func TestIsCIMDClientID(t *testing.T) { func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.valid, isCIMDClientID(tt.raw)) + assert.Equal(t, tt.valid, IsCIMDClientID(tt.raw)) }, ) } } -func TestCIMDClientIDAllowed(t *testing.T) { +func TestCIMDAllowFromClientIDs(t *testing.T) { t.Parallel() clientID := "https://chatgpt.com/oauth/client.json" + allow := CIMDAllowFromClientIDs(nil) - assert.False(t, cimdClientIDAllowed(clientID, nil)) - assert.False(t, cimdClientIDAllowed(clientID, []string{})) - assert.True( - t, - cimdClientIDAllowed(clientID, []string{clientID}), - ) - assert.False( - t, - cimdClientIDAllowed(clientID, []string{"https://other.example.com/oauth/client.json"}), - ) + allowance, err := allow(t.Context(), clientID) + require.NoError(t, err) + assert.Equal(t, CIMDAllowanceDenied, allowance) + + allow = CIMDAllowFromClientIDs([]string{}) + + allowance, err = allow(t.Context(), clientID) + require.NoError(t, err) + assert.Equal(t, CIMDAllowanceDenied, allowance) + + allow = CIMDAllowFromClientIDs([]string{clientID}) + + allowance, err = allow(t.Context(), clientID) + require.NoError(t, err) + assert.Equal(t, CIMDAllowanceAllowed, allowance) + + allow = CIMDAllowFromClientIDs([]string{"https://other.example.com/oauth/client.json"}) + + allowance, err = allow(t.Context(), clientID) + require.NoError(t, err) + assert.Equal(t, CIMDAllowanceDenied, allowance) } func TestValidateClientMetadataDocument(t *testing.T) { @@ -229,3 +243,53 @@ func TestCIMDFetcherFetch(t *testing.T) { }, ) } + +func TestCIMDScopes(t *testing.T) { + t.Parallel() + + reg := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + coredata.OAuth2Scope("v1:example:write"): {"example:write"}, + }, + ) + svc := &Service{registry: reg} + + t.Run( + "defaults to all scopes when metadata omits scope", + func(t *testing.T) { + t.Parallel() + + scopes, err := svc.cimdScopes(&ClientMetadataDocument{}) + require.NoError(t, err) + assert.Contains(t, scopes, ScopeOpenID) + assert.Contains(t, scopes, coredata.OAuth2Scope("v1:example:write")) + }, + ) + + t.Run( + "uses scope declared in metadata", + func(t *testing.T) { + t.Parallel() + + scopes, err := svc.cimdScopes( + &ClientMetadataDocument{Scope: "openid profile email"}, + ) + require.NoError(t, err) + assert.Equal( + t, + coredata.OAuth2Scopes{ScopeOpenID, ScopeProfile, ScopeEmail}, + scopes, + ) + }, + ) + + t.Run( + "rejects unknown scope in metadata", + func(t *testing.T) { + t.Parallel() + + _, err := svc.cimdScopes(&ClientMetadataDocument{Scope: "admin"}) + require.Error(t, err) + }, + ) +} diff --git a/pkg/iam/oauth2/discovery.go b/pkg/iam/oauth2/discovery.go new file mode 100644 index 000000000..05b94c37d --- /dev/null +++ b/pkg/iam/oauth2/discovery.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package oauth2 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "go.probo.inc/probo/pkg/uri" +) + +const ( + openIDConfigurationPath = "/.well-known/openid-configuration" + maxDiscoveryDocumentBytes int64 = 65536 +) + +func FetchServerMetadata( + ctx context.Context, + client *http.Client, + issuerBaseURL string, +) (*ServerMetadata, error) { + discoveryURL, err := discoveryDocumentURL(issuerBaseURL) + if err != nil { + return nil, fmt.Errorf("cannot build discovery document URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return nil, fmt.Errorf("cannot create discovery request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch discovery document: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("discovery endpoint returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryDocumentBytes)) + if err != nil { + return nil, fmt.Errorf("cannot read discovery document: %w", err) + } + + var metadata ServerMetadata + if err := json.Unmarshal(body, &metadata); err != nil { + return nil, fmt.Errorf("cannot decode discovery document: %w", err) + } + + if metadata.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("discovery document does not advertise an authorization endpoint") + } + + return &metadata, nil +} + +func AuthorizationURLWithQuery( + authorizationEndpoint uri.URI, + query url.Values, +) (string, error) { + u, err := url.Parse(authorizationEndpoint.String()) + if err != nil { + return "", fmt.Errorf("cannot parse authorization endpoint: %w", err) + } + + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func discoveryDocumentURL(issuerBaseURL string) (string, error) { + return url.JoinPath(strings.TrimSuffix(issuerBaseURL, "/"), openIDConfigurationPath) +} diff --git a/pkg/iam/oauth2/id_token.go b/pkg/iam/oauth2/id_token.go index 2de710151..66cf4342a 100644 --- a/pkg/iam/oauth2/id_token.go +++ b/pkg/iam/oauth2/id_token.go @@ -24,6 +24,9 @@ import ( "crypto/rsa" "crypto/sha256" "encoding/base64" + "encoding/json" + "fmt" + "strings" "time" "go.probo.inc/probo/pkg/coredata" @@ -112,3 +115,44 @@ func NewIDTokenClaims( return claims } + +func ParseIDTokenClaims(raw string) (*IDTokenClaims, error) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("cannot parse id token: invalid format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("cannot parse id token payload: %w", err) + } + + var claims IDTokenClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("cannot decode id token claims: %w", err) + } + + return &claims, nil +} + +func ParseIDTokenIdentity(raw string, expectedNonce string) (gid.GID, error) { + if raw == "" { + return gid.GID{}, fmt.Errorf("cannot parse id token: missing token") + } + + claims, err := ParseIDTokenClaims(raw) + if err != nil { + return gid.GID{}, err + } + + if claims.Nonce != expectedNonce { + return gid.GID{}, fmt.Errorf("cannot validate nonce: mismatch") + } + + identityID, err := gid.ParseGID(claims.Subject) + if err != nil { + return gid.GID{}, fmt.Errorf("cannot parse identity from id token: %w", err) + } + + return identityID, nil +} diff --git a/pkg/iam/oauth2/metadata_test.go b/pkg/iam/oauth2/metadata_test.go index f0915d319..a638202f0 100644 --- a/pkg/iam/oauth2/metadata_test.go +++ b/pkg/iam/oauth2/metadata_test.go @@ -21,11 +21,17 @@ package oauth2_test import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/iam/oauth2scope" @@ -33,6 +39,57 @@ import ( "go.probo.inc/probo/pkg/uri" ) +func TestFetchServerMetadata(t *testing.T) { + t.Parallel() + + server := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/.well-known/openid-configuration", r.URL.Path) + + _ = json.NewEncoder(w).Encode( + map[string]string{ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/api/connect/v1/oauth2/authorize", + "token_endpoint": "https://auth.example.com/api/connect/v1/oauth2/token", + }, + ) + }, + ), + ) + t.Cleanup(server.Close) + + client := httpclient.DefaultClient( + httpclient.WithSSRFProtection(), + httpclient.WithSSRFAllowLoopback(), + ) + + metadata, err := oauth2.FetchServerMetadata(context.Background(), client, server.URL) + require.NoError(t, err) + assert.Equal( + t, + uri.URI("https://auth.example.com/api/connect/v1/oauth2/authorize"), + metadata.AuthorizationEndpoint, + ) +} + +func TestAuthorizationURLWithQuery(t *testing.T) { + t.Parallel() + + authorizationEndpoint := uri.URI("https://auth.example.com/api/connect/v1/oauth2/authorize") + query := url.Values{} + query.Set("client_id", "https://trust.example.com/.well-known/oauth-client-metadata") + query.Set("response_type", "code") + + got, err := oauth2.AuthorizationURLWithQuery(authorizationEndpoint, query) + require.NoError(t, err) + assert.Equal( + t, + "https://auth.example.com/api/connect/v1/oauth2/authorize?client_id=https%3A%2F%2Ftrust.example.com%2F.well-known%2Foauth-client-metadata&response_type=code", + got, + ) +} + func TestNewMetadata(t *testing.T) { t.Parallel() diff --git a/pkg/iam/oauth2/service.go b/pkg/iam/oauth2/service.go index e7e05b7f9..d8ff129d5 100644 --- a/pkg/iam/oauth2/service.go +++ b/pkg/iam/oauth2/service.go @@ -68,7 +68,7 @@ type ( logger *log.Logger gc *GarbageCollector cimd *cimdFetcher - cimdAllowedClientIDs []string + cimdAllow CIMDAllowFunc registry *oauth2scope.Registry accessTokenDuration time.Duration refreshTokenDuration time.Duration @@ -171,12 +171,16 @@ func WithRegistry(registry *oauth2scope.Registry) Option { } } -func WithCIMDAllowedClientIDs(clientIDs []string) Option { +func WithCIMDAllow(fn CIMDAllowFunc) Option { return func(s *Service) { - s.cimdAllowedClientIDs = clientIDs + s.cimdAllow = fn } } +func (s *Service) SetCIMDAllow(fn CIMDAllowFunc) { + s.cimdAllow = fn +} + func NewService( pgClient *pg.Client, signingKeys SigningKeys, @@ -303,9 +307,14 @@ func (s *Service) GetClientByID(ctx context.Context, clientID gid.GID) (*coredat func (s *Service) ExchangeAuthorizationCode( ctx context.Context, - client *coredata.OAuth2Client, + clientIDRaw string, codeValue, redirectURI, codeVerifier string, ) (*TokenResult, error) { + client, err := s.resolveClient(ctx, nil, clientIDRaw) + if err != nil { + return nil, err + } + var ( code = coredata.OAuth2AuthorizationCode{} identity = coredata.Identity{} @@ -1497,37 +1506,49 @@ func (s *Service) Authorize( codeChallengeMethod = coredata.OAuth2CodeChallengeMethodS256 } - // RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always require - // explicit user consent since they cannot be strongly authenticated. - if client.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone { + skipConsent := false + + if client.TokenEndpointAuthMethod == coredata.OAuth2ClientTokenEndpointAuthMethodNone { + // RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always + // require explicit user consent since they cannot be strongly + // authenticated. + allowance, allowanceErr := s.cimdAllowance(ctx, client.ExternalClientID) + if allowanceErr != nil { + s.logger.WarnCtx(ctx, "cannot check cimd client allowance", log.Error(allowanceErr)) + } else { + skipConsent = allowance.SkipsConsent() + } + } else { var existingConsent coredata.OAuth2Consent - if err := existingConsent.LoadMatchingConsent( + skipConsent = existingConsent.LoadMatchingConsent( ctx, tx, req.IdentityID, client.ID, requestedScopes, - ); err == nil { - var err error + ) == nil + } - code, err = s.issueAuthorizationCode( - ctx, - tx, - client, - req.IdentityID, - uri.URI(req.RedirectURI), - requestedScopes, - req.CodeChallenge, - codeChallengeMethod, - req.Nonce, - req.AuthTime, - ) - if err != nil { - return fmt.Errorf("cannot issue authorization code: %w", err) - } + if skipConsent { + var err error - return nil + code, err = s.issueAuthorizationCode( + ctx, + tx, + client, + req.IdentityID, + uri.URI(req.RedirectURI), + requestedScopes, + req.CodeChallenge, + codeChallengeMethod, + req.Nonce, + req.AuthTime, + ) + if err != nil { + return fmt.Errorf("cannot issue authorization code: %w", err) } + + return nil } now := time.Now()