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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/didit/authentication/base64_url.rb +34 -0
- data/lib/didit/authentication/cache.rb +201 -0
- data/lib/didit/authentication/http.rb +150 -0
- data/lib/didit/authentication/issuer.rb +115 -0
- data/lib/didit/authentication/jws.rb +231 -0
- data/lib/didit/authentication/key_set.rb +89 -0
- data/lib/didit/authentication/pkce.rb +64 -0
- data/lib/didit/authentication/types.rb +175 -0
- data/lib/didit/authentication.rb +546 -0
- data/lib/didit/client.rb +211 -0
- data/lib/didit/errors.rb +153 -0
- data/lib/didit/file_part.rb +85 -0
- data/lib/didit/internal/config.rb +94 -0
- data/lib/didit/internal/errors.rb +83 -0
- data/lib/didit/internal/log.rb +41 -0
- data/lib/didit/internal/multipart.rb +60 -0
- data/lib/didit/internal/request.rb +322 -0
- data/lib/didit/internal/transport.rb +198 -0
- data/lib/didit/internal/util.rb +76 -0
- data/lib/didit/internal.rb +15 -0
- data/lib/didit/model.rb +199 -0
- data/lib/didit/page.rb +130 -0
- data/lib/didit/raw_response.rb +57 -0
- data/lib/didit/resource.rb +15 -0
- data/lib/didit/resources/account.rb +309 -0
- data/lib/didit/resources/address_validation.rb +73 -0
- data/lib/didit/resources/age_estimation.rb +110 -0
- data/lib/didit/resources/aml.rb +166 -0
- data/lib/didit/resources/aml_monitoring.rb +30 -0
- data/lib/didit/resources/analytics.rb +87 -0
- data/lib/didit/resources/bank.rb +65 -0
- data/lib/didit/resources/billing.rb +61 -0
- data/lib/didit/resources/biometric_templates.rb +234 -0
- data/lib/didit/resources/blocklist.rb +130 -0
- data/lib/didit/resources/business_sessions.rb +29 -0
- data/lib/didit/resources/businesses.rb +471 -0
- data/lib/didit/resources/cases.rb +2116 -0
- data/lib/didit/resources/customization.rb +153 -0
- data/lib/didit/resources/database_validation.rb +327 -0
- data/lib/didit/resources/document_ai.rb +145 -0
- data/lib/didit/resources/email.rb +254 -0
- data/lib/didit/resources/face_match.rb +103 -0
- data/lib/didit/resources/face_search.rb +103 -0
- data/lib/didit/resources/fraud_checks.rb +130 -0
- data/lib/didit/resources/id_verification.rb +163 -0
- data/lib/didit/resources/id_wallets.rb +1468 -0
- data/lib/didit/resources/investigator.rb +419 -0
- data/lib/didit/resources/ip.rb +68 -0
- data/lib/didit/resources/kyb.rb +256 -0
- data/lib/didit/resources/lists.rb +332 -0
- data/lib/didit/resources/monitoring_events.rb +466 -0
- data/lib/didit/resources/networks.rb +427 -0
- data/lib/didit/resources/passive_liveness.rb +103 -0
- data/lib/didit/resources/phone.rb +271 -0
- data/lib/didit/resources/pricing.rb +40 -0
- data/lib/didit/resources/proof_of_address.rb +167 -0
- data/lib/didit/resources/questionnaires.rb +170 -0
- data/lib/didit/resources/regulatory_reports.rb +314 -0
- data/lib/didit/resources/report_templates.rb +146 -0
- data/lib/didit/resources/sessions.rb +2820 -0
- data/lib/didit/resources/transactions.rb +981 -0
- data/lib/didit/resources/travel_rule.rb +1202 -0
- data/lib/didit/resources/users.rb +521 -0
- data/lib/didit/resources/wallet_screening.rb +190 -0
- data/lib/didit/resources/webhooks.rb +396 -0
- data/lib/didit/resources/workflows.rb +214 -0
- data/lib/didit/resources.rb +263 -0
- data/lib/didit/types/account.rb +11 -0
- data/lib/didit/types/account_components.rb +105 -0
- data/lib/didit/types/address_validation.rb +8 -0
- data/lib/didit/types/age_estimation.rb +89 -0
- data/lib/didit/types/aml.rb +193 -0
- data/lib/didit/types/aml_monitoring.rb +73 -0
- data/lib/didit/types/analytics.rb +62 -0
- data/lib/didit/types/bank.rb +8 -0
- data/lib/didit/types/billing.rb +18 -0
- data/lib/didit/types/biometric_templates.rb +292 -0
- data/lib/didit/types/blocklist.rb +31 -0
- data/lib/didit/types/businesses.rb +45 -0
- data/lib/didit/types/cases.rb +368 -0
- data/lib/didit/types/components.rb +12206 -0
- data/lib/didit/types/customization.rb +8 -0
- data/lib/didit/types/database_validation.rb +177 -0
- data/lib/didit/types/document_ai.rb +135 -0
- data/lib/didit/types/email.rb +116 -0
- data/lib/didit/types/face_match.rb +93 -0
- data/lib/didit/types/face_search.rb +129 -0
- data/lib/didit/types/fraud_checks.rb +8 -0
- data/lib/didit/types/id_verification.rb +231 -0
- data/lib/didit/types/id_wallets.rb +8 -0
- data/lib/didit/types/investigator.rb +221 -0
- data/lib/didit/types/ip.rb +8 -0
- data/lib/didit/types/kyb.rb +1391 -0
- data/lib/didit/types/lists.rb +111 -0
- data/lib/didit/types/monitoring_events.rb +235 -0
- data/lib/didit/types/networks.rb +120 -0
- data/lib/didit/types/passive_liveness.rb +95 -0
- data/lib/didit/types/phone.rb +69 -0
- data/lib/didit/types/proof_of_address.rb +166 -0
- data/lib/didit/types/questionnaires.rb +29 -0
- data/lib/didit/types/regulatory_reports.rb +100 -0
- data/lib/didit/types/report_templates.rb +41 -0
- data/lib/didit/types/sessions.rb +1441 -0
- data/lib/didit/types/transactions.rb +245 -0
- data/lib/didit/types/travel_rule.rb +168 -0
- data/lib/didit/types/users.rb +180 -0
- data/lib/didit/types/wallet_screening.rb +49 -0
- data/lib/didit/types/webhooks.rb +355 -0
- data/lib/didit/types/workflows.rb +32 -0
- data/lib/didit/types.rb +1681 -0
- data/lib/didit/version.rb +5 -0
- data/lib/didit/webhooks/canonical_json.rb +187 -0
- data/lib/didit/webhooks/client_methods.rb +23 -0
- data/lib/didit/webhooks/events.rb +221 -0
- data/lib/didit/webhooks/headers.rb +83 -0
- data/lib/didit/webhooks/verifier.rb +260 -0
- data/lib/didit/webhooks.rb +99 -0
- data/lib/didit-sdk.rb +4 -0
- data/lib/didit.rb +31 -0
- data/sig/didit/authentication/base64_url.rbs +10 -0
- data/sig/didit/authentication/cache.rbs +60 -0
- data/sig/didit/authentication/http.rbs +37 -0
- data/sig/didit/authentication/issuer.rbs +17 -0
- data/sig/didit/authentication/jws.rbs +41 -0
- data/sig/didit/authentication/jwt.rbs +14 -0
- data/sig/didit/authentication/key_set.rbs +25 -0
- data/sig/didit/authentication/pkce.rbs +20 -0
- data/sig/didit/authentication/types.rbs +114 -0
- data/sig/didit/authentication.rbs +81 -0
- data/sig/didit/client.rbs +61 -0
- data/sig/didit/errors.rbs +80 -0
- data/sig/didit/file_part.rbs +19 -0
- data/sig/didit/internal.rbs +199 -0
- data/sig/didit/model.rbs +42 -0
- data/sig/didit/page.rbs +34 -0
- data/sig/didit/raw_response.rbs +20 -0
- data/sig/didit/resource.rbs +8 -0
- data/sig/didit/resources/account.rbs +23 -0
- data/sig/didit/resources/address_validation.rbs +9 -0
- data/sig/didit/resources/age_estimation.rbs +9 -0
- data/sig/didit/resources/aml.rbs +10 -0
- data/sig/didit/resources/aml_monitoring.rbs +9 -0
- data/sig/didit/resources/analytics.rbs +9 -0
- data/sig/didit/resources/bank.rbs +9 -0
- data/sig/didit/resources/billing.rbs +10 -0
- data/sig/didit/resources/biometric_templates.rbs +13 -0
- data/sig/didit/resources/blocklist.rbs +11 -0
- data/sig/didit/resources/business_sessions.rbs +9 -0
- data/sig/didit/resources/businesses.rbs +22 -0
- data/sig/didit/resources/cases.rbs +103 -0
- data/sig/didit/resources/customization.rbs +10 -0
- data/sig/didit/resources/database_validation.rbs +9 -0
- data/sig/didit/resources/document_ai.rbs +9 -0
- data/sig/didit/resources/email.rbs +11 -0
- data/sig/didit/resources/face_match.rbs +9 -0
- data/sig/didit/resources/face_search.rbs +9 -0
- data/sig/didit/resources/fraud_checks.rbs +11 -0
- data/sig/didit/resources/id_verification.rbs +9 -0
- data/sig/didit/resources/id_wallets.rbs +37 -0
- data/sig/didit/resources/investigator.rbs +23 -0
- data/sig/didit/resources/ip.rbs +9 -0
- data/sig/didit/resources/kyb.rbs +12 -0
- data/sig/didit/resources/lists.rbs +20 -0
- data/sig/didit/resources/monitoring_events.rbs +16 -0
- data/sig/didit/resources/networks.rbs +25 -0
- data/sig/didit/resources/passive_liveness.rbs +9 -0
- data/sig/didit/resources/phone.rbs +11 -0
- data/sig/didit/resources/pricing.rbs +9 -0
- data/sig/didit/resources/proof_of_address.rbs +9 -0
- data/sig/didit/resources/questionnaires.rbs +13 -0
- data/sig/didit/resources/regulatory_reports.rbs +16 -0
- data/sig/didit/resources/report_templates.rbs +13 -0
- data/sig/didit/resources/sessions.rbs +78 -0
- data/sig/didit/resources/transactions.rbs +43 -0
- data/sig/didit/resources/travel_rule.rbs +62 -0
- data/sig/didit/resources/users.rbs +21 -0
- data/sig/didit/resources/wallet_screening.rbs +11 -0
- data/sig/didit/resources/webhooks.rbs +23 -0
- data/sig/didit/resources/workflows.rbs +13 -0
- data/sig/didit/resources.rbs +50 -0
- data/sig/didit/types/account.rbs +22 -0
- data/sig/didit/types/account_components.rbs +94 -0
- data/sig/didit/types/address_validation.rbs +7 -0
- data/sig/didit/types/age_estimation.rbs +80 -0
- data/sig/didit/types/aml.rbs +174 -0
- data/sig/didit/types/aml_monitoring.rbs +67 -0
- data/sig/didit/types/analytics.rbs +57 -0
- data/sig/didit/types/bank.rbs +9 -0
- data/sig/didit/types/billing.rbs +19 -0
- data/sig/didit/types/biometric_templates.rbs +268 -0
- data/sig/didit/types/blocklist.rbs +33 -0
- data/sig/didit/types/businesses.rbs +54 -0
- data/sig/didit/types/cases.rbs +414 -0
- data/sig/didit/types/components.rbs +10920 -0
- data/sig/didit/types/customization.rbs +7 -0
- data/sig/didit/types/database_validation.rbs +152 -0
- data/sig/didit/types/document_ai.rbs +123 -0
- data/sig/didit/types/email.rbs +112 -0
- data/sig/didit/types/face_match.rbs +86 -0
- data/sig/didit/types/face_search.rbs +112 -0
- data/sig/didit/types/fraud_checks.rbs +9 -0
- data/sig/didit/types/id_verification.rbs +196 -0
- data/sig/didit/types/id_wallets.rbs +55 -0
- data/sig/didit/types/investigator.rbs +212 -0
- data/sig/didit/types/ip.rbs +7 -0
- data/sig/didit/types/kyb.rbs +1254 -0
- data/sig/didit/types/lists.rbs +106 -0
- data/sig/didit/types/monitoring_events.rbs +234 -0
- data/sig/didit/types/networks.rbs +113 -0
- data/sig/didit/types/passive_liveness.rbs +87 -0
- data/sig/didit/types/phone.rbs +65 -0
- data/sig/didit/types/proof_of_address.rbs +144 -0
- data/sig/didit/types/questionnaires.rbs +30 -0
- data/sig/didit/types/regulatory_reports.rbs +101 -0
- data/sig/didit/types/report_templates.rbs +42 -0
- data/sig/didit/types/sessions.rbs +1349 -0
- data/sig/didit/types/transactions.rbs +273 -0
- data/sig/didit/types/travel_rule.rbs +188 -0
- data/sig/didit/types/users.rbs +181 -0
- data/sig/didit/types/wallet_screening.rbs +40 -0
- data/sig/didit/types/webhooks.rbs +311 -0
- data/sig/didit/types/workflows.rbs +30 -0
- data/sig/didit/webhooks/canonical_json.rbs +25 -0
- data/sig/didit/webhooks/client_methods.rbs +11 -0
- data/sig/didit/webhooks/events.rbs +125 -0
- data/sig/didit/webhooks/headers.rbs +22 -0
- data/sig/didit/webhooks/verifier.rbs +54 -0
- data/sig/didit/webhooks.rbs +37 -0
- data/sig/didit.rbs +20 -0
- 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
|