Files
Alphaeus Mote e0b002975f feat(api-docs): require auth for docs and add filterable route discovery
The OpenAPI spec and Swagger UI were public. Put them behind the same
authentication as the rest of the API, and add a compact route list so a
client can ask "what can I call?" without opening dev tools.

Access:
- /api/openapi.json and /api/routes require a bearer token or API key.
- /api/docs additionally accepts a session cookie set at login, so a
  signed-in operator can open the docs in a new tab; an anonymous browser
  is redirected to /login?redirect=... and returned afterwards.
- The cookie is HttpOnly and path-scoped to /api/docs, so it is never sent
  to /api/v1/* and cannot authenticate an API call (no CSRF surface).
  Verified: cookie-only request to /api/v1/rules returns 401.

Discovery: both the spec and GET /api/routes accept ?method=get,post and
?path=<substring> (comma-separated, case-insensitive). The route list
returns method, path, summary, tag, public, and `allowed` — false when a
read-scoped API key cannot invoke that route. /api/docs passes the same
query through to the spec it loads.

UI: a </> icon in the header (both layouts) and an Administration → API
Docs menu entry, opened in a new tab via a new `external` nav-item flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:44:54 -04:00

52 lines
1.6 KiB
Go

// Package api - docs session cookie.
//
// The SPA keeps its session token in localStorage and sends it as a bearer
// header, so a plain browser navigation to /api/docs carries no credentials.
// To let a signed-in operator open the interactive docs in a new tab, login
// also sets the session token as an HttpOnly cookie scoped to Path=/api/docs.
// Because of the path scope the cookie is never sent to any /api/v1 route, so
// cookie auth cannot be used for state-changing requests (no CSRF surface);
// Swagger's "Try it out" still needs an explicit bearer token / API key.
package api
import (
"net/http"
"time"
)
// DocsCookieName is the cookie extractToken reads when no header token is present.
const DocsCookieName = "session"
// DocsCookiePath scopes the cookie to the docs pages only.
const DocsCookiePath = "/api/docs"
// SetDocsSessionCookie stores the session token for the docs pages.
func SetDocsSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: DocsCookieName,
Value: token,
Path: DocsCookiePath,
Expires: expires,
HttpOnly: true,
Secure: requestIsSecure(r),
SameSite: http.SameSiteLaxMode,
})
}
// ClearDocsSessionCookie removes the docs cookie (logout).
func ClearDocsSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: DocsCookieName,
Value: "",
Path: DocsCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: requestIsSecure(r),
SameSite: http.SameSiteLaxMode,
})
}
func requestIsSecure(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}