touchque 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +105 -0
- data/LICENSE +21 -0
- data/README.md +383 -0
- data/lib/touchque/agent.rb +211 -0
- data/lib/touchque/agent_card.rb +159 -0
- data/lib/touchque/approvals.rb +214 -0
- data/lib/touchque/client.rb +81 -0
- data/lib/touchque/code_reply.rb +53 -0
- data/lib/touchque/cross_app_access.rb +204 -0
- data/lib/touchque/crypto.rb +215 -0
- data/lib/touchque/errors.rb +246 -0
- data/lib/touchque/flow.rb +362 -0
- data/lib/touchque/hosted_login.rb +401 -0
- data/lib/touchque/http.rb +127 -0
- data/lib/touchque/login_session.rb +86 -0
- data/lib/touchque/org_agent.rb +398 -0
- data/lib/touchque/rack/relay.rb +75 -0
- data/lib/touchque/rails/guard.rb +132 -0
- data/lib/touchque/rails/hosted_login.rb +143 -0
- data/lib/touchque/rails/sessions_controller.rb +12 -0
- data/lib/touchque/railtie.rb +34 -0
- data/lib/touchque/relay.rb +533 -0
- data/lib/touchque/resources/actions.rb +212 -0
- data/lib/touchque/resources/agents.rb +251 -0
- data/lib/touchque/resources/device_risk.rb +130 -0
- data/lib/touchque/resources/identity.rb +331 -0
- data/lib/touchque/resources/login.rb +325 -0
- data/lib/touchque/resources/mobile.rb +115 -0
- data/lib/touchque/resources/people.rb +237 -0
- data/lib/touchque/resources/qr_pairings.rb +82 -0
- data/lib/touchque/resources/safe_guard.rb +87 -0
- data/lib/touchque/resources/webhook.rb +177 -0
- data/lib/touchque/safe_approval.rb +283 -0
- data/lib/touchque/security_events.rb +162 -0
- data/lib/touchque/util.rb +260 -0
- data/lib/touchque/version.rb +5 -0
- data/lib/touchque.rb +124 -0
- metadata +83 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
# What the relay wants sent back: a status, a JSON body and headers. +on_authenticated+
|
|
5
|
+
# receives the pending success response and may change it (e.g. add a Set-Cookie);
|
|
6
|
+
# the handler sets the body afterwards.
|
|
7
|
+
class RelayResponse
|
|
8
|
+
attr_accessor :status, :body
|
|
9
|
+
attr_reader :headers
|
|
10
|
+
|
|
11
|
+
def initialize(status = 200, body = {})
|
|
12
|
+
@status = status
|
|
13
|
+
@body = body
|
|
14
|
+
@headers = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Sets a header, replacing any value it had.
|
|
18
|
+
def set_header(name, value)
|
|
19
|
+
@headers.reject! { |(n, _)| n.casecmp?(name) }
|
|
20
|
+
@headers << [name, value.to_s]
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Adds a header value, keeping existing ones (several Set-Cookie).
|
|
25
|
+
def add_header(name, value)
|
|
26
|
+
@headers << [name, value.to_s]
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def header(name)
|
|
31
|
+
@headers.find { |(n, _)| n.casecmp?(name) }&.last
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_json(*)
|
|
35
|
+
Util.json(@body)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Counters for the relay's rate limits. +hit+ must atomically count one hit for +key+
|
|
40
|
+
# in its fixed window and return [count, reset_at_ms]. Several processes (Puma
|
|
41
|
+
# clusters, several hosts): implement it over Redis (INCR + PEXPIRE, then PTTL).
|
|
42
|
+
class MemoryRateLimitStore
|
|
43
|
+
def initialize(max_entries: 50_000)
|
|
44
|
+
@max_entries = [max_entries, 2].max
|
|
45
|
+
@entries = {}
|
|
46
|
+
@lock = Mutex.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hit(key, window_ms, now_ms)
|
|
50
|
+
@lock.synchronize do
|
|
51
|
+
count, reset_at = @entries[key]
|
|
52
|
+
if count.nil? || reset_at <= now_ms
|
|
53
|
+
prune(now_ms) if count.nil? && @entries.size >= @max_entries
|
|
54
|
+
count = 0
|
|
55
|
+
reset_at = now_ms + window_ms
|
|
56
|
+
end
|
|
57
|
+
@entries[key] = [count + 1, reset_at]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def prune(now_ms)
|
|
64
|
+
@entries.delete_if { |_, (_, reset_at)| reset_at <= now_ms }
|
|
65
|
+
# Still full (a flood of distinct keys): drop the oldest half rather than grow without bound.
|
|
66
|
+
@entries.shift while @entries.size >= @max_entries / 2 + 1 if @entries.size >= @max_entries
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Fixed-window rate limiter for the relay's pre-auth routes.
|
|
71
|
+
class FixedWindowLimiter
|
|
72
|
+
attr_reader :per_ip, :per_user
|
|
73
|
+
|
|
74
|
+
def initialize(window_seconds: 60, per_ip: 20, per_user: 5, store: nil)
|
|
75
|
+
@window_ms = (window_seconds * 1000).to_i
|
|
76
|
+
@per_ip = per_ip
|
|
77
|
+
@per_user = per_user
|
|
78
|
+
@store = store || MemoryRateLimitStore.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Counts one hit. Seconds to wait when over +limit+, otherwise 0.
|
|
82
|
+
def hit(key, limit, now_ms = Util.now_ms)
|
|
83
|
+
count, reset_at = @store.hit(key, @window_ms, now_ms)
|
|
84
|
+
return 0 if count <= limit
|
|
85
|
+
|
|
86
|
+
[1, ((reset_at - now_ms) / 1000.0).ceil].max
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Framework-agnostic relay: the routes a frontend (@touchque/web) needs for push 2FA,
|
|
91
|
+
# offline codes and passkeys, so you don't hand-write them (Touchque::Rack::Relay
|
|
92
|
+
# mounts it in Rails, Sinatra or any Rack app):
|
|
93
|
+
#
|
|
94
|
+
# POST /enroll/start (signed in) generate_secret answer
|
|
95
|
+
# GET /enroll/status (signed in) {linked, used, deviceId}
|
|
96
|
+
# POST /login (pre-auth) 202 {touchque: step, token} until approved
|
|
97
|
+
# POST /offline/challenge|verify|totp (pre-auth) offline codes
|
|
98
|
+
# POST /passkey/authenticate/options|verify passwordless sign-in (no email: any passkey)
|
|
99
|
+
# POST /passkey/login/options|verify approve a pending request with a passkey
|
|
100
|
+
# POST /passkey/register/options|verify (signed in) the phone approves adding a passkey first
|
|
101
|
+
# GET /passkey/credentials (signed in)
|
|
102
|
+
# DELETE /passkey/credentials/{id} (signed in)
|
|
103
|
+
#
|
|
104
|
+
# Options (callables get the native request you pass to +handle+):
|
|
105
|
+
# get_user_id (required) the signed-in user, for the signed-in routes.
|
|
106
|
+
# get_login_user the user who passed YOUR first factor. STRONGLY RECOMMENDED: /login
|
|
107
|
+
# and the offline routes then act for this user and ignore the body's.
|
|
108
|
+
# rate_limit { window_seconds:, per_ip: 20, per_user: 5, store: } or false.
|
|
109
|
+
# allow_offline_without_first_factor let the offline routes trust the body's username.
|
|
110
|
+
# action_type for /login and the offline routes (default LOGIN).
|
|
111
|
+
# get_details transaction context, built from server-side state.
|
|
112
|
+
# on_authenticated ->(req, response, info) right before a success answer: open your
|
|
113
|
+
# session here; info: {"externalUsername", "requestId", "via"}
|
|
114
|
+
# (via: login, passkey, offline, offline-totp, silent).
|
|
115
|
+
# silent_sign_in true or { max_age_seconds: }: a known browser with nothing new skips the push.
|
|
116
|
+
class RelayHandler
|
|
117
|
+
POLLS_PER_ATTEMPT = 10
|
|
118
|
+
PASSKEY_ENROLL_ACTION = "PASSKEY_ENROLL"
|
|
119
|
+
ROUTES = {
|
|
120
|
+
"POST /enroll/start" => :enroll_start, "GET /enroll/status" => :enroll_status, "POST /login" => :login,
|
|
121
|
+
"POST /offline/challenge" => :offline_challenge, "POST /offline/verify" => :offline_verify, "POST /offline/totp" => :offline_totp,
|
|
122
|
+
"POST /passkey/authenticate/options" => :passkey_authenticate_options, "POST /passkey/authenticate/verify" => :passkey_authenticate_verify,
|
|
123
|
+
"POST /passkey/login/options" => :passkey_login_options, "POST /passkey/login/verify" => :passkey_login_verify,
|
|
124
|
+
"POST /passkey/register/options" => :passkey_register_options, "POST /passkey/register/verify" => :passkey_register_verify,
|
|
125
|
+
"GET /passkey/credentials" => :passkey_credentials
|
|
126
|
+
}.freeze
|
|
127
|
+
|
|
128
|
+
@warned = false
|
|
129
|
+
|
|
130
|
+
class << self
|
|
131
|
+
attr_accessor :warned
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def initialize(client, get_user_id:, get_login_user: nil, rate_limit: {}, allow_offline_without_first_factor: false,
|
|
135
|
+
action_type: "LOGIN", get_details: nil, on_authenticated: nil, silent_sign_in: false)
|
|
136
|
+
raise ArgumentError, "RelayHandler: get_user_id must be callable" unless get_user_id.respond_to?(:call)
|
|
137
|
+
|
|
138
|
+
Guard.assert_silent_sign_in!(silent_sign_in, "RelayHandler")
|
|
139
|
+
@tq = client
|
|
140
|
+
@get_user_id = get_user_id
|
|
141
|
+
@get_login_user = get_login_user
|
|
142
|
+
@get_details = get_details
|
|
143
|
+
@on_authenticated = on_authenticated
|
|
144
|
+
@action_type = action_type
|
|
145
|
+
@silent_sign_in = silent_sign_in
|
|
146
|
+
@limiter = rate_limit == false ? nil : FixedWindowLimiter.new(**(rate_limit || {}))
|
|
147
|
+
@offline_enabled = !get_login_user.nil? || allow_offline_without_first_factor == true
|
|
148
|
+
return if get_login_user || self.class.warned
|
|
149
|
+
|
|
150
|
+
self.class.warned = true
|
|
151
|
+
Touchque.log :warn, "[Touchque] RelayHandler: POST /login takes the username from the request body" \
|
|
152
|
+
"#{@offline_enabled ? ", and so do the offline routes" : "; the offline routes are disabled until you set it"}" \
|
|
153
|
+
". Set get_login_user to bind them to your first factor (password step) so they cannot be used against arbitrary users."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Handles one relay request; nil when no relay route matches (answer 404 yourself).
|
|
157
|
+
# path: relative to where the relay is mounted ("/login"). body: the decoded JSON
|
|
158
|
+
# (a Hash). ip: the real client IP ("" when unknown). header: ->(name) { value }.
|
|
159
|
+
def handle(method:, path:, body: {}, ip: "", user_agent: nil, header: ->(_) {}, request: nil)
|
|
160
|
+
method = method.to_s.upcase
|
|
161
|
+
method = "GET" if method == "HEAD"
|
|
162
|
+
path = "/" + path.to_s.split("?", 2).first.to_s.gsub(%r{\A/+|/+\z}, "")
|
|
163
|
+
ctx = { body: body.is_a?(Hash) ? Util.stringify(body) : {}, ip: ip.to_s, user_agent: user_agent, header: header, req: request }
|
|
164
|
+
route = ROUTES["#{method} #{path.downcase}"]
|
|
165
|
+
return send(route, ctx) if route
|
|
166
|
+
|
|
167
|
+
if method == "DELETE" && (m = path.match(%r{\A/passkey/credentials/([^/]+)\z}i))
|
|
168
|
+
return delete_credential(ctx, m[1])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def enroll_start(ctx)
|
|
177
|
+
user_id = user_id(ctx)
|
|
178
|
+
return unauthorized unless user_id
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
RelayResponse.new(200, @tq.auth.generate_secret(user_id))
|
|
182
|
+
rescue APIError => e
|
|
183
|
+
return relay_error(e) unless e.status == 409
|
|
184
|
+
|
|
185
|
+
# 409: a QR was issued and not scanned yet (safe to issue a new one), or a phone is
|
|
186
|
+
# ALREADY linked. Replacing a linked phone must never happen from a plain session.
|
|
187
|
+
begin
|
|
188
|
+
current = @tq.auth.get_user(user_id)
|
|
189
|
+
if current["used"] || current["deviceId"]
|
|
190
|
+
return RelayResponse.new(409, { "error" => "already_linked", "message" => "A phone is already linked to this account." })
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
RelayResponse.new(200, @tq.auth.reset_secret(user_id))
|
|
194
|
+
rescue StandardError => retry_error
|
|
195
|
+
relay_error(retry_error)
|
|
196
|
+
end
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
relay_error(e)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def enroll_status(ctx)
|
|
203
|
+
user_id = user_id(ctx)
|
|
204
|
+
return unauthorized unless user_id
|
|
205
|
+
|
|
206
|
+
u = @tq.auth.get_user(user_id)
|
|
207
|
+
RelayResponse.new(200, { "linked" => !!(u["used"] && u["deviceId"]), "used" => u["used"], "deviceId" => u["deviceId"] })
|
|
208
|
+
rescue APIError => e
|
|
209
|
+
return RelayResponse.new(200, { "linked" => false, "used" => false, "deviceId" => nil }) if e.status == 404
|
|
210
|
+
|
|
211
|
+
relay_error(e)
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
relay_error(e)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def login(ctx)
|
|
217
|
+
user, refusal = login_user(ctx)
|
|
218
|
+
return refusal if refusal
|
|
219
|
+
|
|
220
|
+
input = Guard.input_from_headers(ctx[:header])
|
|
221
|
+
details = @get_details&.call(ctx[:req])
|
|
222
|
+
proof = Guard.silent_on?(@silent_sign_in) ? Guard.presence(ctx[:header].call(Guard::BROWSER_HEADER)) : nil
|
|
223
|
+
# Polling an approval already sent is not a new attempt: its own, larger per-IP budget.
|
|
224
|
+
limited = if Guard.status_poll?(@tq, user, @action_type, details: details, token: input[:token], offline: input[:offline], code: input[:code])
|
|
225
|
+
allow(ctx, "login-poll", nil, POLLS_PER_ATTEMPT)
|
|
226
|
+
else
|
|
227
|
+
allow(ctx, "login", user)
|
|
228
|
+
end
|
|
229
|
+
return limited if limited
|
|
230
|
+
|
|
231
|
+
result = Guard.run(@tq, user, @action_type, details: details, ip: ctx[:ip].empty? ? nil : ctx[:ip], user_agent: ctx[:user_agent],
|
|
232
|
+
token: input[:token], offline: input[:offline], code: input[:code], code_type: input[:code_type],
|
|
233
|
+
# Pre-auth: a typed username proves nothing, so no linking QR unless get_login_user ties this route to the password step.
|
|
234
|
+
enroll: !@get_login_user.nil?, browser_proof: proof, silent_sign_in: @silent_sign_in)
|
|
235
|
+
return guard_response(result) unless result[:approved]
|
|
236
|
+
|
|
237
|
+
approved = result[:approved]
|
|
238
|
+
res = RelayResponse.new(200)
|
|
239
|
+
begin
|
|
240
|
+
if @on_authenticated
|
|
241
|
+
via = { "OFFLINE_TOTP" => "offline-totp", "OFFLINE_CODE" => "offline", "SILENT" => "silent" }.fetch(approved["confirmedVia"], "login")
|
|
242
|
+
@on_authenticated.call(ctx[:req], res, { "externalUsername" => user, "requestId" => approved["requestId"], "via" => via })
|
|
243
|
+
end
|
|
244
|
+
res.body = { "status" => "success", "requestId" => approved["requestId"], "assurance" => approved["assurance"] }
|
|
245
|
+
res
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
auth_result_error(e)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def guard_response(result)
|
|
252
|
+
res = RelayResponse.new(result[:status], result[:body])
|
|
253
|
+
retry_after = result[:body].dig("touchque", "retryAfter")
|
|
254
|
+
res.set_header("Retry-After", retry_after) if retry_after
|
|
255
|
+
res.set_header("Cache-Control", "no-store")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The username used for on_authenticated is the one Touchque returns for the code, never the body's.
|
|
259
|
+
def offline_challenge(ctx)
|
|
260
|
+
return offline_disabled unless @offline_enabled
|
|
261
|
+
|
|
262
|
+
user, refusal = login_user(ctx)
|
|
263
|
+
return refusal if refusal
|
|
264
|
+
|
|
265
|
+
limited = allow(ctx, "offline-challenge", user)
|
|
266
|
+
return limited if limited
|
|
267
|
+
|
|
268
|
+
c = @tq.offline.challenge(user, type: @action_type, details: @get_details&.call(ctx[:req]), client_ip: ctx[:ip].empty? ? nil : ctx[:ip],
|
|
269
|
+
user_agent: ctx[:user_agent], request_id: body_request_id(ctx[:body]))
|
|
270
|
+
out = c.slice("challengeId", "qr", "qrDataUrl", "expiresAt", "expiresInSeconds", "totpAvailable")
|
|
271
|
+
out["challengeCode"] = c["challengeCode"] if c["challengeCode"] && !c["challengeCode"].empty?
|
|
272
|
+
RelayResponse.new(200, out)
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
relay_error(e)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def offline_verify(ctx)
|
|
278
|
+
return offline_disabled unless @offline_enabled
|
|
279
|
+
|
|
280
|
+
limited = allow(ctx, "offline-verify")
|
|
281
|
+
return limited if limited
|
|
282
|
+
|
|
283
|
+
r = @tq.offline.verify(body_string(ctx[:body], "challengeId"), body_string(ctx[:body], "code"))
|
|
284
|
+
if !r["approved"] || r["externalUsername"].to_s.empty?
|
|
285
|
+
return RelayResponse.new(r["reason"] == "invalid_code" ? 401 : 400, { "approved" => false }.merge(r.slice("reason", "attemptsLeft").compact))
|
|
286
|
+
end
|
|
287
|
+
# With a first factor, the code must belong to the user who passed it.
|
|
288
|
+
if @get_login_user && !same_user?(@get_login_user.call(ctx[:req]), r["externalUsername"])
|
|
289
|
+
return RelayResponse.new(401, { "approved" => false, "reason" => "invalid_code" })
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
res = RelayResponse.new(200)
|
|
293
|
+
@on_authenticated&.call(ctx[:req], res, { "externalUsername" => r["externalUsername"], "requestId" => r["challengeId"], "via" => "offline" })
|
|
294
|
+
res.body = { "status" => "success", "approved" => true, "requestId" => r["challengeId"] }.compact
|
|
295
|
+
res
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
relay_error(e)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def offline_totp(ctx)
|
|
301
|
+
return offline_disabled unless @offline_enabled
|
|
302
|
+
|
|
303
|
+
user, refusal = login_user(ctx)
|
|
304
|
+
return refusal if refusal
|
|
305
|
+
|
|
306
|
+
limited = allow(ctx, "offline-totp", user)
|
|
307
|
+
return limited if limited
|
|
308
|
+
|
|
309
|
+
r = @tq.offline.verify_totp(user, body_string(ctx[:body], "code"), type: @action_type, client_ip: ctx[:ip].empty? ? nil : ctx[:ip],
|
|
310
|
+
request_id: body_request_id(ctx[:body]))
|
|
311
|
+
if !r["approved"] || r["externalUsername"].to_s.empty?
|
|
312
|
+
return RelayResponse.new(r["reason"] == "invalid_code" ? 401 : 400, { "approved" => false }.merge(r.slice("reason").compact))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
res = RelayResponse.new(200)
|
|
316
|
+
@on_authenticated&.call(ctx[:req], res, { "externalUsername" => r["externalUsername"], "via" => "offline-totp" })
|
|
317
|
+
res.body = { "status" => "success", "approved" => true }
|
|
318
|
+
res
|
|
319
|
+
rescue StandardError => e
|
|
320
|
+
relay_error(e)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def passkey_authenticate_options(ctx)
|
|
324
|
+
email = body_string(ctx[:body], "email").downcase
|
|
325
|
+
limited = allow(ctx, "passkey-options", email.empty? ? nil : email)
|
|
326
|
+
return limited if limited
|
|
327
|
+
|
|
328
|
+
RelayResponse.new(200, @tq.webauthn.primary_options(email.empty? ? nil : email))
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
relay_error(e)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def passkey_authenticate_verify(ctx)
|
|
334
|
+
limited = allow(ctx, "passkey-verify")
|
|
335
|
+
return limited if limited
|
|
336
|
+
|
|
337
|
+
r = @tq.webauthn.primary_verify(body_string(ctx[:body], "attemptId"), body_hash(ctx[:body], "response"))
|
|
338
|
+
if r["success"]
|
|
339
|
+
res = RelayResponse.new(200)
|
|
340
|
+
@on_authenticated&.call(ctx[:req], res, { "externalUsername" => r["externalUsername"], "requestId" => r["requestId"], "via" => "passkey" })
|
|
341
|
+
res.body = { "status" => "success", "requestId" => r["requestId"] }.compact
|
|
342
|
+
return res
|
|
343
|
+
end
|
|
344
|
+
return RelayResponse.new(200, { "requiresStepUp" => true }) if r["requiresStepUp"]
|
|
345
|
+
|
|
346
|
+
RelayResponse.new(401, { "error" => "verification_failed" })
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
relay_error(e)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def passkey_login_options(ctx)
|
|
352
|
+
limited = allow(ctx, "passkey-approve")
|
|
353
|
+
return limited if limited
|
|
354
|
+
|
|
355
|
+
RelayResponse.new(200, @tq.webauthn.authenticate_options(body_string(ctx[:body], "requestId")))
|
|
356
|
+
rescue StandardError => e
|
|
357
|
+
relay_error(e)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def passkey_login_verify(ctx)
|
|
361
|
+
limited = allow(ctx, "passkey-approve")
|
|
362
|
+
return limited if limited
|
|
363
|
+
|
|
364
|
+
r = @tq.webauthn.authenticate_verify(body_string(ctx[:body], "requestId"), body_hash(ctx[:body], "response"))
|
|
365
|
+
who = r["externalUsername"]
|
|
366
|
+
# Open the session only for the user Touchque says was approved; a name the browser sent is never trusted.
|
|
367
|
+
if r["success"] && @get_login_user && who.is_a?(String) && !same_user?(@get_login_user.call(ctx[:req]), who)
|
|
368
|
+
return RelayResponse.new(401, { "error" => "verification_failed" })
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
res = RelayResponse.new(200)
|
|
372
|
+
if r["success"] && @on_authenticated && who.is_a?(String) && !who.empty?
|
|
373
|
+
request_id = r["requestId"].to_s.empty? ? ctx[:body]["requestId"] : r["requestId"]
|
|
374
|
+
@on_authenticated.call(ctx[:req], res, { "externalUsername" => who, "requestId" => request_id, "via" => "passkey" })
|
|
375
|
+
end
|
|
376
|
+
res.body = r
|
|
377
|
+
res
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
relay_error(e)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# A user with a linked phone approves adding a passkey there first: a passkey approves
|
|
383
|
+
# requests on its own, so a stolen session must not be able to add one.
|
|
384
|
+
def passkey_register_options(ctx)
|
|
385
|
+
user_id = user_id(ctx)
|
|
386
|
+
return unauthorized unless user_id
|
|
387
|
+
|
|
388
|
+
input = Guard.input_from_headers(ctx[:header])
|
|
389
|
+
unless input[:token]
|
|
390
|
+
begin
|
|
391
|
+
return RelayResponse.new(200, @tq.webauthn.register_options(user_id, discoverable: true))
|
|
392
|
+
rescue APIError => e
|
|
393
|
+
raise unless e.status == 403 && e.code == "enrollment_approval_required"
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
limited = if Guard.status_poll?(@tq, user_id, PASSKEY_ENROLL_ACTION, token: input[:token], offline: input[:offline], code: input[:code])
|
|
397
|
+
allow(ctx, "login-poll", nil, POLLS_PER_ATTEMPT)
|
|
398
|
+
else
|
|
399
|
+
allow(ctx, "passkey-enroll", user_id)
|
|
400
|
+
end
|
|
401
|
+
return limited if limited
|
|
402
|
+
|
|
403
|
+
result = Guard.run(@tq, user_id, PASSKEY_ENROLL_ACTION, ip: ctx[:ip].empty? ? nil : ctx[:ip], user_agent: ctx[:user_agent],
|
|
404
|
+
token: input[:token], offline: input[:offline], code: input[:code], code_type: input[:code_type], enroll: false)
|
|
405
|
+
return guard_response(result) unless result[:approved]
|
|
406
|
+
|
|
407
|
+
RelayResponse.new(200, @tq.webauthn.register_options(user_id, discoverable: true, approval_request_id: result[:approved]["requestId"]))
|
|
408
|
+
rescue StandardError => e
|
|
409
|
+
relay_error(e)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def passkey_register_verify(ctx)
|
|
413
|
+
user_id = user_id(ctx)
|
|
414
|
+
return unauthorized unless user_id
|
|
415
|
+
|
|
416
|
+
label = ctx[:body]["label"].is_a?(String) ? ctx[:body]["label"] : nil
|
|
417
|
+
RelayResponse.new(200, @tq.webauthn.register_verify(user_id, body_hash(ctx[:body], "response"), label: label))
|
|
418
|
+
rescue StandardError => e
|
|
419
|
+
relay_error(e)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def passkey_credentials(ctx)
|
|
423
|
+
user_id = user_id(ctx)
|
|
424
|
+
return unauthorized unless user_id
|
|
425
|
+
|
|
426
|
+
RelayResponse.new(200, @tq.webauthn.list_credentials(user_id))
|
|
427
|
+
rescue StandardError => e
|
|
428
|
+
relay_error(e)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def delete_credential(ctx, raw_id)
|
|
432
|
+
user_id = user_id(ctx)
|
|
433
|
+
return unauthorized unless user_id
|
|
434
|
+
|
|
435
|
+
id = begin
|
|
436
|
+
URI.decode_www_form_component(raw_id)
|
|
437
|
+
rescue ArgumentError
|
|
438
|
+
# A malformed %-encoding (%zz) is the browser's mistake, not a relay failure.
|
|
439
|
+
return RelayResponse.new(400, { "error" => "invalid_credential_id" })
|
|
440
|
+
end
|
|
441
|
+
RelayResponse.new(200, @tq.webauthn.delete_credential(id, external_username: user_id))
|
|
442
|
+
rescue StandardError => e
|
|
443
|
+
relay_error(e)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def user_id(ctx)
|
|
447
|
+
v = @get_user_id.call(ctx[:req])
|
|
448
|
+
v.nil? || v == false || v.to_s.empty? ? nil : v.to_s
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Who a pre-auth route acts for: the first-factor user when configured, otherwise the typed username.
|
|
452
|
+
def login_user(ctx)
|
|
453
|
+
if @get_login_user
|
|
454
|
+
u = @get_login_user.call(ctx[:req])
|
|
455
|
+
return [nil, RelayResponse.new(401, { "error" => "FIRST_FACTOR_REQUIRED", "message" => "Sign in with your password first." })] if u.nil? || u == false || u.to_s.empty?
|
|
456
|
+
|
|
457
|
+
return [u.to_s.downcase, nil]
|
|
458
|
+
end
|
|
459
|
+
typed = body_string(ctx[:body], "externalUsername").downcase
|
|
460
|
+
return [nil, RelayResponse.new(400, { "error" => "externalUsername_required" })] if typed.empty?
|
|
461
|
+
|
|
462
|
+
[typed, nil]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# nil when allowed; otherwise the 429 to send.
|
|
466
|
+
def allow(ctx, route, user = nil, ip_factor = 1)
|
|
467
|
+
return nil unless @limiter
|
|
468
|
+
|
|
469
|
+
ip_wait = @limiter.hit("ip|#{route}|#{ctx[:ip].empty? ? "unknown" : ctx[:ip]}", @limiter.per_ip * ip_factor)
|
|
470
|
+
user_wait = user.to_s.empty? ? 0 : @limiter.hit("user|#{route}|#{user}", @limiter.per_user)
|
|
471
|
+
wait = [ip_wait, user_wait].max
|
|
472
|
+
return nil if wait.zero?
|
|
473
|
+
|
|
474
|
+
RelayResponse.new(429, { "error" => "RATE_LIMITED", "message" => "Too many attempts. Try again later.", "retryAfter" => wait })
|
|
475
|
+
.set_header("Retry-After", wait)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def unauthorized
|
|
479
|
+
RelayResponse.new(401, { "error" => "UNAUTHORIZED_FOR_2FA",
|
|
480
|
+
"message" => "Could not find user ID in request. Put your own auth middleware in front of this router." })
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def offline_disabled
|
|
484
|
+
RelayResponse.new(403, { "error" => "OFFLINE_REQUIRES_FIRST_FACTOR",
|
|
485
|
+
"message" => "Offline sign-in is disabled: configure get_login_user (or allow_offline_without_first_factor) on the Touchque relay." })
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Same status mapping as the guard middleware, kept in sync with the Node SDK.
|
|
489
|
+
def auth_result_error(error)
|
|
490
|
+
case error
|
|
491
|
+
when RejectedError then RelayResponse.new(403, { "error" => "2FA_REJECTED", "message" => "User rejected the 2FA request on their device." })
|
|
492
|
+
when PasskeyRequiredError then RelayResponse.new(409, { "error" => "PASSKEY_REQUIRED", "requestId" => error.request_id, "message" => "Approve this sign-in with your passkey." })
|
|
493
|
+
when TimeoutError then RelayResponse.new(408, { "error" => "2FA_TIMEOUT", "message" => "User did not respond to the 2FA request in time." })
|
|
494
|
+
else
|
|
495
|
+
if error.is_a?(APIError) && error.status < 500
|
|
496
|
+
return RelayResponse.new(error.status, { "error" => [error.error, error.data["message"], error.message].find { |t| t.is_a?(String) && !t.empty? } || "request_failed" })
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
Touchque.log :error, "[Touchque Relay Error] #{error.class}: #{error.message}"
|
|
500
|
+
RelayResponse.new(503, { "error" => "2FA_UNAVAILABLE", "message" => "The 2FA verification service is temporarily unavailable." })
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# A client-range Touchque status is forwarded with its machine-readable code only
|
|
505
|
+
# (never Touchque's message text); anything else becomes 502.
|
|
506
|
+
def relay_error(error)
|
|
507
|
+
if error.is_a?(APIError) && error.status < 500
|
|
508
|
+
return RelayResponse.new(error.status, { "error" => [error.code, error.error].find { |t| t.is_a?(String) && !t.empty? } || "request_failed" })
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
RelayResponse.new(502, { "error" => "relay_error" })
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def same_user?(expected, actual)
|
|
515
|
+
return false if expected.nil? || expected == false || expected.to_s.empty?
|
|
516
|
+
|
|
517
|
+
expected.to_s.downcase == actual.to_s.downcase
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def body_string(body, field)
|
|
521
|
+
v = body[field]
|
|
522
|
+
v.is_a?(String) || v.is_a?(Numeric) ? v.to_s : ""
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def body_hash(body, field)
|
|
526
|
+
body[field].is_a?(Hash) ? body[field] : {}
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def body_request_id(body)
|
|
530
|
+
body["requestId"].is_a?(String) && !body["requestId"].empty? ? body["requestId"] : nil
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|