didit-sdk 1.0.0

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.
Files changed (234) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/lib/didit/authentication/base64_url.rb +34 -0
  6. data/lib/didit/authentication/cache.rb +201 -0
  7. data/lib/didit/authentication/http.rb +150 -0
  8. data/lib/didit/authentication/issuer.rb +115 -0
  9. data/lib/didit/authentication/jws.rb +231 -0
  10. data/lib/didit/authentication/key_set.rb +89 -0
  11. data/lib/didit/authentication/pkce.rb +64 -0
  12. data/lib/didit/authentication/types.rb +175 -0
  13. data/lib/didit/authentication.rb +546 -0
  14. data/lib/didit/client.rb +211 -0
  15. data/lib/didit/errors.rb +153 -0
  16. data/lib/didit/file_part.rb +85 -0
  17. data/lib/didit/internal/config.rb +94 -0
  18. data/lib/didit/internal/errors.rb +83 -0
  19. data/lib/didit/internal/log.rb +41 -0
  20. data/lib/didit/internal/multipart.rb +60 -0
  21. data/lib/didit/internal/request.rb +322 -0
  22. data/lib/didit/internal/transport.rb +198 -0
  23. data/lib/didit/internal/util.rb +76 -0
  24. data/lib/didit/internal.rb +15 -0
  25. data/lib/didit/model.rb +199 -0
  26. data/lib/didit/page.rb +130 -0
  27. data/lib/didit/raw_response.rb +57 -0
  28. data/lib/didit/resource.rb +15 -0
  29. data/lib/didit/resources/account.rb +309 -0
  30. data/lib/didit/resources/address_validation.rb +73 -0
  31. data/lib/didit/resources/age_estimation.rb +110 -0
  32. data/lib/didit/resources/aml.rb +166 -0
  33. data/lib/didit/resources/aml_monitoring.rb +30 -0
  34. data/lib/didit/resources/analytics.rb +87 -0
  35. data/lib/didit/resources/bank.rb +65 -0
  36. data/lib/didit/resources/billing.rb +61 -0
  37. data/lib/didit/resources/biometric_templates.rb +234 -0
  38. data/lib/didit/resources/blocklist.rb +130 -0
  39. data/lib/didit/resources/business_sessions.rb +29 -0
  40. data/lib/didit/resources/businesses.rb +471 -0
  41. data/lib/didit/resources/cases.rb +2116 -0
  42. data/lib/didit/resources/customization.rb +153 -0
  43. data/lib/didit/resources/database_validation.rb +327 -0
  44. data/lib/didit/resources/document_ai.rb +145 -0
  45. data/lib/didit/resources/email.rb +254 -0
  46. data/lib/didit/resources/face_match.rb +103 -0
  47. data/lib/didit/resources/face_search.rb +103 -0
  48. data/lib/didit/resources/fraud_checks.rb +130 -0
  49. data/lib/didit/resources/id_verification.rb +163 -0
  50. data/lib/didit/resources/id_wallets.rb +1468 -0
  51. data/lib/didit/resources/investigator.rb +419 -0
  52. data/lib/didit/resources/ip.rb +68 -0
  53. data/lib/didit/resources/kyb.rb +256 -0
  54. data/lib/didit/resources/lists.rb +332 -0
  55. data/lib/didit/resources/monitoring_events.rb +466 -0
  56. data/lib/didit/resources/networks.rb +427 -0
  57. data/lib/didit/resources/passive_liveness.rb +103 -0
  58. data/lib/didit/resources/phone.rb +271 -0
  59. data/lib/didit/resources/pricing.rb +40 -0
  60. data/lib/didit/resources/proof_of_address.rb +167 -0
  61. data/lib/didit/resources/questionnaires.rb +170 -0
  62. data/lib/didit/resources/regulatory_reports.rb +314 -0
  63. data/lib/didit/resources/report_templates.rb +146 -0
  64. data/lib/didit/resources/sessions.rb +2820 -0
  65. data/lib/didit/resources/transactions.rb +981 -0
  66. data/lib/didit/resources/travel_rule.rb +1202 -0
  67. data/lib/didit/resources/users.rb +521 -0
  68. data/lib/didit/resources/wallet_screening.rb +190 -0
  69. data/lib/didit/resources/webhooks.rb +396 -0
  70. data/lib/didit/resources/workflows.rb +214 -0
  71. data/lib/didit/resources.rb +263 -0
  72. data/lib/didit/types/account.rb +11 -0
  73. data/lib/didit/types/account_components.rb +105 -0
  74. data/lib/didit/types/address_validation.rb +8 -0
  75. data/lib/didit/types/age_estimation.rb +89 -0
  76. data/lib/didit/types/aml.rb +193 -0
  77. data/lib/didit/types/aml_monitoring.rb +73 -0
  78. data/lib/didit/types/analytics.rb +62 -0
  79. data/lib/didit/types/bank.rb +8 -0
  80. data/lib/didit/types/billing.rb +18 -0
  81. data/lib/didit/types/biometric_templates.rb +292 -0
  82. data/lib/didit/types/blocklist.rb +31 -0
  83. data/lib/didit/types/businesses.rb +45 -0
  84. data/lib/didit/types/cases.rb +368 -0
  85. data/lib/didit/types/components.rb +12206 -0
  86. data/lib/didit/types/customization.rb +8 -0
  87. data/lib/didit/types/database_validation.rb +177 -0
  88. data/lib/didit/types/document_ai.rb +135 -0
  89. data/lib/didit/types/email.rb +116 -0
  90. data/lib/didit/types/face_match.rb +93 -0
  91. data/lib/didit/types/face_search.rb +129 -0
  92. data/lib/didit/types/fraud_checks.rb +8 -0
  93. data/lib/didit/types/id_verification.rb +231 -0
  94. data/lib/didit/types/id_wallets.rb +8 -0
  95. data/lib/didit/types/investigator.rb +221 -0
  96. data/lib/didit/types/ip.rb +8 -0
  97. data/lib/didit/types/kyb.rb +1391 -0
  98. data/lib/didit/types/lists.rb +111 -0
  99. data/lib/didit/types/monitoring_events.rb +235 -0
  100. data/lib/didit/types/networks.rb +120 -0
  101. data/lib/didit/types/passive_liveness.rb +95 -0
  102. data/lib/didit/types/phone.rb +69 -0
  103. data/lib/didit/types/proof_of_address.rb +166 -0
  104. data/lib/didit/types/questionnaires.rb +29 -0
  105. data/lib/didit/types/regulatory_reports.rb +100 -0
  106. data/lib/didit/types/report_templates.rb +41 -0
  107. data/lib/didit/types/sessions.rb +1441 -0
  108. data/lib/didit/types/transactions.rb +245 -0
  109. data/lib/didit/types/travel_rule.rb +168 -0
  110. data/lib/didit/types/users.rb +180 -0
  111. data/lib/didit/types/wallet_screening.rb +49 -0
  112. data/lib/didit/types/webhooks.rb +355 -0
  113. data/lib/didit/types/workflows.rb +32 -0
  114. data/lib/didit/types.rb +1681 -0
  115. data/lib/didit/version.rb +5 -0
  116. data/lib/didit/webhooks/canonical_json.rb +187 -0
  117. data/lib/didit/webhooks/client_methods.rb +23 -0
  118. data/lib/didit/webhooks/events.rb +221 -0
  119. data/lib/didit/webhooks/headers.rb +83 -0
  120. data/lib/didit/webhooks/verifier.rb +260 -0
  121. data/lib/didit/webhooks.rb +99 -0
  122. data/lib/didit-sdk.rb +4 -0
  123. data/lib/didit.rb +31 -0
  124. data/sig/didit/authentication/base64_url.rbs +10 -0
  125. data/sig/didit/authentication/cache.rbs +60 -0
  126. data/sig/didit/authentication/http.rbs +37 -0
  127. data/sig/didit/authentication/issuer.rbs +17 -0
  128. data/sig/didit/authentication/jws.rbs +41 -0
  129. data/sig/didit/authentication/jwt.rbs +14 -0
  130. data/sig/didit/authentication/key_set.rbs +25 -0
  131. data/sig/didit/authentication/pkce.rbs +20 -0
  132. data/sig/didit/authentication/types.rbs +114 -0
  133. data/sig/didit/authentication.rbs +81 -0
  134. data/sig/didit/client.rbs +61 -0
  135. data/sig/didit/errors.rbs +80 -0
  136. data/sig/didit/file_part.rbs +19 -0
  137. data/sig/didit/internal.rbs +199 -0
  138. data/sig/didit/model.rbs +42 -0
  139. data/sig/didit/page.rbs +34 -0
  140. data/sig/didit/raw_response.rbs +20 -0
  141. data/sig/didit/resource.rbs +8 -0
  142. data/sig/didit/resources/account.rbs +23 -0
  143. data/sig/didit/resources/address_validation.rbs +9 -0
  144. data/sig/didit/resources/age_estimation.rbs +9 -0
  145. data/sig/didit/resources/aml.rbs +10 -0
  146. data/sig/didit/resources/aml_monitoring.rbs +9 -0
  147. data/sig/didit/resources/analytics.rbs +9 -0
  148. data/sig/didit/resources/bank.rbs +9 -0
  149. data/sig/didit/resources/billing.rbs +10 -0
  150. data/sig/didit/resources/biometric_templates.rbs +13 -0
  151. data/sig/didit/resources/blocklist.rbs +11 -0
  152. data/sig/didit/resources/business_sessions.rbs +9 -0
  153. data/sig/didit/resources/businesses.rbs +22 -0
  154. data/sig/didit/resources/cases.rbs +103 -0
  155. data/sig/didit/resources/customization.rbs +10 -0
  156. data/sig/didit/resources/database_validation.rbs +9 -0
  157. data/sig/didit/resources/document_ai.rbs +9 -0
  158. data/sig/didit/resources/email.rbs +11 -0
  159. data/sig/didit/resources/face_match.rbs +9 -0
  160. data/sig/didit/resources/face_search.rbs +9 -0
  161. data/sig/didit/resources/fraud_checks.rbs +11 -0
  162. data/sig/didit/resources/id_verification.rbs +9 -0
  163. data/sig/didit/resources/id_wallets.rbs +37 -0
  164. data/sig/didit/resources/investigator.rbs +23 -0
  165. data/sig/didit/resources/ip.rbs +9 -0
  166. data/sig/didit/resources/kyb.rbs +12 -0
  167. data/sig/didit/resources/lists.rbs +20 -0
  168. data/sig/didit/resources/monitoring_events.rbs +16 -0
  169. data/sig/didit/resources/networks.rbs +25 -0
  170. data/sig/didit/resources/passive_liveness.rbs +9 -0
  171. data/sig/didit/resources/phone.rbs +11 -0
  172. data/sig/didit/resources/pricing.rbs +9 -0
  173. data/sig/didit/resources/proof_of_address.rbs +9 -0
  174. data/sig/didit/resources/questionnaires.rbs +13 -0
  175. data/sig/didit/resources/regulatory_reports.rbs +16 -0
  176. data/sig/didit/resources/report_templates.rbs +13 -0
  177. data/sig/didit/resources/sessions.rbs +78 -0
  178. data/sig/didit/resources/transactions.rbs +43 -0
  179. data/sig/didit/resources/travel_rule.rbs +62 -0
  180. data/sig/didit/resources/users.rbs +21 -0
  181. data/sig/didit/resources/wallet_screening.rbs +11 -0
  182. data/sig/didit/resources/webhooks.rbs +23 -0
  183. data/sig/didit/resources/workflows.rbs +13 -0
  184. data/sig/didit/resources.rbs +50 -0
  185. data/sig/didit/types/account.rbs +22 -0
  186. data/sig/didit/types/account_components.rbs +94 -0
  187. data/sig/didit/types/address_validation.rbs +7 -0
  188. data/sig/didit/types/age_estimation.rbs +80 -0
  189. data/sig/didit/types/aml.rbs +174 -0
  190. data/sig/didit/types/aml_monitoring.rbs +67 -0
  191. data/sig/didit/types/analytics.rbs +57 -0
  192. data/sig/didit/types/bank.rbs +9 -0
  193. data/sig/didit/types/billing.rbs +19 -0
  194. data/sig/didit/types/biometric_templates.rbs +268 -0
  195. data/sig/didit/types/blocklist.rbs +33 -0
  196. data/sig/didit/types/businesses.rbs +54 -0
  197. data/sig/didit/types/cases.rbs +414 -0
  198. data/sig/didit/types/components.rbs +10920 -0
  199. data/sig/didit/types/customization.rbs +7 -0
  200. data/sig/didit/types/database_validation.rbs +152 -0
  201. data/sig/didit/types/document_ai.rbs +123 -0
  202. data/sig/didit/types/email.rbs +112 -0
  203. data/sig/didit/types/face_match.rbs +86 -0
  204. data/sig/didit/types/face_search.rbs +112 -0
  205. data/sig/didit/types/fraud_checks.rbs +9 -0
  206. data/sig/didit/types/id_verification.rbs +196 -0
  207. data/sig/didit/types/id_wallets.rbs +55 -0
  208. data/sig/didit/types/investigator.rbs +212 -0
  209. data/sig/didit/types/ip.rbs +7 -0
  210. data/sig/didit/types/kyb.rbs +1254 -0
  211. data/sig/didit/types/lists.rbs +106 -0
  212. data/sig/didit/types/monitoring_events.rbs +234 -0
  213. data/sig/didit/types/networks.rbs +113 -0
  214. data/sig/didit/types/passive_liveness.rbs +87 -0
  215. data/sig/didit/types/phone.rbs +65 -0
  216. data/sig/didit/types/proof_of_address.rbs +144 -0
  217. data/sig/didit/types/questionnaires.rbs +30 -0
  218. data/sig/didit/types/regulatory_reports.rbs +101 -0
  219. data/sig/didit/types/report_templates.rbs +42 -0
  220. data/sig/didit/types/sessions.rbs +1349 -0
  221. data/sig/didit/types/transactions.rbs +273 -0
  222. data/sig/didit/types/travel_rule.rbs +188 -0
  223. data/sig/didit/types/users.rbs +181 -0
  224. data/sig/didit/types/wallet_screening.rbs +40 -0
  225. data/sig/didit/types/webhooks.rbs +311 -0
  226. data/sig/didit/types/workflows.rbs +30 -0
  227. data/sig/didit/webhooks/canonical_json.rbs +25 -0
  228. data/sig/didit/webhooks/client_methods.rbs +11 -0
  229. data/sig/didit/webhooks/events.rbs +125 -0
  230. data/sig/didit/webhooks/headers.rbs +22 -0
  231. data/sig/didit/webhooks/verifier.rbs +54 -0
  232. data/sig/didit/webhooks.rbs +37 -0
  233. data/sig/didit.rbs +20 -0
  234. metadata +300 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Didit
7
+ class Authentication
8
+ # Requests to the issuer over the SDK's transport ({Didit::Internal::Transport}):
9
+ # never retried, redirects never followed, the API key never sent.
10
+ # @api private
11
+ class Http
12
+ # One request to the issuer.
13
+ class Call
14
+ # @return [String] "GET", "POST" or "DELETE"
15
+ attr_reader :http_method
16
+ # @return [URI::Generic]
17
+ attr_reader :uri
18
+ # @return [String, nil] sent as +Authorization: Bearer+
19
+ attr_reader :bearer
20
+ # @return [Hash{String => String}, nil] form-encoded body
21
+ attr_reader :form
22
+ # @return [Hash{String => Object}, nil] JSON body
23
+ attr_reader :json
24
+ # @return [Array<String>] tokens, codes and verifiers redacted from errors
25
+ attr_reader :secrets
26
+
27
+ def initialize(http_method, uri, bearer: nil, form: nil, json: nil, secrets: [])
28
+ @http_method = http_method
29
+ @uri = uri
30
+ @bearer = bearer
31
+ @form = form
32
+ @json = json
33
+ @secrets = secrets
34
+ freeze
35
+ end
36
+
37
+ # The URL without query string or credentials, for errors.
38
+ def display_url
39
+ Internal::Util.display_url(uri)
40
+ end
41
+
42
+ # "GET /session/", for messages.
43
+ def describe
44
+ "#{http_method} #{uri.path}"
45
+ end
46
+ end
47
+
48
+ VERBS = { "GET" => :get, "POST" => :post, "DELETE" => :delete }.freeze
49
+
50
+ # @param timeout [Numeric] seconds for opening the connection, and for each read and write
51
+ # @param http_client [#call, nil] returns a configured, not yet started Net::HTTP for a URI
52
+ def initialize(timeout:, http_client:)
53
+ @timeout = timeout.to_f
54
+ @transport = Internal::Transport.new(http_client: http_client)
55
+ freeze
56
+ end
57
+
58
+ # Sends +call+ once and returns the response, whatever its status.
59
+ #
60
+ # @param call [Call]
61
+ # @return [Didit::Internal::Transport::Response]
62
+ # @raise [Didit::APIConnectionError, Didit::APITimeoutError] when no response arrives
63
+ def perform(call)
64
+ @transport.execute(VERBS.fetch(call.http_method), call.uri, headers_for(call), body_for(call), @timeout)
65
+ rescue Internal::Transport::Failure => e
66
+ error_class = e.timeout? ? APITimeoutError : APIConnectionError
67
+ raise error_class.new(http_method: call.http_method, url: call.display_url)
68
+ end
69
+
70
+ # Closes the idle connections.
71
+ #
72
+ # @return [nil]
73
+ def close
74
+ @transport.close
75
+ end
76
+
77
+ # The parsed JSON body of a 2xx response, nil when it has none.
78
+ #
79
+ # @return [Object, nil]
80
+ # @raise [Didit::APIStatusError] for any other status, with the call's secrets redacted
81
+ # @raise [Didit::APIResponseValidationError] when the body is not JSON
82
+ def read(call, response)
83
+ body = response.body.to_s
84
+ unless (200..299).cover?(response.status)
85
+ raise Internal::Errors.status_error(
86
+ status: response.status, headers: response.headers, raw_body: body, redact: call.secrets
87
+ )
88
+ end
89
+ return nil if response.status == 204 || body.empty?
90
+
91
+ begin
92
+ JSON.parse(text_of(body))
93
+ rescue JSON::ParserError
94
+ content_type = response.headers.fetch("content-type", "no content type")
95
+ raise validation_error(call, response, "Expected a JSON response from #{call.describe}, got #{content_type}.")
96
+ end
97
+ end
98
+
99
+ # The JSON object of a 2xx response.
100
+ #
101
+ # @return [Hash]
102
+ def read_object(call, response)
103
+ data = read(call, response)
104
+ raise validation_error(call, response, "Expected a JSON object from #{call.describe}.") unless data.is_a?(Hash)
105
+
106
+ data
107
+ end
108
+
109
+ # @return [Didit::APIResponseValidationError]
110
+ def validation_error(call, response, message)
111
+ body = response.body.to_s
112
+ APIResponseValidationError.new(
113
+ message, status: response.status, headers: response.headers,
114
+ body: body.empty? ? nil : Internal::Util.redact(text_of(body), call.secrets),
115
+ request_id: Internal::Util.request_id(response.headers)
116
+ )
117
+ end
118
+
119
+ private
120
+
121
+ def headers_for(call)
122
+ headers = {
123
+ "Accept" => "application/json",
124
+ "User-Agent" => Internal::Util.user_agent,
125
+ "X-Didit-Client" => Internal::Util.client_header
126
+ }
127
+ headers["Authorization"] = "Bearer #{call.bearer}" if call.bearer
128
+ if call.form
129
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
130
+ elsif call.json
131
+ headers["Content-Type"] = "application/json"
132
+ end
133
+ headers
134
+ end
135
+
136
+ def body_for(call)
137
+ form = call.form
138
+ json = call.json
139
+ return URI.encode_www_form(form) if form
140
+
141
+ JSON.generate(json) if json
142
+ end
143
+
144
+ def text_of(body)
145
+ text = body.dup.force_encoding(Encoding::UTF_8)
146
+ text.valid_encoding? ? text : text.scrub
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Didit
6
+ class Authentication
7
+ # The issuer URL and the publishable key that carries it.
8
+ #
9
+ # An application's issuer is its canonical origin: +https://<slug>.auth.didit.me+
10
+ # (live), +https://<slug>.sandbox.auth.didit.me+ (sandbox) or its active custom
11
+ # domain, with no trailing slash. A publishable key is +pk_live_+ (live) or
12
+ # +pk_test_+ (sandbox) followed by the base64url encoding of the issuer, unpadded
13
+ # (correct "=" padding is accepted too).
14
+ # @api private
15
+ module Issuer
16
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
17
+ # Prefix => whether the key may name a plain http loopback issuer.
18
+ PREFIXES = { "pk_live_" => false, "pk_test_" => true }.freeze
19
+ UNSAFE = /[\x00-\x20\x7f?#]/
20
+
21
+ module_function
22
+
23
+ # The issuer without trailing slashes.
24
+ #
25
+ # It must be an absolute https URL with no user name, password, query or
26
+ # fragment. Plain http is accepted only for localhost, *.localhost,
27
+ # 127.0.0.1 and ::1 (a local test stack) when +allow_loopback_http+ is true.
28
+ #
29
+ # @param issuer [String]
30
+ # @return [String]
31
+ # @raise [Didit::ConfigurationError]
32
+ def normalize(issuer, allow_loopback_http: true)
33
+ text = issuer.is_a?(String) ? issuer.strip : ""
34
+ if text.empty?
35
+ raise ConfigurationError,
36
+ "The issuer is empty: pass your application's issuer (https://<slug>.auth.didit.me or your " \
37
+ "custom domain) or use Didit::Authentication.from_publishable_key."
38
+ end
39
+ if PREFIXES.key?(text[0, 8])
40
+ raise ConfigurationError, "That is a publishable key, not an issuer: use Didit::Authentication.from_publishable_key."
41
+ end
42
+
43
+ uri = parse(text)
44
+ scheme = uri.scheme.to_s.downcase
45
+ secure = scheme == "https"
46
+ local = allow_loopback_http && scheme == "http" && loopback?(uri.hostname.to_s)
47
+ unless secure || local
48
+ raise ConfigurationError,
49
+ "The issuer must use https (http only for localhost while testing), got #{text.inspect}."
50
+ end
51
+
52
+ text.sub(%r{/+\z}, "")
53
+ end
54
+
55
+ # The issuer a +pk_live_+ or +pk_test_+ publishable key carries. A live key
56
+ # must name an https issuer; a test (sandbox) key may also name http on a
57
+ # loopback host, as the console allows for sandbox applications.
58
+ #
59
+ # @param publishable_key [String]
60
+ # @return [String]
61
+ # @raise [Didit::ConfigurationError]
62
+ def from_publishable_key(publishable_key)
63
+ key = publishable_key.is_a?(String) ? publishable_key.strip : ""
64
+ prefix = key[0, 8].to_s
65
+ unless PREFIXES.key?(prefix)
66
+ raise ConfigurationError,
67
+ "A publishable key starts with pk_live_ or pk_test_; copy it from the Developers section of " \
68
+ "Authentication in the Didit console."
69
+ end
70
+
71
+ normalize(decode_issuer(prefix, key[8..].to_s), allow_loopback_http: PREFIXES.fetch(prefix))
72
+ end
73
+
74
+ # Whether +host+ (without brackets) is a loopback name or address.
75
+ def loopback?(host)
76
+ host = host.downcase
77
+ LOOPBACK_HOSTS.include?(host) || host.end_with?(".localhost")
78
+ end
79
+
80
+ def decode_issuer(prefix, encoded)
81
+ raw = encoded.empty? ? nil : Base64Url.decode(unpad(encoded))
82
+ issuer = raw&.force_encoding(Encoding::UTF_8)
83
+ return issuer if issuer&.valid_encoding?
84
+
85
+ raise ConfigurationError,
86
+ "The publishable key is not valid: expected #{prefix} followed by the base64url-encoded issuer."
87
+ end
88
+
89
+ # +text+ without its "=" padding when that padding is exactly right (Python's
90
+ # urlsafe_b64encode pads), else unchanged, so that the strict decoder refuses it.
91
+ def unpad(text)
92
+ unpadded = text.sub(/={1,2}\z/, "")
93
+ exact = (text.length % 4).zero? && text.length - unpadded.length == -unpadded.length % 4
94
+ exact ? unpadded : text
95
+ end
96
+
97
+ def parse(text)
98
+ uri = text.match?(UNSAFE) ? nil : URI.parse(text)
99
+ if uri.is_a?(URI::HTTP) && uri.userinfo
100
+ raise ConfigurationError, "The issuer URL must not contain a user name or password."
101
+ end
102
+ return uri if uri.is_a?(URI::HTTP) && !uri.hostname.to_s.empty? && (1..65_535).cover?(uri.port)
103
+
104
+ invalid(text)
105
+ rescue URI::Error
106
+ invalid(text)
107
+ end
108
+
109
+ def invalid(text)
110
+ raise ConfigurationError,
111
+ "The issuer must be an absolute https URL with no query or fragment, got #{text.inspect}."
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jwt"
5
+ require "openssl"
6
+
7
+ module Didit
8
+ class Authentication
9
+ # JWS parsing and claim checks for Didit Authentication tokens (no I/O here).
10
+ #
11
+ # Didit issuers sign with ES256 only: access tokens carry the header
12
+ # +typ: at+jwt+ and ID tokens +typ: JWT+. Every other algorithm, +none+
13
+ # included, is refused before any key is looked up.
14
+ # @api private
15
+ module Jws
16
+ ALGORITHM = "ES256"
17
+ MAX_TOKEN_LENGTH = 16_384
18
+ ACCESS_TYPES = %w[at+jwt application/at+jwt].freeze
19
+ ID_TYPES = %w[jwt application/jwt].freeze
20
+ ZERO = ("\x00" * 32).b.freeze
21
+ JSON_OPTIONS = { allow_nan: false, max_nesting: 64 }.merge(
22
+ # json 2.13 and newer can refuse duplicate member names; older versions keep the last one.
23
+ Gem::Version.new(JSON::VERSION) >= Gem::Version.new("2.13.0") ? { allow_duplicate_key: false } : {}
24
+ ).freeze
25
+ # Every claim is checked here; the jwt gem only checks the signature, whatever
26
+ # its global configuration says.
27
+ DECODE_OPTIONS = {
28
+ algorithm: ALGORITHM, verify_expiration: false, verify_not_before: false, verify_iat: false,
29
+ verify_iss: false, verify_aud: false, verify_jti: false, verify_sub: false, required_claims: nil
30
+ }.freeze
31
+
32
+ # A compact JWS whose header passed the algorithm, type and key id checks; not verified yet.
33
+ class Parsed
34
+ attr_reader :token, :header, :claims, :signature, :kid
35
+
36
+ def initialize(token:, header:, claims:, signature:, kid:)
37
+ @token = token
38
+ @header = header
39
+ @claims = claims
40
+ @signature = signature
41
+ @kid = kid
42
+ freeze
43
+ end
44
+ end
45
+
46
+ module_function
47
+
48
+ # Splits and decodes a compact JWS and checks its header (+alg+, +crit+,
49
+ # +typ+, +kid+). Nothing in it is trusted until the signature is checked.
50
+ #
51
+ # @param token [Object]
52
+ # @param kind [Symbol] +:access+ or +:id+
53
+ # @return [Parsed]
54
+ # @raise [Didit::TokenVerificationError]
55
+ def parse(token, kind)
56
+ fail!(:malformed, "The token is empty or not a string.") unless token.is_a?(String) && !token.empty?
57
+ if token.bytesize > MAX_TOKEN_LENGTH
58
+ fail!(:malformed, "The token is longer than #{MAX_TOKEN_LENGTH} characters.")
59
+ end
60
+
61
+ parts = token.b.split(".", -1)
62
+ fail!(:malformed, "The token is not a compact JWS (three dot-separated segments).") unless parts.length == 3
63
+
64
+ header_segment, payload_segment, signature_segment = parts
65
+ header = json_object(header_segment.to_s, "header")
66
+ check_header(header, kind)
67
+ claims = json_object(payload_segment.to_s, "payload")
68
+ signature = Base64Url.decode(signature_segment.to_s)
69
+ fail!(:malformed, "The token signature is not base64url.") if signature.nil?
70
+
71
+ Parsed.new(token: parts.join("."), header: header, claims: claims, signature: signature, kid: header["kid"])
72
+ end
73
+
74
+ # Checks the ES256 signature with +key+, through the jwt gem.
75
+ #
76
+ # @param parsed [Parsed]
77
+ # @param key [OpenSSL::PKey::EC]
78
+ # @raise [Didit::TokenVerificationError]
79
+ def verify_signature(parsed, key)
80
+ signature = parsed.signature
81
+ # The JWS form only: the 32-byte R then the 32-byte S, neither zero. A DER signature is refused.
82
+ unless signature.bytesize == 64 && signature.byteslice(0, 32) != ZERO && signature.byteslice(32, 32) != ZERO
83
+ fail!(:invalid_signature, "The token's signature is not a 64-byte ES256 signature (R || S).")
84
+ end
85
+
86
+ JWT.decode(parsed.token, key, true, DECODE_OPTIONS)
87
+ nil
88
+ rescue JWT::IncorrectAlgorithm
89
+ fail!(:unsupported_algorithm, "The token is not signed with ES256.")
90
+ rescue JWT::VerificationError, OpenSSL::PKey::PKeyError
91
+ fail!(:invalid_signature, "The token's signature does not verify with the issuer's key.")
92
+ rescue JWT::DecodeError, JSON::ParserError
93
+ fail!(:malformed, "The token could not be decoded.")
94
+ end
95
+
96
+ # Checks the registered claims of a token whose signature is verified.
97
+ #
98
+ # +iss+ must equal +issuer+; +sub+, +exp+ and +iat+ must be present; +exp+,
99
+ # +nbf+ and +iat+ are compared with +now+ allowing +leeway+ seconds of clock
100
+ # skew; +aud+ must contain one of +audience+ when it is given; an ID token's
101
+ # +nonce+ must equal +nonce+, or be absent when +nonce+ is nil.
102
+ #
103
+ # @param claims [Hash]
104
+ # @param kind [Symbol] +:access+ or +:id+
105
+ # @param issuer [String]
106
+ # @param audience [Array<String>, nil]
107
+ # @param nonce [String, nil]
108
+ # @param leeway [Float]
109
+ # @param now [Float]
110
+ # @raise [Didit::TokenVerificationError]
111
+ def check_claims(claims, kind:, issuer:, audience:, nonce:, leeway:, now:)
112
+ check_issuer(claims, issuer)
113
+ sub = claims["sub"]
114
+ fail!(:missing_claim, "The token has no sub claim.") unless sub.is_a?(String) && !sub.empty?
115
+
116
+ check_times(claims, leeway, now)
117
+ check_audience(claims, audience) if audience
118
+ check_nonce(claims, nonce) if kind == :id
119
+ nil
120
+ end
121
+
122
+ # A short, safe rendering of a header or claim value for an error message.
123
+ def shown(value)
124
+ text = value.inspect
125
+ text.length <= 64 ? text : "#{text[0, 61]}..."
126
+ end
127
+
128
+ def fail!(reason, message)
129
+ raise TokenVerificationError.new(reason, message)
130
+ end
131
+
132
+ def json_object(segment, part)
133
+ raw = Base64Url.decode(segment)
134
+ fail!(:malformed, "The token #{part} is not base64url.") if raw.nil?
135
+
136
+ text = raw.force_encoding(Encoding::UTF_8)
137
+ fail!(:malformed, "The token #{part} is not UTF-8.") unless text.valid_encoding?
138
+
139
+ value = JSON.parse(text, **JSON_OPTIONS)
140
+ fail!(:malformed, "The token #{part} is not a JSON object.") unless value.is_a?(Hash)
141
+
142
+ value
143
+ rescue JSON::ParserError, EncodingError
144
+ fail!(:malformed, "The token #{part} is not valid JSON.")
145
+ end
146
+
147
+ def check_header(header, kind)
148
+ alg = header["alg"]
149
+ if alg != ALGORITHM
150
+ fail!(:unsupported_algorithm, "The token is signed with #{shown(alg)}; only ES256 is accepted.")
151
+ end
152
+ if header.key?("crit")
153
+ fail!(:malformed, "The token header has critical extensions (crit), which are not supported.")
154
+ end
155
+ # RFC 7797 unencoded payloads: the jwt gem's versions disagree on b64 without crit.
156
+ fail!(:malformed, "The token header has b64, which is not supported.") if header.key?("b64")
157
+
158
+ check_type(header["typ"], kind)
159
+ kid = header["kid"]
160
+ fail!(:malformed, "The token header names no signing key (kid).") unless kid.is_a?(String) && !kid.empty?
161
+ end
162
+
163
+ def check_type(typ, kind)
164
+ if kind == :access
165
+ return if typ.is_a?(String) && ACCESS_TYPES.include?(typ.downcase)
166
+
167
+ fail!(:invalid_type, "The token is not an access token: its header typ is #{shown(typ)}, not \"at+jwt\".")
168
+ end
169
+ # ID tokens carry typ JWT; refusing at+jwt keeps an access token from passing as one.
170
+ return if typ.nil? || (typ.is_a?(String) && ID_TYPES.include?(typ.downcase))
171
+
172
+ fail!(:invalid_type, "The token is not an ID token: its header typ is #{shown(typ)}, not \"JWT\".")
173
+ end
174
+
175
+ def check_issuer(claims, issuer)
176
+ iss = claims["iss"]
177
+ fail!(:missing_claim, "The token has no iss claim.") if iss.nil?
178
+ fail!(:invalid_issuer, "The token was issued by #{shown(iss)}, not by #{issuer.inspect}.") if iss != issuer
179
+ end
180
+
181
+ def check_times(claims, leeway, now)
182
+ exp = numeric_date(claims, "exp")
183
+ iat = numeric_date(claims, "iat")
184
+ nbf = claims.key?("nbf") ? numeric_date(claims, "nbf") : nil
185
+ fail!(:expired, "The token has expired.") if now >= exp + leeway
186
+ fail!(:not_yet_valid, "The token is not valid yet (nbf is in the future).") if nbf && now + leeway < nbf
187
+ fail!(:invalid_issued_at, "The token was issued in the future (iat).") if now + leeway < iat
188
+ end
189
+
190
+ def numeric_date(claims, name)
191
+ fail!(:missing_claim, "The token has no #{name} claim.") unless claims.key?(name)
192
+
193
+ value = claims[name]
194
+ seconds = case value
195
+ when Float then value
196
+ # An integer beyond any Float is as unusable as NaN.
197
+ when Integer then value.abs < Float::MAX ? value.to_f : Float::NAN
198
+ else Float::NAN
199
+ end
200
+ fail!(:malformed, "The token's #{name} claim is not a number of seconds.") unless seconds.finite?
201
+
202
+ seconds
203
+ end
204
+
205
+ def check_audience(claims, audience)
206
+ fail!(:missing_claim, "The token has no aud claim.") unless claims.key?("aud")
207
+
208
+ aud = claims["aud"]
209
+ # @type var values: Array[String]
210
+ values = case aud
211
+ when String then [aud]
212
+ when Array then aud.grep(String)
213
+ else []
214
+ end
215
+ fail!(:invalid_audience, "The token was not issued for this audience.") unless values.intersect?(audience)
216
+ end
217
+
218
+ def check_nonce(claims, nonce)
219
+ token_nonce = claims["nonce"]
220
+ if nonce.nil?
221
+ return if token_nonce.nil?
222
+
223
+ fail!(:invalid_nonce, "The ID token carries a nonce: pass the nonce this sign-in started with.")
224
+ end
225
+ return if token_nonce.is_a?(String) && OpenSSL.secure_compare(token_nonce, nonce)
226
+
227
+ fail!(:invalid_nonce, "The ID token's nonce does not match this sign-in.")
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Didit
6
+ class Authentication
7
+ # The issuer's published ES256 keys by +kid+.
8
+ # @api private
9
+ class KeySet
10
+ # DER SubjectPublicKeyInfo header (id-ecPublicKey, prime256v1) of an uncompressed P-256 point.
11
+ SPKI_PREFIX = ["3059301306072a8648ce3d020106082a8648ce3d030107034200"].pack("H*").freeze
12
+ UNCOMPRESSED = "\x04".b.freeze
13
+
14
+ class << self
15
+ # The key set, or nil when +data+ is not a JWKS (+{"keys": [...]}+).
16
+ #
17
+ # Only EC P-256 keys with +use+ "sig" (or none) and +alg+ "ES256" (or none)
18
+ # and a non-empty string +kid+ are kept; the first usable key per kid wins.
19
+ #
20
+ # @param data [Object] the parsed JWKS
21
+ # @return [KeySet, nil]
22
+ def parse(data)
23
+ return nil unless data.is_a?(Hash) && data["keys"].is_a?(Array)
24
+
25
+ keys = {} # : Hash[String, OpenSSL::PKey::EC]
26
+ data["keys"].each do |jwk|
27
+ next unless jwk.is_a?(Hash)
28
+
29
+ kid = jwk["kid"]
30
+ next unless kid.is_a?(String) && !kid.empty? && !keys.key?(kid)
31
+
32
+ key = p256_key(jwk)
33
+ keys[kid] = key if key
34
+ end
35
+ new(keys, data)
36
+ end
37
+
38
+ # The public key of a P-256 signing JWK, or nil for any other or invalid key.
39
+ #
40
+ # @param jwk [Hash]
41
+ # @return [OpenSSL::PKey::EC, nil]
42
+ def p256_key(jwk)
43
+ return nil unless jwk["kty"] == "EC" && jwk["crv"] == "P-256"
44
+ return nil unless jwk.fetch("use", "sig") == "sig" && jwk.fetch("alg", Jws::ALGORITHM) == Jws::ALGORITHM
45
+
46
+ x = coordinate(jwk["x"])
47
+ y = coordinate(jwk["y"])
48
+ return nil unless x && y
49
+
50
+ # Reading the point as a SubjectPublicKeyInfo checks that it is on the curve.
51
+ key = OpenSSL::PKey.read(SPKI_PREFIX + UNCOMPRESSED + x + y)
52
+ key if key.is_a?(OpenSSL::PKey::EC) && key.group&.curve_name == "prime256v1"
53
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ # A P-256 coordinate: unpadded base64url of exactly 32 bytes (RFC 7518 section 6.2.1.2).
60
+ def coordinate(value)
61
+ raw = value.is_a?(String) ? Base64Url.decode(value) : nil
62
+ raw if raw && raw.bytesize == 32
63
+ end
64
+ end
65
+
66
+ # @return [Hash] the JWKS as the issuer sent it (frozen)
67
+ attr_reader :document
68
+
69
+ # @param keys [Hash{String => OpenSSL::PKey::EC}]
70
+ # @param document [Hash]
71
+ def initialize(keys, document)
72
+ @keys = keys.freeze
73
+ @document = IssuerCache.deep_freeze(document)
74
+ freeze
75
+ end
76
+
77
+ # @param kid [String]
78
+ # @return [OpenSSL::PKey::EC, nil]
79
+ def [](kid)
80
+ @keys[kid]
81
+ end
82
+
83
+ # @param kid [String]
84
+ def key?(kid)
85
+ @keys.key?(kid)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module Didit
7
+ class Authentication
8
+ # A PKCE verifier and its S256 challenge (RFC 7636).
9
+ #
10
+ # Send {#code_challenge} and {#code_challenge_method} with the authorization
11
+ # request, keep {#code_verifier} on your server (it is left out of {#inspect})
12
+ # and pass it to {Authentication#exchange_code}.
13
+ class Pkce
14
+ METHOD = "S256"
15
+ VERIFIER = /\A[A-Za-z0-9\-._~]{43,128}\z/
16
+
17
+ # @return [String] keep it server-side; never send it to the browser
18
+ attr_reader :code_verifier
19
+ # @return [String] BASE64URL(SHA256(code_verifier))
20
+ attr_reader :code_challenge
21
+ # @return [String] always "S256"
22
+ attr_reader :code_challenge_method
23
+
24
+ # A new random verifier (32 random bytes, 43 characters) and its challenge.
25
+ #
26
+ # @return [Pkce]
27
+ def self.generate
28
+ new(SecureRandom.urlsafe_base64(32))
29
+ end
30
+
31
+ # +BASE64URL(SHA256(ASCII(verifier)))+ without padding (RFC 7636 section 4.2).
32
+ #
33
+ # @param verifier [String]
34
+ # @return [String]
35
+ def self.s256_challenge(verifier)
36
+ Base64Url.encode(Digest::SHA256.digest(verifier))
37
+ end
38
+
39
+ # @param code_verifier [String] 43 to 128 characters of A-Z, a-z, 0-9, "-", ".", "_" and "~"
40
+ # @raise [Didit::ConfigurationError] when the verifier does not fit RFC 7636 section 4.1
41
+ def initialize(code_verifier)
42
+ unless code_verifier.is_a?(String) && VERIFIER.match?(code_verifier)
43
+ raise ConfigurationError,
44
+ "A PKCE code_verifier is 43 to 128 characters of A-Z, a-z, 0-9, '-', '.', '_' and '~'."
45
+ end
46
+
47
+ @code_verifier = code_verifier.dup.freeze
48
+ @code_challenge = Pkce.s256_challenge(code_verifier).freeze
49
+ @code_challenge_method = METHOD
50
+ freeze
51
+ end
52
+
53
+ # @return [Hash{Symbol => String}]
54
+ def to_h
55
+ { code_verifier: code_verifier, code_challenge: code_challenge, code_challenge_method: code_challenge_method }
56
+ end
57
+
58
+ def inspect
59
+ "#<#{self.class.name} code_challenge=#{code_challenge.inspect} code_challenge_method=#{METHOD.inspect}>"
60
+ end
61
+ alias to_s inspect
62
+ end
63
+ end
64
+ end