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
}