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.
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanko
4
+ VERSION = '0.1.1'
5
+ 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