a73x

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
}