From b12eb55fdb603290e3bc62880f6e9dff538571de Mon Sep 17 00:00:00 2001 From: mo khan Date: Mon, 14 Apr 2025 15:53:32 -0600 Subject: feat: connect the sessions controller to oidc provider --- app/controllers/sessions/controller.go | 13 +++---- app/controllers/sessions/controller_test.go | 56 +++++++++++++++++++---------- go.mod | 4 ++- go.sum | 20 ++++++----- pkg/oidc/id_token.go | 39 ++++++++++++++++++++ pkg/oidc/metadata.go | 22 ++++++++++++ pkg/oidc/oidc.go | 31 ++++++++++++++++ pkg/oidc/oidc_test.go | 49 +++++++++++++++++++++++++ 8 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 pkg/oidc/id_token.go create mode 100644 pkg/oidc/metadata.go create mode 100644 pkg/oidc/oidc.go create mode 100644 pkg/oidc/oidc_test.go diff --git a/app/controllers/sessions/controller.go b/app/controllers/sessions/controller.go index c75e204..1a709de 100644 --- a/app/controllers/sessions/controller.go +++ b/app/controllers/sessions/controller.go @@ -3,19 +3,16 @@ package sessions import ( "net/http" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/oidc" "golang.org/x/oauth2" ) type Controller struct { - audience string - cfg *oauth2.Config + cfg *oidc.OpenID } -func New(cfg *oauth2.Config, audience string) *Controller { - return &Controller{ - audience: audience, - cfg: cfg, - } +func New(cfg *oidc.OpenID) *Controller { + return &Controller{cfg: cfg} } func (c *Controller) MountTo(mux *http.ServeMux) { @@ -25,6 +22,6 @@ func (c *Controller) MountTo(mux *http.ServeMux) { func (c *Controller) New(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusFound) - url := c.cfg.AuthCodeURL("csrf-token", oauth2.SetAuthURLParam("audience", c.audience)) + url := c.cfg.Config.AuthCodeURL("todo-csrf-token", oauth2.SetAuthURLParam("audience", "todo")) http.Redirect(w, r, url, http.StatusFound) } diff --git a/app/controllers/sessions/controller_test.go b/app/controllers/sessions/controller_test.go index 51536a4..5018e0c 100644 --- a/app/controllers/sessions/controller_test.go +++ b/app/controllers/sessions/controller_test.go @@ -2,31 +2,50 @@ package sessions import ( "net/http" + "net/http/httptest" "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/xlgmokha/x/pkg/serde" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/oidc" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/test" - "golang.org/x/oauth2" ) func TestSessions(t *testing.T) { - audience := "https://sparklelab.example.com" - cfg := &oauth2.Config{ - ClientID: "client_id", - ClientSecret: "client_secret", - RedirectURL: audience + "/callback", - Scopes: []string{"openid"}, - Endpoint: oauth2.Endpoint{ - AuthStyle: oauth2.AuthStyleAutoDetect, - AuthURL: "https://gitlab.com/oauth/authorize", - DeviceAuthURL: "https://gitlab.com/oauth/authorize", - TokenURL: "https://gitlab.com/oauth/token", - }, + srv := httptest.NewServer(nil) + srv.Config = &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/.well-known/openid-configuration", r.URL.Path) + require.NoError(t, serde.ToJSON(w, &oidc.Metadata{ + AuthorizationEndpoint: srv.URL + "/oauth/authorize", + ClaimsSupported: []string{"aud"}, + CodeChallengeMethodsSupported: []string{"plain"}, + DeviceAuthorizationEndpoint: srv.URL + "/device/authorize", + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + Issuer: srv.URL, + JWKSURI: srv.URL + "/jwks", + MFAChallengeEndpoint: srv.URL + "/mfa", + RegistrationEndpoint: srv.URL + "/users/new", + RequestURIParameterSupported: false, + ResponseModesSupported: []string{"query"}, + ResponseTypeSupported: []string{"code"}, + RevocationEndpoint: srv.URL + "/revoke", + ScopesSupported: []string{"oidc"}, + SubjectTypesSupported: []string{"public"}, + TokenEndpoint: srv.URL + "/token", + TokenEndpointAuthMethodsSupported: []string{"client_secret_post"}, + UserInfoEndpoint: srv.URL + "/users/me", + })) + }), } + defer srv.Close() - controller := New(cfg, audience) + cfg, err := oidc.New(t.Context(), srv.URL, "client_id", "client_secret", "callback_url") + require.NoError(t, err) + controller := New(cfg) mux := http.NewServeMux() controller.MountTo(mux) @@ -41,14 +60,13 @@ func TestSessions(t *testing.T) { require.NotEmpty(t, w.Header().Get("Location")) redirectURL, err := url.Parse(w.Header().Get("Location")) require.NoError(t, err) - assert.Equal(t, "https", redirectURL.Scheme) - assert.Equal(t, "gitlab.com", redirectURL.Host) + assert.Equal(t, strings.TrimPrefix(srv.URL, "http://"), redirectURL.Host) assert.Equal(t, "/oauth/authorize", redirectURL.Path) assert.NotEmpty(t, redirectURL.Query().Get("state")) assert.Equal(t, "client_id", redirectURL.Query().Get("client_id")) - assert.Equal(t, "openid", redirectURL.Query().Get("scope")) - assert.Equal(t, audience, redirectURL.Query().Get("audience")) - assert.Equal(t, cfg.RedirectURL, redirectURL.Query().Get("redirect_uri")) + assert.Equal(t, "openid profile email", redirectURL.Query().Get("scope")) + assert.Equal(t, "todo", redirectURL.Query().Get("audience")) + assert.Equal(t, cfg.Config.RedirectURL, redirectURL.Query().Get("redirect_uri")) assert.Equal(t, "code", redirectURL.Query().Get("response_type")) }) }) diff --git a/go.mod b/go.mod index 5773311..2b129af 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkl go 1.24.0 require ( + github.com/coreos/go-oidc/v3 v3.14.1 github.com/oklog/ulid v1.3.1 github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.36.0 @@ -26,6 +27,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect @@ -62,7 +64,7 @@ require ( go.opentelemetry.io/otel/sdk v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.31.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index 74825c2..7e527e2 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= @@ -31,6 +33,8 @@ github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -134,16 +138,16 @@ go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -160,12 +164,12 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/oidc/id_token.go b/pkg/oidc/id_token.go new file mode 100644 index 0000000..5fc1e63 --- /dev/null +++ b/pkg/oidc/id_token.go @@ -0,0 +1,39 @@ +package oidc + +import ( + "bytes" + "encoding/base64" + "errors" + "strings" + "time" + + "github.com/xlgmokha/x/pkg/serde" +) + +type IDToken struct { + Audience string `json:"aud"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + ExpiredAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Issuer string `json:"iss"` + Name string `json:"name"` + Nickname string `json:"nickname"` + Picture string `json:"picture"` + Subject string `json:"sub"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewIDToken(raw string) (*IDToken, error) { + sections := strings.SplitN(raw, ".", 3) + if len(sections) != 3 { + return nil, errors.New("Invalid token") + } + b, err := base64.RawURLEncoding.DecodeString(sections[1]) + if err != nil { + return nil, err + } + + token, err := serde.FromJSON[*IDToken](bytes.NewReader(b)) + return token, err +} diff --git a/pkg/oidc/metadata.go b/pkg/oidc/metadata.go new file mode 100644 index 0000000..8678f3b --- /dev/null +++ b/pkg/oidc/metadata.go @@ -0,0 +1,22 @@ +package oidc + +type Metadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + MFAChallengeEndpoint string `json:"mfa_challenge_endpoint"` + JWKSURI string `json:"jwks_uri"` + RegistrationEndpoint string `json:"registration_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypeSupported []string `json:"response_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ResponseModesSupported []string `json:"response_modes_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + ClaimsSupported []string `json:"claims_supported"` + RequestURIParameterSupported bool `json:"request_uri_parameter_supported"` +} diff --git a/pkg/oidc/oidc.go b/pkg/oidc/oidc.go new file mode 100644 index 0000000..0526142 --- /dev/null +++ b/pkg/oidc/oidc.go @@ -0,0 +1,31 @@ +package oidc + +import ( + "context" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type OpenID struct { + Provider *oidc.Provider + Config *oauth2.Config +} + +func New(ctx context.Context, issuer string, clientID, clientSecret, callbackURL string) (*OpenID, error) { + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, err + } + + return &OpenID{ + Provider: provider, + Config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: callbackURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + }, nil +} diff --git a/pkg/oidc/oidc_test.go b/pkg/oidc/oidc_test.go new file mode 100644 index 0000000..ef6a4f6 --- /dev/null +++ b/pkg/oidc/oidc_test.go @@ -0,0 +1,49 @@ +package oidc + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xlgmokha/x/pkg/serde" +) + +func TestOpenID(t *testing.T) { + t.Run("GET /.well-known/openid-configuration", func(t *testing.T) { + srv := httptest.NewServer(nil) + srv.Config = &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/.well-known/openid-configuration", r.URL.Path) + require.NoError(t, serde.ToJSON(w, &Metadata{ + AuthorizationEndpoint: srv.URL + "/authorize", + ClaimsSupported: []string{"aud"}, + CodeChallengeMethodsSupported: []string{"plain"}, + DeviceAuthorizationEndpoint: srv.URL + "/device/authorize", + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + Issuer: srv.URL, + JWKSURI: srv.URL + "/jwks", + MFAChallengeEndpoint: srv.URL + "/mfa", + RegistrationEndpoint: srv.URL + "/users/new", + RequestURIParameterSupported: false, + ResponseModesSupported: []string{"query"}, + ResponseTypeSupported: []string{"code"}, + RevocationEndpoint: srv.URL + "/revoke", + ScopesSupported: []string{"oidc"}, + SubjectTypesSupported: []string{"public"}, + TokenEndpoint: srv.URL + "/token", + TokenEndpointAuthMethodsSupported: []string{"client_secret_post"}, + UserInfoEndpoint: srv.URL + "/users/me", + })) + }), + } + defer srv.Close() + + openID, err := New(context.Background(), srv.URL, "client_id", "client_secret", "https://example.com/oauth/callback") + require.NoError(t, err) + + assert.Equal(t, srv.URL+"/authorize", openID.Provider.Endpoint().AuthURL) + }) +} -- cgit v1.2.3