Merge branch '16171-oidc'
[arvados.git] / lib / controller / localdb / login_oidc.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package localdb
6
7 import (
8         "bytes"
9         "context"
10         "crypto/hmac"
11         "crypto/sha256"
12         "encoding/base64"
13         "errors"
14         "fmt"
15         "net/http"
16         "net/url"
17         "strings"
18         "sync"
19         "text/template"
20         "time"
21
22         "git.arvados.org/arvados.git/lib/controller/rpc"
23         "git.arvados.org/arvados.git/sdk/go/arvados"
24         "git.arvados.org/arvados.git/sdk/go/auth"
25         "git.arvados.org/arvados.git/sdk/go/ctxlog"
26         "git.arvados.org/arvados.git/sdk/go/httpserver"
27         "github.com/coreos/go-oidc"
28         "golang.org/x/oauth2"
29         "google.golang.org/api/option"
30         "google.golang.org/api/people/v1"
31 )
32
33 type oidcLoginController struct {
34         Cluster            *arvados.Cluster
35         RailsProxy         *railsProxy
36         Issuer             string // OIDC issuer URL, e.g., "https://accounts.google.com"
37         ClientID           string
38         ClientSecret       string
39         UseGooglePeopleAPI bool // Use Google People API to look up alternate email addresses
40
41         // override Google People API base URL for testing purposes
42         // (normally empty, set by google pkg to
43         // https://people.googleapis.com/)
44         peopleAPIBasePath string
45
46         provider   *oidc.Provider        // initialized by setup()
47         oauth2conf *oauth2.Config        // initialized by setup()
48         verifier   *oidc.IDTokenVerifier // initialized by setup()
49         mu         sync.Mutex            // protects setup()
50 }
51
52 // Initialize ctrl.provider and ctrl.oauth2conf.
53 func (ctrl *oidcLoginController) setup() error {
54         ctrl.mu.Lock()
55         defer ctrl.mu.Unlock()
56         if ctrl.provider != nil {
57                 // already set up
58                 return nil
59         }
60         redirURL, err := (*url.URL)(&ctrl.Cluster.Services.Controller.ExternalURL).Parse("/" + arvados.EndpointLogin.Path)
61         if err != nil {
62                 return fmt.Errorf("error making redirect URL: %s", err)
63         }
64         provider, err := oidc.NewProvider(context.Background(), ctrl.Issuer)
65         if err != nil {
66                 return err
67         }
68         ctrl.oauth2conf = &oauth2.Config{
69                 ClientID:     ctrl.ClientID,
70                 ClientSecret: ctrl.ClientSecret,
71                 Endpoint:     provider.Endpoint(),
72                 Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
73                 RedirectURL:  redirURL.String(),
74         }
75         ctrl.verifier = provider.Verifier(&oidc.Config{
76                 ClientID: ctrl.ClientID,
77         })
78         ctrl.provider = provider
79         return nil
80 }
81
82 func (ctrl *oidcLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
83         return noopLogout(ctrl.Cluster, opts)
84 }
85
86 func (ctrl *oidcLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
87         err := ctrl.setup()
88         if err != nil {
89                 return loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
90         }
91         if opts.State == "" {
92                 // Initiate OIDC sign-in.
93                 if opts.ReturnTo == "" {
94                         return loginError(errors.New("missing return_to parameter"))
95                 }
96                 state := ctrl.newOAuth2State([]byte(ctrl.Cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
97                 return arvados.LoginResponse{
98                         RedirectLocation: ctrl.oauth2conf.AuthCodeURL(state.String(),
99                                 // prompt=select_account tells Google
100                                 // to show the "choose which Google
101                                 // account" page, even if the client
102                                 // is currently logged in to exactly
103                                 // one Google account.
104                                 oauth2.SetAuthURLParam("prompt", "select_account")),
105                 }, nil
106         } else {
107                 // Callback after OIDC sign-in.
108                 state := ctrl.parseOAuth2State(opts.State)
109                 if !state.verify([]byte(ctrl.Cluster.SystemRootToken)) {
110                         return loginError(errors.New("invalid OAuth2 state"))
111                 }
112                 oauth2Token, err := ctrl.oauth2conf.Exchange(ctx, opts.Code)
113                 if err != nil {
114                         return loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
115                 }
116                 rawIDToken, ok := oauth2Token.Extra("id_token").(string)
117                 if !ok {
118                         return loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
119                 }
120                 idToken, err := ctrl.verifier.Verify(ctx, rawIDToken)
121                 if err != nil {
122                         return loginError(fmt.Errorf("error verifying ID token: %s", err))
123                 }
124                 authinfo, err := ctrl.getAuthInfo(ctx, oauth2Token, idToken)
125                 if err != nil {
126                         return loginError(err)
127                 }
128                 ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{ctrl.Cluster.SystemRootToken}})
129                 return ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
130                         ReturnTo: state.Remote + "," + state.ReturnTo,
131                         AuthInfo: *authinfo,
132                 })
133         }
134 }
135
136 func (ctrl *oidcLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
137         return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New("username/password authentication is not available"), http.StatusBadRequest)
138 }
139
140 // Use a person's token to get all of their email addresses, with the
141 // primary address at index 0. The provided defaultAddr is always
142 // included in the returned slice, and is used as the primary if the
143 // Google API does not indicate one.
144 func (ctrl *oidcLoginController) getAuthInfo(ctx context.Context, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
145         var ret rpc.UserSessionAuthInfo
146         defer ctxlog.FromContext(ctx).WithField("ret", &ret).Debug("getAuthInfo returned")
147
148         var claims struct {
149                 Name     string `json:"name"`
150                 Email    string `json:"email"`
151                 Verified bool   `json:"email_verified"`
152         }
153         if err := idToken.Claims(&claims); err != nil {
154                 return nil, fmt.Errorf("error extracting claims from ID token: %s", err)
155         } else if claims.Verified {
156                 // Fall back to this info if the People API call
157                 // (below) doesn't return a primary && verified email.
158                 if names := strings.Fields(strings.TrimSpace(claims.Name)); len(names) > 1 {
159                         ret.FirstName = strings.Join(names[0:len(names)-1], " ")
160                         ret.LastName = names[len(names)-1]
161                 } else {
162                         ret.FirstName = names[0]
163                 }
164                 ret.Email = claims.Email
165         }
166
167         if !ctrl.UseGooglePeopleAPI {
168                 if ret.Email == "" {
169                         return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
170                 }
171                 return &ret, nil
172         }
173
174         svc, err := people.NewService(ctx, option.WithTokenSource(ctrl.oauth2conf.TokenSource(ctx, token)), option.WithScopes(people.UserEmailsReadScope))
175         if err != nil {
176                 return nil, fmt.Errorf("error setting up People API: %s", err)
177         }
178         if p := ctrl.peopleAPIBasePath; p != "" {
179                 // Override normal API endpoint (for testing)
180                 svc.BasePath = p
181         }
182         person, err := people.NewPeopleService(svc).Get("people/me").PersonFields("emailAddresses,names").Do()
183         if err != nil {
184                 if strings.Contains(err.Error(), "Error 403") && strings.Contains(err.Error(), "accessNotConfigured") {
185                         // Log the original API error, but display
186                         // only the "fix config" advice to the user.
187                         ctxlog.FromContext(ctx).WithError(err).WithField("email", ret.Email).Error("People API is not enabled")
188                         return nil, errors.New("configuration error: Login.GoogleAlternateEmailAddresses is true, but Google People API is not enabled")
189                 } else {
190                         return nil, fmt.Errorf("error getting profile info from People API: %s", err)
191                 }
192         }
193
194         // The given/family names returned by the People API and
195         // flagged as "primary" (if any) take precedence over the
196         // split-by-whitespace result from above.
197         for _, name := range person.Names {
198                 if name.Metadata != nil && name.Metadata.Primary {
199                         ret.FirstName = name.GivenName
200                         ret.LastName = name.FamilyName
201                         break
202                 }
203         }
204
205         altEmails := map[string]bool{}
206         if ret.Email != "" {
207                 altEmails[ret.Email] = true
208         }
209         for _, ea := range person.EmailAddresses {
210                 if ea.Metadata == nil || !ea.Metadata.Verified {
211                         ctxlog.FromContext(ctx).WithField("address", ea.Value).Info("skipping unverified email address")
212                         continue
213                 }
214                 altEmails[ea.Value] = true
215                 if ea.Metadata.Primary || ret.Email == "" {
216                         ret.Email = ea.Value
217                 }
218         }
219         if len(altEmails) == 0 {
220                 return nil, errors.New("cannot log in without a verified email address")
221         }
222         for ae := range altEmails {
223                 if ae != ret.Email {
224                         ret.AlternateEmails = append(ret.AlternateEmails, ae)
225                         if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(ctrl.Cluster.Users.PreferDomainForUsername) {
226                                 ret.Username = strings.SplitN(ae[:i], "+", 2)[0]
227                         }
228                 }
229         }
230         return &ret, nil
231 }
232
233 func loginError(sendError error) (resp arvados.LoginResponse, err error) {
234         tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
235         if err != nil {
236                 return
237         }
238         err = tmpl.Execute(&resp.HTML, sendError.Error())
239         return
240 }
241
242 func (ctrl *oidcLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
243         s := oauth2State{
244                 Time:     time.Now().Unix(),
245                 Remote:   remote,
246                 ReturnTo: returnTo,
247         }
248         s.HMAC = s.computeHMAC(key)
249         return s
250 }
251
252 type oauth2State struct {
253         HMAC     []byte // hash of other fields; see computeHMAC()
254         Time     int64  // creation time (unix timestamp)
255         Remote   string // remote cluster if requesting a salted token, otherwise blank
256         ReturnTo string // redirect target
257 }
258
259 func (ctrl *oidcLoginController) parseOAuth2State(encoded string) (s oauth2State) {
260         // Errors are not checked. If decoding/parsing fails, the
261         // token will be rejected by verify().
262         decoded, _ := base64.RawURLEncoding.DecodeString(encoded)
263         f := strings.Split(string(decoded), "\n")
264         if len(f) != 4 {
265                 return
266         }
267         fmt.Sscanf(f[0], "%x", &s.HMAC)
268         fmt.Sscanf(f[1], "%x", &s.Time)
269         fmt.Sscanf(f[2], "%s", &s.Remote)
270         fmt.Sscanf(f[3], "%s", &s.ReturnTo)
271         return
272 }
273
274 func (s oauth2State) verify(key []byte) bool {
275         if delta := time.Now().Unix() - s.Time; delta < 0 || delta > 300 {
276                 return false
277         }
278         return hmac.Equal(s.computeHMAC(key), s.HMAC)
279 }
280
281 func (s oauth2State) String() string {
282         var buf bytes.Buffer
283         enc := base64.NewEncoder(base64.RawURLEncoding, &buf)
284         fmt.Fprintf(enc, "%x\n%x\n%s\n%s", s.HMAC, s.Time, s.Remote, s.ReturnTo)
285         enc.Close()
286         return buf.String()
287 }
288
289 func (s oauth2State) computeHMAC(key []byte) []byte {
290         mac := hmac.New(sha256.New, key)
291         fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)
292         return mac.Sum(nil)
293 }