Files
2026-07-15 07:49:11 -04:00

150 lines
3.8 KiB
Go

package authproxy
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
)
// idToken holds the claims we care about from Google's OpenID Connect
// id_token. The token arrives directly from Google's token endpoint over
// TLS, so per the OIDC spec its signature does not need separate
// verification; we still validate issuer, audience, and expiry.
type idToken struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud flexAud `json:"aud"`
Exp int64 `json:"exp"`
Email string `json:"email"`
EmailVerified flexBool `json:"email_verified"`
Hd string `json:"hd"`
Name string `json:"name"`
}
func (s *Server) exchangeCode(ctx context.Context, code string) (*idToken, error) {
if code == "" {
return nil, fmt.Errorf("missing code parameter")
}
form := url.Values{
"code": {code},
"client_id": {s.cfg.ClientID},
"client_secret": {s.cfg.ClientSecret},
"redirect_uri": {s.redirectURI()},
"grant_type": {"authorization_code"},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("token endpoint: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("token endpoint read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var tr struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("token endpoint response: %w", err)
}
if tr.IDToken == "" {
return nil, fmt.Errorf("token endpoint response missing id_token")
}
return parseIDToken(tr.IDToken)
}
func parseIDToken(raw string) (*idToken, error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("id_token is not a JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("id_token payload: %w", err)
}
var tok idToken
if err := json.Unmarshal(payload, &tok); err != nil {
return nil, fmt.Errorf("id_token claims: %w", err)
}
return &tok, nil
}
func (s *Server) validateIDToken(t *idToken) error {
if t.Iss != "https://accounts.google.com" && t.Iss != "accounts.google.com" {
return fmt.Errorf("unexpected issuer %q", t.Iss)
}
if !t.Aud.contains(s.cfg.ClientID) {
return fmt.Errorf("audience mismatch")
}
if time.Now().Unix() > t.Exp {
return fmt.Errorf("token expired")
}
if t.Email == "" {
return fmt.Errorf("no email claim")
}
if !bool(t.EmailVerified) {
return fmt.Errorf("email %s is not verified", t.Email)
}
return nil
}
// flexAud accepts the JWT aud claim as either a string or an array.
type flexAud []string
func (a *flexAud) UnmarshalJSON(b []byte) error {
var single string
if err := json.Unmarshal(b, &single); err == nil {
*a = flexAud{single}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return err
}
*a = flexAud(many)
return nil
}
func (a flexAud) contains(v string) bool {
return slices.Contains(a, v)
}
// flexBool accepts true, "true", false, or "false" — Google has historically
// been inconsistent about the email_verified type.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.Trim(string(data), `"`)
v, err := strconv.ParseBool(s)
if err != nil {
return fmt.Errorf("invalid bool value %s", data)
}
*b = flexBool(v)
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}