package web import ( "archive/zip" "bytes" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "testing" "time" "git.veillax.com/Veillax/mako/internal/config" "git.veillax.com/Veillax/mako/internal/oidc" ) // idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID // token carrying whatever claims the test set. type idp struct { srv *httptest.Server mu sync.Mutex nonce string challenge string claims map[string]any down bool } func newIdP(t *testing.T) *idp { p := &idp{} mux := http.NewServeMux() mux.HandleFunc("/app/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"issuer": p.srv.URL + "/app/", "authorization_endpoint": p.srv.URL + "/authorize", "token_endpoint": p.srv.URL + "/token"}) }) mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { p.mu.Lock() defer p.mu.Unlock() r.ParseForm() if p.down { http.Error(w, "boom", http.StatusBadGateway) return } if r.Form.Get("code") != "good" || r.Form.Get("client_secret") != "shh" || r.Form.Get("grant_type") != "authorization_code" || oidc.Challenge(r.Form.Get("code_verifier")) != p.challenge || r.Form.Get("redirect_uri") != "http://127.0.0.1:8490/auth/callback" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"}) return } c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "mako-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce} for k, v := range p.claims { c[k] = v } raw, _ := json.Marshal(c) enc := base64.RawURLEncoding.EncodeToString json.NewEncoder(w).Encode(map[string]string{"id_token": enc([]byte(`{"alg":"none"}`)) + "." + enc(raw) + ".sig", "access_token": "x"}) }) p.srv = httptest.NewServer(mux) t.Cleanup(p.srv.Close) return p } type env struct { t *testing.T s *Server h http.Handler idp *idp dir string cfg config.Config } func setup(t *testing.T, tweak func(map[string]any)) *env { t.Helper() p := newIdP(t) dir := t.TempDir() conf := map[string]any{ "data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret", "oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "mako-client", "client_secret": "shh"}, } if tweak != nil { tweak(conf) } b, _ := json.Marshal(conf) path := filepath.Join(dir, "mako.json") os.WriteFile(path, b, 0o600) h, err := config.Load(path) if err != nil { t.Fatal(err) } s, err := New(h, "test") if err != nil { t.Fatal(err) } return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()} } func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder { e.t.Helper() var rd *bytes.Reader if body != nil { b, _ := json.Marshal(body) rd = bytes.NewReader(b) } else { rd = bytes.NewReader(nil) } r := httptest.NewRequest(method, path, rd) if body != nil { r.Header.Set("Content-Type", "application/json") } if method != http.MethodGet { r.Header.Set("X-Mako", "1") } for i := 0; i+1 < len(hdr); i += 2 { if hdr[i+1] == "" { r.Header.Del(hdr[i]) } else { r.Header.Set(hdr[i], hdr[i+1]) } } for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() e.h.ServeHTTP(w, r) return w } func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie { for _, c := range w.Result().Cookies() { if c.Name == name { return c } } return nil } func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) { t.Helper() if err := json.Unmarshal(w.Body.Bytes(), v); err != nil { t.Fatalf("bad json %q: %v", w.Body.String(), err) } } // begin starts a sign-in and returns the redirect the browser would follow and the flow cookie. func (e *env) begin(next string) (*url.URL, *http.Cookie) { e.t.Helper() w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil) if w.Code != http.StatusSeeOther { e.t.Fatalf("login: %d %s", w.Code, w.Body) } to, _ := url.Parse(w.Header().Get("Location")) return to, cookieNamed(w, flowCookie) } // signIn runs the whole flow for a person and returns their session cookie. func (e *env) signIn(claims map[string]any) []*http.Cookie { e.t.Helper() to, flow := e.begin("") e.idp.mu.Lock() e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims e.idp.mu.Unlock() w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow}) ck := cookieNamed(w, sessionCookie) if w.Code != http.StatusSeeOther || ck == nil { e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location")) } return []*http.Cookie{ck} } var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"} var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"} func TestNothingWorksWithoutSigningIn(t *testing.T) { e := setup(t, nil) for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} { if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized { t.Errorf("GET %s without a session: %d", path, w.Code) } } if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized { t.Errorf("POST without a session: %d", w.Code) } if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Mako") { t.Errorf("the sign-in page itself must load: %d", w.Code) } if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK { t.Errorf("healthz: %d", w.Code) } if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized { t.Errorf("garbage cookie: %d", w.Code) } } func TestSignInFlow(t *testing.T) { e := setup(t, nil) to, flow := e.begin("#/p/abc-123") q := to.Query() if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") { t.Fatalf("goes to Authentik: %s", to) } for k, want := range map[string]string{"response_type": "code", "client_id": "mako-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback", "scope": "openid profile email", "code_challenge_method": "S256"} { if q.Get(k) != want { t.Errorf("%s = %q, want %q", k, q.Get(k), want) } } for _, k := range []string{"state", "nonce", "code_challenge"} { if len(q.Get(k)) < 16 { t.Errorf("%s is too short: %q", k, q.Get(k)) } } if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" { t.Errorf("flow cookie: %+v", flow) } e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow}) if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/#/p/abc-123" { t.Fatalf("callback: %d -> %s", w.Code, w.Header().Get("Location")) } ses := cookieNamed(w, sessionCookie) if ses == nil || !ses.HttpOnly || ses.SameSite != http.SameSiteLaxMode || ses.Secure { t.Fatalf("session cookie: %+v", ses) } if c := cookieNamed(w, flowCookie); c == nil || c.MaxAge >= 0 { t.Error("the flow cookie should be cleared after use") } var me struct{ Name, Email string } jsonOf(t, e.do("GET", "/api/me", nil, []*http.Cookie{ses}), &me) if me.Name != "Alice" || me.Email != "alice@example.com" { t.Fatalf("me: %+v", me) } // the same flow cookie cannot be used twice by a browser that kept it, and a stale state is refused if w := e.do("GET", "/auth/callback?code=good&state=wrong", nil, []*http.Cookie{flow}); w.Header().Get("Location") != "/?signin=failed" { t.Errorf("wrong state: %s", w.Header().Get("Location")) } } func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) { e := setup(t, nil) try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) { t.Helper() to, flow := e.begin("") e.idp.mu.Lock() e.idp.nonce, e.idp.challenge, e.idp.claims, e.idp.down = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice, false e.idp.mu.Unlock() target := mutate(to, &flow) var cks []*http.Cookie if flow != nil { cks = append(cks, flow) } w := e.do("GET", target, nil, cks) if got := w.Header().Get("Location"); got != "/?signin="+want || cookieNamed(w, sessionCookie) != nil { t.Errorf("%s: went to %q (%d), session %v; want %s", name, got, w.Code, cookieNamed(w, sessionCookie), want) } } good := func(to *url.URL) string { return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state")) } try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired") try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired") try("wrong state", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=good&state=nope" }, "failed") try("no code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?state=" + to.Query().Get("state") }, "failed") try("denied at Authentik", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?error=access_denied&state=" + to.Query().Get("state") }, "denied") try("bad code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=stolen&state=" + url.QueryEscape(to.Query().Get("state")) }, "failed") try("wrong nonce", func(to *url.URL, f **http.Cookie) string { e.idp.mu.Lock() e.idp.nonce = "other" e.idp.mu.Unlock() return good(to) }, "failed") try("wrong audience", func(to *url.URL, f **http.Cookie) string { e.idp.mu.Lock() e.idp.claims = map[string]any{"sub": "x", "aud": "someone-else"} e.idp.mu.Unlock() return good(to) }, "failed") try("expired token", func(to *url.URL, f **http.Cookie) string { e.idp.mu.Lock() e.idp.claims = map[string]any{"sub": "x", "exp": time.Now().Add(-time.Hour).Unix()} e.idp.mu.Unlock() return good(to) }, "failed") try("wrong issuer", func(to *url.URL, f **http.Cookie) string { e.idp.mu.Lock() e.idp.claims = map[string]any{"sub": "x", "iss": "https://evil.example.com/"} e.idp.mu.Unlock() return good(to) }, "failed") try("no subject", func(to *url.URL, f **http.Cookie) string { e.idp.mu.Lock() e.idp.claims = map[string]any{"sub": ""} e.idp.mu.Unlock() return good(to) }, "failed") try("Authentik is down", func(to *url.URL, f **http.Cookie) string { e.idp.mu.Lock() e.idp.down = true e.idp.mu.Unlock() return good(to) }, "unavailable") // and a next address that points anywhere but inside Mako is dropped for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/