Files
new-grok-glance/internal/auth/auth_test.go
T
iceBear67andClaude Opus 5 051efe8fec grok-glance: web control plane for grok's /rc remote control
A single Go binary that grok dials out to over a WebSocket, and a HeroUI
web UI for driving the session it is attached to.

The roles are inverted relative to the terminal: over the /rc link grok
is the ACP Agent and glance is the Client. That makes glance a stock ACP
client and the web Stop button a real session/cancel rather than a
bespoke control message.

Both notification rails are mirrored. The stable session/update rail
carries correctness; x.ai/session_notification is presentation only and
degrades rather than erroring, because its ~60 variants are grok
internal and drift with every upstream sync. _meta is forwarded
byte for byte so viewers can dedup and order.

Permissions race: the terminal and any browser may answer, first
responder wins, and the loser's UI retracts by itself. All three
interaction methods go through that path, not just permissions.

Auth is TOTP only, with no accounts to have. A bootstrap token printed
at first start gates /setup, which is a 404 without it; state lives in
one 0600 JSON file and history in an in-memory ring, so there is no
database and no recovery story beyond deleting the file.

ARCHITECTURE.md covers the topology and the limits of that auth model;
CLAUDE.md covers building, the fakeagent loop, and the end-to-end
checklist that unit tests cannot replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:07:33 +00:00

203 lines
6.3 KiB
Go

package auth
import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/pquerna/otp/totp"
"github.com/user/grok-glance/internal/state"
)
func newManager(t *testing.T) (*Manager, *state.Store) {
t.Helper()
store, err := state.Open(t.TempDir())
if err != nil {
t.Fatalf("open store: %v", err)
}
return NewManager(store, true), store
}
func code(t *testing.T, secret string) string {
t.Helper()
c, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
return c
}
func TestEnrollmentRequiresAWorkingCode(t *testing.T) {
m, store := newManager(t)
enrollment, err := m.BeginEnrollment("operator@localhost")
if err != nil {
t.Fatalf("BeginEnrollment: %v", err)
}
if enrollment.Secret == "" || !strings.HasPrefix(enrollment.URI, "otpauth://totp/") {
t.Fatalf("unusable enrollment: %+v", enrollment)
}
// Nothing is persisted until the user proves the secret reached their phone.
// Otherwise a failed QR scan would lock the operator out of their own server.
if store.Enrolled() {
t.Fatal("BeginEnrollment persisted the secret before it was confirmed")
}
if err := m.CompleteEnrollment(enrollment.Secret, "000000", ""); !errors.Is(err, ErrBadCode) {
t.Fatalf("CompleteEnrollment with a wrong code: %v, want ErrBadCode", err)
}
if store.Enrolled() {
t.Fatal("a failed confirmation still enrolled")
}
if err := m.CompleteEnrollment(enrollment.Secret, code(t, enrollment.Secret), ""); err != nil {
t.Fatalf("CompleteEnrollment: %v", err)
}
if !store.Enrolled() {
t.Fatal("enrollment did not persist")
}
// A second enrollment would be a password reset with no authentication on it.
if _, err := m.BeginEnrollment(""); err == nil {
t.Fatal("a second enrollment was allowed")
}
}
func TestLoginRejectsReplayAndRateLimits(t *testing.T) {
m, _ := newManager(t)
if err := m.Login("123456"); !errors.Is(err, ErrNotEnrolled) {
t.Fatalf("login before enrollment: %v, want ErrNotEnrolled", err)
}
enrollment, err := m.BeginEnrollment("")
if err != nil {
t.Fatal(err)
}
if err := m.CompleteEnrollment(enrollment.Secret, code(t, enrollment.Secret), ""); err != nil {
t.Fatal(err)
}
valid := code(t, enrollment.Secret)
if err := m.Login(valid); err != nil {
t.Fatalf("Login with a fresh code: %v", err)
}
// A TOTP code stays valid for its whole 30-second step, so anyone who saw it
// could use it again inside that window.
if err := m.Login(valid); !errors.Is(err, ErrReplay) {
t.Fatalf("replayed code: %v, want ErrReplay", err)
}
for i := 0; i < maxAttempts; i++ {
if err := m.Login("000000"); errors.Is(err, ErrRateLimited) {
t.Fatalf("rate limit tripped early, after %d attempts", i)
}
}
// 10^6 codes is a short brute force at network speed; the limiter is what
// makes a 6-digit secret adequate.
if err := m.Login("000000"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("after %d failures: %v, want ErrRateLimited", maxAttempts, err)
}
}
func TestSessionCookieRoundTrip(t *testing.T) {
m, _ := newManager(t)
rec := httptest.NewRecorder()
m.IssueCookie(rec)
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("IssueCookie wrote %d cookies, want 1", len(cookies))
}
cookie := cookies[0]
if cookie.Name != CookieName {
t.Fatalf("cookie name = %q, want %q", cookie.Name, CookieName)
}
// The `__Host-` prefix is only honoured by browsers when all three hold.
if !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" {
t.Fatalf("cookie does not satisfy the __Host- prefix rules: %+v", cookie)
}
if cookie.SameSite != http.SameSiteStrictMode {
t.Fatal("cookie is not SameSite=Strict")
}
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
req.AddCookie(cookie)
if !m.Authenticated(req) {
t.Fatal("a freshly issued cookie did not authenticate")
}
// No cookie at all.
if m.Authenticated(httptest.NewRequest(http.MethodGet, "/api/agents", nil)) {
t.Fatal("an unauthenticated request was accepted")
}
}
func TestForgedCookiesAreRejected(t *testing.T) {
m, _ := newManager(t)
far := strconv.FormatInt(time.Now().Add(100*time.Hour).Unix(), 10)
cases := map[string]string{
"no signature": far,
"empty": "",
"garbage signature": far + ".not-a-signature",
"unsigned future": far + ".",
"expired": m.signSession(time.Now().Add(-time.Minute).Unix()),
}
for name, value := range cases {
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: value})
if m.Authenticated(req) {
t.Fatalf("%s: forged cookie accepted", name)
}
}
// Extending an otherwise-valid cookie's expiry must invalidate the signature.
valid := m.signSession(time.Now().Add(time.Hour).Unix())
_, sig, _ := strings.Cut(valid, ".")
tampered := strconv.FormatInt(time.Now().Add(1000*time.Hour).Unix(), 10) + "." + sig
req := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: tampered})
if m.Authenticated(req) {
t.Fatal("a cookie with an extended expiry was accepted")
}
}
func TestBearerAndBootstrapExtraction(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/acp/agent", nil)
if got := BearerToken(req); got != "" {
t.Fatalf("BearerToken with no header = %q", got)
}
req.Header.Set("Authorization", "Bearer glance_sk_abc")
if got := BearerToken(req); got != "glance_sk_abc" {
t.Fatalf("BearerToken = %q", got)
}
// Some proxies and clients normalise the scheme's case.
req.Header.Set("Authorization", "bearer glance_sk_abc")
if got := BearerToken(req); got != "glance_sk_abc" {
t.Fatalf("BearerToken with a lowercase scheme = %q", got)
}
req.Header.Set("Authorization", "Basic glance_sk_abc")
if got := BearerToken(req); got != "" {
t.Fatalf("BearerToken accepted a Basic header: %q", got)
}
// The token arrives in the URL the operator pastes from the terminal, and in
// a header once the SPA takes over.
q := httptest.NewRequest(http.MethodPost, "/api/setup/begin?token=abc", nil)
if got := BootstrapToken(q); got != "abc" {
t.Fatalf("BootstrapToken from query = %q", got)
}
h := httptest.NewRequest(http.MethodPost, "/api/setup/begin", nil)
h.Header.Set("X-Glance-Bootstrap", "abc")
if got := BootstrapToken(h); got != "abc" {
t.Fatalf("BootstrapToken from header = %q", got)
}
}