-
Notifications
You must be signed in to change notification settings - Fork 1
/
oidcauth.go
584 lines (518 loc) · 18.8 KB
/
oidcauth.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
// Package oidcauth is an authentication middleware for web applications and microservices,
// which uses an external OpenID Connect identity provider (IdP) for user storage and
// authentication.
//
// The library is configurable, except for some choices that have been pre-made on purpose:
// - Supports only the authorization code flow of OAuth2, which makes it suitable for multi-page
// web apps. If you are creating a SPA app, the implicit flow might be a better choice for your
// project.
// - Uses secure cookies to pass session IDs back and forth between the browser and the app.
// Session management is handled by gorilla/sessions, so you can use any of the many available
// implementations for it to choose where to store the session data (eg. CookieStore,
// RedisStore, DynamoStore, etc.).
// - Authenticated handlers verify same origin with standard headers ('Origin' and 'Referer') and
// block potential CSRF requests. If neither the 'Origin' nor the 'Referer' header is present,
// and the request method is anything but GET or OPTIONS, the request is blocked.
// Additionally 'Access-Control-Allow-Origin' header is added to indicate the allowed origin.
// The list of allowed origins must be specified in the configuration object (usually only the
// domain of your own app and the domain of the IdP). Use of origin '*' is not allowed.
//
// Can be used as authentication middleware for (see examples):
// - Standard multi-page web application
// - Complex web application that act as a gateway between the browser and several microservices
// (APIs) by passing the access token acquired during the authentication phase down to the
// microservices.
//
// Tested for compatibility with:
// - Keycloak 3.4.3.Final, a standalone open source identity and access management server
// (http://www.keycloak.org/)
//
// Dependencies:
// - github.com/coreos/go-oidc
// - golang.org/x/oauth2
// - github.com/gorilla/sessions
//
// TODO:
// - Add authorization support.
package oidcauth
import (
"encoding/json"
"fmt"
"log"
"net/http"
"reflect"
"strings"
"context"
oidc "github.com/coreos/go-oidc"
"github.com/gorilla/sessions"
"github.com/quasoft/oauth2state"
"golang.org/x/oauth2"
)
// The Config object determines the behaviour of the Authenticator.
type Config struct {
ClientID string
ClientSecret string
// IssuerURL, eg. "https://EXAMPLE.COM/auth/realms/REALM_NAME" for Keycloak.
IssuerURL string
// LogoutURL is the endpoint used for logging out of IdP session using GET request,
// eg. "https://EXAMPLE.COM/auth/realms/REALM_NAME/protocol/openid-connect/logout" for Keycloak.
LogoutURL string
// CallbackURL is the absolute URL to a handler in your application, which deals with auth
// responses from the IdP.
// You must handle requests to that URL and pass them to the HandleAuthResponse() method.
// eg. "https://localhost:5556/auth/callback".
CallbackURL string
// The default URL to use as return URL after successful authentication of non GET requests.
DefaultReturnURL string
// List of additional scopes to request from the IdP in addition to the default 'openid' scope
// (eg. []string{"profile"}).
Scopes []string
// AllowedOrigins is a list of hosts (https://example.com) allowed as origins of the HTTP request.
// Add the origin of your app and that of the IdP to the list.
// Use domain names in AllowedOrigins, not IP addresses.
AllowedOrigins []string
// SessionStore is where session data like user ID and claims are stored.
// SessionStore could be any of the available gorilla/session implementations
// (eg. CookieStore with secure flag, RedisStore, DynamoStore, etc.)
SessionStore sessions.Store
// TokenStore holds the access/refresh tokens that are acquired during authentication.
// Those tokens can be used to access other services (APIs) that are part of the application.
// Keeping tokens in a separate store from session data helps to avoid reaching the usual
// limit on the amount of data that can be stored in a store (eg. 4KB).
// TokenStore could be any of the available gorilla/session implementations
// (eg. CookieStore with secure flag, RedisStore, DynamoStore, etc.)
TokenStore sessions.Store
// Set StateStore to a sessions store, which will hold the oauth state value.
// Monoliths and scalable applications with sticky sessions could store state in instance memory.
// Scalable apps without sticky sessions can use Memcache or Redis for storage.
StateStore oauth2state.StateStorer
}
// Validate makes basic validation of configuration to make sure that important and required fields
// have been set with values in expected format.
func (c Config) Validate() error {
if strings.TrimSpace(c.ClientID) == "" {
return fmt.Errorf("ClientID not defined")
}
if strings.TrimSpace(c.IssuerURL) == "" {
return fmt.Errorf("IssuerURL not defined")
}
if strings.TrimSpace(c.LogoutURL) == "" {
return fmt.Errorf("LogoutURL not defined")
}
if strings.TrimSpace(c.CallbackURL) == "" {
return fmt.Errorf("CallbackURL not defined")
}
if !strings.HasPrefix(c.CallbackURL, "http://") && !strings.HasPrefix(c.CallbackURL, "https://") {
return fmt.Errorf("CallbackURL is not absolute URL")
}
if len(c.AllowedOrigins) == 0 || strings.TrimSpace(c.AllowedOrigins[0]) == "" {
return fmt.Errorf("specify at least one allowed origin")
}
for _, o := range c.AllowedOrigins {
if strings.Contains(o, "*") {
return fmt.Errorf("usage of * in allowed origins is not allowed")
}
}
return nil
}
type tokenVerifier interface {
Verify(ctx context.Context, token string) (*oidc.IDToken, error)
}
type oauthPackage interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
Exchange(ctx context.Context, code string) (*oauth2.Token, error)
}
// The Authenticator type provides middleware methods for authentication of http requests.
// A single authenticator object can be shared by concurrent goroutines.
type Authenticator struct {
Config Config
ctx context.Context
verifier tokenVerifier
oauthPkg oauthPackage
}
// TODO: Don't just use log.*, use a configurable Logger object
// New creates a new Authenticator object with the given configuration options.
// The ctx context is used only for the initial connection to the well-known configuration
// endpoint of the IdP and can be set to context.Background.
func New(ctx context.Context, config *Config) (*Authenticator, error) {
err := config.Validate()
if err != nil {
return nil, fmt.Errorf("invalid config: %v", err)
}
idp, err := oidc.NewProvider(ctx, config.IssuerURL)
if err != nil {
log.Print("Could not initialize provider object")
return nil, err
}
oidcCfg := &oidc.Config{
ClientID: config.ClientID,
}
ver := idp.Verifier(oidcCfg)
scopes := []string{oidc.ScopeOpenID}
scopes = append(scopes, config.Scopes...)
oauthCfg := oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
Endpoint: idp.Endpoint(),
RedirectURL: config.CallbackURL,
Scopes: scopes,
}
var auth = &Authenticator{
Config: *config,
ctx: ctx,
verifier: ver,
oauthPkg: &oauthCfg,
}
return auth, nil
}
// createSession creates a new session and stores the user ID and claims of the
// authenticated user inside the session data.
func (a *Authenticator) createSession(
w http.ResponseWriter,
r *http.Request,
token *oauth2.Token,
idToken *oidc.IDToken,
claims *json.RawMessage,
) error {
err := a.SetToken(w, r, token)
if err != nil {
return err
}
// Save other session values like subject ID and claims in a separate store.
// Usually those values already exist in the access token, but storing them
// separately removes the overhead of parsing the access token JWT at every
// request.
session, err := getSession(a.Config.SessionStore, r, "oidcauth")
if err != nil {
log.Printf("Could not get session from store: %v", err)
return err
}
setSessionStr(session, "auth", "true")
setSessionStr(session, "sub", idToken.Subject)
setSessionStr(session, "claims", string(*claims))
err = session.Save(r, w)
if err != nil {
log.Printf("Could not save session to store: %v", err)
return err
}
return nil
}
// destroySession deletes all session data associated with this request
// from the SessionStore and removes the cookie with the session ID.
func (a *Authenticator) destroySession(w http.ResponseWriter, r *http.Request) error {
// Delete sessions from both stores
session, err := getSession(a.Config.SessionStore, r, "oidcauth")
if err != nil {
log.Printf("Could not get session from store: %v", err)
return err
}
session.Values["auth"] = "false"
session.Options.MaxAge = -1
err = session.Save(r, w)
if err != nil {
log.Printf("Could not save session to store: %v", err)
return err
}
session, err = getSession(a.Config.TokenStore, r, "oidcauth-token")
if err != nil {
log.Printf("Could not get session from store: %v", err)
return err
}
session.Options.MaxAge = -1
err = session.Save(r, w)
if err != nil {
log.Printf("Could not save session to store: %v", err)
return err
}
return nil
}
// IsAuthenticated checks if current session is authenticated by looking at the
// authentication flag in the session data.
func (a *Authenticator) IsAuthenticated(r *http.Request) error {
session, err := getSession(a.Config.SessionStore, r, "oidcauth")
if err != nil {
return err
}
authenticated, err := getSessionStr(session, "auth")
if err != nil {
return err
}
if authenticated != "true" {
err := fmt.Errorf("Authentication flag is not set")
return err
}
return nil
}
// GetAuthInfo retrieves the subject identifier (user id) with which the
// user is registered with the IdP and the claims that were returned during
// authentication.
// Subject identifer is returned as a string.
// Claims are returned as an opaque JSON value - as provided by the IdP.
// An error is returned if the user has not been authenticated yet or an error
// occurs while reading the session data.
func (a *Authenticator) GetAuthInfo(r *http.Request) (subject string, claims string, err error) {
subject = ""
claims = ""
err = a.IsAuthenticated(r)
if err != nil {
return
}
session, err := getSession(a.Config.SessionStore, r, "oidcauth")
if err != nil {
return
}
subject, err = getSessionStr(session, "sub")
if err != nil {
return
}
claims, err = getSessionStr(session, "claims")
if err != nil {
return
}
return
}
// GetClaims retrieves the claims that were sent by the IdP during authentication as a map.
func (a *Authenticator) GetClaims(r *http.Request) (map[string]interface{}, error) {
m := make(map[string]interface{})
err := a.IsAuthenticated(r)
if err != nil {
return m, err
}
session, err := getSession(a.Config.SessionStore, r, "oidcauth")
if err != nil {
return m, err
}
claims, err := getSessionStr(session, "claims")
if err != nil {
return m, err
}
var data map[string]interface{}
err = json.Unmarshal([]byte(claims), &data)
if err != nil {
return data, err
}
return data, nil
}
// GetToken retrieves an oauth2.Token structure containing the access and refresh tokens.
func (a *Authenticator) GetToken(r *http.Request) (*oauth2.Token, error) {
err := a.IsAuthenticated(r)
if err != nil {
return nil, err
}
session, err := getSession(a.Config.TokenStore, r, "oidcauth-token")
if err != nil {
return nil, err
}
token, err := getSessionToken(session, "token")
if err != nil {
return nil, err
}
return token, nil
}
// SetToken updates the token value stored in the session.
func (a *Authenticator) SetToken(
w http.ResponseWriter,
r *http.Request,
token *oauth2.Token,
) error {
// Save the token structure containing the access and refresh tokens in a separate
// store as the total size of that structure can reach 2-3KB, which is close to the
// usual limit of 4KB in most session storages (eg. in cookies, when using CookieStore).
tokenSession, err := getSession(a.Config.TokenStore, r, "oidcauth-token")
if err != nil {
log.Printf("Could not get token store: %v", err)
return err
}
err = setSessionToken(tokenSession, "token", token)
if err != nil {
log.Printf("Could not store token structure: %v", err)
return err
}
err = tokenSession.Save(r, w)
if err != nil {
log.Printf("Could not save token to store: %v", err)
return err
}
return nil
}
// Client is a wrapper to the underlying Client() method of oauth2.Config object,
// which returns an http.Client that automatically populates the authorization header.
// with the access token.
// If the token stored in the session has expired, uses the refresh_token to renew
// it automatically and stores the updated token in the session.
func (a *Authenticator) Client(ctx context.Context, w http.ResponseWriter, r *http.Request) (*http.Client, error) {
token, err := a.GetToken(r)
if err != nil {
return nil, fmt.Errorf("no token to use for http client: %v", err)
}
client := a.oauthPkg.(*oauth2.Config).Client(ctx, token)
transport := client.Transport.(*oauth2.Transport)
newToken, err := transport.Source.Token()
if err != nil {
return nil, fmt.Errorf("no token in client transport: %v", err)
}
if !reflect.DeepEqual(token, newToken) {
a.SetToken(w, r, newToken)
}
return client, nil
}
// RedirectToLoginPage redirects the request to the login endpoint of the identity provider.
func (a *Authenticator) RedirectToLoginPage(w http.ResponseWriter, r *http.Request) {
returnURL := a.Config.DefaultReturnURL
if isGET(r) {
returnURL = r.RequestURI
}
state, err := a.Config.StateStore.NewState(returnURL)
if err != nil {
log.Printf("Could not generate new state value: %v", err)
http.Error(w, "Error!", http.StatusInternalServerError)
return
}
url := a.oauthPkg.AuthCodeURL(state)
log.Printf("Requested %s, redirecting to auth server (%s)...", returnURL, url)
http.Redirect(w, r, url, http.StatusFound)
}
// checkStateValue verifies that the state value in the query parameters
// exists in the state store.
func (a *Authenticator) checkStateValue(r *http.Request) error {
value := r.URL.Query().Get("state")
found, err := a.Config.StateStore.Contains(value)
if err != nil {
return err
}
if !found {
return fmt.Errorf("could not find state value %s", value)
}
return nil
}
// extractStateURL retrieves the URL associated with the given
// state value and removes the state value from the state store.
func (a *Authenticator) extractStateURL(r *http.Request) (string, error) {
value := r.URL.Query().Get("state")
url, err := a.Config.StateStore.URL(value)
if err != nil {
return "", err
}
err = a.Config.StateStore.Delete(value)
if err != nil {
return "", err
}
return url, nil
}
// VerifyAuthResponse verifies the authentication response received
// from the IdP and redirects to the return URL provided on the first request.
func (a *Authenticator) VerifyAuthResponse() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := a.checkStateValue(r)
if err != nil {
log.Printf("Authentication failed: %v", err)
http.Error(w, "Error!", http.StatusBadRequest)
return
}
authCode := r.URL.Query().Get("code")
token, err := a.oauthPkg.Exchange(r.Context(), authCode)
if err != nil {
log.Printf("Authentication failed: failed to exchange token: %v", err)
http.Error(w, "Error!", http.StatusBadRequest)
return
}
log.Print("Exchanged auth code for token")
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
log.Print("Authentication failed: no id_token field in oauth2 token")
http.Error(w, "Error!", http.StatusBadRequest)
return
}
log.Print("Retrieved ID token")
idToken, err := a.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
log.Printf("Authentication failed: failed to verify ID token: %v", err)
http.Error(w, "Error!", http.StatusBadRequest)
return
}
log.Print("Validated ID token successfully")
claims := new(json.RawMessage)
err = idToken.Claims(&claims)
if err != nil {
log.Printf("Authentication warning: failed to retrieve claims from token: %v", err)
} else {
log.Print("Claims retrieved successfully")
}
log.Print("Creating session")
err = a.createSession(w, r, token, idToken, claims)
if err != nil {
log.Printf("Authentication failed: could not create session: %v", err)
http.Error(w, "Error!", http.StatusInternalServerError)
return
}
log.Print("Authenticated successfully")
log.Print("Extracting return URL by state value")
returnURL, err := a.extractStateURL(r)
if err != nil {
log.Printf("Authenticated but could not determine return URL by session value: %v", err)
http.Error(w, "Error!", http.StatusBadRequest)
return
}
log.Printf("Redirecting back to %s", returnURL)
http.Redirect(w, r, returnURL, http.StatusFound)
})
}
// AuthWithSession authenticates the request. On successful authentication the request
// is passed down to the next http handler. The next handler can access information
// about the authenticated user via the GetAuthInfo, GetClaims and GetToken methods.
// If authentication was not successful, the browser is redirected to the login endpoint
// of the IdP.
// If the redirected request is using the GET method, the RequestURI of the
// current request is set as the return URL for successful login. Config.DefaultReturnURL
// is set for non GET requests.
func (a *Authenticator) AuthWithSession(next http.Handler) http.Handler {
log.Print("AuthWithSession called")
return VerifyOrigin(
a.Config.AllowedOrigins,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Authenticating request to %s", r.RequestURI)
err := a.IsAuthenticated(r)
if err != nil {
log.Print(err)
a.RedirectToLoginPage(w, r)
return
}
log.Print("Authenticated")
next.ServeHTTP(w, r)
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Error!", http.StatusForbidden)
}),
)
}
// HandleAuthResponse handles the authentication response sent by the IdP.
// Users of oidcauth should call this method as callback handler for CallbackURL.
func (a *Authenticator) HandleAuthResponse() http.Handler {
log.Print("HandleAuthResponse called")
return VerifyOrigin(
a.Config.AllowedOrigins,
a.VerifyAuthResponse(),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Error!", http.StatusForbidden)
}),
)
}
// Logout deletes the session data, logouts from the IdP and redirects to the URL provided.
func (a *Authenticator) Logout(redirectURL string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Print("Logging out")
err := a.destroySession(w, r)
if err != nil {
http.Error(w, "Error!", http.StatusInternalServerError)
return
}
url, err := appendQueryValue(a.Config.LogoutURL, "redirect_uri", redirectURL)
if err != nil {
log.Printf("Could not parse LogoutURL from config: %v", err)
http.Error(w, "Error!", http.StatusInternalServerError)
return
}
http.Redirect(w, r, url, http.StatusFound)
log.Print("Logged out")
})
}