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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/LICENSE +202 -28
- data/README.md +2036 -7
- data/lib/triauth/api/attestation.rb +419 -0
- data/lib/triauth/api/authentication.rb +651 -0
- data/lib/triauth/api/identity.rb +245 -0
- data/lib/triauth/api/signing.rb +740 -0
- data/lib/triauth/api/validation.rb +162 -0
- data/lib/triauth/api.rb +33 -0
- data/lib/triauth/authentication_endpoint.rb +196 -0
- data/lib/triauth/challenge.rb +115 -0
- data/lib/triauth/challenge_response_flow.rb +344 -0
- data/lib/triauth/config.rb +93 -0
- data/lib/triauth/error.rb +95 -0
- data/lib/triauth/helpers.rb +1222 -0
- data/lib/triauth/identity.rb +301 -0
- data/lib/triauth/identity_domain.rb +212 -0
- data/lib/triauth/identity_includes.rb +249 -0
- data/lib/triauth/identity_keys.rb +309 -0
- data/lib/triauth/logger.rb +156 -0
- data/lib/triauth/multi_signature.rb +177 -0
- data/lib/triauth/protocol.rb +74 -0
- data/lib/triauth/request_cipher.rb +121 -0
- data/lib/triauth/resolvers/base.rb +147 -0
- data/lib/triauth/resolvers/caching_resolver.rb +202 -0
- data/lib/triauth/resolvers/cloudflare.rb +40 -0
- data/lib/triauth/resolvers/dns_json.rb +391 -0
- data/lib/triauth/resolvers/dns_sb.rb +40 -0
- data/lib/triauth/resolvers/google.rb +39 -0
- data/lib/triauth/resolvers/multi_resolver.rb +202 -0
- data/lib/triauth/resolvers/system_dns.rb +259 -0
- data/lib/triauth/resolvers.rb +33 -0
- data/lib/triauth/response.rb +142 -0
- data/lib/triauth/response_cipher.rb +341 -0
- data/lib/triauth/signature.rb +405 -0
- data/lib/triauth/validator.rb +549 -0
- data/lib/triauth/verifiers/base.rb +147 -0
- data/lib/triauth/verifiers/ecdsa.rb +87 -0
- data/lib/triauth/verifiers/ed25519.rb +89 -0
- data/lib/triauth/verifiers/registry.rb +52 -0
- data/lib/triauth/verifiers/web_authn.rb +253 -0
- data/lib/triauth/verifiers.rb +37 -0
- data/lib/triauth/version.rb +23 -0
- data/lib/triauth.rb +104 -7
- 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
|