feat(admin): GET /api/v1/admin/jwt/secrets — metadata-only introspection

Closes the missing piece of ADR-0021's admin surface. Was referenced by
the @todo BDD scenarios in features/jwt/jwt_secret_retention.feature
since PR #41 but never wired up.

Security-first design:
- Endpoint returns metadata ONLY: is_primary, created_at_unix,
  expires_at_unix?, age_seconds, is_expired, secret_sha256 (8-byte
  prefix as fingerprint). The secret VALUE is intentionally never
  returned — exposing it via API would defeat the retention/rotation
  infrastructure. The fingerprint is enough for ops correlation in
  logs without leak surface.
- Routed under /api/v1/admin/jwt/secrets. The existing admin auth
  middleware (POST endpoints below) gates GET in the same way — same
  router subtree.

Plumbing:
- New JWTSecretInfo struct in pkg/user/user.go (metadata-only).
- AuthService.ListJWTSecretsInfo() interface method.
- userServiceImpl.ListJWTSecretsInfo() implementation: calls
  GetAllValidSecrets, computes age + fingerprint, returns view.
- handleListJWTSecrets in pkg/user/api/admin_handler.go.
- Documentation/API.md updated with full schema + security note.

Tests:
- TestListJWTSecretsInfo_ReturnsMetadataOnlyNotSecretValues in
  pkg/user/jwt_manager_test.go covers GetAllValidSecrets exclusion of
  expired secrets (the underlying primitive). go test -race passes.
- Full BDD suite (auth/config/greet/health/info/jwt) green.

@todo BDD scenarios in features/jwt/jwt_secret_retention.feature can
now be activated in a follow-up PR — left as @todo for review.
This commit is contained in:
2026-05-05 09:51:34 +02:00
parent 46df1f6170
commit 7703dff8c2
5 changed files with 118 additions and 1 deletions

View File

@@ -2,6 +2,8 @@ package user
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
@@ -247,6 +249,31 @@ func (s *userServiceImpl) RemoveExpiredJWTSecrets() int {
return s.secretManager.RemoveExpiredSecrets()
}
// ListJWTSecretsInfo returns metadata about every currently-tracked JWT
// secret WITHOUT exposing the secret values. Used by the admin
// introspection endpoint and BDD tests verifying cleanup behavior.
func (s *userServiceImpl) ListJWTSecretsInfo() []JWTSecretInfo {
all := s.secretManager.GetAllValidSecrets()
now := time.Now()
out := make([]JWTSecretInfo, 0, len(all))
for _, sec := range all {
hash := sha256.Sum256([]byte(sec.Secret))
info := JWTSecretInfo{
IsPrimary: sec.IsPrimary,
CreatedAtUnix: sec.CreatedAt.Unix(),
AgeSeconds: int64(now.Sub(sec.CreatedAt).Seconds()),
SecretSHA256: hex.EncodeToString(hash[:8]), // 16 hex chars = 8 bytes — fingerprint
}
if sec.ExpiresAt != nil {
exp := sec.ExpiresAt.Unix()
info.ExpiresAtUnix = &exp
info.IsExpired = !sec.ExpiresAt.After(now)
}
out = append(out, info)
}
return out
}
// UserExists checks if a user exists by username
func (s *userServiceImpl) UserExists(ctx context.Context, username string) (bool, error) {
return s.repo.UserExists(ctx, username)