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,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
|