Initial commit.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user