Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/local-app/pkg/auth/auth.go
2500 views
1
// Copyright (c) 2021 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 auth
6
7
import (
8
"context"
9
"crypto/sha256"
10
"encoding/hex"
11
"encoding/json"
12
"errors"
13
"fmt"
14
"io"
15
"log/slog"
16
"net"
17
"net/http"
18
"net/url"
19
"strings"
20
"time"
21
22
jwt "github.com/dgrijalva/jwt-go"
23
gitpod "github.com/gitpod-io/gitpod/gitpod-protocol"
24
"github.com/gitpod-io/local-app/pkg/prettyprint"
25
"github.com/skratchdot/open-golang/open"
26
keyring "github.com/zalando/go-keyring"
27
"golang.org/x/oauth2"
28
"golang.org/x/xerrors"
29
)
30
31
const keychainServiceName = "gitpod-io"
32
33
var authScopesLocalCompanion = []string{
34
"function:getGitpodTokenScopes",
35
"function:getWorkspace",
36
"function:getWorkspaces",
37
"function:listenForWorkspaceInstanceUpdates",
38
"resource:default",
39
}
40
41
func fetchValidCLIScopes(ctx context.Context, serviceURL string) ([]string, error) {
42
const clientId = "gitpod-cli"
43
44
endpoint := serviceURL + "/api/oauth/inspect?client=" + clientId
45
46
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
47
defer cancel()
48
49
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
50
if err != nil {
51
return nil, err
52
}
53
54
resp, err := http.DefaultClient.Do(req)
55
if err != nil {
56
return nil, err
57
}
58
defer resp.Body.Close()
59
60
if resp.StatusCode == http.StatusOK {
61
err = json.NewDecoder(resp.Body).Decode(&authScopesLocalCompanion)
62
if err != nil {
63
return nil, err
64
}
65
return authScopesLocalCompanion, nil
66
}
67
68
return nil, prettyprint.MarkExceptional(errors.New(serviceURL + " did not provide valid scopes"))
69
}
70
71
type ErrInvalidGitpodToken struct {
72
cause error
73
}
74
75
func (e *ErrInvalidGitpodToken) Error() string {
76
return "invalid gitpod token: " + e.cause.Error()
77
}
78
79
// ValidateToken validates the given tkn against the given gitpod service
80
func ValidateToken(client gitpod.APIInterface, tkn string) error {
81
hash := sha256.Sum256([]byte(tkn))
82
tokenHash := hex.EncodeToString(hash[:])
83
tknScopes, err := client.GetGitpodTokenScopes(context.Background(), tokenHash)
84
if e, ok := err.(*gitpod.ErrBadHandshake); ok && e.Resp.StatusCode == 401 {
85
return &ErrInvalidGitpodToken{err}
86
}
87
if err != nil && strings.Contains(err.Error(), "jsonrpc2: code 403") {
88
return &ErrInvalidGitpodToken{err}
89
}
90
if err != nil {
91
return err
92
}
93
tknScopesMap := make(map[string]struct{}, len(tknScopes))
94
for _, scope := range tknScopes {
95
tknScopesMap[scope] = struct{}{}
96
}
97
for _, scope := range authScopesLocalCompanion {
98
_, ok := tknScopesMap[scope]
99
if !ok {
100
return &ErrInvalidGitpodToken{fmt.Errorf("%v scope is missing in %v", scope, tknScopes)}
101
}
102
}
103
return nil
104
}
105
106
// SetToken returns the persisted Gitpod token
107
func SetToken(host, token string) error {
108
return keyring.Set(keychainServiceName, host, token)
109
}
110
111
// GetToken returns the persisted Gitpod token
112
func GetToken(host string) (token string, err error) {
113
tkn, err := keyring.Get(keychainServiceName, host)
114
if errors.Is(err, keyring.ErrNotFound) {
115
return "", nil
116
}
117
return tkn, err
118
}
119
120
// DeleteToken deletes the persisted Gitpod token
121
func DeleteToken(host string) error {
122
return keyring.Delete(keychainServiceName, host)
123
}
124
125
// LoginOpts configure the login process
126
type LoginOpts struct {
127
GitpodURL string
128
RedirectURL string
129
AuthTimeout time.Duration
130
131
ExtendScopes bool
132
}
133
134
const html = `
135
<html>
136
<head>
137
<meta charset="utf-8">
138
<title>Done</title>
139
<script>
140
if (window.opener) {
141
const message = new URLSearchParams(window.location.search).get("message");
142
window.opener.postMessage(message, "https://${window.location.hostname}");
143
} else {
144
console.log("This page was not opened by Gitpod.")
145
setTimeout("window.close();", 1000);
146
}
147
</script>
148
</head>
149
<body>
150
If this tab is not closed automatically, feel free to close it and proceed. <button type="button" onclick="window.open('', '_self', ''); window.close();">Close</button>
151
</body>
152
</html>`
153
154
// NOTE: the port ranges all need to be valid redirect URI's in the backend
155
const (
156
StartingPortNum = 63110
157
EndingPortNum = 63120
158
)
159
160
// Login walks through the login flow for obtaining a Gitpod token
161
func Login(ctx context.Context, opts LoginOpts) (token string, err error) {
162
// Try a range of ports for local redirect server
163
rl, port, err := findOpenPortInRange(StartingPortNum, EndingPortNum)
164
if err != nil {
165
return "", err
166
}
167
168
defer func() {
169
closeErr := rl.Close()
170
if closeErr != nil {
171
slog.Debug("Failed to close listener", "port", port, "err", closeErr)
172
}
173
}()
174
175
var (
176
errChan = make(chan error, 1)
177
queryChan = make(chan url.Values, 1)
178
)
179
180
returnHandler := func(rw http.ResponseWriter, req *http.Request) {
181
queryChan <- req.URL.Query()
182
if opts.RedirectURL != "" {
183
http.Redirect(rw, req, opts.RedirectURL, http.StatusSeeOther)
184
} else {
185
_, _ = io.WriteString(rw, html)
186
}
187
}
188
189
returnServer := &http.Server{
190
Addr: fmt.Sprintf("127.0.0.1:%d", port),
191
Handler: http.HandlerFunc(returnHandler),
192
}
193
go func() {
194
err := returnServer.Serve(rl)
195
if err != nil {
196
errChan <- err
197
}
198
}()
199
defer returnServer.Shutdown(ctx)
200
201
baseURL := opts.GitpodURL
202
if baseURL == "" {
203
baseURL = "https://gitpod.io"
204
}
205
reqURL, err := url.Parse(baseURL)
206
if err != nil {
207
return "", err
208
}
209
authURL := *reqURL
210
authURL.Path = "/api/oauth/authorize"
211
tokenURL := *reqURL
212
tokenURL.Path = "/api/oauth/token"
213
conf := &oauth2.Config{
214
ClientID: "gplctl-1.0",
215
ClientSecret: "gplctl-1.0-secret", // Required (even though it is marked as optional?!)
216
Scopes: authScopesLocalCompanion,
217
Endpoint: oauth2.Endpoint{
218
AuthURL: authURL.String(),
219
TokenURL: tokenURL.String(),
220
},
221
}
222
if opts.ExtendScopes {
223
authScopesLocalCompanion, err = fetchValidCLIScopes(ctx, opts.GitpodURL)
224
if err != nil {
225
return "", err
226
}
227
slog.Debug("Using CLI scopes", "scopes", authScopesLocalCompanion)
228
conf = &oauth2.Config{
229
ClientID: "gitpod-cli",
230
ClientSecret: "gitpod-cli-secret",
231
Scopes: authScopesLocalCompanion,
232
Endpoint: oauth2.Endpoint{
233
AuthURL: authURL.String(),
234
TokenURL: tokenURL.String(),
235
},
236
}
237
}
238
responseTypeParam := oauth2.SetAuthURLParam("response_type", "code")
239
redirectURIParam := oauth2.SetAuthURLParam("redirect_uri", fmt.Sprintf("http://127.0.0.1:%d", rl.Addr().(*net.TCPAddr).Port))
240
codeChallengeMethodParam := oauth2.SetAuthURLParam("code_challenge_method", "S256")
241
codeVerifier := PKCEVerifier(84)
242
codeChallengeParam := oauth2.SetAuthURLParam("code_challenge", PKCEChallenge(codeVerifier))
243
244
// Redirect user to consent page to ask for permission
245
// for the scopes specified above.
246
authorizationURL := conf.AuthCodeURL("state", responseTypeParam, redirectURIParam, codeChallengeParam, codeChallengeMethodParam)
247
248
// open a browser window to the authorizationURL
249
err = open.Start(authorizationURL)
250
if err != nil {
251
return "", prettyprint.AddResolution(fmt.Errorf("cannot open browser to URL %s: %s\n", authorizationURL, err),
252
"provide a personal access token using --token or the GITPOD_TOKEN environment variable",
253
)
254
}
255
256
var query url.Values
257
var code, approved string
258
select {
259
case <-ctx.Done():
260
return "", errors.New("context cancelled")
261
case err = <-errChan:
262
return "", err
263
case query = <-queryChan:
264
code = query.Get("code")
265
approved = query.Get("approved")
266
case <-time.After(opts.AuthTimeout):
267
return "", xerrors.Errorf("auth timeout after %d seconds", uint32(opts.AuthTimeout))
268
}
269
270
if approved == "no" {
271
return "", errors.New("client approval was not granted")
272
}
273
274
// Use the authorization code that is pushed to the redirect URL. Exchange will do the handshake to retrieve the
275
// initial access token. We need to add the required PKCE params as well...
276
// NOTE: we do not currently support refreshing so using the client from conf.Client will fail when token expires
277
codeVerifierParam := oauth2.SetAuthURLParam("code_verifier", codeVerifier)
278
tok, err := conf.Exchange(ctx, code, codeVerifierParam, redirectURIParam)
279
if err != nil {
280
return "", err
281
}
282
283
// Extract Gitpod token from OAuth token (JWT)
284
// NOTE: we do not verify the token as that requires a shared secret
285
// ... which wouldn't be secret for a publicly accessible app
286
claims := jwt.MapClaims{}
287
parser := new(jwt.Parser)
288
_, _, err = parser.ParseUnverified(tok.AccessToken, &claims)
289
if err != nil {
290
return "", err
291
}
292
293
gitpodToken := claims["jti"].(string)
294
return gitpodToken, nil
295
}
296
297
func findOpenPortInRange(start, end int) (net.Listener, int, error) {
298
for port := start; port < end; port++ {
299
rl, err := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", port))
300
if err != nil {
301
slog.Debug("could not open port, trying next port", "port", port, "err", err)
302
continue
303
}
304
305
return rl, port, nil
306
}
307
return nil, 0, xerrors.Errorf("could not open any valid port in range %d - %d", start, end)
308
}
309
310
type AuthenticatedTransport struct {
311
T http.RoundTripper
312
Token string
313
}
314
315
func (t *AuthenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
316
req.Header.Add("Authorization", "Bearer "+t.Token)
317
return t.T.RoundTrip(req)
318
}
319
320