From 4de35480b9e06c52fa70a4e0299ce421d915a2e4 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 11:47:12 +0200 Subject: [PATCH 01/11] feat: add generic OpenID Connect auth provider Adds `generic-oidc-auth-provider`, a generic OIDC auth provider that works with any OIDC-compliant IdP (Keycloak, Authentik, Dex, Auth0, etc.) by discovering endpoints from the issuer's /.well-known/openid-configuration. Today the OSS edition only ships Google and GitHub auth providers, so self-hosters who use their own IdP (commonly Keycloak) cannot use it for hub login. This provider closes that gap. It mirrors the existing google-auth-provider, wrapping oauth2-proxy's built-in OIDC provider, and is registered in index.yaml under authProviders. Config: Issuer URL, Client ID/Secret, Email Domains (required); Scopes (default "openid email profile"), Email/Groups claims, and Allow Unverified Email (default true, since many self-hosted IdPs don't set email_verified). Includes unit tests for userinfo discovery/fetch and email_verified parsing. --- generic-oidc-auth-provider/go.mod | 85 ++++++ generic-oidc-auth-provider/go.sum | 259 ++++++++++++++++++ generic-oidc-auth-provider/main.go | 160 +++++++++++ .../pkg/profile/profile.go | 146 ++++++++++ .../pkg/profile/profile_test.go | 64 +++++ generic-oidc-auth-provider/tool.gpt | 94 +++++++ index.yaml | 2 + 7 files changed, 810 insertions(+) create mode 100644 generic-oidc-auth-provider/go.mod create mode 100644 generic-oidc-auth-provider/go.sum create mode 100644 generic-oidc-auth-provider/main.go create mode 100644 generic-oidc-auth-provider/pkg/profile/profile.go create mode 100644 generic-oidc-auth-provider/pkg/profile/profile_test.go create mode 100644 generic-oidc-auth-provider/tool.gpt diff --git a/generic-oidc-auth-provider/go.mod b/generic-oidc-auth-provider/go.mod new file mode 100644 index 00000000..8cc4e49e --- /dev/null +++ b/generic-oidc-auth-provider/go.mod @@ -0,0 +1,85 @@ +module github.com/obot-platform/tools/generic-oidc-auth-provider + +go 1.26.2 + +replace ( + github.com/oauth2-proxy/oauth2-proxy/v7 => github.com/obot-platform/oauth2-proxy/v7 v7.0.0-20260410175959-7ef5428d1af3 + github.com/obot-platform/tools/auth-providers-common => ../auth-providers-common +) + +require ( + github.com/oauth2-proxy/oauth2-proxy/v7 v7.8.1 + github.com/obot-platform/tools/auth-providers-common v0.0.0-20241008222508-3c6174b443e7 +) + +require ( + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/a8m/envsubst v1.4.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bitly/go-simplejson v0.5.1 // indirect + github.com/bsm/redislock v0.9.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/justinas/alice v1.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect + k8s.io/apimachinery v0.35.3 // indirect +) diff --git a/generic-oidc-auth-provider/go.sum b/generic-oidc-auth-provider/go.sum new file mode 100644 index 00000000..981e1d59 --- /dev/null +++ b/generic-oidc-auth-provider/go.sum @@ -0,0 +1,259 @@ +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/Bose/minisentinel v0.0.0-20200130220412-917c5a9223bb h1:ZVN4Iat3runWOFLaBCDVU5a9X/XikSRBosye++6gojw= +github.com/Bose/minisentinel v0.0.0-20200130220412-917c5a9223bb/go.mod h1:WsAABbY4HQBgd3mGuG4KMNTbHJCPvx9IVBHzysbknss= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= +github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw= +github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= +github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk= +github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw= +github.com/obot-platform/oauth2-proxy/v7 v7.0.0-20260410175959-7ef5428d1af3 h1:9ALNL/prah32FbhdTqDZvhqm/QTWq4FRFjOxH/T3o+Q= +github.com/obot-platform/oauth2-proxy/v7 v7.0.0-20260410175959-7ef5428d1af3/go.mod h1:Fzn9iO2soLotFAHmSgVA9wMcnnKw4dew+FbZucQsHZ4= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go new file mode 100644 index 00000000..b2b97cdc --- /dev/null +++ b/generic-oidc-auth-provider/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + oauth2proxy "github.com/oauth2-proxy/oauth2-proxy/v7" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" + "github.com/obot-platform/tools/auth-providers-common/pkg/env" + "github.com/obot-platform/tools/auth-providers-common/pkg/state" + "github.com/obot-platform/tools/generic-oidc-auth-provider/pkg/profile" +) + +type Options struct { + ClientID string `env:"OBOT_OIDC_AUTH_PROVIDER_CLIENT_ID"` + ClientSecret string `env:"OBOT_OIDC_AUTH_PROVIDER_CLIENT_SECRET"` + // IssuerURL is the OIDC issuer (e.g. https://auth.example.com/realms/myrealm). + // oauth2-proxy performs discovery against {IssuerURL}/.well-known/openid-configuration. + IssuerURL string `env:"OBOT_OIDC_AUTH_PROVIDER_ISSUER_URL"` + // Scopes requested at login. "openid" is required; email/profile recommended. + Scopes string `env:"OBOT_OIDC_AUTH_PROVIDER_SCOPES" default:"openid email profile" optional:"true"` + // Claim names — defaults match the OIDC spec / Keycloak conventions. + EmailClaim string `env:"OBOT_OIDC_AUTH_PROVIDER_EMAIL_CLAIM" default:"email" optional:"true"` + GroupsClaim string `env:"OBOT_OIDC_AUTH_PROVIDER_GROUPS_CLAIM" default:"groups" optional:"true"` + // AllowUnverifiedEmail lets users without email_verified=true sign in. + // Many self-hosted IdPs (e.g. Keycloak) do not set email_verified by default. + AllowUnverifiedEmail string `env:"OBOT_OIDC_AUTH_PROVIDER_ALLOW_UNVERIFIED_EMAIL" default:"true" optional:"true"` + + ObotServerURL string `env:"OBOT_SERVER_PUBLIC_URL,OBOT_SERVER_URL"` + PostgresConnectionDSN string `env:"OBOT_AUTH_PROVIDER_POSTGRES_CONNECTION_DSN" optional:"true"` + AuthCookieSecret string `usage:"Secret used to encrypt cookie" env:"OBOT_AUTH_PROVIDER_COOKIE_SECRET"` + AuthEmailDomains string `usage:"Email domains allowed for authentication" default:"*" env:"OBOT_AUTH_PROVIDER_EMAIL_DOMAINS"` + AuthTokenRefreshDuration string `usage:"Duration to refresh auth token after" optional:"true" default:"1h" env:"OBOT_AUTH_PROVIDER_TOKEN_REFRESH_DURATION"` + LoggingEnabled string `usage:"Enable oauth2-proxy logging" optional:"true" env:"OBOT_AUTH_PROVIDER_ENABLE_LOGGING"` +} + +func main() { + var opts Options + if err := env.LoadEnvForStruct(&opts); err != nil { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to load options: %v\n", err) + os.Exit(1) + } + + if opts.IssuerURL == "" { + fmt.Printf("ERROR: generic-oidc-auth-provider: OBOT_OIDC_AUTH_PROVIDER_ISSUER_URL is required\n") + os.Exit(1) + } + + refreshDuration, err := time.ParseDuration(opts.AuthTokenRefreshDuration) + if err != nil { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to parse token refresh duration: %v\n", err) + os.Exit(1) + } + + if refreshDuration < 0 { + fmt.Printf("ERROR: generic-oidc-auth-provider: token refresh duration must be greater than 0\n") + os.Exit(1) + } + + cookieSecret, err := base64.StdEncoding.DecodeString(opts.AuthCookieSecret) + if err != nil { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to decode cookie secret: %v\n", err) + os.Exit(1) + } + + legacyOpts := options.NewLegacyOptions() + legacyOpts.LegacyProvider.ProviderType = "oidc" + legacyOpts.LegacyProvider.ProviderName = "oidc" + legacyOpts.LegacyProvider.ClientID = opts.ClientID + legacyOpts.LegacyProvider.ClientSecret = opts.ClientSecret + legacyOpts.LegacyProvider.OIDCIssuerURL = opts.IssuerURL + legacyOpts.LegacyProvider.Scope = opts.Scopes + legacyOpts.LegacyProvider.OIDCEmailClaim = opts.EmailClaim + legacyOpts.LegacyProvider.OIDCGroupsClaim = opts.GroupsClaim + legacyOpts.LegacyProvider.InsecureOIDCAllowUnverifiedEmail = strings.EqualFold(opts.AllowUnverifiedEmail, "true") + + oauthProxyOpts, err := legacyOpts.ToOptions() + if err != nil { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to convert legacy options to new options: %v\n", err) + os.Exit(1) + } + + oauthProxyOpts.Server.BindAddress = "" + oauthProxyOpts.MetricsServer.BindAddress = "" + if opts.PostgresConnectionDSN != "" { + oauthProxyOpts.Session.Type = options.PostgresSessionStoreType + oauthProxyOpts.Session.Postgres.ConnectionDSN = opts.PostgresConnectionDSN + oauthProxyOpts.Session.Postgres.TableNamePrefix = "oidc_" + } + oauthProxyOpts.Cookie.Refresh = refreshDuration + oauthProxyOpts.Cookie.Name = "obot_access_token" + oauthProxyOpts.Cookie.Secret = string(bytes.TrimSpace(cookieSecret)) + oauthProxyOpts.Cookie.Secure = strings.HasPrefix(opts.ObotServerURL, "https://") + oauthProxyOpts.Cookie.CSRFExpire = 30 * time.Minute + oauthProxyOpts.Templates.Path = os.Getenv("GPTSCRIPT_TOOL_DIR") + "/../auth-providers-common/templates" + oauthProxyOpts.RawRedirectURL = opts.ObotServerURL + "/" + if opts.AuthEmailDomains != "" { + emailDomains := strings.Split(opts.AuthEmailDomains, ",") + for i := range emailDomains { + emailDomains[i] = strings.TrimSpace(emailDomains[i]) + } + oauthProxyOpts.EmailDomains = emailDomains + } + + loggingEnabled := strings.EqualFold(opts.LoggingEnabled, "true") + oauthProxyOpts.Logging.RequestEnabled = loggingEnabled + oauthProxyOpts.Logging.AuthEnabled = loggingEnabled + oauthProxyOpts.Logging.StandardEnabled = loggingEnabled + + if err = validation.Validate(oauthProxyOpts); err != nil { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to validate options: %v\n", err) + os.Exit(1) + } + + oauthProxy, err := oauth2proxy.NewOAuthProxy(oauthProxyOpts, oauth2proxy.NewValidator(oauthProxyOpts.EmailDomains, oauthProxyOpts.AuthenticatedEmailsFile)) + if err != nil { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to create oauth2 proxy: %v\n", err) + os.Exit(1) + } + + port := os.Getenv("PORT") + if port == "" { + port = "9999" + } + + issuerURL := strings.TrimRight(opts.IssuerURL, "/") + + mux := http.NewServeMux() + mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(fmt.Sprintf("http://127.0.0.1:%s", port))) + }) + mux.HandleFunc("/obot-get-state", state.ObotGetState(oauthProxy)) + mux.HandleFunc("/obot-get-user-info", func(w http.ResponseWriter, r *http.Request) { + userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")) + if err != nil { + http.Error(w, fmt.Sprintf("failed to fetch user info: %v", err), http.StatusBadRequest) + return + } + + json.NewEncoder(w).Encode(userInfo) + }) + mux.HandleFunc("/obot-list-user-auth-groups", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("/", oauthProxy.ServeHTTP) + + fmt.Printf("listening on 127.0.0.1:%s\n", port) + if err := http.ListenAndServe("127.0.0.1:"+port, mux); !errors.Is(err, http.ErrServerClosed) { + fmt.Printf("ERROR: generic-oidc-auth-provider: failed to listen and serve: %v\n", err) + os.Exit(1) + } +} diff --git a/generic-oidc-auth-provider/pkg/profile/profile.go b/generic-oidc-auth-provider/pkg/profile/profile.go new file mode 100644 index 00000000..7071f080 --- /dev/null +++ b/generic-oidc-auth-provider/pkg/profile/profile.go @@ -0,0 +1,146 @@ +package profile + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// OIDCProfile is the normalized user info returned to Obot. Field names mirror +// the Google auth provider (id/email/verified_email/name/picture) so Obot's +// server can consume them uniformly, with OIDC-standard extras added. +type OIDCProfile struct { + ID string `json:"id"` + Sub string `json:"sub"` + Email string `json:"email"` + VerifiedEmail bool `json:"verified_email"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` + Groups []string `json:"groups,omitempty"` +} + +type discoveryDoc struct { + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +var ( + discoveryCache = map[string]discoveryDoc{} + discoveryMu sync.Mutex +) + +func discover(ctx context.Context, issuer string) (discoveryDoc, error) { + discoveryMu.Lock() + if d, ok := discoveryCache[issuer]; ok { + discoveryMu.Unlock() + return d, nil + } + discoveryMu.Unlock() + + url := issuer + "/.well-known/openid-configuration" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return discoveryDoc{}, err + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return discoveryDoc{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return discoveryDoc{}, fmt.Errorf("discovery at %s returned %d: %s", url, resp.StatusCode, body) + } + var d discoveryDoc + if err = json.NewDecoder(resp.Body).Decode(&d); err != nil { + return discoveryDoc{}, err + } + if d.UserinfoEndpoint == "" { + return discoveryDoc{}, fmt.Errorf("discovery doc at %s has no userinfo_endpoint", url) + } + + discoveryMu.Lock() + discoveryCache[issuer] = d + discoveryMu.Unlock() + return d, nil +} + +// rawUserInfo captures the standard OIDC userinfo claims plus the common +// non-standard "groups" claim. email_verified can be a bool or a string +// depending on the IdP, so it is decoded leniently. +type rawUserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified json.RawMessage `json:"email_verified"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` + Groups []string `json:"groups"` +} + +// FetchOIDCProfile discovers the issuer's userinfo endpoint and fetches the +// caller's claims using the provided Authorization header value. +func FetchOIDCProfile(ctx context.Context, issuer, authorization string) (*OIDCProfile, error) { + doc, err := discover(ctx, issuer) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, doc.UserinfoEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", authorization) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("userinfo at %s returned %d: %s", doc.UserinfoEndpoint, resp.StatusCode, body) + } + + var raw rawUserInfo + if err = json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + + return &OIDCProfile{ + ID: raw.Sub, + Sub: raw.Sub, + Email: raw.Email, + VerifiedEmail: parseVerified(raw.EmailVerified), + Name: raw.Name, + GivenName: raw.GivenName, + FamilyName: raw.FamilyName, + PreferredUsername: raw.PreferredUsername, + Picture: raw.Picture, + Groups: raw.Groups, + }, nil +} + +func parseVerified(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var b bool + if json.Unmarshal(raw, &b) == nil { + return b + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s == "true" + } + return false +} diff --git a/generic-oidc-auth-provider/pkg/profile/profile_test.go b/generic-oidc-auth-provider/pkg/profile/profile_test.go new file mode 100644 index 00000000..9c589f6d --- /dev/null +++ b/generic-oidc-auth-provider/pkg/profile/profile_test.go @@ -0,0 +1,64 @@ +package profile + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchOIDCProfile(t *testing.T) { + // Mock IdP: serves discovery + userinfo. The userinfo response includes a + // string email_verified to exercise the lenient parser, plus groups. + mux := http.NewServeMux() + var base string + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "issuer": base, + "userinfo_endpoint": base + "/userinfo", + }) + }) + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + http.Error(w, "unexpected auth: "+got, http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"sub":"abc-123","email":"jane@example.com","email_verified":"true",`+ + `"name":"Jane Doe","preferred_username":"jane","groups":["devs","admins"]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + base = server.URL + + p, err := FetchOIDCProfile(context.Background(), server.URL, "Bearer tok") + if err != nil { + t.Fatalf("FetchOIDCProfile returned error: %v", err) + } + if p.ID != "abc-123" || p.Sub != "abc-123" { + t.Errorf("unexpected id/sub: %q/%q", p.ID, p.Sub) + } + if p.Email != "jane@example.com" { + t.Errorf("unexpected email: %q", p.Email) + } + if !p.VerifiedEmail { + t.Errorf("expected verified_email true from string \"true\"") + } + if p.PreferredUsername != "jane" { + t.Errorf("unexpected preferred_username: %q", p.PreferredUsername) + } + if len(p.Groups) != 2 || p.Groups[0] != "devs" { + t.Errorf("unexpected groups: %v", p.Groups) + } +} + +func TestParseVerified(t *testing.T) { + cases := map[string]bool{`true`: true, `false`: false, `"true"`: true, `"false"`: false, ``: false} + for in, want := range cases { + if got := parseVerified(json.RawMessage(in)); got != want { + t.Errorf("parseVerified(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/generic-oidc-auth-provider/tool.gpt b/generic-oidc-auth-provider/tool.gpt new file mode 100644 index 00000000..1d2aee67 --- /dev/null +++ b/generic-oidc-auth-provider/tool.gpt @@ -0,0 +1,94 @@ +Name: OpenID Connect +Description: Generic OpenID Connect auth provider (works with Keycloak, Authentik, Dex, Auth0, and any OIDC-compliant IdP) +Metadata: envVars: OBOT_OIDC_AUTH_PROVIDER_CLIENT_ID,OBOT_OIDC_AUTH_PROVIDER_CLIENT_SECRET,OBOT_OIDC_AUTH_PROVIDER_ISSUER_URL,OBOT_AUTH_PROVIDER_COOKIE_SECRET,OBOT_AUTH_PROVIDER_EMAIL_DOMAINS +Metadata: noUserAuth: generic-oidc-auth-provider +Credential: ../placeholder-credential as generic-oidc-auth-provider + +#!sys.daemon ${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool + +--- +!metadata:OpenID Connect:providerMeta +{ + "icon": "https://cdn.jsdelivr.net/npm/simple-icons@v13/icons/openid.svg", + "iconDark": "https://cdn.jsdelivr.net/npm/simple-icons@v13/icons/openid.svg", + "link": "https://openid.net/connect/", + "postgresTablePrefix": "oidc_", + "envVars": [ + { + "name": "OBOT_OIDC_AUTH_PROVIDER_ISSUER_URL", + "friendlyName": "Issuer URL", + "description": "The OIDC issuer URL. Endpoints are discovered from {issuer}/.well-known/openid-configuration. For Keycloak this is https:///realms/.", + "sensitive": false + }, + { + "name": "OBOT_OIDC_AUTH_PROVIDER_CLIENT_ID", + "friendlyName": "Client ID", + "description": "Client ID of the confidential OIDC client registered with your identity provider.", + "sensitive": false + }, + { + "name": "OBOT_OIDC_AUTH_PROVIDER_CLIENT_SECRET", + "friendlyName": "Client Secret", + "description": "Client secret for the OIDC client.", + "sensitive": true + }, + { + "name": "OBOT_AUTH_PROVIDER_COOKIE_SECRET", + "friendlyName": "Cookie Secret", + "description": "Secret used to encrypt cookies. Must be a random string of length 16, 24, or 32.", + "sensitive": true, + "hidden": true + }, + { + "name": "OBOT_AUTH_PROVIDER_EMAIL_DOMAINS", + "friendlyName": "Allowed E-Mail Domains", + "description": "A list of email domains that are allowed to authenticate with this provider. * is a special value that allows all domains.", + "sensitive": false + } + ], + "optionalEnvVars": [ + { + "name": "OBOT_OIDC_AUTH_PROVIDER_SCOPES", + "friendlyName": "Scopes", + "description": "Space-separated OAuth scopes to request. Default: openid email profile", + "sensitive": false + }, + { + "name": "OBOT_OIDC_AUTH_PROVIDER_EMAIL_CLAIM", + "friendlyName": "Email Claim", + "description": "The OIDC claim containing the user's email. Default: email", + "sensitive": false + }, + { + "name": "OBOT_OIDC_AUTH_PROVIDER_GROUPS_CLAIM", + "friendlyName": "Groups Claim", + "description": "The OIDC claim containing the user's groups. Default: groups", + "sensitive": false + }, + { + "name": "OBOT_OIDC_AUTH_PROVIDER_ALLOW_UNVERIFIED_EMAIL", + "friendlyName": "Allow Unverified Email", + "description": "Allow users whose email_verified claim is not true to sign in. Many self-hosted IdPs do not set email_verified. Default: true", + "sensitive": false + }, + { + "name": "OBOT_AUTH_PROVIDER_POSTGRES_CONNECTION_DSN", + "friendlyName": "PostgreSQL connection string (DSN)", + "description": "The connection string for a PostgreSQL database to use for session storage. If unset, cookies will be used for session storage instead.", + "sensitive": true, + "hidden": true + }, + { + "name": "OBOT_AUTH_PROVIDER_TOKEN_REFRESH_DURATION", + "friendlyName": "Token Refresh Duration", + "description": "Time to wait before attempting to refresh auth tokens. Should be in a format like 1h1m1s. Default: 1h", + "sensitive": false + }, + { + "name": "OBOT_AUTH_PROVIDER_ENABLE_LOGGING", + "friendlyName": "Enable Logging", + "description": "Set to true to enable request, auth, and standard logging for the auth provider. Default: false", + "sensitive": false + } + ] +} diff --git a/index.yaml b/index.yaml index 9b439a39..9e0f1e75 100644 --- a/index.yaml +++ b/index.yaml @@ -19,6 +19,8 @@ modelProviders: reference: ./generic-openai-model-provider authProviders: + generic-oidc-auth-provider: + reference: ./generic-oidc-auth-provider github-auth-provider: reference: ./github-auth-provider google-auth-provider: From edb66f1cac098b02c81165963b73359aafa82851 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 17:32:29 +0200 Subject: [PATCH 02/11] feat(generic-oidc): expose user groups via /obot-list-user-auth-groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the group-listing shim endpoint to return the authenticated user's groups (from the configured groups claim / userinfo) instead of 404. This lets Obot enumerate groups for group-scoped MCP registries and group role assignments. Generic OIDC has no "list all groups" endpoint, so this reports the caller's own groups — sufficient for Obot to populate the groups it has seen across logins. --- generic-oidc-auth-provider/main.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index b2b97cdc..1629c31c 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -148,7 +148,21 @@ func main() { json.NewEncoder(w).Encode(userInfo) }) mux.HandleFunc("/obot-list-user-auth-groups", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) + // Return the caller's groups (from the configured groups claim) so Obot + // can surface them for group-scoped registries and group role assignments. + // Generic OIDC has no "list all groups" endpoint, so this reports the + // authenticated user's own groups — enough for Obot to enumerate the + // groups it has seen across logins. + userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")) + if err != nil { + http.Error(w, fmt.Sprintf("failed to fetch user info: %v", err), http.StatusBadRequest) + return + } + groups := make(state.GroupInfoList, 0, len(userInfo.Groups)) + for _, g := range userInfo.Groups { + groups = append(groups, state.GroupInfo{ID: g, Name: g}) + } + json.NewEncoder(w).Encode(groups) }) mux.HandleFunc("/", oauthProxy.ServeHTTP) From 3bca1651e3fe390cc4fb03652f5a05a50c93feb5 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 17:42:05 +0200 Subject: [PATCH 03/11] fix(generic-oidc): decode groups from JWT access token, fall back to userinfo Avoids spurious userinfo 401s when the access token is near/just expired by reading the groups claim directly from the JWT (signature already verified upstream by oauth2-proxy). Falls back to the userinfo endpoint for opaque tokens. --- generic-oidc-auth-provider/main.go | 62 ++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 1629c31c..1e5157dd 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -42,6 +42,42 @@ type Options struct { LoggingEnabled string `usage:"Enable oauth2-proxy logging" optional:"true" env:"OBOT_AUTH_PROVIDER_ENABLE_LOGGING"` } +// groupsFromBearerToken decodes a JWT bearer token (signature NOT verified — +// the token was already validated by oauth2-proxy upstream) and extracts the +// named claim as a list of group names. Returns nil when the token is opaque +// (not a JWT) or the claim is absent, so the caller can fall back to userinfo. +func groupsFromBearerToken(authHeader, claim string) []string { + tok := strings.TrimSpace(authHeader) + for _, p := range []string{"Bearer ", "bearer "} { + tok = strings.TrimPrefix(tok, p) + } + parts := strings.Split(tok, ".") + if len(parts) < 2 { + return nil + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { + return nil + } + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return nil + } + arr, ok := claims[claim].([]any) + if !ok { + return nil + } + groups := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok { + groups = append(groups, s) + } + } + return groups +} + func main() { var opts Options if err := env.LoadEnvForStruct(&opts); err != nil { @@ -148,18 +184,22 @@ func main() { json.NewEncoder(w).Encode(userInfo) }) mux.HandleFunc("/obot-list-user-auth-groups", func(w http.ResponseWriter, r *http.Request) { - // Return the caller's groups (from the configured groups claim) so Obot - // can surface them for group-scoped registries and group role assignments. - // Generic OIDC has no "list all groups" endpoint, so this reports the - // authenticated user's own groups — enough for Obot to enumerate the - // groups it has seen across logins. - userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")) - if err != nil { - http.Error(w, fmt.Sprintf("failed to fetch user info: %v", err), http.StatusBadRequest) - return + // Return the caller's groups so Obot can surface them for group-scoped + // registries and group role assignments. Generic OIDC has no "list all + // groups" endpoint, so this reports the authenticated user's own groups. + // + // Prefer decoding the groups claim straight from the (JWT) access token: + // it needs no network call and keeps working even if the access token is + // near/just expired (avoids spurious userinfo 401s). Fall back to the + // userinfo endpoint for providers that issue opaque access tokens. + groupNames := groupsFromBearerToken(r.Header.Get("Authorization"), opts.GroupsClaim) + if groupNames == nil { + if userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")); err == nil { + groupNames = userInfo.Groups + } } - groups := make(state.GroupInfoList, 0, len(userInfo.Groups)) - for _, g := range userInfo.Groups { + groups := make(state.GroupInfoList, 0, len(groupNames)) + for _, g := range groupNames { groups = append(groups, state.GroupInfo{ID: g, Name: g}) } json.NewEncoder(w).Encode(groups) From 9787208b24d6e2edfdb5dced554e1f2bd1681859 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 18:24:02 +0200 Subject: [PATCH 04/11] feat(generic-oidc): optional Keycloak Admin-API group enumeration list-user-auth-groups now enumerates realm groups via Keycloak's Admin API using the OIDC client's own service-account (client_credentials), so Obot can populate the group picker for group-scoped registries and group role assignments. Requires the client to have a service account with query-groups/view-users; falls back to the caller's own token groups (and to userinfo) for non-Keycloak issuers or missing permissions. --- generic-oidc-auth-provider/main.go | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 1e5157dd..e8a7917f 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -2,11 +2,13 @@ package main import ( "bytes" + "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" + "net/url" "os" "strings" "time" @@ -78,6 +80,89 @@ func groupsFromBearerToken(authHeader, claim string) []string { return groups } +type idpGroup struct { + Name string `json:"name"` + Path string `json:"path"` + SubGroups []idpGroup `json:"subGroups"` +} + +// listIdPGroupsViaKeycloakAdmin enumerates realm groups through Keycloak's Admin +// API using the OIDC client's own service-account (client_credentials). This is +// how Obot's enterprise connectors populate the group picker; generic OIDC has +// no group-list endpoint, so we offer it for Keycloak when the client has a +// service account with `query-groups`/`view-users`. Returns an error (so the +// caller falls back to per-user groups) for non-Keycloak issuers or missing +// permissions. Group names are returned as IDs to match the token groups claim. +func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, clientSecret, search string) (state.GroupInfoList, error) { + issuer = strings.TrimRight(issuer, "/") + idx := strings.Index(issuer, "/realms/") + if idx < 0 || clientSecret == "" { + return nil, fmt.Errorf("not a keycloak issuer or no client secret") + } + base := issuer[:idx] + realm := issuer[idx+len("/realms/"):] + if i := strings.IndexByte(realm, '/'); i >= 0 { + realm = realm[:i] + } + + // client_credentials token from the OIDC client's service account + form := url.Values{"grant_type": {"client_credentials"}, "client_id": {clientID}, "client_secret": {clientSecret}} + tokReq, err := http.NewRequestWithContext(ctx, http.MethodPost, issuer+"/protocol/openid-connect/token", strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + tokReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := &http.Client{Timeout: 15 * time.Second} + tokResp, err := client.Do(tokReq) + if err != nil { + return nil, err + } + defer tokResp.Body.Close() + if tokResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("client_credentials token request returned %d", tokResp.StatusCode) + } + var tok struct { + AccessToken string `json:"access_token"` + } + if err = json.NewDecoder(tokResp.Body).Decode(&tok); err != nil || tok.AccessToken == "" { + return nil, fmt.Errorf("no service-account token") + } + + groupsURL := fmt.Sprintf("%s/admin/realms/%s/groups?briefRepresentation=true&max=1000", base, realm) + if search != "" { + groupsURL += "&search=" + url.QueryEscape(search) + } + gReq, err := http.NewRequestWithContext(ctx, http.MethodGet, groupsURL, nil) + if err != nil { + return nil, err + } + gReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + gResp, err := client.Do(gReq) + if err != nil { + return nil, err + } + defer gResp.Body.Close() + if gResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("admin groups query returned %d", gResp.StatusCode) + } + var roots []idpGroup + if err = json.NewDecoder(gResp.Body).Decode(&roots); err != nil { + return nil, err + } + + out := state.GroupInfoList{} + var walk func([]idpGroup) + walk = func(gs []idpGroup) { + for _, g := range gs { + // ID = leaf name to match the token's groups claim (full.path=false) + out = append(out, state.GroupInfo{ID: g.Name, Name: g.Name}) + walk(g.SubGroups) + } + } + walk(roots) + return out, nil +} + func main() { var opts Options if err := env.LoadEnvForStruct(&opts); err != nil { @@ -192,6 +277,13 @@ func main() { // it needs no network call and keeps working even if the access token is // near/just expired (avoids spurious userinfo 401s). Fall back to the // userinfo endpoint for providers that issue opaque access tokens. + // Preferred: enumerate realm groups via the IdP admin API (Keycloak) so + // Obot can populate the group picker for group-scoped registries/roles. + if groups, err := listIdPGroupsViaKeycloakAdmin(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, r.URL.Query().Get("name")); err == nil { + json.NewEncoder(w).Encode(groups) + return + } + // Fallback: the caller's own groups from their token (JWT claim, then userinfo). groupNames := groupsFromBearerToken(r.Header.Get("Authorization"), opts.GroupsClaim) if groupNames == nil { if userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")); err == nil { From 83306d01147ab057c24641836221dd92fb732e05 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 18:29:10 +0200 Subject: [PATCH 05/11] feat(generic-oidc): recurse Keycloak subgroups via /children Keycloak (v23+) only returns top-level groups in the groups listing; nested groups (e.g. offices/DEVBORAS) carry subGroupCount but no inline subGroups. Fetch each group's children via the /children endpoint and recurse so the full group tree is enumerable for group-scoped registries/role assignments. --- generic-oidc-auth-provider/main.go | 39 +++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index e8a7917f..431bc7b9 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -81,9 +81,36 @@ func groupsFromBearerToken(authHeader, claim string) []string { } type idpGroup struct { - Name string `json:"name"` - Path string `json:"path"` - SubGroups []idpGroup `json:"subGroups"` + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + SubGroupCount int `json:"subGroupCount"` + SubGroups []idpGroup `json:"subGroups"` +} + +// fetchKeycloakChildren returns the direct children of a Keycloak group. +// Keycloak (v23+) does not inline subGroups in the groups listing, so nested +// groups must be fetched via the /children endpoint. +func fetchKeycloakChildren(ctx context.Context, client *http.Client, base, realm, token, groupID string) []idpGroup { + u := fmt.Sprintf("%s/admin/realms/%s/groups/%s/children?briefRepresentation=true&max=1000", base, realm, url.PathEscape(groupID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + var children []idpGroup + if err = json.NewDecoder(resp.Body).Decode(&children); err != nil { + return nil + } + return children } // listIdPGroupsViaKeycloakAdmin enumerates realm groups through Keycloak's Admin @@ -156,7 +183,11 @@ func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, client for _, g := range gs { // ID = leaf name to match the token's groups claim (full.path=false) out = append(out, state.GroupInfo{ID: g.Name, Name: g.Name}) - walk(g.SubGroups) + children := g.SubGroups + if len(children) == 0 && g.SubGroupCount > 0 && g.ID != "" { + children = fetchKeycloakChildren(ctx, client, base, realm, tok.AccessToken, g.ID) + } + walk(children) } } walk(roots) From ed667e8345e79f8db4afc3992fe6776392b611d5 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 18:45:02 +0200 Subject: [PATCH 06/11] feat(generic-oidc): show subgroups with hierarchical path names Render the Keycloak group path as "parent / child" in the group picker (e.g. "offices / DEVBORAS") so nested groups read as subgroups in Obot's flat list. ID stays the leaf name to match the token's groups claim. Co-Authored-By: Claude Opus 4.8 (1M context) --- generic-oidc-auth-provider/main.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 431bc7b9..0a8b33a9 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -181,8 +181,14 @@ func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, client var walk func([]idpGroup) walk = func(gs []idpGroup) { for _, g := range gs { - // ID = leaf name to match the token's groups claim (full.path=false) - out = append(out, state.GroupInfo{ID: g.Name, Name: g.Name}) + // ID = leaf name to match the token's groups claim (full.path=false). + // Name = the group's path rendered as "parent / child" so nested groups + // read as subgroups in Obot's flat picker (e.g. "offices / DEVBORAS"). + display := g.Name + if g.Path != "" { + display = strings.ReplaceAll(strings.TrimPrefix(g.Path, "/"), "/", " / ") + } + out = append(out, state.GroupInfo{ID: g.Name, Name: display}) children := g.SubGroups if len(children) == 0 && g.SubGroupCount > 0 && g.ID != "" { children = fetchKeycloakChildren(ctx, client, base, realm, tok.AccessToken, g.ID) From 74bc25020665c46c7128486bab098ca447d0bebf Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 18:58:18 +0200 Subject: [PATCH 07/11] fix(generic-oidc): correct group endpoints (security + picker) Obot calls TWO group endpoints; the provider only implemented one, under the wrong name: - /obot-list-auth-groups (GET): enumerate ALL realm groups for the admin group picker. The provider never served this path, so the picker fell back to Obot's DB cache (leaf names only). Now implemented via the Keycloak Admin API with hierarchical "parent / child" display names. - /obot-list-user-auth-groups (POST, body=provider user ID): the groups a SPECIFIC user belongs to, used to sync per-user memberships. The handler previously returned ALL realm groups here, which would make every user a member of every group. Now looks up only that user's groups via /admin/realms/{realm}/users/{id}/groups. Refactors the shared client_credentials token + path-name rendering into keycloakAdmin/groupDisplayName/fetchKeycloakGroups helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- generic-oidc-auth-provider/main.go | 198 +++++++++++++++++------------ 1 file changed, 119 insertions(+), 79 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 0a8b33a9..803aab84 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "os" @@ -88,110 +89,114 @@ type idpGroup struct { SubGroups []idpGroup `json:"subGroups"` } -// fetchKeycloakChildren returns the direct children of a Keycloak group. -// Keycloak (v23+) does not inline subGroups in the groups listing, so nested -// groups must be fetched via the /children endpoint. -func fetchKeycloakChildren(ctx context.Context, client *http.Client, base, realm, token, groupID string) []idpGroup { - u := fmt.Sprintf("%s/admin/realms/%s/groups/%s/children?briefRepresentation=true&max=1000", base, realm, url.PathEscape(groupID)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil - } - req.Header.Set("Authorization", "Bearer "+token) - resp, err := client.Do(req) - if err != nil { - return nil - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil - } - var children []idpGroup - if err = json.NewDecoder(resp.Body).Decode(&children); err != nil { - return nil - } - return children -} +var keycloakHTTP = &http.Client{Timeout: 15 * time.Second} -// listIdPGroupsViaKeycloakAdmin enumerates realm groups through Keycloak's Admin -// API using the OIDC client's own service-account (client_credentials). This is -// how Obot's enterprise connectors populate the group picker; generic OIDC has -// no group-list endpoint, so we offer it for Keycloak when the client has a -// service account with `query-groups`/`view-users`. Returns an error (so the -// caller falls back to per-user groups) for non-Keycloak issuers or missing -// permissions. Group names are returned as IDs to match the token groups claim. -func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, clientSecret, search string) (state.GroupInfoList, error) { +// keycloakAdmin parses a Keycloak issuer into base+realm and obtains a service- +// account access token via client_credentials. Returns an error for non-Keycloak +// issuers or when no client secret is configured, so callers can fall back. +func keycloakAdmin(ctx context.Context, issuer, clientID, clientSecret string) (base, realm, token string, err error) { issuer = strings.TrimRight(issuer, "/") idx := strings.Index(issuer, "/realms/") if idx < 0 || clientSecret == "" { - return nil, fmt.Errorf("not a keycloak issuer or no client secret") + return "", "", "", fmt.Errorf("not a keycloak issuer or no client secret") } - base := issuer[:idx] - realm := issuer[idx+len("/realms/"):] + base = issuer[:idx] + realm = issuer[idx+len("/realms/"):] if i := strings.IndexByte(realm, '/'); i >= 0 { realm = realm[:i] } - // client_credentials token from the OIDC client's service account form := url.Values{"grant_type": {"client_credentials"}, "client_id": {clientID}, "client_secret": {clientSecret}} - tokReq, err := http.NewRequestWithContext(ctx, http.MethodPost, issuer+"/protocol/openid-connect/token", strings.NewReader(form.Encode())) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuer+"/protocol/openid-connect/token", strings.NewReader(form.Encode())) if err != nil { - return nil, err + return "", "", "", err } - tokReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - client := &http.Client{Timeout: 15 * time.Second} - tokResp, err := client.Do(tokReq) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := keycloakHTTP.Do(req) if err != nil { - return nil, err + return "", "", "", err } - defer tokResp.Body.Close() - if tokResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("client_credentials token request returned %d", tokResp.StatusCode) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", "", fmt.Errorf("client_credentials token request returned %d", resp.StatusCode) } var tok struct { AccessToken string `json:"access_token"` } - if err = json.NewDecoder(tokResp.Body).Decode(&tok); err != nil || tok.AccessToken == "" { - return nil, fmt.Errorf("no service-account token") + if err = json.NewDecoder(resp.Body).Decode(&tok); err != nil || tok.AccessToken == "" { + return "", "", "", fmt.Errorf("no service-account token") + } + return base, realm, tok.AccessToken, nil +} + +// groupDisplayName renders a Keycloak group path as "parent / child" so nested +// groups read as subgroups in Obot's flat picker (e.g. "offices / DEVBORAS"). +func groupDisplayName(path, leaf string) string { + if path == "" { + return leaf } + return strings.ReplaceAll(strings.TrimPrefix(path, "/"), "/", " / ") +} - groupsURL := fmt.Sprintf("%s/admin/realms/%s/groups?briefRepresentation=true&max=1000", base, realm) - if search != "" { - groupsURL += "&search=" + url.QueryEscape(search) +// fetchKeycloakGroups GETs a Keycloak admin groups endpoint and decodes the list. +func fetchKeycloakGroups(ctx context.Context, token, endpoint string) ([]idpGroup, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err } - gReq, err := http.NewRequestWithContext(ctx, http.MethodGet, groupsURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := keycloakHTTP.Do(req) if err != nil { return nil, err } - gReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) - gResp, err := client.Do(gReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("keycloak admin query %s returned %d", endpoint, resp.StatusCode) + } + var groups []idpGroup + if err = json.NewDecoder(resp.Body).Decode(&groups); err != nil { + return nil, err + } + return groups, nil +} + +// listIdPGroupsViaKeycloakAdmin enumerates realm groups through Keycloak's Admin +// API using the OIDC client's own service-account (client_credentials). This is +// how Obot's enterprise connectors populate the group picker; generic OIDC has +// no group-list endpoint, so we offer it for Keycloak when the client has a +// service account with `query-groups`/`view-users`. Returns an error (so the +// caller falls back to per-user groups) for non-Keycloak issuers or missing +// permissions. Group names are returned as IDs to match the token groups claim. +func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, clientSecret, search string) (state.GroupInfoList, error) { + base, realm, token, err := keycloakAdmin(ctx, issuer, clientID, clientSecret) if err != nil { return nil, err } - defer gResp.Body.Close() - if gResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("admin groups query returned %d", gResp.StatusCode) + + groupsURL := fmt.Sprintf("%s/admin/realms/%s/groups?briefRepresentation=true&max=1000", base, realm) + if search != "" { + groupsURL += "&search=" + url.QueryEscape(search) } - var roots []idpGroup - if err = json.NewDecoder(gResp.Body).Decode(&roots); err != nil { + roots, err := fetchKeycloakGroups(ctx, token, groupsURL) + if err != nil { return nil, err } + // Keycloak (v23+) does not inline subGroups in the groups listing — it returns + // only subGroupCount — so nested groups are fetched via the /children endpoint. out := state.GroupInfoList{} var walk func([]idpGroup) walk = func(gs []idpGroup) { for _, g := range gs { - // ID = leaf name to match the token's groups claim (full.path=false). - // Name = the group's path rendered as "parent / child" so nested groups - // read as subgroups in Obot's flat picker (e.g. "offices / DEVBORAS"). - display := g.Name - if g.Path != "" { - display = strings.ReplaceAll(strings.TrimPrefix(g.Path, "/"), "/", " / ") - } - out = append(out, state.GroupInfo{ID: g.Name, Name: display}) + // ID = leaf name to match the token's groups claim (full.path=false); + // Name = hierarchical path so subgroups read as such in the picker. + out = append(out, state.GroupInfo{ID: g.Name, Name: groupDisplayName(g.Path, g.Name)}) children := g.SubGroups if len(children) == 0 && g.SubGroupCount > 0 && g.ID != "" { - children = fetchKeycloakChildren(ctx, client, base, realm, tok.AccessToken, g.ID) + if kids, err := fetchKeycloakGroups(ctx, token, + fmt.Sprintf("%s/admin/realms/%s/groups/%s/children?briefRepresentation=true&max=1000", base, realm, url.PathEscape(g.ID))); err == nil { + children = kids + } } walk(children) } @@ -200,6 +205,30 @@ func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, client return out, nil } +// listKeycloakUserGroups returns the groups a SPECIFIC user (by Keycloak user +// UUID) belongs to, via the admin API. Obot calls /obot-list-user-auth-groups to +// sync per-user memberships, so this MUST return only that user's groups — never +// the whole realm (that would make every user a member of every group). +func listKeycloakUserGroups(ctx context.Context, issuer, clientID, clientSecret, userID string) (state.GroupInfoList, error) { + if userID == "" { + return nil, fmt.Errorf("no user id") + } + base, realm, token, err := keycloakAdmin(ctx, issuer, clientID, clientSecret) + if err != nil { + return nil, err + } + groups, err := fetchKeycloakGroups(ctx, token, + fmt.Sprintf("%s/admin/realms/%s/users/%s/groups?briefRepresentation=true&max=1000", base, realm, url.PathEscape(userID))) + if err != nil { + return nil, err + } + out := make(state.GroupInfoList, 0, len(groups)) + for _, g := range groups { + out = append(out, state.GroupInfo{ID: g.Name, Name: groupDisplayName(g.Path, g.Name)}) + } + return out, nil +} + func main() { var opts Options if err := env.LoadEnvForStruct(&opts); err != nil { @@ -305,22 +334,33 @@ func main() { json.NewEncoder(w).Encode(userInfo) }) + // /obot-list-auth-groups: enumerate ALL realm groups for the admin group + // picker (group-scoped registries / role assignments). Obot merges this with + // its DB cache, so on error we return an empty list rather than failing. + mux.HandleFunc("/obot-list-auth-groups", func(w http.ResponseWriter, r *http.Request) { + groups, err := listIdPGroupsViaKeycloakAdmin(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, r.URL.Query().Get("name")) + if err != nil { + groups = state.GroupInfoList{} + } + json.NewEncoder(w).Encode(groups) + }) + // /obot-list-user-auth-groups: the groups a SPECIFIC user belongs to. Obot + // POSTs the provider user ID (Keycloak user UUID) as the request body and uses + // the result to sync that user's memberships — so we must return ONLY that + // user's groups. For Keycloak we look them up via the admin API; otherwise we + // fall back to the groups claim in a bearer token if one was forwarded. mux.HandleFunc("/obot-list-user-auth-groups", func(w http.ResponseWriter, r *http.Request) { - // Return the caller's groups so Obot can surface them for group-scoped - // registries and group role assignments. Generic OIDC has no "list all - // groups" endpoint, so this reports the authenticated user's own groups. - // - // Prefer decoding the groups claim straight from the (JWT) access token: - // it needs no network call and keeps working even if the access token is - // near/just expired (avoids spurious userinfo 401s). Fall back to the - // userinfo endpoint for providers that issue opaque access tokens. - // Preferred: enumerate realm groups via the IdP admin API (Keycloak) so - // Obot can populate the group picker for group-scoped registries/roles. - if groups, err := listIdPGroupsViaKeycloakAdmin(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, r.URL.Query().Get("name")); err == nil { + var userID string + if r.Body != nil { + body, _ := io.ReadAll(r.Body) + userID = strings.TrimSpace(string(body)) + } + if groups, err := listKeycloakUserGroups(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, userID); err == nil { json.NewEncoder(w).Encode(groups) return } - // Fallback: the caller's own groups from their token (JWT claim, then userinfo). + // Fallback for non-Keycloak IdPs: the caller's own groups from a forwarded + // token (JWT claim, then userinfo). Never returns the whole realm. groupNames := groupsFromBearerToken(r.Header.Get("Authorization"), opts.GroupsClaim) if groupNames == nil { if userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")); err == nil { From 36cf07464c87ae24541eefb031f79254b9a58ca3 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 19:10:35 +0200 Subject: [PATCH 08/11] feat(generic-oidc): gate Keycloak group admin behind explicit opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the implicit "/realms/ in issuer" sniff with an explicit OBOT_OIDC_AUTH_PROVIDER_GROUP_ADMIN env var (only supported value: keycloak). This keeps the provider generic OIDC by default — groups come from the token claim for any IdP, exactly like the OSS Google/GitHub providers — and makes the vendor-specific group-admin features (enumerate all groups for the picker + resolve a user's real memberships via the Keycloak Admin API) an unambiguous opt-in. Exposed as an optional config field in tool.gpt. Co-Authored-By: Claude Opus 4.8 (1M context) --- generic-oidc-auth-provider/main.go | 47 ++++++++++++++++++++--------- generic-oidc-auth-provider/tool.gpt | 6 ++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 803aab84..7513fb7e 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -36,6 +36,15 @@ type Options struct { // AllowUnverifiedEmail lets users without email_verified=true sign in. // Many self-hosted IdPs (e.g. Keycloak) do not set email_verified by default. AllowUnverifiedEmail string `env:"OBOT_OIDC_AUTH_PROVIDER_ALLOW_UNVERIFIED_EMAIL" default:"true" optional:"true"` + // GroupAdmin opts in to IdP admin-API group features: enumerating all groups + // for Obot's admin group picker, and resolving a specific user's real group + // memberships. Generic OIDC has no standard endpoint for either, so this is + // vendor-specific. The only supported value is "keycloak", which uses the + // OIDC client's own service account (client_credentials) against the Keycloak + // Admin API — the client must have realm-management roles query-groups and + // view-users. Leave empty to rely solely on the token's groups claim (works + // with any OIDC IdP, same as the OSS Google/GitHub providers). + GroupAdmin string `env:"OBOT_OIDC_AUTH_PROVIDER_GROUP_ADMIN" default:"" optional:"true"` ObotServerURL string `env:"OBOT_SERVER_PUBLIC_URL,OBOT_SERVER_URL"` PostgresConnectionDSN string `env:"OBOT_AUTH_PROVIDER_POSTGRES_CONNECTION_DSN" optional:"true"` @@ -319,6 +328,9 @@ func main() { } issuerURL := strings.TrimRight(opts.IssuerURL, "/") + // Opt-in, vendor-specific group admin (enumeration + per-user lookup). When + // off, the provider is pure generic OIDC: groups come only from the token claim. + keycloakGroupAdmin := strings.EqualFold(strings.TrimSpace(opts.GroupAdmin), "keycloak") mux := http.NewServeMux() mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { @@ -335,29 +347,34 @@ func main() { json.NewEncoder(w).Encode(userInfo) }) // /obot-list-auth-groups: enumerate ALL realm groups for the admin group - // picker (group-scoped registries / role assignments). Obot merges this with - // its DB cache, so on error we return an empty list rather than failing. + // picker (group-scoped registries / role assignments). Only meaningful with an + // IdP admin API (opt-in); generic OIDC has no such endpoint, so we return an + // empty list and Obot falls back to the groups it has discovered in its DB. mux.HandleFunc("/obot-list-auth-groups", func(w http.ResponseWriter, r *http.Request) { - groups, err := listIdPGroupsViaKeycloakAdmin(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, r.URL.Query().Get("name")) - if err != nil { - groups = state.GroupInfoList{} + groups := state.GroupInfoList{} + if keycloakGroupAdmin { + if g, err := listIdPGroupsViaKeycloakAdmin(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, r.URL.Query().Get("name")); err == nil { + groups = g + } } json.NewEncoder(w).Encode(groups) }) // /obot-list-user-auth-groups: the groups a SPECIFIC user belongs to. Obot // POSTs the provider user ID (Keycloak user UUID) as the request body and uses // the result to sync that user's memberships — so we must return ONLY that - // user's groups. For Keycloak we look them up via the admin API; otherwise we - // fall back to the groups claim in a bearer token if one was forwarded. + // user's groups. With the Keycloak group admin opted in we look them up via the + // admin API; otherwise we fall back to the groups claim in a forwarded token. mux.HandleFunc("/obot-list-user-auth-groups", func(w http.ResponseWriter, r *http.Request) { - var userID string - if r.Body != nil { - body, _ := io.ReadAll(r.Body) - userID = strings.TrimSpace(string(body)) - } - if groups, err := listKeycloakUserGroups(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, userID); err == nil { - json.NewEncoder(w).Encode(groups) - return + if keycloakGroupAdmin { + var userID string + if r.Body != nil { + body, _ := io.ReadAll(r.Body) + userID = strings.TrimSpace(string(body)) + } + if groups, err := listKeycloakUserGroups(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, userID); err == nil { + json.NewEncoder(w).Encode(groups) + return + } } // Fallback for non-Keycloak IdPs: the caller's own groups from a forwarded // token (JWT claim, then userinfo). Never returns the whole realm. diff --git a/generic-oidc-auth-provider/tool.gpt b/generic-oidc-auth-provider/tool.gpt index 1d2aee67..fb0e67b0 100644 --- a/generic-oidc-auth-provider/tool.gpt +++ b/generic-oidc-auth-provider/tool.gpt @@ -65,6 +65,12 @@ Credential: ../placeholder-credential as generic-oidc-auth-provider "description": "The OIDC claim containing the user's groups. Default: groups", "sensitive": false }, + { + "name": "OBOT_OIDC_AUTH_PROVIDER_GROUP_ADMIN", + "friendlyName": "Group Admin (IdP)", + "description": "Opt in to IdP admin-API group features: enumerate all groups for the admin group picker, and resolve each user's real group memberships. Only supported value: keycloak. Requires the OIDC client to have a service account with realm-management roles query-groups and view-users. Leave empty to use only the groups claim from the token (any OIDC IdP).", + "sensitive": false + }, { "name": "OBOT_OIDC_AUTH_PROVIDER_ALLOW_UNVERIFIED_EMAIL", "friendlyName": "Allow Unverified Email", From 7b28a5427a07fc4bc616668f40c3107984039349 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 19:17:34 +0200 Subject: [PATCH 09/11] feat(generic-oidc): forgiving Group Admin value + clearer field label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept the canonical "keycloak" plus common truthy values (true/1/yes/ on/enabled) for OBOT_OIDC_AUTH_PROVIDER_GROUP_ADMIN, and treat anything unrecognized (blank, typo, unsupported backend) as off — which only disables group enumeration, never blocks login. Obot's provider config form is text-only (no native dropdown), so the field label/description now make the expected value explicit. Co-Authored-By: Claude Opus 4.8 (1M context) --- generic-oidc-auth-provider/main.go | 27 +++++++++++++++++++++------ generic-oidc-auth-provider/tool.gpt | 4 ++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 7513fb7e..831a35a0 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -39,11 +39,12 @@ type Options struct { // GroupAdmin opts in to IdP admin-API group features: enumerating all groups // for Obot's admin group picker, and resolving a specific user's real group // memberships. Generic OIDC has no standard endpoint for either, so this is - // vendor-specific. The only supported value is "keycloak", which uses the - // OIDC client's own service account (client_credentials) against the Keycloak - // Admin API — the client must have realm-management roles query-groups and - // view-users. Leave empty to rely solely on the token's groups claim (works - // with any OIDC IdP, same as the OSS Google/GitHub providers). + // vendor-specific. Set to "keycloak" to enable (the only backend today); it + // uses the OIDC client's own service account (client_credentials) against the + // Keycloak Admin API — the client must have realm-management roles query-groups + // and view-users. Leave empty to rely solely on the token's groups claim (works + // with any OIDC IdP, same as the OSS Google/GitHub providers). See + // groupAdminEnabled for accepted values. GroupAdmin string `env:"OBOT_OIDC_AUTH_PROVIDER_GROUP_ADMIN" default:"" optional:"true"` ObotServerURL string `env:"OBOT_SERVER_PUBLIC_URL,OBOT_SERVER_URL"` @@ -54,6 +55,20 @@ type Options struct { LoggingEnabled string `usage:"Enable oauth2-proxy logging" optional:"true" env:"OBOT_AUTH_PROVIDER_ENABLE_LOGGING"` } +// groupAdminEnabled reports whether the IdP admin-API group features are opted +// in. The canonical value is the backend name "keycloak"; common truthy values +// are also accepted so a stray "true"/"yes" still works. Anything unrecognized +// (blank, a typo, an unsupported backend) is treated as off — that only disables +// group enumeration / membership lookup, it never blocks login. +func groupAdminEnabled(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "keycloak", "true", "1", "yes", "on", "enabled": + return true + default: + return false + } +} + // groupsFromBearerToken decodes a JWT bearer token (signature NOT verified — // the token was already validated by oauth2-proxy upstream) and extracts the // named claim as a list of group names. Returns nil when the token is opaque @@ -330,7 +345,7 @@ func main() { issuerURL := strings.TrimRight(opts.IssuerURL, "/") // Opt-in, vendor-specific group admin (enumeration + per-user lookup). When // off, the provider is pure generic OIDC: groups come only from the token claim. - keycloakGroupAdmin := strings.EqualFold(strings.TrimSpace(opts.GroupAdmin), "keycloak") + keycloakGroupAdmin := groupAdminEnabled(opts.GroupAdmin) mux := http.NewServeMux() mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { diff --git a/generic-oidc-auth-provider/tool.gpt b/generic-oidc-auth-provider/tool.gpt index fb0e67b0..5519f3ec 100644 --- a/generic-oidc-auth-provider/tool.gpt +++ b/generic-oidc-auth-provider/tool.gpt @@ -67,8 +67,8 @@ Credential: ../placeholder-credential as generic-oidc-auth-provider }, { "name": "OBOT_OIDC_AUTH_PROVIDER_GROUP_ADMIN", - "friendlyName": "Group Admin (IdP)", - "description": "Opt in to IdP admin-API group features: enumerate all groups for the admin group picker, and resolve each user's real group memberships. Only supported value: keycloak. Requires the OIDC client to have a service account with realm-management roles query-groups and view-users. Leave empty to use only the groups claim from the token (any OIDC IdP).", + "friendlyName": "Group Admin (IdP) — enter: keycloak", + "description": "Enter 'keycloak' to enable IdP admin-API group features: enumerate all groups for the admin group picker, and resolve each user's real group memberships. 'keycloak' is the only supported backend today and requires the OIDC client to have a service account with realm-management roles query-groups and view-users. Leave this blank for any other IdP — groups then come only from the token claim (default). An unrecognized value is treated as blank and never blocks login.", "sensitive": false }, { From 704a23c66fdeaa7a47f1cc6063ddbaa1748c9cd5 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 19:37:26 +0200 Subject: [PATCH 10/11] fix(generic-oidc): don't wipe memberships on group lookup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review hardening of /obot-list-user-auth-groups: - On a Keycloak admin-API lookup failure, return an error (502) instead of an empty 200. Obot syncs a user's group memberships from this endpoint and treats an empty list as "member of no groups", deleting their memberships — so a transient Keycloak failure during a periodic re-check would wipe them. An error makes Obot skip the sync and keep them. - When the Keycloak group admin is not opted in, return 404 (as the OSS Google/GitHub providers do) rather than an empty list. Generic-OIDC groups already reach Obot via the token claim in /obot-get-state. - Remove the now-unreachable JWT bearer-token group fallback: Obot calls this endpoint with only the provider user ID and no Authorization header. Co-Authored-By: Claude Opus 4.8 (1M context) --- generic-oidc-auth-provider/main.go | 80 ++++++++---------------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 831a35a0..9c265c5a 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -69,42 +69,6 @@ func groupAdminEnabled(v string) bool { } } -// groupsFromBearerToken decodes a JWT bearer token (signature NOT verified — -// the token was already validated by oauth2-proxy upstream) and extracts the -// named claim as a list of group names. Returns nil when the token is opaque -// (not a JWT) or the claim is absent, so the caller can fall back to userinfo. -func groupsFromBearerToken(authHeader, claim string) []string { - tok := strings.TrimSpace(authHeader) - for _, p := range []string{"Bearer ", "bearer "} { - tok = strings.TrimPrefix(tok, p) - } - parts := strings.Split(tok, ".") - if len(parts) < 2 { - return nil - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { - return nil - } - } - var claims map[string]any - if err := json.Unmarshal(payload, &claims); err != nil { - return nil - } - arr, ok := claims[claim].([]any) - if !ok { - return nil - } - groups := make([]string, 0, len(arr)) - for _, v := range arr { - if s, ok := v.(string); ok { - groups = append(groups, s) - } - } - return groups -} - type idpGroup struct { ID string `json:"id"` Name string `json:"name"` @@ -375,33 +339,29 @@ func main() { json.NewEncoder(w).Encode(groups) }) // /obot-list-user-auth-groups: the groups a SPECIFIC user belongs to. Obot - // POSTs the provider user ID (Keycloak user UUID) as the request body and uses - // the result to sync that user's memberships — so we must return ONLY that - // user's groups. With the Keycloak group admin opted in we look them up via the - // admin API; otherwise we fall back to the groups claim in a forwarded token. + // POSTs the provider user ID as the request body and uses the result to sync + // that user's group memberships, so we must return ONLY that user's groups. mux.HandleFunc("/obot-list-user-auth-groups", func(w http.ResponseWriter, r *http.Request) { - if keycloakGroupAdmin { - var userID string - if r.Body != nil { - body, _ := io.ReadAll(r.Body) - userID = strings.TrimSpace(string(body)) - } - if groups, err := listKeycloakUserGroups(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, userID); err == nil { - json.NewEncoder(w).Encode(groups) - return - } + if !keycloakGroupAdmin { + // Generic OIDC has no per-user group endpoint; a user's groups reach Obot + // via the token's groups claim (surfaced by /obot-get-state). Signal "not + // implemented" like the OSS Google/GitHub providers do. + w.WriteHeader(http.StatusNotFound) + return } - // Fallback for non-Keycloak IdPs: the caller's own groups from a forwarded - // token (JWT claim, then userinfo). Never returns the whole realm. - groupNames := groupsFromBearerToken(r.Header.Get("Authorization"), opts.GroupsClaim) - if groupNames == nil { - if userInfo, err := profile.FetchOIDCProfile(r.Context(), issuerURL, r.Header.Get("Authorization")); err == nil { - groupNames = userInfo.Groups - } + var userID string + if r.Body != nil { + body, _ := io.ReadAll(r.Body) + userID = strings.TrimSpace(string(body)) } - groups := make(state.GroupInfoList, 0, len(groupNames)) - for _, g := range groupNames { - groups = append(groups, state.GroupInfo{ID: g, Name: g}) + groups, err := listKeycloakUserGroups(r.Context(), issuerURL, opts.ClientID, opts.ClientSecret, userID) + if err != nil { + // Return an error (not an empty list) on a lookup failure: Obot treats an + // empty list as "user is in no groups" and deletes their memberships, so a + // transient Keycloak Admin API failure would wipe group memberships. An + // error makes Obot skip the sync and keep the existing memberships. + http.Error(w, fmt.Sprintf("failed to list user groups: %v", err), http.StatusBadGateway) + return } json.NewEncoder(w).Encode(groups) }) From 785d3842bd2e485c7512ac2780f88750aee1ade9 Mon Sep 17 00:00:00 2001 From: we4sz Date: Sat, 30 May 2026 19:42:04 +0200 Subject: [PATCH 11/11] feat(generic-oidc): paginate Keycloak admin group queries Follow Keycloak's first/max pagination in fetchKeycloakGroups (page size 100) until a short page is returned, instead of capping every query at max=1000. Realms, group children, and user memberships larger than one page are now fully enumerated rather than silently truncated. Applies to all three admin queries (realm groups, /children, user groups). Co-Authored-By: Claude Opus 4.8 (1M context) --- generic-oidc-auth-provider/main.go | 63 ++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/generic-oidc-auth-provider/main.go b/generic-oidc-auth-provider/main.go index 9c265c5a..1e9a7e49 100644 --- a/generic-oidc-auth-provider/main.go +++ b/generic-oidc-auth-provider/main.go @@ -126,26 +126,47 @@ func groupDisplayName(path, leaf string) string { return strings.ReplaceAll(strings.TrimPrefix(path, "/"), "/", " / ") } -// fetchKeycloakGroups GETs a Keycloak admin groups endpoint and decodes the list. +// keycloakPageSize is the number of groups requested per Keycloak admin API page. +const keycloakPageSize = 100 + +// fetchKeycloakGroups GETs a Keycloak admin groups endpoint and decodes the list, +// following Keycloak's first/max pagination until a short (final) page is returned +// so realms/children/memberships larger than one page are fully enumerated. The +// endpoint may already carry query params (e.g. briefRepresentation, search); the +// paging params are appended. func fetchKeycloakGroups(ctx context.Context, token, endpoint string) ([]idpGroup, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - resp, err := keycloakHTTP.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("keycloak admin query %s returned %d", endpoint, resp.StatusCode) - } - var groups []idpGroup - if err = json.NewDecoder(resp.Body).Decode(&groups); err != nil { - return nil, err + sep := "&" + if !strings.Contains(endpoint, "?") { + sep = "?" + } + var all []idpGroup + for first := 0; ; first += keycloakPageSize { + u := fmt.Sprintf("%s%sfirst=%d&max=%d", endpoint, sep, first, keycloakPageSize) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := keycloakHTTP.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("keycloak admin query %s returned %d", endpoint, resp.StatusCode) + } + var page []idpGroup + err = json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + if err != nil { + return nil, err + } + all = append(all, page...) + if len(page) < keycloakPageSize { + break + } } - return groups, nil + return all, nil } // listIdPGroupsViaKeycloakAdmin enumerates realm groups through Keycloak's Admin @@ -161,7 +182,7 @@ func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, client return nil, err } - groupsURL := fmt.Sprintf("%s/admin/realms/%s/groups?briefRepresentation=true&max=1000", base, realm) + groupsURL := fmt.Sprintf("%s/admin/realms/%s/groups?briefRepresentation=true", base, realm) if search != "" { groupsURL += "&search=" + url.QueryEscape(search) } @@ -182,7 +203,7 @@ func listIdPGroupsViaKeycloakAdmin(ctx context.Context, issuer, clientID, client children := g.SubGroups if len(children) == 0 && g.SubGroupCount > 0 && g.ID != "" { if kids, err := fetchKeycloakGroups(ctx, token, - fmt.Sprintf("%s/admin/realms/%s/groups/%s/children?briefRepresentation=true&max=1000", base, realm, url.PathEscape(g.ID))); err == nil { + fmt.Sprintf("%s/admin/realms/%s/groups/%s/children?briefRepresentation=true", base, realm, url.PathEscape(g.ID))); err == nil { children = kids } } @@ -206,7 +227,7 @@ func listKeycloakUserGroups(ctx context.Context, issuer, clientID, clientSecret, return nil, err } groups, err := fetchKeycloakGroups(ctx, token, - fmt.Sprintf("%s/admin/realms/%s/users/%s/groups?briefRepresentation=true&max=1000", base, realm, url.PathEscape(userID))) + fmt.Sprintf("%s/admin/realms/%s/users/%s/groups?briefRepresentation=true", base, realm, url.PathEscape(userID))) if err != nil { return nil, err }