blob: 6807f4ad13313184b1c6c4a97fefeacd010b4de8 [file] [log] [blame]
// Package googleoauth implements an http.Handler that uses OAuth 2.0 to
// authenticate with Google and then generates a Veyron identity and
// blesses it with the identity of the HTTP server.
//
// 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
package googleoauth
import (
"encoding/json"
"fmt"
"net/http"
"path"
"strings"
"time"
"code.google.com/p/goauth2/oauth"
"veyron/services/identity/util"
"veyron2"
"veyron2/vlog"
)
type HandlerArgs struct {
// Whether the HTTP server is using TLS or not.
UseTLS bool
// Fully-qualified address (host:port) that the HTTP server is
// listening on (and where redirect requests from Google will come to).
Addr string
// Address prefix (including trailing slash) on which the Google OAuth
// based Identity generator should run.
Prefix string
// client_id and client_secret registered with the Google Developer
// Console for API access.
ClientID, ClientSecret string
// Minimum expiry time (in days) of identities issued by the server
MinExpiryDays int
// Runtime from which the identity of the server itself will be extracted,
// and new identities will be created for blessing.
Runtime veyron2.Runtime
// When set, restricts the allowed email addresses to this domain, e.g.
// google.com.
RestrictEmailDomain string
}
func (a *HandlerArgs) redirectURL() string {
scheme := "http"
if a.UseTLS {
scheme = "https"
}
return fmt.Sprintf("%s://%s%soauth2callback", scheme, a.Addr, a.Prefix)
}
// NewHandler returns an http.Handler that expects to be rooted at args.Prefix
// and can be used to use OAuth 2.0 to authenticate with Google, mint a new
// identity and bless it with the Google email address.
func NewHandler(args HandlerArgs) http.Handler {
config := &oauth.Config{
ClientId: args.ClientID,
ClientSecret: args.ClientSecret,
RedirectURL: args.redirectURL(),
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?id_token="
return &handler{config, args.Addr, args.MinExpiryDays, util.NewCSRFCop(), args.Runtime, verifyURL, args.RestrictEmailDomain}
}
type handler struct {
config *oauth.Config
issuer string
minExpiryDays int
csrfCop *util.CSRFCop
rt veyron2.Runtime
verifyURL string
domain string
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch path.Base(r.URL.Path) {
case "auth":
h.auth(w, r)
case "oauth2callback":
h.callback(w, r)
default:
util.HTTPBadRequest(w, r, nil)
}
}
func (h *handler) auth(w http.ResponseWriter, r *http.Request) {
csrf, err := h.csrfCop.NewToken(w, r)
if err != nil {
vlog.Infof("Failed to create CSRF token[%v] for request %#v", err, r)
util.HTTPBadRequest(w, r, fmt.Errorf("Suspected automated request: %v", err))
return
}
http.Redirect(w, r, h.config.AuthCodeURL(csrf), http.StatusFound)
}
func (h *handler) callback(w http.ResponseWriter, r *http.Request) {
if err := h.csrfCop.ValidateToken(r.FormValue("state"), r); err != nil {
vlog.Infof("Invalid CSRF token: %v in request: %#v", err, r)
util.HTTPBadRequest(w, r, fmt.Errorf("Suspected request forgery: %v", err))
return
}
transport := &oauth.Transport{Config: h.config}
t, err := transport.Exchange(r.FormValue("code"))
if err != nil {
util.HTTPBadRequest(w, r, fmt.Errorf("Failed to fetch GoogleIDToken: %v", err))
return
}
// 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.
if t.Extra == nil {
util.HTTPServerError(w, fmt.Errorf("No GoogleIDToken found in exchange"))
return
}
tinfo, err := http.Get(h.verifyURL + t.Extra["id_token"])
if err != nil {
util.HTTPServerError(w, err)
return
}
if tinfo.StatusCode != http.StatusOK {
util.HTTPBadRequest(w, r, fmt.Errorf("failed to decode GoogleIDToken: %q", tinfo.Status))
return
}
var gtoken token
if err = json.NewDecoder(tinfo.Body).Decode(&gtoken); err != nil {
util.HTTPBadRequest(w, r, fmt.Errorf("invalid response from Google's tokeninfo API: %v", err))
return
}
if !gtoken.VerifiedEmail {
util.HTTPBadRequest(w, r, fmt.Errorf("email not verified: %#v", gtoken))
return
}
if gtoken.Issuer != "accounts.google.com" {
util.HTTPBadRequest(w, r, fmt.Errorf("invalid issuer: %v", gtoken.Issuer))
return
}
if gtoken.Audience != h.config.ClientId {
util.HTTPBadRequest(w, r, fmt.Errorf("unexpected audience(%v) in GoogleIDToken", gtoken.Audience))
return
}
minted, err := h.rt.NewIdentity("unblessed")
if err != nil {
util.HTTPServerError(w, fmt.Errorf("Failed to mint new identity: %v", err))
return
}
if len(h.domain) > 0 && !strings.HasSuffix(gtoken.Email, "@"+h.domain) {
util.HTTPServerError(w, fmt.Errorf("Email domain in %s is not allowed", gtoken.Email))
return
}
blessing, err := h.rt.Identity().Bless(minted.PublicID(), gtoken.Email, 24*time.Hour*time.Duration(h.minExpiryDays), nil)
if err != nil {
util.HTTPServerError(w, fmt.Errorf("%v.Bless(%q, %q, %d days, nil) failed: %v", h.rt.Identity(), minted, h.minExpiryDays, err))
return
}
blessed, err := minted.Derive(blessing)
if err != nil {
util.HTTPServerError(w, fmt.Errorf("%v.Derive(%q) failed: %v", minted, blessed, err))
return
}
vlog.Infof("Created new identity: %v", blessed)
util.HTTPSend(w, blessed)
}
// 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"`
}