package google import ( "bytes" "context" "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "slices" "strings" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "tug/internal/platform/config" "tug/internal/platform/spent" transporthttp "tug/internal/transport/http" "tug/internal/transport/oidcrp" ) const ( TxnCookie = "__Host-authsvc_google" TxnInfo = "tug google txn v1" MaxTxnTTL = 10 * time.Minute MaxStateLen = 128 LinkAfterSudo = 5 * time.Minute ) type Purpose string const ( SignIn Purpose = "signin" Link Purpose = "link" ) var Scopes = []string{oidc.ScopeOpenID, "email", "profile"} var ( ErrConfig = errors.New("google: the configuration is incomplete") ErrBegin = errors.New("google: a sign-in carries a Dex state and no session, a link a session and no Dex state") ErrNoTxn = errors.New("google: no transaction cookie") ErrTxnMalformed = errors.New("google: the transaction does not open") ErrTxnExpired = errors.New("google: the transaction expired") ErrTxnSpent = errors.New("google: the transaction was already spent") ErrTxnHost = errors.New("google: the transaction belongs to another host") ErrState = errors.New("google: state does not match the transaction") ErrPurpose = errors.New("google: the transaction has another purpose") ErrSession = errors.New("google: the link transaction names another session") ErrCancelled = errors.New("google: the user cancelled at Google") ErrProvider = errors.New("google: Google returned an error") ErrExchange = errors.New("google: Google refused the code") ErrUnreachable = errors.New("google: Google could not be reached") ErrToken = errors.New("google: the ID token did not verify") ErrNonce = errors.New("google: nonce does not match the transaction") ErrEmailUnverified = errors.New("google: the Google email is not verified") ErrDomain = errors.New("google: the Google account is outside the allowed domain") ) func Published() oidc.ProviderConfig { return oidc.ProviderConfig{ IssuerURL: "https://accounts.google.com", AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", TokenURL: "https://oauth2.googleapis.com/token", JWKSURL: "https://www.googleapis.com/oauth2/v3/certs", Algorithms: []string{oidc.RS256}, } } type Config struct { ClientID string ClientSecret config.Secret RedirectURL string AllowedHD string Provider *oidc.ProviderConfig HTTPClient *http.Client MasterKey config.Secret TxnTTL time.Duration Now func() time.Time } type Begin struct { Purpose Purpose DexState string SessionID string SudoUntil time.Time } type Txn struct { Purpose Purpose DexState string SessionID string } type Result struct { Sub string Email string HD string Txn Txn } type RP struct { clientID string secret config.Secret redirect string host string hdMu sync.RWMutex allowedHD string endpoint oauth2.Endpoint client *http.Client verifier *oidc.IDTokenVerifier seal *oidcrp.Sealer ttl time.Duration now func() time.Time spent *spent.Set } func New(cfg Config) (*RP, error) { if cfg.ClientID == "" || len(cfg.ClientSecret.Bytes()) == 0 || cfg.HTTPClient == nil { return nil, fmt.Errorf("%w: the client id, the client secret and the HTTP client are required", ErrConfig) } u, err := url.Parse(cfg.RedirectURL) if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path == "" { return nil, fmt.Errorf("%w: the redirect URL %q is not an absolute URL without a query", ErrConfig, cfg.RedirectURL) } ttl := cfg.TxnTTL if ttl == 0 { ttl = MaxTxnTTL } if ttl < 0 || ttl > MaxTxnTTL { return nil, fmt.Errorf("%w: the transaction lifetime is at most ten minutes", ErrConfig) } pc := Published() if cfg.Provider != nil { pc = *cfg.Provider } if pc.IssuerURL == "" || pc.AuthURL == "" || pc.TokenURL == "" || pc.JWKSURL == "" { return nil, fmt.Errorf("%w: the provider needs an issuer, an authorization, a token and a key URL", ErrConfig) } seal, err := oidcrp.NewSealer(cfg.MasterKey.Bytes(), TxnInfo) if err != nil { return nil, fmt.Errorf("%w: %w", ErrConfig, err) } now := cfg.Now if now == nil { now = time.Now } provider := pc.NewProvider(oidc.ClientContext(context.Background(), cfg.HTTPClient)) endpoint := provider.Endpoint() endpoint.AuthStyle = oauth2.AuthStyleInParams return &RP{ clientID: cfg.ClientID, secret: cfg.ClientSecret, redirect: cfg.RedirectURL, host: transporthttp.CanonicalHost(u.Host), allowedHD: cfg.AllowedHD, endpoint: endpoint, client: cfg.HTTPClient, verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID, Now: now}), seal: seal, ttl: ttl, now: now, spent: spent.New(), }, nil } func (rp *RP) SetAllowedHD(hd string) { rp.hdMu.Lock() defer rp.hdMu.Unlock() rp.allowedHD = hd } func (rp *RP) AllowedHD() string { return rp.hostedDomain() } func (rp *RP) hostedDomain() string { rp.hdMu.RLock() defer rp.hdMu.RUnlock() return rp.allowedHD } func (rp *RP) oauth() *oauth2.Config { return &oauth2.Config{ ClientID: rp.clientID, ClientSecret: string(rp.secret.Bytes()), Endpoint: rp.endpoint, RedirectURL: rp.redirect, Scopes: Scopes, } } type txn struct { State string `json:"s"` Nonce string `json:"n"` Verifier string `json:"v"` Purpose Purpose `json:"p"` DexState string `json:"d,omitempty"` SessionID string `json:"i,omitempty"` Host string `json:"h"` Expiry int64 `json:"x"` } func (t txn) coherent() bool { if t.State == "" || t.Nonce == "" || t.Verifier == "" || t.Host == "" || len(t.DexState) > MaxStateLen || len(t.SessionID) > MaxStateLen { return false } switch t.Purpose { case SignIn: return t.DexState != "" && t.SessionID == "" case Link: return t.SessionID != "" && t.DexState == "" } return false } func random() string { b := make([]byte, 32) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func cookie(value string, maxAge int) *http.Cookie { return &http.Cookie{Name: TxnCookie, Value: value, Path: "/", MaxAge: maxAge, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode} } func (rp *RP) Start(w http.ResponseWriter, r *http.Request, b Begin) (string, error) { host := transporthttp.CanonicalHost(r.Host) if host != rp.host { return "", ErrTxnHost } now := rp.now() expiry := now.Add(rp.ttl) if b.Purpose == Link { if end := b.SudoUntil.Add(LinkAfterSudo); end.Before(expiry) { expiry = end } } t := txn{ State: random(), Nonce: random(), Verifier: oauth2.GenerateVerifier(), Purpose: b.Purpose, DexState: b.DexState, SessionID: b.SessionID, Host: host, Expiry: expiry.Unix(), } if !t.coherent() || !expiry.After(now) { return "", ErrBegin } raw, err := json.Marshal(t) if err != nil { return "", err } opts := []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(t.Verifier), oidc.Nonce(t.Nonce)} if t.Purpose == Link { opts = append(opts, oauth2.SetAuthURLParam("prompt", "select_account")) } http.SetCookie(w, cookie(rp.seal.Seal(TxnCookie, raw), int(expiry.Sub(now)/time.Second))) return rp.oauth().AuthCodeURL(t.State, opts...), nil } func (rp *RP) open(value string) (txn, error) { raw, err := rp.seal.Open(TxnCookie, value) if err != nil { return txn{}, ErrTxnMalformed } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() var t txn if err := dec.Decode(&t); err != nil || dec.More() || !t.coherent() { return txn{}, ErrTxnMalformed } return t, nil } type claims struct { Email string `json:"email"` EmailVerified any `json:"email_verified"` HD string `json:"hd"` AZP string `json:"azp"` } func (rp *RP) Finish(w http.ResponseWriter, r *http.Request, session string, accept ...Purpose) (Result, error) { w.Header().Set("Cache-Control", "no-store") host := transporthttp.CanonicalHost(r.Host) ck, err := r.Cookie(TxnCookie) if err != nil { return Result{}, ErrNoTxn } t, err := rp.open(ck.Value) if err != nil { return Result{}, err } expiry := time.Unix(t.Expiry, 0) if !rp.now().Before(expiry) { http.SetCookie(w, cookie("", -1)) return Result{}, ErrTxnExpired } if t.Host != host || host != rp.host { return Result{}, ErrTxnHost } q := r.URL.Query() if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(t.State)) != 1 { return Result{}, ErrState } http.SetCookie(w, cookie("", -1)) if !rp.spent.Spend(t.State, expiry, rp.now()) { return Result{}, ErrTxnSpent } sealed := Result{Txn: Txn{Purpose: t.Purpose, DexState: t.DexState, SessionID: t.SessionID}} if !slices.Contains(accept, t.Purpose) { return sealed, ErrPurpose } if t.Purpose == Link && subtle.ConstantTimeCompare([]byte(session), []byte(t.SessionID)) != 1 { return sealed, ErrSession } switch e := q.Get("error"); { case e == "access_denied": return sealed, ErrCancelled case e != "" || q.Get("code") == "": return sealed, ErrProvider } ctx := oidc.ClientContext(r.Context(), rp.client) tok, err := rp.oauth().Exchange(ctx, q.Get("code"), oauth2.VerifierOption(t.Verifier)) var refused *oauth2.RetrieveError switch { case errors.As(err, &refused): return sealed, fmt.Errorf("%w: %s", ErrExchange, refused.ErrorCode) case err != nil: return sealed, fmt.Errorf("%w: %w", ErrUnreachable, err) } rawID, _ := tok.Extra("id_token").(string) if rawID == "" { return sealed, fmt.Errorf("%w: no ID token", ErrExchange) } idt, err := rp.verifier.Verify(ctx, rawID) if err != nil { return sealed, fmt.Errorf("%w: %w", ErrToken, err) } if len(idt.Audience) != 1 { return sealed, fmt.Errorf("%w: the token names more than one audience", ErrToken) } if subtle.ConstantTimeCompare([]byte(idt.Nonce), []byte(t.Nonce)) != 1 { return sealed, ErrNonce } var c claims if err := idt.Claims(&c); err != nil || idt.Subject == "" || c.Email == "" { return sealed, fmt.Errorf("%w: the token lacks a subject or an email", ErrToken) } if c.AZP != "" && c.AZP != rp.clientID { return sealed, fmt.Errorf("%w: the token was issued to another authorized party", ErrToken) } policy := Result{Sub: idt.Subject, Email: strings.ToLower(c.Email), Txn: sealed.Txn} if verified, _ := c.EmailVerified.(bool); !verified { return policy, ErrEmailUnverified } if hd := rp.hostedDomain(); hd != "" && !strings.EqualFold(c.HD, hd) { return policy, ErrDomain } return Result{Sub: policy.Sub, Email: policy.Email, HD: c.HD, Txn: sealed.Txn}, nil }