1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
21 "git.curoverse.com/arvados.git/lib/controller/rpc"
22 "git.curoverse.com/arvados.git/sdk/go/arvados"
23 "git.curoverse.com/arvados.git/sdk/go/auth"
24 "github.com/coreos/go-oidc"
28 type googleLoginController struct {
29 issuer string // override OIDC issuer URL (normally https://accounts.google.com) for testing
30 provider *oidc.Provider
34 func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
36 defer ctrl.mu.Unlock()
37 if ctrl.provider == nil {
40 issuer = "https://accounts.google.com"
42 provider, err := oidc.NewProvider(context.Background(), issuer)
46 ctrl.provider = provider
48 return ctrl.provider, nil
51 func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
52 provider, err := ctrl.getProvider()
54 return ctrl.loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
56 redirURL, err := (*url.URL)(&cluster.Services.Controller.ExternalURL).Parse("/login")
58 return ctrl.loginError(fmt.Errorf("error making redirect URL: %s", err))
60 conf := &oauth2.Config{
61 ClientID: cluster.Login.GoogleClientID,
62 ClientSecret: cluster.Login.GoogleClientSecret,
63 Endpoint: provider.Endpoint(),
64 Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
65 RedirectURL: redirURL.String(),
67 verifier := provider.Verifier(&oidc.Config{
68 ClientID: conf.ClientID,
71 // Initiate Google sign-in.
72 if opts.ReturnTo == "" {
73 return ctrl.loginError(errors.New("missing return_to parameter"))
75 me := url.URL(cluster.Services.Controller.ExternalURL)
76 callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
78 return ctrl.loginError(err)
80 conf.RedirectURL = callback.String()
81 state := ctrl.newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
82 return arvados.LoginResponse{
83 RedirectLocation: conf.AuthCodeURL(state.String(),
84 // prompt=select_account tells Google
85 // to show the "choose which Google
86 // account" page, even if the client
87 // is currently logged in to exactly
88 // one Google account.
89 oauth2.SetAuthURLParam("prompt", "select_account")),
92 // Callback after Google sign-in.
93 state := ctrl.parseOAuth2State(opts.State)
94 if !state.verify([]byte(cluster.SystemRootToken)) {
95 return ctrl.loginError(errors.New("invalid OAuth2 state"))
97 oauth2Token, err := conf.Exchange(ctx, opts.Code)
99 return ctrl.loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
101 rawIDToken, ok := oauth2Token.Extra("id_token").(string)
103 return ctrl.loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
105 idToken, err := verifier.Verify(ctx, rawIDToken)
107 return ctrl.loginError(fmt.Errorf("error verifying ID token: %s", err))
110 Name string `json:"name"`
111 Email string `json:"email"`
112 Verified bool `json:"email_verified"`
114 if err := idToken.Claims(&claims); err != nil {
115 return ctrl.loginError(fmt.Errorf("error extracting claims from ID token: %s", err))
117 if !claims.Verified {
118 return ctrl.loginError(errors.New("cannot authenticate using an unverified email address"))
121 firstname, lastname := strings.TrimSpace(claims.Name), ""
122 if names := strings.Fields(firstname); len(names) > 1 {
123 firstname = strings.Join(names[0:len(names)-1], " ")
124 lastname = names[len(names)-1]
127 ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{cluster.SystemRootToken}})
128 return railsproxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
129 ReturnTo: state.Remote + "," + state.ReturnTo,
130 AuthInfo: map[string]interface{}{
131 "email": claims.Email,
132 "first_name": firstname,
133 "last_name": lastname,
139 func (ctrl *googleLoginController) loginError(sendError error) (resp arvados.LoginResponse, err error) {
140 tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
144 err = tmpl.Execute(&resp.HTML, sendError.Error())
148 func (ctrl *googleLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
150 Time: time.Now().Unix(),
154 s.HMAC = s.computeHMAC(key)
158 type oauth2State struct {
159 HMAC []byte // hash of other fields; see computeHMAC()
160 Time int64 // creation time (unix timestamp)
161 Remote string // remote cluster if requesting a salted token, otherwise blank
162 ReturnTo string // redirect target
165 func (ctrl *googleLoginController) parseOAuth2State(encoded string) (s oauth2State) {
166 // Errors are not checked. If decoding/parsing fails, the
167 // token will be rejected by verify().
168 decoded, _ := base64.RawURLEncoding.DecodeString(encoded)
169 f := strings.Split(string(decoded), "\n")
173 fmt.Sscanf(f[0], "%x", &s.HMAC)
174 fmt.Sscanf(f[1], "%x", &s.Time)
175 fmt.Sscanf(f[2], "%s", &s.Remote)
176 fmt.Sscanf(f[3], "%s", &s.ReturnTo)
180 func (s oauth2State) verify(key []byte) bool {
181 if delta := time.Now().Unix() - s.Time; delta < 0 || delta > 300 {
184 return hmac.Equal(s.computeHMAC(key), s.HMAC)
187 func (s oauth2State) String() string {
189 enc := base64.NewEncoder(base64.RawURLEncoding, &buf)
190 fmt.Fprintf(enc, "%x\n%x\n%s\n%s", s.HMAC, s.Time, s.Remote, s.ReturnTo)
195 func (s oauth2State) computeHMAC(key []byte) []byte {
196 mac := hmac.New(sha256.New, key)
197 fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)