From a70e3e02d067df239cfe1407d386941b921c4a4f Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 29 Jul 2026 09:45:09 +0200 Subject: [PATCH] feat(security): complete OAuth2/OIDC spec coverage in OAuthServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add client_secret_basic/client_secret_post client authentication, the client_credentials grant (RFC 6749 §4.4, backed by a synthetic service-account user so it reuses the existing session/introspection/RLS pipeline unchanged), RFC 9728 protected resource metadata, and OIDC discovery + JWKS + id_token/userinfo support. Remove plan_oauth.md, which was only meant as a working handoff doc. Co-Authored-By: Claude Sonnet 5 --- go.mod | 4 +- go.sum | 166 ++------ pkg/security/database_schema.sql | 8 +- pkg/security/database_schema_sqlite.sql | 2 + pkg/security/oauth_server.go | 459 ++++++++++++++++++++-- pkg/security/oauth_server_db.go | 12 +- pkg/security/oauth_server_db_direct.go | 43 ++- pkg/security/oauth_server_test.go | 483 ++++++++++++++++++++++++ plan_oauth.md | 87 ----- 9 files changed, 979 insertions(+), 285 deletions(-) create mode 100644 pkg/security/oauth_server_test.go delete mode 100644 plan_oauth.md diff --git a/go.mod b/go.mod index ad06319..4c91887 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/getsentry/sentry-go v0.46.2 github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 @@ -61,7 +62,6 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect @@ -144,12 +144,12 @@ require ( go.uber.org/multierr 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/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect google.golang.org/grpc v1.81.1 // indirect diff --git a/go.sum b/go.sum index 1982489..71238f3 100644 --- a/go.sum +++ b/go.sum @@ -5,43 +5,35 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -68,14 +60,13 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= @@ -94,12 +85,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 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/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo= -github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= @@ -115,16 +102,13 @@ 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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -137,8 +121,6 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0/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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -152,8 +134,6 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -164,8 +144,6 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI 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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -183,10 +161,10 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -200,21 +178,13 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo= -github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA= github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= -github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= -github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= -github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -237,20 +207,14 @@ github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArw github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ= github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= -github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= -github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= @@ -261,8 +225,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -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/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -281,29 +243,20 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h 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.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= 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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= -github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= -github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= -github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= @@ -344,8 +297,6 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -364,22 +315,10 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYm github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= github.com/uptrace/bun/dialect/mssqldialect v1.2.16 h1:rKv0cKPNBviXadB/+2Y/UedA/c1JnwGzUWZkdN5FdSQ= github.com/uptrace/bun/dialect/mssqldialect v1.2.16/go.mod h1:J5U7tGKWDsx2Q7MwDZF2417jCdpD6yD/ZMFJcCR80bk= -github.com/uptrace/bun/dialect/mssqldialect v1.2.17 h1:xEUH4WamuY9rXT9d8wHVZanhmLJCrc4s4v7frDH/PMc= -github.com/uptrace/bun/dialect/mssqldialect v1.2.17/go.mod h1:i1NRx/5cz1nivwtV7FEb/gP3CIbRTj4AQC9/Q0lNVno= -github.com/uptrace/bun/dialect/mssqldialect v1.2.18 h1:nYzHoyJKJlIyl5i95Exi8ZTK8ooKWG+o3z3f404d/yQ= -github.com/uptrace/bun/dialect/mssqldialect v1.2.18/go.mod h1:Su45Je7z66sfeZ3d1ZsnOQEK8xfzGgaMzBvtoE8yFhk= github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4= github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo= -github.com/uptrace/bun/dialect/pgdialect v1.2.17 h1:DFmhOollvbYHvooxoS8ZIbiGC0wXIzstKeFUmWs+TP4= -github.com/uptrace/bun/dialect/pgdialect v1.2.17/go.mod h1:ej8ZDsvLETvyELlRDfUtIoA57sWnATv1GhOEVsuVG/k= -github.com/uptrace/bun/dialect/pgdialect v1.2.18 h1:IZ6nM2+OYrL8lkEAy7UkSEZvoa3vluTAUlZfPtlRB2k= -github.com/uptrace/bun/dialect/pgdialect v1.2.18/go.mod h1:Tqdf4QP1okrGYpXfodXvCOK6Ob1OOTwSaoAzCgBB3IU= github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0= github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E= -github.com/uptrace/bun/dialect/sqlitedialect v1.2.17 h1:ZipEoNr+wQJQleGy2poKSSoaQDavzc+nXTDp3ZzkA0E= -github.com/uptrace/bun/dialect/sqlitedialect v1.2.17/go.mod h1:phXmrxxeYqUhMU09FgazbfNxq9LlArdqjZqHc1ILy9U= -github.com/uptrace/bun/dialect/sqlitedialect v1.2.18 h1:Z33SY/U++XK9uGWqS4h8OZVxfCXguIG+sU9cYq2PGFQ= -github.com/uptrace/bun/dialect/sqlitedialect v1.2.18/go.mod h1:1MVOS/Ncy4FZbkJcgUFH6OqYoQinYNjkEwsmNQEXz2A= github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI= github.com/uptrace/bun/driver/sqliteshim v1.2.16/go.mod h1:iKdJ06P3XS+pwKcONjSIK07bbhksH3lWsw3mpfr0+bY= github.com/uptrace/bunrouter v1.0.23 h1:Bi7NKw3uCQkcA/GUCtDNPq5LE5UdR9pe+UyWbjHB/wU= @@ -403,47 +342,30 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -452,12 +374,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 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= @@ -474,22 +392,14 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= 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.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -509,12 +419,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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= @@ -524,8 +430,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= @@ -551,8 +455,6 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -570,9 +472,8 @@ golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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= @@ -587,12 +488,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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= @@ -601,24 +498,16 @@ 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.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw= google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -644,37 +533,28 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= -modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= -modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= diff --git a/pkg/security/database_schema.sql b/pkg/security/database_schema.sql index ff25af1..cf621f0 100644 --- a/pkg/security/database_schema.sql +++ b/pkg/security/database_schema.sql @@ -1597,6 +1597,8 @@ CREATE TABLE IF NOT EXISTS oauth_clients ( client_name VARCHAR(255), grant_types TEXT[] DEFAULT ARRAY['authorization_code'], allowed_scopes TEXT[] DEFAULT ARRAY['openid','profile','email'], + client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients + token_endpoint_auth_method VARCHAR(30) DEFAULT 'none', is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -1634,13 +1636,15 @@ DECLARE BEGIN v_client_id := p_data->>'client_id'; - INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes) + INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method) VALUES ( v_client_id, ARRAY(SELECT jsonb_array_elements_text(p_data->'redirect_uris')), p_data->>'client_name', COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'grant_types')), ARRAY['authorization_code']), - COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email']) + COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email']), + NULLIF(p_data->>'client_secret_hash', ''), + COALESCE(NULLIF(p_data->>'token_endpoint_auth_method', ''), 'none') ) RETURNING to_jsonb(oauth_clients.*) INTO v_row; diff --git a/pkg/security/database_schema_sqlite.sql b/pkg/security/database_schema_sqlite.sql index 32ab3c6..f2f8502 100644 --- a/pkg/security/database_schema_sqlite.sql +++ b/pkg/security/database_schema_sqlite.sql @@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS oauth_clients ( client_name VARCHAR(255), grant_types TEXT, -- JSON-encoded []string allowed_scopes TEXT, -- JSON-encoded []string + client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients + token_endpoint_auth_method VARCHAR(30) DEFAULT 'none', is_active BOOLEAN DEFAULT 1, created_at TIMESTAMP ); diff --git a/pkg/security/oauth_server.go b/pkg/security/oauth_server.go index b6e6f78..4ec7126 100644 --- a/pkg/security/oauth_server.go +++ b/pkg/security/oauth_server.go @@ -3,8 +3,11 @@ package security import ( "context" "crypto/rand" + "crypto/rsa" "crypto/sha256" + "crypto/subtle" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -12,6 +15,9 @@ import ( "strings" "sync" "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" ) // OAuthServerConfig configures the MCP-standard OAuth2 authorization server. @@ -44,15 +50,32 @@ type OAuthServerConfig struct { // AuthCodeTTL is the auth code lifetime. Defaults to 2 minutes. AuthCodeTTL time.Duration + + // ResourceIdentifier is this server's protected-resource identifier, advertised in + // RFC 9728 metadata. Defaults to Issuer. + ResourceIdentifier string + + // SigningKey signs id_tokens (RS256) and is exposed via the JWKS endpoint. If nil, an + // RSA-2048 key is generated in memory when the server starts. Supply a persistent key + // for multi-instance deployments so id_tokens remain verifiable across restarts/instances. + SigningKey *rsa.PrivateKey } // oauthClient is a dynamically registered OAuth2 client (RFC 7591). type oauthClient struct { - ClientID string `json:"client_id"` - RedirectURIs []string `json:"redirect_uris"` - ClientName string `json:"client_name,omitempty"` - GrantTypes []string `json:"grant_types"` - AllowedScopes []string `json:"allowed_scopes,omitempty"` + ClientID string `json:"client_id"` + RedirectURIs []string `json:"redirect_uris"` + ClientName string `json:"client_name,omitempty"` + GrantTypes []string `json:"grant_types"` + AllowedScopes []string `json:"allowed_scopes,omitempty"` + ClientSecretHash string `json:"client_secret_hash,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` +} + +// isConfidential reports whether the client has a registered secret and must +// authenticate itself at the token endpoint. +func (c *oauthClient) isConfidential() bool { + return c.ClientSecretHash != "" } // pendingAuth tracks an in-progress authorization code exchange. @@ -85,13 +108,25 @@ type externalProvider struct { // The server exposes these RFC-compliant endpoints: // // GET /.well-known/oauth-authorization-server RFC 8414 — server metadata discovery +// GET /.well-known/openid-configuration OIDC discovery (superset of the above) +// GET /.well-known/oauth-protected-resource RFC 9728 — protected resource metadata // POST /oauth/register RFC 7591 — dynamic client registration // GET /oauth/authorize OAuth 2.1 + PKCE — start authorization // POST /oauth/authorize Direct login form submission -// POST /oauth/token Token exchange and refresh +// POST /oauth/token Token exchange: authorization_code, +// refresh_token, client_credentials (RFC 6749 §4.4) // POST /oauth/revoke RFC 7009 — token revocation // POST /oauth/introspect RFC 7662 — token introspection +// GET /oauth/userinfo OIDC UserInfo endpoint +// GET /oauth/jwks.json JWKS — id_token verification keys // GET {ProviderCallbackPath} Internal — external provider callback +// +// Confidential clients (registered with token_endpoint_auth_method other than "none", or +// any grant_types including client_credentials) authenticate at /oauth/token via +// client_secret_basic or client_secret_post. Public clients keep relying on PKCE alone. +// +// When the granted scope includes "openid", authorization_code and refresh_token responses +// include an RS256-signed id_token (see OAuthServerConfig.SigningKey). type OAuthServer struct { cfg OAuthServerConfig auth *DatabaseAuthenticator // nil = only external providers @@ -102,6 +137,9 @@ type OAuthServer struct { pending map[string]*pendingAuth // provider_state → pending (external flow) codes map[string]*pendingAuth // auth_code → pending (post-auth) + signingKey *rsa.PrivateKey + signingKeyID string + done chan struct{} // closed by Close() to stop background goroutines } @@ -130,13 +168,32 @@ func NewOAuthServer(cfg OAuthServerConfig, auth *DatabaseAuthenticator) *OAuthSe } // Normalize issuer: remove trailing slash to ensure consistent endpoint URL construction. cfg.Issuer = strings.TrimSuffix(cfg.Issuer, "/") + if cfg.ResourceIdentifier == "" { + cfg.ResourceIdentifier = cfg.Issuer + } + + signingKey := cfg.SigningKey + if signingKey == nil { + var err error + signingKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + // Signing keys are only required for id_token issuance (OIDC "openid" scope); + // leaving signingKey nil degrades gracefully by omitting id_token/JWKS support. + signingKey = nil + } + } + s := &OAuthServer{ - cfg: cfg, - auth: auth, - clients: make(map[string]*oauthClient), - pending: make(map[string]*pendingAuth), - codes: make(map[string]*pendingAuth), - done: make(chan struct{}), + cfg: cfg, + auth: auth, + clients: make(map[string]*oauthClient), + pending: make(map[string]*pendingAuth), + codes: make(map[string]*pendingAuth), + signingKey: signingKey, + done: make(chan struct{}), + } + if signingKey != nil { + s.signingKeyID = rsaKeyID(&signingKey.PublicKey) } go s.cleanupExpired() return s @@ -178,11 +235,15 @@ func (s *OAuthServer) ProviderCallbackPath() string { func (s *OAuthServer) HTTPHandler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/.well-known/oauth-authorization-server", s.metadataHandler) + mux.HandleFunc("/.well-known/openid-configuration", s.openIDConfigurationHandler) + mux.HandleFunc("/.well-known/oauth-protected-resource", s.protectedResourceHandler) mux.HandleFunc("/oauth/register", s.registerHandler) mux.HandleFunc("/oauth/authorize", s.authorizeHandler) mux.HandleFunc("/oauth/token", s.tokenHandler) mux.HandleFunc("/oauth/revoke", s.revokeHandler) mux.HandleFunc("/oauth/introspect", s.introspectHandler) + mux.HandleFunc("/oauth/userinfo", s.userinfoHandler) + mux.HandleFunc("/oauth/jwks.json", s.jwksHandler) mux.HandleFunc(s.cfg.ProviderCallbackPath, s.providerCallbackHandler) return mux } @@ -217,25 +278,127 @@ func (s *OAuthServer) cleanupExpired() { // RFC 8414 — Server metadata // -------------------------------------------------------------------------- -func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) { +// serverMetadata builds the fields shared by RFC 8414 authorization-server +// metadata and OIDC discovery metadata. +func (s *OAuthServer) serverMetadata() map[string]interface{} { issuer := s.cfg.Issuer - meta := map[string]interface{}{ + grantTypes := []string{"authorization_code", "refresh_token"} + if s.auth != nil { + grantTypes = append(grantTypes, "client_credentials") + } + return map[string]interface{}{ "issuer": issuer, "authorization_endpoint": issuer + "/oauth/authorize", "token_endpoint": issuer + "/oauth/token", "registration_endpoint": issuer + "/oauth/register", "revocation_endpoint": issuer + "/oauth/revoke", "introspection_endpoint": issuer + "/oauth/introspect", + "userinfo_endpoint": issuer + "/oauth/userinfo", + "jwks_uri": issuer + "/oauth/jwks.json", "scopes_supported": s.cfg.DefaultScopes, "response_types_supported": []string{"code"}, - "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "grant_types_supported": grantTypes, "code_challenge_methods_supported": []string{"S256"}, - "token_endpoint_auth_methods_supported": []string{"none"}, + "token_endpoint_auth_methods_supported": []string{"none", "client_secret_basic", "client_secret_post"}, + } +} + +func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s.serverMetadata()) //nolint:errcheck +} + +// -------------------------------------------------------------------------- +// OIDC discovery — GET /.well-known/openid-configuration +// -------------------------------------------------------------------------- + +func (s *OAuthServer) openIDConfigurationHandler(w http.ResponseWriter, r *http.Request) { + meta := s.serverMetadata() + meta["subject_types_supported"] = []string{"public"} + meta["id_token_signing_alg_values_supported"] = []string{"RS256"} + meta["claims_supported"] = []string{"sub", "iss", "aud", "exp", "iat", "email", "preferred_username"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) //nolint:errcheck +} + +// -------------------------------------------------------------------------- +// RFC 9728 — Protected Resource Metadata +// -------------------------------------------------------------------------- + +func (s *OAuthServer) protectedResourceHandler(w http.ResponseWriter, r *http.Request) { + meta := map[string]interface{}{ + "resource": s.cfg.ResourceIdentifier, + "authorization_servers": []string{s.cfg.Issuer}, + "scopes_supported": s.cfg.DefaultScopes, + "bearer_methods_supported": []string{"header"}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(meta) //nolint:errcheck } +// -------------------------------------------------------------------------- +// JWKS — GET /oauth/jwks.json +// -------------------------------------------------------------------------- + +func (s *OAuthServer) jwksHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.signingKey == nil { + json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{}}) //nolint:errcheck + return + } + pub := s.signingKey.PublicKey + jwk := map[string]interface{}{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": s.signingKeyID, + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(bigEndianBytes(pub.E)), + } + json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{jwk}}) //nolint:errcheck +} + +// -------------------------------------------------------------------------- +// Userinfo — GET/POST /oauth/userinfo +// -------------------------------------------------------------------------- + +func (s *OAuthServer) userinfoHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + token := strings.TrimPrefix(auth, "Bearer ") + if token == "" || token == auth { + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + writeOAuthError(w, "invalid_token", "missing bearer token", http.StatusUnauthorized) + return + } + + authToUse := s.auth + if authToUse == nil { + s.mu.RLock() + if len(s.providers) > 0 { + authToUse = s.providers[0].auth + } + s.mu.RUnlock() + } + if authToUse == nil { + writeOAuthError(w, "invalid_token", "no authenticator configured", http.StatusUnauthorized) + return + } + + info, err := authToUse.OAuthIntrospectToken(r.Context(), token) + if err != nil || !info.Active { + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + writeOAuthError(w, "invalid_token", "token is inactive or invalid", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ //nolint:errcheck + "sub": info.Sub, + "preferred_username": info.Username, + "email": info.Email, + }) +} + // -------------------------------------------------------------------------- // RFC 7591 — Dynamic client registration // -------------------------------------------------------------------------- @@ -246,10 +409,11 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) { return } var req struct { - RedirectURIs []string `json:"redirect_uris"` - ClientName string `json:"client_name"` - GrantTypes []string `json:"grant_types"` - AllowedScopes []string `json:"allowed_scopes"` + RedirectURIs []string `json:"redirect_uris"` + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + AllowedScopes []string `json:"allowed_scopes"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeOAuthError(w, "invalid_request", "malformed JSON", http.StatusBadRequest) @@ -272,21 +436,48 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "server error", http.StatusInternalServerError) return } + + // client_credentials is a machine-to-machine grant and requires a confidential + // client (RFC 6749 §4.4), so it always forces secret issuance regardless of the + // requested auth method. + authMethod := req.TokenEndpointAuthMethod + if authMethod == "" { + authMethod = "none" + } + if oauthSliceContains(grantTypes, "client_credentials") && authMethod == "none" { + authMethod = "client_secret_basic" + } + + var plaintextSecret string + var secretHash string + if authMethod != "none" { + plaintextSecret, err = randomOAuthToken() + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + secretHash = hashClientSecret(plaintextSecret) + } + client := &oauthClient{ - ClientID: clientID, - RedirectURIs: req.RedirectURIs, - ClientName: req.ClientName, - GrantTypes: grantTypes, - AllowedScopes: allowedScopes, + ClientID: clientID, + RedirectURIs: req.RedirectURIs, + ClientName: req.ClientName, + GrantTypes: grantTypes, + AllowedScopes: allowedScopes, + ClientSecretHash: secretHash, + TokenEndpointAuthMethod: authMethod, } if s.cfg.PersistClients && s.auth != nil { dbClient := &OAuthServerClient{ - ClientID: client.ClientID, - RedirectURIs: client.RedirectURIs, - ClientName: client.ClientName, - GrantTypes: client.GrantTypes, - AllowedScopes: client.AllowedScopes, + ClientID: client.ClientID, + RedirectURIs: client.RedirectURIs, + ClientName: client.ClientName, + GrantTypes: client.GrantTypes, + AllowedScopes: client.AllowedScopes, + ClientSecretHash: client.ClientSecretHash, + TokenEndpointAuthMethod: client.TokenEndpointAuthMethod, } if _, err := s.auth.OAuthRegisterClient(r.Context(), dbClient); err != nil { http.Error(w, "server error", http.StatusInternalServerError) @@ -298,9 +489,25 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) { s.clients[clientID] = client s.mu.Unlock() + // RFC 7591 registration response: the plaintext secret is returned exactly once here + // and never persisted or served again — only its hash (client.ClientSecretHash) is + // stored, and that hash is deliberately excluded from this response. + resp := map[string]interface{}{ + "client_id": client.ClientID, + "redirect_uris": client.RedirectURIs, + "client_name": client.ClientName, + "grant_types": client.GrantTypes, + "allowed_scopes": client.AllowedScopes, + "token_endpoint_auth_method": client.TokenEndpointAuthMethod, + } + if plaintextSecret != "" { + resp["client_secret"] = plaintextSecret + resp["client_secret_expires_at"] = 0 + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(client) //nolint:errcheck + json.NewEncoder(w).Encode(resp) //nolint:errcheck } // -------------------------------------------------------------------------- @@ -577,6 +784,8 @@ func (s *OAuthServer) tokenHandler(w http.ResponseWriter, r *http.Request) { s.handleAuthCodeGrant(w, r) case "refresh_token": s.handleRefreshGrant(w, r) + case "client_credentials": + s.handleClientCredentialsGrant(w, r) default: writeOAuthError(w, "unsupported_grant_type", "", http.StatusBadRequest) } @@ -593,6 +802,15 @@ func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request return } + // Confidential clients (those registered with a client_secret) must authenticate; + // public clients keep relying on PKCE alone, unchanged from prior behavior. + if client, ok := s.lookupOrFetchClient(r.Context(), clientID); ok && client.isConfidential() { + if _, err := s.authenticateClient(r); err != nil { + writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized) + return + } + } + var sessionToken string var refreshToken string var scopes []string @@ -647,12 +865,13 @@ func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request scopes = pending.Scopes } - s.writeOAuthToken(w, sessionToken, refreshToken, scopes) + s.writeOAuthToken(w, r, sessionToken, refreshToken, clientID, scopes, true) } func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) { refreshToken := r.FormValue("refresh_token") providerName := r.FormValue("provider") + clientID := r.FormValue("client_id") if refreshToken == "" { writeOAuthError(w, "invalid_request", "refresh_token required", http.StatusBadRequest) return @@ -666,7 +885,7 @@ func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest) return } - s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil) + s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false) return } @@ -676,13 +895,86 @@ func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest) return } - s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil) + s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false) return } writeOAuthError(w, "invalid_grant", "no provider available for refresh", http.StatusBadRequest) } +// -------------------------------------------------------------------------- +// RFC 6749 §4.4 — Client credentials grant +// -------------------------------------------------------------------------- + +func (s *OAuthServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + writeOAuthError(w, "unsupported_grant_type", "client_credentials requires a local user store", http.StatusBadRequest) + return + } + + client, err := s.authenticateClient(r) + if err != nil { + w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`) + writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized) + return + } + if !oauthSliceContains(client.GrantTypes, "client_credentials") { + writeOAuthError(w, "unauthorized_client", "client is not authorized for client_credentials", http.StatusBadRequest) + return + } + + requested := strings.Fields(r.FormValue("scope")) + effectiveScopes := client.AllowedScopes + if len(requested) > 0 { + effectiveScopes = nil + for _, sc := range requested { + if oauthSliceContains(client.AllowedScopes, sc) { + effectiveScopes = append(effectiveScopes, sc) + } + } + if len(effectiveScopes) == 0 { + writeOAuthError(w, "invalid_scope", "no requested scope is allowed for this client", http.StatusBadRequest) + return + } + } + + // client_credentials tokens have no end user, but the rest of the stack (RLS-scoping + // hooks, introspection) expects every access token to resolve to a user_sessions row + // with a user_id. Represent the client as a deterministic synthetic "service account" + // user so the existing get-or-create/create-session/introspection pipeline handles it + // unchanged — no new tables or code paths required. + userCtx := &UserContext{ + UserName: "client:" + client.ClientID, + Email: "oauth-client-" + client.ClientID + "@service.internal", + RemoteID: client.ClientID, + Roles: effectiveScopes, + } + userID, err := s.auth.oauth2GetOrCreateUser(r.Context(), userCtx, "oauth2_client") + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + sessionToken, err := randomOAuthToken() + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + expiresAt := time.Now().Add(s.cfg.AccessTokenTTL) + err = s.auth.oauth2CreateSession(r.Context(), sessionToken, userID, &oauth2.Token{ + AccessToken: sessionToken, + TokenType: "Bearer", + }, expiresAt, "oauth2_client") + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + // No refresh token per RFC 6749 §4.4.3, and no id_token — client_credentials has no + // end-user subject to represent in OIDC terms. + s.writeOAuthToken(w, r, sessionToken, "", client.ClientID, effectiveScopes, false) +} + // -------------------------------------------------------------------------- // RFC 7009 — Token revocation // -------------------------------------------------------------------------- @@ -879,7 +1171,10 @@ func oauthSliceContains(slice []string, s string) bool { return false } -func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refreshToken string, scopes []string) { +// writeOAuthToken writes the token response. When issueIDToken is true and the granted +// scopes include "openid", an RS256-signed id_token is included (OIDC); client_credentials +// responses always pass issueIDToken=false since that grant has no end-user subject. +func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, r *http.Request, accessToken, refreshToken, clientID string, scopes []string, issueIDToken bool) { expiresIn := int64(s.cfg.AccessTokenTTL.Seconds()) resp := map[string]interface{}{ "access_token": accessToken, @@ -892,12 +1187,106 @@ func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refres if len(scopes) > 0 { resp["scope"] = strings.Join(scopes, " ") } + if issueIDToken && oauthSliceContains(scopes, "openid") { + if idToken, err := s.buildIDToken(r.Context(), accessToken, clientID, scopes); err == nil { + resp["id_token"] = idToken + } + } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") json.NewEncoder(w).Encode(resp) //nolint:errcheck } +// buildIDToken issues an OIDC id_token for the just-issued access token by reusing the +// existing introspection pipeline to resolve the subject's claims. +func (s *OAuthServer) buildIDToken(ctx context.Context, accessToken, clientID string, scopes []string) (string, error) { + if s.signingKey == nil { + return "", fmt.Errorf("no signing key configured") + } + authToUse := s.auth + if authToUse == nil { + s.mu.RLock() + if len(s.providers) > 0 { + authToUse = s.providers[0].auth + } + s.mu.RUnlock() + } + if authToUse == nil { + return "", fmt.Errorf("no authenticator configured") + } + info, err := authToUse.OAuthIntrospectToken(ctx, accessToken) + if err != nil || !info.Active { + return "", fmt.Errorf("token not active") + } + + now := time.Now() + claims := jwt.MapClaims{ + "iss": s.cfg.Issuer, + "sub": info.Sub, + "aud": clientID, + "exp": now.Add(s.cfg.AccessTokenTTL).Unix(), + "iat": now.Unix(), + } + if oauthSliceContains(scopes, "profile") && info.Username != "" { + claims["preferred_username"] = info.Username + } + if oauthSliceContains(scopes, "email") && info.Email != "" { + claims["email"] = info.Email + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = s.signingKeyID + return token.SignedString(s.signingKey) +} + +// authenticateClient validates client_secret_basic (Authorization: Basic) or +// client_secret_post (client_id/client_secret form fields) credentials against a +// registered confidential client's stored secret hash. +func (s *OAuthServer) authenticateClient(r *http.Request) (*oauthClient, error) { + clientID, clientSecret, ok := r.BasicAuth() + if !ok { + clientID = r.FormValue("client_id") + clientSecret = r.FormValue("client_secret") + } + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("client authentication required") + } + client, ok := s.lookupOrFetchClient(r.Context(), clientID) + if !ok || !client.isConfidential() { + return nil, fmt.Errorf("invalid client credentials") + } + if subtle.ConstantTimeCompare([]byte(hashClientSecret(clientSecret)), []byte(client.ClientSecretHash)) != 1 { + return nil, fmt.Errorf("invalid client credentials") + } + return client, nil +} + +func hashClientSecret(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// rsaKeyID derives a stable JWKS "kid" from an RSA public key's modulus. +func rsaKeyID(pub *rsa.PublicKey) string { + sum := sha256.Sum256(pub.N.Bytes()) + return base64.RawURLEncoding.EncodeToString(sum[:8]) +} + +// bigEndianBytes encodes a small positive int (e.g. an RSA public exponent) as +// minimal big-endian bytes for JWK "e" encoding. +func bigEndianBytes(n int) []byte { + if n == 0 { + return []byte{0} + } + var b []byte + for n > 0 { + b = append([]byte{byte(n & 0xff)}, b...) + n >>= 8 + } + return b +} + func writeOAuthError(w http.ResponseWriter, errCode, description string, status int) { resp := map[string]string{"error": errCode} if description != "" { diff --git a/pkg/security/oauth_server_db.go b/pkg/security/oauth_server_db.go index d0e208d..cb1e5a3 100644 --- a/pkg/security/oauth_server_db.go +++ b/pkg/security/oauth_server_db.go @@ -9,11 +9,13 @@ import ( // OAuthServerClient is a persisted RFC 7591 registered OAuth2 client. type OAuthServerClient struct { - ClientID string `json:"client_id"` - RedirectURIs []string `json:"redirect_uris"` - ClientName string `json:"client_name,omitempty"` - GrantTypes []string `json:"grant_types"` - AllowedScopes []string `json:"allowed_scopes,omitempty"` + ClientID string `json:"client_id"` + RedirectURIs []string `json:"redirect_uris"` + ClientName string `json:"client_name,omitempty"` + GrantTypes []string `json:"grant_types"` + AllowedScopes []string `json:"allowed_scopes,omitempty"` + ClientSecretHash string `json:"client_secret_hash,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` } // OAuthCode is a short-lived authorization code. diff --git a/pkg/security/oauth_server_db_direct.go b/pkg/security/oauth_server_db_direct.go index e0e162e..b25a6bb 100644 --- a/pkg/security/oauth_server_db_direct.go +++ b/pkg/security/oauth_server_db_direct.go @@ -15,6 +15,15 @@ import ( // Array columns (redirect_uris, grant_types, allowed_scopes, scopes) are // JSON-encoded TEXT instead of native Postgres arrays. +// nullIfEmpty converts an empty string to a SQL NULL so optional TEXT columns +// (e.g. client_secret_hash for public clients) stay unset rather than "". +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) { grantTypes := client.GrantTypes if len(grantTypes) == 0 { @@ -38,11 +47,16 @@ func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, c return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err) } + authMethod := client.TokenEndpointAuthMethod + if authMethod == "" { + authMethod = "none" + } + err = a.runDBOpWithReconnect(func(db *sql.DB) error { query := rewritePlaceholders(db, fmt.Sprintf( - `INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.tableNames.OAuthClients)) - _, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), true, time.Now()) + _, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), nullIfEmpty(client.ClientSecretHash), authMethod, true, time.Now()) return err }) if err != nil { @@ -50,23 +64,25 @@ func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, c } return &OAuthServerClient{ - ClientID: client.ClientID, - RedirectURIs: client.RedirectURIs, - ClientName: client.ClientName, - GrantTypes: grantTypes, - AllowedScopes: allowedScopes, + ClientID: client.ClientID, + RedirectURIs: client.RedirectURIs, + ClientName: client.ClientName, + GrantTypes: grantTypes, + AllowedScopes: allowedScopes, + ClientSecretHash: client.ClientSecretHash, + TokenEndpointAuthMethod: authMethod, }, nil } func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, clientID string) (*OAuthServerClient, error) { var redirectURIsJSON, grantTypesJSON, allowedScopesJSON sql.NullString - var clientName sql.NullString + var clientName, clientSecretHash, authMethod sql.NullString err := a.runDBOpWithReconnect(func(db *sql.DB) error { query := rewritePlaceholders(db, fmt.Sprintf( - `SELECT redirect_uris, client_name, grant_types, allowed_scopes FROM %s WHERE client_id = ? AND is_active = ?`, + `SELECT redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method FROM %s WHERE client_id = ? AND is_active = ?`, a.tableNames.OAuthClients)) - return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON) + return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON, &clientSecretHash, &authMethod) }) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -75,7 +91,12 @@ func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, client return nil, fmt.Errorf("failed to get client: %w", err) } - result := &OAuthServerClient{ClientID: clientID, ClientName: clientName.String} + result := &OAuthServerClient{ + ClientID: clientID, + ClientName: clientName.String, + ClientSecretHash: clientSecretHash.String, + TokenEndpointAuthMethod: authMethod.String, + } if redirectURIsJSON.Valid { _ = json.Unmarshal([]byte(redirectURIsJSON.String), &result.RedirectURIs) } diff --git a/pkg/security/oauth_server_test.go b/pkg/security/oauth_server_test.go new file mode 100644 index 0000000..13bb838 --- /dev/null +++ b/pkg/security/oauth_server_test.go @@ -0,0 +1,483 @@ +package security + +import ( + "context" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func newTestOAuthServer(t *testing.T) (*OAuthServer, *DatabaseAuthenticator) { + t.Helper() + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + srv := NewOAuthServer(OAuthServerConfig{Issuer: "https://auth.example.com", PersistCodes: true}, auth) + t.Cleanup(srv.Close) + return srv, auth +} + +// s256Challenge computes the PKCE S256 code_challenge for a given verifier, +// matching validatePKCESHA256 in oauth_server.go. +func s256Challenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func doJSON(t *testing.T, mux http.Handler, method, path string, body map[string]interface{}) (*httptest.ResponseRecorder, map[string]interface{}) { + t.Helper() + var reqBody *strings.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + reqBody = strings.NewReader(string(b)) + } else { + reqBody = strings.NewReader("") + } + req := httptest.NewRequest(method, path, reqBody) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var parsed map[string]interface{} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &parsed) + } + return rec, parsed +} + +func doForm(t *testing.T, mux http.Handler, path string, form url.Values, basicUser, basicPass string) (*httptest.ResponseRecorder, map[string]interface{}) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if basicUser != "" { + req.SetBasicAuth(basicUser, basicPass) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var parsed map[string]interface{} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &parsed) + } + return rec, parsed +} + +func doGet(mux http.Handler, path, bearer string) (*httptest.ResponseRecorder, map[string]interface{}) { + req := httptest.NewRequest(http.MethodGet, path, nil) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + var parsed map[string]interface{} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &parsed) + } + return rec, parsed +} + +func TestOAuthServer_RegisterConfidentialClient_IssuesSecretOnce(t *testing.T) { + srv, _ := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + rec, resp := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + "grant_types": []string{"client_credentials"}, + }) + if rec.Code != http.StatusCreated { + t.Fatalf("register status = %d, body = %s", rec.Code, rec.Body.String()) + } + secret, _ := resp["client_secret"].(string) + if secret == "" { + t.Fatal("expected client_secret to be present in registration response") + } + if _, ok := resp["client_secret_hash"]; ok { + t.Error("client_secret_hash must never be returned in the registration response") + } + if resp["token_endpoint_auth_method"] != "client_secret_basic" { + t.Errorf("token_endpoint_auth_method = %v, want client_secret_basic", resp["token_endpoint_auth_method"]) + } + if resp["client_id"] == "" || resp["client_id"] == nil { + t.Fatal("expected non-empty client_id") + } + + // Fetching the client back (e.g. via a later authorize/token call) must never leak the hash. + clientID := resp["client_id"].(string) + fetched, ok := srv.lookupOrFetchClient(context.Background(), clientID) + if !ok { + t.Fatal("expected client to be found") + } + if fetched.ClientSecretHash == "" { + t.Error("expected ClientSecretHash to be stored internally") + } + if fetched.ClientSecretHash == secret { + t.Error("stored hash must not equal the plaintext secret") + } + + // Public registration (no client_credentials) should stay a public client with no secret. + recPublic, respPublic := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + }) + if recPublic.Code != http.StatusCreated { + t.Fatalf("public register status = %d", recPublic.Code) + } + if _, ok := respPublic["client_secret"]; ok { + t.Error("public client registration should not receive a client_secret") + } + if respPublic["token_endpoint_auth_method"] != "none" { + t.Errorf("public client token_endpoint_auth_method = %v, want none", respPublic["token_endpoint_auth_method"]) + } +} + +func TestOAuthServer_ClientCredentialsGrant(t *testing.T) { + srv, _ := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + _, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + "grant_types": []string{"client_credentials"}, + "allowed_scopes": []string{"read", "write"}, + }) + clientID := reg["client_id"].(string) + clientSecret := reg["client_secret"].(string) + + t.Run("valid credentials", func(t *testing.T) { + rec, resp := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"read"}, + }, clientID, clientSecret) + if rec.Code != http.StatusOK { + t.Fatalf("token status = %d, body = %s", rec.Code, rec.Body.String()) + } + if resp["access_token"] == "" || resp["access_token"] == nil { + t.Error("expected non-empty access_token") + } + if _, ok := resp["refresh_token"]; ok { + t.Error("client_credentials must not issue a refresh_token (RFC 6749 §4.4.3)") + } + if resp["scope"] != "read" { + t.Errorf("scope = %v, want read", resp["scope"]) + } + }) + + t.Run("scope not allowed for client", func(t *testing.T) { + rec, resp := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"admin"}, + }, clientID, clientSecret) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if resp["error"] != "invalid_scope" { + t.Errorf("error = %v, want invalid_scope", resp["error"]) + } + }) + + t.Run("wrong secret", func(t *testing.T) { + rec, resp := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"client_credentials"}, + }, clientID, "wrong-secret") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if resp["error"] != "invalid_client" { + t.Errorf("error = %v, want invalid_client", resp["error"]) + } + }) + + t.Run("public client cannot use client_credentials", func(t *testing.T) { + _, pubReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + }) + pubClientID := pubReg["client_id"].(string) + + rec, _ := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {pubClientID}, + }, "", "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 for public client attempting client_credentials", rec.Code) + } + }) +} + +func TestOAuthServer_ConfidentialClient_AuthCodeGrantRequiresSecret(t *testing.T) { + srv, auth := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + regResp, err := auth.Register(context.Background(), RegisterRequest{ + Username: "nadia", Password: "p", Email: "nadia@example.com", + }) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + _, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + "token_endpoint_auth_method": "client_secret_basic", + }) + clientID := reg["client_id"].(string) + clientSecret := reg["client_secret"].(string) + + verifier := "verifier-nadia-1234567890" + challenge := s256Challenge(verifier) + if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{ + Code: "code-no-auth", + ClientID: clientID, + RedirectURI: "https://app.example.com/callback", + CodeChallenge: challenge, + SessionToken: regResp.Token, + Scopes: []string{"profile"}, + ExpiresAt: futureTime(), + }); err != nil { + t.Fatalf("OAuthSaveCode() error = %v", err) + } + + // No client credentials supplied -> must be rejected for a confidential client. + rec, resp := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"authorization_code"}, + "code": {"code-no-auth"}, + "redirect_uri": {"https://app.example.com/callback"}, + "client_id": {clientID}, + "code_verifier": {verifier}, + }, "", "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 without client auth, body=%s", rec.Code, rec.Body.String()) + } + if resp["error"] != "invalid_client" { + t.Errorf("error = %v, want invalid_client", resp["error"]) + } + + // Same code, now with correct client credentials -> succeeds. + if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{ + Code: "code-with-auth", + ClientID: clientID, + RedirectURI: "https://app.example.com/callback", + CodeChallenge: challenge, + SessionToken: regResp.Token, + Scopes: []string{"profile"}, + ExpiresAt: futureTime(), + }); err != nil { + t.Fatalf("OAuthSaveCode() error = %v", err) + } + rec2, _ := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"authorization_code"}, + "code": {"code-with-auth"}, + "redirect_uri": {"https://app.example.com/callback"}, + "client_id": {clientID}, + "code_verifier": {verifier}, + }, clientID, clientSecret) + if rec2.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 with client auth, body=%s", rec2.Code, rec2.Body.String()) + } +} + +func TestOAuthServer_PublicClient_AuthCodeGrantUnaffected(t *testing.T) { + srv, auth := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + regResp, err := auth.Register(context.Background(), RegisterRequest{ + Username: "oscar", Password: "p", Email: "oscar@example.com", + }) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + _, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + }) + clientID := reg["client_id"].(string) + if _, ok := reg["client_secret"]; ok { + t.Fatal("expected no client_secret for a default (public) registration") + } + + verifier := "verifier-oscar-1234567890" + challenge := s256Challenge(verifier) + if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{ + Code: "public-code", + ClientID: clientID, + RedirectURI: "https://app.example.com/callback", + CodeChallenge: challenge, + SessionToken: regResp.Token, + Scopes: []string{"profile"}, + ExpiresAt: futureTime(), + }); err != nil { + t.Fatalf("OAuthSaveCode() error = %v", err) + } + + // No credentials needed for a public client — PKCE alone is sufficient, unchanged. + rec, _ := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"authorization_code"}, + "code": {"public-code"}, + "redirect_uri": {"https://app.example.com/callback"}, + "client_id": {clientID}, + "code_verifier": {verifier}, + }, "", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 for public client without credentials, body=%s", rec.Code, rec.Body.String()) + } +} + +func TestOAuthServer_ProtectedResourceMetadata(t *testing.T) { + srv, _ := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + rec, resp := doGet(mux, "/.well-known/oauth-protected-resource", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if resp["resource"] != "https://auth.example.com" { + t.Errorf("resource = %v", resp["resource"]) + } + servers, ok := resp["authorization_servers"].([]interface{}) + if !ok || len(servers) != 1 || servers[0] != "https://auth.example.com" { + t.Errorf("authorization_servers = %v", resp["authorization_servers"]) + } +} + +func TestOAuthServer_OIDCDiscoveryAndIDToken(t *testing.T) { + srv, auth := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + // Discovery document. + rec, disc := doGet(mux, "/.well-known/openid-configuration", "") + if rec.Code != http.StatusOK { + t.Fatalf("discovery status = %d", rec.Code) + } + if disc["jwks_uri"] != "https://auth.example.com/oauth/jwks.json" { + t.Errorf("jwks_uri = %v", disc["jwks_uri"]) + } + grantTypes, _ := disc["grant_types_supported"].([]interface{}) + found := false + for _, g := range grantTypes { + if g == "client_credentials" { + found = true + } + } + if !found { + t.Errorf("expected client_credentials in grant_types_supported, got %v", grantTypes) + } + + // JWKS. + rec, _ = doGet(mux, "/oauth/jwks.json", "") + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &jwks) + if len(jwks.Keys) != 1 { + t.Fatalf("expected 1 JWKS key, got %d", len(jwks.Keys)) + } + + // End-to-end authorization_code flow with scope=openid, verifying the id_token + // signature against the published JWKS key. + regResp, err := auth.Register(context.Background(), RegisterRequest{ + Username: "olivia", Password: "p", Email: "olivia@example.com", + }) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + _, clientReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{ + "redirect_uris": []string{"https://app.example.com/callback"}, + }) + clientID := clientReg["client_id"].(string) + + verifier := "test-code-verifier-1234567890" + challenge := s256Challenge(verifier) + if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{ + Code: "oidc-code", + ClientID: clientID, + RedirectURI: "https://app.example.com/callback", + CodeChallenge: challenge, + SessionToken: regResp.Token, + Scopes: []string{"openid", "profile", "email"}, + ExpiresAt: futureTime(), + }); err != nil { + t.Fatalf("OAuthSaveCode() error = %v", err) + } + + tokRec, tokenResp := doForm(t, mux, "/oauth/token", url.Values{ + "grant_type": {"authorization_code"}, + "code": {"oidc-code"}, + "redirect_uri": {"https://app.example.com/callback"}, + "client_id": {clientID}, + "code_verifier": {verifier}, + }, "", "") + if tokRec.Code != http.StatusOK { + t.Fatalf("token exchange status = %d, body = %s", tokRec.Code, tokRec.Body.String()) + } + idTokenStr, _ := tokenResp["id_token"].(string) + if idTokenStr == "" { + t.Fatal("expected id_token in response for scope containing openid") + } + + nBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].N) + if err != nil { + t.Fatalf("decode n: %v", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].E) + if err != nil { + t.Fatalf("decode e: %v", err) + } + pub := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(new(big.Int).SetBytes(eBytes).Int64())} + + parsed, err := jwt.Parse(idTokenStr, func(token *jwt.Token) (interface{}, error) { + return pub, nil + }, jwt.WithValidMethods([]string{"RS256"})) + if err != nil || !parsed.Valid { + t.Fatalf("id_token did not verify against JWKS key: %v", err) + } + claims := parsed.Claims.(jwt.MapClaims) + if claims["iss"] != "https://auth.example.com" { + t.Errorf("iss claim = %v", claims["iss"]) + } + if claims["aud"] != clientID { + t.Errorf("aud claim = %v, want %v", claims["aud"], clientID) + } + if claims["preferred_username"] != "olivia" { + t.Errorf("preferred_username claim = %v", claims["preferred_username"]) + } + if claims["email"] != "olivia@example.com" { + t.Errorf("email claim = %v", claims["email"]) + } +} + +func TestOAuthServer_Userinfo(t *testing.T) { + srv, auth := newTestOAuthServer(t) + mux := srv.HTTPHandler() + + regResp, err := auth.Register(context.Background(), RegisterRequest{ + Username: "pete", Password: "p", Email: "pete@example.com", + }) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + rec, info := doGet(mux, "/oauth/userinfo", regResp.Token) + if rec.Code != http.StatusOK { + t.Fatalf("userinfo status = %d, body = %s", rec.Code, rec.Body.String()) + } + if info["preferred_username"] != "pete" { + t.Errorf("preferred_username = %v", info["preferred_username"]) + } + + rec2, _ := doGet(mux, "/oauth/userinfo", "not-a-real-token") + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 for invalid token", rec2.Code) + } +} diff --git a/plan_oauth.md b/plan_oauth.md deleted file mode 100644 index 477ecdd..0000000 --- a/plan_oauth.md +++ /dev/null @@ -1,87 +0,0 @@ -# Complete the OAuth2 / discovery spec in pkg/security - -## Context - -`pkg/security/oauth_server.go` already implements a solid OAuth 2.1 authorization server: PKCE-only authorization code grant, refresh token grant, RFC 7591 dynamic client registration, RFC 7009 revocation, RFC 7662 introspection, and RFC 8414 authorization-server metadata discovery. The user asked to close the remaining gaps identified in review: - -1. **Client authentication** (`client_secret_basic` / `client_secret_post`) — today every registered client is a public client; there is no `client_secret` at all. -2. **`client_credentials` grant** (RFC 6749 §4.4) — machine-to-machine tokens with no end user. -3. **RFC 9728** OAuth Protected Resource Metadata (`/.well-known/oauth-protected-resource`). -4. **OIDC-flavored discovery + `id_token`** (`/.well-known/openid-configuration`, JWKS, `id_token` issuance) — not core RFC 6749/8414 but explicitly requested. - -The hard architectural constraint (found during exploration): every access token in this codebase is an opaque string backed by a `user_sessions` row with a `user_id`, and `RLS`-scoping hooks (`pkg/resolvespec/handler.go`, `pkg/restheadspec/handler.go`) derive `UserContext` from that row via `OAuthIntrospectToken`. There is no JWT signing/validation live anywhere (`JWTAuthenticator` in `providers.go` is a stub). To keep `client_credentials` tokens compatible with the existing RLS pipeline without touching hooks.go or the introspection contract, client-credentials tokens will be backed by a **synthetic service-account user row**, reusing the *existing* get-or-create-user-by-email + create-session stored procedures/direct-mode functions that already exist for social login (`oauth2GetOrCreateUser` / `oauth2CreateSession` in `oauth2_methods.go`, same package, unexported but directly callable from `oauth_server.go`). This means **no new tables and only two new columns** are needed for the whole feature set. - -## 1. Client authentication (`client_secret_basic` / `client_secret_post`) - -**Schema** (`database_schema.sql` `oauth_clients` table ~line 1593, and `database_schema_sqlite.sql` ~line 96): add -```sql -client_secret_hash TEXT, -token_endpoint_auth_method VARCHAR(30) DEFAULT 'none' -``` -`resolvespec_oauth_register_client` (database_schema.sql:1628) already returns `to_jsonb(oauth_clients.*)`, so the new columns automatically appear in the client JSON with no extra procedure change beyond the INSERT column/value list. - -**Go structs**: add `ClientSecretHash string \`json:"client_secret_hash,omitempty"\`` and `TokenEndpointAuthMethod string \`json:"token_endpoint_auth_method,omitempty"\`` to `OAuthServerClient` (`oauth_server_db.go:11`) and to `oauthClient` (`oauth_server.go:50`). - -**Persistence**: -- `oauth_server_db_direct.go` `oauthRegisterClientDirect`: extend INSERT to include the two new columns; `oauthGetClientDirect`: extend SELECT + Scan. -- Procedure mode needs no new function — only the `INSERT` column/value list inside `resolvespec_oauth_register_client` changes. - -**Secret issuance** (`registerHandler`, `oauth_server.go:243`): decode an optional `token_endpoint_auth_method` field on the registration request. If it is `client_secret_basic`/`client_secret_post`, or the requested `grant_types` includes `client_credentials` (which forces confidential — see §2), generate a secret with the existing `randomOAuthToken()`, store `sha256(secret)` hex in `client_secret_hash`, and return the **plaintext** `client_secret` + `client_secret_expires_at: 0` once in the registration response only (never persisted in plaintext, never returned by `GET`/introspection). Default `token_endpoint_auth_method` is `"none"` (today's public-client behavior, unchanged). - -**Enforcement**: add `authenticateClient(r *http.Request) (*oauthClient, error)` helper: reads `Authorization: Basic base64(client_id:client_secret)` or `client_id`/`client_secret` form fields, looks up the client, and does `subtle.ConstantTimeCompare` against `sha256(provided)` vs `ClientSecretHash`. In `handleAuthCodeGrant` and the new `handleClientCredentialsGrant`, if the resolved client has a non-empty `ClientSecretHash`, require successful `authenticateClient`; if empty (public client), keep today's behavior (PKCE only, no secret check) — fully backward compatible. - -**Metadata**: `metadataHandler` (`oauth_server.go:220`) — change `token_endpoint_auth_methods_supported` to `["none","client_secret_basic","client_secret_post"]`. - -## 2. `client_credentials` grant - -- `tokenHandler` switch (`oauth_server.go:575`): add `case "client_credentials": s.handleClientCredentialsGrant(w, r)`. -- New `handleClientCredentialsGrant`: - 1. Authenticate the client via `authenticateClient` (mandatory — RFC 6749 §4.4 requires a confidential client). No secret ⇒ `invalid_client`. - 2. Confirm `"client_credentials"` is in the client's registered `GrantTypes` ⇒ else `unauthorized_client`. - 3. Compute effective scopes = requested `scope` form value ∩ `client.AllowedScopes` (default to `AllowedScopes` if `scope` omitted). - 4. Build a synthetic `UserContext{UserName: "client:"+clientID, Email: "oauth-client-"+clientID+"@service.internal", RemoteID: clientID, Roles: effectiveScopes}` and call the existing unexported `a.auth.oauth2GetOrCreateUser(ctx, userCtx, "oauth2_client")` to get-or-create the backing user row (deterministic synthetic email makes this idempotent per client). - 5. Generate `sessionToken := randomOAuthToken()`, call the existing unexported `a.auth.oauth2CreateSession(ctx, sessionToken, userID, &oauth2.Token{AccessToken: sessionToken, TokenType: "Bearer"}, time.Now().Add(cfg.AccessTokenTTL), "oauth2_client")` to persist it in `user_sessions` (same table/procedure everything else already reads via introspection). - 6. `s.writeOAuthToken(w, sessionToken, "" /* no refresh token per RFC 6749 §4.4.3 */, effectiveScopes)`. - - Only wired for the `s.auth != nil` (DatabaseAuthenticator) case — external-provider-only servers return `unsupported_grant_type` (no local user store to back a service account). -- `metadataHandler`: add `"client_credentials"` to `grant_types_supported`. - -## 3. RFC 9728 Protected Resource Metadata - -- Add `cfg.ResourceIdentifier string` to `OAuthServerConfig` (`oauth_server.go:18`), defaulting to `cfg.Issuer` in `NewOAuthServer`. -- New handler `protectedResourceHandler` returning: - ```json - {"resource": "", "authorization_servers": [""], "scopes_supported": cfg.DefaultScopes, "bearer_methods_supported": ["header"]} - ``` -- Register in `HTTPHandler()`: `mux.HandleFunc("/.well-known/oauth-protected-resource", s.protectedResourceHandler)`. - -## 4. OIDC discovery + `id_token` - -- **Dependency**: add `github.com/golang-jwt/jwt/v5` as a direct dependency (`go get github.com/golang-jwt/jwt/v5@v5.3.1` — already resolved in `go.sum` transitively, so this should be a clean, network-light `go mod tidy`). -- **Signing key**: in `NewOAuthServer`, generate an RSA-2048 keypair in memory (`rsa.GenerateKey`) unless `cfg.SigningKey *rsa.PrivateKey` is supplied (new optional config field, documented for multi-instance deployments that need id_tokens verifiable consistently across restarts/instances). Compute a stable `kid` from a SHA-256 fingerprint of the public key modulus. -- **JWKS endpoint**: `GET /oauth/jwks.json` → standard JWKS JSON (`kty:"RSA"`, `n`, `e`, `kid`, `use:"sig"`, `alg:"RS256"`). -- **OIDC discovery**: `GET /.well-known/openid-configuration` → same fields as `/.well-known/oauth-authorization-server` plus `jwks_uri`, `userinfo_endpoint`, `subject_types_supported: ["public"]`, `id_token_signing_alg_values_supported: ["RS256"]`, `response_types_supported`, `grant_types_supported` (mirrors §1/§2 additions). -- **`id_token` issuance**: in `writeOAuthToken` (or a thin wrapper called only from `handleAuthCodeGrant`/`handleRefreshGrant`, not from client_credentials — OIDC has no id_token for that grant), if `scopes` contains `"openid"`: call the already-existing `s.auth.OAuthIntrospectToken(ctx, accessToken)` (or the matching provider's) to fetch `OAuthTokenInfo{Sub,Username,Email}` for the just-issued token, then build and RS256-sign a JWT with claims `iss`, `sub`, `aud` (client_id — needs to be threaded down from the token handler), `exp`, `iat`, and (if scope has `profile`/`email`) `preferred_username`/`email`. Attach as `id_token` in the JSON response. -- **Userinfo endpoint**: `GET/POST /oauth/userinfo` with `Authorization: Bearer ` → reuse `OAuthIntrospectToken`, return `{sub, preferred_username, email}` (401 `{"error":"invalid_token"}` if inactive). -- Register `/oauth/jwks.json`, `/.well-known/openid-configuration`, `/oauth/userinfo` in `HTTPHandler()`. - -## Files touched - -- `pkg/security/oauth_server.go` — bulk of the new logic (client auth, client_credentials grant, RFC 9728, OIDC discovery, JWKS, userinfo, id_token signing), updated doc comment listing endpoints. -- `pkg/security/oauth_server_db.go` — `OAuthServerClient` struct fields. -- `pkg/security/oauth_server_db_direct.go` — INSERT/SELECT column updates. -- `pkg/security/database_schema.sql`, `pkg/security/database_schema_sqlite.sql` — `oauth_clients` new columns; `resolvespec_oauth_register_client` INSERT list. -- `pkg/security/OAUTH2.md` — document new endpoints/grant/columns. -- `go.mod`/`go.sum` — new direct dependency. -- New `pkg/security/oauth_server_test.go` — HTTP-level tests (see below); extend `direct_mode_test.go`'s `TestDirectMode_OAuthGetOrCreateUserAndSession`-style coverage only if the column changes require it. - -## Verification - -- `go build ./...` and `go vet ./...` in the module root. -- `go test ./pkg/security/... -run OAuth -v` after adding tests covering: - - Register a confidential client (`grant_types: ["client_credentials"]`) → assert `client_secret` present once, `client_secret_hash` never leaks via `GET`/introspection. - - `client_credentials` grant: valid secret → 200 with `access_token`, no `refresh_token`; bad secret → `invalid_client`; public client (no secret) attempting `client_credentials` → `invalid_client`. - - Authorization-code flow for a confidential client without credentials on `/oauth/token` → `invalid_client`; with credentials → succeeds (regression check that public-client flow with no secret is unaffected). - - `/.well-known/oauth-protected-resource` returns expected JSON shape. - - `/.well-known/openid-configuration` + `/oauth/jwks.json` shape; parse a returned `id_token` (scope `openid profile email`) with `golang-jwt` using the JWKS public key and assert `iss`/`sub`/`aud`/`exp` claims. - - `/oauth/userinfo` with a valid vs. revoked token. -- Full existing suite: `go test ./pkg/security/...` to confirm no regression in direct-mode / procedure-mode OAuth tests already in `direct_mode_test.go`.