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,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ module Resources
5
+ # Touchque Login Management API (tq.identity): your customers' accounts, roles,
6
+ # business-customer organizations, apps, APIs, connections and the tenant itself,
7
+ # from your server with your workspace key. Bodies and queries are Hashes with the
8
+ # API's camelCase fields (symbol or string keys); answers are the API's JSON.
9
+ #
10
+ # tq.identity.users.list(q: "ayse")
11
+ # tq.identity.users.block(user_id, reason: "chargeback")
12
+ # tq.identity.imports.start(users: [{ email: e, passwordHash: "$2b$10$..." }])
13
+ class Identity
14
+ attr_reader :users, :imports, :exports, :roles, :organizations, :clients, :apis, :connections, :custom_domains
15
+
16
+ def initialize(http)
17
+ @http = http
18
+ @users = IdentityUsers.new(http)
19
+ @imports = IdentityJobs.new(http, "imports")
20
+ @exports = IdentityJobs.new(http, "exports")
21
+ @roles = IdentityCrud.new(http, "/identity/roles", "roles")
22
+ @organizations = IdentityOrganizations.new(http)
23
+ @clients = IdentityClients.new(http)
24
+ @apis = IdentityCrud.new(http, "/identity/apis", "apis")
25
+ @connections = IdentityCrud.new(http, "/identity/connections", "connections")
26
+ @custom_domains = IdentityCrud.new(http, "/identity/custom-domains", "domains")
27
+ end
28
+
29
+ # The tenant (404 not_enabled until it is set up).
30
+ def get_tenant
31
+ @http.get("/identity/tenant")
32
+ end
33
+
34
+ # Turns Touchque Login on (slug, displayName) or changes its settings.
35
+ def update_tenant(body)
36
+ @http.put("/identity/tenant", body)
37
+ end
38
+
39
+ def rotate_signing_key
40
+ @http.post("/identity/tenant/signing-keys/rotate", {})
41
+ end
42
+
43
+ # The phone app the hosted pages tell customers to approve on ("Approve on the Acme app").
44
+ # nil: your brand name when the Mobile SDK is set up, else Touchque (approverApp in the tenant shows which).
45
+ def set_approver_app_name(name)
46
+ @http.put("/identity/tenant", { "branding" => { "approverAppName" => name } })
47
+ end
48
+
49
+ # Serve sign-in only on the primary custom domain: {slug}.login.touchque.com redirects page loads there and
50
+ # refuses other calls. Turning it on needs a verified primary domain with a working certificate.
51
+ def set_custom_domain_only(on)
52
+ @http.put("/identity/tenant", { "customDomainOnly" => on })
53
+ end
54
+
55
+ # Checks a verified custom domain's certificate at the sign-in edge now (certificate: pending | active | error).
56
+ def check_domain_certificate(domain_id)
57
+ @http.post("/identity/custom-domains/#{Util.seg(domain_id)}/check-certificate", {})
58
+ end
59
+
60
+ # The delivery log. Message text only in the local dev inbox.
61
+ def outbox(params = {})
62
+ @http.get("/identity/outbox", params)
63
+ end
64
+
65
+ # Wrong passwords, suspicious addresses, bot challenge, breached passwords, new-browser notices, the risk engine.
66
+ def get_attack_protection
67
+ @http.get("/identity/attack-protection")
68
+ end
69
+
70
+ # Partial; the bot challenge's secret is write-only.
71
+ def set_attack_protection(settings)
72
+ @http.put("/identity/attack-protection", settings)
73
+ end
74
+
75
+ # What attack protection caught lately.
76
+ def attack_events(params = {})
77
+ @http.get("/identity/attack-protection/events", params)
78
+ end
79
+
80
+ # The email and SMS providers; secrets never come back (secretsSet).
81
+ def get_delivery
82
+ @http.get("/identity/delivery")
83
+ end
84
+
85
+ # Your own provider: email smtp, ses, postmark, sendgrid; sms twilio, netgsm, e.g.
86
+ # { provider: "postmark", from: "login@acme.com", serverToken: "..." }. A secret left
87
+ # out keeps its stored value.
88
+ def set_delivery(channel, provider)
89
+ @http.put("/identity/delivery/#{Util.seg(channel)}", provider)
90
+ end
91
+
92
+ # Back to Touchque's transport.
93
+ def remove_delivery(channel)
94
+ @http.delete("/identity/delivery/#{Util.seg(channel)}")
95
+ end
96
+
97
+ # One test message through the provider in use.
98
+ def test_delivery(channel, to)
99
+ @http.post("/identity/delivery/#{Util.seg(channel)}/test", { "to" => to })
100
+ end
101
+ end
102
+
103
+ # List, get, create, update and delete for one kind of Touchque Login object.
104
+ class IdentityCrud
105
+ def initialize(http, base, list_key)
106
+ @http = http
107
+ @base = base
108
+ @list_key = list_key
109
+ end
110
+
111
+ def list(params = {})
112
+ @http.get(@base, params)[@list_key] || []
113
+ end
114
+
115
+ def get(id)
116
+ @http.get("#{@base}/#{Util.seg(id)}")
117
+ end
118
+
119
+ def create(body)
120
+ @http.post(@base, body)
121
+ end
122
+
123
+ def update(id, body)
124
+ @http.patch("#{@base}/#{Util.seg(id)}", body)
125
+ end
126
+
127
+ def delete(id)
128
+ @http.delete("#{@base}/#{Util.seg(id)}")
129
+ end
130
+ end
131
+
132
+ # Your customers' apps (OpenID Connect clients).
133
+ class IdentityClients < IdentityCrud
134
+ def initialize(http)
135
+ super(http, "/identity/clients", "clients")
136
+ end
137
+
138
+ # A new client secret: it is in the answer, this once.
139
+ def rotate_secret(client_id)
140
+ @http.post("#{@base}/#{Util.seg(client_id)}/rotate-secret", {})
141
+ end
142
+ end
143
+
144
+ # Business customers, their members and invitations.
145
+ class IdentityOrganizations < IdentityCrud
146
+ def initialize(http)
147
+ super(http, "/identity/organizations", "organizations")
148
+ end
149
+
150
+ def members(org_id)
151
+ @http.get("#{org(org_id)}/members")
152
+ end
153
+
154
+ def add_members(org_id, user_ids)
155
+ @http.post("#{org(org_id)}/members", { "userIds" => user_ids.to_a })
156
+ end
157
+
158
+ def remove_member(org_id, user_id)
159
+ @http.delete("#{org(org_id)}/members/#{Util.seg(user_id)}")
160
+ end
161
+
162
+ def set_connections(org_id, connections)
163
+ @http.put("#{org(org_id)}/connections", { "connections" => connections })
164
+ end
165
+
166
+ def invitations(org_id)
167
+ @http.get("#{org(org_id)}/invitations")
168
+ end
169
+
170
+ # The link opens the app's sign-in route (initiateLoginUri) with organization and invitation.
171
+ def invite(org_id, invitation)
172
+ @http.post("#{org(org_id)}/invitations", invitation)
173
+ end
174
+
175
+ def revoke_invitation(org_id, invitation_id)
176
+ @http.delete("#{org(org_id)}/invitations/#{Util.seg(invitation_id)}")
177
+ end
178
+
179
+ private
180
+
181
+ def org(org_id)
182
+ "#{@base}/#{Util.seg(org_id)}"
183
+ end
184
+ end
185
+
186
+ # Bulk user imports and exports, running in the background.
187
+ class IdentityJobs
188
+ def initialize(http, kind)
189
+ @http = http
190
+ @kind = kind
191
+ end
192
+
193
+ # Imports: { users: [...], upsert: true }; exports: { format: "csv" | "json" }.
194
+ def start(body)
195
+ @http.post("/identity/users-#{@kind}", body)
196
+ end
197
+
198
+ def get(job_id)
199
+ @http.get("/identity/users-#{@kind}/#{Util.seg(job_id)}")
200
+ end
201
+
202
+ # Exports: the finished file (CSV or JSON text).
203
+ def file(job_id)
204
+ @http.get_text("/identity/users-#{@kind}/#{Util.seg(job_id)}/file")
205
+ end
206
+
207
+ # Polls until the job is done or failed (or the timeout passes: the last state).
208
+ def wait(job_id, interval: 1.0, timeout: 600.0)
209
+ deadline = Time.now + timeout
210
+ loop do
211
+ job = get(job_id)
212
+ return job if job["status"] != "running" || Time.now > deadline
213
+
214
+ sleep interval
215
+ end
216
+ end
217
+ end
218
+
219
+ # A tenant's customer accounts.
220
+ class IdentityUsers
221
+ def initialize(http)
222
+ @http = http
223
+ end
224
+
225
+ # Query: q, blocked, emailVerified, connection, organization, limit, cursor.
226
+ def list(params = {})
227
+ @http.get("/identity/users", params)
228
+ end
229
+
230
+ def get(user_id)
231
+ @http.get(u(user_id))
232
+ end
233
+
234
+ # email and: password, passwordHash (bcrypt/argon2), customPasswordHash, emailVerified,
235
+ # name, givenName, familyName, phone, locale, userMetadata, appMetadata, sendVerificationEmail.
236
+ def create(user)
237
+ @http.post("/identity/users", user)
238
+ end
239
+
240
+ # Metadata is merged; a key set to nil is removed.
241
+ def update(user_id, changes)
242
+ @http.patch(u(user_id), changes)
243
+ end
244
+
245
+ # Erasure (KVKK/GDPR): the account and every workspace row that names it.
246
+ def delete(user_id)
247
+ @http.delete(u(user_id))
248
+ end
249
+
250
+ def block(user_id, reason: nil)
251
+ @http.post("#{u(user_id)}/block", Util.compact({ "reason" => reason }))
252
+ end
253
+
254
+ def unblock(user_id)
255
+ @http.post("#{u(user_id)}/unblock", {})
256
+ end
257
+
258
+ # Everything about the person (KVKK/GDPR access request); never hashes.
259
+ def export(user_id)
260
+ @http.get("#{u(user_id)}/export")
261
+ end
262
+
263
+ def sessions(user_id)
264
+ @http.get("#{u(user_id)}/sessions")
265
+ end
266
+
267
+ # Ends one of the user's sessions.
268
+ def end_session(user_id, session_id)
269
+ @http.delete("#{u(user_id)}/sessions/#{Util.seg(session_id)}")
270
+ end
271
+
272
+ def sign_out_everywhere(user_id)
273
+ @http.delete("#{u(user_id)}/sessions")
274
+ end
275
+
276
+ # Removes passkeys, unlinks the Touchque app, turns email codes off, signs out everywhere.
277
+ def reset_mfa(user_id)
278
+ @http.delete("#{u(user_id)}/mfa")
279
+ end
280
+
281
+ # Accounts at providers whose tokens the vault keeps for the user (connections with tokenVault on).
282
+ def connected_accounts(user_id)
283
+ @http.get("#{u(user_id)}/connected-accounts")
284
+ end
285
+
286
+ # Deletes the stored tokens: apps stop using the account for the user; signing in with it stays.
287
+ def disconnect_account(user_id, account_id)
288
+ @http.delete("#{u(user_id)}/connected-accounts/#{Util.seg(account_id)}")
289
+ end
290
+
291
+ def unlink_identity(user_id, identity_id, force: false)
292
+ @http.delete("#{u(user_id)}/identities/#{Util.seg(identity_id)}#{force ? "?force=true" : ""}")
293
+ end
294
+
295
+ def send_verification_email(user_id, return_to: nil)
296
+ @http.post("#{u(user_id)}/verification-email", Util.compact({ "returnTo" => return_to }))
297
+ end
298
+
299
+ # send_email: false: the link comes back for you to deliver.
300
+ def password_reset(user_id, send_email: true, ttl_sec: nil)
301
+ body = { "sendEmail" => send_email }
302
+ body["ttlSec"] = ttl_sec unless ttl_sec.nil?
303
+ @http.post("#{u(user_id)}/password-reset", body)
304
+ end
305
+
306
+ def roles(user_id)
307
+ @http.get("#{u(user_id)}/roles")
308
+ end
309
+
310
+ def add_roles(user_id, role_ids, organization_id: nil)
311
+ body = { "roleIds" => role_ids.to_a }
312
+ body["organizationId"] = organization_id unless organization_id.nil?
313
+ @http.post("#{u(user_id)}/roles", body)
314
+ end
315
+
316
+ def remove_role(user_id, role_id, organization_id: nil)
317
+ @http.delete("#{u(user_id)}/roles/#{Util.seg(role_id)}#{organization_id ? "?organizationId=#{Util.seg(organization_id)}" : ""}")
318
+ end
319
+
320
+ def logs(user_id, limit: nil)
321
+ @http.get("#{u(user_id)}/logs", { "limit" => limit })
322
+ end
323
+
324
+ private
325
+
326
+ def u(user_id)
327
+ "/identity/users/#{Util.seg(user_id)}"
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ module Resources
5
+ # Setup secrets (the QR code the Touchque app scans) and a user's link status.
6
+ class Auth
7
+ def initialize(http)
8
+ @http = http
9
+ end
10
+
11
+ # A new setup secret: show +qrCodeDataUrl+ so the Touchque app links the phone.
12
+ # 409 when an unscanned or used secret exists (reset_secret issues a new one).
13
+ def generate_secret(external_username)
14
+ @http.post("/auth/generate-secret", { "externalUsername" => external_username })
15
+ end
16
+
17
+ def reset_secret(external_username)
18
+ @http.post("/auth/secret/reset", { "externalUsername" => external_username })
19
+ end
20
+
21
+ def validate_secret(secret)
22
+ @http.post("/auth/secret/validate", { "secret" => secret })
23
+ end
24
+
25
+ # A user's link status without sending a push: +used+ flips true and +deviceId+ is
26
+ # set once the app scans the secret. +device+ is the linked phone's self-reported
27
+ # posture (device trust), nil before a phone is linked. APIError 404 for an unknown user.
28
+ def get_user(external_username)
29
+ @http.get("/users/#{Util.seg(external_username)}")
30
+ end
31
+ end
32
+
33
+ # Push approvals on the user's phone: request, status, wait, use once, report fraud.
34
+ class Login
35
+ FRAUD_REASONS = %w[chargeback account_takeover social_engineering other].freeze
36
+
37
+ def initialize(http)
38
+ @http = http
39
+ end
40
+
41
+ # A push to the user's phone (returns at once).
42
+ #
43
+ # details: transaction context for the approval screen, {"Amount" => "250 EUR"} or
44
+ # [{label:, value:}], at most 8 lines (labels <= 40, values <= 120 characters). Build
45
+ # it from server-side state. browser_proof: the X-Touchque-Browser header (silent
46
+ # sign-ins), passed through untouched. context: for your Touchque Actions only.
47
+ # security_changes: {password:, mfa:, device:, email:, phone:} => Time / ISO 8601 /
48
+ # epoch ms, when each last changed in your system. destination: {kind:, value:, known:}
49
+ # where the action sends money (shown in full on the phone). locale: "tr" or "en".
50
+ def request(external_username, type: "LOGIN", reference_id: nil, client_ip: nil, user_agent: nil,
51
+ require_biometric: false, require_number_match: false, details: nil, browser_proof: nil,
52
+ context: nil, challenge_digits: nil, expires_in: nil, client_signals: nil,
53
+ security_changes: nil, destination: nil, locale: nil, qr_pairing: nil, device_risk: nil, device_risk_request_id: nil, reply: nil)
54
+ body = { "externalUsername" => external_username, "type" => type || "LOGIN" }
55
+ # An empty reference is no reference (the API answers null for it).
56
+ body["referenceId"] = reference_id unless reference_id.to_s.empty?
57
+ body["clientIp"] = client_ip unless client_ip.nil?
58
+ body["userAgent"] = user_agent unless user_agent.nil?
59
+ body["requireBiometric"] = true if require_biometric
60
+ body["requireNumberMatch"] = true if require_number_match
61
+ body["challengeDigits"] = challenge_digits if [2, 3].include?(challenge_digits)
62
+ # How long the push waits for the person: 30 (default) to 300 seconds.
63
+ body["expiresIn"] = expires_in unless expires_in.nil?
64
+ # A browser pairing by QR code (qr_pairings), confirmed on the phone: only that phone is asked, once.
65
+ body["qrPairing"] = qr_pairing unless qr_pairing.to_s.empty?
66
+ # Ask the person to type back on the phone a sign-in code another service sent them:
67
+ # { kind: "code", key: Touchque::CodeReply.new_key["publicKey"], channel: "sms", target: "+**********26" }.
68
+ # status(id)["reply"]["ct"] holds only the ciphertext: Touchque::CodeReply.open opens it. Not with qr_pairing.
69
+ body["reply"] = Util.stringify(reply) if reply
70
+ body["clientSignals"] = client_signals if client_signals && !client_signals.empty?
71
+ # The page's device report from @touchque/web (deviceRisk.collect), when the workspace
72
+ # turned Device risk on: its signals only ever ask for more.
73
+ body["deviceRisk"] = device_risk unless device_risk.to_s.empty?
74
+ body["deviceRiskRequestId"] = device_risk_request_id unless device_risk_request_id.to_s.empty?
75
+ body["details"] = Util.stringify(details) if details && !details.empty?
76
+ body["browserProof"] = browser_proof if browser_proof && !browser_proof.empty?
77
+ body["context"] = Util.stringify(context) if context && !context.empty?
78
+ body["securityChanges"] = self.class.security_changes_body(security_changes) if security_changes && !security_changes.empty?
79
+ body["destination"] = Util.stringify(destination) if destination && !destination.empty?
80
+ body["locale"] = locale if locale && !locale.empty?
81
+ @http.post("/login/request", body)
82
+ end
83
+
84
+ # Times go out as ISO 8601 UTC; strings and epoch milliseconds as given.
85
+ def self.security_changes_body(changes)
86
+ changes.each_with_object({}) do |(kind, at), out|
87
+ next if at.nil?
88
+
89
+ out[kind.to_s] = at.respond_to?(:utc) ? at.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") : at
90
+ end
91
+ end
92
+
93
+ # Where a request is now: PENDING, CONFIRMED, REJECTED or EXPIRED.
94
+ def status(request_id)
95
+ @http.get("/login/status/#{Util.seg(request_id)}")
96
+ end
97
+
98
+ # Asks the phone and polls until the user approves or rejects (every 0.4 s, up to
99
+ # +timeout+ seconds). With number matching the user picks a number on the phone:
100
+ # +on_challenge+ (or the block) gets {"requestId", "challengeCode", "expiresAt",
101
+ # "reasons", "caseCode"} right after the request exists and before any waiting.
102
+ # Show +challengeCode+ to the user now; the phone asks for it. An error it raises
103
+ # is logged and ignored.
104
+ #
105
+ # Raises PasskeyRequiredError (policy: no push was sent; step_up: tapped during a
106
+ # call), RejectedError, or TimeoutError.
107
+ def verify(external_username, type: "LOGIN", timeout: 30, poll_interval: 0.4, on_challenge: nil, **options, &block)
108
+ on_challenge ||= block
109
+ sent = request(external_username, type: type, **options)
110
+ request_id = sent["requestId"]
111
+ raise PasskeyRequiredError.new(request_id) if sent["requiresPasskey"]
112
+
113
+ if on_challenge
114
+ challenge = { "requestId" => request_id, "expiresAt" => sent["expiresAt"] }
115
+ %w[challengeCode reasons caseCode].each { |k| challenge[k] = sent[k] if sent[k] && !(sent[k].respond_to?(:empty?) && sent[k].empty?) }
116
+ begin
117
+ on_challenge.call(challenge)
118
+ rescue StandardError => e
119
+ # The caller's callback must not stop the wait.
120
+ Touchque.log :warn, "[Touchque] on_challenge failed: #{e.class}: #{e.message}"
121
+ end
122
+ end
123
+ wait(request_id, sent["challengeCode"], timeout, poll_interval)
124
+ end
125
+
126
+ # Waits for a request you already sent.
127
+ def wait_for_approval(request_id, timeout: 30, poll_interval: 0.4)
128
+ wait(request_id, nil, timeout, poll_interval)
129
+ end
130
+
131
+ # Approves a pending request with one of the user's recovery codes (no phone).
132
+ def approve_with_recovery_code(request_id, code)
133
+ @http.post("/login/recovery", { "requestId" => request_id, "code" => code })
134
+ end
135
+
136
+ # Uses an approved request exactly once. The first call on a CONFIRMED request wins
137
+ # atomically; every later call raises APIError with code +already_used+ (409).
138
+ def consume(request_id)
139
+ @http.post("/login/#{Util.seg(request_id)}/consume", {})
140
+ end
141
+
142
+ # Reports a request you later learned was fraud (a chargeback, a confirmed account
143
+ # takeover, a scam the user was talked into). The risk engine learns from it; the
144
+ # request's status does not change. A repeat answers "alreadyReported" => true.
145
+ def report_fraud(request_id, reason: nil, note: nil)
146
+ raise ConfigError, "report_fraud() needs the request id" if request_id.to_s.empty?
147
+
148
+ @http.post("/login/#{Util.seg(request_id)}/report-fraud", Util.compact({ "reason" => reason, "note" => note }))
149
+ end
150
+
151
+ private
152
+
153
+ def wait(request_id, challenge_code, timeout, interval)
154
+ deadline = Time.now + timeout
155
+ loop do
156
+ s = status(request_id)
157
+ case s["status"]
158
+ when "CONFIRMED"
159
+ return {
160
+ "approved" => true, "status" => "CONFIRMED", "requestId" => request_id,
161
+ "challengeCode" => challenge_code, "assurance" => s["assurance"], "confirmedVia" => s["confirmedVia"],
162
+ # Vishing Shield: approved during a call; critical actions wait for the phone to re-confirm.
163
+ "provisional" => s["provisional"] ? true : false
164
+ }
165
+ when "REJECTED" then raise RejectedError.new(request_id)
166
+ when "EXPIRED" then raise TimeoutError.new("Touchque: request '#{request_id}' expired before it was approved.", request_id)
167
+ end
168
+ # Vishing Shield: tapped during a call, the request now waits for a passkey in the browser.
169
+ raise PasskeyRequiredError.new(request_id, "step_up") if s["status"] == "PENDING" && s["requiresPasskey"]
170
+ if Time.now + interval > deadline
171
+ raise TimeoutError.new("Touchque: request '#{request_id}' was not approved within #{timeout}s. The user did not respond in time.", request_id)
172
+ end
173
+
174
+ sleep interval
175
+ end
176
+ end
177
+ end
178
+
179
+ # WebAuthn / FIDO2 (passkeys), the backend half: the ceremony runs in the browser
180
+ # (@touchque/web); your backend relays its JSON through these methods.
181
+ class WebAuthn
182
+ def initialize(http)
183
+ @http = http
184
+ end
185
+
186
+ # Registration step 1. +approval_request_id+: a PASSKEY_ENROLL request the user approved
187
+ # on their linked phone (403 enrollment_approval_required otherwise).
188
+ def register_options(external_username, discoverable: false, approval_request_id: nil)
189
+ body = { "externalUsername" => external_username }
190
+ body["discoverable"] = true if discoverable
191
+ body["approvalRequestId"] = approval_request_id if approval_request_id && !approval_request_id.empty?
192
+ @http.post("/webauthn/register/options", body)
193
+ end
194
+
195
+ def register_verify(external_username, response, label: nil)
196
+ body = { "externalUsername" => external_username, "response" => response }
197
+ body["label"] = label unless label.nil?
198
+ @http.post("/webauthn/register/verify", body)
199
+ end
200
+
201
+ # Second factor: options scoped to a pending request.
202
+ def authenticate_options(request_id)
203
+ @http.post("/webauthn/login/options", { "requestId" => request_id })
204
+ end
205
+
206
+ def authenticate_verify(request_id, response)
207
+ @http.post("/webauthn/login/verify", { "requestId" => request_id, "response" => response })
208
+ end
209
+
210
+ # Whether passkey sign-in (passwordlessLoginEnabled) is on for your workspace.
211
+ # Ask before your sign-in page shows its passkey button and passkey autofill:
212
+ # when it is off, primary_options answers 403 passwordless_login_disabled.
213
+ # { "enabled" => bool, "userVerification" => "required" | "preferred" | nil }.
214
+ def primary_availability
215
+ raw = @http.get("/webauthn/authenticate/primary/availability") || {}
216
+ enabled = raw["enabled"] == true
217
+ uv = raw["userVerification"]
218
+ { "enabled" => enabled, "userVerification" => enabled && %w[required preferred].include?(uv) ? uv : nil }
219
+ end
220
+
221
+ # Passwordless sign-in step 1; without a user, any passkey of your site (autofill).
222
+ def primary_options(external_username = nil)
223
+ @http.post("/webauthn/authenticate/primary/options", Util.compact({ "externalUsername" => external_username }))
224
+ end
225
+
226
+ def primary_verify(attempt_id, response)
227
+ @http.post("/webauthn/authenticate/primary/verify", { "attemptId" => attempt_id, "response" => response })
228
+ end
229
+
230
+ def list_credentials(external_username)
231
+ @http.get("/webauthn/credentials", { "externalUsername" => external_username })
232
+ end
233
+
234
+ # Scope the deletion to +external_username+ (recommended).
235
+ def delete_credential(credential_record_id, external_username: nil)
236
+ path = "/webauthn/credentials/#{Util.seg(credential_record_id)}"
237
+ path += "?externalUsername=#{Util.seg(external_username)}" if external_username
238
+ @http.delete(path)
239
+ end
240
+ end
241
+
242
+ # Offline Sign: QR challenge and typed-code approvals that work with the phone offline.
243
+ class Offline
244
+ ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
245
+
246
+ def initialize(http)
247
+ @http = http
248
+ end
249
+
250
+ # An offline QR challenge. Always pass +request_id+ when the QR follows a push: a
251
+ # phone-side rejection then kills the QR, and number matching carries over.
252
+ def challenge(external_username, type: "LOGIN", details: nil, client_ip: nil, user_agent: nil,
253
+ ttl_seconds: nil, include_qr_image: true, request_id: nil, require_number_match: false)
254
+ body = { "externalUsername" => external_username, "type" => type }
255
+ body["details"] = Util.stringify(details) unless details.nil?
256
+ body["clientIp"] = client_ip unless client_ip.nil?
257
+ body["userAgent"] = user_agent unless user_agent.nil?
258
+ body["ttlSeconds"] = ttl_seconds unless ttl_seconds.nil?
259
+ body["includeQrImage"] = false unless include_qr_image
260
+ body["requestId"] = request_id unless request_id.nil?
261
+ body["requireNumberMatch"] = true if require_number_match
262
+ @http.post("/offline/challenge", body)
263
+ end
264
+
265
+ # The 7-character code from the phone. Never raises for a wrong, expired or used code:
266
+ # check "approved"; "reason" is invalid_code, locked, expired, used, unknown_challenge,
267
+ # request_rejected, too_many_failures or device_not_enrolled.
268
+ def verify(challenge_id, code)
269
+ not_approved_as_result { @http.post("/offline/verify", { "challengeId" => challenge_id, "code" => code }) }
270
+ end
271
+
272
+ # The rolling time-based code. Refused for critical action types.
273
+ def verify_totp(external_username, code, type: "LOGIN", client_ip: nil, request_id: nil)
274
+ body = { "externalUsername" => external_username, "code" => code, "type" => type }
275
+ body["clientIp"] = client_ip unless client_ip.nil?
276
+ body["requestId"] = request_id unless request_id.nil?
277
+ not_approved_as_result { @http.post("/offline/totp/verify", body) }
278
+ end
279
+
280
+ # Salted, slow digests of each user's next codes, for checking typed codes while your
281
+ # server cannot reach Touchque. Check a code with Offline.match_totp_verifier.
282
+ def totp_verifiers(external_usernames, from_step: nil, steps: nil)
283
+ body = { "externalUsernames" => external_usernames.to_a }
284
+ body["fromStep"] = from_step unless from_step.nil?
285
+ body["steps"] = steps unless steps.nil?
286
+ @http.post("/offline/totp/verifiers", body)
287
+ end
288
+
289
+ # The step a typed code belongs to (the current one or one either side), or nil,
290
+ # without calling Touchque. Accept each step once.
291
+ def self.match_totp_verifier(verifiers, user, code, at: nil)
292
+ given = code.to_s.upcase.delete(" \t-")
293
+ return nil unless given.length == 7 && given.chars.all? { |c| ALPHABET.include?(c) }
294
+
295
+ step_seconds = verifiers["stepSeconds"].to_i
296
+ length = verifiers.dig("kdf", "length").to_i
297
+ return nil if step_seconds <= 0 || length <= 0
298
+
299
+ salt = Util.b64_decode(user["salt"])
300
+ digests = Util.b64_decode(user["digests"])
301
+ now = (at || Time.now).to_i / step_seconds
302
+ [now, now - 1, now + 1].each do |step|
303
+ next if step < user["fromStep"].to_i || step > verifiers["notAfterStep"].to_i
304
+
305
+ i = (step - user["fromStep"].to_i) * 2 * length
306
+ next if i + 2 * length > digests.bytesize
307
+
308
+ got = OpenSSL::KDF.pbkdf2_hmac(given, salt: salt + ":#{step}", iterations: verifiers.dig("kdf", "iterations").to_i, length: length, hash: "sha256")
309
+ return step if Util.secure_compare(digests.byteslice(i, length), got) || Util.secure_compare(digests.byteslice(i + length, length), got)
310
+ end
311
+ nil
312
+ end
313
+
314
+ private
315
+
316
+ def not_approved_as_result
317
+ yield
318
+ rescue APIError => e
319
+ raise if e.status >= 500
320
+
321
+ { "approved" => false, "reason" => e.reason || e.code || e.error, "attemptsLeft" => e.attempts_left }
322
+ end
323
+ end
324
+ end
325
+ end