init
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AI Usage Disclosure contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Universal License Platform — client snapshot
|
||||
|
||||
This directory contains only the runtime verification subset required by the
|
||||
AI Disclosure Standard. It is compatible with Universal License Platform
|
||||
v1.0.0 and intentionally excludes key generation, private-key decoding,
|
||||
license issuance, administrative APIs, storage and server code.
|
||||
|
||||
Source protocol: `github.com/b1tsblog/license-platform`
|
||||
|
||||
The product imports:
|
||||
|
||||
- `pkg/licensekit`: public-key trust-store parsing and token verification only;
|
||||
- `sdk/go/licenseclient`: offline, hybrid and online runtime checks.
|
||||
|
||||
The standalone Universal License Platform remains the sole authority for key
|
||||
management, license issuance, revocation and lease signing.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
module github.com/b1tsblog/license-platform
|
||||
|
||||
go 1.23
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package licensekit implements the verification-only token protocol consumed
|
||||
// by Universal License Platform client applications.
|
||||
package licensekit
|
||||
@@ -0,0 +1,408 @@
|
||||
// Package licensekit contains the verification-only protocol types and
|
||||
// cryptographic checks used by Universal License Platform clients.
|
||||
//
|
||||
// This client snapshot intentionally contains no private-key handling, token
|
||||
// signing, key generation, license issuance or administrative functionality.
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenTypeLicense = "LICENSE"
|
||||
TokenTypeLease = "LEASE"
|
||||
AlgorithmEdDSA = "EdDSA"
|
||||
SchemaVersion = 1
|
||||
)
|
||||
|
||||
type VerificationMode string
|
||||
|
||||
const (
|
||||
ModeOffline VerificationMode = "offline"
|
||||
ModeHybrid VerificationMode = "hybrid"
|
||||
ModeOnline VerificationMode = "online"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version int `json:"v"`
|
||||
}
|
||||
|
||||
type VerificationPolicy struct {
|
||||
Mode VerificationMode `json:"mode"`
|
||||
LeaseTTLSeconds int64 `json:"leaseTtlSeconds,omitempty"`
|
||||
OfflineGraceSeconds int64 `json:"offlineGraceSeconds,omitempty"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version int `json:"version"`
|
||||
LicenseID string `json:"licenseId"`
|
||||
Issuer string `json:"issuer"`
|
||||
Customer string `json:"customer"`
|
||||
Product string `json:"product"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
InstanceIDs []string `json:"instanceIds,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
NotBefore int64 `json:"notBefore,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Verification VerificationPolicy `json:"verification"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type LeaseClaims struct {
|
||||
Version int `json:"version"`
|
||||
LeaseID string `json:"leaseId"`
|
||||
LicenseID string `json:"licenseId"`
|
||||
Product string `json:"product"`
|
||||
Customer string `json:"customer"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type TrustStore struct {
|
||||
LicenseKeys map[string]string `json:"licenseKeys"`
|
||||
LeaseKeys map[string]string `json:"leaseKeys"`
|
||||
}
|
||||
|
||||
type VerifiedLicense struct {
|
||||
Header Header
|
||||
Claims Claims
|
||||
}
|
||||
|
||||
type VerifiedLease struct {
|
||||
Header Header
|
||||
Claims LeaseClaims
|
||||
}
|
||||
|
||||
func NewTrustStore() TrustStore {
|
||||
return TrustStore{LicenseKeys: map[string]string{}, LeaseKeys: map[string]string{}}
|
||||
}
|
||||
|
||||
func ParseTrustStore(data []byte) (TrustStore, error) {
|
||||
var store TrustStore
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&store); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("decode trust store: %w", err)
|
||||
}
|
||||
if store.LicenseKeys == nil {
|
||||
store.LicenseKeys = map[string]string{}
|
||||
}
|
||||
if store.LeaseKeys == nil {
|
||||
store.LeaseKeys = map[string]string{}
|
||||
}
|
||||
for kid, encoded := range store.LicenseKeys {
|
||||
if strings.TrimSpace(kid) == "" {
|
||||
return TrustStore{}, errors.New("license trust store contains an empty key id")
|
||||
}
|
||||
if _, err := DecodePublicKey(encoded); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("license key %q: %w", kid, err)
|
||||
}
|
||||
}
|
||||
for kid, encoded := range store.LeaseKeys {
|
||||
if strings.TrimSpace(kid) == "" {
|
||||
return TrustStore{}, errors.New("lease trust store contains an empty key id")
|
||||
}
|
||||
if _, err := DecodePublicKey(encoded); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("lease key %q: %w", kid, err)
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func VerifyLicense(store TrustStore, token string, now time.Time) (VerifiedLicense, error) {
|
||||
header, payload, err := verifyToken(store.LicenseKeys, TokenTypeLicense, token)
|
||||
if err != nil {
|
||||
return VerifiedLicense{}, err
|
||||
}
|
||||
var claims Claims
|
||||
if err := decodeStrict(payload, &claims); err != nil {
|
||||
return VerifiedLicense{}, fmt.Errorf("decode license payload: %w", err)
|
||||
}
|
||||
if err := validateLicenseClaims(claims, now); err != nil {
|
||||
return VerifiedLicense{}, err
|
||||
}
|
||||
claims.Features = UniqueSorted(claims.Features)
|
||||
claims.Domains = UniqueSorted(claims.Domains)
|
||||
claims.InstanceIDs = UniqueSorted(claims.InstanceIDs)
|
||||
return VerifiedLicense{Header: header, Claims: claims}, nil
|
||||
}
|
||||
|
||||
func VerifyLease(store TrustStore, token string, now time.Time, allowGrace time.Duration) (VerifiedLease, error) {
|
||||
header, payload, err := verifyToken(store.LeaseKeys, TokenTypeLease, token)
|
||||
if err != nil {
|
||||
return VerifiedLease{}, err
|
||||
}
|
||||
var claims LeaseClaims
|
||||
if err := decodeStrict(payload, &claims); err != nil {
|
||||
return VerifiedLease{}, fmt.Errorf("decode lease payload: %w", err)
|
||||
}
|
||||
if err := validateLeaseClaims(claims, now); err != nil {
|
||||
return VerifiedLease{}, err
|
||||
}
|
||||
if now.Unix() >= claims.ExpiresAt+int64(allowGrace.Seconds()) {
|
||||
return VerifiedLease{}, errors.New("lease has expired")
|
||||
}
|
||||
claims.Features = UniqueSorted(claims.Features)
|
||||
return VerifiedLease{Header: header, Claims: claims}, nil
|
||||
}
|
||||
|
||||
func verifyToken(keys map[string]string, expectedType, token string) (Header, []byte, error) {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 3 {
|
||||
return Header{}, nil, errors.New("token has invalid format")
|
||||
}
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token header is not valid base64url")
|
||||
}
|
||||
var header Header
|
||||
if err := decodeStrict(headerBytes, &header); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("decode token header: %w", err)
|
||||
}
|
||||
if header.Algorithm != AlgorithmEdDSA || header.Type != expectedType || header.Version != SchemaVersion {
|
||||
return Header{}, nil, errors.New("unsupported token header")
|
||||
}
|
||||
encodedKey, ok := keys[header.KeyID]
|
||||
if !ok {
|
||||
return Header{}, nil, fmt.Errorf("token is signed by unknown key %q", header.KeyID)
|
||||
}
|
||||
publicKey, err := DecodePublicKey(encodedKey)
|
||||
if err != nil {
|
||||
return Header{}, nil, fmt.Errorf("decode trusted key %q: %w", header.KeyID, err)
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token signature is not valid base64url")
|
||||
}
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
if !ed25519.Verify(publicKey, []byte(signingInput), signature) {
|
||||
return Header{}, nil, errors.New("token signature verification failed")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token payload is not valid base64url")
|
||||
}
|
||||
return header, payload, nil
|
||||
}
|
||||
|
||||
func ValidateLicenseContext(claims Claims, product, baseURL, instanceID string) error {
|
||||
if strings.TrimSpace(product) == "" {
|
||||
return errors.New("client product id is required")
|
||||
}
|
||||
if claims.Product != product {
|
||||
return fmt.Errorf("license is for product %q, not %q", claims.Product, product)
|
||||
}
|
||||
if err := ValidateDomain(claims.Domains, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(claims.InstanceIDs) > 0 {
|
||||
instanceID = strings.TrimSpace(instanceID)
|
||||
if instanceID == "" {
|
||||
return errors.New("license requires an instance id")
|
||||
}
|
||||
allowed := false
|
||||
for _, candidate := range claims.InstanceIDs {
|
||||
if candidate == "*" || candidate == instanceID {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("instance %q is not covered by the license", instanceID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateLeaseContext(claims LeaseClaims, license Claims, product, baseURL, instanceID string) error {
|
||||
if claims.LicenseID != license.LicenseID {
|
||||
return errors.New("lease does not belong to the configured license")
|
||||
}
|
||||
if claims.Product != product || claims.Product != license.Product {
|
||||
return errors.New("lease product does not match")
|
||||
}
|
||||
host, err := HostFromBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if claims.Host != "" && !strings.EqualFold(claims.Host, host) {
|
||||
return errors.New("lease host does not match")
|
||||
}
|
||||
if claims.InstanceID != "" && claims.InstanceID != instanceID {
|
||||
return errors.New("lease instance does not match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateDomain(domains []string, baseURL string) error {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
host, err := HostFromBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, allowed := range domains {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == "*" || host == allowed {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
root := strings.TrimPrefix(allowed, "*.")
|
||||
if host != root && strings.HasSuffix(host, "."+root) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("host %q is not covered by the license", host)
|
||||
}
|
||||
|
||||
func HostFromBaseURL(baseURL string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", errors.New("base URL has no valid host")
|
||||
}
|
||||
return strings.ToLower(u.Hostname()), nil
|
||||
}
|
||||
|
||||
func StricterMode(a, b VerificationMode) VerificationMode {
|
||||
rank := map[VerificationMode]int{ModeOffline: 0, ModeHybrid: 1, ModeOnline: 2}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
if _, ok := rank[a]; !ok {
|
||||
return ModeOffline
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func ParseMode(value string) (VerificationMode, error) {
|
||||
mode := VerificationMode(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch mode {
|
||||
case "", ModeOffline:
|
||||
return ModeOffline, nil
|
||||
case ModeHybrid, ModeOnline:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown verification mode %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func DecodePublicKey(encoded string) (ed25519.PublicKey, error) {
|
||||
b, err := decodeKey(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("public key must contain an Ed25519 public key")
|
||||
}
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("key is not valid base64")
|
||||
}
|
||||
|
||||
func UniqueSorted(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func validateLicenseClaims(c Claims, now time.Time) error {
|
||||
if c.Version != SchemaVersion {
|
||||
return errors.New("unsupported license version")
|
||||
}
|
||||
if strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Issuer) == "" {
|
||||
return errors.New("license id and issuer are required")
|
||||
}
|
||||
if strings.TrimSpace(c.Customer) == "" || strings.TrimSpace(c.Product) == "" || strings.TrimSpace(c.Edition) == "" {
|
||||
return errors.New("customer, product and edition are required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("license timestamps are invalid")
|
||||
}
|
||||
notBefore := c.NotBefore
|
||||
if notBefore == 0 {
|
||||
notBefore = c.IssuedAt
|
||||
}
|
||||
if now.Unix() < notBefore-300 {
|
||||
return errors.New("license is not active yet")
|
||||
}
|
||||
if now.Unix() >= c.ExpiresAt {
|
||||
return errors.New("license has expired")
|
||||
}
|
||||
if _, err := ParseMode(string(c.Verification.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Verification.LeaseTTLSeconds < 0 || c.Verification.OfflineGraceSeconds < 0 {
|
||||
return errors.New("verification durations cannot be negative")
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Verification.ServerURL); raw != "" {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return errors.New("verification server URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLeaseClaims(c LeaseClaims, now time.Time) error {
|
||||
if c.Version != SchemaVersion {
|
||||
return errors.New("unsupported lease version")
|
||||
}
|
||||
if strings.TrimSpace(c.LeaseID) == "" || strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Product) == "" {
|
||||
return errors.New("lease id, license id and product are required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("lease timestamps are invalid")
|
||||
}
|
||||
if now.Unix() < c.IssuedAt-300 {
|
||||
return errors.New("lease is not active yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeStrict(data []byte, target any) error {
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
|
||||
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
|
||||
)
|
||||
|
||||
func TestVerifyUniversalPlatformToken(t *testing.T) {
|
||||
store := NewTrustStore()
|
||||
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
|
||||
|
||||
verified, err := VerifyLicense(store, fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified.Claims.Product != "ai-disclosure-standard" || verified.Claims.Edition != "pro" {
|
||||
t.Fatalf("unexpected claims: %#v", verified.Claims)
|
||||
}
|
||||
if err := ValidateLicenseContext(verified.Claims, "ai-disclosure-standard", "https://any-domain.example", ""); err != nil {
|
||||
t.Fatalf("global domain wildcard should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownIssuerKeyIsRejected(t *testing.T) {
|
||||
_, err := VerifyLicense(NewTrustStore(), fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||
t.Fatalf("expected unknown-key error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Edition string `json:"edition"`
|
||||
Licensed bool `json:"licensed"`
|
||||
LicenseID string `json:"licenseId,omitempty"`
|
||||
Customer string `json:"customer,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Features []string `json:"features"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LastChecked string `json:"lastChecked,omitempty"`
|
||||
LeaseExpires string `json:"leaseExpiresAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Product string
|
||||
Token string
|
||||
TrustStore licensekit.TrustStore
|
||||
BaseURL string
|
||||
InstanceID string
|
||||
Mode licensekit.VerificationMode
|
||||
ServerURL string
|
||||
CacheFile string
|
||||
RefreshEvery time.Duration
|
||||
RequestTimeout time.Duration
|
||||
ClientVersion string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
claims licensekit.Claims
|
||||
features map[string]bool
|
||||
stopOnce sync.Once
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type introspectRequest struct {
|
||||
Token string `json:"token"`
|
||||
Product string `json:"product"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Host string `json:"host"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
}
|
||||
|
||||
type introspectResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type cacheDocument struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
Lease string `json:"lease"`
|
||||
SavedAt int64 `json:"savedAt"`
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg Config) *Client {
|
||||
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||
if cfg.ServerURL == "" {
|
||||
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/")
|
||||
}
|
||||
if cfg.RefreshEvery <= 0 {
|
||||
cfg.RefreshEvery = 15 * time.Minute
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
cfg.RequestTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout}
|
||||
}
|
||||
c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}}
|
||||
c.refresh(ctx)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Start(ctx context.Context) {
|
||||
c.mu.RLock()
|
||||
mode := c.status.Mode
|
||||
c.mu.RUnlock()
|
||||
if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(c.cfg.RefreshEvery)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout)
|
||||
c.refresh(refreshCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) }
|
||||
|
||||
func (c *Client) Refresh(ctx context.Context) Status {
|
||||
c.refresh(ctx)
|
||||
return c.Status()
|
||||
}
|
||||
|
||||
func (c *Client) Has(feature string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
|
||||
}
|
||||
|
||||
func (c *Client) Limit(name string) (int64, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
||||
return 0, false
|
||||
}
|
||||
value, ok := c.status.Limits[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (c *Client) Status() Status {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := c.status
|
||||
if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
||||
out.Licensed = false
|
||||
out.Edition = "community"
|
||||
if out.Reason == "" {
|
||||
out.Reason = "license or online lease is no longer valid"
|
||||
}
|
||||
}
|
||||
out.Features = append([]string{}, c.status.Features...)
|
||||
if c.status.Limits != nil {
|
||||
out.Limits = make(map[string]int64, len(c.status.Limits))
|
||||
for key, value := range c.status.Limits {
|
||||
out.Limits[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool {
|
||||
if !c.status.Licensed {
|
||||
return false
|
||||
}
|
||||
if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt {
|
||||
return false
|
||||
}
|
||||
mode := licensekit.VerificationMode(c.status.Mode)
|
||||
if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" {
|
||||
return true
|
||||
}
|
||||
leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if mode == licensekit.ModeHybrid {
|
||||
leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second)
|
||||
}
|
||||
return now.Before(leaseExpiry)
|
||||
}
|
||||
|
||||
func (c *Client) refresh(ctx context.Context) {
|
||||
now := time.Now().UTC()
|
||||
if strings.TrimSpace(c.cfg.Token) == "" {
|
||||
c.apply(communityStatus(), licensekit.Claims{})
|
||||
return
|
||||
}
|
||||
verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now)
|
||||
if err != nil {
|
||||
c.apply(failedStatus(err.Error(), now), licensekit.Claims{})
|
||||
return
|
||||
}
|
||||
claims := verified.Claims
|
||||
if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
||||
c.apply(failedStatus(err.Error(), now), claims)
|
||||
return
|
||||
}
|
||||
mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode)
|
||||
base := statusFromClaims(claims, mode, now)
|
||||
if mode == licensekit.ModeOffline {
|
||||
base.Licensed = true
|
||||
base.Source = "offline"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
serverURL := c.resolveServerURL(ctx, claims)
|
||||
base.ServerURL = serverURL
|
||||
if serverURL == "" {
|
||||
base.Edition = "community"
|
||||
base.Reason = "online verification is required but no license server URL could be resolved"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
lease, source, err := c.obtainLease(ctx, claims, mode, now, serverURL)
|
||||
if err != nil {
|
||||
base.Edition = "community"
|
||||
base.Reason = err.Error()
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
base.Licensed = true
|
||||
base.Source = source
|
||||
base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||
base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features))
|
||||
c.apply(base, claims)
|
||||
}
|
||||
|
||||
func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time, serverURL string) (licensekit.LeaseClaims, string, error) {
|
||||
leaseToken, err := c.requestLease(ctx, serverURL)
|
||||
if err == nil {
|
||||
lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0)
|
||||
if verifyErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr)
|
||||
}
|
||||
if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", verifyErr
|
||||
}
|
||||
_ = c.writeCache(claims.LicenseID, leaseToken)
|
||||
return lease.Claims, "online", nil
|
||||
}
|
||||
if mode == licensekit.ModeOnline {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err)
|
||||
}
|
||||
cached, cacheErr := c.readCache(claims, now)
|
||||
if cacheErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr)
|
||||
}
|
||||
return cached, "cached-lease", nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveServerURL(ctx context.Context, claims licensekit.Claims) string {
|
||||
if value := strings.TrimRight(strings.TrimSpace(c.cfg.ServerURL), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/license-server", nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var discovery struct {
|
||||
ServerURL string `json:"serverUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&discovery); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(discovery.ServerURL), "/")
|
||||
}
|
||||
|
||||
func (c *Client) requestLease(ctx context.Context, serverURL string) (string, error) {
|
||||
host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
paths := []string{"/api/v1/licenses/validate", "/v1/introspect"}
|
||||
var lastErr error
|
||||
for index, path := range paths {
|
||||
token, status, err := c.requestLeaseAt(ctx, strings.TrimRight(serverURL, "/")+path, body)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
lastErr = err
|
||||
if index == 0 && status == http.StatusNotFound {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (c *Client) requestLeaseAt(ctx context.Context, endpoint string, body []byte) (string, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
var result introspectResponse
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return "", resp.StatusCode, fmt.Errorf("decode verification response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
|
||||
if result.Reason == "" {
|
||||
result.Reason = resp.Status
|
||||
}
|
||||
return "", resp.StatusCode, errors.New(result.Reason)
|
||||
}
|
||||
return result.LeaseToken, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) {
|
||||
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
||||
return licensekit.LeaseClaims{}, errors.New("cache file is not configured")
|
||||
}
|
||||
data, err := os.ReadFile(c.cfg.CacheFile)
|
||||
if err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
var doc cacheDocument
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
if doc.LicenseID != claims.LicenseID {
|
||||
return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license")
|
||||
}
|
||||
grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second
|
||||
verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace)
|
||||
if err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
return verified.Claims, nil
|
||||
}
|
||||
|
||||
func (c *Client) writeCache(licenseID, lease string) error {
|
||||
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp := c.cfg.CacheFile + ".tmp"
|
||||
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, c.cfg.CacheFile)
|
||||
}
|
||||
|
||||
func (c *Client) apply(status Status, claims licensekit.Claims) {
|
||||
status.Features = licensekit.UniqueSorted(status.Features)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status = status
|
||||
c.claims = claims
|
||||
c.features = make(map[string]bool, len(status.Features))
|
||||
for _, feature := range status.Features {
|
||||
c.features[feature] = true
|
||||
}
|
||||
}
|
||||
|
||||
func communityStatus() Status {
|
||||
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}}
|
||||
}
|
||||
|
||||
func failedStatus(reason string, now time.Time) Status {
|
||||
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status {
|
||||
limits := map[string]int64{}
|
||||
for key, value := range claims.Limits {
|
||||
limits[key] = value
|
||||
}
|
||||
return Status{
|
||||
Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product,
|
||||
Features: append([]string(nil), claims.Features...), Limits: limits,
|
||||
ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func intersection(a, b []string) []string {
|
||||
allowed := make(map[string]bool, len(b))
|
||||
for _, value := range b {
|
||||
allowed[value] = true
|
||||
}
|
||||
out := make([]string, 0, len(a))
|
||||
for _, value := range a {
|
||||
if allowed[value] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
|
||||
fixtureLeasePublic = "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA"
|
||||
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
|
||||
fixtureHybridToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfcGxhdGZvcm1fZml4dHVyZSIsImlzc3VlciI6InVuaXZlcnNhbC1saWNlbnNlLXBsYXRmb3JtIiwiY3VzdG9tZXIiOiJGaXh0dXJlIEN1c3RvbWVyIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwibGltaXRzIjp7InNpdGVzIjoxMH0sImRvbWFpbnMiOlsiKiJdLCJpc3N1ZWRBdCI6MTc2NzIyNTYwMCwiZXhwaXJlc0F0IjoyNTI0NjA4MDAwLCJ2ZXJpZmljYXRpb24iOnsibW9kZSI6Imh5YnJpZCIsImxlYXNlVHRsU2Vjb25kcyI6MzYwMCwib2ZmbGluZUdyYWNlU2Vjb25kcyI6NzIwMCwic2VydmVyVXJsIjoiaHR0cHM6Ly9saWNlbnNlcy5leGFtcGxlLnRlc3QifX0.Jvb6NPFnZZgH0KxpEsjsVyn6kjBkk3cce1kOWrNPZmD5qA35_9Prgt1f_2RmtOtaXTp7j6ISNeYGAetpsHIQDw"
|
||||
fixtureLeaseToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxFQVNFIiwia2lkIjoibGVhc2UtZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsZWFzZUlkIjoibGVhc2VfcGxhdGZvcm1fZml4dHVyZSIsImxpY2Vuc2VJZCI6ImxpY19wbGF0Zm9ybV9maXh0dXJlIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwiaG9zdCI6ImV4YW1wbGUub3JnIiwiaXNzdWVkQXQiOjE3NjcyMjU2MDAsImV4cGlyZXNBdCI6MjUyNDYwODAwMH0.NwWSLoJVwY1X9WGobZu2edPAhmUhjHq8QZkCwpN2CTCqxaqWQjs1suC3mFxsjd5EG2wODr-PQh2duZ4qN4mdCA"
|
||||
)
|
||||
|
||||
func fixtureTrustStore() licensekit.TrustStore {
|
||||
store := licensekit.NewTrustStore()
|
||||
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
|
||||
store.LeaseKeys["lease-fixture-1"] = fixtureLeasePublic
|
||||
return store
|
||||
}
|
||||
|
||||
func TestOfflinePlatformLicense(t *testing.T) {
|
||||
client := New(context.Background(), Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
Token: fixtureOfflineToken,
|
||||
TrustStore: fixtureTrustStore(),
|
||||
BaseURL: "https://example.org",
|
||||
Mode: licensekit.ModeOffline,
|
||||
})
|
||||
if !client.Status().Licensed || !client.Has("custom_text") {
|
||||
t.Fatalf("unexpected status: %#v", client.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridUsesPlatformValidateEndpoint(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": fixtureLeaseToken})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(context.Background(), Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
Token: fixtureHybridToken,
|
||||
TrustStore: fixtureTrustStore(),
|
||||
BaseURL: "https://example.org",
|
||||
Mode: licensekit.ModeHybrid,
|
||||
ServerURL: server.URL,
|
||||
})
|
||||
if !client.Status().Licensed || client.Status().Source != "online" {
|
||||
t.Fatalf("unexpected status: %#v", client.Status())
|
||||
}
|
||||
if requestedPath != "/api/v1/licenses/validate" {
|
||||
t.Fatalf("unexpected endpoint %q", requestedPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package licenseclient implements the runtime side of product licensing. It
|
||||
// supports offline verification, hybrid signed-lease caching and mandatory
|
||||
// online introspection without allowing customers to replace trusted keys.
|
||||
package licenseclient
|
||||
Reference in New Issue
Block a user