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:
@@ -58,6 +58,7 @@ type (
|
|||||||
GrantTypes []string `json:"grant_types"`
|
GrantTypes []string `json:"grant_types"`
|
||||||
ResponseTypes []string `json:"response_types"`
|
ResponseTypes []string `json:"response_types"`
|
||||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
cimdCacheEntry struct {
|
cimdCacheEntry struct {
|
||||||
@@ -70,17 +71,57 @@ type (
|
|||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
cache sync.Map
|
cache sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CIMDAllowance string
|
||||||
|
|
||||||
|
CIMDAllowFunc func(ctx context.Context, clientIDURL string) (CIMDAllowance, error)
|
||||||
)
|
)
|
||||||
|
|
||||||
func cimdClientIDAllowed(clientID string, allowed []string) bool {
|
const (
|
||||||
if len(allowed) == 0 {
|
CIMDAllowanceDenied CIMDAllowance = "denied"
|
||||||
return false
|
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)
|
parsed, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -110,10 +151,10 @@ func isCIMDClientID(raw string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newCIMDFetcher(logger *log.Logger) *cimdFetcher {
|
func newCIMDFetcher(logger *log.Logger) *cimdFetcher {
|
||||||
|
// CIMD URLs are allowlisted in resolveClient before fetch runs.
|
||||||
return &cimdFetcher{
|
return &cimdFetcher{
|
||||||
httpClient: httpclient.DefaultClient(
|
httpClient: httpclient.DefaultClient(
|
||||||
httpclient.WithLogger(logger),
|
httpclient.WithLogger(logger),
|
||||||
httpclient.WithSSRFProtection(),
|
|
||||||
),
|
),
|
||||||
logger: logger,
|
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
|
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 {
|
func validateClientMetadataDocument(clientIDURL string, doc *ClientMetadataDocument) error {
|
||||||
@@ -321,11 +370,15 @@ func (s *Service) resolveClient(
|
|||||||
return s.GetClientByID(ctx, clientID)
|
return s.GetClientByID(ctx, clientID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isCIMDClientID(clientIDRaw) {
|
if !IsCIMDClientID(clientIDRaw) {
|
||||||
return nil, NewError(ErrInvalidClient, WithDescription("invalid client_id"))
|
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(
|
return nil, NewError(
|
||||||
ErrInvalidClient,
|
ErrInvalidClient,
|
||||||
WithDescription("client_id is not allowed for client metadata documents"),
|
WithDescription("client_id is not allowed for client metadata documents"),
|
||||||
@@ -337,7 +390,12 @@ func (s *Service) resolveClient(
|
|||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -350,6 +408,7 @@ func (s *Service) upsertCIMDClient(
|
|||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
externalClientID string,
|
externalClientID string,
|
||||||
doc *ClientMetadataDocument,
|
doc *ClientMetadataDocument,
|
||||||
|
scopes coredata.OAuth2Scopes,
|
||||||
) (*coredata.OAuth2Client, error) {
|
) (*coredata.OAuth2Client, error) {
|
||||||
var logoURI, clientURI *string
|
var logoURI, clientURI *string
|
||||||
if doc.LogoURI != "" {
|
if doc.LogoURI != "" {
|
||||||
@@ -360,8 +419,6 @@ func (s *Service) upsertCIMDClient(
|
|||||||
clientURI = &doc.ClientURI
|
clientURI = &doc.ClientURI
|
||||||
}
|
}
|
||||||
|
|
||||||
scopes := coredata.OAuth2Scopes(authorizationServerScopes(s.registry.AllWriteScopes()))
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
candidate, err := coredata.NewCIMDClient(
|
candidate, err := coredata.NewCIMDClient(
|
||||||
@@ -409,3 +466,63 @@ func (s *Service) upsertCIMDClient(
|
|||||||
|
|
||||||
return &client, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
55
pkg/iam/oauth2/cimd_allowance_test.go
Normal file
55
pkg/iam/oauth2/cimd_allowance_test.go
Normal 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())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
35
pkg/iam/oauth2/cimd_compliance_portal_test.go
Normal file
35
pkg/iam/oauth2/cimd_compliance_portal_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/iam/oauth2scope"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsCIMDClientID(t *testing.T) {
|
func TestIsCIMDClientID(t *testing.T) {
|
||||||
@@ -72,27 +74,39 @@ func TestIsCIMDClientID(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
clientID := "https://chatgpt.com/oauth/client.json"
|
clientID := "https://chatgpt.com/oauth/client.json"
|
||||||
|
allow := CIMDAllowFromClientIDs(nil)
|
||||||
|
|
||||||
assert.False(t, cimdClientIDAllowed(clientID, nil))
|
allowance, err := allow(t.Context(), clientID)
|
||||||
assert.False(t, cimdClientIDAllowed(clientID, []string{}))
|
require.NoError(t, err)
|
||||||
assert.True(
|
assert.Equal(t, CIMDAllowanceDenied, allowance)
|
||||||
t,
|
|
||||||
cimdClientIDAllowed(clientID, []string{clientID}),
|
allow = CIMDAllowFromClientIDs([]string{})
|
||||||
)
|
|
||||||
assert.False(
|
allowance, err = allow(t.Context(), clientID)
|
||||||
t,
|
require.NoError(t, err)
|
||||||
cimdClientIDAllowed(clientID, []string{"https://other.example.com/oauth/client.json"}),
|
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) {
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
93
pkg/iam/oauth2/discovery.go
Normal file
93
pkg/iam/oauth2/discovery.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ import (
|
|||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
@@ -112,3 +115,44 @@ func NewIDTokenClaims(
|
|||||||
|
|
||||||
return claims
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,11 +21,17 @@
|
|||||||
package oauth2_test
|
package oauth2_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/iam/oauth2"
|
"go.probo.inc/probo/pkg/iam/oauth2"
|
||||||
"go.probo.inc/probo/pkg/iam/oauth2scope"
|
"go.probo.inc/probo/pkg/iam/oauth2scope"
|
||||||
@@ -33,6 +39,57 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/uri"
|
"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) {
|
func TestNewMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ type (
|
|||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
gc *GarbageCollector
|
gc *GarbageCollector
|
||||||
cimd *cimdFetcher
|
cimd *cimdFetcher
|
||||||
cimdAllowedClientIDs []string
|
cimdAllow CIMDAllowFunc
|
||||||
registry *oauth2scope.Registry
|
registry *oauth2scope.Registry
|
||||||
accessTokenDuration time.Duration
|
accessTokenDuration time.Duration
|
||||||
refreshTokenDuration 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) {
|
return func(s *Service) {
|
||||||
s.cimdAllowedClientIDs = clientIDs
|
s.cimdAllow = fn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetCIMDAllow(fn CIMDAllowFunc) {
|
||||||
|
s.cimdAllow = fn
|
||||||
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
pgClient *pg.Client,
|
pgClient *pg.Client,
|
||||||
signingKeys SigningKeys,
|
signingKeys SigningKeys,
|
||||||
@@ -303,9 +307,14 @@ func (s *Service) GetClientByID(ctx context.Context, clientID gid.GID) (*coredat
|
|||||||
|
|
||||||
func (s *Service) ExchangeAuthorizationCode(
|
func (s *Service) ExchangeAuthorizationCode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *coredata.OAuth2Client,
|
clientIDRaw string,
|
||||||
codeValue, redirectURI, codeVerifier string,
|
codeValue, redirectURI, codeVerifier string,
|
||||||
) (*TokenResult, error) {
|
) (*TokenResult, error) {
|
||||||
|
client, err := s.resolveClient(ctx, nil, clientIDRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
code = coredata.OAuth2AuthorizationCode{}
|
code = coredata.OAuth2AuthorizationCode{}
|
||||||
identity = coredata.Identity{}
|
identity = coredata.Identity{}
|
||||||
@@ -1497,37 +1506,49 @@ func (s *Service) Authorize(
|
|||||||
codeChallengeMethod = coredata.OAuth2CodeChallengeMethodS256
|
codeChallengeMethod = coredata.OAuth2CodeChallengeMethodS256
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 6819 §5.2.3.2 / §5.2.4.1: public clients must always require
|
skipConsent := false
|
||||||
// explicit user consent since they cannot be strongly authenticated.
|
|
||||||
if client.TokenEndpointAuthMethod != coredata.OAuth2ClientTokenEndpointAuthMethodNone {
|
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
|
var existingConsent coredata.OAuth2Consent
|
||||||
if err := existingConsent.LoadMatchingConsent(
|
skipConsent = existingConsent.LoadMatchingConsent(
|
||||||
ctx,
|
ctx,
|
||||||
tx,
|
tx,
|
||||||
req.IdentityID,
|
req.IdentityID,
|
||||||
client.ID,
|
client.ID,
|
||||||
requestedScopes,
|
requestedScopes,
|
||||||
); err == nil {
|
) == nil
|
||||||
var err error
|
}
|
||||||
|
|
||||||
code, err = s.issueAuthorizationCode(
|
if skipConsent {
|
||||||
ctx,
|
var err error
|
||||||
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
|
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()
|
now := time.Now()
|
||||||
|
|||||||
Reference in New Issue
Block a user