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,344 @@
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 'error'
21
+ require_relative 'helpers'
22
+ require_relative 'validator'
23
+ require_relative 'identity'
24
+ require_relative 'challenge'
25
+ require_relative 'response'
26
+ require_relative 'response_cipher'
27
+ require_relative 'request_cipher'
28
+ require_relative 'authentication_endpoint'
29
+ require_relative 'config'
30
+
31
+ module Triauth
32
+ # Internal helper that implements the shared 3-step challenge-response flow used by
33
+ # some of the top-level API methods (e.g., `authenticate`).
34
+ #
35
+ # Each API method delegates to `perform()` with a flow `type` and a pair of callbacks
36
+ # (`onChallenge`, `onResponse`) carrying the per-flow logic.
37
+ class ChallengeResponseFlow
38
+ # Executes one stage of the challenge-response flow:
39
+ # - **Stage 1** (when `identifier`+`callbackUrl` are given, `challenge`/`response` are not): builds a challenge
40
+ # that carries a fresh encryption key (`ek`), invokes `onChallenge` to let the caller mutate `challenge.data`,
41
+ # then returns `{challenge, redirectUrl}`. The returned `challenge` is `<challenge>.<decryptionKey>`.
42
+ # - **Stage 3** (when `challenge`+`response` are given): parses both, opens the sealed response with the
43
+ # decryption key of the stored challenge, sanity-checks them against optional `identifier`/`callbackUrl`
44
+ # constraints, then invokes `onResponse` for per-flow verification.
45
+ #
46
+ # @param type [String] Flow type: `'auth'`, `'ping'`, `'attest'`, `'sign'`, or `'stamp'`.
47
+ # @param options [Hash] User-facing options. Stage 1 expects `{identifier, callbackUrl, ext?, sessionKey?}`
48
+ # plus the flow's own options (`sessionKey` is required for every flow except `'auth'`,
49
+ # where it is rejected). Stage 3 expects `{challenge, response, identifier?, callbackUrl?}`
50
+ # (the optional ones, if provided, are additionally checked against the values embedded
51
+ # in the challenge) and rejects every other option as unrecognized. Defaults to `{}`.
52
+ # @param callbacks [Hash{Symbol => Proc}] `{onChallenge: Proc, onResponse: Proc}` -
53
+ # `onChallenge.call({challenge: challenge})` runs after the `Triauth::Challenge` is built (stage 1), giving the caller
54
+ # a chance to populate `challenge.data` (e.g., `msg`, `attest`, `attachments`).
55
+ # `onResponse.call({challenge: challenge, response: response, sessionKey: session_key})` performs verification (stage 3); must return the per-flow
56
+ # success object, `nil` for expired-style failures, or `false` for generic verification failures.
57
+ # `sessionKey` is the session key from opening the sealed response (see `ResponseCipher.open`).
58
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
59
+ # @param flow_options [Array<Symbol>] Names of the flow's own stage-1 options (e.g., `:message`), which `onChallenge`
60
+ # reads from `options` and which stage 3 rejects like any unrecognized option. Defaults to `[]`.
61
+ #
62
+ # @return [Hash] `{challenge: String, redirectUrl: String}` (stage 1), whatever `onResponse` returned
63
+ # (stage 3 success), or `{error: {code: String, reason: String (optional), message: String}}` if any error was caught. When
64
+ # `config[:requireSecure]` is set, a stage-3 result that is not DNSSEC-secure is rejected with
65
+ # `{error: {code: 'dnssec_required', …}}` instead of being returned (see `assert_secure`).
66
+ def self.perform(type, options = {}, callbacks = { onChallenge: nil, onResponse: nil }, config = {}, flow_options = [])
67
+ Helpers.log(config, :info, "Processing Triauth.#{type}")
68
+ ts = Helpers.now
69
+
70
+ begin
71
+ # Ensure only recognized options are present to help avoid misspellings. Stage 3 reads the challenge and the
72
+ # response, and cross-checks an identifier and a callbackUrl against the challenge. Every other option shapes
73
+ # the challenge at stage 1: `ext`, the flow's own options, and `sessionKey` for the flows that follow a sign-in
74
+ # (ping/attest/sign/stamp), not for auth.
75
+ stage3 = options.is_a?(Hash) && !options[:challenge].nil? && !options[:response].nil? # `options !== null && typeof options === 'object'`: a Hash (an Array has no `challenge` member on either side)
76
+ allowed_options = if stage3 # the JS ternary nests a second one for `sessionKey`: an `if` expression (RuboCop `Style/NestedTernaryOperator`)
77
+ %i[challenge response identifier callbackUrl]
78
+ else
79
+ [:identifier, :callbackUrl, :challenge, :response, :ext, *flow_options, *(type != 'auth' ? [:sessionKey] : [])]
80
+ end
81
+
82
+ if !Helpers.has_only_known_properties(options, allowed_options)
83
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Unrecognized function argument' })
84
+ end
85
+
86
+ # Reject config overrides carrying unknown keys (typos that would otherwise be silently ignored,
87
+ # sometimes weakening security - e.g. a misspelled `requireSecure`). We validate the MERGED config,
88
+ # so a stray key is caught whether it came from this call's override or a global Triauth.config
89
+ if !Helpers.has_only_known_properties(config, Triauth::KNOWN_CONFIG_KEYS)
90
+ raise Triauth::Error.new({ code: 'invalid_config', message: 'Unrecognized configuration key' })
91
+ end
92
+
93
+ # Read supported options
94
+ identifier = options[:identifier]
95
+ callback_url = options[:callbackUrl]
96
+ challenge = options[:challenge]
97
+ response = options[:response]
98
+ ext = options[:ext]
99
+ session_key = options[:sessionKey]
100
+
101
+ # Stage 1 - identifier, callbackUrl, and ext (optional) are provided, and we need to build and return a challenge and redirectUrl
102
+ if !identifier.nil? &&
103
+ !callback_url.nil? &&
104
+ challenge.nil? && response.nil?
105
+ validation_error =
106
+ Validator.validate_identifier(identifier)[:errors][0] ||
107
+ Validator.validate_callback_url(callback_url)[:errors][0] ||
108
+ (!ext.nil? && Validator.validate_ext(ext)[:errors][0]) ||
109
+ (!session_key.nil? && Validator.validate_session_key(session_key)[:errors][0])
110
+
111
+ if validation_error
112
+ raise Triauth::Error.new(validation_error)
113
+ end
114
+
115
+ # Every flow that follows a sign-in (all but `auth`) requires the session key of that sign-in
116
+ if type != 'auth' && session_key.nil?
117
+ raise Triauth::Error.new({ code: 'invalid_session_key', reason: 'invalid', message: 'Invalid session key' })
118
+ end
119
+
120
+ Helpers.log(config, :info, 'Request parameters:', { identifier: identifier, callbackUrl: callback_url, ext: ext })
121
+
122
+ identity = Identity.new(identifier, {}, config)
123
+
124
+ # Prepare options for the authentication request
125
+ challenge_options = {}
126
+
127
+ challenge_options[:cburl] = callback_url
128
+
129
+ if !ext.nil?
130
+ challenge_options[:ext] = ext
131
+ end
132
+
133
+ # Build the authentication request for the given identity with the given options - `callbackUrl` and `ext`.
134
+ challenge_obj = Challenge.build(type, identity, challenge_options, config)
135
+
136
+ # The authenticator seals the response to `ek`. The decryption key stays with the stored challenge.
137
+ pair = ResponseCipher.generate_key_pair
138
+ encryption_key = pair[:encryptionKey]
139
+ decryption_key = pair[:decryptionKey]
140
+ challenge_obj.data[:ek] = encryption_key
141
+
142
+ callbacks[:onChallenge].call({ challenge: challenge_obj })
143
+ challenge_string = challenge_obj.to_s
144
+
145
+ # Fail early if the just-built challenge is not itself a valid challenge
146
+ challenge_error = Validator.validate_challenge(challenge_string)[:errors][0]
147
+ if challenge_error
148
+ raise Triauth::Error.new(challenge_error)
149
+ end
150
+
151
+ # A delegated session key is `<domain>:<key>`, and its session is at the authenticator of that domain
152
+ # (`sessionKey?.includes(':')`: nil, or a validated key of one or two parts; `sessionKeyDomain ?`: the empty string is the only falsy value it can hold)
153
+ session_key_domain, session_key_secret = !session_key.nil? && session_key.include?(':') ? session_key.split(':', -1) : ['', session_key]
154
+ authentication_endpoint = session_key_domain != '' ? AuthenticationEndpoint.new(session_key_domain, config) : identity.authentication_endpoint
155
+
156
+ # Check if authentication endpoint has been configured for the domain name
157
+ if !authentication_endpoint.resolve
158
+ raise Triauth::Error.new({ code: 'domain_not_configured', message: 'Domain is not configured for triauth' })
159
+ end
160
+
161
+ # Enforce config.requireSecure if set
162
+ ChallengeResponseFlow.assert_secure(authentication_endpoint.secure, config)
163
+
164
+ # A request that follows a sign-in travels sealed under the session key, with `skid` to find the key.
165
+ # A sign-in has no session key yet, so its challenge travels readable.
166
+ # (`sessionKey ?`: nil, or a validated non-empty string)
167
+ redirect_url_params = !session_key.nil? ? { skid: RequestCipher.skid(session_key_secret), request: RequestCipher.seal(session_key_secret, challenge_string) } : { challenge: challenge_string }
168
+
169
+ redirect_url = authentication_endpoint.url_for(type, redirect_url_params)
170
+ redirect_url_string = redirect_url.to_s
171
+
172
+ # Return the challenge that the client should remember (with the key that opens the response), and a redirectUrl
173
+ # to which the user's web browser should be redirected.
174
+ return Helpers.clone({ challenge: "#{challenge_string}.#{decryption_key}", redirectUrl: redirect_url_string })
175
+
176
+ # Stage 3 - both challenge and response are provided, and we need to verify them against each other and return the result.
177
+ elsif !challenge.nil? &&
178
+ !response.nil?
179
+
180
+ if response == 'false'
181
+ raise Triauth::Error.new({ code: 'request_denied', message: 'Request has been denied' })
182
+ end
183
+
184
+ # The stored challenge is `<challenge>.<decryptionKey>`, and the response is sealed to the challenge's `ek`
185
+ # (`typeof challenge === 'string' ? challenge.split('.') : []`: JS splits `''` into `['']` where Ruby gives `[]`)
186
+ challenge_string, decryption_key, rest = if Helpers.is_string(challenge)
187
+ challenge.empty? ? [''] : challenge.split('.', -1)
188
+ else
189
+ []
190
+ end
191
+
192
+ validation_error =
193
+ Validator.validate_challenge(challenge_string)[:errors][0] ||
194
+ ((!rest.nil? || !Helpers.is_base64url_string(decryption_key) || decryption_key.length != 43) && { code: 'invalid_challenge', reason: 'invalid', message: 'Invalid challenge' }) || # the `&&` yields the Hash or false, falling through as in JS; `.length` is only reached on a base64url String
195
+ Validator.validate_sealed_response(response)[:errors][0] ||
196
+ (!identifier.nil? && Validator.validate_identifier(identifier)[:errors][0]) ||
197
+ (!callback_url.nil? && Validator.validate_callback_url(callback_url)[:errors][0])
198
+
199
+ if validation_error
200
+ raise Triauth::Error.new(validation_error)
201
+ end
202
+
203
+ Helpers.log(config, :info, 'Verifying response with constraints', { type: type, identifier: identifier, callbackUrl: callback_url })
204
+
205
+ # Parse the received response (string) into the Triauth.Response object.
206
+ challenge_obj = nil
207
+
208
+ begin
209
+ challenge_obj = Challenge.from_string(challenge_string, config)
210
+ rescue StandardError => err
211
+ raise Triauth::Error.new({ code: 'invalid_challenge', reason: 'invalid', message: 'Invalid challenge' }, { cause: err })
212
+ end
213
+
214
+ # Every challenge carries the encryption key that its response is sealed to
215
+ encryption_key = challenge_obj.data[:ek]
216
+ if !Helpers.is_base64url_string(encryption_key) || encryption_key.length != 87
217
+ raise Triauth::Error.new({ code: 'invalid_challenge', reason: 'invalid', message: 'Invalid challenge' })
218
+ end
219
+
220
+ # `let envelope, sessionKey;`: the JS `sessionKey` is a new binding of this block, shadowing the stage-1 `const`; here it re-assigns the same local, which stage 3 never reads otherwise
221
+ envelope = nil
222
+ session_key = nil
223
+
224
+ begin
225
+ opened = ResponseCipher.open(encryption_key, decryption_key, response)
226
+ envelope = opened[:response]
227
+ session_key = opened[:sessionKey]
228
+ rescue StandardError => err
229
+ raise Triauth::Error.new({ code: 'invalid_response', reason: 'invalid', message: 'Invalid response' }, { cause: err })
230
+ end
231
+
232
+ response_error = Validator.validate_response(envelope)[:errors][0]
233
+ if response_error
234
+ raise Triauth::Error.new(response_error)
235
+ end
236
+
237
+ response_obj = Response.new(envelope, config)
238
+
239
+ # Make sure the challenge's type matches the flow type, and (if again passed in options)
240
+ # that the identifier and/or callbackUrl matches the one embedded in the challenge.
241
+ if challenge_obj.data[:type] != type ||
242
+ (!identifier.nil? && challenge_obj.identity.identifier != identifier) ||
243
+ (!callback_url.nil? && challenge_obj.data[:cburl] != callback_url)
244
+ raise Triauth::Error.new({ code: 'verification_failed', message: 'Your identity could not be verified' })
245
+ end
246
+
247
+ retval = callbacks[:onResponse].call({ challenge: challenge_obj, response: response_obj, sessionKey: session_key })
248
+
249
+ # `if (retval)`: the per-flow success Hash is truthy, the `nil` and `false` of the three-state contract are not
250
+ if retval
251
+ # Enforce config.requireSecure if set
252
+ ChallengeResponseFlow.assert_secure(retval[:secure].nil? ? retval[:verificationResult]&.[](:secure) : retval[:secure], config)
253
+ return retval
254
+
255
+ elsif retval.nil?
256
+ raise Triauth::Error.new({ code: 'request_expired', message: 'Authentication request has expired, please try again' })
257
+
258
+ else
259
+ raise Triauth::Error.new({ code: 'verification_failed', message: 'Your identity could not be verified' })
260
+
261
+ end
262
+
263
+ # Fail when the given function arguments do not match to any of stages.
264
+ else
265
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Invalid or missing function arguments - expected either {identifier,callbackUrl,ext,...} or {challenge,response,identifier?,callbackUrl?}' })
266
+
267
+ end
268
+ rescue StandardError => err
269
+ return Triauth::Error.process(err, config)
270
+ ensure
271
+ Helpers.log(config, :info, "Completed in #{(Helpers.now - ts).round}ms")
272
+ end
273
+ end
274
+
275
+ # Verifies a single-signature challenge response and enforces the success invariant shared by the
276
+ # `auth`, `ping`, `sign`, and `stamp` flows: the envelope must carry exactly one signature, that
277
+ # signature must be valid and backed by at least one key, its identifier must match the one bound
278
+ # in the challenge, and the challenge's `cburl` must be a valid callback URL whose base URL equals
279
+ # the signature's `via`. (The multi-signature `attest` flow has a different shape - 1 user + N
280
+ # attester signatures - and intentionally does NOT use this helper.)
281
+ #
282
+ # @param type [String] flow type ('auth'|'ping'|'sign'|'stamp'), passed through to Response#verify.
283
+ # @param signed_payload [String] the exact bytes the signature is verified against (the serialized
284
+ # challenge for auth/ping; the human-readable `msg` for sign/stamp).
285
+ # @param challenge [Triauth::Challenge] the re-hydrated challenge.
286
+ # @param response [Triauth::Response] the parsed response envelope.
287
+ # @param timeout [Integer] the flow's freshness window (e.g., `config[:authTimeout]`), subtracted from now.
288
+ # @param config [Hash] effective config (with an already-spawned logger).
289
+ #
290
+ # @return [Hash, nil, false] On success, `{sig: Hash, verifyResult: Hash}` - the verified first
291
+ # signature plus the full multi-signature result (callers read `secure`/`expires` from the
292
+ # latter); `nil` on a time/expiry failure; `false` on any other verification failure.
293
+ def self.verify_challenge_response(type, signed_payload, challenge, response, timeout, config)
294
+ verify_result = response.verify(
295
+ type,
296
+ signed_payload,
297
+ challenge,
298
+ {
299
+ identifier: challenge.identity.identifier,
300
+ notBefore: Helpers.date_now - (timeout.nil? || timeout == 0 ? 0 : timeout), # `timeout || 0`: the JS falsy set of a number, nil and 0
301
+ notAfter: Helpers.date_now,
302
+ minSignatures: 1,
303
+ maxSignatures: 1
304
+ }
305
+ )
306
+
307
+ sig = verify_result && verify_result[:signatures][0]
308
+
309
+ if verify_result && verify_result[:valid] && verify_result[:signatures].length == 1 &&
310
+ sig && sig[:valid] && sig[:keys].length > 0 &&
311
+ sig[:identifier] == challenge.identity.identifier &&
312
+ Validator.validate_callback_url(challenge.data[:cburl])[:valid] && Helpers.get_base_url(challenge.data[:cburl]) == sig[:via]
313
+ return { sig: sig, verifyResult: verify_result }
314
+
315
+ # For time constraints verification failure, signal "expired" (caller surfaces request_expired).
316
+ elsif verify_result.nil?
317
+ # `parseInt(challenge.data.iat, 10)` of a number: a non-number (or a non-finite one) logs `NaN` there and nil here - log data only
318
+ Helpers.log(config, :info, 'Verification failed - request expired', { identifier: challenge.identity.identifier, iat: Helpers.is_number(challenge.data[:iat]) && challenge.data[:iat].finite? ? challenge.data[:iat].to_i : nil })
319
+ return nil
320
+
321
+ # For other falsy signature verification results, signal a generic failure (caller surfaces verification_failed).
322
+ else
323
+ Helpers.log(config, :info, 'Verification failed', { identifier: challenge.identity.identifier })
324
+ return false
325
+
326
+ end
327
+ end
328
+
329
+ # Enforces the optional `config.requireSecure` policy: when the caller has opted in, an
330
+ # otherwise-valid result whose DNS chain was not reported DNSSEC-validated (`secure !== true`)
331
+ # is rejected.
332
+ #
333
+ # @param secure [Boolean, nil] the aggregate DNSSEC-validation flag of the result.
334
+ # @param config [Hash] effective config; `requireSecure` (default false) toggles enforcement.
335
+ # @raise [Triauth::Error] `dnssec_required` when `config[:requireSecure]` is set and `secure` is not strictly `true`.
336
+ def self.assert_secure(secure, config)
337
+ # `if (config.requireSecure && ...)`: the JS falsy set of a caller value (nil, false, 0 and '') spelled out
338
+ require_secure = config[:requireSecure]
339
+ if !(require_secure.nil? || require_secure == false || require_secure == 0 || require_secure == '') && secure != true
340
+ raise Triauth::Error.new({ code: 'dnssec_required', message: 'Your domain does not support DNSSEC, which is required to continue.' })
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,93 @@
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
+ module Triauth
21
+ class << self
22
+ # The global triauth-ruby configuration, exposed as `Triauth.config` and shared by reference with every internal module.
23
+ # Each API method also takes a per-call override Hash as its last argument, and an unknown key in either place is
24
+ # rejected with the error `invalid_config`. The `logger` and `resolver` defaults are filled in by the entry module.
25
+ # The global `config` Hash should not be mutated while API calls are in flight.
26
+ #
27
+ # @return [Hash{Symbol => Object}] the configuration Hash, with the entries
28
+ # `requireSecure` (Boolean),
29
+ # `resolver` (Object, nil),
30
+ # `logger` (Object, nil),
31
+ # `randomSource` (Proc, nil),
32
+ # `maximalAllowedClientClockDrift` (Integer),
33
+ # `maximalAllowedServerClockDrift` (Integer),
34
+ # `authTimeout` (Integer),
35
+ # `pingTimeout` (Integer),
36
+ # `attestTimeout` (Integer),
37
+ # `signTimeout` (Integer),
38
+ # `stampTimeout` (Integer),
39
+ # `cid` (String, false, nil)
40
+ attr_reader :config
41
+ end
42
+
43
+ @config = {
44
+ # When true, all challenge-response API methods (authenticate, ping, attest, sign, stamp), Triauth.check,
45
+ # and Triauth.verify require a successful result to be DNSSEC-secure: any otherwise-valid response that was
46
+ # not reported DNSSEC-validated (`secure !== true`) is rejected with the error `dnssec_required` ("Your domain does not
47
+ # support DNSSEC, which is required to continue."). Leave false (default) to inspect the `secure` property yourself and stay
48
+ # interoperable with non-DNSSEC domains; set true for mission-critical systems that demand strict
49
+ # cryptographic assurance.
50
+ requireSecure: false,
51
+
52
+ resolver: nil,
53
+ logger: nil,
54
+
55
+ # Source of cryptographic randomness used to generate challenge nonces. Must be a callable that
56
+ # receives a byte count and returns a binary String of exactly that many bytes (same shape as `SecureRandom.random_bytes`).
57
+ # Leave as nil to use `SecureRandom.random_bytes` (default). You may
58
+ # override this with a higher-entropy or hardware-backed RNG, or - in test scenarios - with
59
+ # a deterministic byte stream to make nonces reproducible.
60
+ randomSource: nil,
61
+
62
+ # The maximal allowed clock drift between the current clock (e.g., server time), and the end-user's clock (as seen by the web browser and Triauth Authenticator app),
63
+ # including network round-trip time and any request pre-processing delays.
64
+ # Applied when checking timestamps generated by the user's device (e.g., the `ts` embedded in signatures).
65
+ maximalAllowedClientClockDrift: 30 * 1000,
66
+
67
+ # The maximal allowed clock drift between your own servers - e.g., when, due to load balancing, a challenge
68
+ # is generated (stage 1) and verified (stage 3) by two different hosts with imperfectly synchronized clocks.
69
+ # Applied when checking timestamps generated by your own servers (the challenge `iat` freshness window).
70
+ # Keep it small: well-managed (NTP-synchronized) fleets stay within milliseconds of each other.
71
+ maximalAllowedServerClockDrift: 5 * 1000,
72
+
73
+ # Timeout for the user to approve the request before it is considered expired (basing on challenge `iat` time, widened by `maximalAllowedServerClockDrift`).
74
+ authTimeout: 3 * 60_000,
75
+ pingTimeout: 15_000,
76
+ attestTimeout: 15 * 60_000,
77
+ signTimeout: 30 * 60_000,
78
+ stampTimeout: 15_000,
79
+
80
+ # The correlation id, emitted by the logger to trace related log entries across the system. Three modes:
81
+ # - nil (default): "autofill" - the logger mints a fresh id for each API call (shown as `[cid:...]`).
82
+ # - false: explicit opt-out - no `[cid:...]` tag is emitted.
83
+ # - string: use this exact id for the call.
84
+ cid: nil
85
+ }
86
+
87
+ # Frozen snapshot of the recognized `Triauth.config` keys, captured at module load.
88
+ #
89
+ # The public API methods use this to reject config overrides that carry an unknown key (error `invalid_config`).
90
+ #
91
+ # @return [Array<Symbol>] frozen
92
+ KNOWN_CONFIG_KEYS = config.keys.freeze
93
+ end
@@ -0,0 +1,95 @@
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
+ module Triauth
21
+ # The base error type used throughout triauth-ruby, carrying the `code`, `reason`, and `message` of the
22
+ # error object that Triauth API methods return, and `data` for the log entry.
23
+ class Error < StandardError
24
+ # The issue tracker to which internal errors should be reported
25
+ ISSUES_URL = 'https://github.com/triauth/triauth-ruby/issues'
26
+ private_constant :ISSUES_URL
27
+
28
+ # @return [String] The error code (e.g., `request_denied`).
29
+ attr_reader :code
30
+ # @return [String, nil] The reason (only on errors about one field, e.g., `too_long`).
31
+ attr_reader :reason
32
+ # @return [Hash] Optional metadata, with `cause` stripped.
33
+ attr_reader :data
34
+ # @return [Time] The time the error was created.
35
+ attr_reader :ts
36
+
37
+ # @param error [Hash] The error object returned by Triauth API methods, `{code: String, reason: String (optional), message: String}`: the error
38
+ # code (e.g., `request_denied`), the reason (only on errors about one field, e.g., `too_long`),
39
+ # and a human-readable message.
40
+ # @param data [Hash] Optional metadata; `cause` (if present) is stripped from `data` (defaults to `{}`).
41
+ def initialize(error, data = {})
42
+ super(error[:message])
43
+
44
+ # `this.name = this.constructor.name` has no counterpart: a Ruby exception reports its class name itself
45
+
46
+ @code = error[:code]
47
+ @reason = error[:reason]
48
+
49
+ @data = data.dup
50
+ @data.delete(:cause) # the JS `cause` is Ruby's own `Exception#cause`, set automatically when raised inside a `rescue`
51
+
52
+ @ts = Time.now
53
+ end
54
+
55
+ # Normalizes any thrown value into the `{error: {code, reason?, message}}` shape returned by Triauth API methods,
56
+ # and logs it via `config[:logger]`. Validation errors (the errors about one field, which carry a `reason`) are
57
+ # logged at `info`; other `Triauth::Error`s at `warn`. Anything else is an internal error (`internal_error`) logged
58
+ # at `error` with the original error as `cause`.
59
+ #
60
+ # @param err [Object] The thrown value (typically a `Triauth::Error`, but anything is accepted).
61
+ # @param config [Hash] Effective per-call config; only `config[:logger]` is consulted here.
62
+ #
63
+ # @return [Hash] `{error: {code: String, reason: String (optional), message: String}}`
64
+ def self.process(err, config)
65
+ if err.is_a?(Error)
66
+
67
+ # Validation errors are logged with info severity
68
+ if !err.reason.nil?
69
+ Helpers.log(config, :info, "Validation error - #{err.message} (#{err.code})", { cause: err })
70
+ else
71
+ Helpers.log(config, :warn, "#{err.message} (#{err.code})", { cause: err })
72
+ end
73
+
74
+ return {
75
+ error: err.reason.nil? ? { code: err.code, message: err.message } : { code: err.code, reason: err.reason, message: err.message }
76
+ }
77
+
78
+ else
79
+ Helpers.log(
80
+ config, :error,
81
+ "Internal error: this is a defect in triauth-ruby - please report it at #{ISSUES_URL} together with the stack trace",
82
+ { cause: err }
83
+ )
84
+
85
+ return {
86
+ error: {
87
+ code: 'internal_error',
88
+ message: 'Internal error'
89
+ }
90
+ }
91
+
92
+ end
93
+ end
94
+ end
95
+ end