hanko-ruby 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/hanko/api/admin/audit_logs.rb +19 -0
- data/lib/hanko/api/admin/emails.rb +30 -0
- data/lib/hanko/api/admin/metadata.rb +37 -0
- data/lib/hanko/api/admin/passwords.rb +54 -0
- data/lib/hanko/api/admin/sessions.rb +20 -0
- data/lib/hanko/api/admin/users.rb +77 -0
- data/lib/hanko/api/admin/webauthn_credentials.rb +20 -0
- data/lib/hanko/api/admin/webhooks.rb +19 -0
- data/lib/hanko/api/admin.rb +52 -0
- data/lib/hanko/api/base_resource.rb +79 -0
- data/lib/hanko/api/public/flow.rb +50 -0
- data/lib/hanko/api/public/sessions.rb +35 -0
- data/lib/hanko/api/public/well_known.rb +34 -0
- data/lib/hanko/api/public.rb +45 -0
- data/lib/hanko/client.rb +76 -0
- data/lib/hanko/configuration.rb +64 -0
- data/lib/hanko/connection.rb +92 -0
- data/lib/hanko/errors.rb +60 -0
- data/lib/hanko/flow_response.rb +56 -0
- data/lib/hanko/middleware/raise_error.rb +58 -0
- data/lib/hanko/resource.rb +77 -0
- data/lib/hanko/test_helper.rb +95 -0
- data/lib/hanko/version.rb +5 -0
- data/lib/hanko/webhook_verifier.rb +43 -0
- data/lib/hanko.rb +62 -0
- metadata +112 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanko
|
|
4
|
+
# Holds configuration options for the Hanko SDK.
|
|
5
|
+
#
|
|
6
|
+
# Set values via {Hanko.configure} or pass them directly to {Hanko::Client#initialize}.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# Hanko.configure do |c|
|
|
10
|
+
# c.api_url = "https://example.hanko.io"
|
|
11
|
+
# c.api_key = "your-api-key"
|
|
12
|
+
# c.timeout = 10
|
|
13
|
+
# end
|
|
14
|
+
class Configuration
|
|
15
|
+
ATTRIBUTES = %i[
|
|
16
|
+
api_url api_key timeout open_timeout retry_count
|
|
17
|
+
clock_skew jwks_cache_ttl logger log_level
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# @!attribute [rw] api_url
|
|
21
|
+
# @return [String, nil] the Hanko API base URL
|
|
22
|
+
# @!attribute [rw] api_key
|
|
23
|
+
# @return [String, nil] the API key for admin endpoints
|
|
24
|
+
# @!attribute [rw] timeout
|
|
25
|
+
# @return [Integer] request timeout in seconds (default: 5)
|
|
26
|
+
# @!attribute [rw] open_timeout
|
|
27
|
+
# @return [Integer] connection open timeout in seconds (default: 2)
|
|
28
|
+
# @!attribute [rw] retry_count
|
|
29
|
+
# @return [Integer] number of automatic retries (default: 1)
|
|
30
|
+
# @!attribute [rw] clock_skew
|
|
31
|
+
# @return [Integer] allowed clock skew in seconds for token validation (default: 0)
|
|
32
|
+
# @!attribute [rw] jwks_cache_ttl
|
|
33
|
+
# @return [Integer] JWKS cache time-to-live in seconds (default: 3600)
|
|
34
|
+
# @!attribute [rw] logger
|
|
35
|
+
# @return [Logger, nil] optional logger instance
|
|
36
|
+
# @!attribute [rw] log_level
|
|
37
|
+
# @return [Symbol] log level (default: :info)
|
|
38
|
+
attr_accessor(*ATTRIBUTES)
|
|
39
|
+
|
|
40
|
+
# Creates a new Configuration with sensible defaults.
|
|
41
|
+
#
|
|
42
|
+
# @return [Configuration]
|
|
43
|
+
def initialize
|
|
44
|
+
@timeout = 5
|
|
45
|
+
@open_timeout = 2
|
|
46
|
+
@retry_count = 1
|
|
47
|
+
@clock_skew = 0
|
|
48
|
+
@jwks_cache_ttl = 3600
|
|
49
|
+
@log_level = :info
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns a human-readable representation with the API key redacted.
|
|
53
|
+
#
|
|
54
|
+
# @return [String]
|
|
55
|
+
def inspect
|
|
56
|
+
attrs = ATTRIBUTES.map do |key|
|
|
57
|
+
value = send(key)
|
|
58
|
+
value = '[REDACTED]' if key == :api_key && value
|
|
59
|
+
"#{key}=#{value.inspect}"
|
|
60
|
+
end
|
|
61
|
+
"#<#{self.class} #{attrs.join(', ')}>"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'middleware/raise_error'
|
|
6
|
+
|
|
7
|
+
module Hanko
|
|
8
|
+
# Low-level HTTP wrapper around Faraday.
|
|
9
|
+
#
|
|
10
|
+
# Configures the Faraday connection with authentication headers,
|
|
11
|
+
# timeouts, and the {Middleware::RaiseError} middleware.
|
|
12
|
+
class Connection
|
|
13
|
+
# @return [Faraday::Connection] the underlying Faraday connection
|
|
14
|
+
attr_reader :connection
|
|
15
|
+
|
|
16
|
+
# Builds a new Connection from the given configuration.
|
|
17
|
+
#
|
|
18
|
+
# @param config [Configuration] SDK configuration
|
|
19
|
+
# @param adapter [Array, nil] optional Faraday adapter override (for testing)
|
|
20
|
+
# @return [Connection]
|
|
21
|
+
def initialize(config, adapter: nil)
|
|
22
|
+
@connection = build_connection(config, adapter)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Performs an HTTP GET request.
|
|
26
|
+
#
|
|
27
|
+
# @param path [String] the request path
|
|
28
|
+
# @param params [Hash] query parameters
|
|
29
|
+
# @return [Faraday::Response]
|
|
30
|
+
def get(path, params = {})
|
|
31
|
+
connection.get(path, params)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Performs an HTTP POST request with a JSON body.
|
|
35
|
+
#
|
|
36
|
+
# @param path [String] the request path
|
|
37
|
+
# @param body [Hash] the request body (serialized to JSON)
|
|
38
|
+
# @return [Faraday::Response]
|
|
39
|
+
def post(path, body = {})
|
|
40
|
+
connection.post(path, JSON.generate(body))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Performs an HTTP PUT request with a JSON body.
|
|
44
|
+
#
|
|
45
|
+
# @param path [String] the request path
|
|
46
|
+
# @param body [Hash] the request body (serialized to JSON)
|
|
47
|
+
# @return [Faraday::Response]
|
|
48
|
+
def put(path, body = {})
|
|
49
|
+
connection.put(path, JSON.generate(body))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Performs an HTTP PATCH request with a JSON body.
|
|
53
|
+
#
|
|
54
|
+
# @param path [String] the request path
|
|
55
|
+
# @param body [Hash] the request body (serialized to JSON)
|
|
56
|
+
# @return [Faraday::Response]
|
|
57
|
+
def patch(path, body = {})
|
|
58
|
+
connection.patch(path, JSON.generate(body))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Performs an HTTP DELETE request.
|
|
62
|
+
#
|
|
63
|
+
# @param path [String] the request path
|
|
64
|
+
# @param params [Hash] query parameters
|
|
65
|
+
# @return [Faraday::Response]
|
|
66
|
+
def delete(path, params = {})
|
|
67
|
+
connection.delete(path, params)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def build_connection(config, adapter)
|
|
73
|
+
Faraday.new(url: config.api_url) do |f|
|
|
74
|
+
f.headers['Content-Type'] = 'application/json'
|
|
75
|
+
f.headers['Accept'] = 'application/json'
|
|
76
|
+
f.headers['Authorization'] = "Bearer #{config.api_key}" if config.api_key
|
|
77
|
+
|
|
78
|
+
f.options.timeout = config.timeout
|
|
79
|
+
f.options.open_timeout = config.open_timeout
|
|
80
|
+
|
|
81
|
+
f.use Middleware::RaiseError
|
|
82
|
+
f.request :json
|
|
83
|
+
|
|
84
|
+
if adapter
|
|
85
|
+
f.adapter(*adapter)
|
|
86
|
+
else
|
|
87
|
+
f.adapter Faraday.default_adapter
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/hanko/errors.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanko
|
|
4
|
+
# Base error class for all Hanko SDK errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the SDK configuration is invalid or incomplete.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a JWT token cannot be decoded or has an invalid signature.
|
|
11
|
+
class InvalidTokenError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a JWT token has expired.
|
|
14
|
+
class ExpiredTokenError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when JWKS fetching or parsing fails.
|
|
17
|
+
class JwksError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a network-level connection error occurs.
|
|
20
|
+
class ConnectionError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when the Hanko API returns an HTTP 4xx or 5xx response.
|
|
23
|
+
class ApiError < Error
|
|
24
|
+
# @return [Integer, nil] the HTTP status code
|
|
25
|
+
attr_reader :status
|
|
26
|
+
|
|
27
|
+
# @return [Hash, nil] the parsed response body
|
|
28
|
+
attr_reader :body
|
|
29
|
+
|
|
30
|
+
# @param message [String, nil] the error message
|
|
31
|
+
# @param status [Integer, nil] the HTTP status code
|
|
32
|
+
# @param body [Hash, nil] the parsed response body
|
|
33
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
34
|
+
@status = status
|
|
35
|
+
@body = body
|
|
36
|
+
super(message)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Raised when the API returns HTTP 401 Unauthorized.
|
|
41
|
+
class AuthenticationError < ApiError; end
|
|
42
|
+
|
|
43
|
+
# Raised when the API returns HTTP 404 Not Found.
|
|
44
|
+
class NotFoundError < ApiError; end
|
|
45
|
+
|
|
46
|
+
# Raised when the API returns HTTP 429 Too Many Requests.
|
|
47
|
+
class RateLimitError < ApiError
|
|
48
|
+
# @return [Integer, nil] seconds to wait before retrying
|
|
49
|
+
attr_reader :retry_after
|
|
50
|
+
|
|
51
|
+
# @param message [String, nil] the error message
|
|
52
|
+
# @param status [Integer, nil] the HTTP status code
|
|
53
|
+
# @param body [Hash, nil] the parsed response body
|
|
54
|
+
# @param retry_after [Integer, nil] seconds to wait before retrying
|
|
55
|
+
def initialize(message = nil, status: nil, body: nil, retry_after: nil)
|
|
56
|
+
@retry_after = retry_after
|
|
57
|
+
super(message, status: status, body: body)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanko
|
|
4
|
+
# Structured response from a Hanko passkey flow endpoint.
|
|
5
|
+
#
|
|
6
|
+
# Wraps the raw response hash and provides convenience accessors
|
|
7
|
+
# for status, available actions, session token, and user ID.
|
|
8
|
+
class FlowResponse
|
|
9
|
+
# @return [Symbol, nil] the flow status (e.g. :completed, :error)
|
|
10
|
+
attr_reader :status
|
|
11
|
+
|
|
12
|
+
# @return [Array<Resource>] available actions in the current flow state
|
|
13
|
+
attr_reader :actions
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] the session token, if present in the payload
|
|
16
|
+
attr_reader :session_token
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] the user ID, if present in the payload
|
|
19
|
+
attr_reader :user_id
|
|
20
|
+
|
|
21
|
+
# @return [Hash] the raw response hash
|
|
22
|
+
attr_reader :raw
|
|
23
|
+
|
|
24
|
+
# Creates a new FlowResponse from a parsed response hash.
|
|
25
|
+
#
|
|
26
|
+
# @param data [Hash] the parsed JSON response from a flow endpoint
|
|
27
|
+
def initialize(data)
|
|
28
|
+
@raw = data
|
|
29
|
+
@status = data["status"]&.to_sym
|
|
30
|
+
@actions = (data["actions"] || []).map { |a| Resource.new(a) }
|
|
31
|
+
@session_token = data.dig("payload", "session_token")
|
|
32
|
+
@user_id = data.dig("payload", "user_id")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns true when the flow has completed successfully.
|
|
36
|
+
#
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def completed?
|
|
39
|
+
status == :completed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns true when the flow has entered an error state.
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def error?
|
|
46
|
+
status == :error
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the raw response hash.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash]
|
|
52
|
+
def to_h
|
|
53
|
+
@raw
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Hanko
|
|
7
|
+
module Middleware
|
|
8
|
+
# Faraday middleware that raises typed Hanko errors for HTTP 4xx/5xx responses.
|
|
9
|
+
#
|
|
10
|
+
# Maps specific HTTP status codes to error classes:
|
|
11
|
+
# - 401 -> {AuthenticationError}
|
|
12
|
+
# - 404 -> {NotFoundError}
|
|
13
|
+
# - 429 -> {RateLimitError}
|
|
14
|
+
# - All others -> {ApiError}
|
|
15
|
+
class RaiseError < Faraday::Middleware
|
|
16
|
+
# Called by Faraday after each request completes.
|
|
17
|
+
#
|
|
18
|
+
# @param env [Faraday::Env] the request/response environment
|
|
19
|
+
# @return [void]
|
|
20
|
+
# @raise [AuthenticationError] on HTTP 401
|
|
21
|
+
# @raise [NotFoundError] on HTTP 404
|
|
22
|
+
# @raise [RateLimitError] on HTTP 429
|
|
23
|
+
# @raise [ApiError] on any other HTTP 4xx/5xx
|
|
24
|
+
def on_complete(env)
|
|
25
|
+
return if env.status < 400
|
|
26
|
+
|
|
27
|
+
body = parse_body(env.body)
|
|
28
|
+
message = body['message'] || body['error'] || "HTTP #{env.status}"
|
|
29
|
+
|
|
30
|
+
raise error_for(env.status, message, body, env.response_headers)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def error_for(status, message, body, headers)
|
|
36
|
+
case status
|
|
37
|
+
when 401
|
|
38
|
+
AuthenticationError.new(message, status: status, body: body)
|
|
39
|
+
when 404
|
|
40
|
+
NotFoundError.new(message, status: status, body: body)
|
|
41
|
+
when 429
|
|
42
|
+
retry_after = headers['Retry-After']&.to_i
|
|
43
|
+
RateLimitError.new(message, status: status, body: body, retry_after: retry_after)
|
|
44
|
+
else
|
|
45
|
+
ApiError.new(message, status: status, body: body)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_body(body)
|
|
50
|
+
return {} if body.nil? || body.empty?
|
|
51
|
+
|
|
52
|
+
JSON.parse(body)
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanko
|
|
4
|
+
# Lightweight wrapper around a Hash that provides dot-notation access.
|
|
5
|
+
#
|
|
6
|
+
# Nested hashes are automatically wrapped in their own {Resource} instances.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# resource = Hanko::Resource.new("id" => "abc", "email" => "a@b.com")
|
|
10
|
+
# resource.id #=> "abc"
|
|
11
|
+
# resource[:email] #=> "a@b.com"
|
|
12
|
+
class Resource
|
|
13
|
+
# Creates a new Resource from a hash of attributes.
|
|
14
|
+
#
|
|
15
|
+
# @param attributes [Hash] the raw attribute hash
|
|
16
|
+
def initialize(attributes = {})
|
|
17
|
+
@attributes = normalize(attributes)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Retrieves an attribute by key (string or symbol).
|
|
21
|
+
#
|
|
22
|
+
# @param key [String, Symbol] the attribute name
|
|
23
|
+
# @return [Object, nil] the attribute value
|
|
24
|
+
def [](key)
|
|
25
|
+
@attributes[key.to_s]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Converts the resource (and any nested resources) to a plain Hash.
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash]
|
|
31
|
+
def to_h
|
|
32
|
+
@attributes.transform_values do |v|
|
|
33
|
+
v.is_a?(Resource) ? v.to_h : v
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns a human-readable representation of the resource.
|
|
38
|
+
#
|
|
39
|
+
# @return [String]
|
|
40
|
+
def inspect
|
|
41
|
+
"#<#{self.class} #{@attributes.inspect}>"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds an array of Resource instances from an array of hashes.
|
|
45
|
+
#
|
|
46
|
+
# @param array [Array<Hash>] array of attribute hashes
|
|
47
|
+
# @return [Array<Resource>]
|
|
48
|
+
def self.from_array(array)
|
|
49
|
+
array.map { |attrs| new(attrs) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns true for all method names, enabling dynamic attribute access.
|
|
53
|
+
#
|
|
54
|
+
# @param _method_name [Symbol]
|
|
55
|
+
# @param _include_private [Boolean]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def method_missing(method_name, *args)
|
|
64
|
+
if args.empty? && !block_given?
|
|
65
|
+
self[method_name]
|
|
66
|
+
else
|
|
67
|
+
super
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize(attributes)
|
|
72
|
+
attributes.each_with_object({}) do |(key, value), hash|
|
|
73
|
+
hash[key.to_s] = value.is_a?(Hash) ? self.class.new(value) : value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'webmock'
|
|
7
|
+
|
|
8
|
+
module Hanko
|
|
9
|
+
# Test utilities for generating JWT tokens and stubbing Hanko endpoints.
|
|
10
|
+
#
|
|
11
|
+
# Provides helpers that make it easy to test Hanko authentication
|
|
12
|
+
# without hitting a real Hanko API.
|
|
13
|
+
#
|
|
14
|
+
# @example Generate a test token and stub JWKS
|
|
15
|
+
# token = Hanko::TestHelper.generate_test_token(sub: "user-123", exp: Time.now.to_i + 3600)
|
|
16
|
+
# Hanko::TestHelper.stub_jwks(api_url: "https://example.hanko.io")
|
|
17
|
+
module TestHelper
|
|
18
|
+
# A simple stub that returns a fixed payload instead of verifying a token.
|
|
19
|
+
class StubVerifier
|
|
20
|
+
# @param payload [Hash] the payload to return on verify
|
|
21
|
+
def initialize(payload)
|
|
22
|
+
@payload = payload
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the fixed payload, ignoring the token.
|
|
26
|
+
#
|
|
27
|
+
# @param _token [String] ignored
|
|
28
|
+
# @return [Hash] the stub payload
|
|
29
|
+
def verify(_token)
|
|
30
|
+
@payload
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Generates a signed JWT test token using an ephemeral RSA key.
|
|
36
|
+
#
|
|
37
|
+
# @param sub [String] the subject claim (user ID)
|
|
38
|
+
# @param exp [Integer] the expiration time as a Unix timestamp
|
|
39
|
+
# @param extra_claims [Hash] additional claims to include in the payload
|
|
40
|
+
# @return [String] the encoded JWT token
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# token = Hanko::TestHelper.generate_test_token(sub: "user-123", exp: Time.now.to_i + 3600)
|
|
44
|
+
def generate_test_token(sub:, exp:, **extra_claims)
|
|
45
|
+
payload = { 'sub' => sub, 'exp' => exp }.merge(extra_claims.transform_keys(&:to_s))
|
|
46
|
+
JWT.encode(payload, test_key, 'RS256', kid: test_kid)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns a JSON string containing the test JWKS (public key set).
|
|
50
|
+
#
|
|
51
|
+
# @return [String] JSON-encoded JWKS response body
|
|
52
|
+
def test_jwks_response
|
|
53
|
+
jwk = JWT::JWK.new(test_key, kid: test_kid)
|
|
54
|
+
{ keys: [jwk.export] }.to_json
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Stubs the JWKS endpoint using WebMock so tokens from {generate_test_token} can be verified.
|
|
58
|
+
#
|
|
59
|
+
# @param api_url [String] the Hanko API base URL
|
|
60
|
+
# @return [WebMock::RequestStub] the WebMock stub
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# Hanko::TestHelper.stub_jwks(api_url: "https://example.hanko.io")
|
|
64
|
+
def stub_jwks(api_url:)
|
|
65
|
+
WebMock::API.stub_request(:get, "#{api_url}/.well-known/jwks.json")
|
|
66
|
+
.to_return(status: 200, body: test_jwks_response)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Creates a StubVerifier that returns a fixed session payload without cryptographic verification.
|
|
70
|
+
#
|
|
71
|
+
# @param sub [String] the subject claim (user ID)
|
|
72
|
+
# @param exp [Integer] the expiration time as a Unix timestamp
|
|
73
|
+
# @param extra_claims [Hash] additional claims to include in the payload
|
|
74
|
+
# @return [StubVerifier] a verifier that returns the given payload
|
|
75
|
+
#
|
|
76
|
+
# @example
|
|
77
|
+
# verifier = Hanko::TestHelper.stub_session(sub: "user-123", exp: Time.now.to_i + 3600)
|
|
78
|
+
# verifier.verify("any-token") #=> {"sub" => "user-123", "exp" => ...}
|
|
79
|
+
def stub_session(sub:, exp:, **extra_claims)
|
|
80
|
+
payload = { 'sub' => sub, 'exp' => exp }.merge(extra_claims.transform_keys(&:to_s))
|
|
81
|
+
StubVerifier.new(payload)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def test_key
|
|
87
|
+
@test_key ||= OpenSSL::PKey::RSA.generate(2048)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_kid
|
|
91
|
+
'hanko-test-kid'
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module Hanko
|
|
8
|
+
# Verifies Hanko webhook JWT tokens against a remote JWKS endpoint.
|
|
9
|
+
#
|
|
10
|
+
# @example Verify a webhook token
|
|
11
|
+
# payload = Hanko::WebhookVerifier.verify(token, jwks_url: "https://example.hanko.io/.well-known/jwks.json")
|
|
12
|
+
# puts payload["sub"]
|
|
13
|
+
class WebhookVerifier
|
|
14
|
+
ALGORITHM = 'RS256'
|
|
15
|
+
|
|
16
|
+
# Decodes and verifies a JWT token using keys from the given JWKS URL.
|
|
17
|
+
#
|
|
18
|
+
# @param token [String] the JWT token to verify
|
|
19
|
+
# @param jwks_url [String] URL of the JWKS endpoint
|
|
20
|
+
# @return [Hash] the decoded JWT payload
|
|
21
|
+
# @raise [ExpiredTokenError] if the token has expired
|
|
22
|
+
# @raise [InvalidTokenError] if the token is invalid or cannot be decoded
|
|
23
|
+
def self.verify(token, jwks_url:)
|
|
24
|
+
jwks = fetch_jwks(jwks_url)
|
|
25
|
+
decoded = JWT.decode(token, nil, true, algorithms: [ALGORITHM], jwks: jwks)
|
|
26
|
+
decoded.first
|
|
27
|
+
rescue JWT::ExpiredSignature => e
|
|
28
|
+
raise ExpiredTokenError, e.message
|
|
29
|
+
rescue JWT::DecodeError => e
|
|
30
|
+
raise InvalidTokenError, e.message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.fetch_jwks(url)
|
|
34
|
+
response = Faraday.get(url)
|
|
35
|
+
jwks_data = JSON.parse(response.body)
|
|
36
|
+
JWT::JWK::Set.new(jwks_data)
|
|
37
|
+
rescue JSON::ParserError, Faraday::Error => e
|
|
38
|
+
raise InvalidTokenError, "Failed to fetch JWKS: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method :fetch_jwks
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/hanko.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'hanko/version'
|
|
4
|
+
require_relative 'hanko/errors'
|
|
5
|
+
require_relative 'hanko/resource'
|
|
6
|
+
require_relative 'hanko/flow_response'
|
|
7
|
+
require_relative 'hanko/configuration'
|
|
8
|
+
require_relative 'hanko/middleware/raise_error'
|
|
9
|
+
require_relative 'hanko/connection'
|
|
10
|
+
require_relative 'hanko/api/base_resource'
|
|
11
|
+
require_relative 'hanko/api/admin'
|
|
12
|
+
require_relative 'hanko/api/public'
|
|
13
|
+
require_relative 'hanko/client'
|
|
14
|
+
require_relative 'hanko/webhook_verifier'
|
|
15
|
+
require_relative 'hanko/test_helper'
|
|
16
|
+
|
|
17
|
+
# Top-level module for the Hanko Ruby SDK.
|
|
18
|
+
#
|
|
19
|
+
# Provides authentication and user management via the Hanko API.
|
|
20
|
+
# Use {.configure} to set global defaults shared across all {Client} instances.
|
|
21
|
+
#
|
|
22
|
+
# @example Configure global defaults
|
|
23
|
+
# Hanko.configure do |c|
|
|
24
|
+
# c.api_url = "https://example.hanko.io"
|
|
25
|
+
# c.api_key = "your-api-key"
|
|
26
|
+
# c.timeout = 10
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Create a client using global configuration
|
|
30
|
+
# client = Hanko::Client.new
|
|
31
|
+
module Hanko
|
|
32
|
+
class << self
|
|
33
|
+
# Returns the global configuration instance.
|
|
34
|
+
#
|
|
35
|
+
# @return [Configuration] the current global configuration
|
|
36
|
+
def configuration
|
|
37
|
+
@configuration ||= Configuration.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Yields the global configuration for modification.
|
|
41
|
+
#
|
|
42
|
+
# @yield [config] the global {Configuration} instance
|
|
43
|
+
# @yieldparam config [Configuration]
|
|
44
|
+
# @return [void]
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# Hanko.configure do |c|
|
|
48
|
+
# c.api_url = "https://example.hanko.io"
|
|
49
|
+
# c.api_key = "your-api-key"
|
|
50
|
+
# end
|
|
51
|
+
def configure
|
|
52
|
+
yield(configuration)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resets the global configuration to defaults.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def reset_configuration!
|
|
59
|
+
@configuration = Configuration.new
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|