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}