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"`
|
||||
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
|
||||
}
|
||||
|
||||
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/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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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/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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user