| // Package login handles logging in users. |
| package login |
| |
| // Theory of operation. |
| // |
| // We use OAuth 2.0 handle authentication. We are essentially doing OpenID |
| // Connect, but vastly simplified since we are hardcoded to Google's endpoints. |
| // |
| // We do a simple OAuth 2.0 flow where the user is asked to grant permission to |
| // the 'email' scope. See https://developers.google.com/+/api/oauth#email for |
| // details on that scope. Note that you need to have the Google Plus API turned |
| // on in your project for this to work, but note that the 'email' scope will |
| // still work for people w/o Google Plus accounts. |
| // |
| // Now in theory once we are authorized and have an Access Token we could call |
| // https://developers.google.com/+/api/openidconnect/getOpenIdConnect and get the |
| // users email address. But here we can cheat, as we know it's Google and that for |
| // the 'email' scope an ID Token will be returned along with the Access Token. |
| // If we decode the ID Token we can get the email address out of that w/o the |
| // need for the second round trip. This is all clearly *ahem* explained here: |
| // |
| // https://developers.google.com/accounts/docs/OAuth2Login#exchangecode |
| // |
| // Once we get the users email address we put it in a cookie for later |
| // retrieval. The cookie value is validated using HMAC to stop spoofing. |
| |
| import ( |
| "context" |
| "crypto/rand" |
| "crypto/sha256" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "net/http" |
| "strings" |
| "time" |
| |
| "github.com/gorilla/securecookie" |
| ttlcache "github.com/patrickmn/go-cache" |
| "go.skia.org/infra/go/httputils" |
| "go.skia.org/infra/go/metrics2" |
| "go.skia.org/infra/go/secret" |
| "go.skia.org/infra/go/skerr" |
| "go.skia.org/infra/go/sklog" |
| "golang.org/x/oauth2" |
| "golang.org/x/oauth2/google" |
| oauth2_api "google.golang.org/api/oauth2/v2" |
| "google.golang.org/api/option" |
| ) |
| |
| const ( |
| |
| // DefaultOAuth2Callback is the default relative OAuth2 redirect URL. |
| DefaultOAuth2Callback = "/oauth2callback/" |
| |
| // LoginPath is the path to use for login on the root domain. |
| LoginPath = "/login/" |
| |
| // LogoutPath is the path to use for logout on the root domain. |
| LogoutPath = "/logout/" |
| |
| // emailScope is the scope we request when logging in. |
| emailScope = "email" |
| |
| // The name of the cookie that stores the login info. |
| cookieName = "sktoken" |
| |
| // The name of the session cookie. |
| sessionCookieName = "sksession" |
| |
| // Default cookie salt used only for testing. |
| defaultCookieSalt = "notverysecret" |
| |
| // cookieDomainSkiaCorp is the cookie domain for skia*.corp.goog. |
| cookieDomainSkiaCorp = "corp.goog" |
| |
| // loginSecretProject is the GCP project containing the login secrets. |
| loginSecretProject = "skia-infra-public" |
| |
| // idTokenKeyName is the key of the JWT stored in oauth2.Token.Extra that |
| // contains the authenticated users email address. |
| idTokenKeyName = "id_token" |
| |
| // validBearerTokenCacheLifetime is how long are valid bearer tokens cached |
| // before requiring they be validated again. |
| // |
| // OAuth2 access tokens expire after an hour, so we'll cache them for the |
| // same duration. |
| validBearerTokenCacheLifetime = time.Hour |
| |
| // validBearerTokenCacheCleanup is how often the cache is cleared of expired |
| // bearer tokens. |
| validBearerTokenCacheCleanup = 5 * time.Minute |
| ) |
| |
| var ( |
| // defaultRedirectURL is the redirect URL to use if Init is called with |
| // DEFAULT_ALLOWED_DOMAINS. |
| defaultRedirectURL = "https://skia.org/oauth2callback/" |
| |
| // cookieDomain is the domain to use when setting Cookies. |
| cookieDomain = "skia.org" |
| |
| // clientIDandSecretName is the name of the GCP secret for the Client ID and |
| // Client Secret. |
| clientIDandSecretName = "login-oauth2-secrets" |
| |
| // saltSecretName is the name of the GCP secret for the salt used to encrypt |
| // the Cookie. |
| saltSecretName = "login-oauth2-salt" |
| |
| errMalformedState = errors.New("malformed state value") |
| ) |
| |
| // InitOption are options passed to Init. Note that DomainName implements |
| // InitOption allowing the selection of the login domain. |
| type InitOption interface { |
| Apply() error |
| } |
| |
| // SkipLoadingSecrets should only be used when calling Init during tests. It |
| // skips trying to load secrets. |
| type SkipLoadingSecrets struct{} |
| |
| // Apply implements InitOption. |
| func (s SkipLoadingSecrets) Apply() error { |
| return nil |
| } |
| |
| // DomainName represents a domain name that can be used for login. |
| type DomainName string |
| |
| // Apply implements InitOption for DomainName selection. |
| func (d DomainName) Apply() error { |
| return setDomain(d) |
| } |
| |
| const ( |
| // SkiaOrg selects the configuration for the skia.org domain. |
| SkiaOrg DomainName = "skia.org" |
| |
| // LuciApp selects the configuration for the luci.app domain. |
| LuciApp DomainName = "luci.app" |
| ) |
| |
| // AllDomainNames contains all the allowed domain names. |
| var AllDomainNames = []DomainName{SkiaOrg, LuciApp} |
| |
| // domainConfig contains the configuration to process logins for a domain. |
| type domainConfig struct { |
| CookieDomain string |
| LoginSecretName string |
| } |
| |
| var domainConfigurations = map[DomainName]domainConfig{ |
| SkiaOrg: { |
| CookieDomain: "skia.org", |
| LoginSecretName: "login-oauth2-secrets", |
| }, |
| LuciApp: { |
| CookieDomain: "luci.app", |
| LoginSecretName: "luci-app-login-oauth2-secrets", |
| }, |
| } |
| |
| // setDomain sets the domain used for authentication. |
| func setDomain(d DomainName) error { |
| cfg, ok := domainConfigurations[d] |
| if !ok { |
| return skerr.Fmt("unknown domain: %q", d) |
| } |
| defaultRedirectURL = fmt.Sprintf("https://%s%s", cfg.CookieDomain, DefaultOAuth2Callback) |
| cookieDomain = cfg.CookieDomain |
| clientIDandSecretName = cfg.LoginSecretName |
| return nil |
| } |
| |
| // oAuthConfigConstructor allows choosing OAuthConfig implementations. |
| type oAuthConfigConstructor func(clientID, clientSecret, redirectURL string) OAuthConfig |
| |
| // OAuthConfig is an interface with the subset of the functionality we use of oauth2.Config, used for tests/mocking. |
| type OAuthConfig interface { |
| // See oauth2.Config. |
| AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string |
| |
| // See oauth2.Config. |
| Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) |
| } |
| |
| // oauth2Config implements oAuthConfigConstructor for *oauth2.Config objects. |
| func configConstructor(clientID, clientSecret, redirectURL string) OAuthConfig { |
| return &oauth2.Config{ |
| ClientID: clientID, |
| ClientSecret: clientSecret, |
| Scopes: []string{emailScope}, |
| Endpoint: google.Endpoint, |
| RedirectURL: redirectURL, |
| } |
| } |
| |
| var ( |
| // cookieSalt is some entropy for our encoders. |
| cookieSalt = "" |
| |
| secureCookie *securecookie.SecureCookie = nil |
| |
| tokenValidatorService *oauth2_api.Service = nil |
| |
| // oauthConfig is the OAuth 2.0 client configuration. |
| oauthConfig = configConstructor("not-a-valid-client-id", "not-a-valid-client-secret", "http://localhost:8000/oauth2callback/") |
| |
| // loginCtxKey is used to store login information in the request context. |
| loginCtxKey = &struct{}{} |
| |
| // activeOAuth2ConfigConstructor can be replaced with a func that returns a |
| // mock OAuthConfig for testing. |
| activeOAuth2ConfigConstructor oAuthConfigConstructor = configConstructor |
| |
| // validBearerTokenCache is a TTL cache for bearer tokens that have been |
| // validated, which saves an HTTP round trip for validation for every |
| // request. |
| validBearerTokenCache *ttlcache.Cache |
| ) |
| |
| // Session is encrypted and serialized and stored in a user's cookie. |
| type Session struct { |
| Email string |
| ID string |
| AuthScope string |
| Token *oauth2.Token |
| } |
| |
| // Init or InitVerifyOnly must be called before any other login methods. |
| // |
| // The function first tries to load the cookie salt, client id, and client |
| // secret from a file provided by Kubernetes secrets. If that fails, it tries to |
| // load them from GCP secret manager, and if that also fails it looks for a |
| // "client_secret.json" file in the current directory to extract the client id |
| // and client secret from. If all three of those fail then it returns an error. |
| // |
| // InitOptions include setting the DomainName to be used for authentication. |
| func Init(ctx context.Context, redirectURL string, opts ...InitOption) error { |
| cookieSalt := defaultCookieSalt |
| var clientID string |
| var clientSecret string |
| var err error |
| if !skipLoadingSecrets(opts...) { |
| cookieSalt, clientID, clientSecret, err = TryLoadingFromAllSources(ctx) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| } |
| return initLogin(ctx, clientID, clientSecret, redirectURL, cookieSalt, opts...) |
| } |
| |
| // InitVerifyOnly or Init must be called before any other login methods. |
| // |
| // The function only loads the cookie salt. This use be used by auth-proxy |
| // instead of Init, since auth-proxy only needs the salt to decrypt the login |
| // Cookie, it doesn't need access to the Client ID or Client Secret since |
| // auth-proxy doesn't handle the login itself, it just redirects to an |
| // oauth2redirect instance that does the actual login. |
| func InitVerifyOnly(ctx context.Context, redirectURL string, opts ...InitOption) error { |
| cookieSalt := defaultCookieSalt |
| if !skipLoadingSecrets(opts...) { |
| secretClient, err := secret.NewClient(ctx) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| |
| cookieSalt, err = loadSaltFromGCPSecret(ctx, secretClient) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| } |
| return initLogin(ctx, "", "", redirectURL, cookieSalt, opts...) |
| } |
| |
| // Returns true if SkipLoadingSecrets has been passed in as an option. |
| func skipLoadingSecrets(opts ...InitOption) bool { |
| for _, opt := range opts { |
| if _, ok := opt.(SkipLoadingSecrets); ok { |
| return true |
| } |
| } |
| return false |
| } |
| |
| func abbrev(s string) string { |
| if len(s) < 4 { |
| return s |
| } |
| return s[:4] |
| } |
| |
| // initLogin sets the params. It should only be called directly for testing purposes. |
| // Clients should use Init(). |
| func initLogin(ctx context.Context, clientID, clientSecret, redirectURL, salt string, opts ...InitOption) error { |
| for _, opt := range opts { |
| if err := opt.Apply(); err != nil { |
| return skerr.Wrapf(err, "applying option") |
| } |
| } |
| |
| // Must be done after applying opts, since an opt may change |
| // DefaultRedirectURL. |
| if redirectURL == "" { |
| redirectURL = defaultRedirectURL |
| } |
| |
| // TODO(jcgregorio) We should actually load the last two cookieSalts and try |
| // to decrypt with either of them, while only encoding Cookies with the |
| // latest salt. Also, the two cookieSalts should be periodically re-loaded |
| // in case a new cookieSalt has been uploaded to the secret mananger. |
| secureCookie = securecookie.New([]byte(cookieSalt), nil) |
| oauthConfig = activeOAuth2ConfigConstructor(clientID, clientSecret, redirectURL) |
| cookieSalt = salt |
| |
| sklog.Infof("cookieSalt: %q clientID: %q", abbrev(cookieSalt), abbrev(clientID)) |
| |
| var err error |
| tokenValidatorService, err = oauth2_api.NewService(ctx, option.WithHTTPClient(httputils.NewTimeoutClient())) |
| if err != nil { |
| return skerr.Wrapf(err, "create oauth2 service client") |
| } |
| |
| // Create the valid bearer token cache. |
| validBearerTokenCache = ttlcache.New(validBearerTokenCacheLifetime, validBearerTokenCacheCleanup) |
| |
| // Report metrics on the cache size. |
| validBearerTokens := metrics2.GetInt64Metric("login_valid_bearer_tokens_in_cache") |
| go func() { |
| for range time.Tick(time.Minute) { |
| validBearerTokens.Update(int64(validBearerTokenCache.ItemCount())) |
| } |
| }() |
| |
| return nil |
| } |
| |
| func writeNewSessionCookie(w http.ResponseWriter, r *http.Request) (string, error) { |
| sessionID, err := generateID() |
| if err != nil { |
| return "", skerr.Wrap(err) |
| } |
| cookie := &http.Cookie{ |
| Name: sessionCookieName, |
| Value: sessionID, |
| Path: "/", |
| Domain: domainFromHost(r.Host), |
| HttpOnly: true, |
| Expires: time.Now().Add(365 * 24 * time.Hour), |
| SameSite: http.SameSiteNoneMode, |
| Secure: true, |
| } |
| http.SetCookie(w, cookie) |
| return sessionID, nil |
| } |
| |
| // loginURL returns a URL that the user is to be directed to for login. |
| func loginURL(w http.ResponseWriter, r *http.Request) string { |
| // Check for a session id, if not there then assign one, and add it to the redirect URL. |
| session, err := r.Cookie(sessionCookieName) |
| sessionID := "" |
| if err != nil || session.Value == "" { |
| sessionID, err = writeNewSessionCookie(w, r) |
| if err != nil { |
| sklog.Errorf("Failed to create a session token: %s", err) |
| return "" |
| } |
| } else { |
| sessionID = session.Value |
| } |
| |
| redirect := r.Referer() |
| if redirect == "" { |
| // If we don't have a referrer then we need to construct the URL to |
| // bounce back to. This only works if r.Host is set correctly, which |
| // it should be as long as 'proxy_set_header Host $host;' is set for |
| // the nginx server config. |
| redirect = "https://" + r.Host + r.RequestURI |
| } |
| // Append the current URL to the state, in a way that's safe from tampering, |
| // so that we can use it on the rebound. So the state we pass in has the |
| // form: |
| // |
| // <sessionid>:<hash(salt + original url)>:<original url> |
| // |
| // Note that the sessionid and the hash are hex values and so won't contain |
| // any colons. To break this up when returned from the server just use |
| // strings.SplitN(s, ":", 3) which will ignore any colons found in the |
| // Referral URL. |
| // |
| // On the receiving side we need to recompute the hash and compare against |
| // the hash passed in, and only if they match should the redirect URL be |
| // trusted. |
| state := stateFromParts(sessionID, cookieSalt, redirect) |
| |
| // Only retrieve an online access token, i.e. no refresh token. And when we |
| // go through the approval flow again don't stop if they've already approved |
| // once, unless they have a valid token but aren't in the allow list, in |
| // which case we want to use ApprovalForce so they get the chance to pick a |
| // different account to log in with. |
| opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOnline} |
| s, err := getSession(r) |
| if err == nil && !isAuthorized(s.Email) { |
| opts = append(opts, oauth2.ApprovalForce) |
| } else { |
| opts = append(opts, oauth2.SetAuthURLParam("approval_prompt", "auto")) |
| } |
| return oauthConfig.AuthCodeURL(state, opts...) |
| } |
| |
| // stateFromParts constructs a state value. The state value has the form: |
| // |
| // <sessionid>:<hash(salt + original url)>:<original url> |
| // |
| // Note that the sessionid and the hash are hex values and so won't contain |
| // any colons. To break this up when returned from the server just use |
| // strings.SplitN(s, ":", 3) which will ignore any colons found in the |
| // Referral URL. |
| // |
| // On the receiving side we need to recompute the hash and compare against |
| // the hash passed in, and only if they match should the redirect URL be |
| // trusted. |
| func stateFromParts(sessionsID, salt, redirect string) string { |
| return fmt.Sprintf("%s:%s:%s", sessionsID, hashForURL(salt, redirect), redirect) |
| } |
| |
| // partsFromState breaks up the state, which has the form: |
| // |
| // <sessionid>:<hash(salt + original url)>:<original url> |
| // |
| // and returns each part, or an error if the number of parts is wrong. |
| func partsFromState(state string) (string, string, string, error) { |
| stateParts := strings.SplitN(state, ":", 3) |
| if len(stateParts) == 3 { |
| return stateParts[0], stateParts[1], stateParts[2], nil |
| } |
| return "", "", "", errMalformedState |
| } |
| |
| // hashForURL computes hash(salt+url) and returns it as a hex string. |
| func hashForURL(salt, url string) string { |
| return fmt.Sprintf("%x", sha256.Sum256([]byte(salt+url))) |
| } |
| |
| // generate a 16-byte random ID. |
| func generateID() (string, error) { |
| b := make([]byte, 16) |
| _, err := rand.Read(b) |
| if err != nil { |
| return "", err |
| } |
| return fmt.Sprintf("%X", b), nil |
| } |
| |
| func getSession(r *http.Request) (*Session, error) { |
| cookie, err := r.Cookie(cookieName) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| var s Session |
| if err := secureCookie.Decode(cookieName, cookie.Value, &s); err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| if s.AuthScope != emailScope { |
| return nil, skerr.Fmt("Stored auth scope differs from expected (%q vs %q)", emailScope, s.AuthScope) |
| } |
| return &s, nil |
| } |
| |
| // AuthenticatedAs returns the user's email address, if they are logged in, and |
| // "" if they are not logged in. Note that if a user isn't logged in then the |
| // returned error will contain details on how the login failed. |
| func AuthenticatedAs(r *http.Request) (string, error) { |
| var email string |
| if s, err := getSession(r); err == nil { |
| email = s.Email |
| } else { |
| return viaBearerToken(r) |
| } |
| // TODO(jcgregorio) This should be able to be removed. |
| if isAuthorized(email) { |
| return email, nil |
| } |
| |
| return "", nil |
| } |
| |
| // A JSON Web Token can contain much info, such as 'iss'. We don't care about |
| // that, we only want two fields, 'email' and 'sub'. |
| // |
| // { |
| // "sub":"110...", |
| // "email":"jcgregorio@google.com", |
| // ... |
| // } |
| type decodedIDToken struct { |
| Email string `json:"email"` |
| ID string `json:"sub"` |
| } |
| |
| // domainFromHost returns the value to use in the cookie Domain field based on |
| // the requests Host value. |
| func domainFromHost(fullhost string) string { |
| // Split host and port. |
| parts := strings.Split(fullhost, ":") |
| host := parts[0] |
| if strings.HasPrefix(fullhost, "localhost") { |
| return host |
| } else if strings.HasSuffix(fullhost, "."+cookieDomainSkiaCorp) { |
| return cookieDomainSkiaCorp |
| } else if strings.HasSuffix(fullhost, "."+cookieDomain) || fullhost == cookieDomain { |
| return cookieDomain |
| } else { |
| sklog.Errorf("Unknown domain for host: %s; falling back to %s", fullhost, cookieDomain) |
| return cookieDomain |
| } |
| } |
| |
| // cookieFor creates an encoded Cookie for the given user id. |
| func cookieFor(value *Session, r *http.Request) (*http.Cookie, error) { |
| encoded, err := secureCookie.Encode(cookieName, value) |
| if err != nil { |
| return nil, fmt.Errorf("Failed to encode cookie") |
| } |
| return &http.Cookie{ |
| Name: cookieName, |
| Value: encoded, |
| Path: "/", |
| Domain: domainFromHost(r.Host), |
| HttpOnly: true, |
| Expires: time.Now().Add(365 * 24 * time.Hour), |
| SameSite: http.SameSiteNoneMode, |
| Secure: true, |
| }, nil |
| } |
| |
| func setSkIDCookieValue(w http.ResponseWriter, r *http.Request, value *Session) { |
| cookie, err := cookieFor(value, r) |
| if err != nil { |
| http.Error(w, fmt.Sprintf("%s", err), 500) |
| return |
| } |
| http.SetCookie(w, cookie) |
| } |
| |
| // UnauthenticateUser logs the user out by overwriting the cookie with a blank email |
| // address. |
| // |
| // Note that this doesn't revoke the 'email' grant, so logging in later will |
| // still be fast. Users can always visit |
| // |
| // https://security.google.com/settings/security/permissions |
| // |
| // to revoke any grants they make. |
| func UnauthenticateUser(w http.ResponseWriter, r *http.Request) { |
| sklog.Infof("LogoutHandler") |
| setSkIDCookieValue(w, r, &Session{}) |
| redirect := r.FormValue("redirect") |
| // The empty string for the redirect will just redirect back to the |
| // LogoutHandler in an infinite loop, so fallback to "/". |
| if redirect == "" { |
| redirect = "/" |
| } |
| http.Redirect(w, r, redirect, http.StatusFound) |
| } |
| |
| // AuthenticateUser kicks off the authentication flow. |
| func AuthenticateUser(w http.ResponseWriter, r *http.Request) { |
| sklog.Infof("LoginHandler") |
| http.Redirect(w, r, loginURL(w, r), http.StatusFound) |
| } |
| |
| // OAuth2CallbackHandler must be attached at a handler that matches |
| // the callback URL registered in the APIs Console. In this case |
| // "/oauth2callback". |
| func OAuth2CallbackHandler(w http.ResponseWriter, r *http.Request) { |
| cookie, err := r.Cookie(sessionCookieName) |
| if err != nil || cookie.Value == "" { |
| http.Error(w, "Missing session state.", 500) |
| return |
| } |
| |
| // Validate the session state. |
| sessionID, hash, redirectURL, err := partsFromState(r.FormValue("state")) |
| if err != nil { |
| http.Error(w, "Invalid session state", 500) |
| return |
| } |
| if sessionID != cookie.Value { |
| http.Error(w, "Session state doesn't match callback state.", 500) |
| return |
| } |
| expectedHash := hashForURL(cookieSalt, redirectURL) |
| if hash != expectedHash { |
| sklog.Errorf("Got an invalid redirect: %s != %s", hash, expectedHash) |
| http.Error(w, "Invalid redirect URL", 500) |
| return |
| } |
| |
| // Exchange code for JWT. |
| code := r.FormValue("code") |
| token, err := oauthConfig.Exchange(oauth2.NoContext, code) |
| if err != nil { |
| sklog.Errorf("Failed to authenticate: %s", err) |
| http.Error(w, "Failed to authenticate.", 500) |
| return |
| } |
| |
| // Extract email address and account ID from token. |
| email, accountID, errorMessage := extractEmailAndAccountIDFromToken(token) |
| if errorMessage != "" { |
| http.Error(w, errorMessage, 500) |
| return |
| } |
| |
| if !isAuthorized(email) { |
| http.Error(w, "Accounts from your domain are not allowed or your email address is not on the allow list.", 500) |
| return |
| } |
| s := Session{ |
| Email: email, |
| ID: accountID, |
| AuthScope: emailScope, |
| Token: token, |
| } |
| setSkIDCookieValue(w, r, &s) |
| http.Redirect(w, r, redirectURL, http.StatusFound) |
| } |
| |
| // Returns only an error message instead of an error because the results are |
| // sent back to the caller via http.Error() and we don't want to accidentally |
| // leak internal data that an an error type might accumulate. |
| func extractEmailAndAccountIDFromToken(token *oauth2.Token) (string, string, string) { |
| // idToken is a JSON Web Token. We only need to decode the token, we do not |
| // need to validate the token because it came to us over HTTPS directly from |
| // Google's servers. |
| idToken, ok := token.Extra(idTokenKeyName).(string) |
| if !ok { |
| return "", "", "No id_token returned." |
| } |
| // The id token is actually three base64 encoded parts that are "." separated. |
| segments := strings.Split(idToken, ".") |
| if len(segments) != 3 { |
| return "", "", "Invalid id_token." |
| } |
| // Now base64 decode the middle segment, which decodes to JSON. |
| padding := 4 - (len(segments[1]) % 4) |
| if padding == 4 { |
| padding = 0 |
| } |
| middle := segments[1] + strings.Repeat("=", padding) |
| b, err := base64.URLEncoding.DecodeString(middle) |
| if err != nil { |
| sklog.Errorf("Failed to base64 decode middle part of token: %s From: %#v", middle, segments) |
| return "", "", "Failed to base64 decode id_token." |
| } |
| // Finally decode the JSON. |
| decoded := &decodedIDToken{} |
| if err := json.Unmarshal(b, decoded); err != nil { |
| sklog.Errorf("Failed to JSON decode token: %s", string(b)) |
| return "", "", "Failed to JSON decode id_token." |
| } |
| |
| email := strings.ToLower(decoded.Email) |
| parts := strings.Split(email, "@") |
| if len(parts) != 2 { |
| return "", "", "Invalid email address received." |
| } |
| return email, decoded.ID, "" |
| } |
| |
| // isAuthorized returns true if the given email address is not the empty string |
| // and looks vaguely like an email address. |
| func isAuthorized(email string) bool { |
| if email == "" { |
| return false |
| } |
| parts := strings.Split(email, "@") |
| if len(parts) != 2 { |
| sklog.Errorf("Email %q was not in 2 parts", email) |
| return false |
| } |
| |
| return true |
| } |
| |
| // loginInfo is the JSON file format that client info is stored in as a kubernetes secret. |
| type loginInfo struct { |
| Salt string `json:"salt"` |
| ClientID string `json:"client_id"` |
| ClientSecret string `json:"client_secret"` |
| } |
| |
| // TryLoadingFromAllSources tries to load the cookie salt, client id, and client |
| // secret from GCP secrets, and a local file. Returns an error if all of the |
| // above fail. |
| // |
| // Returns salt, clientID, clientSecret. |
| func TryLoadingFromAllSources(ctx context.Context) (string, string, string, error) { |
| // GCP secret. |
| var err1 error |
| var err2 error |
| secretClient, err1 := secret.NewClient(ctx) |
| if err1 == nil { |
| cookieSalt, clientID, clientSecret, err2 := tryLoadingFromGCPSecret(ctx, secretClient) |
| if err2 == nil { |
| return cookieSalt, clientID, clientSecret, nil |
| } |
| } else { |
| err1 = skerr.Wrapf(err1, "failed loading login secrets from GCP secret manager; failed to create client") |
| } |
| |
| return "", "", "", skerr.Fmt("Failed loading from metadata and GCP secrets: %s | %s", err1, err2) |
| } |
| |
| // tryLoadingFromGCPSecret tries to load the cookie salt, client id, and client |
| // secret from GCP secrets. If it fails, it returns the default cookie salt and |
| // the client id and secret are the empty string. |
| // |
| // Returns salt, clientID, clientSecret. |
| func tryLoadingFromGCPSecret(ctx context.Context, secretClient secret.Client) (string, string, string, error) { |
| contents, err := secretClient.Get(ctx, loginSecretProject, clientIDandSecretName, secret.VersionLatest) |
| if err != nil { |
| return "", "", "", skerr.Wrapf(err, "failed loading login secrets from GCP secret manager; failed to retrieve secret %q", clientIDandSecretName) |
| } |
| var info loginInfo |
| if err := json.Unmarshal([]byte(contents), &info); err != nil { |
| return "", "", "", skerr.Wrapf(err, "successfully retrieved login secret from GCP secrets but failed to decode it as JSON") |
| } |
| |
| salt, err := loadSaltFromGCPSecret(ctx, secretClient) |
| if err != nil { |
| return "", "", "", skerr.Wrap(err) |
| } |
| |
| return salt, info.ClientID, info.ClientSecret, nil |
| } |
| |
| func loadSaltFromGCPSecret(ctx context.Context, secretClient secret.Client) (string, error) { |
| salt, err := secretClient.Get(ctx, loginSecretProject, saltSecretName, secret.VersionLatest) |
| if err != nil { |
| return "", skerr.Wrapf(err, "loading login secret salt from GCP secret manager; failed to retrieve secret %q", saltSecretName) |
| } |
| return salt, nil |
| |
| } |
| |
| // viaBearerToken tries to load an OAuth 2.0 Bearer token from the request and |
| // derives the login email address from it. |
| func viaBearerToken(r *http.Request) (string, error) { |
| tok := r.Header.Get("Authorization") |
| if tok == "" { |
| return "", skerr.Fmt("User is not authenticated. No Authorization header.") |
| } |
| tok = strings.TrimPrefix(tok, "Bearer ") |
| tokenInfo, err := validateBearerToken(r.Context(), tok) |
| if err != nil { |
| return "", skerr.Wrapf(err, "calling validateBearerToken") |
| } |
| return tokenInfo.Email, nil |
| } |
| |
| // validateBearerToken takes an OAuth 2.0 Bearer token (e.g. The third part of |
| // `Authorization: Bearer <value>“) and polls a Google HTTP endpoint to see if |
| // is valid. Valid tokens are cached for one hour. |
| func validateBearerToken(ctx context.Context, token string) (*oauth2_api.Tokeninfo, error) { |
| iTokenInfo, ok := validBearerTokenCache.Get(token) |
| if ok { |
| return iTokenInfo.(*oauth2_api.Tokeninfo), nil |
| } |
| |
| ti, err := tokenValidatorService.Tokeninfo().AccessToken(token).Context(ctx).Do() |
| if err != nil { |
| return nil, skerr.Wrapf(err, "calling tokenValidatorService.Tokeninfo().AccessToken(token).Context(ctx).Do()") |
| } |
| if ti.ExpiresIn <= 0 { |
| return nil, fmt.Errorf("token is expired") |
| } |
| if !ti.VerifiedEmail { |
| return nil, fmt.Errorf("email not verified") |
| } |
| validBearerTokenCache.Set(token, ti, ttlcache.DefaultExpiration) |
| |
| return ti, nil |
| } |