| // Copyright 2015 The Vanadium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package oauth |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "net/http" |
| "os" |
| |
| "golang.org/x/oauth2" |
| |
| "v.io/v23/context" |
| ) |
| |
| // googleOAuth implements the OAuthProvider interface with google oauth 2.0. |
| type googleOAuth struct { |
| // client_id and client_secret registered with the Google Developer |
| // Console for API access. |
| clientID, clientSecret string |
| scope, authURL, tokenURL string |
| // URL used to verify google tokens. |
| // (From https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken |
| // and https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken) |
| verifyURL string |
| |
| ctx *context.T |
| } |
| |
| func NewGoogleOAuth(ctx *context.T, configFile string) (OAuthProvider, error) { |
| clientID, clientSecret, err := getOAuthClientIDAndSecret(configFile) |
| if err != nil { |
| return nil, err |
| } |
| return &googleOAuth{ |
| clientID: clientID, |
| clientSecret: clientSecret, |
| scope: "email", |
| authURL: "https://accounts.google.com/o/oauth2/auth", |
| tokenURL: "https://accounts.google.com/o/oauth2/token", |
| verifyURL: "https://www.googleapis.com/oauth2/v1/tokeninfo?", |
| ctx: ctx, |
| }, nil |
| } |
| |
| func (g *googleOAuth) AuthURL(redirectUrl, state string, approval AuthURLApproval) string { |
| var opts []oauth2.AuthCodeOption |
| if approval == ExplicitApproval { |
| opts = append(opts, oauth2.ApprovalForce) |
| } |
| return g.oauthConfig(redirectUrl).AuthCodeURL(state, opts...) |
| } |
| |
| // ExchangeAuthCodeForEmail exchanges the authorization code (which must |
| // have been obtained with scope=email) for an OAuth token and then uses Google's |
| // tokeninfo API to extract the email address from that token. |
| func (g *googleOAuth) ExchangeAuthCodeForEmail(authcode string, url string) (string, error) { |
| config := g.oauthConfig(url) |
| t, err := config.Exchange(oauth2.NoContext, authcode) |
| if err != nil { |
| return "", fmt.Errorf("failed to exchange authorization code for token: %v", err) |
| } |
| |
| if !t.Valid() { |
| return "", fmt.Errorf("oauth2 token invalid") |
| } |
| // Ideally, would validate the token ourselves without an HTTP roundtrip. |
| // However, for now, as per: |
| // https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken |
| // pay an HTTP round-trip to have Google do this. |
| idToken, ok := t.Extra("id_token").(string) |
| if !ok { |
| return "", fmt.Errorf("no GoogleIDToken found in OAuth token") |
| } |
| // The GoogleIDToken is currently validated by sending an HTTP request to |
| // googleapis.com. This adds a round-trip and service may be denied by |
| // googleapis.com if this handler becomes a breakout success and receives tons |
| // of traffic. If either is a concern, the GoogleIDToken can be validated |
| // without an additional HTTP request. |
| // See: https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken |
| tinfo, err := http.Get(g.verifyURL + "id_token=" + idToken) |
| if err != nil { |
| return "", fmt.Errorf("failed to talk to GoogleIDToken verifier (%q): %v", g.verifyURL, err) |
| } |
| defer tinfo.Body.Close() |
| if tinfo.StatusCode != http.StatusOK { |
| return "", fmt.Errorf("failed to verify GoogleIDToken: %s", tinfo.Status) |
| } |
| var gtoken token |
| if err := json.NewDecoder(tinfo.Body).Decode(>oken); err != nil { |
| return "", fmt.Errorf("invalid JSON response from Google's tokeninfo API: %v", err) |
| } |
| // We check both "verified_email" and "email_verified" here because the token response sometimes |
| // contains one and sometimes contains the other. |
| if !gtoken.VerifiedEmail && !gtoken.EmailVerified { |
| return "", fmt.Errorf("email not verified: %#v", gtoken) |
| } |
| if gtoken.Issuer != "accounts.google.com" { |
| return "", fmt.Errorf("invalid issuer: %v", gtoken.Issuer) |
| } |
| if gtoken.Audience != config.ClientID { |
| return "", fmt.Errorf("unexpected audience(%v) in GoogleIDToken", gtoken.Audience) |
| } |
| return gtoken.Email, nil |
| } |
| |
| // GetEmailAndClientID uses Google's tokeninfo API to determine the email and clientID |
| // associated with the token. |
| func (g *googleOAuth) GetEmailAndClientID(accessToken string) (string, string, error) { |
| // As per https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken |
| // we obtain the 'info' for the token via an HTTP roundtrip to Google. |
| tokeninfo, err := http.Get(g.verifyURL + "access_token=" + accessToken) |
| if err != nil { |
| return "", "", fmt.Errorf("unable to use token: %v", err) |
| } |
| defer tokeninfo.Body.Close() |
| if tokeninfo.StatusCode != http.StatusOK { |
| return "", "", fmt.Errorf("unable to verify access token, OAuth2 TokenInfo endpoint responded with StatusCode: %v", tokeninfo.StatusCode) |
| } |
| // tokeninfo contains a JSON-encoded struct |
| var token struct { |
| IssuedTo string `json:"issued_to"` |
| Audience string `json:"audience"` |
| UserID string `json:"user_id"` |
| Scope string `json:"scope"` |
| ExpiresIn int64 `json:"expires_in"` |
| Email string `json:"email"` |
| VerifiedEmail bool `json:"verified_email"` |
| EmailVerified bool `json:"email_verified"` |
| AccessType string `json:"access_type"` |
| } |
| if err := json.NewDecoder(tokeninfo.Body).Decode(&token); err != nil { |
| return "", "", fmt.Errorf("invalid JSON response from Google's tokeninfo API: %v", err) |
| } |
| // We check both "verified_email" and "email_verified" here because the token response sometimes |
| // contains one and sometimes contains the other. |
| if !token.VerifiedEmail && !token.EmailVerified { |
| return "", "", fmt.Errorf("email not verified") |
| } |
| return token.Email, token.Audience, nil |
| } |
| |
| func (g *googleOAuth) oauthConfig(redirectUrl string) *oauth2.Config { |
| return &oauth2.Config{ |
| ClientID: g.clientID, |
| ClientSecret: g.clientSecret, |
| RedirectURL: redirectUrl, |
| Scopes: []string{g.scope}, |
| Endpoint: oauth2.Endpoint{ |
| AuthURL: g.authURL, |
| TokenURL: g.tokenURL, |
| }, |
| } |
| } |
| |
| func getOAuthClientIDAndSecret(configFile string) (clientID, clientSecret string, err error) { |
| f, err := os.Open(configFile) |
| if err != nil { |
| return "", "", fmt.Errorf("failed to open %q: %v", configFile, err) |
| } |
| defer f.Close() |
| clientID, clientSecret, err = ClientIDAndSecretFromJSON(f) |
| if err != nil { |
| return "", "", fmt.Errorf("failed to decode JSON in %q: %v", configFile, err) |
| } |
| return clientID, clientSecret, nil |
| } |
| |
| // IDToken JSON message returned by Google's verification endpoint. |
| // |
| // This differs from the description in: |
| // https://developers.google.com/accounts/docs/OAuth2Login#obtainuserinfo |
| // because the Google tokeninfo endpoint |
| // (https://www.googleapis.com/oauth2/v1/tokeninfo?id_token=XYZ123) |
| // mentioned in: |
| // https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken |
| // seems to return the following JSON message. |
| type token struct { |
| Issuer string `json:"issuer"` |
| IssuedTo string `json:"issued_to"` |
| Audience string `json:"audience"` |
| UserID string `json:"user_id"` |
| ExpiresIn int64 `json:"expires_in"` |
| IssuedAt int64 `json:"issued_at"` |
| Email string `json:"email"` |
| VerifiedEmail bool `json:"verified_email"` |
| EmailVerified bool `json:"email_verified"` |
| } |