Initial commit.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package authproxy
|
||||
|
||||
// stateClaims rides through Google's OAuth flow as the `state` parameter.
|
||||
// It remembers which app host the user was visiting and where to send them
|
||||
// after sign-in.
|
||||
type stateClaims struct {
|
||||
Host string `json:"h"` // app host the user was visiting
|
||||
RD string `json:"r"` // relative path to return to
|
||||
Proto string `json:"p"` // http or https
|
||||
Nonce string `json:"n"`
|
||||
Exp int64 `json:"e"` // unix seconds
|
||||
}
|
||||
|
||||
// handoffClaims is the short-lived token the callback (on the auth host)
|
||||
// hands to the destination app host so it can mint a session cookie on its
|
||||
// own domain.
|
||||
type handoffClaims struct {
|
||||
Email string `json:"em"`
|
||||
User string `json:"u"` // Google account id (sub)
|
||||
Name string `json:"na"`
|
||||
Host string `json:"h"`
|
||||
RD string `json:"r"`
|
||||
Proto string `json:"p"`
|
||||
Nonce string `json:"n"`
|
||||
Exp int64 `json:"e"`
|
||||
}
|
||||
|
||||
// sessionClaims is the content of the session cookie. Cookies are host-only
|
||||
// (no Domain attribute) and additionally pinned to the host they were minted
|
||||
// for.
|
||||
type sessionClaims struct {
|
||||
Email string `json:"em"`
|
||||
User string `json:"u"`
|
||||
Name string `json:"na"`
|
||||
Host string `json:"h"`
|
||||
Exp int64 `json:"e"`
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
googleAuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
googleTokenURL = "https://oauth2.googleapis.com/token"
|
||||
|
||||
// RoutePrefix is the URL namespace the proxy owns on every protected
|
||||
// host. It must match the locations the plugin writes into nginx.
|
||||
RoutePrefix = "/_google-auth"
|
||||
)
|
||||
|
||||
// Config holds everything the auth service needs. It is normally populated
|
||||
// from GOOGLE_AUTH_* environment variables written by the dokku plugin.
|
||||
type Config struct {
|
||||
// ClientID / ClientSecret identify the Google OAuth client.
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
// CookieSecret is the key material used to encrypt and authenticate
|
||||
// sessions, state, and hand-off tokens. Must be at least 32 characters.
|
||||
CookieSecret string
|
||||
|
||||
// AuthHost is the one hostname whose /_google-auth/callback is
|
||||
// registered with Google as the redirect URI. It must be a domain that
|
||||
// routes to this service through nginx (i.e. a domain of any app that
|
||||
// has google-auth enabled).
|
||||
AuthHost string
|
||||
|
||||
// AllowedDomains / AllowedEmails control who may sign in. An email is
|
||||
// accepted if its domain is in AllowedDomains or the full address is in
|
||||
// AllowedEmails.
|
||||
AllowedDomains []string
|
||||
AllowedEmails []string
|
||||
|
||||
CookieName string
|
||||
SessionTTL time.Duration
|
||||
ListenAddr string
|
||||
|
||||
// AllowInsecure permits plain-HTTP flows (no Secure cookie flag, http
|
||||
// redirect URI). Only for local testing; Google requires HTTPS redirect
|
||||
// URIs in production anyway.
|
||||
AllowInsecure bool
|
||||
|
||||
// AuthorizeURL / TokenURL are overridable for tests.
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds a Config from GOOGLE_AUTH_* environment variables and
|
||||
// validates it.
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
cfg := Config{
|
||||
ClientID: os.Getenv("GOOGLE_AUTH_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GOOGLE_AUTH_CLIENT_SECRET"),
|
||||
CookieSecret: os.Getenv("GOOGLE_AUTH_COOKIE_SECRET"),
|
||||
AuthHost: normalizeHost(os.Getenv("GOOGLE_AUTH_AUTH_HOST")),
|
||||
CookieName: envOr("GOOGLE_AUTH_COOKIE_NAME", "_google_auth"),
|
||||
ListenAddr: envOr("GOOGLE_AUTH_LISTEN", ":2999"),
|
||||
AuthorizeURL: envOr("GOOGLE_AUTH_AUTHORIZE_URL", googleAuthorizeURL),
|
||||
TokenURL: envOr("GOOGLE_AUTH_TOKEN_URL", googleTokenURL),
|
||||
AllowInsecure: os.Getenv("GOOGLE_AUTH_ALLOW_INSECURE") == "true",
|
||||
}
|
||||
|
||||
ttlRaw := envOr("GOOGLE_AUTH_SESSION_TTL", "24h")
|
||||
ttl, err := time.ParseDuration(ttlRaw)
|
||||
if err != nil || ttl <= 0 {
|
||||
return cfg, fmt.Errorf("GOOGLE_AUTH_SESSION_TTL %q is not a valid positive duration", ttlRaw)
|
||||
}
|
||||
cfg.SessionTTL = ttl
|
||||
|
||||
for _, d := range splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_DOMAINS")) {
|
||||
cfg.AllowedDomains = append(cfg.AllowedDomains, strings.TrimPrefix(d, "@"))
|
||||
}
|
||||
cfg.AllowedEmails = splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_EMAILS"))
|
||||
|
||||
return cfg, cfg.validate()
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if c.ClientID == "" {
|
||||
return fmt.Errorf("GOOGLE_AUTH_CLIENT_ID is required")
|
||||
}
|
||||
if c.ClientSecret == "" {
|
||||
return fmt.Errorf("GOOGLE_AUTH_CLIENT_SECRET is required")
|
||||
}
|
||||
if len(c.CookieSecret) < 32 {
|
||||
return fmt.Errorf("GOOGLE_AUTH_COOKIE_SECRET must be at least 32 characters")
|
||||
}
|
||||
if c.AuthHost == "" {
|
||||
return fmt.Errorf("GOOGLE_AUTH_AUTH_HOST is required")
|
||||
}
|
||||
if strings.ContainsAny(c.AuthHost, "/:") {
|
||||
return fmt.Errorf("GOOGLE_AUTH_AUTH_HOST must be a bare hostname, got %q", c.AuthHost)
|
||||
}
|
||||
if len(c.AllowedDomains) == 0 && len(c.AllowedEmails) == 0 {
|
||||
return fmt.Errorf("at least one of GOOGLE_AUTH_ALLOWED_DOMAINS or GOOGLE_AUTH_ALLOWED_EMAILS must be set")
|
||||
}
|
||||
if c.CookieName == "" {
|
||||
return fmt.Errorf("GOOGLE_AUTH_COOKIE_NAME must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// splitList splits a comma- or whitespace-separated list, lowercasing and
|
||||
// trimming each entry.
|
||||
func splitList(raw string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
||||
}) {
|
||||
part = strings.ToLower(strings.TrimSpace(part))
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeHost(raw string) string {
|
||||
h := strings.ToLower(strings.TrimSpace(raw))
|
||||
h = strings.TrimPrefix(h, "https://")
|
||||
h = strings.TrimPrefix(h, "http://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validConfig() Config {
|
||||
return Config{
|
||||
ClientID: "cid",
|
||||
ClientSecret: "sec",
|
||||
CookieSecret: strings.Repeat("x", 32),
|
||||
AuthHost: "auth.example.com",
|
||||
AllowedDomains: []string{"signal.org"},
|
||||
CookieName: "_google_auth",
|
||||
SessionTTL: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
if err := validConfig().validate(); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
|
||||
mutations := map[string]func(*Config){
|
||||
"missing client id": func(c *Config) { c.ClientID = "" },
|
||||
"missing client secret": func(c *Config) { c.ClientSecret = "" },
|
||||
"short cookie secret": func(c *Config) { c.CookieSecret = "short" },
|
||||
"missing auth host": func(c *Config) { c.AuthHost = "" },
|
||||
"auth host with scheme": func(c *Config) { c.AuthHost = "https://auth.example.com" },
|
||||
"no allow rules": func(c *Config) { c.AllowedDomains = nil; c.AllowedEmails = nil },
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
cfg := validConfig()
|
||||
mutate(&cfg)
|
||||
if err := cfg.validate(); err == nil {
|
||||
t.Errorf("%s: expected validation error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("GOOGLE_AUTH_CLIENT_ID", "cid")
|
||||
t.Setenv("GOOGLE_AUTH_CLIENT_SECRET", "sec")
|
||||
t.Setenv("GOOGLE_AUTH_COOKIE_SECRET", strings.Repeat("x", 32))
|
||||
t.Setenv("GOOGLE_AUTH_AUTH_HOST", "HTTPS://Auth.Example.com/")
|
||||
t.Setenv("GOOGLE_AUTH_ALLOWED_DOMAINS", "Signal.org, @example.com")
|
||||
t.Setenv("GOOGLE_AUTH_ALLOWED_EMAILS", "Guest@Partner.com")
|
||||
t.Setenv("GOOGLE_AUTH_SESSION_TTL", "48h")
|
||||
|
||||
cfg, err := ConfigFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AuthHost != "auth.example.com" {
|
||||
t.Errorf("AuthHost = %q", cfg.AuthHost)
|
||||
}
|
||||
if len(cfg.AllowedDomains) != 2 || cfg.AllowedDomains[0] != "signal.org" || cfg.AllowedDomains[1] != "example.com" {
|
||||
t.Errorf("AllowedDomains = %v", cfg.AllowedDomains)
|
||||
}
|
||||
if len(cfg.AllowedEmails) != 1 || cfg.AllowedEmails[0] != "guest@partner.com" {
|
||||
t.Errorf("AllowedEmails = %v", cfg.AllowedEmails)
|
||||
}
|
||||
if cfg.SessionTTL != 48*time.Hour {
|
||||
t.Errorf("SessionTTL = %v", cfg.SessionTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromEnvRejectsBadTTL(t *testing.T) {
|
||||
t.Setenv("GOOGLE_AUTH_CLIENT_ID", "cid")
|
||||
t.Setenv("GOOGLE_AUTH_CLIENT_SECRET", "sec")
|
||||
t.Setenv("GOOGLE_AUTH_COOKIE_SECRET", strings.Repeat("x", 32))
|
||||
t.Setenv("GOOGLE_AUTH_AUTH_HOST", "auth.example.com")
|
||||
t.Setenv("GOOGLE_AUTH_ALLOWED_DOMAINS", "signal.org")
|
||||
t.Setenv("GOOGLE_AUTH_SESSION_TTL", "2 fortnights")
|
||||
if _, err := ConfigFromEnv(); err == nil {
|
||||
t.Fatal("expected error for bad TTL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// box seals and opens small JSON payloads with AES-256-GCM. The purpose
|
||||
// string is bound in as additional authenticated data so a token minted for
|
||||
// one use (e.g. OAuth state) can never be replayed as another (e.g. a
|
||||
// session cookie).
|
||||
type box struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
func newBox(secret string) (*box, error) {
|
||||
key := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &box{aead: aead}, nil
|
||||
}
|
||||
|
||||
func (b *box) seal(purpose string, v any) (string, error) {
|
||||
plain, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := b.aead.Seal(nonce, nonce, plain, []byte(purpose))
|
||||
return base64.RawURLEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func (b *box) open(purpose, token string, v any) error {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("malformed token: %w", err)
|
||||
}
|
||||
ns := b.aead.NonceSize()
|
||||
if len(raw) <= ns {
|
||||
return fmt.Errorf("malformed token: too short")
|
||||
}
|
||||
plain, err := b.aead.Open(nil, raw[:ns], raw[ns:], []byte(purpose))
|
||||
if err != nil {
|
||||
return fmt.Errorf("token failed authentication: %w", err)
|
||||
}
|
||||
return json.Unmarshal(plain, v)
|
||||
}
|
||||
|
||||
func randToken() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
|
||||
panic(err) // crypto/rand failure is unrecoverable
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBoxRoundtrip(t *testing.T) {
|
||||
b, err := newBox(strings.Repeat("s", 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := stateClaims{Host: "app.example.com", RD: "/x?a=1&b=2", Proto: "https", Nonce: "n", Exp: 123}
|
||||
tok, err := b.seal("state", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out stateClaims
|
||||
if err := b.open("state", tok, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("roundtrip mismatch: %+v != %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxRejectsWrongPurpose(t *testing.T) {
|
||||
b, _ := newBox(strings.Repeat("s", 32))
|
||||
tok, _ := b.seal("state", stateClaims{Host: "a"})
|
||||
var out stateClaims
|
||||
if err := b.open("session", tok, &out); err == nil {
|
||||
t.Fatal("expected purpose mismatch to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxRejectsTampering(t *testing.T) {
|
||||
b, _ := newBox(strings.Repeat("s", 32))
|
||||
tok, _ := b.seal("state", stateClaims{Host: "a"})
|
||||
raw, _ := base64.RawURLEncoding.DecodeString(tok)
|
||||
raw[len(raw)-1] ^= 0x01
|
||||
tampered := base64.RawURLEncoding.EncodeToString(raw)
|
||||
var out stateClaims
|
||||
if err := b.open("state", tampered, &out); err == nil {
|
||||
t.Fatal("expected tampered token to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxRejectsWrongKey(t *testing.T) {
|
||||
b1, _ := newBox(strings.Repeat("a", 32))
|
||||
b2, _ := newBox(strings.Repeat("b", 32))
|
||||
tok, _ := b1.seal("state", stateClaims{Host: "a"})
|
||||
var out stateClaims
|
||||
if err := b2.open("state", tok, &out); err == nil {
|
||||
t.Fatal("expected wrong key to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testClientID = "test-client-id.apps.googleusercontent.com"
|
||||
appHost = "myapp.example.com"
|
||||
authHost = "auth.example.com"
|
||||
)
|
||||
|
||||
// fakeGoogle stands in for Google's token endpoint. The returned id_token
|
||||
// carries the given claims; signature contents don't matter because the
|
||||
// token arrives over a direct TLS channel in production.
|
||||
func fakeGoogle(t *testing.T, claims map[string]any) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("parse form: %v", err)
|
||||
}
|
||||
if r.FormValue("grant_type") != "authorization_code" {
|
||||
t.Errorf("unexpected grant_type %q", r.FormValue("grant_type"))
|
||||
}
|
||||
if r.FormValue("code") != "good-code" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error":"invalid_grant"}`)
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(claims)
|
||||
idt := b64(`{"alg":"RS256","typ":"JWT"}`) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(payload) + "." + b64("sig")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"access_token":"at","id_token":%q}`, idt)
|
||||
}))
|
||||
}
|
||||
|
||||
func b64(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func goodClaims() map[string]any {
|
||||
return map[string]any{
|
||||
"iss": "https://accounts.google.com",
|
||||
"aud": testClientID,
|
||||
"sub": "1234567890",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"email": "Greyson@Signal.org",
|
||||
"email_verified": true,
|
||||
"hd": "signal.org",
|
||||
"name": "Greyson",
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, tokenURL string) *Server {
|
||||
t.Helper()
|
||||
cfg := Config{
|
||||
ClientID: testClientID,
|
||||
ClientSecret: "test-secret",
|
||||
CookieSecret: strings.Repeat("k", 32),
|
||||
AuthHost: authHost,
|
||||
AllowedDomains: []string{"signal.org"},
|
||||
CookieName: "_google_auth",
|
||||
SessionTTL: time.Hour,
|
||||
ListenAddr: ":0",
|
||||
AuthorizeURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: tokenURL,
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
t.Fatalf("test config invalid: %v", err)
|
||||
}
|
||||
s, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func do(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestFullFlow walks the whole journey: unauthenticated verify, start,
|
||||
// callback on the auth host, finish on the app host, authenticated verify.
|
||||
func TestFullFlow(t *testing.T) {
|
||||
google := fakeGoogle(t, goodClaims())
|
||||
defer google.Close()
|
||||
s := newTestServer(t, google.URL)
|
||||
h := s.Routes()
|
||||
|
||||
// 1. verify without a cookie → 401
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
|
||||
if w := do(h, r); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("verify without cookie: got %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 2. start (as nginx would proxy it) → 302 to Google
|
||||
origURI := "/secret/page?a=1&b=2"
|
||||
r = httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/start", nil)
|
||||
r.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
r.Header.Set("X-Auth-Request-Redirect", origURI)
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
w := do(h, r)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("start: got %d, want 302", w.Code)
|
||||
}
|
||||
loc, err := url.Parse(w.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loc.Host != "accounts.google.com" {
|
||||
t.Fatalf("start redirected to %s, want accounts.google.com", loc.Host)
|
||||
}
|
||||
if got := loc.Query().Get("client_id"); got != testClientID {
|
||||
t.Fatalf("client_id = %q", got)
|
||||
}
|
||||
if got := loc.Query().Get("redirect_uri"); got != "https://"+authHost+RoutePrefix+"/callback" {
|
||||
t.Fatalf("redirect_uri = %q", got)
|
||||
}
|
||||
if got := loc.Query().Get("hd"); got != "signal.org" {
|
||||
t.Fatalf("hd = %q", got)
|
||||
}
|
||||
state := loc.Query().Get("state")
|
||||
if state == "" {
|
||||
t.Fatal("no state in Google redirect")
|
||||
}
|
||||
|
||||
// 3. callback on the auth host → 302 to finish on the app host
|
||||
r = httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
|
||||
w = do(h, r)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("callback: got %d, want 302; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
finishURL, err := url.Parse(w.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if finishURL.Scheme != "https" || finishURL.Host != appHost || finishURL.Path != RoutePrefix+"/finish" {
|
||||
t.Fatalf("callback redirected to %s", finishURL)
|
||||
}
|
||||
|
||||
// 4. finish on the app host → session cookie + redirect to original URI
|
||||
r = httptest.NewRequest("GET", finishURL.String(), nil)
|
||||
w = do(h, r)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("finish: got %d, want 302; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != origURI {
|
||||
t.Fatalf("finish redirected to %q, want %q", got, origURI)
|
||||
}
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != "_google_auth" {
|
||||
t.Fatalf("expected one session cookie, got %v", cookies)
|
||||
}
|
||||
sessionCookie := cookies[0]
|
||||
if !sessionCookie.Secure || !sessionCookie.HttpOnly {
|
||||
t.Fatalf("session cookie should be Secure+HttpOnly: %+v", sessionCookie)
|
||||
}
|
||||
|
||||
// 5. verify with the cookie → 200 with identity headers
|
||||
r = httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
|
||||
r.AddCookie(sessionCookie)
|
||||
w = do(h, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("verify with cookie: got %d, want 200", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("X-Auth-Request-Email"); got != "greyson@signal.org" {
|
||||
t.Fatalf("X-Auth-Request-Email = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Auth-Request-User"); got != "1234567890" {
|
||||
t.Fatalf("X-Auth-Request-User = %q", got)
|
||||
}
|
||||
|
||||
// 6. the same cookie must NOT work on a different host
|
||||
r = httptest.NewRequest("GET", "http://other.example.com"+RoutePrefix+"/verify", nil)
|
||||
r.AddCookie(sessionCookie)
|
||||
if w := do(h, r); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("verify on wrong host: got %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 7. replaying the finish token must fail (single-use nonce)
|
||||
r = httptest.NewRequest("GET", finishURL.String(), nil)
|
||||
if w := do(h, r); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("finish replay: got %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// 8. logout clears the cookie
|
||||
r = httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/logout", nil)
|
||||
r.AddCookie(sessionCookie)
|
||||
w = do(h, r)
|
||||
found := false
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "_google_auth" && c.MaxAge < 0 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("logout did not clear the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsDisallowedDomain(t *testing.T) {
|
||||
claims := goodClaims()
|
||||
claims["email"] = "intruder@evil.com"
|
||||
claims["hd"] = "evil.com"
|
||||
google := fakeGoogle(t, claims)
|
||||
defer google.Close()
|
||||
s := newTestServer(t, google.URL)
|
||||
h := s.Routes()
|
||||
|
||||
state := mustState(t, s, appHost, "/")
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
|
||||
w := do(h, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "not allowed") {
|
||||
t.Fatalf("body should explain denial: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsWrongAudience(t *testing.T) {
|
||||
claims := goodClaims()
|
||||
claims["aud"] = "someone-else"
|
||||
google := fakeGoogle(t, claims)
|
||||
defer google.Close()
|
||||
s := newTestServer(t, google.URL)
|
||||
|
||||
state := mustState(t, s, appHost, "/")
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
|
||||
if w := do(s.Routes(), r); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsUnverifiedEmail(t *testing.T) {
|
||||
claims := goodClaims()
|
||||
claims["email_verified"] = false
|
||||
google := fakeGoogle(t, claims)
|
||||
defer google.Close()
|
||||
s := newTestServer(t, google.URL)
|
||||
|
||||
state := mustState(t, s, appHost, "/")
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
|
||||
if w := do(s.Routes(), r); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackOnlyServedOnAuthHost(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/callback?code=x&state=y", nil)
|
||||
if w := do(s.Routes(), r); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsExpiredState(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
st := stateClaims{Host: appHost, RD: "/", Proto: "https", Nonce: "n",
|
||||
Exp: time.Now().Add(-time.Minute).Unix()}
|
||||
tok, err := s.box.seal("state", st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(tok), nil)
|
||||
if w := do(s.Routes(), r); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishRejectsWrongHost(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
hand := handoffClaims{Email: "a@signal.org", User: "1", Host: appHost, RD: "/",
|
||||
Proto: "https", Nonce: randToken(), Exp: time.Now().Add(time.Minute).Unix()}
|
||||
tok, _ := s.box.seal("handoff", hand)
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://other.example.com"+RoutePrefix+"/finish?token="+url.QueryEscape(tok), nil)
|
||||
if w := do(s.Routes(), r); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartReturnsJSON401ForNonBrowsers(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/start", nil)
|
||||
r.Header.Set("Accept", "application/json")
|
||||
w := do(s.Routes(), r)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d, want 401", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("content type = %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredSessionRejected(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
sess := sessionClaims{Email: "a@signal.org", User: "1", Host: appHost,
|
||||
Exp: time.Now().Add(-time.Minute).Unix()}
|
||||
val, _ := s.box.seal("session", sess)
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "_google_auth", Value: val})
|
||||
if w := do(s.Routes(), r); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("got %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeRedirect(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "/",
|
||||
"/ok": "/ok",
|
||||
"/ok?a=1&b=2": "/ok?a=1&b=2",
|
||||
"//evil.com/x": "/",
|
||||
"https://evil.com": "/",
|
||||
"/x\r\nSet-Cookie: p": "/",
|
||||
"\\evil": "/",
|
||||
RoutePrefix + "/start": "/", // avoid redirect loops into our own routes
|
||||
"relative/no/lead/slash": "/",
|
||||
"/deep/path/./is/fine": "/deep/path/./is/fine",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeRedirect(in); got != want {
|
||||
t.Errorf("sanitizeRedirect(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailAllowed(t *testing.T) {
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
s.cfg.AllowedEmails = []string{"guest@partner.com"}
|
||||
cases := map[string]bool{
|
||||
"greyson@signal.org": true,
|
||||
"GREYSON@SIGNAL.ORG": true,
|
||||
"guest@partner.com": true,
|
||||
"other@partner.com": false,
|
||||
"evil@notsignal.org": false,
|
||||
"greyson@signal.org.evil.c": false,
|
||||
"signal.org": false,
|
||||
}
|
||||
for email, want := range cases {
|
||||
if got := s.emailAllowed(email); got != want {
|
||||
t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlexClaims(t *testing.T) {
|
||||
var tok idToken
|
||||
payload := `{"aud":["a","b"],"email_verified":"true","iss":"accounts.google.com","exp":99}`
|
||||
if err := json.Unmarshal([]byte(payload), &tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tok.Aud.contains("b") || !bool(tok.EmailVerified) {
|
||||
t.Fatalf("flex claims parsed wrong: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
// mustState mints a valid state token the way handleStart would.
|
||||
func mustState(t *testing.T, s *Server, host, rd string) string {
|
||||
t.Helper()
|
||||
tok, err := s.box.seal("state", stateClaims{
|
||||
Host: host, RD: rd, Proto: "https", Nonce: randToken(),
|
||||
Exp: time.Now().Add(10 * time.Minute).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nonceCache makes hand-off tokens single-use. Entries expire alongside the
|
||||
// token they guard, so the map stays tiny (hand-off tokens live 60 seconds).
|
||||
type nonceCache struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]int64
|
||||
}
|
||||
|
||||
func newNonceCache() *nonceCache {
|
||||
return &nonceCache{seen: make(map[string]int64)}
|
||||
}
|
||||
|
||||
// use records the nonce and reports whether this was its first use.
|
||||
func (c *nonceCache) use(nonce string, exp int64) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := time.Now().Unix()
|
||||
for k, v := range c.seen {
|
||||
if v < now {
|
||||
delete(c.seen, k)
|
||||
}
|
||||
}
|
||||
if _, dup := c.seen[nonce]; dup {
|
||||
return false
|
||||
}
|
||||
c.seen[nonce] = exp
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server implements the auth endpoints nginx talks to:
|
||||
//
|
||||
// GET /_google-auth/verify auth_request subrequest: 200 if signed in, 401 otherwise
|
||||
// GET /_google-auth/start begin the OAuth flow (redirects to Google)
|
||||
// GET /_google-auth/callback Google redirect URI (only served on AuthHost)
|
||||
// GET /_google-auth/finish mint the session cookie on the destination app host
|
||||
// GET /_google-auth/logout clear the session cookie
|
||||
// GET /_google-auth/healthz liveness probe
|
||||
// GET /_google-auth/ human-readable status page
|
||||
type Server struct {
|
||||
cfg Config
|
||||
box *box
|
||||
nonces *nonceCache
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Server, error) {
|
||||
b, err := newBox(cfg.CookieSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
box: b,
|
||||
nonces: newNonceCache(),
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(RoutePrefix+"/verify", s.handleVerify)
|
||||
mux.HandleFunc(RoutePrefix+"/start", s.handleStart)
|
||||
mux.HandleFunc(RoutePrefix+"/callback", s.handleCallback)
|
||||
mux.HandleFunc(RoutePrefix+"/finish", s.handleFinish)
|
||||
mux.HandleFunc(RoutePrefix+"/logout", s.handleLogout)
|
||||
mux.HandleFunc(RoutePrefix+"/healthz", s.handleHealthz)
|
||||
mux.HandleFunc(RoutePrefix+"/", s.handleStatus)
|
||||
mux.HandleFunc("/", s.handleStatus)
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleVerify is the nginx auth_request target. Response headers become
|
||||
// available to nginx as $upstream_http_* variables.
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := s.sessionFromRequest(r)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("X-Auth-Request-Email", headerSafe(sess.Email))
|
||||
h.Set("X-Auth-Request-User", headerSafe(sess.User))
|
||||
h.Set("X-Auth-Request-Name", headerSafe(sess.Name))
|
||||
h.Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleStart begins the OAuth flow. nginx proxies unauthenticated requests
|
||||
// here (via the @google_auth_signin named location) with the original URI in
|
||||
// the X-Auth-Request-Redirect header.
|
||||
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
rd := r.Header.Get("X-Auth-Request-Redirect")
|
||||
if rd == "" {
|
||||
rd = r.URL.Query().Get("rd")
|
||||
}
|
||||
rd = sanitizeRedirect(rd)
|
||||
|
||||
// Non-browser clients (API calls, curl) get a clean 401 instead of a
|
||||
// redirect chain they can't follow meaningfully.
|
||||
if !strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprint(w, `{"error":"authentication required"}`)
|
||||
return
|
||||
}
|
||||
|
||||
host := requestHost(r)
|
||||
if host == "" {
|
||||
s.htmlError(w, http.StatusBadRequest, "Missing Host header.")
|
||||
return
|
||||
}
|
||||
|
||||
st := stateClaims{
|
||||
Host: host,
|
||||
RD: rd,
|
||||
Proto: s.proto(r),
|
||||
Nonce: randToken(),
|
||||
Exp: time.Now().Add(10 * time.Minute).Unix(),
|
||||
}
|
||||
token, err := s.box.seal("state", st)
|
||||
if err != nil {
|
||||
s.htmlError(w, http.StatusInternalServerError, "Could not start sign-in.")
|
||||
return
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("client_id", s.cfg.ClientID)
|
||||
q.Set("redirect_uri", s.redirectURI())
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "openid email profile")
|
||||
q.Set("state", token)
|
||||
if len(s.cfg.AllowedDomains) == 1 {
|
||||
// UX hint only; real enforcement happens in emailAllowed.
|
||||
q.Set("hd", s.cfg.AllowedDomains[0])
|
||||
}
|
||||
http.Redirect(w, r, s.cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleCallback is Google's redirect target. It only ever runs on AuthHost,
|
||||
// exchanges the code, authorizes the email, and bounces the browser back to
|
||||
// the originating app host with a short-lived hand-off token.
|
||||
func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if requestHost(r) != s.cfg.AuthHost {
|
||||
s.htmlError(w, http.StatusNotFound, "This host does not serve the OAuth callback.")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if errCode := q.Get("error"); errCode != "" {
|
||||
s.htmlError(w, http.StatusForbidden, "Google sign-in failed: "+html.EscapeString(errCode))
|
||||
return
|
||||
}
|
||||
|
||||
var st stateClaims
|
||||
if err := s.box.open("state", q.Get("state"), &st); err != nil {
|
||||
s.htmlError(w, http.StatusBadRequest, "Invalid sign-in state. Go back to the app and try again.")
|
||||
return
|
||||
}
|
||||
if expired(st.Exp) {
|
||||
s.htmlError(w, http.StatusForbidden, "This sign-in attempt expired. Go back to the app and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
idTok, err := s.exchangeCode(r.Context(), q.Get("code"))
|
||||
if err != nil {
|
||||
log.Printf("callback: code exchange failed: %v", err)
|
||||
s.htmlError(w, http.StatusBadGateway, "Could not complete sign-in with Google. Try again.")
|
||||
return
|
||||
}
|
||||
if err := s.validateIDToken(idTok); err != nil {
|
||||
log.Printf("callback: id_token rejected: %v", err)
|
||||
s.htmlError(w, http.StatusForbidden, "Google returned an invalid identity token.")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(idTok.Email)
|
||||
if !s.emailAllowed(email) {
|
||||
log.Printf("callback: denied %s (not in allowed domains/emails) for host %s", email, st.Host)
|
||||
s.htmlError(w, http.StatusForbidden,
|
||||
"You are signed in to Google as <b>"+html.EscapeString(email)+"</b>, but that account is not allowed to access this app.")
|
||||
return
|
||||
}
|
||||
|
||||
hand := handoffClaims{
|
||||
Email: email,
|
||||
User: idTok.Sub,
|
||||
Name: idTok.Name,
|
||||
Host: st.Host,
|
||||
RD: st.RD,
|
||||
Proto: st.Proto,
|
||||
Nonce: randToken(),
|
||||
Exp: time.Now().Add(60 * time.Second).Unix(),
|
||||
}
|
||||
token, err := s.box.seal("handoff", hand)
|
||||
if err != nil {
|
||||
s.htmlError(w, http.StatusInternalServerError, "Could not complete sign-in.")
|
||||
return
|
||||
}
|
||||
dest := fmt.Sprintf("%s://%s%s/finish?token=%s", st.Proto, st.Host, RoutePrefix, url.QueryEscape(token))
|
||||
http.Redirect(w, r, dest, http.StatusFound)
|
||||
}
|
||||
|
||||
// handleFinish runs on the destination app host and turns a hand-off token
|
||||
// into a host-scoped session cookie.
|
||||
func (s *Server) handleFinish(w http.ResponseWriter, r *http.Request) {
|
||||
var hand handoffClaims
|
||||
if err := s.box.open("handoff", r.URL.Query().Get("token"), &hand); err != nil {
|
||||
s.htmlError(w, http.StatusForbidden, "Invalid sign-in token. Go back to the app and try again.")
|
||||
return
|
||||
}
|
||||
if expired(hand.Exp) {
|
||||
s.htmlError(w, http.StatusForbidden, "This sign-in token expired. Go back to the app and try again.")
|
||||
return
|
||||
}
|
||||
if hand.Host != requestHost(r) {
|
||||
s.htmlError(w, http.StatusForbidden, "This sign-in token was issued for a different host.")
|
||||
return
|
||||
}
|
||||
if !s.nonces.use(hand.Nonce, hand.Exp) {
|
||||
s.htmlError(w, http.StatusForbidden, "This sign-in token was already used.")
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionClaims{
|
||||
Email: hand.Email,
|
||||
User: hand.User,
|
||||
Name: hand.Name,
|
||||
Host: hand.Host,
|
||||
Exp: time.Now().Add(s.cfg.SessionTTL).Unix(),
|
||||
}
|
||||
value, err := s.box.seal("session", sess)
|
||||
if err != nil {
|
||||
s.htmlError(w, http.StatusInternalServerError, "Could not create session.")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cfg.CookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int(s.cfg.SessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: hand.Proto == "https",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
log.Printf("signed in %s on %s", sess.Email, sess.Host)
|
||||
http.Redirect(w, r, sanitizeRedirect(hand.RD), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cfg.CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.proto(r) == "https",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if rd := sanitizeRedirect(r.URL.Query().Get("rd")); rd != "/" {
|
||||
http.Redirect(w, r, rd, http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.htmlPage(w, http.StatusOK, "Signed out",
|
||||
`You have been signed out of <b>`+html.EscapeString(requestHost(r))+`</b>.
|
||||
<p><a href="/">Sign in again</a></p>`)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "ok")
|
||||
}
|
||||
|
||||
// handleStatus is a small human-readable page for debugging.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if sess, ok := s.sessionFromRequest(r); ok {
|
||||
s.htmlPage(w, http.StatusOK, "Signed in",
|
||||
`Signed in to <b>`+html.EscapeString(requestHost(r))+`</b> as <b>`+html.EscapeString(sess.Email)+`</b>`+
|
||||
` (`+html.EscapeString(sess.Name)+`).`+
|
||||
`<p>Session expires `+time.Unix(sess.Exp, 0).UTC().Format(time.RFC1123)+`.</p>`+
|
||||
`<p><a href="`+RoutePrefix+`/logout">Sign out</a></p>`)
|
||||
return
|
||||
}
|
||||
s.htmlPage(w, http.StatusOK, "Not signed in",
|
||||
`Not signed in on <b>`+html.EscapeString(requestHost(r))+`</b>.
|
||||
<p><a href="`+RoutePrefix+`/start?rd=/">Sign in with Google</a></p>`)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) {
|
||||
c, err := r.Cookie(s.cfg.CookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, false
|
||||
}
|
||||
var sess sessionClaims
|
||||
if err := s.box.open("session", c.Value, &sess); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if expired(sess.Exp) {
|
||||
return nil, false
|
||||
}
|
||||
if sess.Host != requestHost(r) {
|
||||
return nil, false
|
||||
}
|
||||
return &sess, true
|
||||
}
|
||||
|
||||
func (s *Server) emailAllowed(email string) bool {
|
||||
email = strings.ToLower(email)
|
||||
if slices.Contains(s.cfg.AllowedEmails, email) {
|
||||
return true
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(s.cfg.AllowedDomains, email[at+1:])
|
||||
}
|
||||
|
||||
func (s *Server) redirectURI() string {
|
||||
scheme := "https"
|
||||
if s.cfg.AllowInsecure {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + s.cfg.AuthHost + RoutePrefix + "/callback"
|
||||
}
|
||||
|
||||
// proto reports the effective client-facing scheme. Unless insecure mode is
|
||||
// on, everything is treated as https so cookies always carry Secure.
|
||||
func (s *Server) proto(r *http.Request) string {
|
||||
if s.cfg.AllowInsecure && r.Header.Get("X-Forwarded-Proto") == "http" {
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
||||
func requestHost(r *http.Request) string {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
// sanitizeRedirect only permits same-host relative paths, preventing open
|
||||
// redirects. Anything suspicious collapses to "/".
|
||||
func sanitizeRedirect(rd string) string {
|
||||
if rd == "" || !strings.HasPrefix(rd, "/") || strings.HasPrefix(rd, "//") {
|
||||
return "/"
|
||||
}
|
||||
if strings.ContainsAny(rd, "\\\r\n") {
|
||||
return "/"
|
||||
}
|
||||
if strings.HasPrefix(rd, RoutePrefix) {
|
||||
return "/"
|
||||
}
|
||||
return rd
|
||||
}
|
||||
|
||||
func expired(unixSeconds int64) bool {
|
||||
return time.Now().Unix() > unixSeconds
|
||||
}
|
||||
|
||||
// headerSafe strips characters that are not safe in an HTTP header value.
|
||||
func headerSafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
func (s *Server) htmlError(w http.ResponseWriter, status int, body string) {
|
||||
s.htmlPage(w, status, http.StatusText(status), body)
|
||||
}
|
||||
|
||||
func (s *Server) htmlPage(w http.ResponseWriter, status int, title, body string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;max-width:36rem;margin:15vh auto 0;padding:0 1rem;color:#222;line-height:1.5}
|
||||
h1{font-size:1.3rem} a{color:#1a73e8}
|
||||
</style></head>
|
||||
<body><h1>%s</h1><p>%s</p></body></html>`, html.EscapeString(title), html.EscapeString(title), body)
|
||||
}
|
||||
Reference in New Issue
Block a user