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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +105 -0
  3. data/LICENSE +21 -0
  4. data/README.md +383 -0
  5. data/lib/touchque/agent.rb +211 -0
  6. data/lib/touchque/agent_card.rb +159 -0
  7. data/lib/touchque/approvals.rb +214 -0
  8. data/lib/touchque/client.rb +81 -0
  9. data/lib/touchque/code_reply.rb +53 -0
  10. data/lib/touchque/cross_app_access.rb +204 -0
  11. data/lib/touchque/crypto.rb +215 -0
  12. data/lib/touchque/errors.rb +246 -0
  13. data/lib/touchque/flow.rb +362 -0
  14. data/lib/touchque/hosted_login.rb +401 -0
  15. data/lib/touchque/http.rb +127 -0
  16. data/lib/touchque/login_session.rb +86 -0
  17. data/lib/touchque/org_agent.rb +398 -0
  18. data/lib/touchque/rack/relay.rb +75 -0
  19. data/lib/touchque/rails/guard.rb +132 -0
  20. data/lib/touchque/rails/hosted_login.rb +143 -0
  21. data/lib/touchque/rails/sessions_controller.rb +12 -0
  22. data/lib/touchque/railtie.rb +34 -0
  23. data/lib/touchque/relay.rb +533 -0
  24. data/lib/touchque/resources/actions.rb +212 -0
  25. data/lib/touchque/resources/agents.rb +251 -0
  26. data/lib/touchque/resources/device_risk.rb +130 -0
  27. data/lib/touchque/resources/identity.rb +331 -0
  28. data/lib/touchque/resources/login.rb +325 -0
  29. data/lib/touchque/resources/mobile.rb +115 -0
  30. data/lib/touchque/resources/people.rb +237 -0
  31. data/lib/touchque/resources/qr_pairings.rb +82 -0
  32. data/lib/touchque/resources/safe_guard.rb +87 -0
  33. data/lib/touchque/resources/webhook.rb +177 -0
  34. data/lib/touchque/safe_approval.rb +283 -0
  35. data/lib/touchque/security_events.rb +162 -0
  36. data/lib/touchque/util.rb +260 -0
  37. data/lib/touchque/version.rb +5 -0
  38. data/lib/touchque.rb +124 -0
  39. 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