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)
}