Files
llmgw/internal/web/claim_test.go
T
Ilker ManapandClaude Opus 5 3ed211b2f4 Onboard users with one-time links instead of pasted keys
Issuing a key worked; getting it to the person it was issued for did not. The
operator had a secret on their terminal and no way to send it that did not
leave a bearer credential in a chat log forever.

`keys create --claim` prints a link instead. GET shows a button and consumes
nothing; POST reveals the key and burns the link. That split is not politeness
— chat clients, mail scanners and link previewers fetch URLs the moment they
are posted, so a GET that consumed the claim would mean the first thing to
unfurl the link destroys the key before anyone reads it.

A pending claim has to hold the key somewhere, which would otherwise put a
working credential in the database and undo what hashing bought. So the key is
sealed with a key derived from the link's own token, and the token is never
stored — only its hash. A database leak yields two useless hashes and a blob
nothing in the database can open. HKDF rather than the raw token, so the value
used for lookup can never become the value that decrypts.

Consuming is one UPDATE moving claimed_at off NULL, so two people opening the
same link produce one key rather than two copies. A used link, an expired one
and a token that was never real all render identically, because saying which
confirms that a token was once valid. The page is no-store, no-referrer and
noindex: the URL is itself the credential.

`GET /me` reports a key's own limits and spend, so nobody has to ask an
operator to read a dashboard they cannot reach. Outside /v1, because it is not
part of the OpenAI API.

docs/onboarding.md is written for whoever operates this, including the parts
that are deliberately absent — no self-service, no SSO, and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:11:28 +02:00

149 lines
4.2 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// fakeClaims is one pending claim.
type fakeClaims struct {
secret string
consumed atomic.Bool
peeks atomic.Int64
}
func (f *fakeClaims) PeekClaim(context.Context, string) (store.Claim, error) {
f.peeks.Add(1)
if f.consumed.Load() {
return store.Claim{}, store.ErrClaimed
}
return store.Claim{KeyID: "key-1", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (f *fakeClaims) ConsumeClaim(context.Context, string) (string, error) {
if f.consumed.Swap(true) {
return "", store.ErrClaimed
}
return f.secret, nil
}
func do(t *testing.T, h http.Handler, method, target string) (int, string, http.Header) {
t.Helper()
// The handler reads the token from a path value, so it has to go through a
// mux that sets one.
mux := http.NewServeMux()
mux.Handle("GET /claim/{token}", h)
mux.Handle("POST /claim/{token}", h)
r := httptest.NewRequest(method, target, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
return w.Code, w.Body.String(), w.Header()
}
func TestOpeningTheLinkDoesNotBurnIt(t *testing.T) {
// The reason GET and POST differ. Chat clients, mail scanners and link
// previewers fetch a URL the moment it is posted; a GET that consumed the
// claim would mean the first thing to unfurl the link destroys the key
// before anyone reads it.
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
h := Claim(f, "v1", nil)
for range 5 {
code, body, _ := do(t, h, http.MethodGet, "http://x/claim/tok")
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
if strings.Contains(body, "thesecret") {
t.Fatal("a GET revealed the key")
}
}
if f.consumed.Load() {
t.Fatal("a GET consumed the claim")
}
}
func TestRevealingShowsTheKeyOnceAndThenTheLinkIsGone(t *testing.T) {
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
h := Claim(f, "v1", nil)
code, body, _ := do(t, h, http.MethodPost, "http://x/claim/tok")
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "sk-llmgw-thesecret") {
t.Fatal("the key was not shown")
}
code, body, _ = do(t, h, http.MethodPost, "http://x/claim/tok")
if code != http.StatusGone {
t.Errorf("a second reveal returned %d", code)
}
if strings.Contains(body, "thesecret") {
t.Fatal("the key was shown twice")
}
}
func TestTheKeyPageIsNotCached(t *testing.T) {
// A key rendered into a page must not sit in a proxy, a browser cache or a
// search index.
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
_, _, hdr := do(t, Claim(f, "v1", nil), http.MethodPost, "http://x/claim/tok")
if cc := hdr.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control is %q", cc)
}
if hdr.Get("X-Robots-Tag") == "" {
t.Error("the page is not marked noindex")
}
if hdr.Get("Referrer-Policy") != "no-referrer" {
t.Error("the URL — which is the credential — would leak in a referrer")
}
}
func TestAUsedLinkAndAnUnknownOneReadTheSame(t *testing.T) {
// Distinguishing them confirms that a token was once valid.
used := &fakeClaims{secret: "s"}
used.consumed.Store(true)
unknown := &fakeClaims{secret: "s"}
unknown.consumed.Store(true)
_, a, _ := do(t, Claim(used, "v1", nil), http.MethodGet, "http://x/claim/real")
_, b, _ := do(t, Claim(unknown, "v1", nil), http.MethodGet, "http://x/claim/never")
// Strip the timestamp, which differs by construction.
if strip(a) != strip(b) {
t.Error("a used link and an unknown one render differently")
}
}
func strip(s string) string {
if i := strings.Index(s, "<footer>"); i >= 0 {
return s[:i]
}
return s
}
func TestTheExamplesUseTheAddressTheUserReached(t *testing.T) {
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
mux := http.NewServeMux()
mux.Handle("POST /claim/{token}", Claim(f, "v1", nil))
r := httptest.NewRequest(http.MethodPost, "http://10.0.0.140:8080/claim/tok", nil)
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", "llmgw.mynodes.xyz")
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
body := w.Body.String()
if !strings.Contains(body, "https://llmgw.mynodes.xyz/v1/chat/completions") {
t.Error("the example names an address the user cannot use")
}
}