diff --git a/.gitignore b/.gitignore index 25ddf1e..5fc9905 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,15 @@ go.work.sum /bin/ /site/ +# UI build outputs (regenerated by `make ui`; only .gitkeep is tracked +# under internal/ui/dist so the //go:embed directive has something to +# embed when the SPA hasn't been built yet) +/ui/dist/ +/ui/node_modules/ +/ui/tsconfig.tsbuildinfo +/internal/ui/dist/* +!/internal/ui/dist/.gitkeep + # Dev secrets (generated by `make dev-secrets`) .env.dev diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..44b91ad --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# +# Dockerfile for compose / local development. Builds the SPA, builds the +# Go binary, and ships it in a distroless static image. Slower than the +# goreleaser-shaped Dockerfile (which expects a pre-built binary), but +# self-contained: `docker compose build` Just Works. + +FROM node:22-alpine AS ui +WORKDIR /src/ui +COPY ui/package.json ui/pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY ui/ ./ +RUN pnpm build + +FROM golang:1.26-alpine AS build +WORKDIR /src +RUN apk add --no-cache git ca-certificates +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=ui /src/ui/dist /src/internal/ui/dist +ARG VERSION=dev +ARG COMMIT=unknown +ARG DATE=unknown +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags "-s -w -X github.com/plexara/api-test/pkg/build.Version=${VERSION} -X github.com/plexara/api-test/pkg/build.Commit=${COMMIT} -X github.com/plexara/api-test/pkg/build.Date=${DATE}" \ + -o /out/api-test ./cmd/api-test + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/api-test /usr/local/bin/api-test +COPY configs/api-test.example.yaml /app/configs/api-test.yaml + +USER nonroot:nonroot +EXPOSE 8080 + +# Distroless has no shell so we use the binary itself for healthcheck. +# /healthz returns 200 from the unauthenticated health endpoint. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["/usr/local/bin/api-test", "--healthcheck"] || exit 1 + +ENTRYPOINT ["/usr/local/bin/api-test"] +CMD ["--config", "/app/configs/api-test.yaml"] diff --git a/Makefile b/Makefile index 1d9d7f6..f03e07f 100644 --- a/Makefile +++ b/Makefile @@ -55,10 +55,10 @@ CODEQL_RESULT := $(BUILD_DIR)/codeql-results.sarif .PHONY: all build build-all test test-short bench fmt fmt-check vet tidy \ mod-tidy-check mod-verify clean help dev-secrets \ - ui ui-dev ui-clean embed-clean \ + ui ui-dev ui-clean ui-verify embed-clean \ lint security gosec govulncheck semgrep \ coverage coverage-gate coverage-report \ - integration codeql require-docker require-codeql require-semgrep require-jq \ + integration codeql require-docker require-codeql require-semgrep require-jq require-node \ verify tools-check tools-install \ dev dev-anon dev-up dev-wait dev-ui-if-needed dev-down dev-logs \ docker docs docs-serve run version @@ -79,6 +79,39 @@ build-all: @echo "Building all packages..." $(GOBUILD) -v ./... +## ui: Build the SPA into internal/ui/dist for embedding into the binary. +## Uses pnpm with --frozen-lockfile so reproducible. +ui: require-node + @echo "Building UI..." + cd $(UI_DIR) && pnpm install --frozen-lockfile && pnpm build + @rm -rf $(UI_EMBED_DIR) + @cp -R $(UI_DIR)/dist $(UI_EMBED_DIR) + @echo "UI built and copied to $(UI_EMBED_DIR)." + +## ui-verify: TypeScript + Vite build of the SPA without copying to the +## embed dir. Mirrored from CI's frontend job — catches type +## errors and broken imports without rebuilding the Go binary. +ui-verify: require-node + @echo "Verifying UI (typecheck + build)..." + cd $(UI_DIR) && pnpm install --frozen-lockfile && pnpm build + +## ui-dev: Run Vite dev server (proxies /api to localhost:8080). +ui-dev: + cd $(UI_DIR) && pnpm dev + +## ui-clean: Remove UI build artifacts (dist + node_modules). +ui-clean: + @rm -rf $(UI_DIR)/dist $(UI_DIR)/node_modules + +## embed-clean: Reset internal/ui/dist to .gitkeep only (matches a clean +## CI checkout; useful before `make ui` to confirm the build +## produces a complete dist tree from scratch). +embed-clean: + @echo "Cleaning UI embed directory..." + @rm -rf $(UI_EMBED_DIR) + @mkdir -p $(UI_EMBED_DIR) + @touch $(UI_EMBED_DIR)/.gitkeep + ## test: Run unit tests with race detector test: @echo "Running tests..." @@ -267,6 +300,14 @@ require-jq: exit 1; \ fi +require-node: + @if ! command -v pnpm >/dev/null 2>&1; then \ + echo "FAIL: pnpm not on PATH (UI build needs it)." >&2; \ + echo " brew install pnpm # macOS" >&2; \ + echo " npm install -g pnpm # any node install" >&2; \ + exit 1; \ + fi + ## tools-install: Install lint/security tools at the pinned versions into $(TOOLS_DIR). TOOLS_STAMP := $(TOOLS_DIR)/.installed-$(GOLANGCI_LINT_VERSION)-$(GOSEC_VERSION) tools-install: $(TOOLS_STAMP) @@ -302,7 +343,7 @@ tools-check: tools-install ## - docker (running) for `make integration` ## - codeql for `make codeql` ## - semgrep for `make semgrep` -verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security coverage-gate integration codeql +verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security coverage-gate integration codeql ui-verify @echo "" @echo "=== verify: all checks passed (CI-equivalent set) ===" @# Pre-commit gate sentinel: record the current diff hash so the @@ -312,11 +353,31 @@ verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security @{ git diff --cached HEAD 2>/dev/null; git diff 2>/dev/null; } \ | shasum -a 256 | cut -c1-16 > .claude/.last-verify-passed -## dev-anon: Run anonymous-mode dev binary; no DB, no auth (M1 happy path). -dev-anon: - $(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.dev.yaml +## dev: One-command full local stack — postgres + keycloak in docker, +## SPA built if missing, binary in foreground against the live config. +## Generates .env.dev with random secrets on first run (gitignored; +## subsequent runs reuse so portal sessions persist). +dev: dev-secrets dev-up dev-wait dev-ui-if-needed + @. ./.env.dev && \ + echo "" && \ + echo "Starting api-test (config: configs/api-test.live.yaml)..." && \ + echo " Portal: http://localhost:8080/portal/ (sign in with dev/dev or paste an API key)" && \ + echo " /v1/*: http://localhost:8080/v1/... (X-API-Key: \$$APITEST_DEV_KEY)" && \ + echo " Keycloak: http://localhost:8081/ (admin/admin)" && \ + echo " API key: $$APITEST_DEV_KEY" && \ + echo " Bearer: $$APITEST_DEV_BEARER" && \ + echo "" && \ + $(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.live.yaml + +## dev-anon: Run anonymous-mode dev binary against postgres only — no +## Keycloak, no auth required, no portal browser login. +## Fastest iteration for endpoint-group / audit work. +dev-anon: dev-secrets + @. ./.env.dev && docker compose -f docker-compose.dev.yml up -d postgres + @. ./.env.dev && $(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.dev.yaml ## dev-secrets: Generate .env.dev with random cookie secret + dev API key on first run. +## Re-run-safe; only writes if .env.dev is missing. dev-secrets: @if [ ! -f .env.dev ]; then \ echo "Generating .env.dev with random secrets (gitignored)..."; \ @@ -328,9 +389,39 @@ dev-secrets: chmod 600 .env.dev; \ fi -## dev: Full local stack (M3+). For now, points at dev-anon. -## M3 will replace with: postgres + keycloak in compose, binary in foreground. -dev: dev-anon +## dev-up: Start the dev stack (postgres + keycloak) without the binary. +## Depends on dev-secrets because docker compose interpolates the +## APITEST_COOKIE_SECRET reference at parse time even when the +## api-test service isn't being started. +dev-up: dev-secrets require-docker + @. ./.env.dev && docker compose -f docker-compose.dev.yml up -d postgres keycloak + +## dev-wait: Block until postgres and keycloak are reachable. +## Sources .env.dev because `docker compose exec` re-parses the +## compose file and its APITEST_* references must resolve. +dev-wait: dev-secrets + @echo "Waiting for Postgres..." + @. ./.env.dev && until docker compose -f docker-compose.dev.yml exec -T postgres pg_isready -U api >/dev/null 2>&1; do sleep 1; done + @echo "Waiting for Keycloak realm..." + @until curl -fs http://localhost:8081/realms/api-test/.well-known/openid-configuration >/dev/null 2>&1; do sleep 2; done + @echo "Stack ready." + +## dev-ui-if-needed: Build the SPA if internal/ui/dist/index.html is missing. +## Skipped silently when the embed is already populated. +dev-ui-if-needed: + @if [ ! -f $(UI_EMBED_DIR)/index.html ]; then \ + $(MAKE) ui; \ + fi + +## dev-down: Stop the dev stack and remove the compose network. Volumes +## (postgres data) are kept; add `-v` to the underlying command +## to wipe them. +dev-down: dev-secrets + @. ./.env.dev && docker compose -f docker-compose.dev.yml down + +## dev-logs: Tail compose logs (postgres + keycloak + binary if running). +dev-logs: dev-secrets + @. ./.env.dev && docker compose -f docker-compose.dev.yml logs -f --tail=100 ## run: Build and run with dev config run: build diff --git a/configs/api-test.live.yaml b/configs/api-test.live.yaml index 1b65069..fbd207e 100644 --- a/configs/api-test.live.yaml +++ b/configs/api-test.live.yaml @@ -14,6 +14,7 @@ oidc: enabled: true issuer: "http://localhost:8081/realms/api-test" audience: "api-test" + client_id: "api-test-portal" allowed_clients: ["api-test-portal", "plexara-cc", "plexara-ac"] clock_skew_seconds: 30 jwks_cache_ttl: 1h diff --git a/dev/keycloak/api-test-realm.json b/dev/keycloak/api-test-realm.json new file mode 100644 index 0000000..2ec8fe0 --- /dev/null +++ b/dev/keycloak/api-test-realm.json @@ -0,0 +1,112 @@ +{ + "realm": "api-test", + "enabled": true, + "displayName": "api-test (development)", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + + "clients": [ + { + "clientId": "api-test-portal", + "name": "api-test portal (browser PKCE)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "redirectUris": [ + "http://localhost:8080/portal/auth/callback" + ], + "webOrigins": [ + "http://localhost:8080" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "protocolMappers": [ + { + "name": "api-test-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "api-test", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "plexara-cc", + "name": "Plexara API gateway (oauth2_client_credentials connections)", + "enabled": true, + "publicClient": false, + "secret": "dev-plexara-cc-client-secret", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "protocolMappers": [ + { + "name": "api-test-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "api-test", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "plexara-ac", + "name": "Plexara API gateway (oauth2_authorization_code connections)", + "enabled": true, + "publicClient": false, + "secret": "dev-plexara-ac-client-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://localhost:9000/api/v1/admin/api-gateway/oauth/callback", + "http://localhost:8080/*" + ], + "protocolMappers": [ + { + "name": "api-test-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "api-test", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + + "users": [ + { + "username": "dev", + "enabled": true, + "emailVerified": true, + "firstName": "Dev", + "lastName": "User", + "email": "dev@example.com", + "credentials": [ + { + "type": "password", + "value": "dev", + "temporary": false + } + ] + } + ] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..41269c7 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,88 @@ +# Development stack for api-test. +# +# docker compose -f docker-compose.dev.yml up -d postgres keycloak +# +# Services: +# - postgres: stores audit_events, audit_payloads, api_keys +# - keycloak: real OIDC IdP for testing portal browser login (PKCE) +# and the Plexara API gateway's oauth2_client_credentials / +# oauth2_authorization_code connections (the JWTs the +# gateway forwards as Authorization: Bearer are +# validated by api-test against this Keycloak's JWKS). +# +# To bring up the binary too: `docker compose -f docker-compose.dev.yml --profile server up`. +# Most of the time we run the binary outside compose with `make dev` for +# faster iteration. + +services: + postgres: + image: postgres:16-alpine + container_name: api-test-pg + environment: + POSTGRES_USER: api + POSTGRES_PASSWORD: api + POSTGRES_DB: apitest + # Bind to loopback only so the dev DB isn't reachable from the local + # network. Override with COMPOSE_BIND=0.0.0.0 if you actually need it. + ports: + - "127.0.0.1:5432:5432" + volumes: + - api-test-pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U api"] + interval: 5s + timeout: 3s + retries: 10 + + keycloak: + image: quay.io/keycloak/keycloak:25.0 + container_name: api-test-keycloak + command: + - start-dev + - --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + ports: + - "127.0.0.1:8081:8080" + volumes: + - ./dev/keycloak:/opt/keycloak/data/import:ro + depends_on: + postgres: + condition: service_healthy + + # Runtime image of api-test for compose users who want everything in + # docker. Most of the time `make dev` is faster: it runs the binary + # outside compose against the postgres + keycloak services above. + api-test: + build: + context: . + dockerfile: Dockerfile.dev + image: api-test:dev + container_name: api-test-server + environment: + APITEST_DB_URL: postgres://api:api@postgres:5432/apitest?sslmode=disable + APITEST_BASE_URL: http://localhost:8080 + APITEST_OIDC_ISSUER: http://keycloak:8080/realms/api-test + APITEST_OIDC_AUDIENCE: api-test + APITEST_OIDC_CLIENT_ID: api-test-portal + # Both required (no in-config defaults). For dev convenience compose + # passes literal placeholders here; in any non-dev use, set them + # via shell env or a .env file. `make dev-secrets` writes them to + # a gitignored .env.dev that compose reads via env_file. + APITEST_COOKIE_SECRET: ${APITEST_COOKIE_SECRET:?required} + APITEST_DEV_KEY: ${APITEST_DEV_KEY:?required} + APITEST_DEV_BEARER: ${APITEST_DEV_BEARER:?required} + command: ["--config", "/app/configs/api-test.yaml"] + ports: + - "127.0.0.1:8080:8080" + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_started + profiles: + - server + +volumes: + api-test-pg-data: diff --git a/go.mod b/go.mod index 5864b6c..713caf5 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/plexara/api-test go 1.26.3 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 golang.org/x/crypto v0.51.0 + golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -63,7 +65,6 @@ require ( go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 5b6dfb7..93450eb 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/internal/server/server.go b/internal/server/server.go index 91a5680..bdc086e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,9 +16,11 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "github.com/plexara/api-test/internal/ui" "github.com/plexara/api-test/pkg/apikeys" "github.com/plexara/api-test/pkg/audit" auditpg "github.com/plexara/api-test/pkg/audit/postgres" + "github.com/plexara/api-test/pkg/auth" "github.com/plexara/api-test/pkg/auth/inbound" "github.com/plexara/api-test/pkg/build" "github.com/plexara/api-test/pkg/config" @@ -109,7 +111,14 @@ func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Appli } app.readiness = httpsrv.NewReadiness() - core := httpsrv.BuildMux(app.registry, app.readiness, endpointMW) + + // --- Portal (M3+) --- + portalDeps, err := buildPortal(ctx, cfg, app.chain, app.auditLog, app.registry, app.dbKeys, logger) + if err != nil { + return nil, fmt.Errorf("portal: %w", err) + } + + core := httpsrv.BuildMux(app.registry, app.readiness, endpointMW, portalDeps) // AccessLog + RequestID wrap the entire mux so health probes also get // request ids; identity/audit only run on endpoint group routes (via // endpointMW above). @@ -117,6 +126,64 @@ func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Appli return app, nil } +// buildPortal returns the portal handler bundle when cfg.Portal.Enabled is +// true. Returns (nil, nil) when the portal is disabled — the mux falls back +// to the bare /v1/* + /healthz surface. +// +// The OIDC validator + BrowserAuth construction will hit the configured +// issuer's discovery URL at startup; misconfiguration (wrong issuer, IdP +// down) fails Build() rather than the first portal request. +func buildPortal( + ctx context.Context, + cfg *config.Config, + chain *inbound.Chain, + auditLog audit.Logger, + registry *endpoints.Registry, + keys *apikeys.Store, + logger *slog.Logger, +) (*httpsrv.PortalDeps, error) { + if !cfg.Portal.Enabled { + return nil, nil + } + sessions, err := httpsrv.NewSessionStore( + cfg.Portal.CookieName, + cfg.Portal.CookieSecret, + cfg.Portal.CookieSecure, + 0, + ) + if err != nil { + return nil, fmt.Errorf("session store: %w", err) + } + deps := &httpsrv.PortalDeps{ + Cfg: cfg, + PortalAuth: httpsrv.NewPortalAuth(sessions, chain), + PortalAPI: httpsrv.NewPortalAPI(cfg, registry, auditLog, keys), + } + if cfg.OIDC.Enabled { + validator, err := auth.NewOIDC(ctx, cfg.OIDC) + if err != nil { + return nil, fmt.Errorf("oidc validator: %w", err) + } + ba, err := httpsrv.NewBrowserAuth(ctx, cfg, validator, sessions, logger) + if err != nil { + return nil, fmt.Errorf("browser auth: %w", err) + } + deps.BrowserAuth = ba + } else { + logger.Info("portal: oidc disabled, login will not work") + } + if ui.Available() { + spa, err := ui.FS() + if err != nil { + return nil, fmt.Errorf("ui fs: %w", err) + } + deps.SPA = spa + } else { + logger.Warn("portal: ui dist is empty (run `make ui`); /portal/ will return 503") + } + return deps, nil +} + // BuildWithDeps assembles an Application from supplied dependencies, // skipping database setup. Used by tests that inject in-memory loggers // and stub auth. @@ -139,7 +206,7 @@ func BuildWithDeps(cfg *config.Config, logger *slog.Logger, chain *inbound.Chain return identityMW(auditMW(next)) } readiness := httpsrv.NewReadiness() - core := httpsrv.BuildMux(registry, readiness, endpointMW) + core := httpsrv.BuildMux(registry, readiness, endpointMW, nil) mux := httpmw.RequestID(httpmw.AccessLog(logger)(core)) return &Application{ cfg: cfg, diff --git a/internal/ui/embed_test.go b/internal/ui/embed_test.go index ebd8d96..78a99be 100644 --- a/internal/ui/embed_test.go +++ b/internal/ui/embed_test.go @@ -1,16 +1,50 @@ package ui -import "testing" +import ( + "io/fs" + "testing" +) -func TestAvailable_NoSPA(t *testing.T) { - // In M1 dist/ only contains .gitkeep, so Available() returns false. - if Available() { - t.Error("Available() = true, want false (no SPA built yet)") +// distHasIndex reports whether the embedded dist/ tree contains an index.html +// file (i.e. `make ui` has actually populated it). The unit tests must be +// robust to either state because the embed is part of the source tree: +// CI runs from a clean checkout (only .gitkeep present), but a developer +// who has run `make dev` will have a full SPA in the embed. Asserting one +// fixed answer divorces the tests from the actual artifact and produces +// the local-vs-CI divergence we explicitly want to avoid. +func distHasIndex(t *testing.T) bool { + t.Helper() + entries, err := distFS.ReadDir("dist") + if err != nil { + return false + } + for _, e := range entries { + if e.Name() == "index.html" { + return true + } + } + return false +} + +func TestAvailable_MatchesDiskState(t *testing.T) { + want := distHasIndex(t) + if got := Available(); got != want { + t.Errorf("Available() = %v, want %v (based on real dist/ contents)", got, want) } } -func TestFS_ErrorsWhenEmpty(t *testing.T) { - if _, err := FS(); err == nil { - t.Error("FS() returned nil error with empty dist/") +func TestFS_ContractMatchesAvailability(t *testing.T) { + hasIndex := distHasIndex(t) + sub, err := FS() + switch { + case hasIndex && err != nil: + t.Errorf("FS() returned error %v despite dist/index.html existing", err) + case !hasIndex && err == nil: + t.Errorf("FS() returned nil error with empty dist/") + case hasIndex && err == nil: + // Sanity-check the returned subtree: index.html should be readable. + if _, ferr := fs.ReadFile(sub, "index.html"); ferr != nil { + t.Errorf("FS().ReadFile(index.html) failed: %v", ferr) + } } } diff --git a/pkg/auth/context.go b/pkg/auth/context.go new file mode 100644 index 0000000..39d6d7d --- /dev/null +++ b/pkg/auth/context.go @@ -0,0 +1,100 @@ +package auth + +import ( + "context" + "net/http" +) + +type ctxKey int + +const ( + keyIdentity ctxKey = iota + keyHeaders + keyRequestID + keyRemoteAddr +) + +// WithIdentity stashes the resolved portal-session identity for downstream +// handlers (e.g. /portal/api/whoami). +func WithIdentity(ctx context.Context, id *Identity) context.Context { + return context.WithValue(ctx, keyIdentity, id) +} + +// GetIdentity retrieves the resolved identity, or nil if not yet authenticated. +func GetIdentity(ctx context.Context) *Identity { + if v, ok := ctx.Value(keyIdentity).(*Identity); ok { + return v + } + return nil +} + +// sensitiveHeaders is the set of inbound HTTP header names whose values are +// stripped before the headers reach ctx (and from there, the audit_payloads +// row). These carry credentials (Authorization, Cookie, X-API-Key) or proxy +// auth state, none of which the portal needs to introspect and all of which +// are dangerous to surface in a UI. +var sensitiveHeaders = map[string]struct{}{ + "Authorization": {}, + "Proxy-Authorization": {}, + "Cookie": {}, + "Set-Cookie": {}, + "X-Api-Key": {}, // canonical form of X-API-Key +} + +// RedactHeaders returns a clone of h with sensitive header values replaced by +// a single "[redacted]" entry. Header names are preserved so an operator +// reading the audit log can still see "this request carried an Authorization +// header" without seeing the bearer. +func RedactHeaders(h http.Header) http.Header { + if h == nil { + return nil + } + out := make(http.Header, len(h)) + for k, vs := range h { + if _, ok := sensitiveHeaders[http.CanonicalHeaderKey(k)]; ok { + out[k] = []string{"[redacted]"} + continue + } + out[k] = append([]string(nil), vs...) + } + return out +} + +// WithHeaders stashes a redacted clone of the inbound HTTP headers. +func WithHeaders(ctx context.Context, h http.Header) context.Context { + return context.WithValue(ctx, keyHeaders, RedactHeaders(h)) +} + +// GetHeaders retrieves the captured headers, or nil if none were stashed. +func GetHeaders(ctx context.Context) http.Header { + if v, ok := ctx.Value(keyHeaders).(http.Header); ok { + return v + } + return nil +} + +// WithRequestID attaches a request ID to the context. +func WithRequestID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, keyRequestID, id) +} + +// GetRequestID returns the request ID, or "" if absent. +func GetRequestID(ctx context.Context) string { + if v, ok := ctx.Value(keyRequestID).(string); ok { + return v + } + return "" +} + +// WithRemoteAddr stashes the caller's remote address. +func WithRemoteAddr(ctx context.Context, addr string) context.Context { + return context.WithValue(ctx, keyRemoteAddr, addr) +} + +// GetRemoteAddr returns the caller's remote address. +func GetRemoteAddr(ctx context.Context) string { + if v, ok := ctx.Value(keyRemoteAddr).(string); ok { + return v + } + return "" +} diff --git a/pkg/auth/context_test.go b/pkg/auth/context_test.go new file mode 100644 index 0000000..4d5d948 --- /dev/null +++ b/pkg/auth/context_test.go @@ -0,0 +1,102 @@ +package auth + +import ( + "context" + "net/http" + "reflect" + "testing" +) + +func TestWithAndGetIdentity(t *testing.T) { + ctx := context.Background() + if got := GetIdentity(ctx); got != nil { + t.Errorf("GetIdentity on empty ctx = %v, want nil", got) + } + id := &Identity{Subject: "alice", AuthType: "oidc"} + ctx = WithIdentity(ctx, id) + got := GetIdentity(ctx) + if got != id { + t.Errorf("GetIdentity = %v, want %v (same pointer)", got, id) + } +} + +func TestRedactHeaders_StripsSensitiveValuesPreservesNames(t *testing.T) { + h := http.Header{ + "Authorization": {"Bearer abc.def.ghi"}, + "Proxy-Authorization": {"Basic xxx"}, + "Cookie": {"sid=secret"}, + "Set-Cookie": {"sid=secret; HttpOnly"}, + "X-Api-Key": {"plaintext-key"}, + "Content-Type": {"application/json"}, + "X-Request-Id": {"req-1", "req-2"}, + } + out := RedactHeaders(h) + for _, k := range []string{"Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie", "X-Api-Key"} { + if got := out.Get(k); got != "[redacted]" { + t.Errorf("%s header = %q, want \"[redacted]\"", k, got) + } + } + if got := out.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want preserved", got) + } + if got := out["X-Request-Id"]; !reflect.DeepEqual(got, []string{"req-1", "req-2"}) { + t.Errorf("X-Request-Id = %v, want both values preserved", got) + } +} + +func TestRedactHeaders_NilInputReturnsNil(t *testing.T) { + if got := RedactHeaders(nil); got != nil { + t.Errorf("RedactHeaders(nil) = %v, want nil", got) + } +} + +func TestRedactHeaders_DoesNotMutateInput(t *testing.T) { + h := http.Header{"Authorization": {"Bearer secret"}} + _ = RedactHeaders(h) + if got := h.Get("Authorization"); got != "Bearer secret" { + t.Errorf("input header mutated: got %q", got) + } +} + +func TestWithAndGetHeaders(t *testing.T) { + ctx := context.Background() + if got := GetHeaders(ctx); got != nil { + t.Errorf("GetHeaders on empty ctx = %v, want nil", got) + } + in := http.Header{"Authorization": {"Bearer s"}, "X-Trace": {"t1"}} + ctx = WithHeaders(ctx, in) + got := GetHeaders(ctx) + if got.Get("Authorization") != "[redacted]" { + t.Errorf("stashed Authorization = %q, want [redacted]", got.Get("Authorization")) + } + if got.Get("X-Trace") != "t1" { + t.Errorf("stashed X-Trace = %q, want t1", got.Get("X-Trace")) + } +} + +func TestWithAndGetRequestID(t *testing.T) { + if got := GetRequestID(context.Background()); got != "" { + t.Errorf("GetRequestID on empty ctx = %q, want empty", got) + } + ctx := WithRequestID(context.Background(), "req-42") + if got := GetRequestID(ctx); got != "req-42" { + t.Errorf("GetRequestID = %q, want req-42", got) + } +} + +func TestWithAndGetRemoteAddr(t *testing.T) { + if got := GetRemoteAddr(context.Background()); got != "" { + t.Errorf("GetRemoteAddr on empty ctx = %q, want empty", got) + } + ctx := WithRemoteAddr(context.Background(), "10.0.0.1:9999") + if got := GetRemoteAddr(ctx); got != "10.0.0.1:9999" { + t.Errorf("GetRemoteAddr = %q, want 10.0.0.1:9999", got) + } +} + +func TestAnonymousIdentity(t *testing.T) { + a := Anonymous() + if a.Subject != "anonymous" || a.AuthType != "anonymous" { + t.Errorf("Anonymous() = %+v, want subject/authtype = anonymous", a) + } +} diff --git a/pkg/auth/identity.go b/pkg/auth/identity.go new file mode 100644 index 0000000..545c55b --- /dev/null +++ b/pkg/auth/identity.go @@ -0,0 +1,35 @@ +// Package auth holds identity types and authenticators for portal browser +// sessions. It is distinct from pkg/auth/inbound, which handles the +// credentials Plexara API gateway connections present to api-test. +// +// The two packages are intentionally split: +// - pkg/auth/inbound resolves "what creds did the upstream gateway send" +// and produces an inbound.Identity attached to /v1/* request contexts. +// - pkg/auth (this package) resolves "who's signed into the portal in +// this browser" and produces an auth.Identity stored in the session +// cookie and returned by /portal/api/whoami. +// +// They share concepts but not types; mixing them invites bugs where a +// portal-only field (e.g. browser session cookie) gets used as if it +// were a gateway-presented credential. +package auth + +// Identity describes the authenticated portal user for a browser session. +// Populated by the OIDC PKCE callback handler (pkg/httpsrv/browserauth.go) +// from the validated ID token's claims, encoded into the session cookie +// by pkg/httpsrv/session.go. +type Identity struct { + Subject string `json:"subject"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + AuthType string `json:"auth_type"` // "oidc" | "apikey" | "anonymous" + Claims map[string]any `json:"claims,omitempty"` + APIKeyID string `json:"api_key_id,omitempty"` +} + +// Anonymous returns the identity used when allow_anonymous is true and no +// credentials are presented. Mirrors inbound.Anonymous but is not the same +// type; the two domains are kept separate. +func Anonymous() *Identity { + return &Identity{Subject: "anonymous", AuthType: "anonymous"} +} diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 0000000..b87f4b7 --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,324 @@ +package auth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/sync/singleflight" + + "github.com/plexara/api-test/pkg/config" +) + +// OIDCConfig is the runtime view of config.OIDCConfig used by the validator. +// +// Defining a thin internal type lets pkg/auth stay decoupled from config in +// places where that's helpful (e.g. tests). +type OIDCConfig = config.OIDCConfig + +// OIDCAuthenticator verifies bearer JWTs issued by an external OIDC provider. It +// caches JWKS public keys by kid, refreshing on a TTL. JWKS refreshes are +// deduplicated via singleflight so a stampede of unknown-kid requests does +// not hammer the IdP, and stale keys are served briefly past their TTL when +// a refresh fails (stale-while-revalidate) to ride out transient outages. +type OIDCAuthenticator struct { + cfg OIDCConfig + httpClient *http.Client + jwksURL string + + mu sync.RWMutex + keys map[string]*rsa.PublicKey + expiresAt time.Time + staleOK time.Time // keys may be served until this time even after expiry + + refresh singleflight.Group +} + +// staleGracePeriod controls how long expired JWKS keys remain usable while +// a refresh is failing. Long enough to ride out a brief IdP blip; short +// enough that revoked keys don't keep working all day. +const staleGracePeriod = 5 * time.Minute + +// NewOIDC returns an authenticator. It eagerly fetches the OpenID Discovery +// document so that misconfiguration (wrong issuer, network error) fails at +// startup instead of on the first request. +func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDCAuthenticator, error) { + if cfg.Issuer == "" { + return nil, errors.New("oidc: issuer is required") + } + v := &OIDCAuthenticator{ + cfg: cfg, + httpClient: &http.Client{Timeout: 10 * time.Second}, + keys: map[string]*rsa.PublicKey{}, + } + if err := v.discover(ctx); err != nil { + return nil, err + } + if err := v.refreshJWKS(ctx); err != nil { + return nil, err + } + return v, nil +} + +// ValidateBearer parses, verifies, and extracts identity claims from token. +func (v *OIDCAuthenticator) ValidateBearer(ctx context.Context, token string) (*Identity, error) { + if token == "" { + return nil, errors.New("empty bearer token") + } + + parser := jwt.NewParser( + jwt.WithLeeway(time.Duration(v.cfg.ClockSkewSeconds)*time.Second), + jwt.WithIssuer(v.cfg.Issuer), + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{"RS256", "RS384", "RS512"}), + ) + + keyfunc := func(t *jwt.Token) (any, error) { + if v.cfg.SkipSignatureVerification { + return nil, errors.New("signature verification skipped; do not call keyfunc") + } + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + kid, _ := t.Header["kid"].(string) + key, err := v.publicKey(ctx, kid) + if err != nil { + return nil, err + } + return key, nil + } + + var ( + parsed *jwt.Token + err error + ) + if v.cfg.SkipSignatureVerification { + parsed, _, err = parser.ParseUnverified(token, jwt.MapClaims{}) + } else { + parsed, err = parser.Parse(token, keyfunc) + } + if err != nil { + return nil, fmt.Errorf("parse jwt: %w", err) + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("jwt claims not a map") + } + + if v.cfg.Audience != "" { + if !audienceMatches(claims["aud"], v.cfg.Audience) { + return nil, fmt.Errorf("audience mismatch: want %q", v.cfg.Audience) + } + } + if len(v.cfg.AllowedClients) > 0 { + if !clientAllowed(claims, v.cfg.AllowedClients) { + return nil, errors.New("client not in allowed_clients") + } + } + + id := &Identity{ + AuthType: "oidc", + Claims: map[string]any(claims), + } + if sub, _ := claims["sub"].(string); sub != "" { + id.Subject = sub + } + if email, _ := claims["email"].(string); email != "" { + id.Email = email + } + if name, _ := claims["name"].(string); name != "" { + id.Name = name + } + if id.Subject == "" { + if v, _ := claims["preferred_username"].(string); v != "" { + id.Subject = v + } + } + return id, nil +} + +func (v *OIDCAuthenticator) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + v.mu.RLock() + now := time.Now() + if now.Before(v.expiresAt) { + if k, ok := v.keys[kid]; ok { + v.mu.RUnlock() + return k, nil + } + } + cached := v.keys[kid] + staleOK := v.staleOK + v.mu.RUnlock() + + _, err, _ := v.refresh.Do("jwks", func() (any, error) { + return nil, v.refreshJWKS(ctx) + }) + + if err != nil { + if cached != nil && time.Now().Before(staleOK) { + return cached, nil + } + return nil, err + } + + v.mu.RLock() + defer v.mu.RUnlock() + k, ok := v.keys[kid] + if !ok { + return nil, fmt.Errorf("no public key for kid %q", kid) + } + return k, nil +} + +type discoveryDoc struct { + JWKSURI string `json:"jwks_uri"` +} + +func (v *OIDCAuthenticator) discover(ctx context.Context) error { + url := trimRightSlash(v.cfg.Issuer) + "/.well-known/openid-configuration" + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := v.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discover %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("discover %s: status %d", url, resp.StatusCode) + } + var d discoveryDoc + if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { + return fmt.Errorf("decode discovery: %w", err) + } + if d.JWKSURI == "" { + return errors.New("discovery doc missing jwks_uri") + } + v.jwksURL = d.JWKSURI + return nil +} + +type jwksDoc struct { + Keys []jwkRSA `json:"keys"` +} + +type jwkRSA struct { + KTY string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +func (v *OIDCAuthenticator) refreshJWKS(ctx context.Context) error { + if v.jwksURL == "" { + if err := v.discover(ctx); err != nil { + return err + } + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil) + resp, err := v.httpClient.Do(req) + if err != nil { + return fmt.Errorf("fetch jwks: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch jwks: status %d", resp.StatusCode) + } + var d jwksDoc + if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { + return fmt.Errorf("decode jwks: %w", err) + } + + keys := make(map[string]*rsa.PublicKey, len(d.Keys)) + for _, jwk := range d.Keys { + if jwk.KTY != "RSA" { + continue + } + if jwk.Use != "" && jwk.Use != "sig" { + continue + } + if jwk.Alg != "" && jwk.Alg != "RS256" && jwk.Alg != "RS384" && jwk.Alg != "RS512" { + continue + } + k, err := decodeRSA(jwk.N, jwk.E) + if err != nil { + continue + } + keys[jwk.Kid] = k + } + + ttl := v.cfg.JWKSCacheTTL + if ttl == 0 { + ttl = time.Hour + } + + now := time.Now() + v.mu.Lock() + v.keys = keys + v.expiresAt = now.Add(ttl) + v.staleOK = now.Add(ttl + staleGracePeriod) + v.mu.Unlock() + return nil +} + +func decodeRSA(nB64, eB64 string) (*rsa.PublicKey, error) { + n, err := base64.RawURLEncoding.DecodeString(nB64) + if err != nil { + return nil, fmt.Errorf("decode n: %w", err) + } + e, err := base64.RawURLEncoding.DecodeString(eB64) + if err != nil { + return nil, fmt.Errorf("decode e: %w", err) + } + if len(e) > 4 { + return nil, fmt.Errorf("e too large: %d bytes", len(e)) + } + var ei int + for _, b := range e { + ei = ei<<8 + int(b) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: ei}, nil +} + +func audienceMatches(aud any, want string) bool { + switch v := aud.(type) { + case string: + return v == want + case []any: + for _, e := range v { + if s, ok := e.(string); ok && s == want { + return true + } + } + } + return false +} + +func clientAllowed(claims jwt.MapClaims, allowed []string) bool { + for _, claim := range []string{"azp", "client_id", "appid"} { + if v, ok := claims[claim].(string); ok && v != "" { + for _, a := range allowed { + if a == v { + return true + } + } + } + } + return false +} + +func trimRightSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} diff --git a/pkg/auth/oidc_test.go b/pkg/auth/oidc_test.go new file mode 100644 index 0000000..4c42dff --- /dev/null +++ b/pkg/auth/oidc_test.go @@ -0,0 +1,297 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ----- pure helpers ----- + +func TestTrimRightSlash(t *testing.T) { + cases := map[string]string{ + "": "", + "/": "", + "http://x": "http://x", + "http://x/": "http://x", + "http://x///": "http://x", + "http://x/realm/": "http://x/realm", + } + for in, want := range cases { + if got := trimRightSlash(in); got != want { + t.Errorf("trimRightSlash(%q) = %q, want %q", in, got, want) + } + } +} + +func TestAudienceMatches(t *testing.T) { + if !audienceMatches("api-test", "api-test") { + t.Error("string aud equal: should match") + } + if audienceMatches("other", "api-test") { + t.Error("string aud differ: should not match") + } + if !audienceMatches([]any{"a", "api-test", "b"}, "api-test") { + t.Error("slice aud containing want: should match") + } + if audienceMatches([]any{"a", "b"}, "api-test") { + t.Error("slice aud not containing want: should not match") + } + if audienceMatches(42, "api-test") { + t.Error("non-string-non-slice aud: should not match") + } + if audienceMatches([]any{42, true}, "api-test") { + t.Error("slice with no string elements: should not match") + } +} + +func TestClientAllowed(t *testing.T) { + allowed := []string{"web", "cli"} + if !clientAllowed(jwt.MapClaims{"azp": "web"}, allowed) { + t.Error("azp=web should be allowed") + } + if !clientAllowed(jwt.MapClaims{"client_id": "cli"}, allowed) { + t.Error("client_id=cli should be allowed") + } + if !clientAllowed(jwt.MapClaims{"appid": "web"}, allowed) { + t.Error("appid=web should be allowed") + } + if clientAllowed(jwt.MapClaims{"azp": "ghost"}, allowed) { + t.Error("azp=ghost should not be allowed") + } + if clientAllowed(jwt.MapClaims{}, allowed) { + t.Error("missing client claim: should not be allowed") + } +} + +func TestDecodeRSA_RoundTrip(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("genkey: %v", err) + } + pub := priv.Public().(*rsa.PublicKey) + n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()) + + got, err := decodeRSA(n, e) + if err != nil { + t.Fatalf("decodeRSA: %v", err) + } + if got.N.Cmp(pub.N) != 0 || got.E != pub.E { + t.Errorf("decoded key does not match: got E=%d N=%d, want E=%d N=%d", got.E, got.N, pub.E, pub.N) + } +} + +func TestDecodeRSA_InvalidInputs(t *testing.T) { + if _, err := decodeRSA("@@@", "AQAB"); err == nil { + t.Error("invalid n base64: want error") + } + if _, err := decodeRSA("AA", "@@@"); err == nil { + t.Error("invalid e base64: want error") + } + tooBigE := base64.RawURLEncoding.EncodeToString([]byte{1, 2, 3, 4, 5}) + if _, err := decodeRSA("AA", tooBigE); err == nil { + t.Error("oversize e: want error") + } +} + +// ----- ValidateBearer with a fake IdP ----- + +// fakeIdP serves /.well-known/openid-configuration + /jwks pointing at a +// transient RSA key so we can mint tokens it will validate. +type fakeIdP struct { + srv *httptest.Server + priv *rsa.PrivateKey + kid string +} + +func newFakeIdP(t *testing.T) *fakeIdP { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("genkey: %v", err) + } + idp := &fakeIdP{priv: priv, kid: "k1"} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": idp.srv.URL, + "jwks_uri": idp.srv.URL + "/jwks", + "authorization_endpoint": idp.srv.URL + "/authorize", + "token_endpoint": idp.srv.URL + "/token", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": idp.kid, + "n": base64.RawURLEncoding.EncodeToString(priv.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(priv.E)).Bytes()), + }, + }, + }) + }) + idp.srv = httptest.NewServer(mux) + t.Cleanup(idp.srv.Close) + return idp +} + +func (f *fakeIdP) sign(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = f.kid + s, err := tok.SignedString(f.priv) + if err != nil { + t.Fatalf("sign: %v", err) + } + return s +} + +func TestNewOIDC_RejectsEmptyIssuer(t *testing.T) { + _, err := NewOIDC(context.Background(), OIDCConfig{Enabled: true}) + if err == nil { + t.Fatal("NewOIDC with empty issuer: want error") + } +} + +func TestNewOIDC_FailsOnDiscoveryError(t *testing.T) { + _, err := NewOIDC(context.Background(), OIDCConfig{Enabled: true, Issuer: "http://127.0.0.1:1"}) + if err == nil { + t.Fatal("NewOIDC against unreachable issuer: want error") + } +} + +func TestValidateBearer_HappyPath(t *testing.T) { + idp := newFakeIdP(t) + v, err := NewOIDC(context.Background(), OIDCConfig{ + Enabled: true, + Issuer: idp.srv.URL, + Audience: "api-test", + AllowedClients: []string{"web"}, + }) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + tok := idp.sign(t, jwt.MapClaims{ + "iss": idp.srv.URL, + "sub": "alice", + "email": "alice@example.com", + "name": "Alice", + "aud": "api-test", + "azp": "web", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + id, err := v.ValidateBearer(context.Background(), tok) + if err != nil { + t.Fatalf("ValidateBearer: %v", err) + } + if id.Subject != "alice" || id.Email != "alice@example.com" || id.Name != "Alice" || id.AuthType != "oidc" { + t.Errorf("identity = %+v, want subject=alice email=alice@example.com name=Alice authtype=oidc", id) + } +} + +func TestValidateBearer_AudienceMismatch(t *testing.T) { + idp := newFakeIdP(t) + v, err := NewOIDC(context.Background(), OIDCConfig{Enabled: true, Issuer: idp.srv.URL, Audience: "expected"}) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + tok := idp.sign(t, jwt.MapClaims{ + "iss": idp.srv.URL, + "sub": "alice", + "aud": "different", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + if _, err := v.ValidateBearer(context.Background(), tok); err == nil { + t.Fatal("audience mismatch: want error") + } +} + +func TestValidateBearer_DisallowedClient(t *testing.T) { + idp := newFakeIdP(t) + v, err := NewOIDC(context.Background(), OIDCConfig{ + Enabled: true, + Issuer: idp.srv.URL, + AllowedClients: []string{"web"}, + }) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + tok := idp.sign(t, jwt.MapClaims{ + "iss": idp.srv.URL, + "sub": "alice", + "azp": "ghost", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + if _, err := v.ValidateBearer(context.Background(), tok); err == nil { + t.Fatal("disallowed client: want error") + } +} + +func TestValidateBearer_ExpiredToken(t *testing.T) { + idp := newFakeIdP(t) + v, err := NewOIDC(context.Background(), OIDCConfig{Enabled: true, Issuer: idp.srv.URL}) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + tok := idp.sign(t, jwt.MapClaims{ + "iss": idp.srv.URL, + "sub": "alice", + "exp": time.Now().Add(-time.Hour).Unix(), + "iat": time.Now().Add(-2 * time.Hour).Unix(), + }) + if _, err := v.ValidateBearer(context.Background(), tok); err == nil { + t.Fatal("expired token: want error") + } +} + +func TestValidateBearer_EmptyToken(t *testing.T) { + idp := newFakeIdP(t) + v, err := NewOIDC(context.Background(), OIDCConfig{Enabled: true, Issuer: idp.srv.URL}) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + if _, err := v.ValidateBearer(context.Background(), ""); err == nil { + t.Fatal("empty token: want error") + } +} + +func TestValidateBearer_FallbackSubjectFromPreferredUsername(t *testing.T) { + idp := newFakeIdP(t) + v, err := NewOIDC(context.Background(), OIDCConfig{Enabled: true, Issuer: idp.srv.URL}) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + tok := idp.sign(t, jwt.MapClaims{ + "iss": idp.srv.URL, + "preferred_username": "bob", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + id, err := v.ValidateBearer(context.Background(), tok) + if err != nil { + t.Fatalf("ValidateBearer: %v", err) + } + if id.Subject != "bob" { + t.Errorf("subject fallback = %q, want bob", id.Subject) + } +} diff --git a/pkg/httpsrv/authgate.go b/pkg/httpsrv/authgate.go new file mode 100644 index 0000000..951cc6a --- /dev/null +++ b/pkg/httpsrv/authgate.go @@ -0,0 +1,22 @@ +package httpsrv + +import ( + "net/http" + "strings" +) + +// hasCredential reports whether the request carries something the inbound +// auth chain might recognize: an X-API-Key header or an Authorization: +// Bearer token. Used by the portal auth middleware to decide whether to +// run the (relatively expensive) chain at all on requests that lack any +// credential, and to gate the script-client fallback path. +func hasCredential(r *http.Request) bool { + if r.Header.Get("X-API-Key") != "" { + return true + } + a := r.Header.Get("Authorization") + if a == "" { + return false + } + return strings.HasPrefix(strings.ToLower(a), "bearer ") +} diff --git a/pkg/httpsrv/browserauth.go b/pkg/httpsrv/browserauth.go new file mode 100644 index 0000000..b9606e2 --- /dev/null +++ b/pkg/httpsrv/browserauth.go @@ -0,0 +1,367 @@ +package httpsrv + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/config" +) + +// BrowserAuth implements the OIDC PKCE login flow for the portal SPA. +// +// Endpoints: +// - GET /portal/auth/login ; generates state + code_verifier, sets a +// short-lived signed cookie, redirects to the IdP authorization endpoint. +// - GET /portal/auth/callback; validates state cookie, exchanges code for +// tokens, validates the ID token via the OIDC validator, and issues the +// long-lived session cookie carrying the resolved Identity. +// - POST /portal/auth/logout ; clears the session cookie. +type BrowserAuth struct { + cfg config.OIDCConfig + baseURL string + redirectPath string + validator *auth.OIDCAuthenticator + sessions *SessionStore + pkceCookie *SessionStore // separate signing for the short-lived state cookie + logger *slog.Logger + + authzURL string + tokenURL string + httpc *http.Client + + // Nonce ledger: every PKCE cookie carries a fresh nonce; on successful + // callback we record it as used and reject replays for 10 minutes. This + // is defense-in-depth on top of single-use IdP codes. + usedNoncesMu sync.Mutex + usedNonces map[string]time.Time +} + +// NewBrowserAuth wires the flow. The validator is the OIDC authenticator +// used elsewhere by the server (typically the same instance shared with +// inbound JWT validation). The PKCE signing key is derived from the +// long-lived cookie secret with a domain separator so a leak of one +// cookie does not directly forge the other. +func NewBrowserAuth( + ctx context.Context, + cfg *config.Config, + validator *auth.OIDCAuthenticator, + sessions *SessionStore, + logger *slog.Logger, +) (*BrowserAuth, error) { + if !cfg.OIDC.Enabled { + return nil, fmt.Errorf("oidc is not enabled") + } + if validator == nil { + return nil, fmt.Errorf("validator is required") + } + pkceSecret := derivePKCESecret(cfg.Portal.CookieSecret) + pkceStore, err := NewSessionStore("api_test_pkce", pkceSecret, cfg.Portal.CookieSecure, 0) + if err != nil { + return nil, err + } + + b := &BrowserAuth{ + cfg: cfg.OIDC, + baseURL: strings.TrimRight(cfg.Server.BaseURL, "/"), + redirectPath: cfg.Portal.OIDCRedirectPath, + validator: validator, + sessions: sessions, + pkceCookie: pkceStore, + logger: logger, + httpc: &http.Client{Timeout: 10 * time.Second}, + usedNonces: make(map[string]time.Time), + } + if err := b.discoverEndpoints(ctx); err != nil { + return nil, err + } + return b, nil +} + +// derivePKCESecret produces a PKCE-flow signing key from the long-lived +// cookie secret. We use HMAC-SHA256 with a fixed domain-separator label so +// the two flows have independent keys derived from a single configured +// secret. This protects against cross-flow forgery if the bytes of one +// signed blob ever leaked while the secret stayed safe. +func derivePKCESecret(cookieSecret string) string { + mac := hmac.New(sha256.New, []byte(cookieSecret)) + _, _ = mac.Write([]byte("api-test/pkce/v1")) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// Mount adds the three handlers to the given mux. +func (b *BrowserAuth) Mount(mux *http.ServeMux) { + mux.HandleFunc("GET /portal/auth/login", b.handleLogin) + mux.HandleFunc("GET /portal/auth/callback", b.handleCallback) + mux.HandleFunc("POST /portal/auth/logout", b.handleLogout) +} + +func (b *BrowserAuth) handleLogin(w http.ResponseWriter, r *http.Request) { + state, err := randomString(32) + if err != nil { + http.Error(w, "rng failure", http.StatusInternalServerError) + return + } + verifier, err := randomString(64) + if err != nil { + http.Error(w, "rng failure", http.StatusInternalServerError) + return + } + nonce, err := randomString(16) + if err != nil { + http.Error(w, "rng failure", http.StatusInternalServerError) + return + } + challenge := pkceChallenge(verifier) + + // Stash state + verifier + nonce in a short-lived signed cookie so we can + // recover them in the callback without server-side storage. The nonce is + // what we record as "used" after a successful callback to prevent replay. + pl := SessionPayload{ + Identity: &auth.Identity{Claims: map[string]any{ + "state": state, + "verifier": verifier, + "nonce": nonce, + "return": sanitizeReturnPath(r.URL.Query().Get("return")), + }}, + } + encoded, err := b.pkceCookie.encode(pl) + if err != nil { + http.Error(w, "encode pkce: "+err.Error(), http.StatusInternalServerError) + return + } + // #nosec G124 -- Secure is set from config (dev/HTTP-only deployments need false). + http.SetCookie(w, &http.Cookie{ // nosemgrep: go.lang.security.audit.net.cookie-missing-secure.cookie-missing-secure -- Secure follows config; dev/HTTP loopback uses false intentionally. + Name: b.pkceCookie.cookieName, + Value: encoded, + Path: "/portal/auth/", + HttpOnly: true, + Secure: b.pkceCookie.secure, + SameSite: http.SameSiteLaxMode, + MaxAge: 600, + }) + + redirectURI := b.baseURL + b.redirectPath + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", b.cfg.ClientID) + q.Set("redirect_uri", redirectURI) + q.Set("scope", "openid email profile") + q.Set("state", state) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + if b.cfg.Audience != "" { + q.Set("audience", b.cfg.Audience) + } + sep := "?" + if strings.Contains(b.authzURL, "?") { + sep = "&" + } + http.Redirect(w, r, b.authzURL+sep+q.Encode(), http.StatusFound) +} + +func (b *BrowserAuth) handleCallback(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(b.pkceCookie.cookieName) + if err != nil { + http.Error(w, "missing pkce cookie", http.StatusBadRequest) + return + } + pl, err := b.pkceCookie.decode(c.Value) + if err != nil { + http.Error(w, "bad pkce cookie", http.StatusBadRequest) + return + } + st, _ := pl.Identity.Claims["state"].(string) + verifier, _ := pl.Identity.Claims["verifier"].(string) + nonce, _ := pl.Identity.Claims["nonce"].(string) + returnTo, _ := pl.Identity.Claims["return"].(string) + + if r.URL.Query().Get("state") != st || st == "" { + http.Error(w, "state mismatch", http.StatusBadRequest) + return + } + if !b.consumeNonce(nonce) { + http.Error(w, "replay rejected", http.StatusBadRequest) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + + idToken, err := b.exchangeCode(r.Context(), code, verifier) + if err != nil { + // Surface a generic 502 to the client; the IdP body may contain + // tokens or sensitive details that should not be echoed downstream. + b.logger.Warn("oidc code exchange failed", "err", err) + http.Error(w, "code exchange failed", http.StatusBadGateway) + return + } + + identity, err := b.validator.ValidateBearer(r.Context(), idToken) + if err != nil { + b.logger.Warn("oidc id_token validation failed", "err", err) + http.Error(w, "id_token validation failed", http.StatusUnauthorized) + return + } + + // Clear the short-lived PKCE cookie. + // #nosec G124 -- Secure follows config; SameSite default is fine for clears. + http.SetCookie(w, &http.Cookie{ // nosemgrep: go.lang.security.audit.net.cookie-missing-secure.cookie-missing-secure -- Secure follows config; dev/HTTP loopback uses false intentionally. + Name: b.pkceCookie.cookieName, + Value: "", + Path: "/portal/auth/", + MaxAge: -1, + HttpOnly: true, + Secure: b.pkceCookie.secure, + SameSite: http.SameSiteLaxMode, + }) + + if err := b.sessions.Issue(w, identity); err != nil { + http.Error(w, "issue session: "+err.Error(), http.StatusInternalServerError) + return + } + + if returnTo == "" { + returnTo = "/portal/" + } + // nosemgrep: go.lang.security.injection.open-redirect.open-redirect -- returnTo is constrained by sanitizeReturnPath to a same-origin path; cross-origin and protocol-relative forms are rejected at login time. + http.Redirect(w, r, returnTo, http.StatusFound) +} + +func (b *BrowserAuth) handleLogout(w http.ResponseWriter, _ *http.Request) { + b.sessions.Clear(w) + w.WriteHeader(http.StatusNoContent) +} + +// sanitizeReturnPath enforces "must be a same-origin path." Browsers parse +// `//evil.com` as a scheme-relative URL to a different origin, and `/\evil` +// as a path containing a backslash that some IdPs/browsers normalize into +// `//`. We reject both alongside the obvious "must start with /" rule. +func sanitizeReturnPath(p string) string { + if p == "" { + return "" + } + if !strings.HasPrefix(p, "/") { + return "" + } + if strings.HasPrefix(p, "//") || strings.HasPrefix(p, `/\`) { + return "" + } + return p +} + +// consumeNonce records a one-time-use nonce. Returns false if it was already +// recorded within the TTL window. Stale entries are evicted opportunistically. +func (b *BrowserAuth) consumeNonce(nonce string) bool { + if nonce == "" { + return false + } + const ttl = 10 * time.Minute + now := time.Now() + b.usedNoncesMu.Lock() + defer b.usedNoncesMu.Unlock() + for k, t := range b.usedNonces { + if now.Sub(t) > ttl { + delete(b.usedNonces, k) + } + } + if _, used := b.usedNonces[nonce]; used { + return false + } + b.usedNonces[nonce] = now + return true +} + +// discoverEndpoints fetches the OIDC discovery document for authz/token URLs. +// +// We deliberately do this in BrowserAuth rather than reaching into the +// validator: the validator only needs JWKS, but PKCE login also needs the +// authorization_endpoint and token_endpoint. +func (b *BrowserAuth) discoverEndpoints(ctx context.Context) error { + discoveryURL := strings.TrimRight(b.cfg.Issuer, "/") + "/.well-known/openid-configuration" + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + resp, err := b.httpc.Do(req) + if err != nil { + return fmt.Errorf("discover: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("discover %s: status %d", discoveryURL, resp.StatusCode) + } + var doc struct { + AuthzEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return fmt.Errorf("decode discovery: %w", err) + } + if doc.AuthzEndpoint == "" || doc.TokenEndpoint == "" { + return fmt.Errorf("discovery missing authorization_endpoint or token_endpoint") + } + b.authzURL = doc.AuthzEndpoint + b.tokenURL = doc.TokenEndpoint + return nil +} + +func (b *BrowserAuth) exchangeCode(ctx context.Context, code, verifier string) (string, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", b.baseURL+b.redirectPath) + form.Set("client_id", b.cfg.ClientID) + form.Set("code_verifier", verifier) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, b.tokenURL, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if b.cfg.ClientSecret != "" { + req.SetBasicAuth(b.cfg.ClientID, b.cfg.ClientSecret) + } + resp, err := b.httpc.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + // Truncate IdP body to a status + length, never embed the raw body in + // the returned error: it can carry refresh tokens or other secrets + // depending on IdP misbehavior. + return "", fmt.Errorf("token endpoint returned %d (body %d bytes)", resp.StatusCode, len(body)) + } + var tok struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return "", fmt.Errorf("decode token response: %w", err) + } + if tok.IDToken == "" { + return "", fmt.Errorf("token response missing id_token") + } + return tok.IDToken, nil +} + +func randomString(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/pkg/httpsrv/browserauth_test.go b/pkg/httpsrv/browserauth_test.go new file mode 100644 index 0000000..bd8a066 --- /dev/null +++ b/pkg/httpsrv/browserauth_test.go @@ -0,0 +1,350 @@ +package httpsrv + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/config" +) + +// ----- pure helpers (no fixture needed) ----- + +func TestSanitizeReturnPath(t *testing.T) { + cases := map[string]string{ + "": "", + "/portal/": "/portal/", + "/portal/keys": "/portal/keys", + "//evil.com": "", + `/\evil`: "", + "http://x": "", + "portal/keys": "", + "/": "/", + } + for in, want := range cases { + if got := sanitizeReturnPath(in); got != want { + t.Errorf("sanitizeReturnPath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestPKCEChallengeMatchesSpec(t *testing.T) { + // RFC 7636 §4.2: code_challenge = BASE64URL-ENCODE(SHA256(verifier)). + // Verify the helper produces exactly that, computed live so the test + // can't drift from the spec via a stale pre-computed constant. + verifier := "M25iVXpKU3puUjFaYWg3T1NDTDQtcW1ROUY5YXlwalNoc0hhakxifmZHag" + sum := sha256.Sum256([]byte(verifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if got := pkceChallenge(verifier); got != want { + t.Errorf("pkceChallenge = %q, want %q", got, want) + } + // Determinism: same verifier always produces the same challenge. + if pkceChallenge(verifier) != pkceChallenge(verifier) { + t.Error("pkceChallenge non-deterministic") + } +} + +func TestRandomString_LengthAndUniqueness(t *testing.T) { + a, err := randomString(16) + if err != nil { + t.Fatalf("randomString: %v", err) + } + if len(a) == 0 { + t.Error("randomString returned empty") + } + b, _ := randomString(16) + if a == b { + t.Error("randomString produced colliding values; vanishingly unlikely") + } +} + +func TestDerivePKCESecret_Stable(t *testing.T) { + s1 := derivePKCESecret("0123456789abcdef") + s2 := derivePKCESecret("0123456789abcdef") + s3 := derivePKCESecret("different-cookie-secret") + if s1 != s2 { + t.Error("derivePKCESecret should be deterministic") + } + if s1 == s3 { + t.Error("derivePKCESecret with different cookie secret should not match") + } +} + +func TestConsumeNonce_OneShot(t *testing.T) { + b := &BrowserAuth{usedNonces: map[string]time.Time{}} + if !b.consumeNonce("n1") { + t.Error("first consume should succeed") + } + if b.consumeNonce("n1") { + t.Error("second consume should fail (replay)") + } + if b.consumeNonce("") { + t.Error("empty nonce should fail") + } +} + +func TestConsumeNonce_GCStaleEntries(t *testing.T) { + b := &BrowserAuth{usedNonces: map[string]time.Time{ + "old": time.Now().Add(-30 * time.Minute), + }} + if !b.consumeNonce("new") { + t.Error("should accept new nonce") + } + b.usedNoncesMu.Lock() + _, stillThere := b.usedNonces["old"] + b.usedNoncesMu.Unlock() + if stillThere { + t.Error("stale nonce should have been GC'd") + } +} + +// ----- fixture: a minimal IdP that supports PKCE callback ----- + +type pkceTestIdP struct { + srv *httptest.Server + priv *rsa.PrivateKey + kid string + + mu sync.Mutex + codeStore map[string]string // code -> issued id_token +} + +func newPKCEIdP(t *testing.T) *pkceTestIdP { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("genkey: %v", err) + } + idp := &pkceTestIdP{priv: priv, kid: "k1", codeStore: map[string]string{}} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": idp.srv.URL, + "jwks_uri": idp.srv.URL + "/jwks", + "authorization_endpoint": idp.srv.URL + "/authorize", + "token_endpoint": idp.srv.URL + "/token", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + {"kty": "RSA", "use": "sig", "alg": "RS256", "kid": idp.kid, + "n": base64.RawURLEncoding.EncodeToString(priv.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(priv.E)).Bytes())}, + }, + }) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + code := r.Form.Get("code") + idp.mu.Lock() + tok, ok := idp.codeStore[code] + delete(idp.codeStore, code) + idp.mu.Unlock() + if !ok { + http.Error(w, "unknown code", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"id_token": tok, "access_token": "ignored"}) + }) + idp.srv = httptest.NewServer(mux) + t.Cleanup(idp.srv.Close) + return idp +} + +func (i *pkceTestIdP) issueCode(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tk := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tk.Header["kid"] = i.kid + signed, err := tk.SignedString(i.priv) + if err != nil { + t.Fatalf("sign: %v", err) + } + code := "code-" + signed[:8] + i.mu.Lock() + i.codeStore[code] = signed + i.mu.Unlock() + return code +} + +func newBrowserAuthForTest(t *testing.T) (*BrowserAuth, *pkceTestIdP, *SessionStore) { + t.Helper() + idp := newPKCEIdP(t) + cfg := &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080" + cfg.Portal.CookieSecret = "0123456789abcdef-cs" + cfg.Portal.OIDCRedirectPath = "/portal/auth/callback" + cfg.OIDC.Enabled = true + cfg.OIDC.Issuer = idp.srv.URL + cfg.OIDC.ClientID = "web" + validator, err := auth.NewOIDC(context.Background(), cfg.OIDC) + if err != nil { + t.Fatalf("NewOIDC: %v", err) + } + sessions, err := NewSessionStore("c", cfg.Portal.CookieSecret, false, time.Hour) + if err != nil { + t.Fatalf("NewSessionStore: %v", err) + } + ba, err := NewBrowserAuth(context.Background(), cfg, validator, sessions, slog.Default()) + if err != nil { + t.Fatalf("NewBrowserAuth: %v", err) + } + return ba, idp, sessions +} + +func TestBrowserAuth_LoginRedirectsToIdPWithPKCEParams(t *testing.T) { + ba, idp, _ := newBrowserAuthForTest(t) + mux := http.NewServeMux() + ba.Mount(mux) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/portal/auth/login", nil)) + if w.Code != http.StatusFound { + t.Fatalf("login status = %d, want 302", w.Code) + } + loc := w.Header().Get("Location") + u, err := url.Parse(loc) + if err != nil { + t.Fatalf("parse Location %q: %v", loc, err) + } + if !strings.HasPrefix(loc, idp.srv.URL+"/authorize") { + t.Errorf("Location host = %q, want %s/authorize prefix", loc, idp.srv.URL) + } + q := u.Query() + if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("state") == "" { + t.Errorf("PKCE params missing: %v", q) + } + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("login should set the PKCE cookie") + } +} + +func TestBrowserAuth_CallbackHappyPath(t *testing.T) { + ba, idp, sessions := newBrowserAuthForTest(t) + mux := http.NewServeMux() + ba.Mount(mux) + + loginRec := httptest.NewRecorder() + mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/portal/auth/login?return=/portal/keys", nil)) + loc := loginRec.Header().Get("Location") + authzURL, _ := url.Parse(loc) + state := authzURL.Query().Get("state") + pkceCookie := loginRec.Result().Cookies()[0] + + code := idp.issueCode(t, jwt.MapClaims{ + "iss": idp.srv.URL, + "sub": "alice", + "email": "alice@example.com", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + + cb := httptest.NewRequest(http.MethodGet, "/portal/auth/callback?state="+state+"&code="+code, nil) + cb.AddCookie(pkceCookie) + cbRec := httptest.NewRecorder() + mux.ServeHTTP(cbRec, cb) + + if cbRec.Code != http.StatusFound { + body, _ := io.ReadAll(cbRec.Body) + t.Fatalf("callback status = %d, body=%s", cbRec.Code, body) + } + if got := cbRec.Header().Get("Location"); got != "/portal/keys" { + t.Errorf("post-callback Location = %q, want /portal/keys (sanitized return)", got) + } + // And the session cookie should now read back as alice. + sessCookie := findCookie(cbRec.Result().Cookies(), sessions.CookieName()) + if sessCookie == nil { + t.Fatal("session cookie not set") + } + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(sessCookie) + id := sessions.Read(r) + if id == nil || id.Subject != "alice" { + t.Errorf("session identity = %+v, want subject=alice", id) + } +} + +func TestBrowserAuth_CallbackRejectsStateMismatch(t *testing.T) { + ba, _, _ := newBrowserAuthForTest(t) + mux := http.NewServeMux() + ba.Mount(mux) + + loginRec := httptest.NewRecorder() + mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/portal/auth/login", nil)) + pkceCookie := loginRec.Result().Cookies()[0] + + cb := httptest.NewRequest(http.MethodGet, "/portal/auth/callback?state=wrong&code=x", nil) + cb.AddCookie(pkceCookie) + cbRec := httptest.NewRecorder() + mux.ServeHTTP(cbRec, cb) + if cbRec.Code != http.StatusBadRequest { + t.Errorf("state mismatch status = %d, want 400", cbRec.Code) + } +} + +func TestBrowserAuth_CallbackMissingCookie(t *testing.T) { + ba, _, _ := newBrowserAuthForTest(t) + mux := http.NewServeMux() + ba.Mount(mux) + + cb := httptest.NewRequest(http.MethodGet, "/portal/auth/callback?state=x&code=x", nil) + cbRec := httptest.NewRecorder() + mux.ServeHTTP(cbRec, cb) + if cbRec.Code != http.StatusBadRequest { + t.Errorf("no cookie status = %d, want 400", cbRec.Code) + } +} + +func TestBrowserAuth_Logout(t *testing.T) { + ba, _, _ := newBrowserAuthForTest(t) + mux := http.NewServeMux() + ba.Mount(mux) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/portal/auth/logout", nil)) + if w.Code != http.StatusNoContent { + t.Errorf("logout status = %d, want 204", w.Code) + } + cookies := w.Result().Cookies() + if len(cookies) == 0 || cookies[0].MaxAge >= 0 { + t.Errorf("logout did not clear session cookie: %+v", cookies) + } +} + +func TestNewBrowserAuth_RejectsDisabledOIDC(t *testing.T) { + cfg := &config.Config{} + cfg.Portal.CookieSecret = "0123456789abcdef-x" + if _, err := NewBrowserAuth(context.Background(), cfg, nil, nil, slog.Default()); err == nil { + t.Error("NewBrowserAuth with oidc disabled: want error") + } +} + +func findCookie(cs []*http.Cookie, name string) *http.Cookie { + for _, c := range cs { + if c.Name == name { + return c + } + } + return nil +} diff --git a/pkg/httpsrv/browserredirect.go b/pkg/httpsrv/browserredirect.go new file mode 100644 index 0000000..ef68ab0 --- /dev/null +++ b/pkg/httpsrv/browserredirect.go @@ -0,0 +1,34 @@ +package httpsrv + +import ( + "net/http" + "strings" +) + +// BrowserRedirect bounces apparent browser GETs at "/" to the portal SPA so +// that operators visiting the bare host get a UI instead of a 404. Non-browser +// callers (curl, integration tests) pass through to next. +// +// The check is intentionally narrow: +// - method must be GET +// - URL.Path must be exactly "/" +// - Accept must include text/html +func BrowserRedirect(portalPath string, next http.Handler) http.Handler { + if portalPath == "" { + portalPath = "/portal/" + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isBrowserRoot(r) { + http.Redirect(w, r, portalPath, http.StatusFound) + return + } + next.ServeHTTP(w, r) + }) +} + +func isBrowserRoot(r *http.Request) bool { + if r.Method != http.MethodGet || r.URL.Path != "/" { + return false + } + return strings.Contains(r.Header.Get("Accept"), "text/html") +} diff --git a/pkg/httpsrv/browserredirect_test.go b/pkg/httpsrv/browserredirect_test.go new file mode 100644 index 0000000..e600cca --- /dev/null +++ b/pkg/httpsrv/browserredirect_test.go @@ -0,0 +1,62 @@ +package httpsrv + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestBrowserRedirect_BouncesHTMLGetAtRoot(t *testing.T) { + pass := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) + h := BrowserRedirect("/portal/", pass) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept", "text/html,application/xhtml+xml") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusFound { + t.Errorf("status = %d, want 302", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/portal/" { + t.Errorf("Location = %q, want /portal/", loc) + } +} + +func TestBrowserRedirect_DefaultPathFallback(t *testing.T) { + h := BrowserRedirect("", http.NotFoundHandler()) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if loc := w.Header().Get("Location"); loc != "/portal/" { + t.Errorf("Location with empty portalPath = %q, want /portal/ default", loc) + } +} + +func TestBrowserRedirect_NonBrowserPassesThrough(t *testing.T) { + cases := []struct { + name, accept, method, path string + }{ + {"curl-no-accept", "", http.MethodGet, "/"}, + {"json-accept", "application/json", http.MethodGet, "/"}, + {"non-root-html", "text/html", http.MethodGet, "/v1/foo"}, + {"post-html", "text/html", http.MethodPost, "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + passed := false + h := BrowserRedirect("/portal/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + passed = true + w.WriteHeader(http.StatusOK) + })) + r := httptest.NewRequest(tc.method, tc.path, nil) + if tc.accept != "" { + r.Header.Set("Accept", tc.accept) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if !passed { + t.Errorf("expected pass-through for %+v, got status %d", tc, w.Code) + } + }) + } +} diff --git a/pkg/httpsrv/csrf.go b/pkg/httpsrv/csrf.go new file mode 100644 index 0000000..76ad72b --- /dev/null +++ b/pkg/httpsrv/csrf.go @@ -0,0 +1,25 @@ +package httpsrv + +import ( + "net/http" +) + +// requireCSRFHeader rejects state-changing requests (POST/PUT/PATCH/DELETE) +// that don't carry the X-Requested-With header. Defense-in-depth on top of +// SameSite=Lax cookies: a browser
submission cannot set custom +// headers, and a cross-origin fetch can only set X-Requested-With through +// a CORS preflight (which our CORS handler does not approve), so this +// header acts as a portable CSRF token without requiring per-request +// minting. +func requireCSRFHeader(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + if r.Header.Get("X-Requested-With") == "" { + http.Error(w, "missing X-Requested-With header", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/httpsrv/csrf_test.go b/pkg/httpsrv/csrf_test.go new file mode 100644 index 0000000..9915aca --- /dev/null +++ b/pkg/httpsrv/csrf_test.go @@ -0,0 +1,58 @@ +package httpsrv + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequireCSRFHeader_PassesGet(t *testing.T) { + called := false + h := requireCSRFHeader(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/portal/api/things", nil)) + if !called { + t.Error("GET should pass through") + } + if w.Code != http.StatusOK { + t.Errorf("GET status = %d, want 200", w.Code) + } +} + +func TestRequireCSRFHeader_BlocksPostWithoutHeader(t *testing.T) { + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + called := false + h := requireCSRFHeader(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(m, "/x", nil)) + if called { + t.Errorf("%s without X-Requested-With: handler should not be reached", m) + } + if w.Code != http.StatusForbidden { + t.Errorf("%s status = %d, want 403", m, w.Code) + } + } +} + +func TestRequireCSRFHeader_AllowsPostWithHeader(t *testing.T) { + called := false + h := requireCSRFHeader(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + })) + r := httptest.NewRequest(http.MethodPost, "/x", nil) + r.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if !called { + t.Error("POST with X-Requested-With: handler should run") + } + if w.Code != http.StatusCreated { + t.Errorf("status = %d, want 201", w.Code) + } +} diff --git a/pkg/httpsrv/mux.go b/pkg/httpsrv/mux.go index fbfa1a4..993474e 100644 --- a/pkg/httpsrv/mux.go +++ b/pkg/httpsrv/mux.go @@ -2,16 +2,31 @@ package httpsrv import ( "encoding/json" + "io/fs" "net/http" + "github.com/plexara/api-test/pkg/config" "github.com/plexara/api-test/pkg/endpoints" ) -// BuildMux assembles the M1 HTTP mux: /healthz, /readyz, the endpoint -// groups under /v1, and a friendly JSON 404 root. Later milestones will -// extend this with /.well-known/*, the portal SPA at /portal/, the -// portal/admin APIs at /api/v1/portal/, and the OpenAPI doc. -func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.Middleware) http.Handler { +// PortalDeps bundles everything needed to mount the portal under /portal/ +// and /portal/api/*. Pass nil to BuildMux to disable the portal entirely. +type PortalDeps struct { + Cfg *config.Config + SPA fs.FS // when nil, /portal/ serves a JSON stub + BrowserAuth *BrowserAuth + PortalAuth *PortalAuth + PortalAPI *PortalAPI +} + +// BuildMux assembles the HTTP mux: +// - /healthz, /readyz +// - /v1/* endpoint groups (with the supplied middleware) +// - /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server +// - /portal/, /portal/api/*, /portal/auth/{login,callback,logout} when portal != nil +// - / — root handler returning a JSON banner (or a redirect to the portal +// when the request looks like a browser GET) +func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.Middleware, portal *PortalDeps) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", HealthzHandler()) @@ -22,19 +37,53 @@ func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.M } registry.Mount(mux, mw) - mux.HandleFunc("GET /", rootHandler(registry)) + if portal != nil { + mux.HandleFunc("GET /.well-known/oauth-protected-resource", ProtectedResourceMetadata(portal.Cfg)) + mux.HandleFunc("GET /.well-known/oauth-authorization-server", AuthorizationServerStub(portal.Cfg)) + + if portal.BrowserAuth != nil { + portal.BrowserAuth.Mount(mux) + } + if portal.PortalAPI != nil && portal.PortalAuth != nil { + portal.PortalAPI.Mount(mux, portal.PortalAuth.Middleware) + } + mux.Handle("GET /portal/", http.StripPrefix("/portal", spaOrStub(portal.SPA))) + } + + mux.HandleFunc("GET /", rootHandler(registry, portal != nil)) - return CORS(mux) + var handler http.Handler = mux + if portal != nil { + // Bounce browser GETs at "/" to /portal/ so a curl returns the JSON + // banner but a browser visit lands on the SPA. + handler = BrowserRedirect("/portal/", handler) + } + return CORS(handler) +} + +// spaOrStub serves the SPA when spaFS is non-nil; otherwise emits a small +// JSON stub explaining how to build the UI. Used by `make dev` runs that +// haven't built the SPA yet so the binary still starts cleanly. +func spaOrStub(spaFS fs.FS) http.Handler { + if spaFS != nil { + return SPAHandler(spaFS) + } + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "ui not built", + "detail": "internal/ui/dist is empty; run `make ui` and rebuild the binary", + }) + }) } // rootHandler returns a small JSON banner at "/" so a curl to the bare host // gets a useful response (a list of mounted endpoint groups + version -// pointer to /healthz). M3 will replace this with a 302 to /portal/ when -// the SPA is enabled. -func rootHandler(registry *endpoints.Registry) http.HandlerFunc { +// pointer to /healthz). When the portal is enabled, the banner advertises +// the portal URL and BrowserRedirect handles browser GETs upstream. +func rootHandler(registry *endpoints.Registry, portalEnabled bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Only handle the literal root; everything else here means "no - // matching route" and should be a JSON 404. if r.URL.Path != "/" { writeJSONError(w, http.StatusNotFound, "no such endpoint") return @@ -43,16 +92,20 @@ func rootHandler(registry *endpoints.Registry) http.HandlerFunc { for _, g := range registry.Groups() { groups = append(groups, g.Name()) } + links := map[string]string{ + "healthz": "/healthz", + "readyz": "/readyz", + } + if portalEnabled { + links["portal"] = "/portal/" + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]any{ "name": "api-test", "endpoint_groups": groups, "endpoints": len(registry.All()), - "links": map[string]string{ - "healthz": "/healthz", - "readyz": "/readyz", - }, + "links": links, }) } } diff --git a/pkg/httpsrv/mux_test.go b/pkg/httpsrv/mux_test.go index 82111c9..e737438 100644 --- a/pkg/httpsrv/mux_test.go +++ b/pkg/httpsrv/mux_test.go @@ -24,7 +24,7 @@ func (stubGroup) Mount(mux *http.ServeMux, mw endpoints.Middleware) { func TestBuildMux_RootBanner(t *testing.T) { r := endpoints.NewRegistry() r.Add(stubGroup{}) - mux := BuildMux(r, NewReadiness(), nil) + mux := BuildMux(r, NewReadiness(), nil, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -43,7 +43,7 @@ func TestBuildMux_RootBanner(t *testing.T) { } func TestBuildMux_Healthz(t *testing.T) { - mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil) + mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil, nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -55,7 +55,7 @@ func TestBuildMux_Healthz(t *testing.T) { func TestBuildMux_GroupMounted(t *testing.T) { r := endpoints.NewRegistry() r.Add(stubGroup{}) - mux := BuildMux(r, NewReadiness(), nil) + mux := BuildMux(r, NewReadiness(), nil, nil) req := httptest.NewRequest(http.MethodGet, "/v1/ping", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -65,7 +65,7 @@ func TestBuildMux_GroupMounted(t *testing.T) { } func TestBuildMux_404(t *testing.T) { - mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil) + mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil, nil) req := httptest.NewRequest(http.MethodGet, "/no-such-thing", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) diff --git a/pkg/httpsrv/portal_api.go b/pkg/httpsrv/portal_api.go new file mode 100644 index 0000000..6c666c3 --- /dev/null +++ b/pkg/httpsrv/portal_api.go @@ -0,0 +1,431 @@ +package httpsrv + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/plexara/api-test/pkg/apikeys" + "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/build" + "github.com/plexara/api-test/pkg/config" + "github.com/plexara/api-test/pkg/endpoints" +) + +// PortalAPI bundles the portal handlers under /api/v1/portal/*. +// +// State separation: read-only handlers (me, server, endpoints, audit/events, +// dashboard, wellknown, keys list) are mounted via the standard auth +// middleware; mutating handlers (keys create/delete) are wrapped in the +// CSRF header check on top of auth so a forged POST cannot reach +// them via SameSite=Lax cookies alone. +type PortalAPI struct { + cfg *config.Config + registry *endpoints.Registry + audit audit.Logger + keys *apikeys.Store // nil if config.APIKeys.DB.Enabled=false +} + +// NewPortalAPI returns the API. keys may be nil when the DB-backed key store +// is not enabled; the keys handlers respond 503 in that case. +func NewPortalAPI( + cfg *config.Config, + registry *endpoints.Registry, + auditLog audit.Logger, + keys *apikeys.Store, +) *PortalAPI { + return &PortalAPI{cfg: cfg, registry: registry, audit: auditLog, keys: keys} +} + +// Mount adds every endpoint behind the supplied auth middleware. +func (p *PortalAPI) Mount(mux *http.ServeMux, mw func(http.Handler) http.Handler) { + wrap := func(h http.Handler) http.Handler { return mw(requireCSRFHeader(h)) } + + mux.Handle("GET /api/v1/portal/me", mw(http.HandlerFunc(p.me))) + mux.Handle("GET /api/v1/portal/server", mw(http.HandlerFunc(p.server))) + mux.Handle("GET /api/v1/portal/wellknown", mw(http.HandlerFunc(p.wellknown))) + mux.Handle("GET /api/v1/portal/dashboard", mw(http.HandlerFunc(p.dashboard))) + + mux.Handle("GET /api/v1/portal/endpoints", mw(http.HandlerFunc(p.endpoints))) + mux.Handle("GET /api/v1/portal/endpoints/{name}", mw(http.HandlerFunc(p.endpointDetail))) + + mux.Handle("GET /api/v1/portal/audit/meta", mw(http.HandlerFunc(p.auditMeta))) + mux.Handle("GET /api/v1/portal/audit/events", mw(http.HandlerFunc(p.auditEvents))) + mux.Handle("GET /api/v1/portal/audit/events/{id}", mw(http.HandlerFunc(p.auditEventDetail))) + + mux.Handle("GET /api/v1/admin/keys", mw(http.HandlerFunc(p.listKeys))) + mux.Handle("POST /api/v1/admin/keys", wrap(http.HandlerFunc(p.createKey))) + mux.Handle("DELETE /api/v1/admin/keys/{name}", wrap(http.HandlerFunc(p.deleteKey))) +} + +func (p *PortalAPI) me(w http.ResponseWriter, r *http.Request) { + id := auth.GetIdentity(r.Context()) + writeJSON(w, http.StatusOK, id) +} + +func (p *PortalAPI) server(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, sanitizedConfig(p.cfg)) +} + +func (p *PortalAPI) wellknown(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "protected_resource_url": ProtectedResourceMetadataURL(p.cfg), + "authorization_server": p.cfg.OIDC.Issuer, + "oidc_enabled": p.cfg.OIDC.Enabled, + "audience": p.cfg.OIDC.Audience, + "api_endpoint": strings.TrimRight(p.cfg.Server.BaseURL, "/") + "/v1/", + }) +} + +func (p *PortalAPI) endpoints(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "endpoints": p.registry.All(), + }) +} + +func (p *PortalAPI) endpointDetail(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + for _, m := range p.registry.All() { + if m.Name == name { + writeJSON(w, http.StatusOK, m) + return + } + } + writeError(w, http.StatusNotFound, fmt.Errorf("endpoint %q not found", name)) +} + +// auditMeta exposes the filter contract surface the SPA's audit filter +// editor uses. Dashboard timeseries / breakdown / SSE / export / replay +// are M3+ features; the SPA detects their absence via the `features` map +// and disables matching panels. +func (p *PortalAPI) auditMeta(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "filters": []string{"from", "to", "method", "path", "route_name", "status", "user", "session", "success", "q"}, + "features": map[string]bool{ + "timeseries": false, + "breakdown": false, + "stream": false, + "export": false, + "replay": false, + }, + }) +} + +func (p *PortalAPI) auditEvents(w http.ResponseWriter, r *http.Request) { + f := parseQueryFilter(r) + if f.Limit == 0 { + f.Limit = 50 + } + events, err := p.audit.Query(r.Context(), f) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + total, _ := p.audit.Count(r.Context(), f) + writeJSON(w, http.StatusOK, map[string]any{ + "events": events, + "total": total, + "limit": f.Limit, + "offset": f.Offset, + }) +} + +// auditEventDetail returns one event identified by ID, plus its +// audit_payloads sibling row (when the configured logger persists payloads). +// Validates the path value as a UUID before any backend lookup; the +// canonicalized form is what gets logged so gosec's taint analysis doesn't +// have to trust the raw path bytes. +func (p *PortalAPI) auditEventDetail(w http.ResponseWriter, r *http.Request) { + rawID := r.PathValue("id") + parsed, err := uuid.Parse(rawID) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("event id is not a valid uuid")) + return + } + id := parsed.String() + + events, err := p.audit.Query(r.Context(), audit.QueryFilter{Limit: 1, EventID: id}) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if len(events) == 0 { + writeError(w, http.StatusNotFound, fmt.Errorf("event %q not found", id)) + return + } + ev := events[0] + ev.Payload = nil + if pl, ok := p.audit.(audit.PayloadLogger); ok { + if payload, perr := pl.GetPayload(r.Context(), id); perr == nil { + ev.Payload = payload + } + } + writeJSON(w, http.StatusOK, ev) +} + +// dashboard computes a small inline summary from Query: total + success +// counts in the last hour, plus a list of the 20 most recent events. When +// the audit Logger gains a Stats / TimeSeries / Breakdown surface in M3+, +// move the heavy lifting into the backend. +func (p *PortalAPI) dashboard(w http.ResponseWriter, r *http.Request) { + now := time.Now().UTC() + from := now.Add(-1 * time.Hour) + f := audit.QueryFilter{From: from, To: now} + total, _ := p.audit.Count(r.Context(), f) + succ := true + successFilter := f + successFilter.Success = &succ + successCount, _ := p.audit.Count(r.Context(), successFilter) + recent, _ := p.audit.Query(r.Context(), audit.QueryFilter{From: from, To: now, Limit: 20}) + writeJSON(w, http.StatusOK, map[string]any{ + "window_from": from, + "window_to": now, + "total": total, + "success_count": successCount, + "recent": recent, + }) +} + +func (p *PortalAPI) listKeys(w http.ResponseWriter, r *http.Request) { + if p.keys == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("api keys store not enabled")) + return + } + keys, err := p.keys.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if keys == nil { + keys = []apikeys.Key{} + } + writeJSON(w, http.StatusOK, map[string]any{"keys": keys}) +} + +type createKeyRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` // RFC3339; "" = no expiry +} + +func (p *PortalAPI) createKey(w http.ResponseWriter, r *http.Request) { + if p.keys == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("api keys store not enabled")) + return + } + var body createKeyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if body.Name == "" { + writeError(w, http.StatusBadRequest, errors.New("name required")) + return + } + var expires *time.Time + if body.ExpiresAt != "" { + t, err := time.Parse(time.RFC3339, body.ExpiresAt) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("expires_at: %w", err)) + return + } + expires = &t + } + createdBy := "" + if id := auth.GetIdentity(r.Context()); id != nil { + createdBy = firstNonEmpty(id.Email, id.Subject, id.Name) + } + created, err := p.keys.Create(r.Context(), body.Name, body.Description, createdBy, expires) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (p *PortalAPI) deleteKey(w http.ResponseWriter, r *http.Request) { + if p.keys == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("api keys store not enabled")) + return + } + name := r.PathValue("name") + if name == "" { + writeError(w, http.StatusBadRequest, errors.New("name required")) + return + } + if err := p.keys.Delete(r.Context(), name); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// sanitizedConfig returns a config with secrets replaced by "[redacted]". +// +// Important: every nested slice that holds secrets is deep-copied before +// mutating. A naive `c := *cfg` only copies the slice header, so mutating +// entries through the local copy would corrupt the live in-memory config +// and leak the redacted form back to the inbound auth chain. +func sanitizedConfig(cfg *config.Config) map[string]any { + c := *cfg + c.Portal.CookieSecret = redactIfSet(c.Portal.CookieSecret) + c.OIDC.ClientSecret = redactIfSet(c.OIDC.ClientSecret) + c.Plexara.Register.AuthHeader = redactIfSet(c.Plexara.Register.AuthHeader) + if len(c.APIKeys.File) > 0 { + clone := make([]config.FileAPIKey, len(c.APIKeys.File)) + copy(clone, c.APIKeys.File) + for i := range clone { + clone[i].Key = redactIfSet(clone[i].Key) + } + c.APIKeys.File = clone + } + if len(c.Bearer.Tokens) > 0 { + clone := make([]config.FileBearerToken, len(c.Bearer.Tokens)) + copy(clone, c.Bearer.Tokens) + for i := range clone { + clone[i].Token = redactIfSet(clone[i].Token) + } + c.Bearer.Tokens = clone + } + c.Database.URL = redactDatabaseURL(c.Database.URL) + return map[string]any{ + "version": build.Version, + "commit": build.Commit, + "date": build.Date, + "config": c, + } +} + +func redactIfSet(v string) string { + if v == "" { + return "" + } + return "[redacted]" +} + +// redactDatabaseURL redacts the password portion of either form pgxpool +// accepts. pgx's accepted password-bearing keywords are `password` AND +// `sslpassword` (the SSL client-key passphrase); pgx's keyword/value +// separators include vertical tab `\v` which Go's `\s` does NOT match. +// Both forms below account for those. +// +// - URL form (postgres://user:pass@host/db?...): use stdlib +// (*url.URL).Redacted() to redact the userinfo password, but blanket +// -redact the whole string if the query string carries `password=` or +// `sslpassword=` (pgx accepts these as connection settings; +// Redacted() does not touch the query). +// - libpq DSN form (host=localhost user=api password=secret dbname=...): +// if the string contains any `password=` or `sslpassword=` keyword +// (case-insensitive, libpq separator set, allowing whitespace around +// `=`), the whole string is replaced with "[redacted]". Intentionally +// blanket: hand-rolled value parsers for libpq quoting / escaping / +// whitespace have repeatedly leaked tails on malformed-but-accepted +// inputs (rounds 4–5 of pre-commit review). Loud loss of host +// visibility on the Config page is acceptable; silent leak is not. +// +// The goal is "no plaintext password reaches the SPA via +// /api/v1/portal/server", whichever form the operator configured. +// pgxSepClass is pgx's keyword/value separator set per +// pgconn.parseKeywordValueSettings (pgconn/config.go) — `[\t\n\v\f\r ]`. +// Critically includes `\v` (vertical tab) which Go's regex `\s` does NOT +// match. Used as the leading anchor and the around-`=` whitespace class +// in dsnPasswordKeywordRE so pgx-accepted DSNs cannot bypass detection +// via any separator variant. +const pgxSepClass = `[\t\n\v\f\r ]` + +var dsnPasswordKeywordRE = regexp.MustCompile(`(?i)(?:^|` + pgxSepClass + `)(?:ssl)?password` + pgxSepClass + `*=`) + +func redactDatabaseURL(s string) string { + if s == "" { + return "" + } + if u, err := url.Parse(s); err == nil && u.Scheme != "" && + (strings.EqualFold(u.Scheme, "postgres") || strings.EqualFold(u.Scheme, "postgresql")) { + q := u.Query() + // Key-presence rather than first-value: a URL like + // `?password=&password=actualsecret` would have q.Get("password")=="", + // but the second value still reaches pgx as the connection password. + if _, ok := q["password"]; ok { + return "[redacted]" + } + if _, ok := q["sslpassword"]; ok { + return "[redacted]" + } + return u.Redacted() + } + if dsnPasswordKeywordRE.MatchString(s) { + return "[redacted]" + } + return s +} + +func parseQueryFilter(r *http.Request) audit.QueryFilter { + q := r.URL.Query() + f := audit.QueryFilter{} + if v := q.Get("from"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + f.From = t + } + } + if v := q.Get("to"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + f.To = t + } + } + f.Method = q.Get("method") + f.Path = q.Get("path") + f.RouteName = q.Get("route_name") + f.UserID = q.Get("user") + f.SessionID = q.Get("session") + f.Search = q.Get("q") + if v := q.Get("status"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + f.Status = n + } + } + switch q.Get("success") { + case "true": + yes := true + f.Success = &yes + case "false": + no := false + f.Success = &no + } + if v, _ := strconv.Atoi(q.Get("limit")); v > 0 { + f.Limit = v + } + if v, _ := strconv.Atoi(q.Get("offset")); v > 0 { + f.Offset = v + } + return f +} + +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if v != "" { + return v + } + } + return "" +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()}) +} diff --git a/pkg/httpsrv/portal_api_test.go b/pkg/httpsrv/portal_api_test.go new file mode 100644 index 0000000..d1d9407 --- /dev/null +++ b/pkg/httpsrv/portal_api_test.go @@ -0,0 +1,397 @@ +package httpsrv + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/config" + "github.com/plexara/api-test/pkg/endpoints" +) + +// stubGroupForPortal implements endpoints.Endpoints with one route so the +// portal's endpoints catalog has something to enumerate. It's intentionally +// trivial — the portal only reads metadata. +type stubGroupForPortal struct{} + +func (stubGroupForPortal) Name() string { return "stub" } +func (stubGroupForPortal) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + {Name: "ping", Group: "stub", Method: "GET", Path: "/v1/ping", Description: "ping"}, + {Name: "echo", Group: "stub", Method: "POST", Path: "/v1/echo", Description: "echo"}, + } +} +func (stubGroupForPortal) Mount(*http.ServeMux, endpoints.Middleware) {} + +// passthroughMW invokes the next handler with no additional auth — fine +// for tests that exercise the handlers themselves. +func passthroughMW(next http.Handler) http.Handler { return next } + +// authedMW attaches a fixed identity, mimicking what PortalAuth would do. +func authedMW(id *auth.Identity) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(auth.WithIdentity(r.Context(), id))) + }) + } +} + +func newTestPortalAPI(t *testing.T, cfg *config.Config) (*PortalAPI, *audit.MemoryLogger, *endpoints.Registry) { + t.Helper() + if cfg == nil { + cfg = &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080" + } + reg := endpoints.NewRegistry() + reg.Add(stubGroupForPortal{}) + auditLog := audit.NewMemoryLogger() + return NewPortalAPI(cfg, reg, auditLog, nil), auditLog, reg +} + +func TestPortalAPI_Me(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + id := &auth.Identity{Subject: "alice", AuthType: "oidc"} + p.Mount(mux, authedMW(id)) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/me", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var got auth.Identity + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Subject != "alice" || got.AuthType != "oidc" { + t.Errorf("identity = %+v, want subject=alice authtype=oidc", got) + } +} + +func TestPortalAPI_Server_RedactsAllSecrets(t *testing.T) { + cfg := &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080" + cfg.Portal.CookieSecret = "super-secret-cookie" + cfg.OIDC.ClientSecret = "super-secret-client" + cfg.Plexara.Register.AuthHeader = "Bearer admin-pat" + cfg.APIKeys.File = []config.FileAPIKey{{Name: "f1", Key: "raw-file-key"}} + cfg.Bearer.Tokens = []config.FileBearerToken{{Name: "b1", Token: "raw-bearer-tok"}} + cfg.Database.URL = "postgres://api:s3cret@localhost:5432/db?sslmode=disable" + + p, _, _ := newTestPortalAPI(t, cfg) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/server", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + for _, secret := range []string{"super-secret-cookie", "super-secret-client", "Bearer admin-pat", "raw-file-key", "raw-bearer-tok", "s3cret"} { + if strings.Contains(body, secret) { + t.Errorf("response leaked secret %q in body: %s", secret, body) + } + } + // And confirm the live config wasn't mutated. + if cfg.Bearer.Tokens[0].Token != "raw-bearer-tok" { + t.Error("live cfg.Bearer.Tokens was mutated by sanitization") + } + if cfg.APIKeys.File[0].Key != "raw-file-key" { + t.Error("live cfg.APIKeys.File was mutated by sanitization") + } + if cfg.Plexara.Register.AuthHeader != "Bearer admin-pat" { + t.Error("live cfg.Plexara.Register.AuthHeader was mutated by sanitization") + } +} + +func TestRedactDatabaseURL(t *testing.T) { + cases := map[string]string{ + "": "", + // URL form: stdlib's (*url.URL).Redacted() replaces password with "xxxxx". + "postgres://api:s3cret@localhost:5432/db": "postgres://api:xxxxx@localhost:5432/db", + "postgresql://api:s3cret@host/db?sslmode=disable": "postgresql://api:xxxxx@host/db?sslmode=disable", + "Postgres://api:s3cret@host/db": "postgres://api:xxxxx@host/db", // url.Parse lowercases scheme + "postgres://api:s3cret@host/db?application_name=u@example": "postgres://api:xxxxx@host/db?application_name=u@example", + "postgres://api@host/db": "postgres://api@host/db", // userinfo, no password + "postgres://localhost/db": "postgres://localhost/db", // no userinfo + "postgres://api:s3cret@[::1]:5432/db": "postgres://api:xxxxx@[::1]:5432/db", // IPv6 host + // DSN form: any string containing a `password=` keyword is blanket-redacted. + // Operator loses the rest of the DSN on the Config page, but no + // libpq quoting/escaping/whitespace edge case can leak the tail. + "host=localhost user=api password=s3cret dbname=apitest": "[redacted]", + "host=h user=u password = s3cret dbname=d": "[redacted]", + "password='spaced s3cret' dbname=d": "[redacted]", + `password="dq spaced s3cret" dbname=d`: "[redacted]", + `password='it\'s s3cret' dbname=d`: "[redacted]", + `password='unterminated s3cret`: "[redacted]", // malformed quote — would leak tail under field-scan + `password="unterminated s3cret`: "[redacted]", // ditto for double-quote + "PASSWORD=Caps": "[redacted]", + // pgx accepts these forms too; round-6 review caught all three. + "postgres://api@host/db?password=urlquery_s3cret": "[redacted]", // URL form, password in query + "postgres://api@host/db?sslpassword=urlssl_s3cret": "[redacted]", // URL form, sslpassword in query + "postgres://api@host/db?password=": "[redacted]", // URL form, key-presence (empty value) + "postgres://api@host/db?password=&password=dup_s3cret": "[redacted]", // duplicate-key (round-8) + "host=h sslpassword=ssl_s3cret": "[redacted]", // DSN sslpassword keyword + "host=h\vpassword=vt_s3cret": "[redacted]", // \v separator before key (pgx accepts; Go \s does not) + "host=h password\v=vt2_s3cret": "[redacted]", // \v between key and = (round-7) + "host=h sslpassword\v=vt3_s3cret": "[redacted]", // \v between sslpassword key and = (round-7) + // No password keyword anywhere: pass through unchanged. + "host=h port=5432": "host=h port=5432", + "weird-no-equals-no-at": "weird-no-equals-no-at", + "applicationname=foo dbname=bar": "applicationname=foo dbname=bar", + "applicationname=password_age=foo": "applicationname=password_age=foo", // no `password=` (matched key has trailing `_`) + } + for in, want := range cases { + if got := redactDatabaseURL(in); got != want { + t.Errorf("redactDatabaseURL(%q) = %q, want %q", in, got, want) + } + // Cross-check: the original password substring must never survive any + // transformation. Catches future regressions automatically when new + // inputs are added. + if strings.Contains(in, "s3cret") && strings.Contains(redactDatabaseURL(in), "s3cret") { + t.Errorf("redactDatabaseURL(%q) leaked s3cret in output: %q", in, redactDatabaseURL(in)) + } + } +} + +func TestPortalAPI_Wellknown(t *testing.T) { + cfg := &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080/" + cfg.OIDC.Enabled = true + cfg.OIDC.Issuer = "http://idp" + cfg.OIDC.Audience = "api-test" + + p, _, _ := newTestPortalAPI(t, cfg) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/wellknown", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var body map[string]any + _ = json.NewDecoder(w.Body).Decode(&body) + if body["oidc_enabled"] != true || body["audience"] != "api-test" { + t.Errorf("wellknown body = %+v", body) + } + if body["api_endpoint"] != "http://localhost:8080/v1/" { + t.Errorf("api_endpoint = %v, want http://localhost:8080/v1/", body["api_endpoint"]) + } +} + +func TestPortalAPI_EndpointsAndDetail(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/endpoints", nil)) + var listed map[string]any + _ = json.NewDecoder(w.Body).Decode(&listed) + if got, _ := listed["endpoints"].([]any); len(got) != 2 { + t.Errorf("endpoint count = %d, want 2", len(got)) + } + + w = httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/endpoints/ping", nil)) + if w.Code != http.StatusOK { + t.Fatalf("detail status = %d", w.Code) + } + var detail endpoints.EndpointMeta + _ = json.NewDecoder(w.Body).Decode(&detail) + if detail.Name != "ping" { + t.Errorf("detail.Name = %q, want ping", detail.Name) + } + + w = httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/endpoints/no-such", nil)) + if w.Code != http.StatusNotFound { + t.Errorf("missing detail status = %d, want 404", w.Code) + } +} + +func TestPortalAPI_AuditMeta_AdvertisesContract(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/meta", nil)) + var body map[string]any + _ = json.NewDecoder(w.Body).Decode(&body) + filters, _ := body["filters"].([]any) + if len(filters) == 0 { + t.Errorf("audit/meta filters empty") + } +} + +func TestPortalAPI_AuditEventsAndDetail(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + id := uuid.NewString() + ev := audit.Event{ID: id, Method: "GET", Path: "/v1/ping", Status: 200, Timestamp: time.Now(), Success: true} + if err := log.Log(context.Background(), ev); err != nil { + t.Fatalf("seed: %v", err) + } + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/events", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var listed map[string]any + _ = json.NewDecoder(w.Body).Decode(&listed) + events, _ := listed["events"].([]any) + if len(events) != 1 { + t.Errorf("events len = %d, want 1", len(events)) + } + + w = httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/events/"+id, nil)) + if w.Code != http.StatusOK { + t.Errorf("detail status = %d, want 200", w.Code) + } + + // invalid UUID -> 400 + w = httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/events/not-a-uuid", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("invalid uuid status = %d, want 400", w.Code) + } + + // well-formed but unknown id -> 404 + w = httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/events/"+uuid.NewString(), nil)) + if w.Code != http.StatusNotFound { + t.Errorf("missing detail status = %d, want 404", w.Code) + } +} + +func TestPortalAPI_Dashboard(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + if err := log.Log(context.Background(), audit.Event{ID: uuid.NewString(), Method: "GET", Path: "/v1/a", Status: 200, Timestamp: time.Now(), Success: true}); err != nil { + t.Fatalf("seed: %v", err) + } + if err := log.Log(context.Background(), audit.Event{ID: uuid.NewString(), Method: "GET", Path: "/v1/b", Status: 500, Timestamp: time.Now(), Success: false}); err != nil { + t.Fatalf("seed: %v", err) + } + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/dashboard", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var body map[string]any + _ = json.NewDecoder(w.Body).Decode(&body) + if total, _ := body["total"].(float64); total < 2 { + t.Errorf("total = %v, want >= 2", body["total"]) + } + if succ, _ := body["success_count"].(float64); succ < 1 { + t.Errorf("success_count = %v, want >= 1", body["success_count"]) + } +} + +func TestPortalAPI_KeysHandlersReturn503WhenStoreNil(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + for _, tc := range []struct{ method, path string }{ + {http.MethodGet, "/api/v1/admin/keys"}, + {http.MethodDelete, "/api/v1/admin/keys/dev"}, + } { + w := httptest.NewRecorder() + r := httptest.NewRequest(tc.method, tc.path, nil) + r.Header.Set("X-Requested-With", "XMLHttpRequest") // satisfies CSRF check + mux.ServeHTTP(w, r) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("%s %s status = %d, want 503 (no DB store)", tc.method, tc.path, w.Code) + } + } + + // POST with body + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/admin/keys", strings.NewReader(`{"name":"x"}`)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Requested-With", "XMLHttpRequest") + mux.ServeHTTP(w, r) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("POST status = %d, want 503", w.Code) + } +} + +func TestParseQueryFilter(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z&method=GET&path=/v1/x&route_name=ping&user=u1&session=s1&q=err&status=500&success=false&limit=25&offset=10", nil) + f := parseQueryFilter(r) + if f.Method != "GET" || f.Path != "/v1/x" || f.RouteName != "ping" || f.UserID != "u1" || f.SessionID != "s1" || f.Search != "err" { + t.Errorf("parsed filter = %+v", f) + } + if f.Status != 500 || f.Limit != 25 || f.Offset != 10 { + t.Errorf("numeric fields = %+v", f) + } + if f.Success == nil || *f.Success != false { + t.Errorf("Success = %v, want false", f.Success) + } + if f.From.IsZero() || f.To.IsZero() { + t.Errorf("From/To = %v/%v, want parsed", f.From, f.To) + } + + // success=true branch + r2 := httptest.NewRequest(http.MethodGet, "/?success=true", nil) + if f := parseQueryFilter(r2); f.Success == nil || *f.Success != true { + t.Errorf("success=true: Success = %v", f.Success) + } +} + +func TestFirstNonEmpty(t *testing.T) { + if got := firstNonEmpty("", "", "x", "y"); got != "x" { + t.Errorf("firstNonEmpty = %q, want x", got) + } + if got := firstNonEmpty("", "", ""); got != "" { + t.Errorf("firstNonEmpty all empty = %q, want empty", got) + } +} + +func TestRedactIfSet(t *testing.T) { + if got := redactIfSet(""); got != "" { + t.Errorf("redactIfSet(\"\") = %q, want empty", got) + } + if got := redactIfSet("anything"); got != "[redacted]" { + t.Errorf("redactIfSet = %q, want [redacted]", got) + } +} + +func TestWriteJSONAndError(t *testing.T) { + w := httptest.NewRecorder() + writeJSON(w, http.StatusCreated, map[string]string{"k": "v"}) + if w.Code != http.StatusCreated || w.Header().Get("Content-Type") != "application/json" { + t.Errorf("writeJSON header/status wrong: %d %q", w.Code, w.Header().Get("Content-Type")) + } + + w = httptest.NewRecorder() + writeError(w, http.StatusBadRequest, errInvalid) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), errInvalid.Error()) { + t.Errorf("writeError body = %q, status %d", w.Body.String(), w.Code) + } +} + +var errInvalid = constErr("invalid") + +type constErr string + +func (e constErr) Error() string { return string(e) } diff --git a/pkg/httpsrv/portalauth.go b/pkg/httpsrv/portalauth.go new file mode 100644 index 0000000..79afb73 --- /dev/null +++ b/pkg/httpsrv/portalauth.go @@ -0,0 +1,78 @@ +package httpsrv + +import ( + "net/http" + + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/auth/inbound" +) + +// PortalAuth resolves the caller's identity for /portal/api/* routes from +// either: +// 1. A signed session cookie (browser flow), or +// 2. An X-API-Key header / Authorization: Bearer header (script clients). +// +// On success the *auth.Identity is attached to the request context. On failure +// a 401 is served; anonymous access is intentionally NOT honored on portal +// routes even when auth.allow_anonymous is true, because the portal exposes +// audit data and admin actions. +type PortalAuth struct { + sessions *SessionStore + chain *inbound.Chain +} + +// NewPortalAuth returns the middleware factory. chain may be nil if no +// API-key fallback is desired. +func NewPortalAuth(sessions *SessionStore, chain *inbound.Chain) *PortalAuth { + return &PortalAuth{sessions: sessions, chain: chain} +} + +// Middleware returns an http.Handler middleware that requires authentication. +func (p *PortalAuth) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // 1. Cookie path. + if p.sessions != nil { + if id := p.sessions.Read(r); id != nil { + ctx = auth.WithIdentity(ctx, id) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + // 2. API-key / Bearer fallback for script clients. Reuse the inbound + // auth chain so portal scripting and gateway calls share credentials. + if hasCredential(r) && p.chain != nil { + id, err := p.chain.Authenticate(ctx, r) + if err == nil && id != nil && id.AuthType != "anonymous" { + ctx = auth.WithIdentity(ctx, adaptInboundIdentity(id)) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + w.Header().Set("WWW-Authenticate", `Bearer realm="api-test-portal"`) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }) +} + +// adaptInboundIdentity translates an inbound.Identity (gateway-presented +// credential) into an auth.Identity (portal session). The two types are +// kept separate by design (see pkg/auth doc); this function is the single +// approved bridge between them. +func adaptInboundIdentity(in *inbound.Identity) *auth.Identity { + if in == nil { + return nil + } + return &auth.Identity{ + Subject: in.Subject, + Email: in.Email, + Name: in.KeyName, + AuthType: in.AuthType, + APIKeyID: in.KeyName, + Claims: in.Claims, + } +} diff --git a/pkg/httpsrv/portalauth_test.go b/pkg/httpsrv/portalauth_test.go new file mode 100644 index 0000000..9e05c99 --- /dev/null +++ b/pkg/httpsrv/portalauth_test.go @@ -0,0 +1,135 @@ +package httpsrv + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/plexara/api-test/pkg/auth" + "github.com/plexara/api-test/pkg/auth/inbound" +) + +// stubKeyStore implements inbound.APIKeyStore for fallback-credential tests. +type stubKeyStore struct { + plaintext, name string +} + +func (s stubKeyStore) LookupAPIKey(_ context.Context, presented string) (string, error) { + if presented == s.plaintext { + return s.name, nil + } + return "", inbound.ErrInvalidCredential +} + +func TestPortalAuth_RejectsAnonymous(t *testing.T) { + store, _ := NewSessionStore("c", testSecret, false, time.Hour) + pa := NewPortalAuth(store, nil) + called := false + h := pa.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/portal/api/whoami", nil)) + if called { + t.Error("anonymous request reached handler; should be 401") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } + if w.Header().Get("WWW-Authenticate") == "" { + t.Error("missing WWW-Authenticate challenge on 401") + } +} + +func TestPortalAuth_AcceptsValidSessionCookie(t *testing.T) { + store, _ := NewSessionStore("c", testSecret, false, time.Hour) + pa := NewPortalAuth(store, nil) + + cookieRec := httptest.NewRecorder() + want := &auth.Identity{Subject: "alice", AuthType: "oidc"} + if err := store.Issue(cookieRec, want); err != nil { + t.Fatalf("Issue: %v", err) + } + cookie := cookieRec.Result().Cookies()[0] + + var seen *auth.Identity + h := pa.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = auth.GetIdentity(r.Context()) + w.WriteHeader(http.StatusOK) + })) + r := httptest.NewRequest(http.MethodGet, "/portal/api/whoami", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if seen == nil || seen.Subject != "alice" { + t.Errorf("identity in ctx = %+v, want subject=alice", seen) + } +} + +func TestPortalAuth_FallsBackToAPIKey(t *testing.T) { + store, _ := NewSessionStore("c", testSecret, false, time.Hour) + keyStore := stubKeyStore{plaintext: "raw", name: "ci"} + chain := inbound.NewChain(false, inbound.NewAPIKey(keyStore, "X-API-Key", "api_key")) + pa := NewPortalAuth(store, chain) + + var seen *auth.Identity + h := pa.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = auth.GetIdentity(r.Context()) + w.WriteHeader(http.StatusOK) + })) + r := httptest.NewRequest(http.MethodGet, "/portal/api/whoami", nil) + r.Header.Set("X-API-Key", "raw") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if seen == nil || seen.AuthType != "apikey" || seen.APIKeyID != "ci" { + t.Errorf("identity = %+v, want authtype=apikey api_key_id=ci", seen) + } +} + +func TestPortalAuth_RejectsAnonymousChainEvenWithBadKey(t *testing.T) { + store, _ := NewSessionStore("c", testSecret, false, time.Hour) + keyStore := stubKeyStore{plaintext: "good", name: "ci"} + chain := inbound.NewChain(true, inbound.NewAPIKey(keyStore, "X-API-Key", "api_key")) // chain itself permits anonymous + + pa := NewPortalAuth(store, chain) + called := false + h := pa.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true })) + r := httptest.NewRequest(http.MethodGet, "/portal/api/whoami", nil) + r.Header.Set("X-API-Key", "wrong-key") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if called { + t.Error("portal auth admitted anonymous identity; must require non-anonymous") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAdaptInboundIdentity(t *testing.T) { + if got := adaptInboundIdentity(nil); got != nil { + t.Errorf("adapt(nil) = %+v, want nil", got) + } + in := &inbound.Identity{ + Subject: "ci", + Email: "ci@example.com", + AuthType: "apikey", + KeyName: "ci", + Claims: map[string]any{"role": "admin"}, + } + got := adaptInboundIdentity(in) + if got.Subject != "ci" || got.AuthType != "apikey" || got.APIKeyID != "ci" || got.Email != "ci@example.com" || got.Name != "ci" { + t.Errorf("adapt = %+v", got) + } + if got.Claims["role"] != "admin" { + t.Errorf("claims not preserved: %+v", got.Claims) + } +} diff --git a/pkg/httpsrv/session.go b/pkg/httpsrv/session.go new file mode 100644 index 0000000..4d4326d --- /dev/null +++ b/pkg/httpsrv/session.go @@ -0,0 +1,141 @@ +package httpsrv + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/plexara/api-test/pkg/auth" +) + +// SessionPayload is what we encode in the cookie. Identity is the resolved +// browser-session identity; Expires is enforced server-side. +type SessionPayload struct { + Identity *auth.Identity `json:"identity"` + Expires time.Time `json:"expires"` +} + +// SessionStore signs and verifies cookie payloads with HMAC-SHA256. +type SessionStore struct { + cookieName string + secret []byte + secure bool + maxAge time.Duration +} + +// NewSessionStore returns a store. secret should be at least 16 bytes. +func NewSessionStore(cookieName, secret string, secure bool, maxAge time.Duration) (*SessionStore, error) { + if len(secret) < 16 { + return nil, errors.New("session secret too short (need >= 16 bytes)") + } + if cookieName == "" { + cookieName = "api_test_session" + } + if maxAge == 0 { + maxAge = 12 * time.Hour + } + return &SessionStore{cookieName: cookieName, secret: []byte(secret), secure: secure, maxAge: maxAge}, nil +} + +// Issue writes the cookie carrying the given Identity. +func (s *SessionStore) Issue(w http.ResponseWriter, id *auth.Identity) error { + pl := SessionPayload{Identity: id, Expires: time.Now().Add(s.maxAge)} + enc, err := s.encode(pl) + if err != nil { + return err + } + // #nosec G124 -- Secure is set from config (dev/HTTP-only deployments need false). + http.SetCookie(w, &http.Cookie{ // nosemgrep: go.lang.security.audit.net.cookie-missing-secure.cookie-missing-secure -- Secure follows config; dev/HTTP loopback uses false intentionally. + Name: s.cookieName, + Value: enc, + Path: "/", + Expires: pl.Expires, + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) + return nil +} + +// Clear removes the cookie. +func (s *SessionStore) Clear(w http.ResponseWriter) { + // #nosec G124 -- Secure is set from config; SameSite default is fine for clears. + http.SetCookie(w, &http.Cookie{ // nosemgrep: go.lang.security.audit.net.cookie-missing-secure.cookie-missing-secure -- Secure follows config; dev/HTTP loopback uses false intentionally. + Name: s.cookieName, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// Read returns the Identity from the request's session cookie, or nil if no +// valid cookie is present (missing, signature mismatch, expired). +func (s *SessionStore) Read(r *http.Request) *auth.Identity { + c, err := r.Cookie(s.cookieName) + if err != nil || c.Value == "" { + return nil + } + pl, err := s.decode(c.Value) + if err != nil { + return nil + } + if time.Now().After(pl.Expires) { + return nil + } + return pl.Identity +} + +// CookieName returns the cookie name (used by tests). +func (s *SessionStore) CookieName() string { return s.cookieName } + +func (s *SessionStore) encode(pl SessionPayload) (string, error) { + body, err := json.Marshal(pl) + if err != nil { + return "", err + } + b64 := base64.RawURLEncoding.EncodeToString(body) + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(b64)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return b64 + "." + sig, nil +} + +func (s *SessionStore) decode(v string) (SessionPayload, error) { + parts := strings.SplitN(v, ".", 2) + if len(parts) != 2 { + return SessionPayload{}, errors.New("malformed cookie") + } + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(parts[0])) + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + got, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return SessionPayload{}, fmt.Errorf("decode sig: %w", err) + } + wantBytes, err := base64.RawURLEncoding.DecodeString(want) + if err != nil { + return SessionPayload{}, err + } + if !hmac.Equal(got, wantBytes) { + return SessionPayload{}, errors.New("bad signature") + } + body, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return SessionPayload{}, err + } + var pl SessionPayload + if err := json.Unmarshal(body, &pl); err != nil { + return SessionPayload{}, err + } + return pl, nil +} diff --git a/pkg/httpsrv/session_test.go b/pkg/httpsrv/session_test.go new file mode 100644 index 0000000..f3edc21 --- /dev/null +++ b/pkg/httpsrv/session_test.go @@ -0,0 +1,137 @@ +package httpsrv + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/plexara/api-test/pkg/auth" +) + +const testSecret = "0123456789abcdef-test" + +func TestNewSessionStore_RejectsShortSecret(t *testing.T) { + if _, err := NewSessionStore("c", "short", false, 0); err == nil { + t.Fatal("short secret: want error") + } +} + +func TestNewSessionStore_DefaultsCookieNameAndMaxAge(t *testing.T) { + s, err := NewSessionStore("", testSecret, false, 0) + if err != nil { + t.Fatalf("NewSessionStore: %v", err) + } + if s.CookieName() != "api_test_session" { + t.Errorf("default cookie name = %q, want api_test_session", s.CookieName()) + } +} + +func TestSessionStore_IssueAndReadRoundTrip(t *testing.T) { + s, err := NewSessionStore("api_test_session", testSecret, false, time.Hour) + if err != nil { + t.Fatalf("NewSessionStore: %v", err) + } + id := &auth.Identity{Subject: "alice", Email: "a@x", AuthType: "oidc"} + + w := httptest.NewRecorder() + if err := s.Issue(w, id); err != nil { + t.Fatalf("Issue: %v", err) + } + cookie := w.Result().Cookies()[0] + if cookie.Name != "api_test_session" || !cookie.HttpOnly { + t.Errorf("cookie attrs unexpected: %+v", cookie) + } + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(cookie) + got := s.Read(r) + if got == nil || got.Subject != "alice" || got.Email != "a@x" { + t.Errorf("Read returned %+v, want subject=alice email=a@x", got) + } +} + +func TestSessionStore_ReadNoCookie(t *testing.T) { + s, _ := NewSessionStore("c", testSecret, false, time.Hour) + if got := s.Read(httptest.NewRequest(http.MethodGet, "/", nil)); got != nil { + t.Errorf("Read with no cookie = %+v, want nil", got) + } +} + +func TestSessionStore_ReadEmptyCookie(t *testing.T) { + s, _ := NewSessionStore("c", testSecret, false, time.Hour) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "c", Value: ""}) + if got := s.Read(r); got != nil { + t.Errorf("Read with empty cookie = %+v, want nil", got) + } +} + +func TestSessionStore_ReadRejectsTamperedSignature(t *testing.T) { + s, _ := NewSessionStore("c", testSecret, false, time.Hour) + w := httptest.NewRecorder() + if err := s.Issue(w, &auth.Identity{Subject: "alice"}); err != nil { + t.Fatalf("Issue: %v", err) + } + cookie := w.Result().Cookies()[0] + parts := strings.SplitN(cookie.Value, ".", 2) + cookie.Value = parts[0] + "." + strings.Repeat("A", len(parts[1])) // signature flipped to garbage + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(cookie) + if got := s.Read(r); got != nil { + t.Errorf("Read with tampered sig = %+v, want nil", got) + } +} + +func TestSessionStore_ReadRejectsMalformedCookie(t *testing.T) { + s, _ := NewSessionStore("c", testSecret, false, time.Hour) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "c", Value: "no-dot-separator"}) + if got := s.Read(r); got != nil { + t.Errorf("Read with malformed cookie = %+v, want nil", got) + } +} + +func TestSessionStore_ReadRejectsExpired(t *testing.T) { + s, _ := NewSessionStore("c", testSecret, false, time.Hour) + pl := SessionPayload{Identity: &auth.Identity{Subject: "alice"}, Expires: time.Now().Add(-time.Minute)} + enc, err := s.encode(pl) + if err != nil { + t.Fatalf("encode: %v", err) + } + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "c", Value: enc}) + if got := s.Read(r); got != nil { + t.Errorf("Read expired = %+v, want nil", got) + } +} + +func TestSessionStore_ReadRejectsForeignSecret(t *testing.T) { + a, _ := NewSessionStore("c", testSecret, false, time.Hour) + b, _ := NewSessionStore("c", "different-secret-value-1234", false, time.Hour) + w := httptest.NewRecorder() + if err := a.Issue(w, &auth.Identity{Subject: "alice"}); err != nil { + t.Fatalf("Issue: %v", err) + } + cookie := w.Result().Cookies()[0] + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(cookie) + if got := b.Read(r); got != nil { + t.Errorf("Read across stores with different secrets = %+v, want nil", got) + } +} + +func TestSessionStore_Clear(t *testing.T) { + s, _ := NewSessionStore("c", testSecret, true, time.Hour) + w := httptest.NewRecorder() + s.Clear(w) + cookie := w.Result().Cookies()[0] + if cookie.MaxAge >= 0 { + t.Errorf("Clear should set MaxAge<0, got %d", cookie.MaxAge) + } + if cookie.Value != "" { + t.Errorf("Clear should empty value, got %q", cookie.Value) + } +} diff --git a/pkg/httpsrv/spa.go b/pkg/httpsrv/spa.go new file mode 100644 index 0000000..ae576dc --- /dev/null +++ b/pkg/httpsrv/spa.go @@ -0,0 +1,60 @@ +package httpsrv + +import ( + "io/fs" + "net/http" + "strings" +) + +// SPAHandler serves a single-page app from spaFS, falling back to index.html +// for any path that doesn't match an existing file (so client-side routing +// can take over). +// +// Mount via http.StripPrefix("/portal", SPAHandler(fsys)) so that requests to +// /portal/foo become /foo and resolve against the FS root. +func SPAHandler(spaFS fs.FS) http.Handler { + indexBytes, _ := fs.ReadFile(spaFS, "index.html") + fileServer := http.FileServer(http.FS(spaFS)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Don't fall through for asset requests that 404; return the real 404 + // so missing chunks are visible. Only fall back when the path looks + // like a client route (no extension and not /assets/). + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + if path == "index.html" { + serveIndex(w, indexBytes) + return + } + + if f, err := spaFS.Open(path); err == nil { + _ = f.Close() + fileServer.ServeHTTP(w, r) + return + } + if isClientRoute(path) { + serveIndex(w, indexBytes) + return + } + http.NotFound(w, r) + }) +} + +func serveIndex(w http.ResponseWriter, bytes []byte) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(bytes) +} + +func isClientRoute(path string) bool { + if strings.HasPrefix(path, "assets/") { + return false + } + if i := strings.LastIndex(path, "/"); i >= 0 { + path = path[i+1:] + } + return !strings.Contains(path, ".") +} diff --git a/pkg/httpsrv/spa_test.go b/pkg/httpsrv/spa_test.go new file mode 100644 index 0000000..9d911e3 --- /dev/null +++ b/pkg/httpsrv/spa_test.go @@ -0,0 +1,85 @@ +package httpsrv + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +func newTestSPA() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte("spa")}, + "assets/app-abc123.js": {Data: []byte("console.log(1)")}, + } +} + +func TestSPAHandler_RootServesIndex(t *testing.T) { + h := SPAHandler(newTestSPA()) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "spa") { + t.Errorf("root: status=%d body=%q", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Errorf("Content-Type = %q, want text/html…", got) + } +} + +func TestSPAHandler_ExplicitIndexPath(t *testing.T) { + h := SPAHandler(newTestSPA()) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/index.html", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "spa") { + t.Errorf("index.html: status=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestSPAHandler_ServesRealAsset(t *testing.T) { + h := SPAHandler(newTestSPA()) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/assets/app-abc123.js", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "console.log") { + t.Errorf("asset: status=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestSPAHandler_FallsBackToIndexForClientRoute(t *testing.T) { + h := SPAHandler(newTestSPA()) + for _, path := range []string{"/dashboard", "/audit/123", "/keys"} { + t.Run(path, func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "spa") { + t.Errorf("client route %q: status=%d body=%q", path, w.Code, w.Body.String()) + } + }) + } +} + +func TestSPAHandler_404sMissingAsset(t *testing.T) { + h := SPAHandler(newTestSPA()) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil)) + if w.Code != http.StatusNotFound { + t.Errorf("missing asset: status=%d, want 404", w.Code) + } +} + +func TestIsClientRoute(t *testing.T) { + cases := map[string]bool{ + "dashboard": true, + "audit/some-id": true, + "keys/": true, // trailing slash splits to empty -> "" has no dot -> client route + "assets/app.js": false, + "favicon.ico": false, + "plexara-mark.svg": false, + "some/path/file.html": false, + } + for in, want := range cases { + if got := isClientRoute(in); got != want { + t.Errorf("isClientRoute(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/pkg/httpsrv/wellknown.go b/pkg/httpsrv/wellknown.go new file mode 100644 index 0000000..6a9fee1 --- /dev/null +++ b/pkg/httpsrv/wellknown.go @@ -0,0 +1,53 @@ +package httpsrv + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/plexara/api-test/pkg/config" +) + +// ProtectedResourceMetadata responds to RFC 9728 metadata queries. The api-test +// server is the protected resource; the configured OIDC issuer is the +// authorization server. The resource value is a URL identifier (matched +// against the JWT aud claim), not the literal endpoint path; we use the +// base URL so it stays stable regardless of where REST handlers are mounted. +func ProtectedResourceMetadata(cfg *config.Config) http.HandlerFunc { + resource := strings.TrimRight(cfg.Server.BaseURL, "/") + "/" + authServers := []string{} + if cfg.OIDC.Enabled && cfg.OIDC.Issuer != "" { + authServers = append(authServers, cfg.OIDC.Issuer) + } + body := map[string]any{ + "resource": resource, + "authorization_servers": authServers, + "bearer_methods_supported": []string{"header"}, + "scopes_supported": []string{}, + } + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + } +} + +// AuthorizationServerStub responds to /.well-known/oauth-authorization-server +// with a minimal pointer to the upstream OIDC issuer's metadata. The real +// metadata lives at /.well-known/openid-configuration. +func AuthorizationServerStub(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := map[string]any{ + "issuer": cfg.OIDC.Issuer, + "openid_configuration_url": strings.TrimRight(cfg.OIDC.Issuer, "/") + "/.well-known/openid-configuration", + "note": "api-test delegates auth to the issuer above; fetch the openid_configuration_url for the real authorization server metadata", + } + _ = json.NewEncoder(w).Encode(body) + } +} + +// ProtectedResourceMetadataURL returns the absolute URL of the metadata doc +// for use in WWW-Authenticate challenges. +func ProtectedResourceMetadataURL(cfg *config.Config) string { + return strings.TrimRight(cfg.Server.BaseURL, "/") + "/.well-known/oauth-protected-resource" +} diff --git a/pkg/httpsrv/wellknown_test.go b/pkg/httpsrv/wellknown_test.go new file mode 100644 index 0000000..83ebe98 --- /dev/null +++ b/pkg/httpsrv/wellknown_test.go @@ -0,0 +1,76 @@ +package httpsrv + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/plexara/api-test/pkg/config" +) + +func TestProtectedResourceMetadata_OIDCEnabled(t *testing.T) { + cfg := &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080/" + cfg.OIDC.Enabled = true + cfg.OIDC.Issuer = "http://idp/realm" + + h := ProtectedResourceMetadata(cfg) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if got := w.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + var body map[string]any + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["resource"] != "http://localhost:8080/" { + t.Errorf("resource = %v, want http://localhost:8080/", body["resource"]) + } + servers, _ := body["authorization_servers"].([]any) + if len(servers) != 1 || servers[0] != "http://idp/realm" { + t.Errorf("authorization_servers = %v, want [http://idp/realm]", servers) + } +} + +func TestProtectedResourceMetadata_OIDCDisabledHasEmptyAuthServers(t *testing.T) { + cfg := &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080" + h := ProtectedResourceMetadata(cfg) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + var body map[string]any + _ = json.NewDecoder(w.Body).Decode(&body) + servers, _ := body["authorization_servers"].([]any) + if len(servers) != 0 { + t.Errorf("authorization_servers = %v, want empty when oidc disabled", servers) + } +} + +func TestAuthorizationServerStub_PointsAtIssuer(t *testing.T) { + cfg := &config.Config{} + cfg.OIDC.Issuer = "http://idp/realm/" + h := AuthorizationServerStub(cfg) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + var body map[string]any + _ = json.NewDecoder(w.Body).Decode(&body) + if body["issuer"] != "http://idp/realm/" { + t.Errorf("issuer = %v, want http://idp/realm/", body["issuer"]) + } + if body["openid_configuration_url"] != "http://idp/realm/.well-known/openid-configuration" { + t.Errorf("openid_configuration_url = %v, want http://idp/realm/.well-known/openid-configuration", body["openid_configuration_url"]) + } +} + +func TestProtectedResourceMetadataURL(t *testing.T) { + cfg := &config.Config{} + cfg.Server.BaseURL = "http://localhost:8080/" + if got := ProtectedResourceMetadataURL(cfg); got != "http://localhost:8080/.well-known/oauth-protected-resource" { + t.Errorf("URL = %q, want http://localhost:8080/.well-known/oauth-protected-resource", got) + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..2f1089f --- /dev/null +++ b/ui/index.html @@ -0,0 +1,24 @@ + + + + + + api-test portal + + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..0fbe9d4 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "api-test-portal", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.59.16", + "lucide-react": "^0.453.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^6.27.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.0", + "vite": "^5.4.0" + } +} diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml new file mode 100644 index 0000000..57e958d --- /dev/null +++ b/ui/pnpm-lock.yaml @@ -0,0 +1,1468 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.59.16 + version: 5.100.9(react@19.2.6) + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@19.2.6) + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + react-router-dom: + specifier: ^6.27.0 + version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + zustand: + specifier: ^5.0.0 + version: 5.0.13(@types/react@19.2.14)(react@19.2.6) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.3.0(vite@5.4.21(lightningcss@1.32.0)) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@5.4.21(lightningcss@1.32.0)) + tailwindcss: + specifier: ^4.0.0 + version: 4.3.0 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vite: + specifier: ^5.4.0 + version: 5.4.21(lightningcss@1.32.0) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.100.9': + resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} + + '@tanstack/react-query@5.100.9': + resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + + enhanced-resolve@5.21.2: + resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} + engines: {node: '>=10.13.0'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.453.0: + resolution: {integrity: sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@remix-run/router@1.23.2': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@5.4.21(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 5.4.21(lightningcss@1.32.0) + + '@tanstack/query-core@5.100.9': {} + + '@tanstack/react-query@5.100.9(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.9 + react: 19.2.6 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(lightningcss@1.32.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.29: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.353 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001792: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.353: {} + + enhanced-resolve@5.21.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.453.0(react@19.2.6): + dependencies: + react: 19.2.6 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.38: {} + + picocolors@1.1.1: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@remix-run/router': 1.23.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-router: 6.30.3(react@19.2.6) + + react-router@6.30.3(react@19.2.6): + dependencies: + '@remix-run/router': 1.23.2 + react: 19.2.6 + + react@19.2.6: {} + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@5.4.21(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.60.3 + optionalDependencies: + fsevents: 2.3.3 + lightningcss: 1.32.0 + + yallist@3.1.1: {} + + zustand@5.0.13(@types/react@19.2.14)(react@19.2.6): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.6 diff --git a/ui/public/plexara-mark.svg b/ui/public/plexara-mark.svg new file mode 100644 index 0000000..42de4d2 --- /dev/null +++ b/ui/public/plexara-mark.svg @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + diff --git a/ui/public/plexara-wordmark.svg b/ui/public/plexara-wordmark.svg new file mode 100644 index 0000000..26cb556 --- /dev/null +++ b/ui/public/plexara-wordmark.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..b542402 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,93 @@ +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { useAuth } from "./stores/auth"; +import ThemeToggle from "./components/ThemeToggle"; +import { SidebarBrand, SponsoredBy } from "./components/Brand"; +import { Activity, Network, ShieldCheck, KeyRound, Settings, Info, LogOut } from "lucide-react"; + +function prettySubject(s: string | undefined): string { + if (!s) return ""; + if (s.startsWith("apikey:")) return s.slice("apikey:".length); + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return ""; + return s; +} + +const NAV: { to: string; label: string; icon: typeof Activity }[] = [ + { to: "/", label: "Dashboard", icon: Activity }, + { to: "/endpoints", label: "Endpoints", icon: Network }, + { to: "/audit", label: "Audit", icon: ShieldCheck }, + { to: "/keys", label: "API Keys", icon: KeyRound }, + { to: "/config", label: "Config", icon: Settings }, + { to: "/about", label: "About", icon: Info }, +]; + +export default function App() { + const { identity, status, refresh, signOut } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (status === "idle") void refresh(); + }, [status, refresh]); + + useEffect(() => { + if (status === "anonymous") navigate("/login", { replace: true }); + }, [status, navigate]); + + if (status === "idle" || status === "loading") { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/ui/src/components/Brand.tsx b/ui/src/components/Brand.tsx new file mode 100644 index 0000000..9db8201 --- /dev/null +++ b/ui/src/components/Brand.tsx @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query"; +import { portalAPI } from "@/lib/api"; + +const MARK = `${import.meta.env.BASE_URL}plexara-mark.svg`; +const WORDMARK = `${import.meta.env.BASE_URL}plexara-wordmark.svg`; + +// SidebarBrand is the top-of-sidebar lockup: mark + product name. +export function SidebarBrand() { + const v = useVersion(); + return ( +
+ Plexara +
+
api-test
+
+ {v} +
+
+
+ ); +} + +// SponsoredBy is the small footer line: "Sponsored by [Plexara wordmark]". +export function SponsoredBy() { + return ( + + Sponsored by + Plexara + + ); +} + +function useVersion(): string { + const q = useQuery({ + queryKey: ["server-version"], + queryFn: portalAPI.server, + staleTime: 60_000, + }); + return q.data?.version ?? "-"; +} diff --git a/ui/src/components/ErrorBoundary.tsx b/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ef53b28 --- /dev/null +++ b/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { Component, type ReactNode } from "react"; + +type Props = { children: ReactNode }; +type State = { error: Error | null }; + +// Top-level error boundary so a render-time exception in any page produces +// a recoverable surface instead of white-screening the entire portal. +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: { componentStack?: string | null }) { + + console.error("[portal] uncaught error", error, info.componentStack); + } + + reset = () => { + this.setState({ error: null }); + window.location.reload(); + }; + + render() { + if (this.state.error) { + return ( +
+

Something went wrong

+

+ The portal hit an unexpected error. Reloading usually clears it. +

+
+            {String(this.state.error?.message || this.state.error)}
+          
+ +
+ ); + } + return this.props.children; + } +} diff --git a/ui/src/components/JsonView.tsx b/ui/src/components/JsonView.tsx new file mode 100644 index 0000000..227d63b --- /dev/null +++ b/ui/src/components/JsonView.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { Copy, Check } from "lucide-react"; + +export function JsonView({ value, label }: { value: unknown; label?: string }) { + const [copied, setCopied] = useState(false); + const json = value === undefined || value === null ? "" : JSON.stringify(value, null, 2); + + async function copy() { + try { + await navigator.clipboard.writeText(json); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable on http:// origins */ + } + } + + if (!json) { + return ( +
+ {label ? `${label}: ` : ""}(empty) +
+ ); + } + + return ( +
+ {label &&
{label}
} +
+        {json}
+      
+ +
+ ); +} diff --git a/ui/src/components/ThemeToggle.tsx b/ui/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..18e5348 --- /dev/null +++ b/ui/src/components/ThemeToggle.tsx @@ -0,0 +1,32 @@ +import { Sun, Moon, Monitor } from "lucide-react"; +import { useTheme } from "@/stores/theme"; + +const options = [ + { value: "light" as const, icon: Sun, label: "Light" }, + { value: "dark" as const, icon: Moon, label: "Dark" }, + { value: "system" as const, icon: Monitor, label: "System" }, +]; + +export default function ThemeToggle() { + const { theme, setTheme } = useTheme(); + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..b313f8e --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,101 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --success: 142.1 76.2% 36.3%; + --success-foreground: 0 0% 100%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --success: 142.1 70.6% 45.3%; + --success-foreground: 144.9 80.4% 10%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + html, body, #root { + height: 100%; + } + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; + } +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.875rem; +} + +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-success: hsl(var(--success)); + --color-success-foreground: hsl(var(--success-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts new file mode 100644 index 0000000..03c7db7 --- /dev/null +++ b/ui/src/lib/api.ts @@ -0,0 +1,185 @@ +// Thin fetch wrapper that: +// - Targets /api/v1/portal/* and /api/v1/admin/* on the same origin. +// - Sends X-API-Key from sessionStorage when present (falls through to the +// session cookie otherwise). +// - Always sends X-Requested-With on writes so the server's CSRF check +// accepts the request (and a forged POST cannot reach it). +// - Surfaces 401 as a typed Error and triggers a registered handler so +// the auth store can clear local state and bounce to /login. + +const API_KEY_STORAGE = "api-test-api-key"; + +export class HttpError extends Error { + constructor(public status: number, message: string, public body?: unknown) { + super(message); + } +} + +export function setApiKey(key: string) { + sessionStorage.setItem(API_KEY_STORAGE, key); +} + +export function clearApiKey() { + sessionStorage.removeItem(API_KEY_STORAGE); +} + +export function getApiKey(): string | null { + return sessionStorage.getItem(API_KEY_STORAGE); +} + +let onUnauthorized: (() => void) | null = null; +export function setUnauthorizedHandler(fn: () => void) { + onUnauthorized = fn; +} + +async function request( + path: string, + init: RequestInit = {}, + signal?: AbortSignal, +): Promise { + const headers = new Headers(init.headers); + const key = getApiKey(); + if (key) headers.set("X-API-Key", key); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (!headers.has("X-Requested-With")) { + headers.set("X-Requested-With", "XMLHttpRequest"); + } + const resp = await fetch(path, { + credentials: "include", + ...init, + headers, + signal, + }); + if (resp.status === 204) return undefined as T; + + const ct = resp.headers.get("content-type") || ""; + let body: unknown; + if (ct.includes("application/json")) { + body = await resp.json().catch(() => undefined); + } else { + body = await resp.text(); + } + + if (!resp.ok) { + if (resp.status === 401) { + clearApiKey(); + onUnauthorized?.(); + } + const msg = + typeof body === "object" && body !== null && "error" in body + ? String((body as { error: string }).error) + : resp.statusText || `HTTP ${resp.status}`; + throw new HttpError(resp.status, msg, body); + } + return body as T; +} + +export const api = { + get: (path: string, signal?: AbortSignal) => request(path, undefined, signal), + post: (path: string, body: unknown, signal?: AbortSignal) => request(path, { method: "POST", body: JSON.stringify(body) }, signal), + delete: (path: string, signal?: AbortSignal) => request(path, { method: "DELETE" }, signal), +}; + +// --- typed endpoints --- + +export type Identity = { + subject: string; + email?: string; + name?: string; + auth_type: "oidc" | "apikey" | "anonymous"; + claims?: Record; + api_key_id?: string; +}; + +export type EndpointMeta = { + name: string; + group: string; + method: string; + path: string; + description: string; + auth_required: boolean; +}; + +export type AuditEvent = { + id: string; + timestamp: string; + duration_ms: number; + request_id?: string; + session_id?: string; + user_subject?: string; + user_email?: string; + auth_type?: string; + api_key_name?: string; + method: string; + path: string; + route_name?: string; + endpoint_group?: string; + status: number; + bytes_in: number; + bytes_out: number; + success: boolean; + error_message?: string; + error_category?: string; + remote_addr?: string; + user_agent?: string; + payload?: AuditPayload; +}; + +export type AuditPayload = { + request_headers?: Record; + request_query?: Record; + request_content_type?: string; + request_body?: string; + request_size_bytes?: number; + request_truncated?: boolean; + request_remote_addr?: string; + response_headers?: Record; + response_content_type?: string; + response_body?: string; + response_size_bytes?: number; + response_truncated?: boolean; + replayed_from?: string; +}; + +export type AuditMeta = { + filters: string[]; + features: { timeseries: boolean; breakdown: boolean; stream: boolean; export: boolean; replay: boolean }; +}; + +export type DashboardResponse = { + window_from: string; + window_to: string; + total: number; + success_count: number; + recent: AuditEvent[]; +}; + +export type Key = { + id: string; + name: string; + description?: string; + created_by?: string; + created_at: string; + expires_at?: string; + last_used_at?: string; +}; + +export const portalAPI = { + me: () => api.get("/api/v1/portal/me"), + server: () => api.get<{ version: string; commit: string; date: string; config: unknown }>("/api/v1/portal/server"), + endpoints: () => api.get<{ endpoints: EndpointMeta[] }>("/api/v1/portal/endpoints"), + endpointDetail: (name: string) => api.get(`/api/v1/portal/endpoints/${encodeURIComponent(name)}`), + auditMeta: () => api.get("/api/v1/portal/audit/meta"), + audit: (qs: string) => api.get<{ events: AuditEvent[]; total: number; limit: number; offset: number }>(`/api/v1/portal/audit/events${qs ? "?" + qs : ""}`), + auditEvent: (id: string) => api.get(`/api/v1/portal/audit/events/${encodeURIComponent(id)}`), + dashboard: () => api.get("/api/v1/portal/dashboard"), + wellknown: () => api.get<{ protected_resource_url: string; authorization_server: string; oidc_enabled: boolean; audience: string; api_endpoint: string }>("/api/v1/portal/wellknown"), +}; + +export const adminAPI = { + listKeys: () => api.get<{ keys: Key[] }>("/api/v1/admin/keys"), + createKey: (name: string, description?: string) => api.post<{ key: Key; plaintext: string }>("/api/v1/admin/keys", { name, description }), + deleteKey: (name: string) => api.delete(`/api/v1/admin/keys/${encodeURIComponent(name)}`), +}; diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..b7d8c07 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import "./index.css"; +import App from "./App"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import Endpoints from "./pages/Endpoints"; +import Audit from "./pages/Audit"; +import ApiKeys from "./pages/ApiKeys"; +import Config from "./pages/Config"; +import About from "./pages/About"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import "./stores/auth"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 5_000, refetchOnWindowFocus: false, retry: false }, + }, +}); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + +); diff --git a/ui/src/pages/About.tsx b/ui/src/pages/About.tsx new file mode 100644 index 0000000..1befc4b --- /dev/null +++ b/ui/src/pages/About.tsx @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; +import { portalAPI } from "@/lib/api"; + +export default function About() { + const sq = useQuery({ queryKey: ["server"], queryFn: portalAPI.server }); + const wq = useQuery({ queryKey: ["wellknown"], queryFn: portalAPI.wellknown }); + + return ( +
+
+

About api-test

+ {sq.data && ( +
+ {sq.data.version} · {sq.data.commit?.slice(0, 8) || "?"} · {sq.data.date} +
+ )} +
+ +

+ api-test is a controllable HTTP REST fixture used to exercise the + Plexara API gateway end-to-end. Endpoints are deliberately simple + and deterministic — their job is not to compute anything useful, + their job is to make a gateway's behavior observable. Every request + is recorded in a Postgres-backed audit log, so you can compare what + a client sent, what reached this server, and what came back. +

+ +
+
Test against Plexara
+

+ Register api-test as a Plexara connection (see examples/plexara-connection.yaml), + then call its endpoints from any Plexara client. Every call lands in this portal's Audit page. +

+ {wq.data && ( +
+
API endpoint: {wq.data.api_endpoint}
+
OIDC issuer: {wq.data.authorization_server || "(disabled)"}
+
OIDC audience: {wq.data.audience || "(disabled)"}
+
+ )} +
+
+ ); +} diff --git a/ui/src/pages/ApiKeys.tsx b/ui/src/pages/ApiKeys.tsx new file mode 100644 index 0000000..ffc8f21 --- /dev/null +++ b/ui/src/pages/ApiKeys.tsx @@ -0,0 +1,141 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { adminAPI, HttpError } from "@/lib/api"; +import { useState } from "react"; +import { Trash2, Copy, Check } from "lucide-react"; + +export default function ApiKeys() { + const qc = useQueryClient(); + const q = useQuery({ queryKey: ["keys"], queryFn: adminAPI.listKeys }); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [created, setCreated] = useState<{ name: string; plaintext: string } | null>(null); + const [error, setError] = useState(null); + + const createMut = useMutation({ + mutationFn: () => adminAPI.createKey(name, description || undefined), + onSuccess: (r) => { + setCreated({ name: r.key.name, plaintext: r.plaintext }); + setName(""); + setDescription(""); + setError(null); + void qc.invalidateQueries({ queryKey: ["keys"] }); + }, + onError: (e: HttpError) => setError(e.message), + }); + + const deleteMut = useMutation({ + mutationFn: (n: string) => adminAPI.deleteKey(n), + onSuccess: () => qc.invalidateQueries({ queryKey: ["keys"] }), + }); + + if (q.isLoading) return
Loading…
; + + return ( +
+
+

API Keys

+
{q.data?.keys.length ?? 0} keys
+
+ + {q.error && ( +
+ Failed to load keys (DB-backed key store may be disabled in this build). +
+ )} + + {created && ( +
+
Key {created.name} created.
+
+ This is the only time the plaintext value is shown — copy it now. +
+ + +
+ )} + +
+
Create new
+
+ setName(e.target.value)} + className="flex-1 bg-background border border-input rounded px-3 py-2 text-sm" + /> + setDescription(e.target.value)} + className="flex-1 bg-background border border-input rounded px-3 py-2 text-sm" + /> + +
+ {error &&
{error}
} +
+ +
+ + + + + + + + + + + + {(q.data?.keys ?? []).map((k) => ( + + + + + + + + ))} + {(!q.data?.keys || q.data.keys.length === 0) && ( + + )} + +
NameDescriptionCreatedLast used
{k.name}{k.description || "-"}{new Date(k.created_at).toLocaleString()}{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "never"} + +
No keys yet.
+
+
+ ); +} + +function CopyBox({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* clipboard unavailable */ } + } + return ( +
+ {value} + +
+ ); +} diff --git a/ui/src/pages/Audit.tsx b/ui/src/pages/Audit.tsx new file mode 100644 index 0000000..ea485bf --- /dev/null +++ b/ui/src/pages/Audit.tsx @@ -0,0 +1,153 @@ +import { useQuery } from "@tanstack/react-query"; +import { portalAPI } from "@/lib/api"; +import { useState } from "react"; +import { JsonView } from "@/components/JsonView"; + +export default function Audit() { + const [filter, setFilter] = useState({ method: "", path: "", success: "" }); + const [selected, setSelected] = useState(null); + + const qs = new URLSearchParams(); + if (filter.method) qs.set("method", filter.method); + if (filter.path) qs.set("path", filter.path); + if (filter.success) qs.set("success", filter.success); + qs.set("limit", "100"); + + const q = useQuery({ + queryKey: ["audit", qs.toString()], + queryFn: () => portalAPI.audit(qs.toString()), + refetchInterval: 5_000, + }); + + return ( +
+
+

Audit

+
+ {q.data ? `${q.data.events.length} of ${q.data.total}` : "…"} +
+
+ +
+ setFilter({ ...filter, method: v })} /> + setFilter({ ...filter, path: v })} /> + +
+ +
+
+ + + + + + + + + + + {(q.data?.events ?? []).map((e) => ( + setSelected(e.id)} + className={`border-t border-border cursor-pointer hover:bg-muted/40 ${selected === e.id ? "bg-muted/60" : ""}`} + > + + + + + + ))} + {q.data?.events.length === 0 && ( + + )} + +
TimeMethodPathStatus
{new Date(e.timestamp).toLocaleTimeString()}{e.method}{e.path} + {e.status} +
No events match.
+
+
+ {selected ? :
Click a row to inspect the request.
} +
+
+
+ ); +} + +function EventDetail({ id }: { id: string }) { + const q = useQuery({ queryKey: ["audit-event", id], queryFn: () => portalAPI.auditEvent(id) }); + if (q.isLoading) return
Loading…
; + if (q.error || !q.data) return
Failed to load event.
; + const e = q.data; + return ( +
+
+
{e.method} {e.path}
+ {e.status} +
+
+ + + + + + + + +
+ {e.payload && ( +
+ {e.payload.request_headers && } + {e.payload.request_query && } + {e.payload.request_body && } + {e.payload.response_headers && } + {e.payload.response_body && } +
+ )} +
+ ); +} + +function tryParseJSON(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function FilterInput({ placeholder, value, onChange }: { placeholder: string; value: string; onChange: (v: string) => void }) { + return ( + onChange(e.target.value)} + className="bg-background border border-input rounded px-2 py-1 text-sm w-40" + /> + ); +} + +function statusColor(status: number): string { + if (status >= 500) return "text-destructive"; + if (status >= 400) return "text-destructive/80"; + if (status >= 300) return "text-muted-foreground"; + return "text-success"; +} diff --git a/ui/src/pages/Config.tsx b/ui/src/pages/Config.tsx new file mode 100644 index 0000000..bf3d8e0 --- /dev/null +++ b/ui/src/pages/Config.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import { portalAPI } from "@/lib/api"; +import { JsonView } from "@/components/JsonView"; + +export default function Config() { + const q = useQuery({ queryKey: ["server"], queryFn: portalAPI.server }); + + if (q.isLoading) return
Loading…
; + if (q.error) return
Failed to load config.
; + const d = q.data!; + + return ( +
+
+

Config

+
{d.version} · {d.commit?.slice(0, 8) || "?"}
+
+

+ The effective server config with secrets redacted. Read-only; edit via configs/api-test.live.yaml and restart. +

+ +
+ ); +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx new file mode 100644 index 0000000..ea59fe9 --- /dev/null +++ b/ui/src/pages/Dashboard.tsx @@ -0,0 +1,94 @@ +import { useQuery } from "@tanstack/react-query"; +import { portalAPI, type AuditEvent } from "@/lib/api"; +import { Link } from "react-router-dom"; + +export default function Dashboard() { + const q = useQuery({ queryKey: ["dashboard"], queryFn: portalAPI.dashboard, refetchInterval: 5_000 }); + + if (q.isLoading) return
Loading…
; + if (q.error) return
Failed to load dashboard.
; + const d = q.data!; + const errorCount = Number(d.total) - Number(d.success_count); + const errorRate = d.total > 0 ? errorCount / d.total : 0; + + return ( +
+
+

Dashboard

+
+ last 1h · {new Date(d.window_to).toLocaleTimeString()} +
+
+ +
+ + + 0 ? "danger" : undefined} /> + +
+ +
+
+

Recent activity

+ View all → +
+
+ + + + + + + + + + + + + {(d.recent ?? []).map((e) => ( + + + + + + + + + ))} + {(!d.recent || d.recent.length === 0) && ( + + )} + +
TimeMethodPathUserStatusms
{new Date(e.timestamp).toLocaleTimeString()}{e.method}{e.path} + {displayUser(e)} + + {e.status} + {e.duration_ms}
No events in the last hour.
+
+
+
+ ); +} + +function Stat({ label, value, accent }: { label: string; value: number | string; accent?: "danger" }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function displayUser(e: AuditEvent): string { + if (e.user_email) return e.user_email; + if (e.api_key_name) return e.api_key_name; + const sub = e.user_subject ?? ""; + return sub || "-"; +} + +function statusColor(status: number): string { + if (status >= 500) return "text-destructive"; + if (status >= 400) return "text-destructive/80"; + if (status >= 300) return "text-muted-foreground"; + return "text-success"; +} diff --git a/ui/src/pages/Endpoints.tsx b/ui/src/pages/Endpoints.tsx new file mode 100644 index 0000000..7197592 --- /dev/null +++ b/ui/src/pages/Endpoints.tsx @@ -0,0 +1,91 @@ +import { useQuery } from "@tanstack/react-query"; +import { portalAPI, type EndpointMeta } from "@/lib/api"; +import { useParams, Link } from "react-router-dom"; + +export default function Endpoints() { + const q = useQuery({ queryKey: ["endpoints"], queryFn: portalAPI.endpoints }); + const { name } = useParams<{ name?: string }>(); + + if (q.isLoading) return
Loading…
; + if (q.error) return
Failed to load endpoints.
; + const all = q.data?.endpoints ?? []; + const selected = name ? all.find((e) => e.name === name) : null; + + return ( +
+
+

Endpoints

+
{all.length} registered
+
+ +
+
+ + + + + + + + + + {all.map((e) => ( + + + + + + ))} + +
MethodPathGroup
+ {e.method} + + {e.path} + {e.group}
+
+ +
+ {selected ? : ( +
Select an endpoint to view details.
+ )} +
+
+
+ ); +} + +function EndpointDetail({ e }: { e: EndpointMeta }) { + return ( +
+
+
Endpoint
+
{e.name}
+
+
+ + + + +
+ {e.description && ( +
+
Description
+
{e.description}
+
+ )} +
+ Try-It panel arrives in M4 (OpenAPI generator). For now, invoke directly: +
curl -H "X-API-Key: $KEY" http://localhost:8080{e.path}
+
+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/ui/src/pages/Login.tsx b/ui/src/pages/Login.tsx new file mode 100644 index 0000000..c4f076d --- /dev/null +++ b/ui/src/pages/Login.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { setApiKey } from "@/lib/api"; +import { useAuth } from "@/stores/auth"; +import { useNavigate } from "react-router-dom"; +import ThemeToggle from "@/components/ThemeToggle"; +import { SponsoredBy } from "@/components/Brand"; + +const MARK = `${import.meta.env.BASE_URL}plexara-mark.svg`; + +export default function Login() { + const [key, setKey] = useState(""); + const [error, setError] = useState(null); + const refresh = useAuth((s) => s.refresh); + const navigate = useNavigate(); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setApiKey(key.trim()); + await refresh(); + if (useAuth.getState().status === "authenticated") { + navigate("/", { replace: true }); + } else { + setError("API key was not accepted."); + } + }; + + return ( +
+
+ +
+
+
+ Plexara +
+
api-test portal
+
Sign in to inspect endpoints and audit logs.
+
+
+ + + Sign in with OIDC + + +
+
+
+
+
+ or use an API key +
+
+ + + setKey(e.target.value)} + className="w-full bg-background border border-input rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + {error &&
{error}
} + + + +
+ +
+
+
+ ); +} diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts new file mode 100644 index 0000000..9f4463b --- /dev/null +++ b/ui/src/stores/auth.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; +import { portalAPI, clearApiKey, setUnauthorizedHandler, type Identity } from "@/lib/api"; + +type Status = "idle" | "loading" | "authenticated" | "anonymous"; + +type AuthState = { + identity: Identity | null; + status: Status; + refresh: () => Promise; + signOut: () => Promise; +}; + +export const useAuth = create((set) => ({ + identity: null, + status: "idle", + refresh: async () => { + set({ status: "loading" }); + try { + const id = await portalAPI.me(); + set({ identity: id, status: "authenticated" }); + } catch { + set({ identity: null, status: "anonymous" }); + } + }, + signOut: async () => { + clearApiKey(); + try { + await fetch("/portal/auth/logout", { + method: "POST", + credentials: "include", + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + } catch { /* ignore */ } + set({ identity: null, status: "anonymous" }); + window.location.href = "/portal/login"; + }, +})); + +setUnauthorizedHandler(() => { + clearApiKey(); + useAuth.setState({ identity: null, status: "anonymous" }); + if (typeof window !== "undefined" && !window.location.pathname.endsWith("/login")) { + window.location.href = "/portal/login"; + } +}); diff --git a/ui/src/stores/theme.ts b/ui/src/stores/theme.ts new file mode 100644 index 0000000..84cab53 --- /dev/null +++ b/ui/src/stores/theme.ts @@ -0,0 +1,63 @@ +import { create } from "zustand"; + +type Theme = "light" | "dark" | "system"; + +interface ThemeState { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const STORAGE_KEY = "api-test-theme"; + +function getStoredTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark" || stored === "system") { + return stored; + } + } catch { + /* localStorage unavailable */ + } + return "system"; +} + +function prefersDark(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +function applyTheme(theme: Theme) { + if (typeof document === "undefined") return; + const root = document.documentElement; + if (theme === "system") { + root.classList.toggle("dark", prefersDark()); + } else { + root.classList.toggle("dark", theme === "dark"); + } +} + +applyTheme(getStoredTheme()); + +if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if (getStoredTheme() === "system") { + applyTheme("system"); + } + }); +} + +export const useTheme = create((set) => ({ + theme: getStoredTheme(), + setTheme: (theme: Theme) => { + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch { + /* localStorage unavailable */ + } + applyTheme(theme); + set({ theme }); + }, +})); diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..32db3bd --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..66c919c --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "node:path"; + +// Vite serves the SPA from /portal/ in production. The Go backend embeds +// dist/ via go:embed and falls back to index.html for unknown paths so +// react-router can take over. +export default defineConfig({ + base: "/portal/", + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + server: { + proxy: { + "/api": { target: "http://localhost:8080", changeOrigin: true }, + "/portal/auth": { target: "http://localhost:8080", changeOrigin: true }, + "/.well-known": { target: "http://localhost:8080", changeOrigin: true }, + "/healthz": { target: "http://localhost:8080", changeOrigin: true }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +});