blob: 562c40e782925346a984d86f382ee56641e433fa [file] [log] [blame]
// 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(&gtoken); 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"`
}