package adminapi import ( "bytes" "net/http" "strings" "testing" "time" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/store" ) // createKey posts to path and returns the minted credential. func (e *env) createKey(t *testing.T, path, token string, req api.CreateKeyRequest) api.CreateKeyResponse { t.Helper() status, body := e.do(t, http.MethodPost, path, token, req) var out api.CreateKeyResponse mustJSON(t, status, http.StatusCreated, body, &out) return out } func TestCreateAdminKeyReturnsWorkingToken(t *testing.T) { e := newEnv(t) out := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "ci"}) if out.Key.Scope != api.ScopeAdmin { t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeAdmin) } if out.Key.Project != "" { t.Errorf("admin key reports project %q", out.Key.Project) } if !strings.HasPrefix(out.Token, "pgs_"+out.Key.ID+"_") { t.Errorf("token does not carry its own key id %q", out.Key.ID) } // The new key authenticates and reports itself. status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil) var who api.WhoAmI mustJSON(t, status, http.StatusOK, body, &who) if who.KeyID != out.Key.ID || who.Scope != api.ScopeAdmin || who.Name != "ci" { t.Errorf("whoami = %+v, want key %q scope admin name ci", who, out.Key.ID) } } // The token exists on the wire exactly once. If it were cacheable, a shared // proxy in front of the management API would keep a live credential on disk. func TestCreateKeyResponseIsNoStore(t *testing.T) { e := newEnv(t) resp := e.doResp(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201", resp.StatusCode) } if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") { t.Errorf("Cache-Control = %q, want it to contain no-store", cc) } } // The one invariant that matters most in this package: no endpoint other than // creation may ever put a secret on the wire. Asserted against the raw bytes, // not a decoded struct, so a field added to api.Key later cannot leak one past // this test. func TestNoEndpointEverReturnsASecret(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") admin := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"}) proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) secrets := []string{ admin.Token, secretOf(t, admin.Token), proj.Token, secretOf(t, proj.Token), e.adminToken, secretOf(t, e.adminToken), } for _, tc := range []struct{ method, path, token string }{ {http.MethodGet, api.PathKeys(), e.adminToken}, {http.MethodGet, api.PathProjectKeys("demo"), e.adminToken}, {http.MethodGet, api.PathProjectKeys("demo"), proj.Token}, {http.MethodGet, api.PathWhoAmI(), proj.Token}, {http.MethodGet, api.PathProject("demo"), e.adminToken}, {http.MethodGet, api.PathProjects(), e.adminToken}, } { t.Run(tc.method+" "+tc.path, func(t *testing.T) { status, body := e.do(t, tc.method, tc.path, tc.token, nil) if status != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", status, body) } for _, s := range secrets { if bytes.Contains(body, []byte(s)) { t.Fatalf("response contains a credential") } } // The public half is fine to return, and the listings would be // useless without it — check the test is actually looking at keys. if strings.HasSuffix(tc.path, "/keys") && !bytes.Contains(body, []byte(proj.Key.ID)) && !bytes.Contains(body, []byte(admin.Key.ID)) { t.Errorf("key listing mentions no key id at all: %s", body) } }) } // And the log, which sees every request, never saw one either. if logged := e.logBuf.String(); logged != "" { for _, s := range secrets { if strings.Contains(logged, s) { t.Fatal("a credential reached the log") } } } } // secretOf returns the half of a token that must never be seen again. The split // is bounded at three because the base64url secret may itself contain "_". func secretOf(t *testing.T, token string) string { t.Helper() parts := strings.SplitN(token, "_", 3) if len(parts) != 3 { t.Fatalf("token has %d parts, want 3", len(parts)) } return parts[2] } func TestCreateProjectKey(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") out := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) if out.Key.Scope != api.ScopeProject { t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeProject) } if out.Key.Project != "demo" { t.Errorf("project = %q, want demo", out.Key.Project) } status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil) var who api.WhoAmI mustJSON(t, status, http.StatusOK, body, &who) if who.Scope != api.ScopeProject || who.Project != "demo" { t.Errorf("whoami = %+v, want scope project on demo", who) } } func TestCreateProjectKeyForUnknownProject(t *testing.T) { e := newEnv(t) status, body := e.do(t, http.MethodPost, api.PathProjectKeys("ghost"), e.adminToken, api.CreateKeyRequest{}) if status != http.StatusNotFound { t.Fatalf("status = %d, want 404; body: %s", status, body) } } func TestCreateKeyRejectsBadInput(t *testing.T) { e := newEnv(t) past := time.Now().Add(-time.Minute) t.Run("expired on arrival", func(t *testing.T) { status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{ExpiresAt: &past}) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", status, body) } }) t.Run("control characters in name", func(t *testing.T) { status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "ci\x1b[2Jrunner"}) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", status, body) } }) t.Run("oversized name", func(t *testing.T) { status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: strings.Repeat("a", maxKeyNameLen+1)}) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", status, body) } }) } func TestExpiredKeyDoesNotAuthenticate(t *testing.T) { e := newEnv(t) // The API refuses to mint one already expired, so this goes in through the // store to exercise the verifier's expiry check rather than the validator's. future := time.Now().Add(time.Hour) live := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{ExpiresAt: &future}) if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), live.Token, nil); status != http.StatusOK { t.Fatalf("key expiring in an hour: status = %d, body: %s", status, body) } past := time.Now().Add(-time.Hour) token := e.mintWith(t, store.ScopeAdmin, nil, "stale", &past) if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized { t.Errorf("expired key authenticated: status = %d", status) } } // Revocation must be visible on the next request, not when a cache entry ages // out — an operator revoking a leaked token is racing an attacker who has it. func TestRevokeTakesEffectImmediately(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") victim := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) // Warm the auth cache: without Invalidate() the revocation would not be // observed until the entry expired. if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusOK { t.Fatalf("key did not work before revocation") } status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil) if status != http.StatusNoContent { t.Fatalf("revoke: status = %d, want 204; body: %s", status, body) } if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusUnauthorized { t.Errorf("revoked key still authenticates: status = %d", status) } // Revoking again is a no-op rather than an error: a retrying CI step must // not fail on the second attempt. if status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil); status != http.StatusNoContent { t.Errorf("second revoke: status = %d, want 204; body: %s", status, body) } // The listing keeps it, with a revocation timestamp, so an operator can see // what happened. status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil) var list api.KeyList mustJSON(t, status, http.StatusOK, body, &list) var found bool for _, k := range list.Keys { if k.ID == victim.Key.ID { found = true if !k.Revoked() { t.Errorf("key %s is listed without revoked_at", k.ID) } } } if !found { t.Errorf("revoked key vanished from the listing") } } // A CI runner that believes its token leaked should be able to burn it without // waiting for an operator. func TestProjectKeyMayRevokeItself(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") k := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) status, body := e.do(t, http.MethodDelete, api.PathKey(k.Key.ID), k.Token, nil) if status != http.StatusNoContent { t.Fatalf("status = %d, want 204; body: %s", status, body) } if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), k.Token, nil); status != http.StatusUnauthorized { t.Errorf("key survived revoking itself: status = %d", status) } } func TestRevokeAuthorisation(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") e.createProject(t, "other") mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) sibling := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci-2"}) foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) adminKey := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"}) t.Run("sibling in the same project", func(t *testing.T) { status, body := e.do(t, http.MethodDelete, api.PathKey(sibling.Key.ID), mine.Token, nil) if status != http.StatusNoContent { t.Fatalf("status = %d, want 204; body: %s", status, body) } }) t.Run("another project's key", func(t *testing.T) { status, body := e.do(t, http.MethodDelete, api.PathKey(foreign.Key.ID), mine.Token, nil) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403; body: %s", status, body) } }) t.Run("an admin key", func(t *testing.T) { // An admin key has no project, so a project-scoped caller can never own // it. Escalating by revoking the operator's credentials is the attack // this closes. status, body := e.do(t, http.MethodDelete, api.PathKey(adminKey.Key.ID), mine.Token, nil) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403; body: %s", status, body) } if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), adminKey.Token, nil); status != http.StatusOK { t.Errorf("the admin key stopped working: status = %d", status) } }) } // 404 versus 403 is an oracle: a project key must not be able to walk the key // id space and learn which ones exist. func TestRevokeUnknownKey(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) const unknown = "abcdefghijklmnop" // well-formed, never minted t.Run("admin", func(t *testing.T) { status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), e.adminToken, nil) if status != http.StatusNotFound { t.Fatalf("status = %d, want 404; body: %s", status, body) } }) t.Run("project", func(t *testing.T) { status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), proj.Token, nil) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403; body: %s", status, body) } }) } func TestRevokeMalformedKeyID(t *testing.T) { e := newEnv(t) for _, id := range []string{"nope", "ABCDEFGHIJKLMNOP", "abcdefghijklmno1", strings.Repeat("a", 17)} { t.Run(id, func(t *testing.T) { status, body := e.do(t, http.MethodDelete, api.PathKey(id), e.adminToken, nil) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", status, body) } }) } } func TestListProjectKeysIsScoped(t *testing.T) { e := newEnv(t) e.createProject(t, "demo") e.createProject(t, "other") mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"}) status, body := e.do(t, http.MethodGet, api.PathProjectKeys("demo"), mine.Token, nil) var list api.KeyList mustJSON(t, status, http.StatusOK, body, &list) if len(list.Keys) != 1 || list.Keys[0].ID != mine.Key.ID { t.Fatalf("listing = %+v, want just %s", list.Keys, mine.Key.ID) } if list.Keys[0].Project != "demo" { t.Errorf("project = %q, want demo", list.Keys[0].Project) } if status, _ := e.do(t, http.MethodGet, api.PathProjectKeys("other"), mine.Token, nil); status != http.StatusForbidden { t.Errorf("read another project's keys: status = %d, want 403", status) } // The admin listing spans projects and names each key's project. status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil) var all api.KeyList mustJSON(t, status, http.StatusOK, body, &all) byID := make(map[string]api.Key, len(all.Keys)) for _, k := range all.Keys { byID[k.ID] = k } if got := byID[foreign.Key.ID].Project; got != "other" { t.Errorf("foreign key's project = %q, want other", got) } if _, ok := byID[mine.Key.ID]; !ok { t.Errorf("admin listing is missing %s", mine.Key.ID) } }