feat(auth): support API tokens and bearer token authentication

- Add backend services to create, list, and delete API tokens.
- Implement Bearer token authentication to resolve tokens to users.
- Register HTTP routes for managing user tokens under `/account/tokens`.
- Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
This commit is contained in:
2026-05-31 12:50:13 +03:00
parent 0503fad9af
commit d99f8ee82a
9 changed files with 533 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ package services
import (
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -103,6 +104,107 @@ func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
}
}
func TestAPITokenLifecycle(t *testing.T) {
auth := newTestAuthService(t)
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
result, err := auth.CreateAPIToken(user.ID, "CLI laptop")
if err != nil {
t.Fatalf("CreateAPIToken returned error: %v", err)
}
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
}
// The secret must never be stored in plaintext — only its hash.
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
t.Fatalf("stored token hash leaks the plaintext secret")
}
resolved, err := auth.UserForAPIToken(result.Plaintext)
if err != nil {
t.Fatalf("UserForAPIToken returned error: %v", err)
}
if resolved.ID != user.ID {
t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID)
}
tokens, err := auth.ListAPITokens(user.ID)
if err != nil {
t.Fatalf("ListAPITokens returned error: %v", err)
}
if len(tokens) != 1 {
t.Fatalf("token count = %d, want 1", len(tokens))
}
if tokens[0].Name != "CLI laptop" {
t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop")
}
if tokens[0].LastUsedAt == nil {
t.Fatalf("LastUsedAt not recorded after UserForAPIToken")
}
if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil {
t.Fatalf("UserForAPIToken accepted a tampered token")
}
if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil {
t.Fatalf("UserForAPIToken accepted an unknown token")
}
if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil {
t.Fatalf("DeleteAPIToken returned error: %v", err)
}
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
t.Fatalf("deleted token still resolved")
}
remaining, err := auth.ListAPITokens(user.ID)
if err != nil {
t.Fatalf("ListAPITokens returned error: %v", err)
}
if len(remaining) != 0 {
t.Fatalf("token count after delete = %d, want 0", len(remaining))
}
}
func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) {
auth := newTestAuthService(t)
owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
other, err := auth.AcceptInvite(invite.Token, "other", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
result, err := auth.CreateAPIToken(owner.ID, "owner token")
if err != nil {
t.Fatalf("CreateAPIToken returned error: %v", err)
}
tokens, err := auth.ListAPITokens(owner.ID)
if err != nil {
t.Fatalf("ListAPITokens returned error: %v", err)
}
// Another user cannot delete tokens they do not own.
if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil {
t.Fatalf("DeleteAPIToken allowed deletion across users")
}
// A disabled owner cannot authenticate with their token.
if err := auth.DisableUser(owner.ID, true); err != nil {
t.Fatalf("DisableUser returned error: %v", err)
}
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
t.Fatalf("disabled user token still resolved")
}
}
func newTestAuthService(t *testing.T) *AuthService {
t.Helper()
root := t.TempDir()