From 5b0d3e50529e85af9e1d3296f38c205ba72cd8eb Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 19 Jun 2026 16:35:53 +0200 Subject: [PATCH] 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 --- .env.example | 5 + .../charts/probo/templates/deployment.yaml | 4 + contrib/helm/charts/probo/values.yaml | 3 + e2e/console/oauth2_test.go | 1 + e2e/internal/testutil/oauth2.go | 1 + pkg/bootstrap/builder.go | 3 + pkg/bootstrap/builder_test.go | 10 + pkg/cachecontrol/cachecontrol.go | 404 +++++++++++++++++ pkg/cachecontrol/cachecontrol_test.go | 410 ++++++++++++++++++ pkg/cachecontrol/directives.go | 152 +++++++ pkg/coredata/migrations/20260619T094531Z.sql | 22 + pkg/coredata/oauth2_client.go | 200 +++++++++ pkg/iam/oauth2/cimd.go | 393 +++++++++++++++++ pkg/iam/oauth2/cimd_test.go | 177 ++++++++ pkg/iam/oauth2/metadata.go | 2 + pkg/iam/oauth2/metadata_test.go | 9 + pkg/iam/oauth2/service.go | 38 +- pkg/iam/service.go | 5 +- pkg/probod/probod.go | 4 + pkg/probodconfig/auth_config.go | 1 + pkg/server/api/connect/v1/oauth2_handler.go | 10 +- pkg/server/api/connect/v1/types/oauth2.go | 8 +- 22 files changed, 1836 insertions(+), 26 deletions(-) create mode 100644 pkg/cachecontrol/cachecontrol.go create mode 100644 pkg/cachecontrol/cachecontrol_test.go create mode 100644 pkg/cachecontrol/directives.go create mode 100644 pkg/coredata/migrations/20260619T094531Z.sql create mode 100644 pkg/iam/oauth2/cimd.go create mode 100644 pkg/iam/oauth2/cimd_test.go diff --git a/.env.example b/.env.example index 69cb8d06b..31ce84477 100644 --- a/.env.example +++ b/.env.example @@ -137,6 +137,11 @@ # deployment is publicly reachable at PROBOD_BASE_URL. Self-hosted PostHog uses # the API-key path with an instance URL. No CONNECTOR_POSTHOG_* vars required. +# ── OAuth2 authorization server ─────────────────────────────────────── +# Comma-separated HTTPS client metadata document URLs allowed for CIMD +# OAuth clients (e.g. MCP connectors). Leave unset to disable CIMD. +# OAUTH2_SERVER_CIMD_ALLOWED_CLIENT_IDS=https://chatgpt.com/oauth/client.json,https://claude.ai/oauth/client.json + # ── Custom domains (Pebble ACME via compose) ────────────────────────── # CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com # ACME_DIRECTORY=https://localhost:14000/dir diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index 134ef68ff..fb7941e6d 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -135,6 +135,10 @@ spec: secretKeyRef: name: {{ include "probo.fullname" . }} key: oauth2-signing-key + {{- if .Values.probo.oauth2.cimdAllowedClientIds }} + - name: OAUTH2_SERVER_CIMD_ALLOWED_CLIENT_IDS + value: {{ join "," .Values.probo.oauth2.cimdAllowedClientIds | quote }} + {{- end }} - name: AUTH_PASSWORD_ITERATIONS value: {{ .Values.probo.auth.passwordIterations | quote }} {{- if .Values.probo.saml.enabled }} diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index 9f6f09048..a52fe7998 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -191,6 +191,9 @@ probo: # Generate with: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 oauth2: signingKey: "" + # Comma-separated HTTPS client metadata document URLs allowed for CIMD + # OAuth clients (e.g. MCP connectors). Leave empty to disable CIMD. + cimdAllowedClientIds: [] # CORS configuration cors: diff --git a/e2e/console/oauth2_test.go b/e2e/console/oauth2_test.go index 6453b1eeb..ef14e5c69 100644 --- a/e2e/console/oauth2_test.go +++ b/e2e/console/oauth2_test.go @@ -96,6 +96,7 @@ func TestOAuth2_Discovery(t *testing.T) { assert.Contains(t, discovery.ClaimsSupported, "email") assert.Contains(t, discovery.ClaimsSupported, "email_verified") assert.Contains(t, discovery.ClaimsSupported, "name") + assert.True(t, discovery.ClientIDMetadataDocumentSupported) } func TestOAuth2_ProtectedResourceMetadata(t *testing.T) { diff --git a/e2e/internal/testutil/oauth2.go b/e2e/internal/testutil/oauth2.go index 9dd041153..f8d48832d 100644 --- a/e2e/internal/testutil/oauth2.go +++ b/e2e/internal/testutil/oauth2.go @@ -97,6 +97,7 @@ type ( CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` ClaimsSupported []string `json:"claims_supported"` ProtectedResources []string `json:"protected_resources,omitempty"` + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` } OAuth2ProtectedResourceMetadataResponse struct { diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index f7d16e42c..878c768ce 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -139,6 +139,9 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { RefreshTokenDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_REFRESH_TOKEN_DURATION", 2592000), AuthorizationCodeDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_AUTHORIZATION_CODE_DURATION", 600), DeviceCodeDuration: b.getEnvIntOrDefault("OAUTH2_SERVER_DEVICE_CODE_DURATION", 600), + CIMDAllowedClientIDs: b.parseOriginsList( + b.getEnv("OAUTH2_SERVER_CIMD_ALLOWED_CLIENT_IDS"), + ), }, }, TrustCenter: probodconfig.TrustCenterConfig{ diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 4019d6e7b..94f35efce 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -728,6 +728,7 @@ func TestBuilder_Build_OAuth2Defaults(t *testing.T) { assert.Equal(t, 2592000, cfg.Probod.Auth.OAuth2Server.RefreshTokenDuration) assert.Equal(t, 600, cfg.Probod.Auth.OAuth2Server.AuthorizationCodeDuration) assert.Equal(t, 600, cfg.Probod.Auth.OAuth2Server.DeviceCodeDuration) + assert.Nil(t, cfg.Probod.Auth.OAuth2Server.CIMDAllowedClientIDs) } func TestBuilder_Build_OAuth2FromEnv(t *testing.T) { @@ -738,6 +739,7 @@ func TestBuilder_Build_OAuth2FromEnv(t *testing.T) { env["OAUTH2_SERVER_REFRESH_TOKEN_DURATION"] = "20" env["OAUTH2_SERVER_AUTHORIZATION_CODE_DURATION"] = "30" env["OAUTH2_SERVER_DEVICE_CODE_DURATION"] = "40" + env["OAUTH2_SERVER_CIMD_ALLOWED_CLIENT_IDS"] = "https://chatgpt.com/oauth/client.json,https://claude.ai/oauth/client.json" b := NewBuilder(mockEnv(env)) @@ -754,6 +756,14 @@ func TestBuilder_Build_OAuth2FromEnv(t *testing.T) { assert.Equal(t, 20, cfg.Probod.Auth.OAuth2Server.RefreshTokenDuration) assert.Equal(t, 30, cfg.Probod.Auth.OAuth2Server.AuthorizationCodeDuration) assert.Equal(t, 40, cfg.Probod.Auth.OAuth2Server.DeviceCodeDuration) + assert.Equal( + t, + []string{ + "https://chatgpt.com/oauth/client.json", + "https://claude.ai/oauth/client.json", + }, + cfg.Probod.Auth.OAuth2Server.CIMDAllowedClientIDs, + ) } func TestBuilder_Build_OAuth2Preset(t *testing.T) { diff --git a/pkg/cachecontrol/cachecontrol.go b/pkg/cachecontrol/cachecontrol.go new file mode 100644 index 000000000..72b4dece6 --- /dev/null +++ b/pkg/cachecontrol/cachecontrol.go @@ -0,0 +1,404 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 cachecontrol parses HTTP Cache-Control header values as defined in +// RFC 9111 Section 5.2. +// +// The API and parsing approach are adapted from github.com/lestrrat-go/httpcc +// (MIT license, https://github.com/lestrrat-go/httpcc). +package cachecontrol + +import ( + "bufio" + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + MaxAge = "max-age" + MaxStale = "max-stale" + MinFresh = "min-fresh" + NoCache = "no-cache" + NoStore = "no-store" + NoTransform = "no-transform" + OnlyIfCached = "only-if-cached" + MustRevalidate = "must-revalidate" + Public = "public" + Private = "private" + ProxyRevalidate = "proxy-revalidate" + SMaxAge = "s-maxage" +) + +type ( + TokenPair struct { + Name string + Value string + } + + TokenValuePolicy int + + directiveValidator interface { + Validate(name string) TokenValuePolicy + } + + directiveValidatorFn func(string) TokenValuePolicy +) + +const ( + NoArgument TokenValuePolicy = iota + TokenOnly + QuotedStringOnly + AnyTokenValue +) + +func (fn directiveValidatorFn) Validate(name string) TokenValuePolicy { + return fn(name) +} + +func responseDirectiveValidator(name string) TokenValuePolicy { + switch name { + case MustRevalidate, NoStore, NoTransform, Public, ProxyRevalidate: + return NoArgument + case NoCache, Private: + return QuotedStringOnly + case MaxAge, SMaxAge: + return TokenOnly + default: + return AnyTokenValue + } +} + +func requestDirectiveValidator(name string) TokenValuePolicy { + switch name { + case MaxAge, MaxStale, MinFresh: + return TokenOnly + case NoCache, NoStore, NoTransform, OnlyIfCached: + return NoArgument + default: + return AnyTokenValue + } +} + +// ParseRequestDirective parses a single Cache-Control directive from a request. +func ParseRequestDirective(raw string) (*TokenPair, error) { + return parseDirective(raw, directiveValidatorFn(requestDirectiveValidator)) +} + +// ParseResponseDirective parses a single Cache-Control directive from a response. +func ParseResponseDirective(raw string) (*TokenPair, error) { + return parseDirective(raw, directiveValidatorFn(responseDirectiveValidator)) +} + +// ParseRequestDirectives parses Cache-Control directives from a request header. +func ParseRequestDirectives(header string) ([]*TokenPair, error) { + return parseDirectives(header, ParseRequestDirective) +} + +// ParseResponseDirectives parses Cache-Control directives from a response header. +func ParseResponseDirectives(header string) ([]*TokenPair, error) { + return parseDirectives(header, ParseResponseDirective) +} + +// ParseRequest parses the Cache-Control header value of an HTTP request. +func ParseRequest(header string) (*RequestDirective, error) { + tokens, err := ParseRequestDirectives(header) + if err != nil { + return nil, fmt.Errorf("cannot parse request cache-control: %w", err) + } + + dir := &RequestDirective{ + extensions: make(map[string]string), + } + + for _, token := range tokens { + name := strings.ToLower(token.Name) + + switch name { + case MaxAge: + seconds, err := parseDeltaSeconds(token.Value) + if err != nil { + return nil, fmt.Errorf("cannot parse max-age: %w", err) + } + + dir.maxAge = &seconds + case MaxStale: + seconds, err := parseDeltaSeconds(token.Value) + if err != nil { + return nil, fmt.Errorf("cannot parse max-stale: %w", err) + } + + dir.maxStale = &seconds + case MinFresh: + seconds, err := parseDeltaSeconds(token.Value) + if err != nil { + return nil, fmt.Errorf("cannot parse min-fresh: %w", err) + } + + dir.minFresh = &seconds + case NoCache: + dir.noCache = true + case NoStore: + dir.noStore = true + case NoTransform: + dir.noTransform = true + case OnlyIfCached: + dir.onlyIfCached = true + default: + dir.extensions[token.Name] = token.Value + } + } + + return dir, nil +} + +// ParseResponse parses the Cache-Control header value of an HTTP response. +// When multiple max-age directives are present, the minimum value is kept +// per RFC 7234 Section 4.2.3. +func ParseResponse(header string) (*ResponseDirective, error) { + tokens, err := ParseResponseDirectives(header) + if err != nil { + return nil, fmt.Errorf("cannot parse response cache-control: %w", err) + } + + dir := &ResponseDirective{ + extensions: make(map[string]string), + } + + for _, token := range tokens { + name := strings.ToLower(token.Name) + + switch name { + case MaxAge: + seconds, err := parseDeltaSeconds(token.Value) + if err != nil { + return nil, fmt.Errorf("cannot parse max-age: %w", err) + } + + setMinimumUint64(&dir.maxAge, seconds) + case MustRevalidate: + dir.mustRevalidate = true + case NoCache: + dir.noCache = appendFields(dir.noCache, token.Value) + case NoStore: + dir.noStore = true + case NoTransform: + dir.noTransform = true + case Public: + dir.public = true + case Private: + dir.private = appendFields(dir.private, token.Value) + case ProxyRevalidate: + dir.proxyRevalidate = true + case SMaxAge: + seconds, err := parseDeltaSeconds(token.Value) + if err != nil { + return nil, fmt.Errorf("cannot parse s-maxage: %w", err) + } + + setMinimumUint64(&dir.sMaxAge, seconds) + default: + dir.extensions[token.Name] = token.Value + } + } + + return dir, nil +} + +func parseDirective(raw string, validator directiveValidator) (*TokenPair, error) { + raw = strings.TrimSpace(raw) + + idx := strings.IndexByte(raw, '=') + if idx == -1 { + return &TokenPair{Name: raw}, nil + } + + pair := &TokenPair{ + Name: strings.TrimSpace(raw[:idx]), + } + + if len(raw) <= idx { + return pair, nil + } + + value := strings.TrimSpace(raw[idx+1:]) + + switch validator.Validate(strings.ToLower(pair.Name)) { + case TokenOnly: + if value != "" && value[0] == '"' { + return nil, fmt.Errorf("invalid value for %s: quoted string not allowed", pair.Name) + } + case QuotedStringOnly: + if value == "" { + break + } + + if value[0] != '"' { + return nil, fmt.Errorf("invalid value for %s: bare token not allowed", pair.Name) + } + + unquoted, err := strconv.Unquote(value) + if err != nil { + return nil, fmt.Errorf("invalid value for %s: malformed quoted string", pair.Name) + } + + value = unquoted + case AnyTokenValue: + if value != "" && value[0] == '"' { + unquoted, err := strconv.Unquote(value) + if err != nil { + return nil, fmt.Errorf("invalid value for %s: malformed quoted string", pair.Name) + } + + value = unquoted + } + case NoArgument: + if value != "" { + return nil, fmt.Errorf("received argument to directive %s", pair.Name) + } + } + + pair.Value = value + + return pair, nil +} + +func parseDirectives(header string, parse func(string) (*TokenPair, error)) ([]*TokenPair, error) { + scanner := bufio.NewScanner(strings.NewReader(header)) + scanner.Split(scanCommaSeparatedWords) + + var tokens []*TokenPair + + for scanner.Scan() { + token, err := parse(scanner.Text()) + if err != nil { + return nil, fmt.Errorf("cannot parse directive #%d: %w", len(tokens)+1, err) + } + + tokens = append(tokens, token) + } + + return tokens, nil +} + +func appendFields(fields []string, raw string) []string { + scanner := bufio.NewScanner(strings.NewReader(raw)) + scanner.Split(scanCommaSeparatedWords) + + for scanner.Scan() { + fields = append(fields, scanner.Text()) + } + + return fields +} + +func setMinimumUint64(target **uint64, value uint64) { + if *target == nil || value < **target { + v := value + *target = &v + } +} + +func parseDeltaSeconds(raw string) (uint64, error) { + if raw == "" { + return 0, fmt.Errorf("empty delta-seconds") + } + + for _, r := range raw { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid delta-seconds %q", raw) + } + } + + seconds, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid delta-seconds %q: %w", raw, err) + } + + return seconds, nil +} + +func secondsToDuration(seconds uint64) time.Duration { + const maxSeconds = uint64(math.MaxInt64 / int64(time.Second)) + if seconds > maxSeconds { + return time.Duration(math.MaxInt64) + } + + return time.Duration(seconds) * time.Second +} + +func isSpace(r rune) bool { + if r <= '\u00FF' { + switch r { + case ' ', '\t', '\n', '\v', '\f', '\r': + return true + case '\u0085', '\u00A0': + return true + } + + return false + } + + if '\u2000' <= r && r <= '\u200a' { + return true + } + + switch r { + case '\u1680', '\u2028', '\u2029', '\u202f', '\u205f', '\u3000': + return true + } + + return false +} + +func scanCommaSeparatedWords(data []byte, atEOF bool) (advance int, token []byte, err error) { + start := 0 + + for width := 0; start < len(data); start += width { + var r rune + r, width = utf8.DecodeRune(data[start:]) + if !isSpace(r) { + break + } + } + + var ws int + inQuotes := false + + for width, i := 0, start; i < len(data); i += width { + var r rune + r, width = utf8.DecodeRune(data[i:]) + + switch { + case r == '"': + inQuotes = !inQuotes + ws = 0 + case isSpace(r) && !inQuotes: + ws++ + case r == ',' && !inQuotes: + return i + width, data[start : i-ws], nil + default: + ws = 0 + } + } + + if atEOF && len(data) > start { + return len(data), data[start : len(data)-ws], nil + } + + return start, nil, nil +} diff --git a/pkg/cachecontrol/cachecontrol_test.go b/pkg/cachecontrol/cachecontrol_test.go new file mode 100644 index 000000000..6fe313456 --- /dev/null +++ b/pkg/cachecontrol/cachecontrol_test.go @@ -0,0 +1,410 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 cachecontrol_test + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/cachecontrol" +) + +func TestParseRequestDirective(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + wantErr bool + want *cachecontrol.TokenPair + }{ + { + name: "no-store flag", + source: "no-store", + want: &cachecontrol.TokenPair{Name: "no-store"}, + }, + { + name: "max-age token", + source: "max-age=4649", + want: &cachecontrol.TokenPair{Name: "max-age", Value: "4649"}, + }, + { + name: "max-age quoted rejected", + source: `max-age="4649"`, + wantErr: true, + }, + { + name: "no-store with argument rejected", + source: `no-store="foo"`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + got, err := cachecontrol.ParseRequestDirective(tt.source) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }, + ) + } +} + +func TestParseResponseDirective(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + wantErr bool + want *cachecontrol.TokenPair + }{ + { + name: "s-maxage token", + source: "s-maxage=4649", + want: &cachecontrol.TokenPair{Name: "s-maxage", Value: "4649"}, + }, + { + name: "no-store flag", + source: "no-store", + want: &cachecontrol.TokenPair{Name: "no-store"}, + }, + { + name: "extension with quoted value", + source: `community="UCI"`, + want: &cachecontrol.TokenPair{Name: "community", Value: "UCI"}, + }, + { + name: "max-age quoted rejected", + source: `max-age="4649"`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + got, err := cachecontrol.ParseResponseDirective(tt.source) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }, + ) + } +} + +func TestParseRequestDirectives(t *testing.T) { + t.Parallel() + + tokens, err := cachecontrol.ParseRequestDirectives(` max-age=4649 , no-store `) + require.NoError(t, err) + require.Len(t, tokens, 2) + assert.Equal(t, &cachecontrol.TokenPair{Name: "max-age", Value: "4649"}, tokens[0]) + assert.Equal(t, &cachecontrol.TokenPair{Name: "no-store"}, tokens[1]) +} + +func TestParseResponseDirectives(t *testing.T) { + t.Parallel() + + tokens, err := cachecontrol.ParseResponseDirectives(`max-age=4649, no-store, community="UCI"`) + require.NoError(t, err) + require.Len(t, tokens, 3) + assert.Equal(t, &cachecontrol.TokenPair{Name: "max-age", Value: "4649"}, tokens[0]) + assert.Equal(t, &cachecontrol.TokenPair{Name: "no-store"}, tokens[1]) + assert.Equal(t, &cachecontrol.TokenPair{Name: "community", Value: "UCI"}, tokens[2]) +} + +func TestParseRequest(t *testing.T) { + t.Parallel() + + t.Run( + "max-age and no-store", + func(t *testing.T) { + t.Parallel() + + dir, err := cachecontrol.ParseRequest("max-age=4649, no-store") + require.NoError(t, err) + + seconds, ok := dir.MaxAge() + require.True(t, ok) + assert.Equal(t, uint64(4649), seconds) + assert.True(t, dir.NoStore()) + }, + ) + + t.Run( + "invalid max-age rejected", + func(t *testing.T) { + t.Parallel() + + _, err := cachecontrol.ParseRequest(`max-age="4649"`) + require.Error(t, err) + }, + ) +} + +func TestParseResponse(t *testing.T) { + t.Parallel() + + t.Run( + "response directives and extension", + func(t *testing.T) { + t.Parallel() + + dir, err := cachecontrol.ParseResponse(`max-age=4649, no-store, community="UCI"`) + require.NoError(t, err) + + seconds, ok := dir.MaxAge() + require.True(t, ok) + assert.Equal(t, uint64(4649), seconds) + assert.True(t, dir.NoStore()) + assert.Equal(t, map[string]string{"community": "UCI"}, dir.Extensions()) + }, + ) + + t.Run( + "multiple max-age uses minimum", + func(t *testing.T) { + t.Parallel() + + dir, err := cachecontrol.ParseResponse("max-age=3600, max-age=60") + require.NoError(t, err) + + seconds, ok := dir.MaxAge() + require.True(t, ok) + assert.Equal(t, uint64(60), seconds) + }, + ) + + t.Run( + "s-maxage and flags", + func(t *testing.T) { + t.Parallel() + + dir, err := cachecontrol.ParseResponse("public, max-age=604800, s-maxage=86400, must-revalidate") + require.NoError(t, err) + + maxAge, ok := dir.MaxAge() + require.True(t, ok) + assert.Equal(t, uint64(604800), maxAge) + + sMaxAge, ok := dir.SMaxAge() + require.True(t, ok) + assert.Equal(t, uint64(86400), sMaxAge) + + assert.True(t, dir.Public()) + assert.True(t, dir.MustRevalidate()) + }, + ) + + t.Run( + "invalid max-age rejected", + func(t *testing.T) { + t.Parallel() + + _, err := cachecontrol.ParseResponse(`max-age="4649"`) + require.Error(t, err) + }, + ) +} + +func TestResponseMaxAgeDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + header string + wantAge time.Duration + wantOK bool + }{ + { + name: "empty header", + header: "", + wantOK: false, + }, + { + name: "whitespace only", + header: " ", + wantOK: false, + }, + { + name: "no max-age directive", + header: "public, private, no-cache", + wantOK: false, + }, + { + name: "single max-age", + header: "max-age=120", + wantAge: 120 * time.Second, + wantOK: true, + }, + { + name: "max-age with other directives", + header: "public, max-age=120, private", + wantAge: 120 * time.Second, + wantOK: true, + }, + { + name: "max-age zero", + header: "max-age=0", + wantAge: 0, + wantOK: true, + }, + { + name: "case insensitive directive name", + header: "Max-Age=90", + wantAge: 90 * time.Second, + wantOK: true, + }, + { + name: "whitespace around comma separators", + header: "public , max-age=120 , private", + wantAge: 120 * time.Second, + wantOK: true, + }, + { + name: "whitespace around equals sign", + header: "max-age = 120", + wantAge: 120 * time.Second, + wantOK: true, + }, + { + name: "multiple max-age uses minimum", + header: "max-age=3600, max-age=60", + wantAge: 60 * time.Second, + wantOK: true, + }, + { + name: "invalid max-age makes header invalid", + header: "max-age=bad, max-age=30", + wantOK: false, + }, + { + name: "all max-age values invalid", + header: "max-age=, max-age=abc", + wantOK: false, + }, + { + name: "negative max-age rejected", + header: "max-age=-1", + wantOK: false, + }, + { + name: "decimal max-age rejected", + header: "max-age=1.5", + wantOK: false, + }, + { + name: "quoted max-age rejected", + header: `max-age="120"`, + wantOK: false, + }, + { + name: "leading zeros preserved", + header: "max-age=0060", + wantAge: 60 * time.Second, + wantOK: true, + }, + { + name: "s-maxage ignored by MaxAge helper", + header: "s-maxage=3600", + wantOK: false, + }, + { + name: "s-maxage and max-age both present", + header: "s-maxage=3600, max-age=120", + wantAge: 120 * time.Second, + wantOK: true, + }, + { + name: "directive name must match exactly", + header: "foo-max-age=120", + wantOK: false, + }, + { + name: "comma inside quoted extension value", + header: `foo="bar,baz", max-age=120`, + wantAge: 120 * time.Second, + wantOK: true, + }, + { + name: "real world nginx style", + header: "max-age=31536000, public, immutable", + wantAge: 365 * 24 * time.Hour, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + dir, err := cachecontrol.ParseResponse(tt.header) + if !tt.wantOK { + if err == nil { + _, gotOK := dir.MaxAgeDuration() + assert.False(t, gotOK) + } + return + } + + require.NoError(t, err) + gotAge, gotOK := dir.MaxAgeDuration() + assert.True(t, gotOK) + assert.Equal(t, tt.wantAge, gotAge) + }, + ) + } +} + +func TestResponseMaxAgeDuration_Overflow(t *testing.T) { + t.Parallel() + + dir, err := cachecontrol.ParseResponse("max-age=9223372036854775807") + require.NoError(t, err) + + age, ok := dir.MaxAgeDuration() + require.True(t, ok) + assert.Equal(t, time.Duration(math.MaxInt64), age) +} + +func TestParseResponseDirectives_NoSpaceAfterComma(t *testing.T) { + t.Parallel() + + tokens, err := cachecontrol.ParseResponseDirectives("max-age=120,no-store") + require.NoError(t, err) + require.Len(t, tokens, 2) +} diff --git a/pkg/cachecontrol/directives.go b/pkg/cachecontrol/directives.go new file mode 100644 index 000000000..bdbf84142 --- /dev/null +++ b/pkg/cachecontrol/directives.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 cachecontrol + +import "time" + +type ( + RequestDirective struct { + maxAge *uint64 + maxStale *uint64 + minFresh *uint64 + noCache bool + noStore bool + noTransform bool + onlyIfCached bool + extensions map[string]string + } + + ResponseDirective struct { + maxAge *uint64 + mustRevalidate bool + noCache []string + noStore bool + noTransform bool + public bool + private []string + proxyRevalidate bool + sMaxAge *uint64 + extensions map[string]string + } +) + +func (d *RequestDirective) MaxAge() (uint64, bool) { + if v := d.maxAge; v != nil { + return *v, true + } + + return 0, false +} + +func (d *RequestDirective) MaxStale() (uint64, bool) { + if v := d.maxStale; v != nil { + return *v, true + } + + return 0, false +} + +func (d *RequestDirective) MinFresh() (uint64, bool) { + if v := d.minFresh; v != nil { + return *v, true + } + + return 0, false +} + +func (d *RequestDirective) NoCache() bool { + return d.noCache +} + +func (d *RequestDirective) NoStore() bool { + return d.noStore +} + +func (d *RequestDirective) NoTransform() bool { + return d.noTransform +} + +func (d *RequestDirective) OnlyIfCached() bool { + return d.onlyIfCached +} + +func (d *RequestDirective) Extensions() map[string]string { + return d.extensions +} + +func (d *RequestDirective) Extension(name string) string { + return d.extensions[name] +} + +func (d *ResponseDirective) MaxAge() (uint64, bool) { + if v := d.maxAge; v != nil { + return *v, true + } + + return 0, false +} + +func (d *ResponseDirective) MaxAgeDuration() (time.Duration, bool) { + seconds, ok := d.MaxAge() + if !ok { + return 0, false + } + + return secondsToDuration(seconds), true +} + +func (d *ResponseDirective) MustRevalidate() bool { + return d.mustRevalidate +} + +func (d *ResponseDirective) NoCache() []string { + return d.noCache +} + +func (d *ResponseDirective) NoStore() bool { + return d.noStore +} + +func (d *ResponseDirective) NoTransform() bool { + return d.noTransform +} + +func (d *ResponseDirective) Public() bool { + return d.public +} + +func (d *ResponseDirective) Private() []string { + return d.private +} + +func (d *ResponseDirective) ProxyRevalidate() bool { + return d.proxyRevalidate +} + +func (d *ResponseDirective) SMaxAge() (uint64, bool) { + if v := d.sMaxAge; v != nil { + return *v, true + } + + return 0, false +} + +func (d *ResponseDirective) Extensions() map[string]string { + return d.extensions +} + +func (d *ResponseDirective) Extension(name string) string { + return d.extensions[name] +} diff --git a/pkg/coredata/migrations/20260619T094531Z.sql b/pkg/coredata/migrations/20260619T094531Z.sql new file mode 100644 index 000000000..898966265 --- /dev/null +++ b/pkg/coredata/migrations/20260619T094531Z.sql @@ -0,0 +1,22 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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. + +-- Store the HTTPS client_id URL for OAuth Client ID Metadata Document (CIMD) +-- clients registered on first use (e.g. ChatGPT, Claude MCP connectors). +ALTER TABLE iam_oauth2_clients +ADD COLUMN external_client_id TEXT; + +CREATE UNIQUE INDEX iam_oauth2_clients_external_client_id_unique +ON iam_oauth2_clients (external_client_id) +WHERE external_client_id IS NOT NULL; diff --git a/pkg/coredata/oauth2_client.go b/pkg/coredata/oauth2_client.go index 246b739cd..11a2773e5 100644 --- a/pkg/coredata/oauth2_client.go +++ b/pkg/coredata/oauth2_client.go @@ -33,6 +33,7 @@ import ( type ( OAuth2Client struct { ID gid.GID `db:"id"` + ExternalClientID string `db:"external_client_id"` OrganizationID *gid.GID `db:"organization_id"` ClientSecretHash []byte `db:"client_secret_hash"` ClientName string `db:"client_name"` @@ -181,6 +182,56 @@ LIMIT 1; return nil } +func (c *OAuth2Client) LoadByExternalClientID( + ctx context.Context, + conn pg.Querier, + externalClientID string, +) error { + q := ` +SELECT + id, + organization_id, + client_secret_hash, + client_name, + visibility, + redirect_uris, + scopes, + grant_types, + response_types, + token_endpoint_auth_method, + logo_uri, + client_uri, + contacts, + created_at, + updated_at +FROM + iam_oauth2_clients +WHERE + external_client_id = @external_client_id +LIMIT 1; +` + + args := pgx.StrictNamedArgs{"external_client_id": externalClientID} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query iam_oauth2_clients by external_client_id: %w", err) + } + + client, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Client]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_client: %w", err) + } + + *c = client + + return nil +} + func (c *OAuth2Clients) LoadByOrganizationID( ctx context.Context, conn pg.Querier, @@ -337,6 +388,110 @@ INSERT INTO iam_oauth2_clients ( return nil } +func (c *OAuth2Client) UpsertCIMD( + ctx context.Context, + conn pg.Tx, +) error { + q := ` +INSERT INTO iam_oauth2_clients ( + id, + tenant_id, + organization_id, + external_client_id, + client_secret_hash, + client_name, + visibility, + redirect_uris, + scopes, + grant_types, + response_types, + token_endpoint_auth_method, + logo_uri, + client_uri, + contacts, + created_at, + updated_at +) VALUES ( + @id, + NULL, + NULL, + @external_client_id, + @client_secret_hash, + @client_name, + @visibility, + @redirect_uris, + @scopes, + @grant_types, + @response_types, + @token_endpoint_auth_method, + @logo_uri, + @client_uri, + @contacts, + @created_at, + @updated_at +) +ON CONFLICT (external_client_id) WHERE external_client_id IS NOT NULL DO UPDATE SET + client_name = EXCLUDED.client_name, + redirect_uris = EXCLUDED.redirect_uris, + grant_types = EXCLUDED.grant_types, + response_types = EXCLUDED.response_types, + token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method, + logo_uri = EXCLUDED.logo_uri, + client_uri = EXCLUDED.client_uri, + updated_at = EXCLUDED.updated_at +RETURNING + id, + external_client_id, + organization_id, + client_secret_hash, + client_name, + visibility, + redirect_uris, + scopes, + grant_types, + response_types, + token_endpoint_auth_method, + logo_uri, + client_uri, + contacts, + created_at, + updated_at +` + + args := pgx.StrictNamedArgs{ + "id": c.ID, + "external_client_id": c.ExternalClientID, + "client_secret_hash": c.ClientSecretHash, + "client_name": c.ClientName, + "visibility": c.Visibility, + "redirect_uris": c.RedirectURIs, + "scopes": c.Scopes, + "grant_types": c.GrantTypes, + "response_types": c.ResponseTypes, + "token_endpoint_auth_method": c.TokenEndpointAuthMethod, + "logo_uri": c.LogoURI, + "client_uri": c.ClientURI, + "contacts": c.Contacts, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot upsert cimd oauth2_client: %w", err) + } + defer rows.Close() + + row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Client]) + if err != nil { + return fmt.Errorf("cannot collect upsert result: %w", err) + } + + *c = row + + return nil +} + func (c *OAuth2Client) Update( ctx context.Context, conn pg.Tx, @@ -411,3 +566,48 @@ WHERE return nil } + +func NewCIMDClient( + externalClientID string, + name string, + redirectURIs []string, + scopes OAuth2Scopes, + logoURI, clientURI *string, + now time.Time, +) (*OAuth2Client, error) { + uris := make([]uri.URI, 0, len(redirectURIs)) + for _, raw := range redirectURIs { + uris = append(uris, uri.URI(raw)) + } + + client := &OAuth2Client{ + ID: gid.New(gid.NewTenantID(), OAuth2ClientEntityType), + ExternalClientID: externalClientID, + ClientName: name, + Visibility: OAuth2ClientVisibilityPublic, + RedirectURIs: uris, + Scopes: scopes, + GrantTypes: OAuth2GrantTypes{ + OAuth2GrantTypeAuthorizationCode, + OAuth2GrantTypeRefreshToken, + }, + ResponseTypes: OAuth2ResponseTypes{ + OAuth2ResponseTypeCode, + }, + TokenEndpointAuthMethod: OAuth2ClientTokenEndpointAuthMethodNone, + CreatedAt: now, + UpdatedAt: now, + } + + if logoURI != nil && *logoURI != "" { + u := uri.URI(*logoURI) + client.LogoURI = &u + } + + if clientURI != nil && *clientURI != "" { + u := uri.URI(*clientURI) + client.ClientURI = &u + } + + return client, nil +} diff --git a/pkg/iam/oauth2/cimd.go b/pkg/iam/oauth2/cimd.go new file mode 100644 index 000000000..02d6555a2 --- /dev/null +++ b/pkg/iam/oauth2/cimd.go @@ -0,0 +1,393 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/iam/oauth2/cimd_test.go b/pkg/iam/oauth2/cimd_test.go new file mode 100644 index 000000000..b15567bfc --- /dev/null +++ b/pkg/iam/oauth2/cimd_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/iam/oauth2/metadata.go b/pkg/iam/oauth2/metadata.go index be262bdd1..91a5eb965 100644 --- a/pkg/iam/oauth2/metadata.go +++ b/pkg/iam/oauth2/metadata.go @@ -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, } } diff --git a/pkg/iam/oauth2/metadata_test.go b/pkg/iam/oauth2/metadata_test.go index fee9c09b9..2b470778b 100644 --- a/pkg/iam/oauth2/metadata_test.go +++ b/pkg/iam/oauth2/metadata_test.go @@ -253,4 +253,13 @@ func TestNewMetadata(t *testing.T) { ) }, ) + + t.Run( + "cimd supported", + func(t *testing.T) { + t.Parallel() + + assert.True(t, metadata.ClientIDMetadataDocumentSupported) + }, + ) } diff --git a/pkg/iam/oauth2/service.go b/pkg/iam/oauth2/service.go index bd04fdf6a..74d5dfbca 100644 --- a/pkg/iam/oauth2/service.go +++ b/pkg/iam/oauth2/service.go @@ -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 { diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 2ba799469..ba3d6113b 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -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( diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 5da19a483..b4143db0d 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -1422,5 +1422,9 @@ func oauth2ServerOptions(cfg OAuth2ServerConfig) []oauth2.Option { opts = append(opts, oauth2.WithDeviceCodeDuration(time.Duration(cfg.DeviceCodeDuration)*time.Second)) } + if len(cfg.CIMDAllowedClientIDs) > 0 { + opts = append(opts, oauth2.WithCIMDAllowedClientIDs(cfg.CIMDAllowedClientIDs)) + } + return opts } diff --git a/pkg/probodconfig/auth_config.go b/pkg/probodconfig/auth_config.go index 78da62deb..a936d578c 100644 --- a/pkg/probodconfig/auth_config.go +++ b/pkg/probodconfig/auth_config.go @@ -38,6 +38,7 @@ type OAuth2ServerConfig struct { RefreshTokenDuration int `json:"refresh-token-duration"` AuthorizationCodeDuration int `json:"authorization-code-duration"` DeviceCodeDuration int `json:"device-code-duration"` + CIMDAllowedClientIDs []string `json:"cimd-allowed-client-ids"` } type OAuth2SigningKeyConfig struct { diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go index 50900c787..10425c24f 100644 --- a/pkg/server/api/connect/v1/oauth2_handler.go +++ b/pkg/server/api/connect/v1/oauth2_handler.go @@ -27,7 +27,6 @@ import ( "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/bearertoken" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/securecookie" @@ -167,7 +166,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) IdentityID: identity.ID, SessionID: session.ID, ResponseType: in.ResponseType, - ClientID: in.ClientID, + ClientIDRaw: in.ClientIDRaw, RedirectURI: in.RedirectURI, Scopes: in.Scopes, CodeChallenge: in.CodeChallenge, @@ -445,12 +444,7 @@ func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Cli return nil, oauth2.ErrInvalidClient } - clientID, err := gid.ParseGID(clientIDStr) - if err != nil { - return nil, oauth2.ErrInvalidClient - } - - return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientID, clientSecret) + return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientIDStr, clientSecret) } func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/api/connect/v1/types/oauth2.go b/pkg/server/api/connect/v1/types/oauth2.go index d4204e9fe..945af09bd 100644 --- a/pkg/server/api/connect/v1/types/oauth2.go +++ b/pkg/server/api/connect/v1/types/oauth2.go @@ -50,7 +50,7 @@ func parseScopes(s string) (coredata.OAuth2Scopes, error) { type ( OAuth2AuthorizeInput struct { - ClientID gid.GID + ClientIDRaw string RedirectURI string State string ResponseType coredata.OAuth2ResponseType @@ -112,9 +112,9 @@ type ( func (in *OAuth2AuthorizeInput) DecodeQuery(q url.Values) error { var err error - in.ClientID, err = requireGID(q, "client_id") - if err != nil { - return err + in.ClientIDRaw = q.Get("client_id") + if in.ClientIDRaw == "" { + return fmt.Errorf("missing client_id") } in.RedirectURI = q.Get("redirect_uri")