Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/api/license_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type generateResponse struct {
Slug string `json:"slug"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata"`
Attributes map[string]any `json:"attributes"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
Expand Down Expand Up @@ -158,6 +159,7 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) {
"slug": generated.Slug,
"status": generated.Status,
"metadata": generated.Metadata,
"attributes": generated.Attributes,
"expires_at": generated.ExpiresAt,
"created_at": generated.CreatedAt,
})
Expand All @@ -182,6 +184,7 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) {
Slug: generated.Slug,
Status: generated.Status,
Metadata: generated.Metadata,
Attributes: generated.Attributes,
ExpiresAt: generated.ExpiresAt,
CreatedAt: generated.CreatedAt,
}
Expand All @@ -197,6 +200,7 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) {
"slug": generated.Slug,
"status": generated.Status,
"metadata": generated.Metadata,
"attributes": generated.Attributes,
"expires_at": generated.ExpiresAt,
"created_at": generated.CreatedAt,
})
Expand Down Expand Up @@ -299,7 +303,7 @@ func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) {
token := ""
if result.Valid && result.OfflineEnabled {
var err error
token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, result.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds)
token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, result.Fingerprint, result.Attributes, result.ExpiresAt, result.OfflineTokenLifetimeSeconds)
if err != nil {
s.writeUnexpectedError(w, "failed to issue offline token", err)
return
Expand Down Expand Up @@ -368,7 +372,7 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
token := ""
if result.Valid && result.OfflineEnabled {
var err error
token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, req.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds)
token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, req.Fingerprint, result.Attributes, result.ExpiresAt, result.OfflineTokenLifetimeSeconds)
if err != nil {
s.writeUnexpectedError(w, "failed to refresh offline token", err)
return
Expand Down
58 changes: 30 additions & 28 deletions internal/api/management_licenses.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@ import (
)

type licenseManagementResponse struct {
ID string `json:"id"`
LicenseKey string `json:"license_key"`
Slug string `json:"slug"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata"`
MaxActivations int `json:"max_activations"`
ActiveSeats int `json:"active_seats"`
OfflineEnabled bool `json:"offline_enabled"`
OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
ActivatedAt *time.Time `json:"activated_at"`
LastValidatedAt *time.Time `json:"last_validated_at"`
RevokedAt *time.Time `json:"revoked_at"`
ID string `json:"id"`
LicenseKey string `json:"license_key"`
Slug string `json:"slug"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata"`
Attributes map[string]any `json:"attributes"`
MaxActivations int `json:"max_activations"`
ActiveSeats int `json:"active_seats"`
OfflineEnabled bool `json:"offline_enabled"`
OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
ActivatedAt *time.Time `json:"activated_at"`
LastValidatedAt *time.Time `json:"last_validated_at"`
RevokedAt *time.Time `json:"revoked_at"`
}

type licensePaginationResponse struct {
Expand Down Expand Up @@ -109,20 +110,21 @@ func (s *Server) handleListLicenses(w http.ResponseWriter, r *http.Request) {

func mapLicenseManagementResponse(record storage.LicenseRow) licenseManagementResponse {
return licenseManagementResponse{
ID: record.ID,
LicenseKey: record.Key,
Slug: record.SlugName,
Status: displayLicenseStatus(record),
Metadata: record.Metadata,
MaxActivations: record.MaxActivations,
ActiveSeats: record.ActiveSeats,
OfflineEnabled: record.OfflineEnabled,
OfflineTokenLifetimeHours: offlineTokenLifetimeHoursFromSeconds(record.OfflineTokenLifetimeSeconds),
ExpiresAt: record.ExpiresAt,
CreatedAt: record.CreatedAt,
ActivatedAt: record.ActivatedAt,
LastValidatedAt: record.LastValidatedAt,
RevokedAt: record.RevokedAt,
ID: record.ID,
LicenseKey: record.Key,
Slug: record.SlugName,
Status: displayLicenseStatus(record),
Metadata: record.Metadata,
Attributes: record.Attributes,
MaxActivations: record.MaxActivations,
ActiveSeats: record.ActiveSeats,
OfflineEnabled: record.OfflineEnabled,
OfflineTokenLifetimeHours: offlineTokenLifetimeHoursFromSeconds(record.OfflineTokenLifetimeSeconds),
ExpiresAt: record.ExpiresAt,
CreatedAt: record.CreatedAt,
ActivatedAt: record.ActivatedAt,
LastValidatedAt: record.LastValidatedAt,
RevokedAt: record.RevokedAt,
}
}

Expand Down
70 changes: 43 additions & 27 deletions internal/api/management_slugs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,42 +13,45 @@ import (
)

type slugResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
MaxActivations int `json:"max_activations"`
ExpirationType string `json:"expiration_type"`
ExpirationDays *int `json:"expiration_days"`
FixedExpiresAt *time.Time `json:"fixed_expires_at"`
OfflineEnabled bool `json:"offline_enabled"`
OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"`
IsDefault bool `json:"is_default"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
MaxActivations int `json:"max_activations"`
ExpirationType string `json:"expiration_type"`
ExpirationDays *int `json:"expiration_days"`
FixedExpiresAt *time.Time `json:"fixed_expires_at"`
OfflineEnabled bool `json:"offline_enabled"`
OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"`
Attributes map[string]any `json:"attributes"`
IsDefault bool `json:"is_default"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type listSlugsResponse struct {
Slugs []slugResponse `json:"slugs"`
}

type createSlugRequest struct {
Name string `json:"name"`
MaxActivations int `json:"max_activations"`
ExpirationType string `json:"expiration_type"`
ExpirationDays *int `json:"expiration_days"`
FixedExpiresAt *string `json:"fixed_expires_at"`
OfflineEnabled bool `json:"offline_enabled"`
OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"`
Name string `json:"name"`
MaxActivations int `json:"max_activations"`
ExpirationType string `json:"expiration_type"`
ExpirationDays *int `json:"expiration_days"`
FixedExpiresAt *string `json:"fixed_expires_at"`
OfflineEnabled bool `json:"offline_enabled"`
OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"`
Attributes map[string]any `json:"attributes"`
}

type updateSlugRequest struct {
Name *string `json:"name"`
MaxActivations *int `json:"max_activations"`
ExpirationType *string `json:"expiration_type"`
ExpirationDays *int `json:"expiration_days"`
FixedExpiresAt *string `json:"fixed_expires_at"`
OfflineEnabled *bool `json:"offline_enabled"`
OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"`
Name *string `json:"name"`
MaxActivations *int `json:"max_activations"`
ExpirationType *string `json:"expiration_type"`
ExpirationDays *int `json:"expiration_days"`
FixedExpiresAt *string `json:"fixed_expires_at"`
OfflineEnabled *bool `json:"offline_enabled"`
OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"`
Attributes map[string]any `json:"attributes"`
}

func (s *Server) handleListSlugs(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -125,6 +128,10 @@ func (s *Server) handleCreateSlug(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
}
if err := validateAttributes(req.Attributes); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
}

record, err := s.service.CreateSlug(r.Context(), storage.CreateSlugParams{
Name: req.Name,
Expand All @@ -134,6 +141,7 @@ func (s *Server) handleCreateSlug(w http.ResponseWriter, r *http.Request) {
FixedExpiresAt: policy.FixedExpiresAt(),
OfflineEnabled: req.OfflineEnabled,
OfflineTokenLifetimeSeconds: offlineTokenLifetimeHours * 3600,
Attributes: req.Attributes,
})
if err != nil {
if errors.Is(err, storage.ErrConflict) {
Expand Down Expand Up @@ -170,10 +178,16 @@ func (s *Server) handleUpdateSlug(w http.ResponseWriter, r *http.Request) {
return
}

if req.Name == nil && req.MaxActivations == nil && req.ExpirationType == nil && req.ExpirationDays == nil && req.FixedExpiresAt == nil && req.OfflineEnabled == nil && req.OfflineTokenLifetimeHours == nil {
if req.Name == nil && req.MaxActivations == nil && req.ExpirationType == nil && req.ExpirationDays == nil && req.FixedExpiresAt == nil && req.OfflineEnabled == nil && req.OfflineTokenLifetimeHours == nil && req.Attributes == nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "at least one field must be provided"})
return
}
if req.Attributes != nil {
if err := validateAttributes(req.Attributes); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
}
}

resolvedName := current.Name
if req.Name != nil {
Expand Down Expand Up @@ -245,6 +259,7 @@ func (s *Server) handleUpdateSlug(w http.ResponseWriter, r *http.Request) {
FixedExpiresAt: &fixedParam,
OfflineEnabled: req.OfflineEnabled,
OfflineTokenLifetimeSeconds: offlineTokenLifetimeSeconds,
Attributes: req.Attributes,
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
Expand Down Expand Up @@ -296,6 +311,7 @@ func mapSlugResponse(record storage.SlugRecord) slugResponse {
FixedExpiresAt: record.FixedExpiresAt,
OfflineEnabled: record.OfflineEnabled,
OfflineTokenLifetimeHours: offlineTokenLifetimeHoursFromSeconds(record.OfflineTokenLifetimeSeconds),
Attributes: record.Attributes,
IsDefault: record.IsDefault,
DeletedAt: record.DeletedAt,
CreatedAt: record.CreatedAt,
Expand Down
3 changes: 2 additions & 1 deletion internal/api/offline_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"simple-license-server/internal/storage"
)

func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fingerprint string, licenseExpiresAt *time.Time, lifetimeSeconds int) (string, error) {
func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fingerprint string, attributes map[string]any, licenseExpiresAt *time.Time, lifetimeSeconds int) (string, error) {
licenseID = strings.TrimSpace(licenseID)
slugName = strings.TrimSpace(slugName)
fingerprint = strings.TrimSpace(fingerprint)
Expand Down Expand Up @@ -51,6 +51,7 @@ func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fin
Subject: licenseID,
Slug: slugName,
Fingerprint: fingerprint,
Attributes: attributes,
ExpiresAt: expiresAt,
IssuedAt: now,
})
Expand Down
22 changes: 15 additions & 7 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,27 +664,35 @@ func validateSlugName(value string) error {
}

func validateMetadata(metadata map[string]any) error {
if metadata == nil {
return validateJSONMap(metadata, "metadata")
}

func validateAttributes(attributes map[string]any) error {
return validateJSONMap(attributes, "attributes")
}

func validateJSONMap(values map[string]any, label string) error {
if values == nil {
return nil
}

if len(metadata) > maxMetadataEntries {
return fmt.Errorf("metadata exceeds max entries of %d", maxMetadataEntries)
if len(values) > maxMetadataEntries {
return fmt.Errorf("%s exceeds max entries of %d", label, maxMetadataEntries)
}

nodes := 0
for key, value := range metadata {
for key, value := range values {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
return fmt.Errorf("metadata keys must be non-empty")
return fmt.Errorf("%s keys must be non-empty", label)
}

if len(trimmedKey) > maxSlugLength {
return fmt.Errorf("metadata key %q exceeds max length of %d", trimmedKey, maxSlugLength)
return fmt.Errorf("%s key %q exceeds max length of %d", label, trimmedKey, maxSlugLength)
}

if err := validateMetadataValue(value, 1, &nodes); err != nil {
return fmt.Errorf("metadata value for key %q invalid: %w", trimmedKey, err)
return fmt.Errorf("%s value for key %q invalid: %w", label, trimmedKey, err)
}
}

Expand Down
21 changes: 21 additions & 0 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
Expand Down Expand Up @@ -399,6 +400,7 @@ func TestValidateRefreshesOfflineTokenWhenSlugAllowsOffline(t *testing.T) {
Status: "active",
LicenseID: "8c50f4f7-761d-4bf1-8e09-27a5f7ea0a12",
Slug: "default",
Attributes: map[string]any{"features": []any{"analytics"}, "tier": "pro"},
OfflineEnabled: true,
OfflineTokenLifetimeSeconds: 86400,
}, nil
Expand Down Expand Up @@ -439,6 +441,25 @@ func TestValidateRefreshesOfflineTokenWhenSlugAllowsOffline(t *testing.T) {
if strings.Count(response.Token, ".") != 2 {
t.Fatalf("expected JWT-shaped token, got %q", response.Token)
}

parts := strings.Split(response.Token, ".")
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("decode jwt claims: %v", err)
}

var claims map[string]any
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
t.Fatalf("unmarshal jwt claims: %v", err)
}

attributes, ok := claims["attributes"].(map[string]any)
if !ok {
t.Fatalf("expected attributes object claim, got %T", claims["attributes"])
}
if attributes["tier"] != "pro" {
t.Fatalf("expected tier=pro in attributes, got %v", attributes)
}
}

func TestManagementEndpointRequiresManagementKey(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions internal/offlinejwt/offlinejwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type TokenParams struct {
Subject string
Slug string
Fingerprint string
Attributes map[string]any
ExpiresAt time.Time
IssuedAt time.Time
}
Expand Down Expand Up @@ -148,6 +149,7 @@ func SignEd25519JWT(encryptedPrivateKey, encryptionSecret, kid string, params To
"sub": strings.TrimSpace(params.Subject),
"slug": strings.TrimSpace(params.Slug),
"fingerprint": strings.TrimSpace(params.Fingerprint),
"attributes": copyMap(params.Attributes),
"iat": now.Unix(),
"nbf": now.Unix(),
"exp": expiresAt.Unix(),
Expand All @@ -171,6 +173,19 @@ func SignEd25519JWT(encryptedPrivateKey, encryptionSecret, kid string, params To
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}

func copyMap(values map[string]any) map[string]any {
if values == nil {
return map[string]any{}
}

out := make(map[string]any, len(values))
for k, v := range values {
out[k] = v
}

return out
}

func encryptionAEAD(secret string) (cipher.AEAD, error) {
trimmed := strings.TrimSpace(secret)
if len(trimmed) < 32 {
Expand Down
Loading
Loading