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,546 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jwt"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "authentication/types"
9
+ require_relative "authentication/base64_url"
10
+ require_relative "authentication/pkce"
11
+ require_relative "authentication/issuer"
12
+ require_relative "authentication/jws"
13
+ require_relative "authentication/key_set"
14
+ require_relative "authentication/cache"
15
+ require_relative "authentication/http"
16
+
17
+ module Didit
18
+ # Didit Authentication for your backend (docs/DESIGN.md section 10): verify the
19
+ # tokens your application's issuer signs and call its token, session and
20
+ # step-up endpoints.
21
+ #
22
+ # auth = Didit::Authentication.from_publishable_key("pk_live_...", client_id: APPLICATION_ID)
23
+ # begin
24
+ # claims = auth.verify_access_token(access_token)
25
+ # rescue Didit::TokenVerificationError => e
26
+ # e.reason # machine readable, for example :expired
27
+ # end
28
+ #
29
+ # Each application has its own OpenID Connect issuer (+https://<slug>.auth.didit.me+
30
+ # or its custom domain). This class talks only to that issuer and never takes or
31
+ # sends your Didit API key. The discovery document and the issuer's keys are
32
+ # fetched on first use and cached; one instance per process is the intended use
33
+ # and it is thread safe. Requests are never retried.
34
+ class Authentication
35
+ DEFAULT_TIMEOUT = 10
36
+ DEFAULT_LEEWAY = 60
37
+ DISCOVERY_PATH = "/.well-known/openid-configuration"
38
+ JWKS_PATH = "/.well-known/jwks.json"
39
+ SESSION_PATH = "/session/"
40
+ STEP_UP_PATH = "/step-up/"
41
+ # The errors that mean the issuer could not give its discovery document or keys.
42
+ FETCH_ERRORS = [APIConnectionError, APIStatusError, APIResponseValidationError].freeze
43
+ UNRESERVED = /[^A-Za-z0-9\-._~]/n
44
+ DECIMAL = /\A-?[0-9]+(\.[0-9]+)?\z/
45
+ NUMBER = /\A(-?)([0-9]+)(?:\.([0-9]+))?(?:e([-+]?[0-9]+))?\z/i
46
+
47
+ class << self
48
+ # The client for the issuer a +pk_live_+ or +pk_test_+ publishable key carries.
49
+ #
50
+ # @param publishable_key [String]
51
+ # @return [Authentication]
52
+ # @raise [Didit::ConfigurationError] when the key is malformed
53
+ def from_publishable_key(publishable_key, client_id: nil, timeout: DEFAULT_TIMEOUT, leeway: DEFAULT_LEEWAY,
54
+ http_client: nil, clock: nil)
55
+ new(Issuer.from_publishable_key(publishable_key),
56
+ client_id: client_id, timeout: timeout, leeway: leeway, http_client: http_client, clock: clock)
57
+ end
58
+
59
+ # A new random PKCE verifier and its S256 challenge.
60
+ #
61
+ # @return [Pkce]
62
+ def create_pkce
63
+ Pkce.generate
64
+ end
65
+
66
+ # A new random +state+ (43 url-safe characters, 256 bits). Store it with the
67
+ # sign-in attempt and compare it with the callback's +state+ before
68
+ # exchanging the code.
69
+ #
70
+ # @return [String]
71
+ def create_state
72
+ SecureRandom.urlsafe_base64(32)
73
+ end
74
+
75
+ # A new random +nonce+ (43 url-safe characters, 256 bits). Send it with the
76
+ # authorization request and pass it to {#verify_id_token}.
77
+ #
78
+ # @return [String]
79
+ def create_nonce
80
+ SecureRandom.urlsafe_base64(32)
81
+ end
82
+ end
83
+
84
+ # @return [String] the configured issuer, without a trailing slash. Tokens must name the
85
+ # issuer in +iss+ exactly as its discovery document does, which may end in a slash.
86
+ attr_reader :issuer
87
+ # @return [String, nil] the application id: sent with token requests and the default audience
88
+ attr_reader :client_id
89
+ # @return [Float] seconds allowed to open a connection, and for each read and write
90
+ attr_reader :timeout
91
+ # @return [Float] seconds of clock skew allowed for +exp+, +nbf+ and +iat+
92
+ attr_reader :leeway
93
+
94
+ # @param issuer [String] the application's issuer: +https://<slug>.auth.didit.me+,
95
+ # +https://<slug>.sandbox.auth.didit.me+ or its active custom domain
96
+ # @param client_id [String, nil] the application id; when set it is sent with
97
+ # token requests (the issuer checks it) and is the default audience
98
+ # @param timeout [Numeric] seconds per connection open, read and write
99
+ # @param leeway [Numeric] seconds of clock skew allowed for +exp+, +nbf+ and +iat+
100
+ # @param http_client [#call, nil] called with a +URI+, returns a configured, not
101
+ # yet started +Net::HTTP+ for its host (proxies, CA bundle). The SDK sets its
102
+ # timeouts and never follows redirects.
103
+ # @param clock [#call, nil] for tests: returns the current unix time in seconds
104
+ # @raise [Didit::ConfigurationError]
105
+ def initialize(issuer, client_id: nil, timeout: DEFAULT_TIMEOUT, leeway: DEFAULT_LEEWAY, http_client: nil,
106
+ clock: nil)
107
+ @issuer = Issuer.normalize(issuer)
108
+ @client_id = checked_client_id(client_id)
109
+ @timeout = checked_timeout(timeout)
110
+ @leeway = checked_leeway(leeway)
111
+ [[http_client, "http_client"], [clock, "clock"]].each do |value, name|
112
+ raise ConfigurationError, "#{name} must respond to #call." unless value.nil? || value.respond_to?(:call)
113
+ end
114
+
115
+ @clock = clock || -> { Time.now.to_f }
116
+ @cache = IssuerCache.new(@clock)
117
+ @http = Http.new(timeout: @timeout, http_client: http_client)
118
+ @lock = Mutex.new
119
+ @token_issuer = nil
120
+ end
121
+
122
+ # Verifies an access token offline and returns its claims.
123
+ #
124
+ # Checks, in order: the header (+alg+ ES256 only, so +none+, HS*, RS*, PS*,
125
+ # EdDSA and every other algorithm are refused; +typ+ +at+jwt+), the ES256
126
+ # signature against the issuer's published key with the token's +kid+, then
127
+ # +iss+ (equal to the issuer), +sub+, +exp+, +nbf+ and +iat+ (with {#leeway}
128
+ # seconds of clock skew) and, when +audience+ is given or +client_id+ is
129
+ # configured, +aud+.
130
+ #
131
+ # An offline check cannot see a session revoked a moment ago: access tokens
132
+ # expire within five minutes. Call {#get_session} when the action needs an
133
+ # instant revocation check.
134
+ #
135
+ # @param token [String]
136
+ # @param audience [String, Array<String>, nil] accepted audiences; defaults to +client_id+
137
+ # @return [AccessTokenClaims]
138
+ # @raise [Didit::TokenVerificationError] the token is not valid; +reason+ says why
139
+ # @raise [Didit::APIConnectionError, Didit::APIStatusError, Didit::APIResponseValidationError]
140
+ # the issuer's keys could not be fetched
141
+ def verify_access_token(token, audience: nil)
142
+ expected = expected_audience(audience, required: false)
143
+ AccessTokenClaims.new(verify(token, :access, expected, nil))
144
+ end
145
+
146
+ # Verifies an ID token and returns its claims.
147
+ #
148
+ # The same checks as {#verify_access_token} (+typ+ must be +JWT+ or absent,
149
+ # never +at+jwt+), plus +aud+, which is always checked: pass +audience+ or
150
+ # configure +client_id+. +nonce+ must equal the +nonce+ you sent with the
151
+ # authorization request. Pass +nonce: nil+ for an ID token returned by
152
+ # {#refresh}, which carries none: then a token that carries a nonce is refused.
153
+ #
154
+ # @param token [String]
155
+ # @param nonce [String, nil]
156
+ # @param audience [String, Array<String>, nil] defaults to +client_id+
157
+ # @return [IdTokenClaims]
158
+ # @raise [Didit::TokenVerificationError]
159
+ # @raise [Didit::ConfigurationError] without an audience
160
+ def verify_id_token(token, nonce:, audience: nil)
161
+ expected = expected_audience(audience, required: true)
162
+ if !nonce.nil? && !(nonce.is_a?(String) && !nonce.empty?)
163
+ raise ConfigurationError, "nonce is empty: pass the nonce of this sign-in, or nil for an ID token from refresh."
164
+ end
165
+
166
+ IdTokenClaims.new(verify(token, :id, expected, nonce))
167
+ end
168
+
169
+ # Exchanges an authorization code for tokens (+POST token/+, form encoded).
170
+ #
171
+ # Check the callback's +state+ first. The code is single use and expires
172
+ # after 60 seconds; +code_verifier+ is the PKCE verifier of this sign-in and
173
+ # +redirect_uri+ must be exactly the one the sign-in used. Never retried.
174
+ #
175
+ # @return [TokenResponse]
176
+ # @raise [Didit::BadRequestError] +invalid_grant+ for a used, expired or unknown code
177
+ def exchange_code(code, code_verifier:, redirect_uri:)
178
+ code = required_text(code, "code")
179
+ code_verifier = required_text(code_verifier, "code_verifier")
180
+ form = {
181
+ "grant_type" => "authorization_code",
182
+ "code" => code,
183
+ "code_verifier" => code_verifier,
184
+ "redirect_uri" => required_text(redirect_uri, "redirect_uri")
185
+ }
186
+ token_request(form, [code, code_verifier])
187
+ end
188
+
189
+ # Swaps a refresh token for new tokens (+POST token/+, form encoded).
190
+ #
191
+ # Refresh tokens are single use: replace both tokens with the result.
192
+ # Serialise refreshes for one session (one at a time, across processes and
193
+ # browser tabs), because presenting a refresh token that was already used
194
+ # revokes the whole session. For the same reason this request is never
195
+ # retried; if its outcome is unknown, sign the person in again.
196
+ #
197
+ # @return [TokenResponse]
198
+ def refresh(refresh_token)
199
+ refresh_token = required_text(refresh_token, "refresh_token")
200
+ token_request({ "grant_type" => "refresh_token", "refresh_token" => refresh_token }, [refresh_token])
201
+ end
202
+
203
+ # The live session behind +access_token+ (+GET session/+).
204
+ #
205
+ # The instant revocation check: unlike {#verify_access_token} it fails
206
+ # (+Didit::AuthenticationError+, HTTP 401) as soon as the session is revoked,
207
+ # expired or idle, or the token was replaced by a refresh. Agent access
208
+ # tokens are refused here.
209
+ #
210
+ # @return [Session]
211
+ def get_session(access_token)
212
+ Session.new(send_object(bearer_call("GET", issuer_uri(SESSION_PATH), access_token)))
213
+ end
214
+
215
+ # Signs the person out: revokes the session behind +access_token+ (+DELETE session/+).
216
+ #
217
+ # @return [nil]
218
+ def revoke_session(access_token)
219
+ call = bearer_call("DELETE", issuer_uri(SESSION_PATH), access_token)
220
+ @http.read(call, @http.perform(call))
221
+ nil
222
+ end
223
+
224
+ # Asks whether the person must verify before a sensitive action (+POST step-up/+).
225
+ #
226
+ # +action+ is "payout" (with +amount+ and an optional three-letter
227
+ # +currency+), "bank" or "contact". +return_to+ is where the person comes back
228
+ # after verifying and must be one of the application's redirect URIs; the
229
+ # issuer currently requires it and answers HTTP 400 without it.
230
+ #
231
+ # +required == false+ means no rule applies and the action may go ahead.
232
+ # Otherwise send the person to +verification_url+; they come back to
233
+ # +return_to+ with +step_up=<id>&status=approved+, which proves nothing: call
234
+ # {#retrieve_step_up} and act only on status "approved".
235
+ #
236
+ # @param amount [String, Integer, Float, BigDecimal, nil] sent as a decimal string
237
+ # @return [StepUp]
238
+ def create_step_up(access_token, action:, amount: nil, currency: nil, return_to: nil)
239
+ body = { "action" => required_text(action, "action") }
240
+ body["amount"] = amount_text(amount) unless amount.nil?
241
+ body["currency"] = optional_text(currency, "currency") unless currency.nil?
242
+ body["return_to"] = optional_text(return_to, "return_to") unless return_to.nil?
243
+ StepUp.new(send_object(bearer_call("POST", issuer_uri(STEP_UP_PATH), access_token, json: body)))
244
+ end
245
+
246
+ # Reads a step-up (+GET step-up/{id}/+) with a token of the session that asked
247
+ # for it. Act only when its status is "approved" ({StepUp#approved?});
248
+ # "pending", "review", "declined", "expired" and "superseded" all mean no.
249
+ #
250
+ # @return [StepUp]
251
+ # @raise [Didit::ConfigurationError] when +step_up_id+ is empty, "." or ".."
252
+ def retrieve_step_up(access_token, step_up_id)
253
+ unless step_up_id.is_a?(String) && !step_up_id.empty? && !%w[. ..].include?(step_up_id)
254
+ raise ConfigurationError, "step_up_id must be a non-empty string other than \".\" and \"..\"."
255
+ end
256
+
257
+ path = "#{STEP_UP_PATH}#{step_up_id.b.gsub(UNRESERVED) { |c| format("%%%02X", c.ord) }}/"
258
+ StepUp.new(send_object(bearer_call("GET", issuer_uri(path), access_token)))
259
+ end
260
+
261
+ # The issuer's discovery document, fetched and cached like the keys. For debugging.
262
+ #
263
+ # @return [Hash] frozen
264
+ def discovery
265
+ current_discovery.document
266
+ end
267
+
268
+ # The issuer's JSON Web Key Set, fetched and cached as for token verification. For debugging.
269
+ #
270
+ # @return [Hash] frozen
271
+ def jwks
272
+ key_set(nil).document
273
+ end
274
+
275
+ # Closes the idle connections to the issuer. The instance stays usable.
276
+ #
277
+ # @return [nil]
278
+ def close
279
+ @http.close
280
+ end
281
+
282
+ def inspect
283
+ "#<#{self.class.name} issuer=#{issuer.inspect} client_id=#{client_id.inspect}>"
284
+ end
285
+ alias to_s inspect
286
+
287
+ private
288
+
289
+ # Token checks ------------------------------------------------------------
290
+
291
+ def verify(token, kind, audience, nonce)
292
+ parsed = Jws.parse(token, kind)
293
+ key = key_set(parsed.kid)[parsed.kid]
294
+ unless key
295
+ raise TokenVerificationError.new(
296
+ :unknown_key, "The issuer publishes no ES256 (P-256) key with the token's key id (kid)."
297
+ )
298
+ end
299
+
300
+ Jws.verify_signature(parsed, key)
301
+ Jws.check_claims(parsed.claims, kind: kind, issuer: token_issuer, audience: audience, nonce: nonce,
302
+ leeway: leeway, now: @clock.call.to_f)
303
+ parsed.claims
304
+ end
305
+
306
+ # The issuer as the discovery document names it, the +iss+ every token must carry. The keys
307
+ # are only ever fetched after the discovery document, so it is known here.
308
+ def token_issuer
309
+ @token_issuer || current_discovery.issuer
310
+ end
311
+
312
+ def expected_audience(audience, required:)
313
+ if audience.nil?
314
+ return [client_id] if client_id
315
+ return nil unless required
316
+
317
+ raise ConfigurationError,
318
+ "verify_id_token needs the audience: pass audience: <application id>, or client_id: <application id> " \
319
+ "to Didit::Authentication."
320
+ end
321
+ values = audience.is_a?(Array) ? audience : [audience]
322
+ if values.empty? || !values.all? { |value| value.is_a?(String) && !value.empty? }
323
+ raise ConfigurationError, "audience must be a non-empty string or an array of them."
324
+ end
325
+
326
+ values
327
+ end
328
+
329
+ # Discovery and keys ------------------------------------------------------
330
+
331
+ def current_discovery
332
+ @cache.discovery || @lock.synchronize { discovery_locked }
333
+ end
334
+
335
+ def discovery_locked
336
+ cached = @cache.discovery
337
+ return cached if cached
338
+
339
+ # A document that names another issuer is a configuration problem the next fetch most
340
+ # likely repeats: report it for a minute instead of fetching it again for every token.
341
+ refused = @cache.refused_discovery
342
+ raise ConfigurationError, refused if refused
343
+
344
+ call = Http::Call.new("GET", issuer_uri(DISCOVERY_PATH))
345
+ response = @http.perform(call)
346
+ discovery = begin
347
+ parse_discovery(call, response)
348
+ rescue ConfigurationError => e
349
+ @cache.discovery_refused(e.message)
350
+ raise
351
+ end
352
+ @token_issuer = discovery.issuer
353
+ @cache.store_discovery(discovery, response.headers)
354
+ end
355
+
356
+ # The fresh key set that should hold +kid+, fetching it when needed (at most
357
+ # once a minute for an unknown kid). A nil +kid+ asks for any fresh key set.
358
+ def key_set(kid)
359
+ cached = kid ? @cache.key_set_with(kid) : @cache.key_set
360
+ cached || @lock.synchronize { key_set_locked(kid) }
361
+ end
362
+
363
+ def key_set_locked(kid)
364
+ raise keys_unavailable if @cache.key_set_unavailable?
365
+
366
+ cached = @cache.key_set
367
+ return cached if cached && !@cache.needs_key_set_fetch?(kid)
368
+
369
+ begin
370
+ call = Http::Call.new("GET", URI(discovery_locked.jwks_uri))
371
+ response = @http.perform(call)
372
+ data = @http.read(call, response)
373
+ parsed = KeySet.parse(data)
374
+ raise @http.validation_error(call, response, "The issuer's JWKS is not a key set.") unless parsed
375
+
376
+ @cache.store_key_set(parsed, response.headers)
377
+ rescue *FETCH_ERRORS
378
+ # Counted only when the issuer failed, not for a configuration error, which is raised every time.
379
+ @cache.key_set_fetch_failed
380
+ raise
381
+ end
382
+ end
383
+
384
+ def keys_unavailable
385
+ url = @cache.discovery&.jwks_uri || "#{issuer}#{JWKS_PATH}"
386
+ APIConnectionError.new(
387
+ "The issuer's keys could not be fetched less than a minute ago; the next attempt is made once that " \
388
+ "minute has passed.",
389
+ http_method: "GET", url: Internal::Util.display_url(url)
390
+ )
391
+ end
392
+
393
+ def parse_discovery(call, response)
394
+ data = @http.read_object(call, response)
395
+ found = data["issuer"]
396
+ raise @http.validation_error(call, response, "The discovery document names no issuer.") unless found.is_a?(String)
397
+ # The configured issuer has no trailing slash; the document's may have one (OpenID Connect
398
+ # Discovery allows it), and then tokens carry it too.
399
+ unless [issuer, "#{issuer}/"].include?(found)
400
+ raise ConfigurationError,
401
+ "The discovery document at #{call.display_url} names the issuer #{found.inspect}, " \
402
+ "not #{issuer.inspect}. Configure Didit::Authentication with #{found.inspect} (an application's " \
403
+ "issuer changes when its custom domain is activated). Checked again after a minute."
404
+ end
405
+
406
+ Discovery.new(
407
+ issuer: found, document: data,
408
+ token_endpoint: issuer_endpoint(call, response, data, "token_endpoint"),
409
+ jwks_uri: issuer_endpoint(call, response, data, "jwks_uri")
410
+ )
411
+ end
412
+
413
+ # A discovery endpoint, which must be on the issuer's own origin: codes and
414
+ # refresh tokens go to the token endpoint and the keys decide which tokens are
415
+ # trusted, so neither may move to plain http or to another host.
416
+ def issuer_endpoint(call, response, data, name)
417
+ value = data[name]
418
+ uri = endpoint_uri(value)
419
+ raise @http.validation_error(call, response, "The discovery document has no valid #{name}.") unless uri
420
+
421
+ issuer_origin = Internal::Util.origin(issuer)
422
+ return value if Internal::Util.origin(uri) == issuer_origin && value.is_a?(String)
423
+
424
+ problem = if uri.scheme == "http" && issuer_origin.start_with?("https:")
425
+ "does not use https"
426
+ else
427
+ "is not on the issuer's origin #{issuer_origin}"
428
+ end
429
+ raise @http.validation_error(call, response, "The discovery document's #{name} #{value.inspect} #{problem}.")
430
+ end
431
+
432
+ def endpoint_uri(value)
433
+ return nil unless value.is_a?(String)
434
+
435
+ uri = URI.parse(value)
436
+ uri if uri.is_a?(URI::HTTP) && !uri.hostname.to_s.empty? && uri.userinfo.nil?
437
+ rescue URI::Error
438
+ nil
439
+ end
440
+
441
+ # Requests ----------------------------------------------------------------
442
+
443
+ def token_request(form, secrets)
444
+ form["client_id"] = client_id if client_id
445
+ call = Http::Call.new("POST", URI(current_discovery.token_endpoint), form: form, secrets: secrets)
446
+ TokenResponse.new(send_object(call))
447
+ end
448
+
449
+ def bearer_call(http_method, uri, access_token, json: nil)
450
+ token = required_text(access_token, "access_token")
451
+ # Sent in a header: printable ASCII only, so it can never split or extend the request.
452
+ raise ConfigurationError, "access_token contains invalid characters." if token.b.match?(/[^\x21-\x7e]/n)
453
+
454
+ Http::Call.new(http_method, uri, bearer: token, json: json, secrets: [token])
455
+ end
456
+
457
+ def send_object(call)
458
+ @http.read_object(call, @http.perform(call))
459
+ end
460
+
461
+ def issuer_uri(path)
462
+ URI("#{issuer}#{path}")
463
+ end
464
+
465
+ # Arguments ---------------------------------------------------------------
466
+
467
+ def required_text(value, name)
468
+ text = value.is_a?(String) ? value.strip : ""
469
+ raise ConfigurationError, "#{name} is empty." if text.empty?
470
+
471
+ text
472
+ end
473
+
474
+ def optional_text(value, name)
475
+ raise ConfigurationError, "#{name} must be a string." unless value.is_a?(String)
476
+
477
+ value
478
+ end
479
+
480
+ # A money amount as a plain decimal string ("1500.00"), never in exponent form.
481
+ def amount_text(amount)
482
+ if amount.is_a?(String)
483
+ text = amount.strip
484
+ return text if DECIMAL.match?(text)
485
+
486
+ raise ConfigurationError, "amount must be a decimal number such as \"1500.00\", got #{amount.inspect}."
487
+ end
488
+ return amount.to_s if amount.is_a?(Integer)
489
+
490
+ text = plain_decimal(amount.to_s) if amount.is_a?(Numeric) && !amount.is_a?(Complex)
491
+ return text if text
492
+
493
+ raise ConfigurationError,
494
+ "amount must be a finite decimal String, Integer, Float or BigDecimal, got #{amount.inspect}."
495
+ end
496
+
497
+ # "1.5e-05" => "0.000015", "0.15e4" => "1500"; nil for anything else (NaN, Infinity, "3/2").
498
+ def plain_decimal(text)
499
+ match = NUMBER.match(text)
500
+ return nil unless match
501
+
502
+ sign, whole, fraction, exponent = match.captures
503
+ return text unless exponent
504
+
505
+ digits = "#{whole}#{fraction}"
506
+ point = whole.to_s.length + exponent.to_i
507
+ value = if point <= 0
508
+ "0.#{"0" * -point}#{digits}"
509
+ elsif point >= digits.length
510
+ digits + ("0" * (point - digits.length))
511
+ else
512
+ "#{digits[0, point]}.#{digits[point..]}"
513
+ end
514
+ value = value.sub(/\A0+(?=\d)/, "")
515
+ value = value.sub(/\.?0+\z/, "") if value.include?(".")
516
+ "#{sign}#{value}"
517
+ end
518
+
519
+ # Options -----------------------------------------------------------------
520
+
521
+ def checked_client_id(client_id)
522
+ return nil if client_id.nil?
523
+ return client_id.strip if client_id.is_a?(String) && !client_id.strip.empty?
524
+
525
+ raise ConfigurationError, "client_id is empty: pass your application id or leave it out."
526
+ end
527
+
528
+ def checked_timeout(timeout)
529
+ if timeout.is_a?(Integer) || timeout.is_a?(Float)
530
+ value = timeout.to_f
531
+ return value if value.finite? && value.positive?
532
+ end
533
+
534
+ raise ConfigurationError, "timeout must be a positive number of seconds."
535
+ end
536
+
537
+ def checked_leeway(leeway)
538
+ if leeway.is_a?(Integer) || leeway.is_a?(Float)
539
+ value = leeway.to_f
540
+ return value if value.finite? && value >= 0
541
+ end
542
+
543
+ raise ConfigurationError, "leeway must be 0 or more seconds."
544
+ end
545
+ end
546
+ end