Skip to content

Add basic OIDC client #15267

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions components/iam/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug client",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "/workspace/gitpod/components/iam/main.go",
"args": ["run"]
}
]
}
13 changes: 9 additions & 4 deletions components/iam/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ module github.com/gitpod-io/gitpod/iam
go 1.19

require (
github.com/coreos/go-oidc/v3 v3.4.0
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
github.com/go-chi/chi/v5 v5.0.8
github.com/sirupsen/logrus v1.8.1
github.com/spf13/cobra v1.4.0
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b
golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094
)

require (
Expand All @@ -31,16 +34,18 @@ require (
github.com/slok/go-http-metrics v0.10.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.7.0 // indirect
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 // indirect
google.golang.org/grpc v1.49.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

Expand Down
270 changes: 264 additions & 6 deletions components/iam/go.sum

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions components/iam/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ type ServiceConfig struct {
Server *baseserver.Configuration `json:"server"`

DatabaseConfigPath string `json:"databaseConfigPath"`

OIDCClientsConfigFile string `json:"oidcClientsConfigFile,omitempty"`
}
39 changes: 39 additions & 0 deletions components/iam/pkg/oidc/demo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package oidc

import (
"encoding/json"
"fmt"
"os"
)

// The demo config is used to setup a OIDC client with Google.
//
// This is a temporary way to boot the OIDC client service with a single
// configuration, e.g. mounted as secret into a preview environment.
//
// ‼️ This demo config will be removed once the configuration is read from DB.
type DemoConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURL string `json:"redirectURL"`
}

func ReadDemoConfigFromFile(path string) (*DemoConfig, error) {
bytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read test config: %w", err)
}

var config DemoConfig
err = json.Unmarshal(bytes, &config)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}

return &config, nil
}
67 changes: 67 additions & 0 deletions components/iam/pkg/oidc/oauth2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package oidc

import (
"context"
"net/http"

"github.com/gitpod-io/gitpod/common-go/log"
"golang.org/x/oauth2"
)

type OAuth2Result struct {
OAuth2Token *oauth2.Token
Redirect string
}

type keyOAuth2Result struct{}

func OAuth2Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
log.Trace("at oauth2 middleware")
ctx := r.Context()
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
if !ok {
http.Error(rw, "config not found", http.StatusInternalServerError)
return
}

// http-only cookie written during flow start request
stateCookie, err := r.Cookie(stateCookieName)
if err != nil {
http.Error(rw, "state cookie not found", http.StatusBadRequest)
return
}
// the starte param passed back from IdP
stateParam := r.URL.Query().Get("state")
if stateParam == "" {
http.Error(rw, "state param not found", http.StatusBadRequest)
return
}
// on mismatch, obviously there is a client side error
if stateParam != stateCookie.Value {
http.Error(rw, "state did not match", http.StatusBadRequest)
return
}

code := r.URL.Query().Get("code")
if code == "" {
http.Error(rw, "code param not found", http.StatusBadRequest)
return
}

oauth2Token, err := config.OAuth2Config.Exchange(ctx, code)
if err != nil {
http.Error(rw, "failed to exchange token: "+err.Error(), http.StatusInternalServerError)
return
}

ctx = context.WithValue(ctx, keyOAuth2Result{}, OAuth2Result{
OAuth2Token: oauth2Token,
})
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
Comment on lines +22 to +67
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd strongly recommend not to over-use middlewares which extract concrete data. Here's why:

  • Golang's context is not the same as the express.Request context. The context in go is used for downstream calls irrespective of whether it's an http handler or not. Using the context to pass request specific arguments results in carry extra baggage.
  • The middleware forces you write/and parse the data out of the context. Because context is not type safe, you incur extra overhead in extracting the data, but also risk cases where the context does not parse correctly.
  • It forces you to handle the existence of the data twice, once on the middleware here, and another when you pull the value from the context
  • Middlewares tend to make sense, once you have general purpose logic you want to re-use across handlers. Here, we only have a single handler. This causes a couple of problems:
    • Testing the handler becomes harder, you always have to test it together with the middleware
    • Readability becomes harder, the reader now has to step a layer above and figure out what's populating the context
  • It makes handling errors from context values much harder. The context of why the value is not present is missing. Yes, the middleware should return an error but we need to be defensive and also guard this in the handler.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

middlewares which extract concrete data

The point in using middleware here is to be made clear: this is to separate protocol implementations. First there is the OAuth2 layer, then the OIDC AuthN validation. It seems to be an acceptable cost if you consider that the OAuth2 layer can be reused for Git Auth later with a different handler.

The point on configuration and lax contract, indeed that's key-value onion might be fragile and requires complete coverage and precondition checks. I'll make improvements to get the coverage needed.

Golang's context is not the same as the express.Request context.

Do you have examples, or docs for that? I read different and saw different examples, see https://github.com/go-chi/chi and comparable, they advertising it as such. Could you name concrete problems with that?

I see the usage of middleware is justified here, especially for the separation of two protocols, where one is an extension to the other. The outcome of the first part can/must be checked and verified. To test the first part separately, one would need to slice it anyways. Same applies for the testability of the second part.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, checking for a solid connection should be a must here, thus it's worth revisiting tests. 👍🏻

143 changes: 138 additions & 5 deletions components/iam/pkg/oidc/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,149 @@
package oidc

import (
"github.com/go-chi/chi/v5"
"context"
"encoding/json"
"net/http"
"time"

"github.com/gitpod-io/gitpod/common-go/log"
"golang.org/x/oauth2"

"github.com/go-chi/chi/v5"
)

func Router() *chi.Mux {
router := chi.NewMux()
func Router(oidcService *OIDCService) *chi.Mux {
router := chi.NewRouter()

router.HandleFunc("/start", func(writer http.ResponseWriter, request *http.Request) {
writer.Write([]byte(`hello`))
router.Route("/start", func(r chi.Router) {
r.Use(oidcService.clientConfigMiddleware())
r.Get("/", oidcService.getStartHandler())
})
router.Route("/callback", func(r chi.Router) {
r.Use(oidcService.clientConfigMiddleware())
r.Use(OAuth2Middleware)
r.Get("/", oidcService.getCallbackHandler())
})

return router
}

type keyOIDCClientConfig struct{}

const (
stateCookieName = "state"
nonceCookieName = "nonce"
)

func (oidcService *OIDCService) getStartHandler() http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
log.Trace("at start handler")

ctx := r.Context()
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
if !ok {
http.Error(rw, "config not found", http.StatusInternalServerError)
return
}

startParams, err := oidcService.GetStartParams(&config)
if err != nil {
http.Error(rw, "failed to start auth flow", http.StatusInternalServerError)
return
}

http.SetCookie(rw, newCallbackCookie(r, nonceCookieName, startParams.Nonce))
http.SetCookie(rw, newCallbackCookie(r, stateCookieName, startParams.State))

http.Redirect(rw, r, startParams.AuthCodeURL, http.StatusTemporaryRedirect)
}
}

func newCallbackCookie(r *http.Request, name string, value string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
MaxAge: int(10 * time.Minute.Seconds()),
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
}
}

// The config middleware is responsible to retrieve the client config suitable for request
func (oidcService *OIDCService) clientConfigMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
log.Trace("at config middleware")

config, err := oidcService.GetClientConfigFromRequest(r)
if err != nil {
log.Warn("client config not found: " + err.Error())
http.Error(rw, "config not found", http.StatusNotFound)
return
}

ctx := context.WithValue(r.Context(), keyOIDCClientConfig{}, config)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}

// The OIDC callback handler depends on the state produced in the OAuth2 middleware
func (oidcService *OIDCService) getCallbackHandler() http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
log.Trace("at callback handler")

ctx := r.Context()
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
if !ok {
http.Error(rw, "config not found", http.StatusInternalServerError)
return
}
oauth2Result, ok := ctx.Value(keyOAuth2Result{}).(OAuth2Result)
if !ok {
http.Error(rw, "OIDC precondition failure", http.StatusInternalServerError)
return
}

// nonce = number used once
nonceCookie, err := r.Cookie(nonceCookieName)
if err != nil {
http.Error(rw, "nonce not found", http.StatusBadRequest)
return
}

result, err := oidcService.Authenticate(ctx, &oauth2Result,
config.Issuer, nonceCookie.Value)
if err != nil {
http.Error(rw, "OIDC authentication failed", http.StatusInternalServerError)
return
}

// TODO(at) given the result of OIDC authN, let's proceed with the redirect

// For testing purposes, let's print out redacted results
oauth2Result.OAuth2Token.AccessToken = "*** REDACTED ***"

var claims map[string]interface{}
err = result.IDToken.Claims(&claims)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a user error, no? If the user supplies the wrong claim, we shouldn't return a Server error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claims is what Gitpod (as the OIDC client) can obtain from the end user.

Not being able to parse the id_token can have these causes:

  • unparseable gibberish received from IdP (e.g. their server/lb is misconfigured)
  • otherwise parsing failures => status code 500 it's Gitpod's fault

Also, while the error modes are still relevant, following PRs will remove this section.

return
}
resp := struct {
OAuth2Token *oauth2.Token
Claims map[string]interface{}
}{oauth2Result.OAuth2Token, claims}

data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
_, err = rw.Write(data)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
}
}
Loading