Add explict allow deny commands.

This commit is contained in:
Greyson Parrelli
2026-08-06 10:45:45 -04:00
parent a3f0f8a1be
commit c15a1cc14c
15 changed files with 566 additions and 13 deletions
+9 -3
View File
@@ -33,12 +33,17 @@ type Config struct {
// 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 / AllowedEmails are the allowlist: an email is accepted
// if its domain is in AllowedDomains or the full address is in
// AllowedEmails. Anything else is rejected.
AllowedDomains []string
AllowedEmails []string
// DeniedEmails is the denylist. It is checked before the allowlist and
// always wins, so a single address can be revoked without dropping the
// whole domain it belongs to.
DeniedEmails []string
CookieName string
SessionTTL time.Duration
ListenAddr string
@@ -79,6 +84,7 @@ func ConfigFromEnv() (Config, error) {
cfg.AllowedDomains = append(cfg.AllowedDomains, strings.TrimPrefix(d, "@"))
}
cfg.AllowedEmails = splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_EMAILS"))
cfg.DeniedEmails = splitList(os.Getenv("GOOGLE_AUTH_DENIED_EMAILS"))
return cfg, cfg.validate()
}
+4
View File
@@ -47,6 +47,7 @@ func TestConfigFromEnv(t *testing.T) {
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_DENIED_EMAILS", "Former@Signal.org example@example.com")
t.Setenv("GOOGLE_AUTH_SESSION_TTL", "48h")
cfg, err := ConfigFromEnv()
@@ -62,6 +63,9 @@ func TestConfigFromEnv(t *testing.T) {
if len(cfg.AllowedEmails) != 1 || cfg.AllowedEmails[0] != "guest@partner.com" {
t.Errorf("AllowedEmails = %v", cfg.AllowedEmails)
}
if len(cfg.DeniedEmails) != 2 || cfg.DeniedEmails[0] != "former@signal.org" || cfg.DeniedEmails[1] != "example@example.com" {
t.Errorf("DeniedEmails = %v", cfg.DeniedEmails)
}
if cfg.SessionTTL != 48*time.Hour {
t.Errorf("SessionTTL = %v", cfg.SessionTTL)
}
+62
View File
@@ -360,6 +360,68 @@ func TestEmailAllowed(t *testing.T) {
}
}
// The denylist outranks both an allowed domain and an explicitly allowed
// address.
func TestEmailDenied(t *testing.T) {
s := newTestServer(t, "http://unused.invalid")
s.cfg.AllowedEmails = []string{"guest@partner.com"}
s.cfg.DeniedEmails = []string{"former@signal.org", "guest@partner.com"}
cases := map[string]bool{
"greyson@signal.org": true,
"former@signal.org": false,
"FORMER@SIGNAL.ORG": false,
"guest@partner.com": false,
}
for email, want := range cases {
if got := s.emailAllowed(email); got != want {
t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want)
}
}
}
func TestCallbackRejectsDeniedEmail(t *testing.T) {
google := fakeGoogle(t, goodClaims())
defer google.Close()
s := newTestServer(t, google.URL)
s.cfg.DeniedEmails = []string{"greyson@signal.org"}
state := mustState(t, s, appHost, "/")
r := httptest.NewRequest("GET",
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
w := do(s.Routes(), 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())
}
}
// Denying an address invalidates the sessions it already holds, rather than
// waiting for them to expire.
func TestDenylistInvalidatesExistingSession(t *testing.T) {
s := newTestServer(t, "http://unused.invalid")
sess := sessionClaims{Email: "greyson@signal.org", User: "1", Host: appHost,
Exp: time.Now().Add(time.Hour).Unix()}
val, err := s.box.seal("session", sess)
if err != nil {
t.Fatal(err)
}
newVerify := func() *http.Request {
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
r.AddCookie(&http.Cookie{Name: "_google_auth", Value: val})
return r
}
if w := do(s.Routes(), newVerify()); w.Code != http.StatusOK {
t.Fatalf("before denial: got %d, want 200", w.Code)
}
s.cfg.DeniedEmails = []string{"greyson@signal.org"}
if w := do(s.Routes(), newVerify()); w.Code != http.StatusUnauthorized {
t.Fatalf("after denial: got %d, want 401", w.Code)
}
}
func TestFlexClaims(t *testing.T) {
var tok idToken
payload := `{"aud":["a","b"],"email_verified":"true","iss":"accounts.google.com","exp":99}`
+11
View File
@@ -286,11 +286,22 @@ func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) {
if sess.Host != requestHost(r) {
return nil, false
}
// Re-check authorization on every request so allow/deny list changes take
// effect immediately instead of whenever existing sessions happen to
// expire.
if !s.emailAllowed(sess.Email) {
return nil, false
}
return &sess, true
}
// emailAllowed applies the denylist first (it always wins), then the
// allowlist: an address must match an allowed email or an allowed domain.
func (s *Server) emailAllowed(email string) bool {
email = strings.ToLower(email)
if slices.Contains(s.cfg.DeniedEmails, email) {
return false
}
if slices.Contains(s.cfg.AllowedEmails, email) {
return true
}