| // 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(>oken); 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"` |
| } |