Extend OAuth2 CIMD for compliance portal clients

Teach CIMD registration and discovery about per-portal client
metadata, and carry portal context through token and ID token
issuance for downstream session creation.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-15 10:58:53 +02:00
parent ec91106063
commit 6d5217ae6e
8 changed files with 539 additions and 53 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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())
},
)
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -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)
},
)
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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()