triauth 0.0.1 → 1.0.0.beta.4

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE +202 -28
  4. data/README.md +2036 -7
  5. data/lib/triauth/api/attestation.rb +419 -0
  6. data/lib/triauth/api/authentication.rb +651 -0
  7. data/lib/triauth/api/identity.rb +245 -0
  8. data/lib/triauth/api/signing.rb +740 -0
  9. data/lib/triauth/api/validation.rb +162 -0
  10. data/lib/triauth/api.rb +33 -0
  11. data/lib/triauth/authentication_endpoint.rb +196 -0
  12. data/lib/triauth/challenge.rb +115 -0
  13. data/lib/triauth/challenge_response_flow.rb +344 -0
  14. data/lib/triauth/config.rb +93 -0
  15. data/lib/triauth/error.rb +95 -0
  16. data/lib/triauth/helpers.rb +1222 -0
  17. data/lib/triauth/identity.rb +301 -0
  18. data/lib/triauth/identity_domain.rb +212 -0
  19. data/lib/triauth/identity_includes.rb +249 -0
  20. data/lib/triauth/identity_keys.rb +309 -0
  21. data/lib/triauth/logger.rb +156 -0
  22. data/lib/triauth/multi_signature.rb +177 -0
  23. data/lib/triauth/protocol.rb +74 -0
  24. data/lib/triauth/request_cipher.rb +121 -0
  25. data/lib/triauth/resolvers/base.rb +147 -0
  26. data/lib/triauth/resolvers/caching_resolver.rb +202 -0
  27. data/lib/triauth/resolvers/cloudflare.rb +40 -0
  28. data/lib/triauth/resolvers/dns_json.rb +391 -0
  29. data/lib/triauth/resolvers/dns_sb.rb +40 -0
  30. data/lib/triauth/resolvers/google.rb +39 -0
  31. data/lib/triauth/resolvers/multi_resolver.rb +202 -0
  32. data/lib/triauth/resolvers/system_dns.rb +259 -0
  33. data/lib/triauth/resolvers.rb +33 -0
  34. data/lib/triauth/response.rb +142 -0
  35. data/lib/triauth/response_cipher.rb +341 -0
  36. data/lib/triauth/signature.rb +405 -0
  37. data/lib/triauth/validator.rb +549 -0
  38. data/lib/triauth/verifiers/base.rb +147 -0
  39. data/lib/triauth/verifiers/ecdsa.rb +87 -0
  40. data/lib/triauth/verifiers/ed25519.rb +89 -0
  41. data/lib/triauth/verifiers/registry.rb +52 -0
  42. data/lib/triauth/verifiers/web_authn.rb +253 -0
  43. data/lib/triauth/verifiers.rb +37 -0
  44. data/lib/triauth/version.rb +23 -0
  45. data/lib/triauth.rb +104 -7
  46. metadata +55 -13
@@ -0,0 +1,651 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require_relative '../config'
21
+ require_relative '../error'
22
+ require_relative '../helpers'
23
+ require_relative '../validator'
24
+ require_relative '../identity'
25
+ require_relative '../challenge_response_flow'
26
+
27
+ module Triauth
28
+ module Api
29
+ # Methods related to authentication that are a part of the official triauth-ruby API.
30
+ module Authentication
31
+ # The authenticate method performs a challenge-response authentication based on the triauth protocol.
32
+ # Successful authentication confirms that the user is in control of the provided identifier, and may be e.g., logged in to your website with it.
33
+ #
34
+ # The authentication is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
35
+ #
36
+ # **1. Request**
37
+ #
38
+ # At first, you need to call the `Triauth.authenticate` method with the `identifier` option set to the user provided personal identifier (lowercase string),
39
+ # a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
40
+ # and an optional `ext` option (Hash) listing the [protocol extensions](#protocol-extensions) that you wish to use.
41
+ #
42
+ # In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
43
+ # You should temporarily store the `challenge` under the user's session, where the user can't tamper with it, and redirect the user's web browser to the `redirectUrl`.
44
+ #
45
+ # Synopsis:
46
+ #
47
+ # ```ruby
48
+ #
49
+ # result = Triauth.authenticate({ identifier: identifier, callbackUrl: callback_url, ext: ext }) # ext is optional
50
+ # # returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
51
+ # # returns {error:{code, message}} when the input is invalid, DNS resolution fails, or the domain is not configured for triauth
52
+ #
53
+ # ```
54
+ #
55
+ # Example:
56
+ #
57
+ # ```ruby
58
+ # Triauth.authenticate(
59
+ # {
60
+ # identifier: 'john@triauthdemo.org',
61
+ # callbackUrl: 'https://example.com/triauth-callback'
62
+ # }
63
+ # )
64
+ # # {
65
+ # # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJhdXRoIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJTSWRJaXZRRTVJbkhHQmFUWGhDb0UxMG8iLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCQXZJbDlWVDJ3Z3JoS2FBMmw3MTFhWmIxSVJlWURkX0UtdVRfcDl2VnNzZndqWG5VWjVNcVhjRTNmSUNCRlpmOElFeXJoUXVmX0F0ODlSaHBZMFlScUUifQ.-rkB18wDNcYxPU7jK3Lb8nwmQcV-fhJ-1nLCxdDgEzE',
66
+ # # redirectUrl: 'https://auth.triauthdemo.org/auth.html#?challenge=eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJhdXRoIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJTSWRJaXZRRTVJbkhHQmFUWGhDb0UxMG8iLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCQXZJbDlWVDJ3Z3JoS2FBMmw3MTFhWmIxSVJlWURkX0UtdVRfcDl2VnNzZndqWG5VWjVNcVhjRTNmSUNCRlpmOElFeXJoUXVmX0F0ODlSaHBZMFlScUUifQ'
67
+ # # }
68
+ # ```
69
+ #
70
+ # **2. Redirect**
71
+ #
72
+ # Once the user's web browser is redirected to the `redirectUrl`, they will be taken to the Triauth Authenticator web application, where they can approve or reject the authentication request.
73
+ #
74
+ # If the user approves the authentication request, Triauth Authenticator signs the challenge using one or more private keys that are available to it, and for which matching public keys may be obtained from the identity records, associated with the user's `identifier`, stored in the DNS.
75
+ # It then redirects the web browser to the `callbackUrl`, by default using a `HTTP GET` method, and passes the signature, together with optional additional data, inside the `response` URL parameter.
76
+ #
77
+ # If the user declines the authentication request, the Triauth Authenticator sends `false` as the `response`, which `Triauth.authenticate` reports as the error `request_denied`.
78
+ #
79
+ # **3. Verify**
80
+ #
81
+ # After the user approves the authentication request, Triauth Authenticator redirects the web browser to the previously provided `callbackUrl`, by default through the [HTTP GET method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/GET), and passes a response in the `response` URL parameter.
82
+ # The callback method that is used to pass the response to the client application can be configured with [callbackMethod extension](#protocol-extensions).
83
+ #
84
+ # Once you extract the `response` (string), you should pass it to the `Triauth.authenticate` function together with the previously stored `challenge` (string) to obtain authentication result.
85
+ # If you decide to also pass the `identifier` and/or `callbackUrl` together with `challenge` and `response`, it will be additionally verified that they are the same as embedded in the `challenge`. Every other option belongs to stage 1 and is rejected here with `invalid_arguments`.
86
+ #
87
+ # > [!WARNING]
88
+ # > **Invalidate the challenge after every authentication attempt.**
89
+ # > Upon authentication attempt, remove, invalidate, or forget the associated `challenge` that was stored in the user's session or your local database,
90
+ # > so that it cannot be re-used in replay attacks.
91
+ #
92
+ # Synopsis:
93
+ #
94
+ # ```ruby
95
+ #
96
+ # authentication_result = Triauth.authenticate({ challenge: challenge, response: response })
97
+ # # returns {authenticated:true, issuedAt, signedAt, verifiedAt, expires, secure, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, publicProfile, groups, keys, deviceName, deviceTag, sessionKey, ext} upon successful authentication
98
+ # # returns {error:{code, message}} upon failed authentication
99
+ #
100
+ # ```
101
+ #
102
+ # Example:
103
+ #
104
+ # ```ruby
105
+ # Triauth.authenticate(
106
+ # {
107
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJhdXRoIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJTSWRJaXZRRTVJbkhHQmFUWGhDb0UxMG8iLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCQXZJbDlWVDJ3Z3JoS2FBMmw3MTFhWmIxSVJlWURkX0UtdVRfcDl2VnNzZndqWG5VWjVNcVhjRTNmSUNCRlpmOElFeXJoUXVmX0F0ODlSaHBZMFlScUUifQ.-rkB18wDNcYxPU7jK3Lb8nwmQcV-fhJ-1nLCxdDgEzE',
108
+ # response: 'BNh1cQjNAGfM2AT7Kee_wcSpCMPmf393GaP5h1bOOFgrH58q9CIgGz0ChLLBzLGOUGbZB46FIgY8CPljfgKTNj4.DJguMmSAhafOTygveRsBQlHfpOcc4haXughtMSANoZO3ra2IIpYviH6H4ErvaqeRb_bEBu2e0URsJUjsA4iJ1Fu_2MMnn80zA-slH6XIsReM6i7m7a74iU_hmDmUMaL6KkwDM_LVJQw71pR1qnSLEE9EWOHHe8sqOT8t42S4TX0fhmzZFrlPm969B2eopvMFvJ0HMQ0I1USB6XB-lT0arzJt9UQB6XKsi2Dm'
109
+ # }
110
+ # )
111
+ # #
112
+ # # {
113
+ # # authenticated: true,
114
+ # # issuedAt: 1787224748449,
115
+ # # signedAt: 1787224766683,
116
+ # # verifiedAt: 1787224766784,
117
+ # # expires: 1787226566784,
118
+ # # secure: true,
119
+ # # identifier: "john@triauthdemo.org",
120
+ # # identityDomain: "john._at.triauthdemo.org",
121
+ # # lookupCode: "",
122
+ # # actor: "",
123
+ # # actorIdentityDomain: "",
124
+ # # actorLookupCode: "",
125
+ # # publicProfile: {
126
+ # # initials: "JD",
127
+ # # name: "John Doe"
128
+ # # },
129
+ # # groups: [],
130
+ # # deviceName: "desktop",
131
+ # # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
132
+ # # keys: [
133
+ # # {
134
+ # # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
135
+ # # options: {
136
+ # # type: "es256",
137
+ # # use: "attest,auth,ping,sign,stamp"
138
+ # # },
139
+ # # verified: true,
140
+ # # skipped: false
141
+ # # }
142
+ # # ],
143
+ # # sessionKey: "59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8",
144
+ # # ext: {}
145
+ # # }
146
+ # #
147
+ # ```
148
+ #
149
+ # > [!IMPORTANT]
150
+ # > Upon successful authentication, the authentication result Hash will have the `authenticated` property set to `true`, and the authenticated personal identifier available in the `identifier` property.
151
+ # >
152
+ # > Upon failed authentication, the `authenticated` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
153
+ #
154
+ # @see https://github.com/triauth/triauth-ruby#user-content-triauth-authenticate Documentation and usage examples on GitHub
155
+ #
156
+ # @param options [Hash] authentication options. Defaults to `{}` (a missing argument is rejected with `invalid_arguments` by the flow's stage detection, as the JS `undefined` is through `perform`'s own `options = {}` default; an explicit nil is the JS `null`, rejected with `invalid_arguments` by the known-keys guard).
157
+ #
158
+ # @option options [String] :identifier the user provided identifier that is being authenticated (stage 1)
159
+ # @option options [String] :callbackUrl an application provided URL to which the user's web browser should be redirected with the response (stage 1)
160
+ # @option options [Hash] :ext an optional Hash of protocol extensions to use (e.g., `{permissions:{ping:true}}`) (stage 1)
161
+ #
162
+ # @option options [String] :challenge a challenge as it was returned by the method after stage 1 call (stage 3)
163
+ # @option options [String] :response a received response, by default delivered as a `response` URL parameter of a GET to the `callbackUrl` (overridable via the `callbackMethod` extension) (stage 3)
164
+ #
165
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
166
+ #
167
+ # @return [Hash] one of the following:
168
+ # `{challenge: String, redirectUrl: String}`,
169
+ # `{authenticated: true, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, expires: (Integer, nil), secure: Boolean, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, publicProfile: Hash, groups: Array<String>, deviceName: String, deviceTag: String, keys: Array<Hash>, sessionKey: String, ext: Hash}`, or
170
+ # `{error: {code: String, reason?: String, message: String}}`
171
+ # - `{challenge: String, redirectUrl: String}`: Returned during stage 1 when a challenge is generated. The `challenge` should be stored and the user should be redirected to the `redirectUrl`.
172
+ # - `{authenticated: true, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, expires?: Integer, secure: Boolean, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, publicProfile: Hash, groups: Array<String>, deviceName: String, deviceTag: String, keys: Array<Hash>, sessionKey: String, ext: Hash}`: Returned during stage 3 upon successful authentication. Includes:
173
+ # - `authenticated` (true): Indicates authentication was successful.
174
+ # - `issuedAt` (Integer): Unix timestamp on **your** server's clock at which you issued the challenge in stage 1 — the start of the flow's timeline.
175
+ # - `signedAt` (Integer): Unix timestamp on the **user's device** clock at which the authenticator signed the response. Verified to fall within the freshness window, but device-asserted — do not treat it as a trusted wall clock; prefer `issuedAt`/`verifiedAt` for authoritative timing.
176
+ # - `verifiedAt` (Integer): Unix timestamp on **your** server's clock at which this library confirmed the signature — the authoritative "when did this happen" anchor.
177
+ # - `expires` (Integer, optional): Unix timestamp at which the DNS records backing the verified keys expire (the earliest TTL expiry) and re-verification via `Triauth.check`/`Triauth.ping` is due. This is **not** a hard session lifetime: it is a hint for when to re-check, not a license to keep a session open without re-checking — a long DNS TTL otherwise delays the effect of a key revocation by the full TTL. May be `nil` when DNS records carry no TTL; in that case apply your own minimum/maximum re-check interval. Do not perform arithmetic against it without first handling the `nil` case.
178
+ # - `secure` (Boolean): Indicates whether DNSSEC was used for strong cryptographic integrity. `true` only when the domain's `triauth` configuration record and every well-formed record of the identity answer (for delegated authentication, of both the subject's and the delegate's answers) were DNSSEC-validated; a single non-validated identity record degrades it.
179
+ # - `identifier` (String): The authenticated user's identifier.
180
+ # - `identityDomain` (String): The domain name under which identity records are stored for this identifier (the subject's, for delegated authentication).
181
+ # - `lookupCode` (String): The lookup code the identifier's domain derivation consumed (under `mode=private` the identity records live at a code-derived label); `''` where none was. The same semi-secret that suffixes the `deviceTag` - handle it identically; it feeds `Triauth.whois({identifier, lookupCode})`.
182
+ # - `actor` (String): For delegated authentication (an `include` identity record granting a delegate the right to act on behalf of the identifier), the identifier of the delegate that actually performed the authentication; `''` for direct authentication (the user's own keys).
183
+ # - `actorIdentityDomain` (String): For delegated authentication, the domain name under which the delegate's identity records are stored — the records the signing keys were read from; `''` for direct authentication.
184
+ # - `actorLookupCode` (String): The lookup code the delegate's domain derivation consumed; `''` where none was, and always `''` for direct authentication.
185
+ # - `publicProfile` (Hash): Public details of the authenticated identity as read from identity records (e.g., 'name' and/or 'initials').
186
+ # - `groups` (Array): Fully-qualified group names the identity's records claim membership in (e.g., `['admins@triauthdemo.org']`), sorted and deduplicated; `[]` when none are published. The claim is made by the identity's DNS zone — treat it as authoritative only where that zone's operator is the authorization authority, compare entries only against other group names (never against identifiers), and escape before rendering. For delegated authentication these are the subject's groups, never the delegate's.
187
+ # - `deviceName` (String): User-given, public name of the device that was used for authentication, as read from the DNS key records (e.g., `"mylaptop"`).
188
+ # - `deviceTag` (String): Unique identifier of the device (and, for delegated authentication, the delegation) used; under `mode=private` it also carries the lookup code. Store it verbatim and pass it to `Triauth.check`.
189
+ # - `keys` (Array): Public keys that were considered and/or used during verification.
190
+ # - `sessionKey` (String): A secret of this sign-in, in 43 base64url characters, that only your application and the user's authenticator know. Pass it to `Triauth.ping`, `Triauth.sign`, `Triauth.stamp` and `Triauth.attest`. Store it with the user's server-side or encrypted session, and keep it out of logs. After a delegated sign-in, it starts with the actor's domain and a colon.
191
+ # - `ext` (Hash): Data returned by the authenticator application for the requested protocol extensions. Always present - `{}` when no extension data was returned (a malformed `ext` is discarded with a logged warning and also yields `{}`).
192
+ # - `{error: {code: String, reason?: String, message: String}}`: Returned during stage 1 or stage 3 when authentication fails, includes an error Hash with code and message.
193
+ def self.authenticate(options = {}, config = {})
194
+ # (`options = {}`: a missing argument is the JS `undefined`, which the flow's `perform(type, options = {}, ...)` default turns into `{}` (`invalid_arguments` from the stage detection);
195
+ # a Ruby default applies only at the method called without the argument, so it lives here too, and an explicit nil stays the JS `null` (`invalid_arguments` from the known-keys guard))
196
+ config = Helpers.merge_config(config)
197
+ config[:logger] = (config[:logger].respond_to?(:spawn) ? config[:logger].spawn(config[:cid]) : nil) || config[:logger]
198
+
199
+ # No challenge processing needed here
200
+ # (`async () => {}` ignores its argument; a zero-parameter lambda would raise ArgumentError when the flow calls it with the `{challenge}` Hash)
201
+ on_challenge = ->(_args) {}
202
+
203
+ on_response = lambda do |args|
204
+ challenge = args[:challenge]
205
+ response = args[:response]
206
+ session_key = args[:sessionKey]
207
+
208
+ # Verify the response against the challenge
209
+ result = ChallengeResponseFlow.verify_challenge_response('auth', challenge.challenge_string, challenge, response, config[:authTimeout], config)
210
+
211
+ # Propagate the failure signals unchanged: null -> request_expired, false -> verification_failed.
212
+ return result if !result
213
+
214
+ sig = result[:sig]
215
+ verify_result = result[:verifyResult]
216
+
217
+ Helpers.log(config, :info, 'Verification successful', { identifier: sig[:identifier], deviceTag: sig[:deviceTag] })
218
+
219
+ received_ext = sig[:signedMetadata].nil? ? nil : sig[:signedMetadata][:ext]
220
+ # `receivedExt && ...`: `ext` is a parsed JSON value, so the JS falsy cases are nil, false, 0 and ''
221
+ received_ext_truthy = !(received_ext.nil? || received_ext == false || received_ext == 0 || received_ext == '')
222
+ received_ext_valid = received_ext_truthy && Validator.validate_ext(received_ext)[:errors].length == 0
223
+
224
+ # Auxiliary data tolerance: a malformed `ext` does NOT block authentication (a buggy or
225
+ # forward-incompatible authenticator must not lock the user out). Coerce to {} silently
226
+ # for the caller, but log a warning so operators that care can plug a custom logger.
227
+ if received_ext_truthy && !received_ext_valid
228
+ Helpers.log(config, :warn, 'Discarding malformed signedMetadata.ext', { identifier: sig[:identifier], deviceTag: sig[:deviceTag] })
229
+ end
230
+
231
+ # (an `expires` of nil stays a present nil member: the JS result carries `expires: undefined` under structuredClone, and the JSON runner drops nil members before comparing)
232
+ return Helpers.clone(
233
+ {
234
+ authenticated: true,
235
+ issuedAt: challenge.data[:iat],
236
+ signedAt: sig[:signedAt],
237
+ verifiedAt: verify_result[:verifiedAt],
238
+ expires: verify_result[:expires],
239
+ secure: verify_result[:secure],
240
+ identifier: sig[:identifier],
241
+ identityDomain: sig[:identityDomain],
242
+ lookupCode: sig[:lookupCode],
243
+ actor: sig[:actor],
244
+ actorIdentityDomain: sig[:actorIdentityDomain],
245
+ actorLookupCode: sig[:actorLookupCode],
246
+ publicProfile: sig[:publicProfile],
247
+ groups: sig[:groups],
248
+ deviceName: sig[:deviceName],
249
+ deviceTag: sig[:deviceTag],
250
+ keys: sig[:keys],
251
+ sessionKey: sig[:actor] != '' ? "#{sig[:actor].split('@', -1)[1]}:#{session_key}" : session_key, # A delegated session is at the authenticator of the actor's domain, so its key starts with that domain (`sig.actor ?`: the actor is '' or an identifier, so the empty string is the only falsy value)
252
+ ext: received_ext_valid ? received_ext : {}
253
+ }
254
+ )
255
+ end
256
+
257
+ return ChallengeResponseFlow.perform('auth', options, { onChallenge: on_challenge, onResponse: on_response }, config)
258
+ end
259
+
260
+ # The check method verifies that the user's public keys and delegation associated with the given `deviceTag` are still present and unchanged in the Domain Name System (DNS), and that the user may remain authenticated.
261
+ # It also returns the user's current group memberships, allowing the application to detect membership changes.
262
+ #
263
+ # A key may be removed from the DNS by the user or their organization when, for example, an associated device is stolen, decommissioned, or the user leaves the organization.
264
+ # Similarly, a delegation may be revoked when the user no longer allows a particular actor to act on their behalf.
265
+ # Group membership changes may affect the user's access to your application features or resources.
266
+ #
267
+ # This method may be periodically called with the `identifier` and `deviceTag` as returned by the `Triauth.authenticate` method.
268
+ #
269
+ # Synopsis:
270
+ #
271
+ # ```ruby
272
+ #
273
+ # check_result = Triauth.check({ identifier: identifier, deviceTag: device_tag })
274
+ # # returns {valid:true, secure, expires, groups} keys are still present in the DNS; `expires` (when a number) is the unix timestamp until which the check method need not be called again; `expires` may be nil when DNS records carry no TTL - in that case apply your own minimum re-check interval; `groups` is the identity's current membership list - replace any session-cached groups with it on every check
275
+ # # returns {valid:false, reason} when the device may no longer remain authenticated and should be logged out; reason is 'revoked' (the device's keys are no longer published) or 'unresolved' (the identity itself no longer resolves)
276
+ # # returns {error} when status cannot be determined at the moment due to some error
277
+ #
278
+ # ```
279
+ #
280
+ # > [!IMPORTANT]
281
+ # > Accept only when `valid == true` (the device keys are still present). The `reason` on a `{valid:false}` result is diagnostic only - both `'revoked'` and `'unresolved'` mean the user should be logged out.
282
+ #
283
+ # Example:
284
+ #
285
+ # ```ruby
286
+ # Triauth.check(
287
+ # {
288
+ # identifier: "john@triauthdemo.org",
289
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ"
290
+ # }
291
+ # )
292
+ # #
293
+ # # {
294
+ # # valid: true,
295
+ # # secure: true,
296
+ # # expires: 1737547197157,
297
+ # # groups: []
298
+ # # }
299
+ # #
300
+ # ```
301
+ #
302
+ # @param options [Hash] check options. Defaults to nil (a missing argument is rejected with `invalid_arguments`).
303
+ # @option options [String] :identifier identifier to be verified
304
+ # @option options [String] :deviceTag the `deviceTag` as returned by a challenge-response method's result
305
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
306
+ #
307
+ # @return [Hash] one of the following:
308
+ # `{valid: true, secure: Boolean, expires: (Integer, nil), groups: Array<String>}`,
309
+ # `{valid: false, reason: 'revoked'|'unresolved'}`, or
310
+ # `{error: {code: String, reason?: String, message: String}}`
311
+ # - `{valid: true, secure: Boolean, expires?: Integer, groups: Array<String>}`: Indicates that the user may remain authenticated. The `secure` property is `true` only when the domain's `triauth` configuration record and every well-formed record of the identity answer (for composite device tags, of both identities' answers) were DNSSEC-validated. The `expires` property, when a number, is the Unix timestamp until which the `check` method need not be called again; it is `nil` when the DNS records carry no TTL - in that case apply your own minimum re-check interval. The `groups` property carries the identity's **current** fully-qualified group names — replace any session-cached groups with this list on every check, so membership changes take effect within the same bound as key revocation.
312
+ # - `{valid: false, reason: 'revoked'|'unresolved'}`: Indicates that the device may no longer remain authenticated and should be logged out (call `Triauth.authenticate` again to re-authenticate). The `reason` is diagnostic only: `'revoked'` - the device's keys are no longer published in the DNS; `'unresolved'` - the identity itself (or, for a delegated `deviceTag`, the actor's identity) no longer resolves (e.g., the domain is no longer configured for triauth, or has no identity records). A `deviceTag` outside the format of a device tag is reported as `'revoked'` without a DNS lookup.
313
+ # - `{error: {code: String, reason?: String, message: String}}`: Indicates that there was some problem, and the status cannot be determined at the moment, including an `error` message with more details.
314
+ def self.check(options = nil, config = {})
315
+ config = Helpers.merge_config(config)
316
+ config[:logger] = (config[:logger].respond_to?(:spawn) ? config[:logger].spawn(config[:cid]) : nil) || config[:logger]
317
+
318
+ Helpers.log(config, :info, 'Processing Triauth.check')
319
+ ts = Helpers.now
320
+
321
+ begin
322
+ # Ensure only recognized options are present to help avoid misspellings.
323
+ if !Helpers.has_only_known_properties(options, %i[identifier deviceTag])
324
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Unrecognized function argument' })
325
+ end
326
+
327
+ # Reject config overrides carrying unknown keys
328
+ if !Helpers.has_only_known_properties(config, Triauth::KNOWN_CONFIG_KEYS)
329
+ raise Triauth::Error.new({ code: 'invalid_config', message: 'Unrecognized configuration key' })
330
+ end
331
+
332
+ identifier = options[:identifier]
333
+ device_tag = options[:deviceTag]
334
+
335
+ Helpers.log(config, :info, 'Request parameters:', { identifier: identifier, deviceTag: device_tag })
336
+
337
+ if !Helpers.is_string(identifier) || !Helpers.is_string(device_tag)
338
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Invalid or missing function arguments' })
339
+ end
340
+
341
+ # A malformed identifier is a caller error, reported ahead of the device tag verdict
342
+ identifier_error = Validator.validate_identifier(identifier)[:errors][0]
343
+ if identifier_error
344
+ raise Triauth::Error.new(identifier_error)
345
+ end
346
+
347
+ # A device tag outside the closed grammar was never minted by a result, so it reads as revoked, with no DNS lookup
348
+ if !Validator.validate_device_tag(device_tag)[:valid]
349
+ Helpers.log(config, :debug, 'Check failed - malformed deviceTag', { identifier: identifier })
350
+ return { valid: false, reason: 'revoked' }
351
+ end
352
+
353
+ # Decompose the deviceTag: a direct tag is one component, a delegated tag is three
354
+ parts = device_tag.split(':', -1)
355
+ subject_component, subject_lookup_code = parts[0].split('~', -1)
356
+
357
+ # (`Triauth::Identity`, qualified: the bare `Identity` resolves to the `Triauth::Api::Identity` module here;
358
+ # `lookupCode` is nil when absent, exactly as the JS `undefined`)
359
+ identity = Triauth::Identity.new(identifier, { lookupCode: subject_lookup_code }, config)
360
+
361
+ identity.resolve
362
+
363
+ if !identity.resolved
364
+ Helpers.log(config, :debug, 'Check failed', { reason: 'unresolved', identifier: identifier, deviceTag: device_tag })
365
+ return { valid: false, reason: 'unresolved' }
366
+ end
367
+
368
+ # There are two types of deviceTags:
369
+ # - 'direct' ones that directly match the user's device (keyGroup),
370
+ # - 'delegated' ones that were produced by delegated authentication (when `include` was followed), that have ':' in them
371
+
372
+ secure = nil
373
+ expires = nil
374
+
375
+ # 'direct' deviceTags don't have ':'
376
+ if parts.length == 1
377
+
378
+ # Attempt direct match to one of keyGroup tags
379
+ key_group = identity.keys.find_by_tag(subject_component)
380
+
381
+ if !key_group
382
+ Helpers.log(config, :debug, 'Check failed - deviceTag revoked', { identifier: identifier, deviceTag: device_tag })
383
+ return { valid: false, reason: 'revoked' }
384
+ end
385
+
386
+ secure = identity.secure
387
+ expires = Helpers.earliest(identity.authentication_endpoint.expires, key_group[:expires])
388
+
389
+ # 'delegated' deviceTag
390
+ else
391
+
392
+ # Decompose the deviceTag for delegated/actor flows into the `include` statement tag, actor's identifier,
393
+ # and actor's device tag; the device component resolves in the actor's zone, so it carries the actor's salt
394
+ include_tag = subject_component
395
+ _, actor_identifier, actor_device_tag_component = parts
396
+ actor_device_tag, actor_lookup_code = actor_device_tag_component.split('~', -1)
397
+
398
+ actor_identity = Triauth::Identity.new(actor_identifier, { lookupCode: actor_lookup_code }, config)
399
+
400
+ # Verify that there is a matching include statement
401
+ include_statement = identity.includes.find(actor_identity, include_tag, nil)
402
+
403
+ if !include_statement
404
+ Helpers.log(config, :debug, 'Check failed - include statement unresolved or revoked', { identifier: identifier, deviceTag: device_tag, includeTag: include_tag })
405
+ return { valid: false, reason: include_statement == false ? 'unresolved' : 'revoked' }
406
+ end
407
+
408
+ # Finally, ensure that the actor's deviceTag matches one of actor's keyGroups
409
+ actor_key_group = actor_identity.keys.find_by_tag(actor_device_tag)
410
+
411
+ if !actor_key_group
412
+ Helpers.log(config, :debug, 'Check failed - actor deviceTag revoked', { actorIdentifier: actor_identifier, actorDeviceTag: actor_device_tag })
413
+ return { valid: false, reason: 'revoked' }
414
+ end
415
+
416
+ secure = identity.secure && actor_identity.secure
417
+ expires = Helpers.earliest(
418
+ Helpers.earliest(identity.authentication_endpoint.expires, include_statement[:expires]),
419
+ Helpers.earliest(actor_identity.authentication_endpoint.expires, actor_key_group[:expires])
420
+ )
421
+ end
422
+
423
+ Helpers.log(config, :debug, 'Check successful', { identifier: identifier, deviceTag: device_tag })
424
+
425
+ # Enforce config.requireSecure. valid:false (revoked) path stays not gated by design
426
+ ChallengeResponseFlow.assert_secure(secure, config)
427
+
428
+ return Helpers.clone(
429
+ {
430
+ valid: true,
431
+ secure: secure,
432
+ expires: expires,
433
+ groups: identity.groups
434
+ }
435
+ )
436
+ rescue StandardError => err
437
+ return Triauth::Error.process(err, config)
438
+ ensure
439
+ Helpers.log(config, :info, "Completed in #{(Helpers.now - ts).round}ms")
440
+ end
441
+ end
442
+
443
+ # The `ping` method may be used to re-authenticate the user in background, by obtaining a fresh proof that the user still has access to cryptographic keys.
444
+ # While the `Triauth.check` method ensures only that the keys and the optional delegation used for authentication were not changed or removed from DNS,
445
+ # `Triauth.ping` goes a step further, and requires a fresh, valid signature.
446
+ #
447
+ # Pings typically do not require any interaction from the user, and may be used as one of the signals in Continuous Authentication schemes.
448
+ #
449
+ # > [!NOTE]
450
+ # > **Get the `ping` permission and use the same base callbackUrl as for authentication**<br/>
451
+ # > To get the permission, provide the `ext:{permissions:{ping:true}}` parameter to the `Triauth.authenticate` function during the initial call (i.e., when building the challenge).
452
+ # > The authentication result shows the decision in `ext[:permissions]`. You can call `ping` only when `ext.dig(:permissions, :ping) == true` in the result.
453
+ # >
454
+ # > Also, the `callbackUrl` used in this method must have the **same base URL** (its origin plus the path up to and including the last `/`) as the `callbackUrl` from the initial `Triauth.authenticate` call.
455
+ # > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
456
+ # > must sit in that same `https://example.com/dir1/` base directory — only the final path segment may differ, e.g., `https://example.com/dir1/this-callback` (a deeper path such as `https://example.com/dir1/dir2/this-callback` does **not** match the base URL, and the request will be rejected).
457
+ #
458
+ # The ping is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
459
+ #
460
+ # **1. Request**
461
+ #
462
+ # At first, you need to call the `Triauth.ping` method with the `identifier` option set to the user provided personal identifier (lowercase string),
463
+ # a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
464
+ # a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
465
+ # and an optional `ext` option (Hash) listing the [protocol extensions](#protocol-extensions) that you wish to use.
466
+ #
467
+ # In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
468
+ # You should temporarily store the `challenge` under the user's session, where the user can't tamper with it, and redirect the user's web browser to the `redirectUrl`.
469
+ #
470
+ # Synopsis:
471
+ #
472
+ # ```ruby
473
+ #
474
+ # result = Triauth.ping({ identifier: identifier, callbackUrl: callback_url, sessionKey: session_key, ext: ext })
475
+ # # returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
476
+ # # returns {error} on error
477
+ #
478
+ # ```
479
+ #
480
+ # Example:
481
+ #
482
+ # ```ruby
483
+ #
484
+ # Triauth.ping(
485
+ # {
486
+ # identifier: 'john@triauthdemo.org',
487
+ # callbackUrl: 'https://example.com/callback',
488
+ # sessionKey: '59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
489
+ # }
490
+ # )
491
+ # #
492
+ # # {
493
+ # # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY2FsbGJhY2siLCJ0eXBlIjoicGluZyIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoicWFvU1JnZlVnREpwZG5LX1FnQTd4SG9ZIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkVoLTJGR0JOUkUyUGZvSTVsWjh4eU9KX2IzaTlaRVFadGtQOGJVajJ6VWljWDdsaTdqalBvYzVmUnczUlZKb2tjb1BZdVRLdGdGaVVENlR1LURrM3I0In0.urITupCNu0ZNV4CF692qaZs6UaBb5LNov-Z9hhB5ArU',
494
+ # # redirectUrl: 'https://auth.triauthdemo.org/ping.html#?skid=wzI1sz3n1R4biw4FL_VJ3Q&request=aTO6cylS_52etdEH.hAskvAG-kqxradXNXvr3S4yxpgI3c6EdBOWOnN3UP4CjEfhkyQ1vbcYTmYQ_L3UOtIm6VwPYczazFTW7G7mXByWzAITIVkTRaQHlwyOj9CkPiOP278pc8pQdjPsCKmVHEPSqfwYlnUvNOOp_dsVRgb1zKT2QEkWFnCAH1CUvxDb4bpYqQ_YFa-q48WjkC9cDXRSKRgpN51iwPGV-koSmoIwgYLghNpUC4Cvx72ctYq1tLyP6jrXa3dS8VrSZgy5D4EFwYx22VHg1LloHgDc730QN560wpD7yGL8ylk0eENLml357QTLextpeqZ4Q_MG6JEg0fsN1XdNduNlsiQd_GfTiVwLnaXh3'
495
+ # # }
496
+ # #
497
+ # ```
498
+ #
499
+ # **2. Redirect**
500
+ #
501
+ # Once the user's web browser is redirected to the `redirectUrl`, the Triauth Authenticator web application immediately responds to the `callbackUrl`. This needs a valid session key with the `ping` permission.
502
+ #
503
+ # **3. Verify**
504
+ #
505
+ # The Triauth Authenticator application redirects the user's web browser to the previously provided `callbackUrl`, by default through the [HTTP GET method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/GET), and passes a fresh signature in the `response` URL parameter.
506
+ # The method that is used to pass the response to your application can be configured with [callbackMethod extension](#protocol-extensions).
507
+ #
508
+ # Once you extract the `response` (string), you should pass it to the `Triauth.ping` function together with the previously stored `challenge` (string) to obtain ping result.
509
+ # If you decide to also pass the `identifier` and/or `callbackUrl` together with `challenge` and `response`, it will be additionally verified that they are the same as embedded in the `challenge`. Every other option belongs to stage 1 and is rejected here with `invalid_arguments`.
510
+ #
511
+ # > [!WARNING]
512
+ # > **Invalidate the challenge after every ping attempt.**
513
+ # > Only ever pass a `challenge` that your application generated and stored server-side (e.g., in the user's session or your database) - never one taken from user-controlled input.
514
+ # > Upon every ping attempt, make sure to remove or invalidate that stored `challenge`, so that a captured `(challenge, response)` pair cannot be re-used in replay attacks within the `pingTimeout` freshness window. This matters especially for ping, which is meant for frequent, unattended background / Continuous Authentication use.
515
+ #
516
+ # Synopsis:
517
+ #
518
+ # ```ruby
519
+ #
520
+ # ping_result = Triauth.ping({ challenge: challenge, response: response })
521
+ # # returns {pinged:true, issuedAt, signedAt, verifiedAt, expires, secure, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, groups, deviceName, deviceTag, keys} upon success
522
+ # # returns {error:{code, message}} upon error
523
+ #
524
+ # ```
525
+ #
526
+ # Example:
527
+ #
528
+ # ```ruby
529
+ # Triauth.ping(
530
+ # {
531
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY2FsbGJhY2siLCJ0eXBlIjoicGluZyIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoicWFvU1JnZlVnREpwZG5LX1FnQTd4SG9ZIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkVoLTJGR0JOUkUyUGZvSTVsWjh4eU9KX2IzaTlaRVFadGtQOGJVajJ6VWljWDdsaTdqalBvYzVmUnczUlZKb2tjb1BZdVRLdGdGaVVENlR1LURrM3I0In0.urITupCNu0ZNV4CF692qaZs6UaBb5LNov-Z9hhB5ArU',
532
+ # response: 'BGb9tY40nX1dXVl8KlCg8c6mSThXTM2UPWvkN_tHT8h05FIbypseeOplJNXLRH3mVtiTT4YarHDUVT5OqmHziOc.AV7MibGdeVKtc2hBh9BfpeB48dxc1xwu1bv2LtfvmHNEXUf_J_g0j3Njndh-_wiUI2sF2s_b8g9WJe5SewgFblFvdKIz6rXNL6SeCZsQeZhIvNSAvMtXgAHJtybBU5MmQJhx0zqh0Rf7Osuju8bceqMHFzuY9083btLzpU6iCbJeKGXqnwAMyCVOOwCDkJgCVRE_Mb2wADTsFpIUiQAQX-k6q0qAaBbZ1bec'
533
+ # }
534
+ # )
535
+ #
536
+ # #
537
+ # # {
538
+ # # pinged: true,
539
+ # # issuedAt: 1787224748449,
540
+ # # signedAt: 1787224751161,
541
+ # # verifiedAt: 1787224751262,
542
+ # # expires: 1787226551262,
543
+ # # secure: true,
544
+ # # identifier: "john@triauthdemo.org",
545
+ # # identityDomain: "john._at.triauthdemo.org",
546
+ # # lookupCode: "",
547
+ # # actor: "",
548
+ # # actorIdentityDomain: "",
549
+ # # actorLookupCode: "",
550
+ # # groups: [],
551
+ # # deviceName: "desktop",
552
+ # # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
553
+ # # keys: [
554
+ # # {
555
+ # # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
556
+ # # options: {
557
+ # # type: "es256",
558
+ # # use: "attest,auth,ping,sign,stamp"
559
+ # # },
560
+ # # verified: true,
561
+ # # skipped: false
562
+ # # }
563
+ # # ]
564
+ # # }
565
+ # #
566
+ # ```
567
+ #
568
+ # > [!IMPORTANT]
569
+ # > Upon successful pinging, the result Hash will have the `pinged` property set to `true`.
570
+ # > You can also check that the returned `identifier`, `deviceTag`, and `secure` values match your expectations.
571
+ # > Note that `expires` may be nil or arbitrarily low - apply your own minimum re-ping interval, as described for `Triauth.authenticate`.
572
+ # >
573
+ # > Upon failed pinging, the `pinged` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
574
+ #
575
+ # > [!NOTE]
576
+ # > Users may configure a different set of cryptographic keys (that still belongs to the same device) for the purpose of responding to ping requests.
577
+ # > You can assume that the `deviceTag` in `ping` result should be the same as in the original `Triauth.authenticate` response, but the combination of verified/skipped keys may differ.
578
+ #
579
+ # @param options [Hash] ping options. Defaults to `{}` (a missing argument is rejected with `invalid_arguments` by the flow's stage detection, as the JS `undefined` is through `perform`'s own `options = {}` default; an explicit nil is the JS `null`, rejected with `invalid_arguments` by the known-keys guard).
580
+ #
581
+ # @option options [String] :identifier the user provided identifier (stage 1)
582
+ # @option options [String] :callbackUrl an application provided URL to which the user's web browser should be redirected with the response (stage 1)
583
+ # @option options [String] :sessionKey the `sessionKey` of a `Triauth.authenticate` result whose `ext.permissions.ping` is `true`; a stage-1 request without a valid session key returns the error `invalid_session_key` (stage 1, required)
584
+ # @option options [Hash] :ext an optional Hash of protocol extensions to use (e.g., `{callbackMethod:'GET'}`) (stage 1)
585
+ #
586
+ # @option options [String] :challenge a challenge as it was returned by the method after stage 1 call (stage 3)
587
+ # @option options [String] :response a received response, by default delivered as a `response` URL parameter of a GET to the `callbackUrl` (overridable via the `callbackMethod` extension) (stage 3)
588
+ #
589
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
590
+ #
591
+ # @return [Hash] one of the following:
592
+ # `{challenge: String, redirectUrl: String}`,
593
+ # `{pinged: true, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, expires: (Integer, nil), secure: Boolean, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, groups: Array<String>, deviceName: String, deviceTag: String, keys: Array<Hash>}`, or
594
+ # `{error: {code: String, reason?: String, message: String}}`
595
+ def self.ping(options = {}, config = {})
596
+ # (`options = {}`: a missing argument is the JS `undefined`, which the flow's `perform(type, options = {}, ...)` default turns into `{}` (`invalid_arguments` from the stage detection);
597
+ # a Ruby default applies only at the method called without the argument, so it lives here too, and an explicit nil stays the JS `null` (`invalid_arguments` from the known-keys guard))
598
+ config = Helpers.merge_config(config)
599
+ config[:logger] = (config[:logger].respond_to?(:spawn) ? config[:logger].spawn(config[:cid]) : nil) || config[:logger]
600
+
601
+ # No challenge processing needed here
602
+ # (`async () => {}` ignores its argument; a zero-parameter lambda would raise ArgumentError when the flow calls it with the `{challenge}` Hash)
603
+ on_challenge = ->(_args) {}
604
+
605
+ on_response = lambda do |args|
606
+ challenge = args[:challenge]
607
+ response = args[:response]
608
+
609
+ # Verify the response against the challenge
610
+ result = ChallengeResponseFlow.verify_challenge_response('ping', challenge.challenge_string, challenge, response, config[:pingTimeout], config)
611
+
612
+ # Propagate the failure signals unchanged: null -> request_expired, false -> verification_failed.
613
+ return result if !result
614
+
615
+ sig = result[:sig]
616
+ verify_result = result[:verifyResult]
617
+
618
+ Helpers.log(config, :info, 'Verification successful', { identifier: sig[:identifier], deviceTag: sig[:deviceTag] })
619
+
620
+ # (an `expires` of nil stays a present nil member, as in `authenticate`)
621
+ return Helpers.clone(
622
+ {
623
+ pinged: true,
624
+
625
+ issuedAt: challenge.data[:iat],
626
+ signedAt: sig[:signedAt],
627
+ verifiedAt: verify_result[:verifiedAt],
628
+
629
+ expires: verify_result[:expires],
630
+ secure: verify_result[:secure],
631
+
632
+ identifier: sig[:identifier],
633
+ identityDomain: sig[:identityDomain],
634
+ lookupCode: sig[:lookupCode],
635
+ actor: sig[:actor],
636
+ actorIdentityDomain: sig[:actorIdentityDomain],
637
+ actorLookupCode: sig[:actorLookupCode],
638
+ groups: sig[:groups],
639
+
640
+ deviceName: sig[:deviceName],
641
+ deviceTag: sig[:deviceTag],
642
+ keys: sig[:keys]
643
+ }
644
+ )
645
+ end
646
+
647
+ return ChallengeResponseFlow.perform('ping', options, { onChallenge: on_challenge, onResponse: on_response }, config)
648
+ end
649
+ end
650
+ end
651
+ end