Skip to content

Commit 46c2a8a

Browse files
AlexTugarevroboquat
authored andcommitted
[iam] add basic OIDC client
1 parent 0605514 commit 46c2a8a

File tree

10 files changed

+949
-23
lines changed

10 files changed

+949
-23
lines changed

components/iam/.vscode/launch.json

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Debug client",
9+
"type": "go",
10+
"request": "launch",
11+
"mode": "debug",
12+
"program": "/workspace/gitpod/components/iam/main.go",
13+
"args": ["run"]
14+
}
15+
]
16+
}

components/iam/go.mod

+9-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ module github.com/gitpod-io/gitpod/iam
33
go 1.19
44

55
require (
6+
github.com/coreos/go-oidc/v3 v3.4.0
67
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
78
github.com/go-chi/chi/v5 v5.0.8
89
github.com/sirupsen/logrus v1.8.1
910
github.com/spf13/cobra v1.4.0
11+
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b
12+
golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094
1013
)
1114

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

components/iam/go.sum

+264-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/iam/pkg/config/config.go

+2
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ type ServiceConfig struct {
1313
Server *baseserver.Configuration `json:"server"`
1414

1515
DatabaseConfigPath string `json:"databaseConfigPath"`
16+
17+
OIDCClientsConfigFile string `json:"oidcClientsConfigFile,omitempty"`
1618
}

components/iam/pkg/oidc/demo.go

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package oidc
6+
7+
import (
8+
"encoding/json"
9+
"fmt"
10+
"os"
11+
)
12+
13+
// The demo config is used to setup a OIDC client with Google.
14+
//
15+
// This is a temporary way to boot the OIDC client service with a single
16+
// configuration, e.g. mounted as secret into a preview environment.
17+
//
18+
// ‼️ This demo config will be removed once the configuration is read from DB.
19+
type DemoConfig struct {
20+
Issuer string `json:"issuer"`
21+
ClientID string `json:"clientID"`
22+
ClientSecret string `json:"clientSecret"`
23+
RedirectURL string `json:"redirectURL"`
24+
}
25+
26+
func ReadDemoConfigFromFile(path string) (*DemoConfig, error) {
27+
bytes, err := os.ReadFile(path)
28+
if err != nil {
29+
return nil, fmt.Errorf("failed to read test config: %w", err)
30+
}
31+
32+
var config DemoConfig
33+
err = json.Unmarshal(bytes, &config)
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
36+
}
37+
38+
return &config, nil
39+
}

components/iam/pkg/oidc/oauth2.go

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package oidc
6+
7+
import (
8+
"context"
9+
"net/http"
10+
11+
"github.com/gitpod-io/gitpod/common-go/log"
12+
"golang.org/x/oauth2"
13+
)
14+
15+
type OAuth2Result struct {
16+
OAuth2Token *oauth2.Token
17+
Redirect string
18+
}
19+
20+
type keyOAuth2Result struct{}
21+
22+
func OAuth2Middleware(next http.Handler) http.Handler {
23+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
24+
log.Trace("at oauth2 middleware")
25+
ctx := r.Context()
26+
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
27+
if !ok {
28+
http.Error(rw, "config not found", http.StatusInternalServerError)
29+
return
30+
}
31+
32+
// http-only cookie written during flow start request
33+
stateCookie, err := r.Cookie(stateCookieName)
34+
if err != nil {
35+
http.Error(rw, "state cookie not found", http.StatusBadRequest)
36+
return
37+
}
38+
// the starte param passed back from IdP
39+
stateParam := r.URL.Query().Get("state")
40+
if stateParam == "" {
41+
http.Error(rw, "state param not found", http.StatusBadRequest)
42+
return
43+
}
44+
// on mismatch, obviously there is a client side error
45+
if stateParam != stateCookie.Value {
46+
http.Error(rw, "state did not match", http.StatusBadRequest)
47+
return
48+
}
49+
50+
code := r.URL.Query().Get("code")
51+
if code == "" {
52+
http.Error(rw, "code param not found", http.StatusBadRequest)
53+
return
54+
}
55+
56+
oauth2Token, err := config.OAuth2Config.Exchange(ctx, code)
57+
if err != nil {
58+
http.Error(rw, "failed to exchange token: "+err.Error(), http.StatusInternalServerError)
59+
return
60+
}
61+
62+
ctx = context.WithValue(ctx, keyOAuth2Result{}, OAuth2Result{
63+
OAuth2Token: oauth2Token,
64+
})
65+
next.ServeHTTP(rw, r.WithContext(ctx))
66+
})
67+
}

components/iam/pkg/oidc/router.go

+138-5
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,149 @@
55
package oidc
66

77
import (
8-
"github.com/go-chi/chi/v5"
8+
"context"
9+
"encoding/json"
910
"net/http"
11+
"time"
12+
13+
"github.com/gitpod-io/gitpod/common-go/log"
14+
"golang.org/x/oauth2"
15+
16+
"github.com/go-chi/chi/v5"
1017
)
1118

12-
func Router() *chi.Mux {
13-
router := chi.NewMux()
19+
func Router(oidcService *OIDCService) *chi.Mux {
20+
router := chi.NewRouter()
1421

15-
router.HandleFunc("/start", func(writer http.ResponseWriter, request *http.Request) {
16-
writer.Write([]byte(`hello`))
22+
router.Route("/start", func(r chi.Router) {
23+
r.Use(oidcService.clientConfigMiddleware())
24+
r.Get("/", oidcService.getStartHandler())
25+
})
26+
router.Route("/callback", func(r chi.Router) {
27+
r.Use(oidcService.clientConfigMiddleware())
28+
r.Use(OAuth2Middleware)
29+
r.Get("/", oidcService.getCallbackHandler())
1730
})
1831

1932
return router
2033
}
34+
35+
type keyOIDCClientConfig struct{}
36+
37+
const (
38+
stateCookieName = "state"
39+
nonceCookieName = "nonce"
40+
)
41+
42+
func (oidcService *OIDCService) getStartHandler() http.HandlerFunc {
43+
return func(rw http.ResponseWriter, r *http.Request) {
44+
log.Trace("at start handler")
45+
46+
ctx := r.Context()
47+
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
48+
if !ok {
49+
http.Error(rw, "config not found", http.StatusInternalServerError)
50+
return
51+
}
52+
53+
startParams, err := oidcService.GetStartParams(&config)
54+
if err != nil {
55+
http.Error(rw, "failed to start auth flow", http.StatusInternalServerError)
56+
return
57+
}
58+
59+
http.SetCookie(rw, newCallbackCookie(r, nonceCookieName, startParams.Nonce))
60+
http.SetCookie(rw, newCallbackCookie(r, stateCookieName, startParams.State))
61+
62+
http.Redirect(rw, r, startParams.AuthCodeURL, http.StatusTemporaryRedirect)
63+
}
64+
}
65+
66+
func newCallbackCookie(r *http.Request, name string, value string) *http.Cookie {
67+
return &http.Cookie{
68+
Name: name,
69+
Value: value,
70+
MaxAge: int(10 * time.Minute.Seconds()),
71+
Secure: r.TLS != nil,
72+
SameSite: http.SameSiteLaxMode,
73+
HttpOnly: true,
74+
}
75+
}
76+
77+
// The config middleware is responsible to retrieve the client config suitable for request
78+
func (oidcService *OIDCService) clientConfigMiddleware() func(http.Handler) http.Handler {
79+
return func(next http.Handler) http.Handler {
80+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
81+
log.Trace("at config middleware")
82+
83+
config, err := oidcService.GetClientConfigFromRequest(r)
84+
if err != nil {
85+
log.Warn("client config not found: " + err.Error())
86+
http.Error(rw, "config not found", http.StatusNotFound)
87+
return
88+
}
89+
90+
ctx := context.WithValue(r.Context(), keyOIDCClientConfig{}, config)
91+
next.ServeHTTP(rw, r.WithContext(ctx))
92+
})
93+
}
94+
}
95+
96+
// The OIDC callback handler depends on the state produced in the OAuth2 middleware
97+
func (oidcService *OIDCService) getCallbackHandler() http.HandlerFunc {
98+
return func(rw http.ResponseWriter, r *http.Request) {
99+
log.Trace("at callback handler")
100+
101+
ctx := r.Context()
102+
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
103+
if !ok {
104+
http.Error(rw, "config not found", http.StatusInternalServerError)
105+
return
106+
}
107+
oauth2Result, ok := ctx.Value(keyOAuth2Result{}).(OAuth2Result)
108+
if !ok {
109+
http.Error(rw, "OIDC precondition failure", http.StatusInternalServerError)
110+
return
111+
}
112+
113+
// nonce = number used once
114+
nonceCookie, err := r.Cookie(nonceCookieName)
115+
if err != nil {
116+
http.Error(rw, "nonce not found", http.StatusBadRequest)
117+
return
118+
}
119+
120+
result, err := oidcService.Authenticate(ctx, &oauth2Result,
121+
config.Issuer, nonceCookie.Value)
122+
if err != nil {
123+
http.Error(rw, "OIDC authentication failed", http.StatusInternalServerError)
124+
return
125+
}
126+
127+
// TODO(at) given the result of OIDC authN, let's proceed with the redirect
128+
129+
// For testing purposes, let's print out redacted results
130+
oauth2Result.OAuth2Token.AccessToken = "*** REDACTED ***"
131+
132+
var claims map[string]interface{}
133+
err = result.IDToken.Claims(&claims)
134+
if err != nil {
135+
http.Error(rw, err.Error(), http.StatusInternalServerError)
136+
return
137+
}
138+
resp := struct {
139+
OAuth2Token *oauth2.Token
140+
Claims map[string]interface{}
141+
}{oauth2Result.OAuth2Token, claims}
142+
143+
data, err := json.MarshalIndent(resp, "", " ")
144+
if err != nil {
145+
http.Error(rw, err.Error(), http.StatusInternalServerError)
146+
return
147+
}
148+
_, err = rw.Write(data)
149+
if err != nil {
150+
http.Error(rw, err.Error(), http.StatusInternalServerError)
151+
}
152+
}
153+
}

0 commit comments

Comments
 (0)