Files
OwnCord/Server/auth/helpers_test.go
T
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth)

Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.

- migration 018 + dedicated api_tokens table (kept separate from sessions so
  bulk logout and the per-user session cap never touch these); only the
  SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
  and adminAuthMiddleware now call. Sessions are matched first so existing
  login behavior is unchanged; API tokens are a fallback only on session miss.
  A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
  HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
  api middleware integration (valid + revoked token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tools): add owncord-introspect MCP server

A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.

- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
  exposes api_request (full read-write passthrough), server_logs (admin SSE
  ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
  cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
  tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dependencies): update and add various crate versions in Cargo.lock

* feat(admin): manage API tokens from the admin panel

Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.

- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
  admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
  backups/updates): an HTTP token-mint endpoint is a network-reachable
  credential-minting surface, and API tokens deliberately survive password
  change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
  from request context (audits who clicked, not the bound user); the raw
  token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
  revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: modernize to Go 1.26 idioms + enable modernize linter

Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).

Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.

- plugin/manifest.go parent-traversal check applied by hand: modernize
  skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.

No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:46 +02:00

427 lines
14 KiB
Go

package auth_test
import (
"net/http"
"testing"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// ─── ExtractBearerToken ───────────────────────────────────────────────────────
func TestExtractBearerToken_ValidHeader(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer mytoken123")
token, ok := auth.ExtractBearerToken(r)
if !ok {
t.Fatal("ExtractBearerToken() ok = false, want true")
}
if token != "mytoken123" {
t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken123")
}
}
func TestExtractBearerToken_MissingHeader(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
token, ok := auth.ExtractBearerToken(r)
if ok {
t.Error("ExtractBearerToken() ok = true with no Authorization header, want false")
}
if token != "" {
t.Errorf("ExtractBearerToken() token = %q, want empty string", token)
}
}
func TestExtractBearerToken_EmptyHeaderValue(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "")
_, ok := auth.ExtractBearerToken(r)
if ok {
t.Error("ExtractBearerToken() ok = true for empty header value, want false")
}
}
func TestExtractBearerToken_WrongScheme(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
_, ok := auth.ExtractBearerToken(r)
if ok {
t.Error("ExtractBearerToken() ok = true for Basic scheme, want false")
}
}
func TestExtractBearerToken_BearerCaseInsensitive(t *testing.T) {
cases := []string{
"BEARER mytoken",
"bearer mytoken",
"Bearer mytoken",
"bEaReR mytoken",
}
for _, authHeader := range cases {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", authHeader)
token, ok := auth.ExtractBearerToken(r)
if !ok {
t.Errorf("ExtractBearerToken() ok = false for header %q, want true", authHeader)
}
if token != "mytoken" {
t.Errorf("ExtractBearerToken() token = %q for header %q, want %q", token, authHeader, "mytoken")
}
}
}
func TestExtractBearerToken_BearerWithNoToken(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer ")
_, ok := auth.ExtractBearerToken(r)
if ok {
t.Error("ExtractBearerToken() ok = true for 'Bearer ' with empty token, want false")
}
}
func TestExtractBearerToken_OnlySchemeNoSpace(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer")
_, ok := auth.ExtractBearerToken(r)
if ok {
t.Error("ExtractBearerToken() ok = true for 'Bearer' with no space or token, want false")
}
}
func TestExtractBearerToken_TokenPreservesValue(t *testing.T) {
// Tokens can contain mixed-case, digits, hyphens, underscores, dots.
rawToken := "aB3-xY9_zZ0.qQ7"
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+rawToken)
token, ok := auth.ExtractBearerToken(r)
if !ok {
t.Fatal("ExtractBearerToken() ok = false, want true")
}
if token != rawToken {
t.Errorf("ExtractBearerToken() token = %q, want %q", token, rawToken)
}
}
func TestExtractBearerToken_MultipleSpaces(t *testing.T) {
// SplitN with n=2 means "Bearer tok" splits into ["Bearer", " tok"].
// The implementation trims whitespace, so " mytoken" becomes "mytoken".
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer mytoken")
token, ok := auth.ExtractBearerToken(r)
// The implementation applies TrimSpace to the extracted token,
// so the leading space from the double-space header is stripped.
if !ok {
t.Fatal("ExtractBearerToken() ok = false for double-space header, want true")
}
if token != "mytoken" {
t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken")
}
}
// ─── IsSessionExpired ─────────────────────────────────────────────────────────
func TestIsSessionExpired_FutureTimeNotExpired(t *testing.T) {
future := time.Now().UTC().Add(time.Hour)
expiresAt := future.Format("2006-01-02 15:04:05")
if auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = true for future time, want false", expiresAt)
}
}
func TestIsSessionExpired_PastTimeExpired(t *testing.T) {
past := time.Now().UTC().Add(-time.Hour)
expiresAt := past.Format("2006-01-02 15:04:05")
if !auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = false for past time, want true", expiresAt)
}
}
func TestIsSessionExpired_FutureTimeSQLiteFormat(t *testing.T) {
future := time.Now().UTC().Add(24 * time.Hour)
expiresAt := future.Format("2006-01-02 15:04:05")
if auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = true for future SQLite-format time, want false", expiresAt)
}
}
func TestIsSessionExpired_PastTimeSQLiteFormat(t *testing.T) {
past := time.Now().UTC().Add(-24 * time.Hour)
expiresAt := past.Format("2006-01-02 15:04:05")
if !auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = false for past SQLite-format time, want true", expiresAt)
}
}
func TestIsSessionExpired_FutureTimeISO8601Format(t *testing.T) {
future := time.Now().UTC().Add(time.Hour)
expiresAt := future.Format("2006-01-02T15:04:05Z")
if auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = true for future ISO-8601 time, want false", expiresAt)
}
}
func TestIsSessionExpired_PastTimeISO8601Format(t *testing.T) {
past := time.Now().UTC().Add(-time.Hour)
expiresAt := past.Format("2006-01-02T15:04:05Z")
if !auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = false for past ISO-8601 time, want true", expiresAt)
}
}
func TestIsSessionExpired_EmptyString(t *testing.T) {
// Unparseable — must treat as expired for safety.
if !auth.IsSessionExpired("") {
t.Error("IsSessionExpired(\"\") = false for empty string, want true (fail-safe)")
}
}
func TestIsSessionExpired_InvalidFormat(t *testing.T) {
cases := []string{
"not-a-date",
"2025/03/15 12:00:00",
"15-03-2025",
"2025-13-45T99:99:99Z", // out-of-range values
}
for _, s := range cases {
if !auth.IsSessionExpired(s) {
t.Errorf("IsSessionExpired(%q) = false for invalid format, want true (fail-safe)", s)
}
}
}
func TestIsSessionExpired_ExactlyNow(t *testing.T) {
// A timestamp one second in the past must always be expired.
justPast := time.Now().UTC().Add(-time.Second)
expiresAt := justPast.Format("2006-01-02 15:04:05")
if !auth.IsSessionExpired(expiresAt) {
t.Errorf("IsSessionExpired(%q) = false for just-past time, want true", expiresAt)
}
}
// ─── IsEffectivelyBanned ──────────────────────────────────────────────────────
func TestIsEffectivelyBanned_NotBanned(t *testing.T) {
u := &db.User{Banned: false}
if auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=false) = true, want false")
}
}
func TestIsEffectivelyBanned_BannedNilExpiry(t *testing.T) {
// Banned with no expiry — permanently banned.
u := &db.User{Banned: true, BanExpires: nil}
if !auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=true, BanExpires=nil) = false, want true")
}
}
func TestIsEffectivelyBanned_BannedFutureExpiry(t *testing.T) {
// Banned with an expiry in the future — still banned.
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05")
u := &db.User{Banned: true, BanExpires: new(future)}
if !auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=true, future expiry) = false, want true")
}
}
func TestIsEffectivelyBanned_BannedPastExpiry(t *testing.T) {
// Banned but the ban expired in the past — should be treated as NOT banned.
past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05")
u := &db.User{Banned: true, BanExpires: new(past)}
if auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=true, past expiry) = true, want false")
}
}
func TestIsEffectivelyBanned_BannedExpiredISO8601(t *testing.T) {
// ISO-8601 format for BanExpires past — should be treated as NOT banned.
past := time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05Z")
u := &db.User{Banned: true, BanExpires: new(past)}
if auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 past expiry) = true, want false")
}
}
func TestIsEffectivelyBanned_BannedFutureISO8601(t *testing.T) {
// ISO-8601 format for BanExpires in future — still banned.
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z")
u := &db.User{Banned: true, BanExpires: new(future)}
if !auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 future expiry) = false, want true")
}
}
func TestIsEffectivelyBanned_BannedUnparsableExpiry(t *testing.T) {
// Unparseable expiry string — fail-safe: treat as still banned.
u := &db.User{Banned: true, BanExpires: new("not-a-date")}
if !auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=true, unparseable expiry) = false, want true (fail-safe)")
}
}
func TestIsEffectivelyBanned_NotBannedIgnoresExpiry(t *testing.T) {
// Banned=false even with a future expiry field — should be false.
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05")
u := &db.User{Banned: false, BanExpires: new(future)}
if auth.IsEffectivelyBanned(u) {
t.Error("IsEffectivelyBanned(Banned=false, future expiry) = true, want false")
}
}
func TestIsEffectivelyBanned_NilUser(t *testing.T) {
// A nil user pointer must not panic and must return false.
defer func() {
if r := recover(); r != nil {
t.Errorf("IsEffectivelyBanned(nil) panicked: %v", r)
}
}()
if auth.IsEffectivelyBanned(nil) {
t.Error("IsEffectivelyBanned(nil) = true, want false")
}
}
// ─── ValidateUsername ────────────────────────────────────────────────────────
func TestValidateUsername_ValidNames(t *testing.T) {
cases := []string{
"ab", // minimum length (2 runes)
"alice", // normal ASCII
"user_name", // with underscore
"日本語ユーザー", // CJK (multi-byte runes)
"abcdefghijklmnopqrstuvwxyz123456", // exactly 32 chars
}
for _, name := range cases {
if err := auth.ValidateUsername(name); err != nil {
t.Errorf("ValidateUsername(%q) = %v, want nil", name, err)
}
}
}
// TestValidateUsername_ReservedDeletedNamespace locks the anonymisation
// namespace shut. db.DeleteAccount renames a deleted account to
// "[deleted-<id>]" in a UNIQUE COLLATE NOCASE column, so while the name was
// registrable a member could take a chosen victim's and make every subsequent
// account-deletion attempt fail on the unique index.
func TestValidateUsername_ReservedDeletedNamespace(t *testing.T) {
reserved := []string{
"[deleted-42]", // the exact anonymised form
"[DELETED-42]", // UNIQUE is COLLATE NOCASE, so case must not help
"[Deleted-1]", // mixed case
"[deleted-42-a3f19c]", // the collision-fallback form
"[deleted-]", // the namespace, not just the numeric form
}
for _, name := range reserved {
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for reserved namespace", name)
}
}
// Names that merely resemble it stay available.
allowed := []string{
"[deleted", // no closing bracket
"deleted-42]", // no opening bracket
"[not-deleted-1]", // different namespace
"[cool]", // ordinary bracketed name
}
for _, name := range allowed {
if err := auth.ValidateUsername(name); err != nil {
t.Errorf("ValidateUsername(%q) = %v, want nil", name, err)
}
}
}
func TestValidateUsername_TooShort(t *testing.T) {
cases := []string{
"", // empty
"a", // single char
}
for _, name := range cases {
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for too short", name)
}
}
}
func TestValidateUsername_TooLong(t *testing.T) {
// 33 runes exceeds the 32-rune limit.
long := "abcdefghijklmnopqrstuvwxyz1234567"
if err := auth.ValidateUsername(long); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for too long", long)
}
}
func TestValidateUsername_ControlCharactersRejected(t *testing.T) {
cases := []string{
"user\x00name", // null byte
"user\nname", // newline
"user\tname", // tab
"abc\x07def", // bell
}
for _, name := range cases {
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for control char", name)
}
}
}
func TestValidateUsername_InvisibleCharactersRejected(t *testing.T) {
// Zero-width joiner (U+200D) is in unicode.Cf category.
name := "user\u200Dname"
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for invisible character", name)
}
// Zero-width space (U+200B).
name2 := "user\u200Bname"
if err := auth.ValidateUsername(name2); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for zero-width space", name2)
}
}
func TestValidateUsername_WhitespaceTrimmed(t *testing.T) {
// Leading/trailing whitespace is trimmed, so " a " becomes "a" (1 rune = too short).
if err := auth.ValidateUsername(" a "); err == nil {
t.Error("ValidateUsername(\" a \") = nil, want error (trimmed to 1 rune)")
}
// After trimming, " ab " becomes "ab" (2 runes = valid).
if err := auth.ValidateUsername(" ab "); err != nil {
t.Errorf("ValidateUsername(\" ab \") = %v, want nil (trimmed to 2 runes)", err)
}
}
func TestValidateUsername_UnicodeLength(t *testing.T) {
// Each emoji is 1 rune but multiple bytes. 2 emoji should be valid (min length).
twoEmoji := "😀😀"
if err := auth.ValidateUsername(twoEmoji); err != nil {
t.Errorf("ValidateUsername(%q) = %v, want nil for 2-rune emoji name", twoEmoji, err)
}
}