Add OAuth2 Client ID Metadata Document support
MCP connectors such as ChatGPT and Claude register via HTTPS client_id URLs instead of pre-provisioned GIDs. Fetch and cache their metadata documents, upsert clients on first use, and advertise CIMD in OIDC discovery when allowed URLs are configured. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
393
pkg/iam/oauth2/cimd.go
Normal file
393
pkg/iam/oauth2/cimd.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// 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"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/cachecontrol"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/net"
|
||||
)
|
||||
|
||||
const (
|
||||
cimdMaxDocumentBytes = 5120
|
||||
cimdFetchTimeout = 10 * time.Second
|
||||
cimdDefaultCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
ClientMetadataDocument struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name"`
|
||||
ClientURI string `json:"client_uri"`
|
||||
LogoURI string `json:"logo_uri"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
ResponseTypes []string `json:"response_types"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
}
|
||||
|
||||
cimdCacheEntry struct {
|
||||
document *ClientMetadataDocument
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
cimdFetcher struct {
|
||||
httpClient *http.Client
|
||||
logger *log.Logger
|
||||
cache sync.Map
|
||||
}
|
||||
)
|
||||
|
||||
func cimdClientIDAllowed(clientID string, allowed []string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.Contains(allowed, clientID)
|
||||
}
|
||||
|
||||
func isCIMDClientID(raw string) bool {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
|
||||
if parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if parsed.Path == "" || parsed.Path == "/" {
|
||||
return false
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if parsed.Fragment != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func newCIMDFetcher(logger *log.Logger) *cimdFetcher {
|
||||
return &cimdFetcher{
|
||||
httpClient: httpclient.DefaultClient(
|
||||
httpclient.WithLogger(logger),
|
||||
httpclient.WithSSRFProtection(),
|
||||
),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *cimdFetcher) fetch(ctx context.Context, clientIDURL string) (*ClientMetadataDocument, error) {
|
||||
if entry, ok := f.loadCache(clientIDURL); ok {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, cimdFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, clientIDURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create cimd request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("cannot fetch client metadata document"),
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document unavailable"),
|
||||
)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, cimdMaxDocumentBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read cimd response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) > cimdMaxDocumentBytes {
|
||||
return nil, NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document too large"),
|
||||
)
|
||||
}
|
||||
|
||||
var doc ClientMetadataDocument
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document is not valid JSON"),
|
||||
)
|
||||
}
|
||||
|
||||
if err := validateClientMetadataDocument(clientIDURL, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.storeCache(clientIDURL, &doc, resp.Header.Get("Cache-Control"))
|
||||
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func validateClientMetadataDocument(clientIDURL string, doc *ClientMetadataDocument) error {
|
||||
if doc.ClientID != clientIDURL {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata client_id does not match document URL"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(doc.ClientName) == "" {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document missing client_name"),
|
||||
)
|
||||
}
|
||||
|
||||
if len(doc.RedirectURIs) == 0 {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document missing redirect_uris"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, redirectURI := range doc.RedirectURIs {
|
||||
parsed, err := url.Parse(redirectURI)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client metadata document contains invalid redirect_uri"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
authMethod := doc.TokenEndpointAuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = string(coredata.OAuth2ClientTokenEndpointAuthMethodNone)
|
||||
}
|
||||
|
||||
switch coredata.OAuth2ClientTokenEndpointAuthMethod(authMethod) {
|
||||
case coredata.OAuth2ClientTokenEndpointAuthMethodNone:
|
||||
// Public MCP clients (ChatGPT, Claude) authenticate with PKCE.
|
||||
default:
|
||||
return NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("unsupported token_endpoint_auth_method in client metadata document"),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cimdRedirectURIAllowed(doc *ClientMetadataDocument, redirectURI string) bool {
|
||||
for _, allowed := range doc.RedirectURIs {
|
||||
if redirectURI == allowed {
|
||||
return true
|
||||
}
|
||||
|
||||
if cimdLoopbackRedirectMatches(allowed, redirectURI) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func cimdLoopbackRedirectMatches(registered, requested string) bool {
|
||||
registeredURL, err := url.Parse(registered)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
requestedURL, err := url.Parse(requested)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if registeredURL.Scheme != requestedURL.Scheme {
|
||||
return false
|
||||
}
|
||||
|
||||
if !net.IsLoopback(registeredURL.Hostname()) || !net.IsLoopback(requestedURL.Hostname()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if registeredURL.Hostname() != requestedURL.Hostname() {
|
||||
return false
|
||||
}
|
||||
|
||||
return registeredURL.Path == requestedURL.Path &&
|
||||
registeredURL.RawQuery == requestedURL.RawQuery
|
||||
}
|
||||
|
||||
func (f *cimdFetcher) loadCache(clientIDURL string) (*ClientMetadataDocument, bool) {
|
||||
raw, ok := f.cache.Load(clientIDURL)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
entry := raw.(cimdCacheEntry)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
f.cache.Delete(clientIDURL)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.document, true
|
||||
}
|
||||
|
||||
func (f *cimdFetcher) storeCache(clientIDURL string, doc *ClientMetadataDocument, cacheControl string) {
|
||||
ttl := cimdDefaultCacheTTL
|
||||
if dir, err := cachecontrol.ParseResponse(cacheControl); err == nil {
|
||||
if maxAge, ok := dir.MaxAgeDuration(); ok {
|
||||
ttl = min(ttl, maxAge)
|
||||
}
|
||||
}
|
||||
|
||||
f.cache.Store(
|
||||
clientIDURL,
|
||||
cimdCacheEntry{
|
||||
document: doc,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveClient(
|
||||
ctx context.Context,
|
||||
clientIDRaw string,
|
||||
redirectURI string,
|
||||
) (*coredata.OAuth2Client, error) {
|
||||
if clientID, err := gid.ParseGID(clientIDRaw); err == nil {
|
||||
return s.GetClientByID(ctx, clientID)
|
||||
}
|
||||
|
||||
if !isCIMDClientID(clientIDRaw) {
|
||||
return nil, NewError(ErrInvalidClient, WithDescription("invalid client_id"))
|
||||
}
|
||||
|
||||
if !cimdClientIDAllowed(clientIDRaw, s.cimdAllowedClientIDs) {
|
||||
return nil, NewError(
|
||||
ErrInvalidClient,
|
||||
WithDescription("client_id is not allowed for client metadata documents"),
|
||||
)
|
||||
}
|
||||
|
||||
doc, err := s.cimd.fetch(ctx, clientIDRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if redirectURI != "" && !cimdRedirectURIAllowed(doc, redirectURI) {
|
||||
return nil, ErrInvalidRedirectURI
|
||||
}
|
||||
|
||||
client, err := s.upsertCIMDClient(ctx, clientIDRaw, doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *Service) upsertCIMDClient(
|
||||
ctx context.Context,
|
||||
externalClientID string,
|
||||
doc *ClientMetadataDocument,
|
||||
) (*coredata.OAuth2Client, error) {
|
||||
var logoURI, clientURI *string
|
||||
if doc.LogoURI != "" {
|
||||
logoURI = &doc.LogoURI
|
||||
}
|
||||
|
||||
if doc.ClientURI != "" {
|
||||
clientURI = &doc.ClientURI
|
||||
}
|
||||
|
||||
scopes := slices.Concat(
|
||||
[]coredata.OAuth2Scope{
|
||||
ScopeOpenID,
|
||||
ScopeProfile,
|
||||
ScopeEmail,
|
||||
ScopeOfflineAccess,
|
||||
},
|
||||
s.apiScopes,
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
candidate, err := coredata.NewCIMDClient(
|
||||
externalClientID,
|
||||
doc.ClientName,
|
||||
doc.RedirectURIs,
|
||||
scopes,
|
||||
logoURI,
|
||||
clientURI,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build cimd client: %w", err)
|
||||
}
|
||||
|
||||
var client coredata.OAuth2Client
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
client = *candidate
|
||||
|
||||
if err := client.UpsertCIMD(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot upsert cimd oauth2 client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
177
pkg/iam/oauth2/cimd_test.go
Normal file
177
pkg/iam/oauth2/cimd_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func TestIsCIMDClientID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "https with path",
|
||||
raw: "https://chatgpt.com/oauth/client.json",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "gid is not cimd",
|
||||
raw: "AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "http rejected",
|
||||
raw: "http://chatgpt.com/oauth/client.json",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "https root path rejected",
|
||||
raw: "https://chatgpt.com/",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "fragment rejected",
|
||||
raw: "https://chatgpt.com/oauth/client.json#frag",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.valid, isCIMDClientID(tt.raw))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIMDClientIDAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID := "https://chatgpt.com/oauth/client.json"
|
||||
|
||||
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"}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestValidateClientMetadataDocument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID := "https://mcp.example.com/oauth/metadata.json"
|
||||
doc := ClientMetadataDocument{
|
||||
ClientID: clientID,
|
||||
ClientName: "Example MCP",
|
||||
RedirectURIs: []string{"https://mcp.example.com/callback"},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
}
|
||||
|
||||
require.NoError(t, validateClientMetadataDocument(clientID, &doc))
|
||||
|
||||
t.Run(
|
||||
"mismatched client_id",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bad := doc
|
||||
bad.ClientID = "https://other.example.com/oauth/metadata.json"
|
||||
|
||||
err := validateClientMetadataDocument(clientID, &bad)
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestCIMDRedirectURIAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := &ClientMetadataDocument{
|
||||
RedirectURIs: []string{
|
||||
"https://chatgpt.com/connector/oauth/callback",
|
||||
"http://127.0.0.1:53682/callback",
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(
|
||||
t,
|
||||
cimdRedirectURIAllowed(doc, "https://chatgpt.com/connector/oauth/callback"),
|
||||
)
|
||||
assert.True(
|
||||
t,
|
||||
cimdRedirectURIAllowed(doc, "http://127.0.0.1:8080/callback"),
|
||||
)
|
||||
assert.False(
|
||||
t,
|
||||
cimdRedirectURIAllowed(doc, "https://evil.example/callback"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestCIMDFetcherFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := ClientMetadataDocument{
|
||||
ClientName: "Test MCP Client",
|
||||
RedirectURIs: []string{"http://127.0.0.1:3000/callback"},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
}
|
||||
|
||||
server := httptest.NewTLSServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=60")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
},
|
||||
),
|
||||
)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientID := server.URL + "/oauth/client.json"
|
||||
doc.ClientID = clientID
|
||||
|
||||
fetcher := &cimdFetcher{
|
||||
httpClient: server.Client(),
|
||||
logger: log.NewLogger(),
|
||||
}
|
||||
|
||||
fetched, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, doc.ClientName, fetched.ClientName)
|
||||
|
||||
cached, err := fetcher.fetch(t.Context(), clientID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fetched.ClientName, cached.ClientName)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type (
|
||||
IDTokenSigningAlgValuesSupported []coredata.OAuth2SigningAlgorithm `json:"id_token_signing_alg_values_supported"`
|
||||
CodeChallengeMethodsSupported []coredata.OAuth2CodeChallengeMethod `json:"code_challenge_methods_supported"`
|
||||
ClaimsSupported []coredata.OAuth2Claim `json:"claims_supported"`
|
||||
ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
|
||||
}
|
||||
|
||||
// Endpoints holds the endpoint URLs for the OIDC discovery document.
|
||||
@@ -126,5 +127,6 @@ func NewMetadata(issuer uri.URI, endpoints Endpoints, apiScopes []coredata.OAuth
|
||||
coredata.OAuth2ClaimEmailVerified,
|
||||
coredata.OAuth2ClaimName,
|
||||
},
|
||||
ClientIDMetadataDocumentSupported: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,4 +253,13 @@ func TestNewMetadata(t *testing.T) {
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"cimd supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, metadata.ClientIDMetadataDocumentSupported)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ type (
|
||||
baseURL uri.URI
|
||||
logger *log.Logger
|
||||
gc *GarbageCollector
|
||||
cimd *cimdFetcher
|
||||
cimdAllowedClientIDs []string
|
||||
apiScopes []coredata.OAuth2Scope
|
||||
accessTokenDuration time.Duration
|
||||
refreshTokenDuration time.Duration
|
||||
authorizationCodeDuration time.Duration
|
||||
@@ -72,7 +75,7 @@ type (
|
||||
IdentityID gid.GID
|
||||
SessionID gid.GID
|
||||
ResponseType coredata.OAuth2ResponseType
|
||||
ClientID gid.GID
|
||||
ClientIDRaw string
|
||||
RedirectURI string
|
||||
Scopes coredata.OAuth2Scopes
|
||||
CodeChallenge string
|
||||
@@ -156,6 +159,18 @@ func WithDeviceCodeDuration(d time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithAPIScopes(scopes []coredata.OAuth2Scope) Option {
|
||||
return func(s *Service) {
|
||||
s.apiScopes = scopes
|
||||
}
|
||||
}
|
||||
|
||||
func WithCIMDAllowedClientIDs(clientIDs []string) Option {
|
||||
return func(s *Service) {
|
||||
s.cimdAllowedClientIDs = clientIDs
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(
|
||||
pgClient *pg.Client,
|
||||
signingKeys SigningKeys,
|
||||
@@ -188,6 +203,7 @@ func NewService(
|
||||
}
|
||||
|
||||
s.gc = NewGarbageCollector(pgClient, logger)
|
||||
s.cimd = newCIMDFetcher(logger.Named("cimd"))
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1417,13 +1433,9 @@ func (s *Service) Authorize(
|
||||
if err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
var client coredata.OAuth2Client
|
||||
if err := client.LoadByID(ctx, tx, coredata.NewNoScope(), req.ClientID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrClientNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load client: %w", err)
|
||||
client, err := s.ResolveClient(ctx, req.ClientIDRaw, req.RedirectURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !client.IsRedirectURIAllowed(req.RedirectURI) {
|
||||
@@ -1495,7 +1507,7 @@ func (s *Service) Authorize(
|
||||
code, err = s.issueAuthorizationCode(
|
||||
ctx,
|
||||
tx,
|
||||
&client,
|
||||
client,
|
||||
req.IdentityID,
|
||||
uri.URI(req.RedirectURI),
|
||||
requestedScopes,
|
||||
@@ -1536,7 +1548,7 @@ func (s *Service) Authorize(
|
||||
return pg.NoRollback(
|
||||
&ConsentRequiredError{
|
||||
ConsentID: pendingConsent.ID,
|
||||
Client: &client,
|
||||
Client: client,
|
||||
Scopes: requestedScopes,
|
||||
},
|
||||
)
|
||||
@@ -1721,12 +1733,12 @@ func (s *Service) ApproveConsent(
|
||||
|
||||
func (s *Service) AuthenticateClient(
|
||||
ctx context.Context,
|
||||
clientID gid.GID,
|
||||
clientIDRaw string,
|
||||
clientSecret string,
|
||||
) (*coredata.OAuth2Client, error) {
|
||||
client, err := s.GetClientByID(ctx, clientID)
|
||||
client, err := s.ResolveClient(ctx, clientIDRaw, "")
|
||||
if err != nil {
|
||||
return nil, NewError(ErrInvalidClient, WithDescription("cannot load client"))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if client.TokenEndpointAuthMethod == coredata.OAuth2ClientTokenEndpointAuthMethodNone {
|
||||
|
||||
@@ -200,7 +200,10 @@ func NewService(
|
||||
cfg.OAuth2ServerSigningKeys,
|
||||
uri.URI(cfg.BaseURL.String()),
|
||||
cfg.Logger.Named("oauth2"),
|
||||
cfg.OAuth2ServerOptions...,
|
||||
append(
|
||||
[]oauth2.Option{oauth2.WithAPIScopes(svc.Authorizer.APIScopes())},
|
||||
cfg.OAuth2ServerOptions...,
|
||||
)...,
|
||||
)
|
||||
|
||||
svc.samlDomainVerifier = NewSAMLDomainVerifier(
|
||||
|
||||
Reference in New Issue
Block a user