internal/server/store/apitokens.go
Ref: Size: 4.4 KiB History
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/a73x/eitri/internal/random"
)
// APIToken is the metadata of a personal access token — never the secret, which
// exists in cleartext only at mint time. ExpiresAt/LastUsedAt/RevokedAt are nil
// when unset (non-expiring / never used / not revoked).
type APIToken struct {
ID, Name string
CreatedAt time.Time
ExpiresAt *time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
// hashToken returns the hex SHA-256 of a PAT secret. The stored hash is the
// constant-time defense: a lookup keyed on the hash of the presented secret
// never compares the secret itself, so there is no timing side-channel to
// exploit and a database leak yields no usable tokens.
func hashToken(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// CreateAPIToken mints a tenant-scoped PAT. The secret is returned once (stored
// only as its SHA-256) and prefixed eitri_pat_ so it is greppable in leaks. A
// ttl of 0 mints a non-expiring token (NULL expires_at).
func (s *Store) CreateAPIToken(tenant, name string, ttl time.Duration) (secret, id string, err error) {
secret = "eitri_pat_" + random.Hex(32)
id = random.Hex(16)
now := time.Now().UTC()
var expiresAt any // NULL when ttl == 0
if ttl != 0 {
expiresAt = now.Add(ttl).Format(time.RFC3339)
}
_, err = s.db.Exec(
`INSERT INTO api_tokens(id, tenant, name, token_hash, created_at, expires_at) VALUES (?,?,?,?,?,?)`,
id, tenant, name, hashToken(secret), now.Format(time.RFC3339), expiresAt,
)
if err != nil {
return "", "", fmt.Errorf("create api token: %w", err)
}
return secret, id, nil
}
// TenantForAPIToken resolves a presented PAT secret to its tenant, enforcing
// revocation and expiry. ok=false (no error) for an unknown, revoked, or
// expired token. On success it stamps last_used_at. The lookup is keyed on the
// hash of the secret (constant-time defense — see hashToken).
func (s *Store) TenantForAPIToken(secret string) (string, bool, error) {
hash := hashToken(secret)
now := time.Now().UTC().Format(time.RFC3339)
var tenant string
err := s.db.QueryRow(
`SELECT tenant FROM api_tokens
WHERE token_hash=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
hash, now,
).Scan(&tenant)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("tenant for api token: %w", err)
}
if _, err := s.db.Exec(`UPDATE api_tokens SET last_used_at=? WHERE token_hash=?`, now, hash); err != nil {
return "", false, fmt.Errorf("touch api token: %w", err)
}
return tenant, true, nil
}
// ListAPITokens returns tenant's tokens (metadata only — never the secret or
// hash), newest first.
func (s *Store) ListAPITokens(tenant string) ([]APIToken, error) {
rows, err := s.db.Query(
`SELECT id, name, created_at, expires_at, last_used_at, revoked_at
FROM api_tokens WHERE tenant=? ORDER BY created_at DESC, id`, tenant)
if err != nil {
return nil, fmt.Errorf("list api tokens: %w", err)
}
defer rows.Close()
var out []APIToken
for rows.Next() {
var tok APIToken
var created string
var expires, lastUsed, revoked sql.NullString
if err := rows.Scan(&tok.ID, &tok.Name, &created, &expires, &lastUsed, &revoked); err != nil {
return nil, err
}
tok.CreatedAt, _ = time.Parse(time.RFC3339, created)
tok.ExpiresAt = parseNullTime(expires)
tok.LastUsedAt = parseNullTime(lastUsed)
tok.RevokedAt = parseNullTime(revoked)
out = append(out, tok)
}
return out, rows.Err()
}
// RevokeAPIToken marks a token revoked. It is scoped to tenant: another
// tenant's token id is not found (sql.ErrNoRows), so revoke cannot reach across
// the partition. Re-revoking keeps the original revoked_at.
func (s *Store) RevokeAPIToken(tenant, id string) error {
res, err := s.db.Exec(
`UPDATE api_tokens SET revoked_at=? WHERE id=? AND tenant=? AND revoked_at IS NULL`,
time.Now().UTC().Format(time.RFC3339), id, tenant)
if err != nil {
return fmt.Errorf("revoke api token: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
// Unknown id, wrong tenant, or already revoked — all not-found to the caller.
return sql.ErrNoRows
}
return nil
}
// parseNullTime maps a nullable RFC3339 column to *time.Time (nil when NULL).
func parseNullTime(ns sql.NullString) *time.Time {
if !ns.Valid {
return nil
}
t, _ := time.Parse(time.RFC3339, ns.String)
return &t
}