diff --git a/go.mod b/go.mod index 474e2a111..79a60c708 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/zitadel/zitadel go 1.19 require ( - cloud.google.com/go/storage v1.14.0 + cloud.google.com/go/storage v1.28.1 github.com/BurntSushi/toml v0.4.1 github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.0.0 @@ -16,6 +16,7 @@ require ( github.com/cockroachdb/cockroach-go/v2 v2.2.18 github.com/dop251/goja v0.0.0-20220815083517-0c74f9139fd6 github.com/dop251/goja_nodejs v0.0.0-20220905124449-678b33ca5009 + github.com/drone/envsubst v1.0.3 github.com/duo-labs/webauthn v0.0.0-20211216225436-9a12cd078b8a github.com/envoyproxy/protoc-gen-validate v0.6.7 github.com/golang/glog v1.0.0 @@ -28,10 +29,12 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/grpc-ecosystem/grpc-gateway v1.16.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.10.1 + github.com/h2non/gock v1.2.0 github.com/improbable-eng/grpc-web v0.15.0 github.com/jackc/pgconn v1.12.1 github.com/jackc/pgtype v1.11.0 github.com/jackc/pgx/v4 v4.16.1 + github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 github.com/jinzhu/gorm v1.9.16 github.com/k3a/html2text v1.0.8 github.com/kevinburke/twilio-go v0.0.0-20210327194925-1623146bcf73 @@ -48,7 +51,7 @@ require ( github.com/sony/sonyflake v1.0.0 github.com/spf13/cobra v1.3.0 github.com/spf13/viper v1.10.1 - github.com/stretchr/testify v1.8.0 + github.com/stretchr/testify v1.8.1 github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 github.com/ttacon/libphonenumber v1.2.1 github.com/zitadel/logging v0.3.4 @@ -66,36 +69,36 @@ require ( go.opentelemetry.io/otel/sdk/metric v0.25.0 go.opentelemetry.io/otel/trace v1.2.0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa - golang.org/x/net v0.4.0 - golang.org/x/oauth2 v0.3.0 - golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 - golang.org/x/text v0.5.0 - golang.org/x/tools v0.1.12 - google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd - google.golang.org/grpc v1.46.2 - google.golang.org/protobuf v1.28.0 + golang.org/x/net v0.5.0 + golang.org/x/oauth2 v0.4.0 + golang.org/x/sync v0.1.0 + golang.org/x/text v0.6.0 + golang.org/x/tools v0.1.13-0.20220928184430-f80e98464e27 + google.golang.org/api v0.106.0 + google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9 + google.golang.org/grpc v1.51.0 + google.golang.org/protobuf v1.28.1 gopkg.in/square/go-jose.v2 v2.6.0 sigs.k8s.io/yaml v1.3.0 ) require ( - cloud.google.com/go v0.99.0 // indirect - cloud.google.com/go/trace v1.0.0 // indirect + cloud.google.com/go v0.108.0 // indirect + cloud.google.com/go/compute v1.15.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/iam v0.10.0 // indirect + cloud.google.com/go/trace v1.4.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/amdonov/xmlsig v0.1.0 // indirect github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.1.2 // indirect - github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7 // indirect - github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect - github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect github.com/dlclark/regexp2 v1.7.0 // indirect - github.com/drone/envsubst v1.0.3 github.com/dsoprea/go-exif v0.0.0-20210131231135-d154f10435cc // indirect github.com/dsoprea/go-exif/v2 v2.0.0-20200604193436-ca8584a0e1c4 // indirect github.com/dsoprea/go-iptc v0.0.0-20200609062250-162ae6b44feb // indirect @@ -105,7 +108,6 @@ require ( github.com/dsoprea/go-png-image-structure v0.0.0-20200807080309-a98d4e94ac82 // indirect github.com/dsoprea/go-utility v0.0.0-20200512094054-1abbbc781176 // indirect github.com/dustin/go-humanize v1.0.0 // indirect - github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect github.com/felixge/httpsnoop v1.0.2 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/fxamacker/cbor/v2 v2.2.0 // indirect @@ -121,12 +123,14 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/certificate-transparency-go v1.0.21 // indirect - github.com/google/go-cmp v0.5.8 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/googleapis/gax-go/v2 v2.1.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect + github.com/googleapis/gax-go/v2 v2.7.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/h2non/filetype v1.1.1 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect @@ -136,7 +140,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.0 // indirect github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect - github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 github.com/jinzhu/inflection v1.0.0 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -177,15 +180,14 @@ require ( github.com/wcharczuk/go-chart/v2 v2.1.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b // indirect - go.opencensus.io v0.23.0 // indirect + go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.2.0 // indirect go.opentelemetry.io/otel/internal/metric v0.25.0 // indirect go.opentelemetry.io/proto/otlp v0.10.0 // indirect golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect - golang.org/x/sys v0.3.0 // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - google.golang.org/api v0.63.0 + golang.org/x/sys v0.4.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 0531728b9..2d1a01e5d 100644 --- a/go.sum +++ b/go.sum @@ -29,18 +29,27 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= -cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.108.0 h1:xntQwnfn8oHGX0crLVinvHM+AhXvi3QHQIEcX/2hiWk= +cloud.google.com/go v0.108.0/go.mod h1:lNUfQqusBJp0bgAg6qrHgYFYbTB+dOiob1itwnlD33Q= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute v1.15.0 h1:PiKE4V948A1BRvhuwA2hOxL8imyvwuRgrOiytC+NlXo= +cloud.google.com/go/compute v1.15.0/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI= +cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM= +cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= +cloud.google.com/go/monitoring v1.8.0 h1:c9riaGSPQ4dUKWB+M1Fl0N+iLxstMbCktdEwYSPGDvA= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -50,10 +59,11 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.14.0 h1:6RRlFMv1omScs6iq2hfE3IvgE+l6RfJPampq8UZc5TU= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= -cloud.google.com/go/trace v1.0.0 h1:laKx2y7IWMjguCe5zZx6n7qLtREk4kyE69SXVC0VSN8= -cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A= +cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI= +cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= +cloud.google.com/go/trace v1.4.0 h1:qO9eLn2esajC9sxpqp1YKX37nXC3L4BfGnPS0Cx9dYo= +cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= @@ -129,7 +139,6 @@ github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -147,14 +156,12 @@ github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7/go.mod h1:yMWuSON github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -232,8 +239,6 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 h1:xvqufLtNVwAhN8NMyWklVgxnWohi+wtMGQMhtxexlm0= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= github.com/envoyproxy/protoc-gen-validate v0.6.7 h1:qcZcULcd/abmQg6dwigimCNEyi4gg31M/xaciQlDml8= @@ -370,8 +375,9 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -402,11 +408,14 @@ github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg= +github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= +github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/googleinterns/cloud-operations-api-mock v0.0.0-20200709193332-a1e58c29bdd3 h1:eHv/jVY/JNop1xg2J9cBb4EzyMpWZoNCP1BslSAIkOI= github.com/googleinterns/cloud-operations-api-mock v0.0.0-20200709193332-a1e58c29bdd3/go.mod h1:h/KNeRx7oYU4SpA4SoY7W2/NxDKEEVuwA6j9A27L4OI= @@ -441,6 +450,10 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.10.1 h1:Y7pyy1viWfoKMUVxmjfI5X6fVLl github.com/grpc-ecosystem/grpc-gateway/v2 v2.10.1/go.mod h1:chrfS3YoLAlKTRE5cFWvCbt8uGAjshktT4PveTUpsFQ= github.com/h2non/filetype v1.1.1 h1:xvOwnXKAckvtLWsN398qS9QhlxlnVXBjXBydK2/UFB4= github.com/h2non/filetype v1.1.1/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= @@ -704,6 +717,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/nicksnyder/go-i18n/v2 v2.1.2 h1:QHYxcUJnGHBaq7XbvgunmZ2Pn0focXFqTD61CkH146c= github.com/nicksnyder/go-i18n/v2 v2.1.2/go.mod h1:d++QJC9ZVf7pa48qrsRWhMJ5pSHIPmS3OLqK1niyLxs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -848,6 +863,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -855,8 +871,9 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 h1:1SWXcTphBQjYGWRRxLFIAR1LVtQEj4eR7xPtyeOVM/c= @@ -906,8 +923,9 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.27.0 h1:TON1iU3Y5oIytGQHIejDYLam5uoSMsmA0UV9Yupb5gQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.27.0/go.mod h1:T/zQwBldOpoAEpE3HMbLnI8ydESZVz4ggw6Is4FF9LI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.24.0/go.mod h1:7W3JSDYTtH3qKKHrS1fMiwLtK7iZFLPq1+7htfspX/E= @@ -1081,8 +1099,9 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1100,8 +1119,9 @@ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8= golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk= +golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M= +golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1113,8 +1133,9 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1197,14 +1218,14 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1218,8 +1239,9 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1288,15 +1310,17 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.1.13-0.20220928184430-f80e98464e27 h1:mOqz7ZhDqMSA3LafrO1Q+1yLQ/KCnCy2/5xiFQVkCWQ= +golang.org/x/tools v0.1.13-0.20220928184430-f80e98464e27/go.mod h1:VsjNM1dMo+Ofkp5d7y7fOdQZD8MTXSQ4w3EPk65AvKU= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -1330,8 +1354,8 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= -google.golang.org/api v0.63.0 h1:n2bqqK895ygnBpdPDYetfy23K7fJ22wsrZKCyfuRkkA= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.106.0 h1:ffmW0faWCwKkpbbtvlY/K/8fUl+JKvNS5CVzRoyfCv8= +google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1404,7 +1428,6 @@ google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= @@ -1413,8 +1436,8 @@ google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd h1:e0TwkXOdbnH/1x5rc5MZ/VYyiZ4v+RdVfrGMqEwT68I= -google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9 h1:3wPBShTLWQnEkZ9VW/HZZ8zT/9LLtleBtq7l8SKtJIA= +google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -1449,9 +1472,8 @@ google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.46.2 h1:u+MLGgVf7vRdjEYZ8wDFhAVNmhkbJ5hmrA1LMWK1CAQ= -google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U= +google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1466,8 +1488,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/idp/provider.go b/internal/idp/provider.go new file mode 100644 index 000000000..db2808449 --- /dev/null +++ b/internal/idp/provider.go @@ -0,0 +1,34 @@ +package idp + +import ( + "context" + + "golang.org/x/text/language" +) + +// Provider is the minimal implementation for a 3rd party authentication provider +type Provider interface { + Name() string + BeginAuth(ctx context.Context, state string, params ...any) (Session, error) + IsLinkingAllowed() bool + IsCreationAllowed() bool + IsAutoCreation() bool + IsAutoUpdate() bool +} + +// User contains the information of a federated user. +type User interface { + GetID() string + GetFirstName() string + GetLastName() string + GetDisplayName() string + GetNickname() string + GetPreferredUsername() string + GetEmail() string + IsEmailVerified() bool + GetPhone() string + IsPhoneVerified() bool + GetPreferredLanguage() language.Tag + GetAvatarURL() string + GetProfile() string +} diff --git a/internal/idp/providers/azuread/azuread.go b/internal/idp/providers/azuread/azuread.go new file mode 100644 index 000000000..b9cb50bf6 --- /dev/null +++ b/internal/idp/providers/azuread/azuread.go @@ -0,0 +1,205 @@ +package azuread + +import ( + "fmt" + + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" +) + +const ( + authURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize" + tokenURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + userinfoURL string = "https://graph.microsoft.com/oidc/userinfo" +) + +// TenantType are the well known tenant types to scope the users that can authenticate. TenantType is not an +// exclusive list of Azure Tenants which can be used. A consumer can also use their own Tenant ID to scope +// authentication to their specific Tenant either through the Tenant ID or the friendly domain name. +// +// see also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints +type TenantType string + +const ( + // CommonTenant allows users with both personal Microsoft accounts and work/school accounts from Azure Active + // Directory to sign in to the application. + CommonTenant TenantType = "common" + + // OrganizationsTenant allows only users with work/school accounts from Azure Active Directory to sign in to the application. + OrganizationsTenant TenantType = "organizations" + + // ConsumersTenant allows only users with personal Microsoft accounts (MSA) to sign in to the application. + ConsumersTenant TenantType = "consumers" +) + +var _ idp.Provider = (*Provider)(nil) + +// Provider is the [idp.Provider] implementation for AzureAD (V2 Endpoints) +type Provider struct { + *oauth.Provider + tenant TenantType + emailVerified bool + options []oauth.ProviderOpts +} + +type ProviderOptions func(*Provider) + +// WithTenant allows to set a [TenantType] (can also be a Tenant ID) +// default is CommonTenant +func WithTenant(tenantType TenantType) ProviderOptions { + return func(p *Provider) { + p.tenant = tenantType + } +} + +// WithEmailVerified allows to set every email received as verified +func WithEmailVerified() ProviderOptions { + return func(p *Provider) { + p.emailVerified = true + } +} + +// WithOAuthOptions allows to specify [oauth.ProviderOpts] like [oauth.WithLinkingAllowed] +func WithOAuthOptions(opts ...oauth.ProviderOpts) ProviderOptions { + return func(p *Provider) { + p.options = append(p.options, opts...) + } +} + +// New creates an AzureAD provider using the [oauth.Provider] (OAuth 2.0 generic provider). +// By default, it uses the [CommonTenant] and unverified emails. +func New(name, clientID, clientSecret, redirectURI string, opts ...ProviderOptions) (*Provider, error) { + provider := &Provider{ + tenant: CommonTenant, + options: make([]oauth.ProviderOpts, 0), + } + for _, opt := range opts { + opt(provider) + } + config := newConfig(provider.tenant, clientID, clientSecret, redirectURI, []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail}) + rp, err := oauth.New( + config, + name, + userinfoURL, + func() idp.User { + return &User{isEmailVerified: provider.emailVerified} + }, + provider.options..., + ) + if err != nil { + return nil, err + } + provider.Provider = rp + return provider, nil +} + +func newConfig(tenant TenantType, clientID, secret, callbackURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: clientID, + ClientSecret: secret, + RedirectURL: callbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf(authURLTemplate, tenant), + TokenURL: fmt.Sprintf(tokenURLTemplate, tenant), + }, + Scopes: []string{oidc.ScopeOpenID}, + } + if len(scopes) > 0 { + c.Scopes = scopes + } + + return c +} + +// User represents the structure return on the userinfo endpoint and implements the [idp.User] interface +// +// AzureAD does not return an `email_verified` claim. +// The verification can be automatically activated on the provider ([WithEmailVerified]) +type User struct { + Sub string `json:"sub"` + FamilyName string `json:"family_name"` + GivenName string `json:"given_name"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Picture string `json:"picture"` + isEmailVerified bool +} + +// GetID is an implementation of the [idp.User] interface. +func (u *User) GetID() string { + return u.Sub +} + +// GetFirstName is an implementation of the [idp.User] interface. +func (u *User) GetFirstName() string { + return u.GivenName +} + +// GetLastName is an implementation of the [idp.User] interface. +func (u *User) GetLastName() string { + return u.FamilyName +} + +// GetDisplayName is an implementation of the [idp.User] interface. +func (u *User) GetDisplayName() string { + return u.Name +} + +// GetNickname is an implementation of the [idp.User] interface. +// It returns an empty string because AzureAD does not provide the user's nickname. +func (u *User) GetNickname() string { + return "" +} + +// GetPreferredUsername is an implementation of the [idp.User] interface. +func (u *User) GetPreferredUsername() string { + return u.PreferredUsername +} + +// GetEmail is an implementation of the [idp.User] interface. +func (u *User) GetEmail() string { + return u.Email +} + +// IsEmailVerified is an implementation of the [idp.User] interface +// returning the value specified in the creation of the [Provider]. +// Default is false because AzureAD does not return an `email_verified` claim. +// The verification can be automatically activated on the provider ([WithEmailVerified]). +func (u *User) IsEmailVerified() bool { + return u.isEmailVerified +} + +// GetPhone is an implementation of the [idp.User] interface. +// It returns an empty string because AzureAD does not provide the user's phone. +func (u *User) GetPhone() string { + return "" +} + +// IsPhoneVerified is an implementation of the [idp.User] interface. +// It returns false because AzureAD does not provide the user's phone. +func (u *User) IsPhoneVerified() bool { + return false +} + +// GetPreferredLanguage is an implementation of the [idp.User] interface. +// It returns [language.Und] because AzureAD does not provide the user's language +func (u *User) GetPreferredLanguage() language.Tag { + // AzureAD does not provide the user's language + return language.Und +} + +// GetProfile is an implementation of the [idp.User] interface. +// It returns an empty string because AzureAD does not provide the user's profile page. +func (u *User) GetProfile() string { + return "" +} + +// GetAvatarURL is an implementation of the [idp.User] interface. +func (u *User) GetAvatarURL() string { + return u.Picture +} diff --git a/internal/idp/providers/azuread/azuread_test.go b/internal/idp/providers/azuread/azuread_test.go new file mode 100644 index 000000000..dbd18a9ae --- /dev/null +++ b/internal/idp/providers/azuread/azuread_test.go @@ -0,0 +1,162 @@ +package azuread + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + name string + clientID string + clientSecret string + redirectURI string + options []ProviderOptions + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "default common tenant", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + }, + want: &oidc.Session{ + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState", + }, + }, + { + name: "tenant", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + options: []ProviderOptions{ + WithTenant(ConsumersTenant), + }, + }, + want: &oidc.Session{ + AuthURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} + +func TestProvider_Options(t *testing.T) { + type fields struct { + name string + clientID string + clientSecret string + redirectURI string + options []ProviderOptions + } + type want struct { + name string + tenant TenantType + emailVerified bool + linkingAllowed bool + creationAllowed bool + autoCreation bool + autoUpdate bool + pkce bool + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "default common tenant", + fields: fields{ + name: "default common tenant", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + options: nil, + }, + want: want{ + name: "default common tenant", + tenant: CommonTenant, + emailVerified: false, + linkingAllowed: false, + creationAllowed: false, + autoCreation: false, + autoUpdate: false, + pkce: false, + }, + }, + { + name: "all set", + fields: fields{ + name: "custom tenant", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + options: []ProviderOptions{ + WithTenant("tenant"), + WithEmailVerified(), + WithOAuthOptions( + oauth.WithLinkingAllowed(), + oauth.WithCreationAllowed(), + oauth.WithAutoCreation(), + oauth.WithAutoUpdate(), + oauth.WithRelyingPartyOption(rp.WithPKCE(nil)), + ), + }, + }, + want: want{ + name: "custom tenant", + tenant: "tenant", + emailVerified: true, + linkingAllowed: true, + creationAllowed: true, + autoCreation: true, + autoUpdate: true, + pkce: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...) + require.NoError(t, err) + + a.Equal(tt.want.name, provider.Name()) + a.Equal(tt.want.tenant, provider.tenant) + a.Equal(tt.want.emailVerified, provider.emailVerified) + a.Equal(tt.want.linkingAllowed, provider.IsLinkingAllowed()) + a.Equal(tt.want.creationAllowed, provider.IsCreationAllowed()) + a.Equal(tt.want.autoCreation, provider.IsAutoCreation()) + a.Equal(tt.want.autoUpdate, provider.IsAutoUpdate()) + a.Equal(tt.want.pkce, provider.RelyingParty.IsPKCE()) + }) + } +} diff --git a/internal/idp/providers/azuread/session_test.go b/internal/idp/providers/azuread/session_test.go new file mode 100644 index 000000000..d86b80929 --- /dev/null +++ b/internal/idp/providers/azuread/session_test.go @@ -0,0 +1,285 @@ +package azuread + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" +) + +func TestSession_FetchUser(t *testing.T) { + type fields struct { + name string + clientID string + clientSecret string + redirectURI string + httpMock func() + options []ProviderOptions + authURL string + code string + tokens *oidc.Tokens + } + type want struct { + err func(error) bool + user idp.User + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://graph.microsoft.com"). + Get("/oidc/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState", + tokens: nil, + }, + want: want{ + err: func(err error) bool { + return errors.Is(err, oauth.ErrCodeMissing) + }, + }, + }, + { + name: "user error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://graph.microsoft.com"). + Get("/oidc/userinfo"). + Reply(http.StatusInternalServerError) + }, + authURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + IDTokenClaims: oidc.NewIDTokenClaims( + "https://login.microsoftonline.com/consumers/oauth2/v2.0", + "sub2", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + err: func(err error) bool { + return err.Error() == "http status not ok: 500 Internal Server Error " + }, + }, + }, + { + name: "successful fetch", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://graph.microsoft.com"). + Get("/oidc/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + IDTokenClaims: oidc.NewIDTokenClaims( + "https://login.microsoftonline.com/consumers/oauth2/v2.0", + "sub", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + user: &User{ + Sub: "sub", + FamilyName: "lastname", + GivenName: "firstname", + Name: "firstname lastname", + PreferredUsername: "username", + Email: "email", + Picture: "picture", + isEmailVerified: false, + }, + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "", + preferredUsername: "username", + email: "email", + isEmailVerified: false, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "picture", + profile: "", + }, + }, + { + name: "successful fetch with email verified", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + options: []ProviderOptions{ + WithEmailVerified(), + }, + httpMock: func() { + gock.New("https://graph.microsoft.com"). + Get("/oidc/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid+profile+email&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + IDTokenClaims: oidc.NewIDTokenClaims( + "https://login.microsoftonline.com/consumers/oauth2/v2.0", + "sub", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + user: &User{ + Sub: "sub", + FamilyName: "lastname", + GivenName: "firstname", + Name: "firstname lastname", + PreferredUsername: "username", + Email: "email", + Picture: "picture", + isEmailVerified: true, + }, + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "", + preferredUsername: "username", + email: "email", + isEmailVerified: true, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "picture", + profile: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock() + a := assert.New(t) + + provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...) + require.NoError(t, err) + + session := &oauth.Session{ + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + Provider: provider.Provider, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.user, user) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} + +func userinfo() oidc.UserInfoSetter { + userinfo := oidc.NewUserInfo() + userinfo.SetSubject("sub") + userinfo.SetName("firstname lastname") + userinfo.SetPreferredUsername("username") + userinfo.SetNickname("nickname") + userinfo.SetEmail("email", false) // azure add does not send the email_verified claim + userinfo.SetPicture("picture") + userinfo.SetGivenName("firstname") + userinfo.SetFamilyName("lastname") + return userinfo +} diff --git a/internal/idp/providers/github/github.go b/internal/idp/providers/github/github.go new file mode 100644 index 000000000..c3d08fa19 --- /dev/null +++ b/internal/idp/providers/github/github.go @@ -0,0 +1,189 @@ +package github + +import ( + "strconv" + "time" + + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" +) + +const ( + authURL = "https://github.com/login/oauth/authorize" + tokenURL = "https://github.com/login/oauth/access_token" + profileURL = "https://api.github.com/user" + name = "GitHub" +) + +var _ idp.Provider = (*Provider)(nil) + +// New creates a GitHub.com provider using the [oauth.Provider] (OAuth 2.0 generic provider) +func New(clientID, secret, callbackURL string, scopes []string, options ...oauth.ProviderOpts) (*Provider, error) { + return NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profileURL, scopes, options...) +} + +// NewCustomURL creates a GitHub provider using the [oauth.Provider] (OAuth 2.0 generic provider) +// with custom endpoints, e.g. GitHub Enterprise server +func NewCustomURL(name, clientID, secret, callbackURL, authURL, tokenURL, profileURL string, scopes []string, options ...oauth.ProviderOpts) (*Provider, error) { + rp, err := oauth.New( + newConfig(clientID, secret, callbackURL, authURL, tokenURL, scopes), + name, + profileURL, + func() idp.User { + return new(User) + }, + options..., + ) + if err != nil { + return nil, err + } + return &Provider{ + Provider: rp, + }, nil +} + +// Provider is the [idp.Provider] implementation for GitHub +type Provider struct { + *oauth.Provider +} + +func newConfig(clientID, secret, callbackURL, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: clientID, + ClientSecret: secret, + RedirectURL: callbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: scopes, + } + + return c +} + +// User is a representation of the authenticated GitHub user and implements the [idp.User] interface +// https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user +type User struct { + Login string `json:"login"` + ID int `json:"id"` + NodeId string `json:"node_id"` + AvatarUrl string `json:"avatar_url"` + GravatarId string `json:"gravatar_id"` + Url string `json:"url"` + HtmlUrl string `json:"html_url"` + FollowersUrl string `json:"followers_url"` + FollowingUrl string `json:"following_url"` + GistsUrl string `json:"gists_url"` + StarredUrl string `json:"starred_url"` + SubscriptionsUrl string `json:"subscriptions_url"` + OrganizationsUrl string `json:"organizations_url"` + ReposUrl string `json:"repos_url"` + EventsUrl string `json:"events_url"` + ReceivedEventsUrl string `json:"received_events_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` + Name string `json:"name"` + Company string `json:"company"` + Blog string `json:"blog"` + Location string `json:"location"` + Email string `json:"email"` + Hireable bool `json:"hireable"` + Bio string `json:"bio"` + TwitterUsername string `json:"twitter_username"` + PublicRepos int `json:"public_repos"` + PublicGists int `json:"public_gists"` + Followers int `json:"followers"` + Following int `json:"following"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PrivateGists int `json:"private_gists"` + TotalPrivateRepos int `json:"total_private_repos"` + OwnedPrivateRepos int `json:"owned_private_repos"` + DiskUsage int `json:"disk_usage"` + Collaborators int `json:"collaborators"` + TwoFactorAuthentication bool `json:"two_factor_authentication"` + Plan struct { + Name string `json:"name"` + Space int `json:"space"` + PrivateRepos int `json:"private_repos"` + Collaborators int `json:"collaborators"` + } `json:"plan"` +} + +// GetID is an implementation of the [idp.User] interface. +func (u *User) GetID() string { + return strconv.Itoa(u.ID) +} + +// GetFirstName is an implementation of the [idp.User] interface. +// It returns an empty string because GitHub does not provide the user's firstname. +func (u *User) GetFirstName() string { + return "" +} + +// GetLastName is an implementation of the [idp.User] interface. +// It returns an empty string because GitHub does not provide the user's lastname. +func (u *User) GetLastName() string { + // GitHub does not provide the user's lastname + return "" +} + +// GetDisplayName is an implementation of the [idp.User] interface. +func (u *User) GetDisplayName() string { + return u.Name +} + +// GetNickname is an implementation of the [idp.User] interface +// returning the login name of the GitHub user. +func (u *User) GetNickname() string { + return u.Login +} + +// GetPreferredUsername is an implementation of the [idp.User] interface +// returning the login name of the GitHub user. +func (u *User) GetPreferredUsername() string { + return u.Login +} + +// GetEmail is an implementation of the [idp.User] interface. +func (u *User) GetEmail() string { + return u.Email +} + +// IsEmailVerified is an implementation of the [idp.User] interface. +// It returns true because GitHub validates emails themselves. +func (u *User) IsEmailVerified() bool { + return true +} + +// GetPhone is an implementation of the [idp.User] interface. +// It returns an empty string because GitHub does not provide the user's phone. +func (u *User) GetPhone() string { + return "" +} + +// IsPhoneVerified is an implementation of the [idp.User] interface +// it returns false because GitHub does not provide the user's phone +func (u *User) IsPhoneVerified() bool { + return false +} + +// GetPreferredLanguage is an implementation of the [idp.User] interface. +// It returns [language.Und] because GitHub does not provide the user's language. +func (u *User) GetPreferredLanguage() language.Tag { + return language.Und +} + +// GetProfile is an implementation of the [idp.User] interface. +func (u *User) GetProfile() string { + return u.HtmlUrl +} + +// GetAvatarURL is an implementation of the [idp.User] interface. +func (u *User) GetAvatarURL() string { + return u.AvatarUrl +} diff --git a/internal/idp/providers/github/github_test.go b/internal/idp/providers/github/github_test.go new file mode 100644 index 000000000..bc61bb512 --- /dev/null +++ b/internal/idp/providers/github/github_test.go @@ -0,0 +1,53 @@ +package github + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + clientID string + clientSecret string + redirectURI string + scopes []string + options []oauth.ProviderOpts + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "successful auth", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + }, + want: &oauth.Session{ + AuthURL: "https://github.com/login/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&state=testState", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} diff --git a/internal/idp/providers/github/session_test.go b/internal/idp/providers/github/session_test.go new file mode 100644 index 000000000..43a770a29 --- /dev/null +++ b/internal/idp/providers/github/session_test.go @@ -0,0 +1,215 @@ +package github + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" +) + +func TestSession_FetchUser(t *testing.T) { + type fields struct { + clientID string + clientSecret string + redirectURI string + httpMock func() + authURL string + code string + tokens *oidc.Tokens + scopes []string + options []oauth.ProviderOpts + } + type args struct { + session idp.Session + } + type want struct { + err func(error) bool + user idp.User + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://api.github.com"). + Get("/user"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://github.com/login/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&state=testState", + tokens: nil, + }, + args: args{ + &oauth.Session{}, + }, + want: want{ + err: func(err error) bool { + return errors.Is(err, oauth.ErrCodeMissing) + }, + }, + }, + { + name: "user error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://api.github.com"). + Get("/user"). + Reply(http.StatusInternalServerError) + }, + authURL: "https://github.com/login/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + }, + }, + args: args{ + &oauth.Session{}, + }, + want: want{ + err: func(err error) bool { + return err.Error() == "http status not ok: 500 Internal Server Error " + }, + }, + }, + { + name: "successful fetch", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://api.github.com"). + Get("/user"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://github.com/login/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + }, + }, + args: args{ + &oauth.Session{}, + }, + want: want{ + user: &User{ + Login: "login", + ID: 1, + AvatarUrl: "avatarURL", + GravatarId: "gravatarID", + Name: "name", + Email: "email", + HtmlUrl: "htmlURL", + CreatedAt: time.Date(2023, 01, 10, 11, 10, 35, 0, time.UTC), + UpdatedAt: time.Date(2023, 01, 10, 11, 10, 35, 0, time.UTC), + }, + id: "1", + firstName: "", + lastName: "", + displayName: "name", + nickName: "login", + preferredUsername: "login", + email: "email", + isEmailVerified: true, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "avatarURL", + profile: "htmlURL", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock() + a := assert.New(t) + + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) + require.NoError(t, err) + + session := &oauth.Session{ + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + Provider: provider.Provider, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.user, user) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} + +func userinfo() *User { + return &User{ + Login: "login", + ID: 1, + AvatarUrl: "avatarURL", + GravatarId: "gravatarID", + Name: "name", + Email: "email", + HtmlUrl: "htmlURL", + CreatedAt: time.Date(2023, 01, 10, 11, 10, 35, 0, time.UTC), + UpdatedAt: time.Date(2023, 01, 10, 11, 10, 35, 0, time.UTC), + } +} diff --git a/internal/idp/providers/gitlab/gitlab.go b/internal/idp/providers/gitlab/gitlab.go new file mode 100644 index 000000000..36e7404ff --- /dev/null +++ b/internal/idp/providers/gitlab/gitlab.go @@ -0,0 +1,35 @@ +package gitlab + +import ( + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +const ( + issuer = "https://gitlab.com" + name = "GitLab" +) + +var _ idp.Provider = (*Provider)(nil) + +// Provider is the [idp.Provider] implementation for Gitlab +type Provider struct { + *oidc.Provider +} + +// New creates a GitLab.com provider using the [oidc.Provider] (OIDC generic provider) +func New(clientID, clientSecret, redirectURI string, options ...oidc.ProviderOpts) (*Provider, error) { + return NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI, options...) +} + +// NewCustomIssuer creates a GitLab provider using the [oidc.Provider] (OIDC generic provider) +// with a custom issuer for self-managed instances +func NewCustomIssuer(name, issuer, clientID, clientSecret, redirectURI string, options ...oidc.ProviderOpts) (*Provider, error) { + rp, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, oidc.DefaultMapper, options...) + if err != nil { + return nil, err + } + return &Provider{ + Provider: rp, + }, nil +} diff --git a/internal/idp/providers/gitlab/gitlab_test.go b/internal/idp/providers/gitlab/gitlab_test.go new file mode 100644 index 000000000..dd6808bb3 --- /dev/null +++ b/internal/idp/providers/gitlab/gitlab_test.go @@ -0,0 +1,52 @@ +package gitlab + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + clientID string + clientSecret string + redirectURI string + opts []oidc.ProviderOpts + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "successful auth", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + }, + want: &oidc.Session{ + AuthURL: "https://gitlab.com/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.opts...) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} diff --git a/internal/idp/providers/gitlab/session_test.go b/internal/idp/providers/gitlab/session_test.go new file mode 100644 index 000000000..cdc5cd1a4 --- /dev/null +++ b/internal/idp/providers/gitlab/session_test.go @@ -0,0 +1,212 @@ +package gitlab + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + openid "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +func TestProvider_FetchUser(t *testing.T) { + type fields struct { + clientID string + clientSecret string + redirectURI string + httpMock func() + authURL string + code string + tokens *openid.Tokens + options []oidc.ProviderOpts + } + type want struct { + err error + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://gitlab.com/oauth"). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://gitlab.com/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: nil, + }, + want: want{ + err: oidc.ErrCodeMissing, + }, + }, + { + name: "userinfo error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://gitlab.com/oauth"). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://gitlab.com/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: &openid.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: openid.BearerToken, + }, + IDTokenClaims: openid.NewIDTokenClaims( + issuer, + "sub2", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + err: rp.ErrUserInfoSubNotMatching, + }, + }, + { + name: "successful fetch", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://gitlab.com/oauth"). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://gitlab.com/oauth/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: &openid.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: openid.BearerToken, + }, + IDTokenClaims: openid.NewIDTokenClaims( + issuer, + "sub", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "nickname", + preferredUsername: "username", + email: "email", + isEmailVerified: true, + phone: "phone", + isPhoneVerified: true, + preferredLanguage: language.English, + avatarURL: "picture", + profile: "profile", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock() + a := assert.New(t) + + // call the real discovery endpoint + gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking() + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.options...) + require.NoError(t, err) + + session := &oidc.Session{ + Provider: provider.Provider, + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !errors.Is(err, tt.want.err) { + a.Fail("invalid error", "expected %v, got %v", tt.want.err, err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} + +func userinfo() openid.UserInfoSetter { + info := openid.NewUserInfo() + info.SetSubject("sub") + info.SetGivenName("firstname") + info.SetFamilyName("lastname") + info.SetName("firstname lastname") + info.SetNickname("nickname") + info.SetPreferredUsername("username") + info.SetEmail("email", true) + info.SetPhone("phone", true) + info.SetLocale(language.English) + info.SetPicture("picture") + info.SetProfile("profile") + return info +} diff --git a/internal/idp/providers/google/google.go b/internal/idp/providers/google/google.go new file mode 100644 index 000000000..c60382632 --- /dev/null +++ b/internal/idp/providers/google/google.go @@ -0,0 +1,47 @@ +package google + +import ( + openid "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +const ( + issuer = "https://accounts.google.com" + name = "Google" +) + +var _ idp.Provider = (*Provider)(nil) + +// Provider is the [idp.Provider] implementation for Google +type Provider struct { + *oidc.Provider +} + +// New creates a Google provider using the [oidc.Provider] (OIDC generic provider) +func New(clientID, clientSecret, redirectURI string, opts ...oidc.ProviderOpts) (*Provider, error) { + rp, err := oidc.New(name, issuer, clientID, clientSecret, redirectURI, userMapper, opts...) + if err != nil { + return nil, err + } + return &Provider{ + Provider: rp, + }, nil +} + +var userMapper = func(info openid.UserInfo) idp.User { + return &User{oidc.DefaultMapper(info)} +} + +// User is a representation of the authenticated Google and implements the [idp.User] interface +// by wrapping an [idp.User] (implemented by [oidc.User]). It overwrites the [GetPreferredUsername] to use the `email` claim. +type User struct { + idp.User +} + +// GetPreferredUsername implements the [idp.User] interface. +// It returns the email, because Google does not return a username. +func (u *User) GetPreferredUsername() string { + return u.GetEmail() +} diff --git a/internal/idp/providers/google/google_test.go b/internal/idp/providers/google/google_test.go new file mode 100644 index 000000000..c6f860396 --- /dev/null +++ b/internal/idp/providers/google/google_test.go @@ -0,0 +1,51 @@ +package google + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + clientID string + clientSecret string + redirectURI string + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "successful auth", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + }, + want: &oidc.Session{ + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} diff --git a/internal/idp/providers/google/session_test.go b/internal/idp/providers/google/session_test.go new file mode 100644 index 000000000..941060f69 --- /dev/null +++ b/internal/idp/providers/google/session_test.go @@ -0,0 +1,210 @@ +package google + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + openid "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +func TestSession_FetchUser(t *testing.T) { + type fields struct { + clientID string + clientSecret string + redirectURI string + httpMock func() + authURL string + code string + tokens *openid.Tokens + } + type want struct { + err error + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + hostedDomain string + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://openidconnect.googleapis.com"). + Get("/v1/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://accounts.google.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: nil, + }, + want: want{ + err: oidc.ErrCodeMissing, + }, + }, + { + name: "userinfo error", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://openidconnect.googleapis.com"). + Get("/v1/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://accounts.google.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: &openid.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: openid.BearerToken, + }, + IDTokenClaims: openid.NewIDTokenClaims( + issuer, + "sub2", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + err: rp.ErrUserInfoSubNotMatching, + }, + }, + { + name: "successful fetch", + fields: fields{ + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + httpMock: func() { + gock.New("https://openidconnect.googleapis.com"). + Get("/v1/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://accounts.google.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: &openid.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: openid.BearerToken, + }, + IDTokenClaims: openid.NewIDTokenClaims( + issuer, + "sub", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "", + preferredUsername: "email", + email: "email", + isEmailVerified: true, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.English, + avatarURL: "picture", + profile: "", + hostedDomain: "hosted domain", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock() + a := assert.New(t) + + // call the real discovery endpoint + gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking() + provider, err := New(tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI) + require.NoError(t, err) + + session := &oidc.Session{ + Provider: provider.Provider, + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !errors.Is(err, tt.want.err) { + a.Fail("invalid error", "expected %v, got %v", tt.want.err, err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} + +func userinfo() openid.UserInfoSetter { + info := openid.NewUserInfo() + info.SetSubject("sub") + info.SetGivenName("firstname") + info.SetFamilyName("lastname") + info.SetName("firstname lastname") + info.SetEmail("email", true) + info.SetLocale(language.English) + info.SetPicture("picture") + info.AppendClaims("hd", "hosted domain") + return info +} diff --git a/internal/idp/providers/jwt/jwt.go b/internal/idp/providers/jwt/jwt.go new file mode 100644 index 000000000..104e70e6a --- /dev/null +++ b/internal/idp/providers/jwt/jwt.go @@ -0,0 +1,136 @@ +package jwt + +import ( + "context" + "encoding/base64" + "errors" + "net/url" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/idp" +) + +const ( + queryAuthRequestID = "authRequestID" + queryUserAgentID = "userAgentID" +) + +var _ idp.Provider = (*Provider)(nil) + +var ( + ErrNoTokens = errors.New("no tokens provided") + ErrMissingUserAgentID = errors.New("userAgentID missing") +) + +// Provider is the [idp.Provider] implementation for a JWT provider +type Provider struct { + name string + headerName string + issuer string + jwtEndpoint string + keysEndpoint string + isLinkingAllowed bool + isCreationAllowed bool + isAutoCreation bool + isAutoUpdate bool + encryptionAlg crypto.EncryptionAlgorithm +} + +type ProviderOpts func(provider *Provider) + +// WithLinkingAllowed allows end users to link the federated user to an existing one +func WithLinkingAllowed() ProviderOpts { + return func(p *Provider) { + p.isLinkingAllowed = true + } +} + +// WithCreationAllowed allows end users to create a new user using the federated information +func WithCreationAllowed() ProviderOpts { + return func(p *Provider) { + p.isCreationAllowed = true + } +} + +// WithAutoCreation enables that federated users are automatically created if not already existing +func WithAutoCreation() ProviderOpts { + return func(p *Provider) { + p.isAutoCreation = true + } +} + +// WithAutoUpdate enables that information retrieved from the provider is automatically used to update +// the existing user on each authentication +func WithAutoUpdate() ProviderOpts { + return func(p *Provider) { + p.isAutoUpdate = true + } +} + +// New creates a JWT provider +func New(name, issuer, jwtEndpoint, keysEndpoint, headerName string, encryptionAlg crypto.EncryptionAlgorithm, options ...ProviderOpts) (*Provider, error) { + provider := &Provider{ + name: name, + issuer: issuer, + jwtEndpoint: jwtEndpoint, + keysEndpoint: keysEndpoint, + headerName: headerName, + encryptionAlg: encryptionAlg, + } + for _, option := range options { + option(provider) + } + + return provider, nil +} + +// Name implements the [idp.Provider] interface +func (p *Provider) Name() string { + return p.name +} + +// BeginAuth implements the [idp.Provider] interface. +// It will create a [Session] with an AuthURL, pointing to the jwtEndpoint +// with the authRequest and encrypted userAgent ids. +func (p *Provider) BeginAuth(ctx context.Context, state string, params ...any) (idp.Session, error) { + if len(params) != 1 { + return nil, ErrMissingUserAgentID + } + userAgentID, ok := params[0].(string) + if !ok { + return nil, ErrMissingUserAgentID + } + redirect, err := url.Parse(p.jwtEndpoint) + if err != nil { + return nil, err + } + q := redirect.Query() + q.Set(queryAuthRequestID, state) + nonce, err := p.encryptionAlg.Encrypt([]byte(userAgentID)) + if err != nil { + return nil, err + } + q.Set(queryUserAgentID, base64.RawURLEncoding.EncodeToString(nonce)) + redirect.RawQuery = q.Encode() + return &Session{AuthURL: redirect.String()}, nil +} + +// IsLinkingAllowed implements the [idp.Provider] interface. +func (p *Provider) IsLinkingAllowed() bool { + return p.isLinkingAllowed +} + +// IsCreationAllowed implements the [idp.Provider] interface. +func (p *Provider) IsCreationAllowed() bool { + return p.isCreationAllowed +} + +// IsAutoCreation implements the [idp.Provider] interface. +func (p *Provider) IsAutoCreation() bool { + return p.isAutoCreation +} + +// IsAutoUpdate implements the [idp.Provider] interface. +func (p *Provider) IsAutoUpdate() bool { + return p.isAutoUpdate +} diff --git a/internal/idp/providers/jwt/jwt_test.go b/internal/idp/providers/jwt/jwt_test.go new file mode 100644 index 000000000..2c8725fc9 --- /dev/null +++ b/internal/idp/providers/jwt/jwt_test.go @@ -0,0 +1,222 @@ +package jwt + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/idp" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + name string + issuer string + jwtEndpoint string + keysEndpoint string + headerName string + encryptionAlg func(t *testing.T) crypto.EncryptionAlgorithm + } + type args struct { + params []any + } + type want struct { + session idp.Session + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "missing userAgentID error", + fields: fields{ + issuer: "https://jwt.com", + jwtEndpoint: "https://auth.com/jwt", + keysEndpoint: "https://jwt.com/keys", + headerName: "jwt-header", + encryptionAlg: func(t *testing.T) crypto.EncryptionAlgorithm { + return crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + }, + }, + args: args{ + params: nil, + }, + want: want{ + err: func(err error) bool { + return errors.Is(err, ErrMissingUserAgentID) + }, + }, + }, + { + name: "invalid userAgentID error", + fields: fields{ + issuer: "https://jwt.com", + jwtEndpoint: "https://auth.com/jwt", + keysEndpoint: "https://jwt.com/keys", + headerName: "jwt-header", + encryptionAlg: func(t *testing.T) crypto.EncryptionAlgorithm { + return crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + }, + }, + args: args{ + params: []any{ + 0, + }, + }, + want: want{ + err: func(err error) bool { + return errors.Is(err, ErrMissingUserAgentID) + }, + }, + }, + { + name: "successful auth", + fields: fields{ + issuer: "https://jwt.com", + jwtEndpoint: "https://auth.com/jwt", + keysEndpoint: "https://jwt.com/keys", + headerName: "jwt-header", + encryptionAlg: func(t *testing.T) crypto.EncryptionAlgorithm { + return crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + }, + }, + args: args{ + params: []any{ + "agent", + }, + }, + want: want{ + session: &Session{AuthURL: "https://auth.com/jwt?authRequestID=testState&userAgentID=YWdlbnQ"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New( + tt.fields.name, + tt.fields.issuer, + tt.fields.jwtEndpoint, + tt.fields.keysEndpoint, + tt.fields.headerName, + tt.fields.encryptionAlg(t), + ) + require.NoError(t, err) + + session, err := provider.BeginAuth(context.Background(), "testState", tt.args.params...) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.session.GetAuthURL(), session.GetAuthURL()) + } + }) + } +} + +func TestProvider_Options(t *testing.T) { + type fields struct { + name string + issuer string + jwtEndpoint string + keysEndpoint string + headerName string + encryptionAlg func(t *testing.T) crypto.EncryptionAlgorithm + opts []ProviderOpts + } + type want struct { + name string + linkingAllowed bool + creationAllowed bool + autoCreation bool + autoUpdate bool + pkce bool + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "default", + fields: fields{ + name: "jwt", + issuer: "https://jwt.com", + jwtEndpoint: "https://auth.com/jwt", + keysEndpoint: "https://jwt.com/keys", + headerName: "jwt-header", + encryptionAlg: func(t *testing.T) crypto.EncryptionAlgorithm { + return crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + }, + opts: nil, + }, + want: want{ + name: "jwt", + linkingAllowed: false, + creationAllowed: false, + autoCreation: false, + autoUpdate: false, + pkce: false, + }, + }, + { + name: "all true", + fields: fields{ + name: "jwt", + issuer: "https://jwt.com", + jwtEndpoint: "https://auth.com/jwt", + keysEndpoint: "https://jwt.com/keys", + headerName: "jwt-header", + encryptionAlg: func(t *testing.T) crypto.EncryptionAlgorithm { + return crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + }, + opts: []ProviderOpts{ + WithLinkingAllowed(), + WithCreationAllowed(), + WithAutoCreation(), + WithAutoUpdate(), + }, + }, + want: want{ + name: "jwt", + linkingAllowed: true, + creationAllowed: true, + autoCreation: true, + autoUpdate: true, + pkce: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New( + tt.fields.name, + tt.fields.issuer, + tt.fields.jwtEndpoint, + tt.fields.keysEndpoint, + tt.fields.headerName, + tt.fields.encryptionAlg(t), + tt.fields.opts..., + ) + require.NoError(t, err) + + a.Equal(tt.want.name, provider.Name()) + a.Equal(tt.want.linkingAllowed, provider.IsLinkingAllowed()) + a.Equal(tt.want.creationAllowed, provider.IsCreationAllowed()) + a.Equal(tt.want.autoCreation, provider.IsAutoCreation()) + a.Equal(tt.want.autoUpdate, provider.IsAutoUpdate()) + }) + } +} diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go new file mode 100644 index 000000000..9aa29735a --- /dev/null +++ b/internal/idp/providers/jwt/session.go @@ -0,0 +1,72 @@ +package jwt + +import ( + "context" + + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" +) + +var _ idp.Session = (*Session)(nil) + +// Session is the [idp.Session] implementation for the JWT provider +type Session struct { + AuthURL string + Tokens *oidc.Tokens +} + +// GetAuthURL implements the [idp.Session] interface +func (s *Session) GetAuthURL() string { + return s.AuthURL +} + +// FetchUser implements the [idp.Session] interface. +// It will map the received idToken into an [idp.User]. +func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { + if s.Tokens == nil { + return nil, ErrNoTokens + } + return &User{s.Tokens.IDTokenClaims}, nil +} + +type User struct { + oidc.IDTokenClaims +} + +func (u *User) GetID() string { + return u.IDTokenClaims.GetSubject() +} + +func (u *User) GetFirstName() string { + return u.IDTokenClaims.GetGivenName() +} + +func (u *User) GetLastName() string { + return u.IDTokenClaims.GetFamilyName() +} + +func (u *User) GetDisplayName() string { + return u.IDTokenClaims.GetName() +} + +func (u *User) GetNickname() string { + return u.IDTokenClaims.GetNickname() +} + +func (u *User) GetPhone() string { + return u.IDTokenClaims.GetPhoneNumber() +} + +func (u *User) IsPhoneVerified() bool { + return u.IDTokenClaims.IsPhoneNumberVerified() +} + +func (u *User) GetPreferredLanguage() language.Tag { + return u.IDTokenClaims.GetLocale() +} + +func (u *User) GetAvatarURL() string { + return u.IDTokenClaims.GetPicture() +} diff --git a/internal/idp/providers/jwt/session_test.go b/internal/idp/providers/jwt/session_test.go new file mode 100644 index 000000000..d8a92a421 --- /dev/null +++ b/internal/idp/providers/jwt/session_test.go @@ -0,0 +1,145 @@ +package jwt + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" +) + +func TestSession_FetchUser(t *testing.T) { + type fields struct { + authURL string + tokens *oidc.Tokens + } + type want struct { + err func(error) bool + user idp.User + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "no tokens", + fields: fields{}, + want: want{ + err: func(err error) bool { + return errors.Is(err, ErrNoTokens) + }, + }, + }, + { + name: "successful fetch", + fields: fields{ + authURL: "https://auth.com/jwt?authRequestID=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{}, + IDTokenClaims: func() oidc.IDTokenClaims { + claims := oidc.EmptyIDTokenClaims() + userinfo := oidc.NewUserInfo() + userinfo.SetSubject("sub") + userinfo.SetPicture("picture") + userinfo.SetName("firstname lastname") + userinfo.SetEmail("email", true) + userinfo.SetGivenName("firstname") + userinfo.SetFamilyName("lastname") + userinfo.SetNickname("nickname") + userinfo.SetPreferredUsername("username") + userinfo.SetProfile("profile") + userinfo.SetPhone("phone", true) + userinfo.SetLocale(language.English) + claims.SetUserinfo(userinfo) + return claims + }(), + }, + }, + want: want{ + user: &User{ + IDTokenClaims: func() oidc.IDTokenClaims { + claims := oidc.EmptyIDTokenClaims() + userinfo := oidc.NewUserInfo() + userinfo.SetSubject("sub") + userinfo.SetPicture("picture") + userinfo.SetName("firstname lastname") + userinfo.SetEmail("email", true) + userinfo.SetGivenName("firstname") + userinfo.SetFamilyName("lastname") + userinfo.SetNickname("nickname") + userinfo.SetPreferredUsername("username") + userinfo.SetProfile("profile") + userinfo.SetPhone("phone", true) + userinfo.SetLocale(language.English) + claims.SetUserinfo(userinfo) + return claims + }(), + }, + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "nickname", + preferredUsername: "username", + email: "email", + isEmailVerified: true, + phone: "phone", + isPhoneVerified: true, + preferredLanguage: language.English, + avatarURL: "picture", + profile: "profile", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + session := &Session{ + AuthURL: tt.fields.authURL, + Tokens: tt.fields.tokens, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.user, user) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} diff --git a/internal/idp/providers/oauth/mapper.go b/internal/idp/providers/oauth/mapper.go new file mode 100644 index 000000000..fca9d994b --- /dev/null +++ b/internal/idp/providers/oauth/mapper.go @@ -0,0 +1,102 @@ +package oauth + +import ( + "encoding/json" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" +) + +var _ idp.User = (*UserMapper)(nil) + +// UserMapper is an implementation of [idp.User]. +// It can be used in ZITADEL actions to map the raw `info` +type UserMapper struct { + ID string + FirstName string + LastName string + DisplayName string + NickName string + PreferredUsername string + Email string + EmailVerified bool + Phone string + PhoneVerified bool + PreferredLanguage string + AvatarURL string + Profile string + info map[string]interface{} +} + +func (u *UserMapper) UnmarshalJSON(data []byte) error { + if u.info == nil { + u.info = make(map[string]interface{}) + } + return json.Unmarshal(data, &u.info) +} + +// GetID is an implementation of the [idp.User] interface. +func (u *UserMapper) GetID() string { + return u.ID +} + +// GetFirstName is an implementation of the [idp.User] interface. +func (u *UserMapper) GetFirstName() string { + return u.FirstName +} + +// GetLastName is an implementation of the [idp.User] interface. +func (u *UserMapper) GetLastName() string { + return u.LastName +} + +// GetDisplayName is an implementation of the [idp.User] interface. +func (u *UserMapper) GetDisplayName() string { + return u.DisplayName +} + +// GetNickname is an implementation of the [idp.User] interface. +func (u *UserMapper) GetNickname() string { + return u.NickName +} + +// GetPreferredUsername is an implementation of the [idp.User] interface. +func (u *UserMapper) GetPreferredUsername() string { + return u.PreferredUsername +} + +// GetEmail is an implementation of the [idp.User] interface. +func (u *UserMapper) GetEmail() string { + return u.Email +} + +// IsEmailVerified is an implementation of the [idp.User] interface. +func (u *UserMapper) IsEmailVerified() bool { + return u.EmailVerified +} + +// GetPhone is an implementation of the [idp.User] interface. +func (u *UserMapper) GetPhone() string { + return u.Phone +} + +// IsPhoneVerified is an implementation of the [idp.User] interface. +func (u *UserMapper) IsPhoneVerified() bool { + return u.PhoneVerified +} + +// GetPreferredLanguage is an implementation of the [idp.User] interface. +func (u *UserMapper) GetPreferredLanguage() language.Tag { + return language.Make(u.PreferredLanguage) +} + +// GetAvatarURL is an implementation of the [idp.User] interface. +func (u *UserMapper) GetAvatarURL() string { + return u.AvatarURL +} + +// GetProfile is an implementation of the [idp.User] interface. +func (u *UserMapper) GetProfile() string { + return u.Profile +} diff --git a/internal/idp/providers/oauth/oauth2.go b/internal/idp/providers/oauth/oauth2.go new file mode 100644 index 000000000..479913192 --- /dev/null +++ b/internal/idp/providers/oauth/oauth2.go @@ -0,0 +1,112 @@ +package oauth + +import ( + "context" + + "github.com/zitadel/oidc/v2/pkg/client/rp" + "golang.org/x/oauth2" + + "github.com/zitadel/zitadel/internal/idp" +) + +var _ idp.Provider = (*Provider)(nil) + +// Provider is the [idp.Provider] implementation for a generic OAuth 2.0 provider +type Provider struct { + rp.RelyingParty + options []rp.Option + name string + userEndpoint string + userMapper func() idp.User + isLinkingAllowed bool + isCreationAllowed bool + isAutoCreation bool + isAutoUpdate bool +} + +type ProviderOpts func(provider *Provider) + +// WithLinkingAllowed allows end users to link the federated user to an existing one. +func WithLinkingAllowed() ProviderOpts { + return func(p *Provider) { + p.isLinkingAllowed = true + } +} + +// WithCreationAllowed allows end users to create a new user using the federated information. +func WithCreationAllowed() ProviderOpts { + return func(p *Provider) { + p.isCreationAllowed = true + } +} + +// WithAutoCreation enables that federated users are automatically created if not already existing. +func WithAutoCreation() ProviderOpts { + return func(p *Provider) { + p.isAutoCreation = true + } +} + +// WithAutoUpdate enables that information retrieved from the provider is automatically used to update +// the existing user on each authentication. +func WithAutoUpdate() ProviderOpts { + return func(p *Provider) { + p.isAutoUpdate = true + } +} + +// WithRelyingPartyOption allows to set an additional [rp.Option] like [rp.WithPKCE]. +func WithRelyingPartyOption(option rp.Option) ProviderOpts { + return func(p *Provider) { + p.options = append(p.options, option) + } +} + +// New creates a generic OAuth 2.0 provider +func New(config *oauth2.Config, name, userEndpoint string, userMapper func() idp.User, options ...ProviderOpts) (provider *Provider, err error) { + provider = &Provider{ + name: name, + userEndpoint: userEndpoint, + userMapper: userMapper, + } + for _, option := range options { + option(provider) + } + provider.RelyingParty, err = rp.NewRelyingPartyOAuth(config, provider.options...) + if err != nil { + return nil, err + } + return provider, nil +} + +// Name implements the [idp.Provider] interface +func (p *Provider) Name() string { + return p.name +} + +// BeginAuth implements the [idp.Provider] interface. +// It will create a [Session] with an OAuth2.0 authorization request as AuthURL. +func (p *Provider) BeginAuth(ctx context.Context, state string, _ ...any) (idp.Session, error) { + url := rp.AuthURL(state, p.RelyingParty) + return &Session{AuthURL: url, Provider: p}, nil +} + +// IsLinkingAllowed implements the [idp.Provider] interface. +func (p *Provider) IsLinkingAllowed() bool { + return p.isLinkingAllowed +} + +// IsCreationAllowed implements the [idp.Provider] interface. +func (p *Provider) IsCreationAllowed() bool { + return p.isCreationAllowed +} + +// IsAutoCreation implements the [idp.Provider] interface. +func (p *Provider) IsAutoCreation() bool { + return p.isAutoCreation +} + +// IsAutoUpdate implements the [idp.Provider] interface. +func (p *Provider) IsAutoUpdate() bool { + return p.isAutoUpdate +} diff --git a/internal/idp/providers/oauth/oauth2_test.go b/internal/idp/providers/oauth/oauth2_test.go new file mode 100644 index 000000000..84eca5509 --- /dev/null +++ b/internal/idp/providers/oauth/oauth2_test.go @@ -0,0 +1,153 @@ +package oauth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "golang.org/x/oauth2" + + "github.com/zitadel/zitadel/internal/idp" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + config *oauth2.Config + name string + userEndpoint string + userMapper func() idp.User + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "successful auth", + fields: fields{ + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + }, + want: &Session{AuthURL: "https://oauth2.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=user&state=testState"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} + +func TestProvider_Options(t *testing.T) { + type fields struct { + config *oauth2.Config + name string + userEndpoint string + userMapper func() idp.User + options []ProviderOpts + } + type want struct { + name string + linkingAllowed bool + creationAllowed bool + autoCreation bool + autoUpdate bool + pkce bool + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "default", + fields: fields{ + name: "oauth", + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + options: nil, + }, + want: want{ + name: "oauth", + linkingAllowed: false, + creationAllowed: false, + autoCreation: false, + autoUpdate: false, + pkce: false, + }, + }, + { + name: "all true", + fields: fields{ + name: "oauth", + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + options: []ProviderOpts{ + WithLinkingAllowed(), + WithCreationAllowed(), + WithAutoCreation(), + WithAutoUpdate(), + WithRelyingPartyOption(rp.WithPKCE(nil)), + }, + }, + want: want{ + name: "oauth", + linkingAllowed: true, + creationAllowed: true, + autoCreation: true, + autoUpdate: true, + pkce: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper, tt.fields.options...) + require.NoError(t, err) + + a.Equal(tt.want.name, provider.Name()) + a.Equal(tt.want.linkingAllowed, provider.IsLinkingAllowed()) + a.Equal(tt.want.creationAllowed, provider.IsCreationAllowed()) + a.Equal(tt.want.autoCreation, provider.IsAutoCreation()) + a.Equal(tt.want.autoUpdate, provider.IsAutoUpdate()) + a.Equal(tt.want.pkce, provider.RelyingParty.IsPKCE()) + }) + } +} diff --git a/internal/idp/providers/oauth/session.go b/internal/idp/providers/oauth/session.go new file mode 100644 index 000000000..3f9ccd23b --- /dev/null +++ b/internal/idp/providers/oauth/session.go @@ -0,0 +1,63 @@ +package oauth + +import ( + "context" + "errors" + "net/http" + + "github.com/zitadel/oidc/v2/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/internal/idp" +) + +var ErrCodeMissing = errors.New("no auth code provided") + +var _ idp.Session = (*Session)(nil) + +// Session is the [idp.Session] implementation for the OAuth2.0 provider. +type Session struct { + AuthURL string + Code string + Tokens *oidc.Tokens + + Provider *Provider +} + +// GetAuthURL implements the [idp.Session] interface. +func (s *Session) GetAuthURL() string { + return s.AuthURL +} + +// FetchUser implements the [idp.Session] interface. +// It will execute an OAuth 2.0 code exchange if needed to retrieve the access token, +// call the specified userEndpoint and map the received information into an [idp.User]. +func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { + if s.Tokens == nil { + if err = s.authorize(ctx); err != nil { + return nil, err + } + } + req, err := http.NewRequest("GET", s.Provider.userEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("authorization", s.Tokens.TokenType+" "+s.Tokens.AccessToken) + mapper := s.Provider.userMapper() + if err := httphelper.HttpRequest(s.Provider.RelyingParty.HttpClient(), req, &mapper); err != nil { + return nil, err + } + return mapper, nil +} + +func (s *Session) authorize(ctx context.Context) (err error) { + if s.Code == "" { + return ErrCodeMissing + } + s.Tokens, err = rp.CodeExchange(ctx, s.Code, s.Provider.RelyingParty) + if err != nil { + return err + } + return nil +} diff --git a/internal/idp/providers/oauth/session_test.go b/internal/idp/providers/oauth/session_test.go new file mode 100644 index 000000000..f0761b45a --- /dev/null +++ b/internal/idp/providers/oauth/session_test.go @@ -0,0 +1,273 @@ +package oauth + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" +) + +func TestProvider_FetchUser(t *testing.T) { + type fields struct { + config *oauth2.Config + name string + userEndpoint string + httpMock func(issuer string) + userMapper func() idp.User + authURL string + code string + tokens *oidc.Tokens + } + type want struct { + err func(error) bool + user idp.User + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + userEndpoint: "https://oauth2.com/user", + httpMock: func(issuer string) {}, + authURL: "https://oauth2.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=user&state=testState", + tokens: nil, + }, + want: want{ + err: func(err error) bool { + return errors.Is(err, ErrCodeMissing) + }, + }, + }, + { + name: "user error", + fields: fields{ + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + userEndpoint: "https://oauth2.com/user", + httpMock: func(issuer string) { + gock.New(issuer). + Get("/user"). + Reply(http.StatusInternalServerError) + }, + userMapper: func() idp.User { + return &UserMapper{ + ID: "userID", + } + }, + authURL: "https://oauth2.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=user&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + }, + }, + want: want{ + err: func(err error) bool { + return err.Error() == "http status not ok: 500 Internal Server Error " + }, + }, + }, + { + name: "successful fetch", + fields: fields{ + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + userEndpoint: "https://oauth2.com/user", + httpMock: func(issuer string) { + gock.New(issuer). + Get("/user"). + Reply(200). + JSON(map[string]interface{}{ + "userID": "id", + "custom": "claim", + }) + }, + userMapper: func() idp.User { + return &UserMapper{} + }, + authURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=user&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + }, + }, + want: want{ + user: &UserMapper{ + info: map[string]interface{}{ + "userID": "id", + "custom": "claim", + }, + }, + id: "", + firstName: "", + lastName: "", + displayName: "", + nickName: "", + preferredUsername: "", + email: "", + isEmailVerified: false, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "", + profile: "", + }, + }, + { + name: "successful fetch with code exchange", + fields: fields{ + config: &oauth2.Config{ + ClientID: "clientID", + ClientSecret: "clientSecret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://oauth2.com/authorize", + TokenURL: "https://oauth2.com/token", + }, + RedirectURL: "redirectURI", + Scopes: []string{"user"}, + }, + userEndpoint: "https://oauth2.com/user", + httpMock: func(issuer string) { + gock.New(issuer). + Post("/token"). + BodyString("client_id=clientID&client_secret=clientSecret&code=code&grant_type=authorization_code&redirect_uri=redirectURI"). + Reply(200). + JSON(&oidc.AccessTokenResponse{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + RefreshToken: "", + ExpiresIn: 3600, + IDToken: "", + State: "testState"}) + gock.New(issuer). + Get("/user"). + Reply(200). + JSON(map[string]interface{}{ + "userID": "id", + "custom": "claim", + }) + }, + userMapper: func() idp.User { + return &UserMapper{} + }, + authURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=user&state=testState", + tokens: nil, + code: "code", + }, + want: want{ + user: &UserMapper{ + info: map[string]interface{}{ + "userID": "id", + "custom": "claim", + }, + }, + id: "", + firstName: "", + lastName: "", + displayName: "", + nickName: "", + preferredUsername: "", + email: "", + isEmailVerified: false, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "", + profile: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock("https://oauth2.com") + a := assert.New(t) + + provider, err := New(tt.fields.config, tt.fields.name, tt.fields.userEndpoint, tt.fields.userMapper) + require.NoError(t, err) + + session := &Session{ + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + Provider: provider, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.user, user) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} diff --git a/internal/idp/providers/oidc/oidc.go b/internal/idp/providers/oidc/oidc.go new file mode 100644 index 000000000..06fedbab4 --- /dev/null +++ b/internal/idp/providers/oidc/oidc.go @@ -0,0 +1,116 @@ +package oidc + +import ( + "context" + + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/internal/idp" +) + +var _ idp.Provider = (*Provider)(nil) + +// Provider is the [idp.Provider] implementation for a generic OIDC provider +type Provider struct { + rp.RelyingParty + options []rp.Option + name string + isLinkingAllowed bool + isCreationAllowed bool + isAutoCreation bool + isAutoUpdate bool + userInfoMapper func(info oidc.UserInfo) idp.User +} + +type ProviderOpts func(provider *Provider) + +// WithLinkingAllowed allows end users to link the federated user to an existing one. +func WithLinkingAllowed() ProviderOpts { + return func(p *Provider) { + p.isLinkingAllowed = true + } +} + +// WithCreationAllowed allows end users to create a new user using the federated information. +func WithCreationAllowed() ProviderOpts { + return func(p *Provider) { + p.isCreationAllowed = true + } +} + +// WithAutoCreation enables that federated users are automatically created if not already existing. +func WithAutoCreation() ProviderOpts { + return func(p *Provider) { + p.isAutoCreation = true + } +} + +// WithAutoUpdate enables that information retrieved from the provider is automatically used to update +// the existing user on each authentication. +func WithAutoUpdate() ProviderOpts { + return func(p *Provider) { + p.isAutoUpdate = true + } +} + +// WithRelyingPartyOption allows to set an additional [rp.Option] like [rp.WithPKCE]. +func WithRelyingPartyOption(option rp.Option) ProviderOpts { + return func(p *Provider) { + p.options = append(p.options, option) + } +} + +type UserInfoMapper func(info oidc.UserInfo) idp.User + +var DefaultMapper UserInfoMapper = func(info oidc.UserInfo) idp.User { + return NewUser(info) +} + +// New creates a generic OIDC provider +func New(name, issuer, clientID, clientSecret, redirectURI string, userInfoMapper UserInfoMapper, options ...ProviderOpts) (provider *Provider, err error) { + provider = &Provider{ + name: name, + userInfoMapper: userInfoMapper, + } + for _, option := range options { + option(provider) + } + provider.RelyingParty, err = rp.NewRelyingPartyOIDC(issuer, clientID, clientSecret, redirectURI, []string{oidc.ScopeOpenID}, provider.options...) + if err != nil { + return nil, err + } + return provider, nil +} + +// Name implements the [idp.Provider] interface +func (p *Provider) Name() string { + return p.name +} + +// BeginAuth implements the [idp.Provider] interface. +// It will create a [Session] with an OIDC authorization request as AuthURL. +func (p *Provider) BeginAuth(ctx context.Context, state string, _ ...any) (idp.Session, error) { + url := rp.AuthURL(state, p.RelyingParty) + return &Session{AuthURL: url, Provider: p}, nil +} + +// IsLinkingAllowed implements the [idp.Provider] interface. +func (p *Provider) IsLinkingAllowed() bool { + return p.isLinkingAllowed +} + +// IsCreationAllowed implements the [idp.Provider] interface. +func (p *Provider) IsCreationAllowed() bool { + return p.isCreationAllowed +} + +// IsAutoCreation implements the [idp.Provider] interface. +func (p *Provider) IsAutoCreation() bool { + return p.isAutoCreation +} + +// IsAutoUpdate implements the [idp.Provider] interface. +func (p *Provider) IsAutoUpdate() bool { + return p.isAutoUpdate +} diff --git a/internal/idp/providers/oidc/oidc_test.go b/internal/idp/providers/oidc/oidc_test.go new file mode 100644 index 000000000..571ab4ae5 --- /dev/null +++ b/internal/idp/providers/oidc/oidc_test.go @@ -0,0 +1,182 @@ +package oidc + +import ( + "context" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/internal/idp" +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + name string + issuer string + clientID string + clientSecret string + redirectURI string + userMapper func(info oidc.UserInfo) idp.User + httpMock func(issuer string) + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "successful auth", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + }, + }, + want: &Session{AuthURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock(tt.fields.issuer) + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.userMapper) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} + +func TestProvider_Options(t *testing.T) { + type fields struct { + name string + issuer string + clientID string + clientSecret string + redirectURI string + userMapper func(info oidc.UserInfo) idp.User + opts []ProviderOpts + httpMock func(issuer string) + } + type want struct { + name string + linkingAllowed bool + creationAllowed bool + autoCreation bool + autoUpdate bool + pkce bool + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "default", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + opts: nil, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + }, + }, + want: want{ + name: "oidc", + linkingAllowed: false, + creationAllowed: false, + autoCreation: false, + autoUpdate: false, + pkce: false, + }, + }, + { + name: "all true", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + opts: []ProviderOpts{ + WithLinkingAllowed(), + WithCreationAllowed(), + WithAutoCreation(), + WithAutoUpdate(), + WithRelyingPartyOption(rp.WithPKCE(nil)), + }, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + }, + }, + want: want{ + name: "oidc", + linkingAllowed: true, + creationAllowed: true, + autoCreation: true, + autoUpdate: true, + pkce: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock(tt.fields.issuer) + a := assert.New(t) + + provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.userMapper, tt.fields.opts...) + require.NoError(t, err) + + a.Equal(tt.want.name, provider.Name()) + a.Equal(tt.want.linkingAllowed, provider.IsLinkingAllowed()) + a.Equal(tt.want.creationAllowed, provider.IsCreationAllowed()) + a.Equal(tt.want.autoCreation, provider.IsAutoCreation()) + a.Equal(tt.want.autoUpdate, provider.IsAutoUpdate()) + }) + } +} diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go new file mode 100644 index 000000000..a70a189cd --- /dev/null +++ b/internal/idp/providers/oidc/session.go @@ -0,0 +1,99 @@ +package oidc + +import ( + "context" + "errors" + + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/idp" +) + +var ErrCodeMissing = errors.New("no auth code provided") + +var _ idp.Session = (*Session)(nil) + +// Session is the [idp.Session] implementation for the OIDC provider. +type Session struct { + Provider *Provider + AuthURL string + Code string + Tokens *oidc.Tokens +} + +// GetAuthURL implements the [idp.Session] interface. +func (s *Session) GetAuthURL() string { + return s.AuthURL +} + +// FetchUser implements the [idp.Session] interface. +// It will execute an OIDC code exchange if needed to retrieve the tokens, +// call the userinfo endpoint and map the received information into an [idp.User]. +func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { + if s.Tokens == nil { + if err = s.authorize(ctx); err != nil { + return nil, err + } + } + info, err := rp.Userinfo( + s.Tokens.AccessToken, + s.Tokens.TokenType, + s.Tokens.IDTokenClaims.GetSubject(), + s.Provider.RelyingParty, + ) + if err != nil { + return nil, err + } + u := s.Provider.userInfoMapper(info) + return u, nil +} + +func (s *Session) authorize(ctx context.Context) (err error) { + if s.Code == "" { + return ErrCodeMissing + } + s.Tokens, err = rp.CodeExchange(ctx, s.Code, s.Provider.RelyingParty) + return err +} + +func NewUser(info oidc.UserInfo) *User { + return &User{UserInfo: info} +} + +type User struct { + oidc.UserInfo +} + +func (u *User) GetID() string { + return u.GetSubject() +} + +func (u *User) GetFirstName() string { + return u.GetGivenName() +} + +func (u *User) GetLastName() string { + return u.GetFamilyName() +} + +func (u *User) GetDisplayName() string { + return u.GetName() +} + +func (u *User) GetPhone() string { + return u.GetPhoneNumber() +} + +func (u *User) IsPhoneVerified() bool { + return u.IsPhoneNumberVerified() +} + +func (u *User) GetPreferredLanguage() language.Tag { + return u.GetLocale() +} + +func (u *User) GetAvatarURL() string { + return u.GetPicture() +} diff --git a/internal/idp/providers/oidc/session_test.go b/internal/idp/providers/oidc/session_test.go new file mode 100644 index 000000000..e31dd1383 --- /dev/null +++ b/internal/idp/providers/oidc/session_test.go @@ -0,0 +1,392 @@ +package oidc + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + "gopkg.in/square/go-jose.v2" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/idp" +) + +func TestSession_FetchUser(t *testing.T) { + type fields struct { + name string + issuer string + clientID string + clientSecret string + redirectURI string + userMapper func(oidc.UserInfo) idp.User + httpMock func(issuer string) + authURL string + code string + tokens *oidc.Tokens + } + type want struct { + err error + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + gock.New(issuer). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: nil, + }, + want: want{ + err: ErrCodeMissing, + }, + }, + { + name: "userinfo error", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + gock.New(issuer). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + IDTokenClaims: oidc.NewIDTokenClaims( + "https://issuer.com", + "sub2", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + err: rp.ErrUserInfoSubNotMatching, + }, + }, + { + name: "successful fetch", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + UserinfoEndpoint: issuer + "/userinfo", + }) + gock.New(issuer). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: &oidc.Tokens{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + }, + IDTokenClaims: oidc.NewIDTokenClaims( + "https://issuer.com", + "sub", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Second), + "nonce", + "", + nil, + "clientID", + 0, + ), + }, + }, + want: want{ + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "nickname", + preferredUsername: "username", + email: "email", + isEmailVerified: true, + phone: "phone", + isPhoneVerified: true, + preferredLanguage: language.English, + avatarURL: "picture", + profile: "profile", + }, + }, + { + name: "successful fetch with token exchange", + fields: fields{ + name: "oidc", + issuer: "https://issuer.com", + clientID: "clientID", + clientSecret: "clientSecret", + redirectURI: "redirectURI", + userMapper: DefaultMapper, + httpMock: func(issuer string) { + gock.New(issuer). + Get(oidc.DiscoveryEndpoint). + Reply(200). + JSON(&oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + JwksURI: issuer + "/keys", + UserinfoEndpoint: issuer + "/userinfo", + }) + gock.New(issuer). + Post("/token"). + BodyString("client_id=clientID&client_secret=clientSecret&code=code&grant_type=authorization_code&redirect_uri=redirectURI"). + Reply(200). + JSON(tokenResponse(t, issuer)) + gock.New(issuer). + Get("/keys"). + Reply(200). + JSON(keys(t)) + gock.New(issuer). + Get("/userinfo"). + Reply(200). + JSON(userinfo()) + }, + authURL: "https://issuer.com/authorize?client_id=clientID&redirect_uri=redirectURI&response_type=code&scope=openid&state=testState", + tokens: nil, + code: "code", + }, + want: want{ + id: "sub", + firstName: "firstname", + lastName: "lastname", + displayName: "firstname lastname", + nickName: "nickname", + preferredUsername: "username", + email: "email", + isEmailVerified: true, + phone: "phone", + isPhoneVerified: true, + preferredLanguage: language.English, + avatarURL: "picture", + profile: "profile", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock(tt.fields.issuer) + a := assert.New(t) + + provider, err := New(tt.fields.name, tt.fields.issuer, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.userMapper) + require.NoError(t, err) + + session := &Session{ + Provider: provider, + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !errors.Is(err, tt.want.err) { + a.Fail("invalid error", "expected %v, got %v", tt.want.err, err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(tt.want.email, user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(tt.want.phone, user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} + +func userinfo() oidc.UserInfoSetter { + info := oidc.NewUserInfo() + info.SetSubject("sub") + info.SetGivenName("firstname") + info.SetFamilyName("lastname") + info.SetName("firstname lastname") + info.SetNickname("nickname") + info.SetPreferredUsername("username") + info.SetEmail("email", true) + info.SetPhone("phone", true) + info.SetLocale(language.English) + info.SetPicture("picture") + info.SetProfile("profile") + return info +} + +func tokenResponse(t *testing.T, issuer string) *oidc.AccessTokenResponse { + claims := oidc.NewIDTokenClaims( + issuer, + "sub", + []string{"clientID"}, + time.Now().Add(1*time.Hour), + time.Now().Add(-1*time.Minute), + "", + "", + nil, + "clientID", + 0, + ) + privateKey, err := crypto.BytesToPrivateKey([]byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAs38btwb3c7r0tMaQpGvBmY+mPwMU/LpfuPoC0k2t4RsKp0fv +40SMl50CRrHgk395wch8PMPYbl3+8TtYAJuyrFALIj3Ff1UcKIk0hOH5DDsfh7/q +2wFuncTmS6bifYo8CfSq2vDGnM7nZnEvxY/MfSydZdcmIqlkUpfQmtzExw9+tSe5 +Dxq6gn5JtlGgLgZGt69r5iMMrTEGhhVAXzNuMZbmlCoBru+rC8ITlTX/0V1ZcsSb +L8tYWhthyu9x6yjo1bH85wiVI4gs0MhU8f2a+kjL/KGZbR14Ua2eo6tonBZLC5DH +WM2TkYXgRCDPufjcgmzN0Lm91E4P8KvBcvly6QIDAQABAoIBAQCPj1nbSPcg2KZe +73FAD+8HopyUSSK//1AP4eXfzcEECVy77g0u9+R6XlkzsZCsZ4g6NN8ounqfyw3c +YlpAIkcFCf/dowoSjT+4LASVQyatYZwWNqjgAIU4KgMG/rKnNahPTiBYe7peMB1j +EaPjnt8uPkCk8y7NCi3y4Pk24tt/WM5KbJK2NQhUi1csGnleDfE+0blV0l/e6C68 +W5cbnbWAroMqae/Yon3XVZiXX0m+l2f6ZzIgKaD18J+eEM8FjJC+jQKiRe1i9v3K +nQrLwh/gn8J10FcbKn3xqslKVidzASIrNIzHT9j/Z5T9NXuAKa7IV2x+Dtdus+wq +iBsUunwBAoGBANpYew+8i9vDwK4/SefduDTuzJ0H9lWTjtbiWQ+KYZoeJ7q3/qns +jsmi+mjxkXxXg1RrGbNbjtbl3RXXIrUeeBB0lglRJUjc3VK7VvNoyXIWsiqhCspH +IJ9Yuknv4mXB01m/glbSCS/xu4RTgf5aOG4jUiRb9+dCIpvDxI9gbXEVAoGBANJz +hIJkplIJ+biTi3G1Oz17qkUkInNXzAEzKD9Atoz5AIAiR1ivOMLOlbucfjevw/Nw +TnpkMs9xqCefKupTlsriXtZI88m7ZKzAmolYsPolOy/Jhi31h9JFVTEfKGqVS+dk +A4ndhgdW9RUeNJPY2YVCARXQrWpueweQDA1cNaeFAoGAPJsYtXqBW6PPRM5+ZiSt +78tk8iV2o7RMjqrPS7f+dXfvUS2nO2VVEPTzCtQarOfhpToBLT65vD6bimdn09w8 +OV0TFEz4y2u65y7m6LNqTwertpdy1ki97l0DgGhccCBH2P6GYDD2qd8wTH+dcot6 +ZF/begopGoDJ+HBzi9SZLC0CgYBZzPslHMevyBvr++GLwrallKhiWnns1/DwLiEl +ZHrBCtuA0Z+6IwLIdZiE9tEQ+ApYTXrfVPQteqUzSwLn/IUiy5eGPpjwYushoAoR +Q2w5QTvRN1/vKo8rVXR1woLfgBdkhFPSN1mitiNcQIhU8jpXV4PZCDOHb99FqdzK +sqcedQKBgQCOmgbqxGsnT2WQhoOdzln+NOo6Tx+FveLLqat2KzpY59W4noeI2Awn +HfIQgWUAW9dsjVVOXMP1jhq8U9hmH/PFWA11V/iCdk1NTxZEw87VAOeWuajpdDHG ++iex349j8h2BcQ4Zd0FWu07gGFnS/yuDJPn6jBhRusdieEcxLRjTKg== +-----END RSA PRIVATE KEY----- +`)) + if err != nil { + t.Fatal(err) + } + signer, err := jose.NewSigner(jose.SigningKey{Key: privateKey, Algorithm: "RS256"}, &jose.SignerOptions{}) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + jws, err := signer.Sign(data) + if err != nil { + t.Fatal(err) + } + idToken, err := jws.CompactSerialize() + if err != nil { + t.Fatal(err) + } + return &oidc.AccessTokenResponse{ + AccessToken: "accessToken", + TokenType: oidc.BearerToken, + RefreshToken: "", + ExpiresIn: 3600, + IDToken: idToken, + State: "testState", + } +} + +func keys(t *testing.T) *jose.JSONWebKeySet { + privateKey, err := crypto.BytesToPublicKey([]byte(`-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs38btwb3c7r0tMaQpGvB +mY+mPwMU/LpfuPoC0k2t4RsKp0fv40SMl50CRrHgk395wch8PMPYbl3+8TtYAJuy +rFALIj3Ff1UcKIk0hOH5DDsfh7/q2wFuncTmS6bifYo8CfSq2vDGnM7nZnEvxY/M +fSydZdcmIqlkUpfQmtzExw9+tSe5Dxq6gn5JtlGgLgZGt69r5iMMrTEGhhVAXzNu +MZbmlCoBru+rC8ITlTX/0V1ZcsSbL8tYWhthyu9x6yjo1bH85wiVI4gs0MhU8f2a ++kjL/KGZbR14Ua2eo6tonBZLC5DHWM2TkYXgRCDPufjcgmzN0Lm91E4P8KvBcvly +6QIDAQAB +-----END PUBLIC KEY----- +`)) + if err != nil { + t.Fatal(err) + } + return &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: privateKey, Algorithm: "RS256", Use: oidc.KeyUseSignature}}} +} diff --git a/internal/idp/session.go b/internal/idp/session.go new file mode 100644 index 000000000..8cc2dbfef --- /dev/null +++ b/internal/idp/session.go @@ -0,0 +1,11 @@ +package idp + +import ( + "context" +) + +// Session is the minimal implementation for a session of a 3rd party authentication [Provider] +type Session interface { + GetAuthURL() string + FetchUser(ctx context.Context) (User, error) +}