150 lines
3.8 KiB
Go
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] + "..."
|
|
}
|