Raw
  1package google
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto/rand"
  7	"crypto/subtle"
  8	"encoding/base64"
  9	"encoding/json"
 10	"errors"
 11	"fmt"
 12	"net/http"
 13	"net/url"
 14	"slices"
 15	"strings"
 16	"sync"
 17	"time"
 18
 19	"github.com/coreos/go-oidc/v3/oidc"
 20	"golang.org/x/oauth2"
 21
 22	"tug/internal/platform/config"
 23	"tug/internal/platform/spent"
 24	transporthttp "tug/internal/transport/http"
 25	"tug/internal/transport/oidcrp"
 26)
 27
 28const (
 29	TxnCookie     = "__Host-authsvc_google"
 30	TxnInfo       = "tug google txn v1"
 31	MaxTxnTTL     = 10 * time.Minute
 32	MaxStateLen   = 128
 33	LinkAfterSudo = 5 * time.Minute
 34)
 35
 36type Purpose string
 37
 38const (
 39	SignIn Purpose = "signin"
 40	Link   Purpose = "link"
 41)
 42
 43var Scopes = []string{oidc.ScopeOpenID, "email", "profile"}
 44
 45var (
 46	ErrConfig          = errors.New("google: the configuration is incomplete")
 47	ErrBegin           = errors.New("google: a sign-in carries a Dex state and no session, a link a session and no Dex state")
 48	ErrNoTxn           = errors.New("google: no transaction cookie")
 49	ErrTxnMalformed    = errors.New("google: the transaction does not open")
 50	ErrTxnExpired      = errors.New("google: the transaction expired")
 51	ErrTxnSpent        = errors.New("google: the transaction was already spent")
 52	ErrTxnHost         = errors.New("google: the transaction belongs to another host")
 53	ErrState           = errors.New("google: state does not match the transaction")
 54	ErrPurpose         = errors.New("google: the transaction has another purpose")
 55	ErrSession         = errors.New("google: the link transaction names another session")
 56	ErrCancelled       = errors.New("google: the user cancelled at Google")
 57	ErrProvider        = errors.New("google: Google returned an error")
 58	ErrExchange        = errors.New("google: Google refused the code")
 59	ErrUnreachable     = errors.New("google: Google could not be reached")
 60	ErrToken           = errors.New("google: the ID token did not verify")
 61	ErrNonce           = errors.New("google: nonce does not match the transaction")
 62	ErrEmailUnverified = errors.New("google: the Google email is not verified")
 63	ErrDomain          = errors.New("google: the Google account is outside the allowed domain")
 64)
 65
 66func Published() oidc.ProviderConfig {
 67	return oidc.ProviderConfig{
 68		IssuerURL:  "https://accounts.google.com",
 69		AuthURL:    "https://accounts.google.com/o/oauth2/v2/auth",
 70		TokenURL:   "https://oauth2.googleapis.com/token",
 71		JWKSURL:    "https://www.googleapis.com/oauth2/v3/certs",
 72		Algorithms: []string{oidc.RS256},
 73	}
 74}
 75
 76type Config struct {
 77	ClientID     string
 78	ClientSecret config.Secret
 79	RedirectURL  string
 80	AllowedHD    string
 81	Provider     *oidc.ProviderConfig
 82	HTTPClient   *http.Client
 83	MasterKey    config.Secret
 84	TxnTTL       time.Duration
 85	Now          func() time.Time
 86}
 87
 88type Begin struct {
 89	Purpose   Purpose
 90	DexState  string
 91	SessionID string
 92	SudoUntil time.Time
 93}
 94
 95type Txn struct {
 96	Purpose   Purpose
 97	DexState  string
 98	SessionID string
 99}
100
101type Result struct {
102	Sub   string
103	Email string
104	HD    string
105	Txn   Txn
106}
107
108type RP struct {
109	clientID  string
110	secret    config.Secret
111	redirect  string
112	host      string
113	hdMu      sync.RWMutex
114	allowedHD string
115	endpoint  oauth2.Endpoint
116	client    *http.Client
117	verifier  *oidc.IDTokenVerifier
118	seal      *oidcrp.Sealer
119	ttl       time.Duration
120	now       func() time.Time
121	spent     *spent.Set
122}
123
124func New(cfg Config) (*RP, error) {
125	if cfg.ClientID == "" || len(cfg.ClientSecret.Bytes()) == 0 || cfg.HTTPClient == nil {
126		return nil, fmt.Errorf("%w: the client id, the client secret and the HTTP client are required", ErrConfig)
127	}
128	u, err := url.Parse(cfg.RedirectURL)
129	if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path == "" {
130		return nil, fmt.Errorf("%w: the redirect URL %q is not an absolute URL without a query", ErrConfig, cfg.RedirectURL)
131	}
132	ttl := cfg.TxnTTL
133	if ttl == 0 {
134		ttl = MaxTxnTTL
135	}
136	if ttl < 0 || ttl > MaxTxnTTL {
137		return nil, fmt.Errorf("%w: the transaction lifetime is at most ten minutes", ErrConfig)
138	}
139	pc := Published()
140	if cfg.Provider != nil {
141		pc = *cfg.Provider
142	}
143	if pc.IssuerURL == "" || pc.AuthURL == "" || pc.TokenURL == "" || pc.JWKSURL == "" {
144		return nil, fmt.Errorf("%w: the provider needs an issuer, an authorization, a token and a key URL", ErrConfig)
145	}
146	seal, err := oidcrp.NewSealer(cfg.MasterKey.Bytes(), TxnInfo)
147	if err != nil {
148		return nil, fmt.Errorf("%w: %w", ErrConfig, err)
149	}
150	now := cfg.Now
151	if now == nil {
152		now = time.Now
153	}
154	provider := pc.NewProvider(oidc.ClientContext(context.Background(), cfg.HTTPClient))
155	endpoint := provider.Endpoint()
156	endpoint.AuthStyle = oauth2.AuthStyleInParams
157	return &RP{
158		clientID: cfg.ClientID, secret: cfg.ClientSecret, redirect: cfg.RedirectURL,
159		host: transporthttp.CanonicalHost(u.Host), allowedHD: cfg.AllowedHD,
160		endpoint: endpoint, client: cfg.HTTPClient,
161		verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID, Now: now}),
162		seal:     seal, ttl: ttl, now: now, spent: spent.New(),
163	}, nil
164}
165
166func (rp *RP) SetAllowedHD(hd string) {
167	rp.hdMu.Lock()
168	defer rp.hdMu.Unlock()
169	rp.allowedHD = hd
170}
171
172func (rp *RP) AllowedHD() string {
173	return rp.hostedDomain()
174}
175
176func (rp *RP) hostedDomain() string {
177	rp.hdMu.RLock()
178	defer rp.hdMu.RUnlock()
179	return rp.allowedHD
180}
181
182func (rp *RP) oauth() *oauth2.Config {
183	return &oauth2.Config{
184		ClientID: rp.clientID, ClientSecret: string(rp.secret.Bytes()), Endpoint: rp.endpoint,
185		RedirectURL: rp.redirect, Scopes: Scopes,
186	}
187}
188
189type txn struct {
190	State     string  `json:"s"`
191	Nonce     string  `json:"n"`
192	Verifier  string  `json:"v"`
193	Purpose   Purpose `json:"p"`
194	DexState  string  `json:"d,omitempty"`
195	SessionID string  `json:"i,omitempty"`
196	Host      string  `json:"h"`
197	Expiry    int64   `json:"x"`
198}
199
200func (t txn) coherent() bool {
201	if t.State == "" || t.Nonce == "" || t.Verifier == "" || t.Host == "" || len(t.DexState) > MaxStateLen || len(t.SessionID) > MaxStateLen {
202		return false
203	}
204	switch t.Purpose {
205	case SignIn:
206		return t.DexState != "" && t.SessionID == ""
207	case Link:
208		return t.SessionID != "" && t.DexState == ""
209	}
210	return false
211}
212
213func random() string {
214	b := make([]byte, 32)
215	rand.Read(b)
216	return base64.RawURLEncoding.EncodeToString(b)
217}
218
219func cookie(value string, maxAge int) *http.Cookie {
220	return &http.Cookie{Name: TxnCookie, Value: value, Path: "/", MaxAge: maxAge, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}
221}
222
223func (rp *RP) Start(w http.ResponseWriter, r *http.Request, b Begin) (string, error) {
224	host := transporthttp.CanonicalHost(r.Host)
225	if host != rp.host {
226		return "", ErrTxnHost
227	}
228	now := rp.now()
229	expiry := now.Add(rp.ttl)
230	if b.Purpose == Link {
231		if end := b.SudoUntil.Add(LinkAfterSudo); end.Before(expiry) {
232			expiry = end
233		}
234	}
235	t := txn{
236		State: random(), Nonce: random(), Verifier: oauth2.GenerateVerifier(),
237		Purpose: b.Purpose, DexState: b.DexState, SessionID: b.SessionID,
238		Host: host, Expiry: expiry.Unix(),
239	}
240	if !t.coherent() || !expiry.After(now) {
241		return "", ErrBegin
242	}
243	raw, err := json.Marshal(t)
244	if err != nil {
245		return "", err
246	}
247	opts := []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(t.Verifier), oidc.Nonce(t.Nonce)}
248	if t.Purpose == Link {
249		opts = append(opts, oauth2.SetAuthURLParam("prompt", "select_account"))
250	}
251	http.SetCookie(w, cookie(rp.seal.Seal(TxnCookie, raw), int(expiry.Sub(now)/time.Second)))
252	return rp.oauth().AuthCodeURL(t.State, opts...), nil
253}
254
255func (rp *RP) open(value string) (txn, error) {
256	raw, err := rp.seal.Open(TxnCookie, value)
257	if err != nil {
258		return txn{}, ErrTxnMalformed
259	}
260	dec := json.NewDecoder(bytes.NewReader(raw))
261	dec.DisallowUnknownFields()
262	var t txn
263	if err := dec.Decode(&t); err != nil || dec.More() || !t.coherent() {
264		return txn{}, ErrTxnMalformed
265	}
266	return t, nil
267}
268
269type claims struct {
270	Email         string `json:"email"`
271	EmailVerified any    `json:"email_verified"`
272	HD            string `json:"hd"`
273	AZP           string `json:"azp"`
274}
275
276func (rp *RP) Finish(w http.ResponseWriter, r *http.Request, session string, accept ...Purpose) (Result, error) {
277	w.Header().Set("Cache-Control", "no-store")
278	host := transporthttp.CanonicalHost(r.Host)
279	ck, err := r.Cookie(TxnCookie)
280	if err != nil {
281		return Result{}, ErrNoTxn
282	}
283	t, err := rp.open(ck.Value)
284	if err != nil {
285		return Result{}, err
286	}
287	expiry := time.Unix(t.Expiry, 0)
288	if !rp.now().Before(expiry) {
289		http.SetCookie(w, cookie("", -1))
290		return Result{}, ErrTxnExpired
291	}
292	if t.Host != host || host != rp.host {
293		return Result{}, ErrTxnHost
294	}
295	q := r.URL.Query()
296	if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(t.State)) != 1 {
297		return Result{}, ErrState
298	}
299	http.SetCookie(w, cookie("", -1))
300	if !rp.spent.Spend(t.State, expiry, rp.now()) {
301		return Result{}, ErrTxnSpent
302	}
303	sealed := Result{Txn: Txn{Purpose: t.Purpose, DexState: t.DexState, SessionID: t.SessionID}}
304	if !slices.Contains(accept, t.Purpose) {
305		return sealed, ErrPurpose
306	}
307	if t.Purpose == Link && subtle.ConstantTimeCompare([]byte(session), []byte(t.SessionID)) != 1 {
308		return sealed, ErrSession
309	}
310	switch e := q.Get("error"); {
311	case e == "access_denied":
312		return sealed, ErrCancelled
313	case e != "" || q.Get("code") == "":
314		return sealed, ErrProvider
315	}
316	ctx := oidc.ClientContext(r.Context(), rp.client)
317	tok, err := rp.oauth().Exchange(ctx, q.Get("code"), oauth2.VerifierOption(t.Verifier))
318	var refused *oauth2.RetrieveError
319	switch {
320	case errors.As(err, &refused):
321		return sealed, fmt.Errorf("%w: %s", ErrExchange, refused.ErrorCode)
322	case err != nil:
323		return sealed, fmt.Errorf("%w: %w", ErrUnreachable, err)
324	}
325	rawID, _ := tok.Extra("id_token").(string)
326	if rawID == "" {
327		return sealed, fmt.Errorf("%w: no ID token", ErrExchange)
328	}
329	idt, err := rp.verifier.Verify(ctx, rawID)
330	if err != nil {
331		return sealed, fmt.Errorf("%w: %w", ErrToken, err)
332	}
333	if len(idt.Audience) != 1 {
334		return sealed, fmt.Errorf("%w: the token names more than one audience", ErrToken)
335	}
336	if subtle.ConstantTimeCompare([]byte(idt.Nonce), []byte(t.Nonce)) != 1 {
337		return sealed, ErrNonce
338	}
339	var c claims
340	if err := idt.Claims(&c); err != nil || idt.Subject == "" || c.Email == "" {
341		return sealed, fmt.Errorf("%w: the token lacks a subject or an email", ErrToken)
342	}
343	if c.AZP != "" && c.AZP != rp.clientID {
344		return sealed, fmt.Errorf("%w: the token was issued to another authorized party", ErrToken)
345	}
346	policy := Result{Sub: idt.Subject, Email: strings.ToLower(c.Email), Txn: sealed.Txn}
347	if verified, _ := c.EmailVerified.(bool); !verified {
348		return policy, ErrEmailUnverified
349	}
350	if hd := rp.hostedDomain(); hd != "" && !strings.EqualFold(c.HD, hd) {
351		return policy, ErrDomain
352	}
353	return Result{Sub: policy.Sub, Email: policy.Email, HD: c.HD, Txn: sealed.Txn}, nil
354}