Skip to content

Commit d43670b

Browse files
committed
feat: license JWT/JWKS and verify parity updates
1 parent 0d606d5 commit d43670b

4 files changed

Lines changed: 254 additions & 177 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
require 'jwt'
4+
require 'json'
5+
require 'uri'
6+
require 'net/http'
7+
8+
module LicenseChain
9+
# RS256 license_token verification via JWKS (parity with Node verifyLicenseAssertionJwt).
10+
module LicenseAssertion
11+
LICENSE_TOKEN_USE_CLAIM = 'licensechain_license_v1'
12+
13+
module_function
14+
15+
# @param expected_app_id [String, nil]
16+
# @param issuer [String, nil]
17+
# @return [Hash] decoded JWT payload
18+
def verify_license_assertion_jwt(token, jwks_url, expected_app_id: nil, issuer: nil)
19+
token = token.to_s.strip
20+
raise ArgumentError, 'empty token' if token.empty?
21+
22+
jwks_url = jwks_url.to_s.strip
23+
raise ArgumentError, 'empty jwks_url' if jwks_url.empty?
24+
25+
jwks = fetch_jwks(jwks_url)
26+
decode_opts = {
27+
algorithms: ['RS256'],
28+
jwks: jwks,
29+
verify_aud: false
30+
}
31+
if issuer && !issuer.to_s.strip.empty?
32+
decode_opts[:verify_iss] = true
33+
decode_opts[:iss] = issuer.to_s.strip
34+
end
35+
36+
payload, = JWT.decode(token, nil, true, decode_opts)
37+
38+
tu = payload['token_use']
39+
if tu != LICENSE_TOKEN_USE_CLAIM
40+
raise JWT::DecodeError, %(Invalid license token: expected token_use "#{LICENSE_TOKEN_USE_CLAIM}")
41+
end
42+
43+
if expected_app_id && !expected_app_id.to_s.strip.empty?
44+
want = expected_app_id.to_s.strip
45+
aud = payload['aud']
46+
ok = aud == want || (aud.is_a?(Array) && aud.include?(want))
47+
raise JWT::DecodeError, 'Invalid license token: aud does not match expected app id' unless ok
48+
end
49+
50+
payload
51+
end
52+
53+
def fetch_jwks(jwks_url)
54+
uri = URI.parse(jwks_url)
55+
http = Net::HTTP.new(uri.host, uri.port)
56+
http.use_ssl = uri.scheme == 'https'
57+
http.open_timeout = 20
58+
http.read_timeout = 20
59+
req = Net::HTTP::Get.new(uri.request_uri)
60+
res = http.request(req)
61+
raise JWT::DecodeError, "JWKS HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
62+
63+
JSON.parse(res.body)
64+
end
65+
private_class_method :fetch_jwks
66+
end
67+
end
Lines changed: 108 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,108 @@
1-
module LicenseChain
2-
module Services
3-
class LicenseService
4-
def initialize(client)
5-
@client = client
6-
end
7-
8-
def create(app_id, user_email, metadata = {})
9-
Utils.validate_not_empty(app_id, 'app_id')
10-
Utils.validate_not_empty(user_email, 'user_email')
11-
12-
data = {
13-
appId: app_id,
14-
plan: 'FREE',
15-
issuedEmail: user_email,
16-
metadata: Utils.sanitize_metadata(metadata)
17-
}
18-
19-
response = @client.post("/apps/#{app_id}/licenses", data)
20-
License.new(normalize_license_payload(response[:data] || response))
21-
end
22-
23-
def get(license_id)
24-
Utils.validate_not_empty(license_id, 'license_id')
25-
26-
response = @client.get("/licenses/#{license_id}")
27-
License.new(normalize_license_payload(response[:data] || response))
28-
end
29-
30-
def update(license_id, updates = {})
31-
Utils.validate_not_empty(license_id, 'license_id')
32-
33-
response = @client.patch("/licenses/#{license_id}", Utils.sanitize_metadata(updates))
34-
License.new(normalize_license_payload(response[:data] || response))
35-
end
36-
37-
def revoke(license_id)
38-
Utils.validate_not_empty(license_id, 'license_id')
39-
40-
@client.delete("/licenses/#{license_id}")
41-
true
42-
end
43-
44-
def validate(license_key, hwuid = nil)
45-
Utils.validate_not_empty(license_key, 'license_key')
46-
body = { key: license_key }
47-
body[:hwuid] = hwuid.to_s.strip != '' ? hwuid.to_s.strip : Utils.default_hwuid
48-
response = @client.post('/licenses/verify', body)
49-
response[:valid]
50-
end
51-
52-
def list_user_licenses(user_id, page = 1, limit = 10)
53-
Utils.validate_not_empty(user_id, 'user_id')
54-
page, limit = Utils.validate_pagination(page, limit)
55-
56-
response = @client.get('/licenses', { page: page, limit: limit })
57-
items = response[:data] || response[:licenses] || []
58-
filtered = items.select do |license|
59-
license[:issuedEmail] == user_id || license[:email] == user_id || license[:user_id] == user_id
60-
end
61-
{
62-
data: filtered.map { |license| License.new(normalize_license_payload(license)) },
63-
total: filtered.length,
64-
page: page,
65-
limit: limit
66-
}
67-
end
68-
69-
def stats
70-
response = @client.get('/licenses/stats')
71-
LicenseStats.new(response[:data] || response)
72-
end
73-
74-
private
75-
76-
def validate_uuid(id, field_name)
77-
Utils.validate_not_empty(id, field_name)
78-
raise ValidationError, "Invalid #{field_name} format" unless Utils.validate_uuid(id)
79-
end
80-
81-
def normalize_license_payload(payload)
82-
{
83-
id: payload[:id],
84-
key: payload[:key] || payload[:licenseKey],
85-
app_id: payload[:app_id] || payload[:appId] || '',
86-
user_id: payload[:user_id],
87-
user_email: payload[:user_email] || payload[:issuedEmail] || payload[:email] || '',
88-
user_name: payload[:user_name] || payload[:issuedTo],
89-
status: (payload[:status] || 'active').to_s.downcase,
90-
expires_at: payload[:expires_at] || payload[:expiresAt],
91-
created_at: payload[:created_at] || payload[:createdAt],
92-
updated_at: payload[:updated_at] || payload[:updatedAt],
93-
metadata: payload[:metadata] || {},
94-
features: payload[:features] || [],
95-
usage_count: payload[:usage_count] || 0
96-
}
97-
end
98-
end
99-
end
100-
end
1+
module LicenseChain
2+
module Services
3+
class LicenseService
4+
def initialize(client)
5+
@client = client
6+
end
7+
8+
def create(app_id, user_email, metadata = {})
9+
Utils.validate_not_empty(app_id, 'app_id')
10+
Utils.validate_not_empty(user_email, 'user_email')
11+
12+
data = {
13+
appId: app_id,
14+
plan: 'FREE',
15+
issuedEmail: user_email,
16+
metadata: Utils.sanitize_metadata(metadata)
17+
}
18+
19+
response = @client.post("/apps/#{app_id}/licenses", data)
20+
License.new(normalize_license_payload(response[:data] || response))
21+
end
22+
23+
def get(license_id)
24+
Utils.validate_not_empty(license_id, 'license_id')
25+
26+
response = @client.get("/licenses/#{license_id}")
27+
License.new(normalize_license_payload(response[:data] || response))
28+
end
29+
30+
def update(license_id, updates = {})
31+
Utils.validate_not_empty(license_id, 'license_id')
32+
33+
response = @client.patch("/licenses/#{license_id}", Utils.sanitize_metadata(updates))
34+
License.new(normalize_license_payload(response[:data] || response))
35+
end
36+
37+
def revoke(license_id)
38+
Utils.validate_not_empty(license_id, 'license_id')
39+
40+
@client.delete("/licenses/#{license_id}")
41+
true
42+
end
43+
44+
def validate(license_key, hwuid = nil)
45+
Utils.validate_not_empty(license_key, 'license_key')
46+
body = { key: license_key }
47+
body[:hwuid] = hwuid.to_s.strip != '' ? hwuid.to_s.strip : Utils.default_hwuid
48+
response = @client.post('/licenses/verify', body)
49+
response[:valid]
50+
end
51+
52+
# Full POST /licenses/verify body (valid, optional license_token, license_jwks_uri, etc.).
53+
def verify_with_details(license_key, hwuid = nil)
54+
Utils.validate_not_empty(license_key, 'license_key')
55+
body = { key: license_key }
56+
body[:hwuid] = hwuid.to_s.strip != '' ? hwuid.to_s.strip : Utils.default_hwuid
57+
@client.post('/licenses/verify', body)
58+
end
59+
60+
def list_user_licenses(user_id, page = 1, limit = 10)
61+
Utils.validate_not_empty(user_id, 'user_id')
62+
page, limit = Utils.validate_pagination(page, limit)
63+
64+
response = @client.get('/licenses', { page: page, limit: limit })
65+
items = response[:data] || response[:licenses] || []
66+
filtered = items.select do |license|
67+
license[:issuedEmail] == user_id || license[:email] == user_id || license[:user_id] == user_id
68+
end
69+
{
70+
data: filtered.map { |license| License.new(normalize_license_payload(license)) },
71+
total: filtered.length,
72+
page: page,
73+
limit: limit
74+
}
75+
end
76+
77+
def stats
78+
response = @client.get('/licenses/stats')
79+
LicenseStats.new(response[:data] || response)
80+
end
81+
82+
private
83+
84+
def validate_uuid(id, field_name)
85+
Utils.validate_not_empty(id, field_name)
86+
raise ValidationError, "Invalid #{field_name} format" unless Utils.validate_uuid(id)
87+
end
88+
89+
def normalize_license_payload(payload)
90+
{
91+
id: payload[:id],
92+
key: payload[:key] || payload[:licenseKey],
93+
app_id: payload[:app_id] || payload[:appId] || '',
94+
user_id: payload[:user_id],
95+
user_email: payload[:user_email] || payload[:issuedEmail] || payload[:email] || '',
96+
user_name: payload[:user_name] || payload[:issuedTo],
97+
status: (payload[:status] || 'active').to_s.downcase,
98+
expires_at: payload[:expires_at] || payload[:expiresAt],
99+
created_at: payload[:created_at] || payload[:createdAt],
100+
updated_at: payload[:updated_at] || payload[:updatedAt],
101+
metadata: payload[:metadata] || {},
102+
features: payload[:features] || [],
103+
usage_count: payload[:usage_count] || 0
104+
}
105+
end
106+
end
107+
end
108+
end

lib/licensechain_ruby_sdk.rb

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,36 @@
1-
require 'logger'
2-
require 'securerandom'
3-
require 'time'
4-
require 'json'
5-
require 'net/http'
6-
require 'uri'
7-
8-
require_relative 'licensechain_ruby_sdk/version'
9-
require_relative 'licensechain/configuration'
10-
require_relative 'licensechain/errors'
11-
require_relative 'licensechain/utils'
12-
require_relative 'licensechain/models'
13-
require_relative 'licensechain/api_client'
14-
require_relative 'licensechain/client'
15-
require_relative 'licensechain/webhook_handler'
16-
require_relative 'licensechain/services/license_service'
17-
require_relative 'licensechain/services/user_service'
18-
require_relative 'licensechain/services/product_service'
19-
require_relative 'licensechain/services/webhook_service'
20-
21-
module LicenseChainRubySdk
22-
class Error < StandardError; end
23-
24-
def self.configure
25-
yield(LicenseChain.configuration)
26-
end
27-
28-
def self.configuration
29-
LicenseChain.configuration
30-
end
31-
32-
def self.client(config = nil)
33-
LicenseChain::Client.new(config)
34-
end
1+
require 'logger'
2+
require 'securerandom'
3+
require 'time'
4+
require 'json'
5+
require 'net/http'
6+
require 'uri'
7+
8+
require_relative 'licensechain_ruby_sdk/version'
9+
require_relative 'licensechain/configuration'
10+
require_relative 'licensechain/errors'
11+
require_relative 'licensechain/utils'
12+
require_relative 'licensechain/license_assertion'
13+
require_relative 'licensechain/models'
14+
require_relative 'licensechain/api_client'
15+
require_relative 'licensechain/client'
16+
require_relative 'licensechain/webhook_handler'
17+
require_relative 'licensechain/services/license_service'
18+
require_relative 'licensechain/services/user_service'
19+
require_relative 'licensechain/services/product_service'
20+
require_relative 'licensechain/services/webhook_service'
21+
22+
module LicenseChainRubySdk
23+
class Error < StandardError; end
24+
25+
def self.configure
26+
yield(LicenseChain.configuration)
27+
end
28+
29+
def self.configuration
30+
LicenseChain.configuration
31+
end
32+
33+
def self.client(config = nil)
34+
LicenseChain::Client.new(config)
35+
end
3536
end

0 commit comments

Comments
 (0)