a73x

internal/server/api/tokens.go

Ref:   Size: 4.4 KiB   History

package api

import (
	"database/sql"
	"errors"
	"net/http"
	"time"

	"github.com/a73x/eitri/internal/server/api/types"
)

// handleMe returns the signed-in identity: the caller's tenant handle, the
// email bound to the tenant row, and the plane's jump-gate address (empty when
// it runs no gate). It works identically for a PAT- or session-authenticated
// caller — both resolve to a tenant, and the email comes off that tenant's row.
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
	tenant := principalFromContext(r).Tenant
	tn, ok, err := a.st.TenantByID(tenant)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if !ok {
		// The credential resolved to a tenant that no longer has a row — treat
		// it as an invalid session rather than inventing an identity.
		http.Error(w, "sign in required", http.StatusUnauthorized)
		return
	}
	writeJSON(w, http.StatusOK, types.Me{Email: tn.Email, SSHGate: a.sshGate, Tenant: tn.ID})
}

// handleCreateAPIToken mints a tenant-scoped personal access token and returns
// the secret exactly once. The store owns the secret's generation and hashing
// (never echoes it again). ExpiresAt in the response is computed here from the
// requested TTL, so it can drift from the store's stored expires_at by up to a
// second (the two capture time.Now microseconds apart, both truncated to
// RFC3339 seconds); GET /api/v1/tokens is the source of truth for the exact
// value. Empty ExpiresAt means non-expiring.
func (a *API) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
	var req types.CreateAPITokenRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if req.Name == "" {
		http.Error(w, "name is required", http.StatusBadRequest)
		return
	}
	// Upper clamp keeps time.Duration(ttl)*time.Second from overflowing
	// int64 nanoseconds; a century is "non-expiring" for any honest caller.
	const maxTTLSeconds = 100 * 365 * 24 * 60 * 60
	if req.TTLSeconds < 0 || req.TTLSeconds > maxTTLSeconds {
		http.Error(w, "ttl_seconds must be between 0 and 3153600000", http.StatusBadRequest)
		return
	}
	tenant := principalFromContext(r).Tenant
	ttl := time.Duration(req.TTLSeconds) * time.Second
	secret, id, err := a.st.CreateAPIToken(tenant, req.Name, ttl)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	expiresAt := ""
	if ttl > 0 {
		expiresAt = time.Now().UTC().Add(ttl).Format(time.RFC3339)
	}
	a.audit(tenant, "api-token.mint", map[string]string{"token_id": id, "name": req.Name})
	writeJSON(w, http.StatusCreated, types.CreateAPITokenResponse{
		ExpiresAt: expiresAt,
		ID:        id,
		Name:      req.Name,
		Token:     secret,
	})
}

// handleListAPITokens lists the tenant's PAT metadata (never a secret), newest
// first. Nil timestamp columns (non-expiring / never used / not revoked) map to
// empty strings.
func (a *API) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
	tenant := principalFromContext(r).Tenant
	toks, err := a.st.ListAPITokens(tenant)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	out := make([]types.APIToken, 0, len(toks))
	for _, tok := range toks {
		out = append(out, types.APIToken{
			CreatedAt:  tok.CreatedAt.Format(time.RFC3339),
			ExpiresAt:  formatTimePtr(tok.ExpiresAt),
			ID:         tok.ID,
			LastUsedAt: formatTimePtr(tok.LastUsedAt),
			Name:       tok.Name,
			RevokedAt:  formatTimePtr(tok.RevokedAt),
		})
	}
	writeJSON(w, http.StatusOK, out)
}

// handleRevokeAPIToken revokes a PAT by id, scoped to the caller's tenant. An
// unknown id, a foreign tenant's id, and an already-revoked id all answer 404:
// revoked-vs-unknown indistinguishability is deliberate, so revoking cannot be
// used to probe which token ids exist across the partition.
func (a *API) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
	tenant := principalFromContext(r).Tenant
	id := r.PathValue("id")
	if err := a.st.RevokeAPIToken(tenant, id); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	a.audit(tenant, "api-token.revoke", map[string]string{"token_id": id})
	w.WriteHeader(http.StatusNoContent)
}

// formatTimePtr renders a nullable store timestamp as RFC3339, or "" when nil.
func formatTimePtr(t *time.Time) string {
	if t == nil {
		return ""
	}
	return t.Format(time.RFC3339)
}