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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6955277b3bc3e32deb8f97a7d137f02f5d3517b7897801a47f2ea7450025459c
|
|
4
|
+
data.tar.gz: cbbe23f464baba65fc927cd7d34ec609be4bdc1a33d2841e07a53b1f66a0c61c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 89047ee9495e0d3f2871feda126bea65fc520db8a4300eb9e49a9c0e167e2a803c34407dbb052101c1104ddc03be71141dfa3bebb818319ebf3058bb74d4370e
|
|
7
|
+
data.tar.gz: 20ce50cfbc756507d749bc4c404755b74f561708bd7c88f8845d77a4a11115f5df2226b1e2c61a3c324b8f1f908440badc97def0a783567c8a4275d11d776afb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file. The
|
|
4
|
+
format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [5.0.0] — 2026-11-12
|
|
9
|
+
|
|
10
|
+
### Breaking
|
|
11
|
+
- **Every name is spelled Touchque instead of TouchQue.** Code that uses the old names must be updated; nothing else changes. Old → new:
|
|
12
|
+
- The module: `TouchQue` → `Touchque` (`TouchQue.client`, `.configure`, `.logger`, `.replay_cache`, `.login_options`, `.login_secret` and `.hosted_login` → `Touchque.client`, `Touchque.configure`...).
|
|
13
|
+
- The client class: `TouchQue::Client` → `Touchque::Client`.
|
|
14
|
+
- The error classes: `TouchQue::Error`, `TouchQue::APIError`, `TouchQue::NetworkError`, `TouchQue::RejectedError`, `TouchQue::TimeoutError`, `TouchQue::PasskeyRequiredError`, `TouchQue::ConfigError`, `TouchQue::WebhookSignatureError`, `TouchQue::WebhookReplayError`, `TouchQue::ApprovalError`, `TouchQue::ApprovalMismatchError`, `TouchQue::FeatureError` (and `FeatureNotInPlanError`, `FeatureReadOnlyError`, `FeatureOffError`), `TouchQue::HostedLoginError`, `TouchQue::MyAccountError`, `TouchQue::McpAuthError`, `TouchQue::IdJagError`, `TouchQue::AgentCardError` and `TouchQue::SecurityEventError` → `Touchque::Error`, `Touchque::APIError`, `Touchque::NetworkError` and so on.
|
|
15
|
+
- Every other class and module under it: `TouchQue::Resources::*`, `TouchQue::Guard`, `TouchQue::Agent`, `TouchQue::OrgAgent`, `TouchQue::McpAuth`, `TouchQue::HostedLogin`, `TouchQue::SafeApproval`, `TouchQue::CodeReply`... → `Touchque::Resources::*`, `Touchque::Guard`, `Touchque::Agent`...
|
|
16
|
+
- Rails: `TouchQue::Rails::Guard`, `TouchQue::Rails::Webhook`, `TouchQue::Rails::HostedLogin`, `TouchQue::Rails.login_routes` and `TouchQue::SessionsController` → `Touchque::Rails::Guard`, `Touchque::Rails::Webhook`, `Touchque::Rails::HostedLogin`, `Touchque::Rails.login_routes` and `Touchque::SessionsController`. `login_routes`' default controller is now `"touchque/sessions"` (was `"touch_que/sessions"`, the route name Rails derived from the old class name); change it if you passed it yourself.
|
|
17
|
+
- Rack: `TouchQue::Rack::Relay` → `Touchque::Rack::Relay`.
|
|
18
|
+
- The guard's header names (`Touchque::Guard::TOKEN_HEADER`, `OFFLINE_HEADER`, `CODE_HEADER`, `CODE_TYPE_HEADER`, `BROWSER_HEADER`): `X-TouchQue-Token`, `X-TouchQue-Offline`, `X-TouchQue-Code`, `X-TouchQue-Code-Type`, `X-TouchQue-Browser` → `X-Touchque-Token`, `X-Touchque-Offline`, `X-Touchque-Code`, `X-Touchque-Code-Type`, `X-Touchque-Browser`. HTTP header names are case-insensitive, so servers are unaffected.
|
|
19
|
+
- The SDK's log lines start with `[Touchque]` (was `[TouchQue]`) and its error messages with `Touchque:`.
|
|
20
|
+
- The same as before: the gem name `touchque`, `require "touchque"`, `config.touchque` and `credentials.touchque`, the `/touchque` mount path, the cookies, the JSON the API sends and receives, the `TQ_*` environment variables, and the signed EIP-712 type string `TouchQueGovernance(...)` that the Safe Guard contract hashes.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- The README has an "Action types" section: define a type once (dashboard → Developers → Action types, or from code); the guard answers 500 `misconfigured` for an undefined name and 403 `action_disabled` for one turned off.
|
|
24
|
+
|
|
25
|
+
## [4.2.0] — not published (these changes ship in 5.0.0)
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
- **The offline approval-token check refuses another workspace's token.** `GET /agents/jwks.json` is one key set for every Touchque workspace, and the check used to compare `aud` only when you passed an audience, so a token Touchque signed for someone else's workspace (their own phone, any person's name, any details) could pass your executor. Now:
|
|
29
|
+
- `agents.verify_approval_token(token, audience: nil, ...)` uses this workspace's own audience by default: `agents.approval_audience` asks `GET /agents/approval-audience` once (`touchque:workspace:<id>`) and keeps it.
|
|
30
|
+
- `ApprovalToken.verify_with(token, jwks, audience:, ...)` requires the audience. A missing or empty one throws Touchque::ApprovalMismatchError (`audience is required`).
|
|
31
|
+
- A token without `aud` is refused (`the approval token names no workspace`), as is one for another workspace (`the approval token is for another workspace`).
|
|
32
|
+
- Breaking for callers of the low-level function who passed no audience: pass agents.approval_audience's value.
|
|
33
|
+
- `redeemApproval` was already safe: Touchque compares `aud` with your workspace there.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **`device_risk.search_events(...)`: search your workspace's Device risk identifications**, newest first, one page at a time (`GET /device-risk/events`): `from:`/`to:` (7 days by default, at most 90, within your retention), `signal:`, `step_up:`, `kind:`, `origin:`, `ip:` (an address or a CIDR range; never returned), `account:`, `visitor_id:`, `request_id:`, `limit:` (1 to 100) and the opaque `cursor:` from the previous page's `"nextCursor"`.
|
|
37
|
+
- `device_risk.check` returns `"engine"` (String or nil): the engine's state for this check; `"unavailable"` when the risk engine could not be reached and the verdict is a safe default (`"stepUp"` true), so an outage can be told apart from risk.
|
|
38
|
+
- **`webauthn.primary_availability`: is passkey sign-in on for your workspace?** `GET /webauthn/authenticate/primary/availability` answers `{ "enabled" => bool, "userVerification" => "required" | "preferred" | nil }`. Ask before your sign-in page shows its passkey button and passkey autofill; when it is off, `primary_options` answers 403 `passwordless_login_disabled`.
|
|
39
|
+
- `agents.get_workspace_policy`, the name every other Touchque SDK uses, as an alias of `agents.workspace_policy` (which stays; `set_workspace_policy` was already there).
|
|
40
|
+
- **Webhook events of Never Locked Out and the help desk**, the names the API sends: `Webhook::EVENT_RECOVERY_STARTED`, `EVENT_RECOVERY_DEVICE_BOUND`, `EVENT_RECOVERY_COOLING`, `EVENT_RECOVERY_COMPLETED`, `EVENT_RECOVERY_VETOED`, `EVENT_RECOVERY_EXPIRED`, `EVENT_RECOVERY_FAILED` and `EVENT_ASSIST_OPENED`, `EVENT_ASSIST_ENDED`, `EVENT_ASSIST_CHECK_PASSED`, `EVENT_ASSIST_CHECK_FAILED`, `EVENT_ASSIST_UNFROZEN`, `EVENT_ASSIST_CLAIM_CONFIRMED`, `EVENT_ASSIST_CLAIM_UNVERIFIED`.
|
|
41
|
+
- `SecurityEvents.poll` sends `Accept: application/json`, like the Node SDK (it sent Net::HTTP's `*/*`).
|
|
42
|
+
- README: "Shared Signals (security events from Touchque SSO)", verifying a pushed SET and polling; tests for the keys found through `/.well-known/ssf-configuration` and for a poll, against a local transmitter.
|
|
43
|
+
- **Shared Signals: receive the security events an identity provider sends after sign-in.** Touchque SSO sends Security Event Tokens (OpenID Shared Signals Framework 1.0) for an organization's members to the receivers its admin adds (Organization > Continuous access): CAEP `session-revoked`, `credential-change`, `device-compliance-change`, RISC `account-disabled`, `credential-compromise`; `txn` carries the case code of Touchque's decision. `Touchque::SecurityEventVerifier.new(issuer:, audience:, jwks_uri:, jwks:, clock_skew:).verify(token)` returns `{ "jti", "issuer", "audience", "issued_at", "txn", "subject", "type", "name", "claims" }` or raises `Touchque::SecurityEventError` with the RFC 8935 code to answer in `error` (`invalid_request`, `invalid_key`, `invalid_issuer`, `invalid_audience`); `Touchque::SecurityEvents.poll(url:, token:, ack:, set_errs:, max_events:)` for poll delivery (RFC 8936); `SecurityEvents::TYPES`. The keys come from the issuer's `/.well-known/ssf-configuration`; a SET does not expire, so keep the jtis you handled. Tested against the shared vectors (`fixtures/security-event-vectors.json`).
|
|
44
|
+
- **Safe Guard: Safe transactions approved on the approver's phone, for TouchqueSafeGuard.** `tq.safe_guard.request(external_username, type, safe_tx, reference_id:, expires_in:)` sends the raw transaction (the phone decodes it itself and signs with a key only its biometrics unlock), `status(request_id)` and `wait_for_approval(request_id)` hand back the 105-byte approval. `Touchque::SafeApproval.verify(target, approval, approver_key, purpose:, approver_index:, rows: | display_digest:, now:)` checks it the way the guard does, without trusting Touchque: the Safe transaction hash (EIP-712, Keccak-256 here) and the governance hash computed here, the 149-byte message, low `s`, the one-day expiry window, the P-256 signature. `SafeApproval.with_approvals(owner_signatures, approvals)` for `execTransaction`, `SafeApproval.schedule_call(safe, governance_hash, approvals)` for a governance change; `safe_tx_hash`, `governance_hash`, `message`, `display_digest`. Tested against the shared vectors (`fixtures/safe-guard-vectors.json`).
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- An empty `reference_id` is no reference, as for the API: `login.request` no longer sends `"referenceId": ""`, and `complete` (and so the guard) no longer raises `ApprovalMismatchError` after using up an approval started with `reference_id: ""`.
|
|
48
|
+
- The step-up guard (`Guard.run`, `require_touchque`, the relay's `/login`) answers the contract's 503 `unavailable` for any failure, an approval for another transaction included, instead of a bare 500. Every connection failure and garbled answer (`ENETUNREACH`, `EPIPE`, a bad status line...) now raises `Touchque::NetworkError`.
|
|
49
|
+
- Transaction details are trimmed like the API trims them (JavaScript's trim: no-break and other Unicode spaces too), so `complete` and `ApprovalProof.verify` no longer refuse details that start or end with such a space.
|
|
50
|
+
- `login.verify` passes `expires_in:` and `client_signals:` on to the request instead of dropping them.
|
|
51
|
+
- `Touchque::Rack::Relay` loads on first use: the documented Sinatra / plain Rack mount works without Rails and without `require "touchque/rack/relay"`.
|
|
52
|
+
- The relay answers 400 `invalid_credential_id` to `DELETE /passkey/credentials/{id}` with a malformed %-encoding instead of a 500.
|
|
53
|
+
|
|
54
|
+
### Security
|
|
55
|
+
- **Hosted login: an empty `iss` is refused too (RFC 9207).** `handle_callback` (and so the Rails `/callback`) already refused a callback whose `iss` names another issuer; one with an empty `iss=` now raises `issuer_mismatch` as well, before the code is exchanged. A callback without `iss` (a server that does not send it yet, or a nil value) is still accepted.
|
|
56
|
+
- `inspect` (and `pp`) of `Touchque::Client`, its `webhook`, `HostedLogin` and `MyAccount` no longer show the API key, the API secret, the client secret or the access token.
|
|
57
|
+
- `SafeApproval.verify` without `purpose:` accepts only an ordinary approval (`"approve"`). A schedule approval covers no Safe nonce or gas fields, so it is accepted only with `purpose: "schedule"`, never as permission to execute.
|
|
58
|
+
- Touchque Login (`HostedLogin`, `MyAccount`) refuses a plaintext `http://` domain except on this machine (`localhost`, `127.0.0.1`, `::1`, `*.localhost`), like `base_url`: codes, PKCE verifiers, the client secret and tokens never travel in the clear.
|
|
59
|
+
|
|
60
|
+
## [4.1.0] — not published (these changes ship in 5.0.0)
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- **Device risk events like a device-intelligence event, without raw traits:** `deviceRisk.check` and `getEvent` answers carry `network`, `velocity` and `bot`: the address as the risk engine saw it (country, region, city, never coordinates; the operator; `type` isp, hosting or tor; `timezoneMismatch` when the browser's clock does not fit the address), the visitor's counts (accounts, sign-ups, countries, stored-id resets, identifications in the last hour and day) and one answer to "a bot?" (`detected`, `suspected` or `not_detected`, with reasons). The field the script tag fills may now carry its pointer summary after the requestId: pass it as it came.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
- **Silent by default.** Nothing goes to `$stderr` (`Kernel#warn`) on its own any more: the SDK's own problems (a misconfigured guard, a failed `on_challenge`, a relay error or a relay without `get_login_user`) go to `Touchque.logger` (`Touchque.logger = Rails.logger`); nil, the default, logs nothing.
|
|
67
|
+
- `ApprovalMismatchError` no longer names the other person in its message.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
- **A code typed back on the phone:** `tq.login.request(..., reply: { kind: "code", key:, channel:, target: })`; `Touchque::CodeReply.new_key` and `Touchque::CodeReply.open(private_key, request_id, ct)` open `status(id)["reply"]["ct"]`.
|
|
71
|
+
- **Feature errors:** a feature your organization cannot use here (403 `feature_not_in_plan`, `feature_read_only`, `feature_off`) raises `FeatureNotInPlanError`, `FeatureReadOnlyError` or `FeatureOffError`, all `FeatureError` (`feature`, `feature_reason`) and still `APIError`.
|
|
72
|
+
|
|
73
|
+
First release of the Ruby SDK, at the same surface as the Node, Python, PHP, Go and .NET SDKs 4.1.0.
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
- **Webhook events `DEVICE_RISK_SIGNAL` and `DEVICE_RISK_ALERT`** (`Webhook::EVENT_DEVICE_RISK_*`): a Device risk signal that asks for more (identifiers, signals and the engine's verdict, never a trait), and an alert rule of the workspace that went off. Signed like every event.
|
|
77
|
+
- **Device risk in one line: `tq.device_risk.check(params[:touchque_request_id], event: "sign_in", external_username: email)`** with the script tag (`device.js`, `@touchque/web` 1.6.0), which fills that field. Pass it as it came: nil, empty, made up or expired is still a verdict (`report` missing, unknown, expired, invalid; the signal `device_identification_missing`, a number-matched step-up at most), never an exception. `Touchque::Resources::DeviceRisk::REPORTS`.
|
|
78
|
+
- **Device risk: `tq.device_risk.check(request_id, event:, external_username:, client_ip:)` and `tq.device_risk.get_event(request_id)`** (a paid add-on; 409 `device_risk_off` until the workspace turns it on), and `device_risk_request_id:` (or `device_risk:`) on `login.request` and `risk.check`. The page's requestId comes from `@touchque/web` `deviceRisk.identify`. Every signal only ever asks for more (`"stepUp"`, never a refusal); signals start in shadow.
|
|
79
|
+
- **Pairing a browser by QR code: `tq.qr_pairings`.** `create(key_thumbprint, client_ip:, user_agent:, browser:, os:, ttl_seconds:)` registers a pairing for the browser key your page holds (`@touchque/web` `browserKey.thumbprint()`) and returns `"uri"` (`touchque://pair-browser?...`) to show as a QR code; the nonce in it stays with you (Touchque keeps its hash). `get(pairing_id)` says who scanned it and how near the phone was (`"proximity"`), `refuse(pairing_id, reason:, message:)` tells the phone why your own checks said no. `login.request(..., qr_pairing:)` asks the phone that scanned, once, with number matching. `Resources::QrPairings.uri`, `REFUSAL_REASONS`.
|
|
80
|
+
- **Actions: the `agent.approval` trigger**, before an AI agent's request reaches the phone (`trigger: "agent.approval"`); only tightening counts: `api.deny`, `api.requirePasskey`, `api.stepUp`. See [Actions](https://support.touchque.com/actions).
|
|
81
|
+
- **Webhook reason `user_blocked_agent`** (`Webhook::REASON_USER_BLOCKED_AGENT`): the person rejected an AI agent's request with Block this agent on their phone; the agent may not ask them again, and 2FA is not frozen. `agents.list_blocks` can return the block reason `"phone"`.
|
|
82
|
+
- Request options and resources at the 4.1.0 surface: `challenge_digits:`, `expires_in:`, `client_signals:`, `security_changes:`, `destination:` and `locale:` on login requests; `client_signals:` on risk checks; the linked phone's posture on `auth.get_user`; `offline.totp_verifiers` + `Offline.match_totp_verifier` for a Touchque outage; Touchque Login email and SMS providers (`identity.set_delivery`) and attack protection; Mobile SDK settings with your own APNs key / FCM service account, `androidCerts` and `mobile.test_push`.
|
|
83
|
+
- **AI agents: events for a SIEM.** `agents.list_events(after:, since:, limit:)`: the workspace's agent events (`AGENT_*`, `AGENTS_*`) oldest first, each with its agent, person, approval and case code; keep `"next"` and pass it as `after:`.
|
|
84
|
+
- **AI agents: the workspace's policy and a passkey line.** `agents.workspace_policy` / `set_workspace_policy(policy)`: every agent's floor (an agent's own policy can only be stricter, field by field). `"requirePasskeyAbove" => { "TRY" => "10000.00" }` in any policy: over it the person approves with a passkey on your page (`"requiresPasskey"` on the approval), never on the phone or with a mandate; a background request gets 409 `interactive_required`. `Webhook::EVENT_AGENT_WORKSPACE_POLICY_CHANGED`. A second approver who answers sooner than a person can gets no token (status `blocked`, reason `inhuman_approval`).
|
|
85
|
+
- **Touchque Login: the approving app's name and custom-domain-only sign-in.** `identity.set_approver_app_name(name)`: the phone app the hosted pages tell customers to approve on ("Approve on the Acme app"; `nil` = your brand when the Mobile SDK is set up, else Touchque). `identity.set_custom_domain_only(true)`: serve sign-in only on the primary custom domain ({slug}.login.touchque.com redirects page loads there and refuses other calls; needs a verified primary domain with a working certificate). `identity.check_domain_certificate(domain_id)`: the domain's certificate at the sign-in edge now (`certificate`: pending, active or error).
|
|
86
|
+
- **Cross App Access (ID-JAG) and A2A agent cards.** `CrossAppAccess.request_id_jag` trades a member's ID token for an ID-JAG at the organization's identity provider, `redeem_id_jag` trades it for an access token at the other service (jwt-bearer); `IdJagVerifier` checks one at your own authorization server (typ, issuer, single audience, client, expiry, used once; `first_use` for a shared store). `AgentCard.verify` checks an A2A card's signatures (JWS over the RFC 8785 canonical card; `jku` only on the card's host or `trusted_jku_hosts`), `sign`, `canonical_json`.
|
|
87
|
+
- **`Touchque::Client`** (Ruby 3.1+, standard library only): `auth`, `login` (request, status,
|
|
88
|
+
`verify` with an `on_challenge` block, `consume`, `report_fraud`, recovery codes), `webauthn`,
|
|
89
|
+
`offline` (QR and typed codes, outage verifiers), `risk` (silent sign-ins), `action_types`
|
|
90
|
+
(Second Thought questions), `actions` (Touchque Actions and their secrets), `recovery` (Never
|
|
91
|
+
Locked Out), `assist` (help desk), `agents` (with the token vault, mandates, blocks, the kill
|
|
92
|
+
switch, approval tokens), `identity` (the Touchque Login Management API), `mobile` (the Mobile
|
|
93
|
+
SDK's server side, `push_from_webhook`) and `webhook` (signature, timestamp window, replay cache).
|
|
94
|
+
- `start` / `check` / `complete` and `Touchque::Guard.run`: the two-phase step-up contract shared
|
|
95
|
+
by every Touchque server SDK, with offline codes, silent sign-ins, Vishing Shield, Mirror Alarm
|
|
96
|
+
canaries and "Why?" case codes.
|
|
97
|
+
- `Touchque::RelayHandler` and `Touchque::Rack::Relay`: the routes `@touchque/web` calls, with rate limits.
|
|
98
|
+
- `Touchque::ApprovalProof`, `ApprovalToken`, `AgentKeys` (ES256 and EdDSA), `Touchque::Agent`
|
|
99
|
+
(private_key_jwt + DPoP), `Touchque::OrgAgent` (Touchque SSO agents) and `Touchque::McpAuth`.
|
|
100
|
+
- `Touchque::HostedLogin` (Touchque Login OpenID Connect client) and `Touchque::MyAccount`;
|
|
101
|
+
`Touchque::LoginSession` (encrypted, chunked cookies, readable by the Node SDK's sealSession).
|
|
102
|
+
- **Rails**: a Railtie (`config.touchque`, credentials), `Touchque::Rails::Guard`
|
|
103
|
+
(`require_touchque`), `Touchque::Rails::Webhook` (`verify_touchque_webhook`),
|
|
104
|
+
`Touchque::Rails::HostedLogin` (`/login`, `/callback`, `/logout`, `require_touchque_sign_in`)
|
|
105
|
+
and `Touchque::Rails.login_routes`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Touchque
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# touchque (Ruby)
|
|
2
|
+
|
|
3
|
+
The official Ruby server SDK for [Touchque](https://touchque.com): push approvals with number
|
|
4
|
+
matching, passkeys, silent sign-ins, offline codes, verified webhooks, Touchque Actions, AI agent
|
|
5
|
+
approvals, Touchque Login (hosted sign-in and the Management API) and the server side of the
|
|
6
|
+
Mobile SDK. Rails integration included (a Railtie and controller concerns), plus a Rack relay for
|
|
7
|
+
Sinatra and any Rack app.
|
|
8
|
+
|
|
9
|
+
Ruby 3.1+, no runtime dependencies beyond the standard library (`net/http`, `openssl`, `json`).
|
|
10
|
+
|
|
11
|
+
📘 Full docs: **[support.touchque.com](https://support.touchque.com)**
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "touchque"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **Not on RubyGems yet.** Until it is, take it from GitHub:
|
|
21
|
+
> `gem "touchque", git: "https://github.com/Touchque/touchque-ruby"`.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
Get an API key and secret from your [Touchque dashboard](https://dashboard.touchque.com):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export TQ_API_KEY=tq_auth_your_key
|
|
29
|
+
export TQ_API_SECRET=your_api_secret
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The secret signs every request: it belongs on your server only, never in a browser or an app.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
tq = Touchque.client # from the environment, shared
|
|
36
|
+
tq = Touchque::Client.new(api_key: "tq_...", api_secret: "...") # explicit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
In Rails the Railtie reads `config.touchque` (or `credentials.touchque`, then the environment):
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/application.rb
|
|
43
|
+
config.touchque.api_key = Rails.application.credentials.dig(:touchque, :api_key)
|
|
44
|
+
config.touchque.api_secret = Rails.application.credentials.dig(:touchque, :api_secret)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start (Rails)
|
|
48
|
+
|
|
49
|
+
One line per protected action:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class TransfersController < ApplicationController
|
|
53
|
+
include Touchque::Rails::Guard
|
|
54
|
+
require_touchque "SEND_MONEY", only: :create,
|
|
55
|
+
details: -> { { "Amount" => params.require(:amount), "To" => params.require(:to) } }
|
|
56
|
+
|
|
57
|
+
def create
|
|
58
|
+
touchque_approval # only reached once the user approved on their phone
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Until the user approves on their phone the action answers
|
|
64
|
+
**`202 {"touchque": step, "token": "..."}`**. Your page shows `step` in its own design (the
|
|
65
|
+
matching number, or a QR code the first time the user links the app) and sends the same request
|
|
66
|
+
again with header `X-Touchque-Token: <token>`; [`@touchque/web`](https://www.npmjs.com/package/@touchque/web)
|
|
67
|
+
does this loop for you. Once approved the action runs **once**. Refusals answer 403 (rejected,
|
|
68
|
+
blocked), 408 (expired), 423 (frozen) or 429 (rate limited) with `Retry-After`.
|
|
69
|
+
|
|
70
|
+
The approving user is `current_user`'s email (else id, else username). For your login action
|
|
71
|
+
(nobody is signed in yet) pass `user: -> { session[:password_verified_user] }`.
|
|
72
|
+
|
|
73
|
+
**The end user's address.** The risk checks (Tor, location, impossible travel) and the location
|
|
74
|
+
shown on the phone use `request.remote_ip`: configure `config.action_dispatch.trusted_proxies`
|
|
75
|
+
behind a load balancer.
|
|
76
|
+
|
|
77
|
+
## Action types
|
|
78
|
+
|
|
79
|
+
`"SEND_MONEY"` above is an action type: one kind of approval, with the title the phone shows and
|
|
80
|
+
whether it is critical. Define each one once, in the dashboard (**Developers → Action types**)
|
|
81
|
+
or from code:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
tq.action_types.define("SEND_MONEY", name: "Send money", critical: true)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The guard refuses what is not ready. A name that is not defined is a setup error
|
|
88
|
+
(`Touchque::ConfigError`, "Unknown action …"): the guard answers 500 with `reason: "misconfigured"`. A type
|
|
89
|
+
turned off in the dashboard answers 403 with `reason: "action_disabled"`, which stops that kind of
|
|
90
|
+
approval at once, without a deploy.
|
|
91
|
+
|
|
92
|
+
## The three primitives (any framework)
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
step = tq.start("SEND_MONEY", "jane@acme.com", details: { "Amount" => "250 EUR" })
|
|
96
|
+
# step["state"]: waiting (show step["number"]) | enroll (show step["enroll"]["qrCodeDataUrl"]) | passkey_required | blocked | frozen | rate_limited
|
|
97
|
+
now = tq.check(step["requestId"])
|
|
98
|
+
approval = tq.complete(step["requestId"], "jane@acme.com", "SEND_MONEY", details: { "Amount" => "250 EUR" })
|
|
99
|
+
# uses the approval once, and only for this user, action and amount
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`Touchque::Guard.run(tq, user, action, token:, offline:, code:, ...)` is the whole two-phase
|
|
103
|
+
contract for Sinatra, Hanami or plain Rack.
|
|
104
|
+
|
|
105
|
+
## Waiting in one call
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
result = tq.login.verify("jane@acme.com", type: "LOGIN", client_ip: request.remote_ip, user_agent: request.user_agent) do |c|
|
|
109
|
+
# Number matching: show the number now; the phone asks for it.
|
|
110
|
+
notify("Pick #{c["challengeCode"]} on your phone") if c["challengeCode"]
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`verify` raises `Touchque::RejectedError`, `Touchque::TimeoutError` or
|
|
115
|
+
`Touchque::PasskeyRequiredError` (`reason` "policy": approve with a passkey; "step_up": the user
|
|
116
|
+
tapped Approve during a call and the request now waits for a passkey).
|
|
117
|
+
|
|
118
|
+
## Relay routes for `@touchque/web`
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# config/routes.rb
|
|
122
|
+
mount Touchque::Rack::Relay.new(
|
|
123
|
+
get_user_id: ->(req) { req.session["user_email"] },
|
|
124
|
+
# The user who already passed your password step. Binds /login and the offline routes to them.
|
|
125
|
+
get_login_user: ->(req) { req.session["password_verified_user"] },
|
|
126
|
+
on_authenticated: ->(req, response, info) { req.session["user_email"] = info["externalUsername"] },
|
|
127
|
+
silent_sign_in: true # a known browser with nothing new skips the push
|
|
128
|
+
) => "/touchque"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Routes: `POST /login`, `/enroll/start`, `GET /enroll/status`, `/offline/challenge|verify|totp`,
|
|
132
|
+
`/passkey/authenticate/options|verify`, `/passkey/login/options|verify`,
|
|
133
|
+
`/passkey/register/options|verify`, `GET /passkey/credentials`, `DELETE /passkey/credentials/:id`.
|
|
134
|
+
Pre-auth routes are rate limited (20 per IP, 5 per username a minute; pass `rate_limit: { store: }`
|
|
135
|
+
for Redis across processes). The offline routes stay off until `get_login_user` is set. Adding a
|
|
136
|
+
passkey for a user with a linked phone waits for the phone first.
|
|
137
|
+
|
|
138
|
+
## Webhooks
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class TouchqueWebhooksController < ActionController::API
|
|
142
|
+
include Touchque::Rails::Webhook
|
|
143
|
+
verify_touchque_webhook
|
|
144
|
+
|
|
145
|
+
def create
|
|
146
|
+
EndSession.call(touchque_webhook["requestId"]) if touchque_webhook["event"] == Touchque::Resources::Webhook::EVENT_APPROVAL_REVOKED
|
|
147
|
+
head :ok
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The signature (your API secret over the canonical JSON), the signed timestamp (5 minutes) and the
|
|
153
|
+
`jti` are checked first: a bad one answers 401, a replay 200 without running your action again.
|
|
154
|
+
`Touchque.replay_cache` is in-process by default; with several Puma workers or hosts set one over
|
|
155
|
+
Redis (`check_and_set(jti, ttl_seconds)` with `SET NX EX`). Anywhere else:
|
|
156
|
+
`tq.webhook.verify(raw_body, signature_header, replay_cache: cache)`.
|
|
157
|
+
|
|
158
|
+
## Silent sign-ins
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
risk = tq.risk.check(user, browser_proof: request.headers["X-Touchque-Browser"], client_ip: request.remote_ip)
|
|
162
|
+
sign_in(user) if risk["prompt"] == false # a known browser, nothing new: no push
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Fails closed: only an answer of exactly `prompt: false` skips the phone. `tq.risk.browsers(user)`
|
|
166
|
+
lists a user's known browsers; `forget_browsers(user)` forgets them (after a password reset).
|
|
167
|
+
|
|
168
|
+
## Passkeys, offline codes, recovery, help desk
|
|
169
|
+
|
|
170
|
+
- `tq.webauthn`: registration, second factor and passwordless sign-in options/verify, list and delete passkeys.
|
|
171
|
+
- `tq.offline`: QR challenge and typed codes with the phone offline; `totp_verifiers` +
|
|
172
|
+
`Touchque::Resources::Offline.match_totp_verifier` check time-based codes while Touchque is unreachable.
|
|
173
|
+
- `tq.recovery` (Never Locked Out): vouchers, `start` (show `bindUri` as a QR), `status`, `veto`.
|
|
174
|
+
- `tq.assist` (help desk): `open`, `verify` (a 2-digit code the agent reads to the person), `spend`, `unfreeze`, `end`.
|
|
175
|
+
- `Touchque::ApprovalProof.verify(proof, request_id:, type:, details:, expected_public_key:)`: the
|
|
176
|
+
phone's own signature over what it showed ("what you see is what you sign").
|
|
177
|
+
|
|
178
|
+
## Touchque Actions
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
tq.actions.deploy("night-alert", trigger: "approval.decided",
|
|
182
|
+
template: { id: "alert", params: { channel: "slack", when: { countries: ["TR"] } } })
|
|
183
|
+
replay = tq.actions.dry_run(trigger: "login.request", code: code, limit: 500)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Plus `test`, `rollback`, `logs`, `metrics`, `templates`, `secrets`. Action code is JavaScript run
|
|
187
|
+
by Touchque in a sandbox, whatever language your server is in. Action types:
|
|
188
|
+
`tq.action_types.define("SEND_MONEY", name: "Send money", critical: true)`.
|
|
189
|
+
|
|
190
|
+
## AI agents
|
|
191
|
+
|
|
192
|
+
Your server registers agents (public key only) and checks their approvals; the agent signs in with its own key:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
keys = Touchque::AgentKeys.generate # where the agent runs; "ES256" or "EdDSA"
|
|
196
|
+
agent = tq.agents.create("Invoice bot", ["PAY_INVOICE"], public_key: keys["publicJwk"])
|
|
197
|
+
|
|
198
|
+
# The agent (TQ_AGENT_ID, TQ_AGENT_KEY): private_key_jwt + DPoP, never your API key.
|
|
199
|
+
bot = Touchque::Agent.new
|
|
200
|
+
approved = bot.require_approval("ayse@acme.com", "PAY_INVOICE", [payment]) { |s| say "Type #{s["challengeCode"]} on your phone" }
|
|
201
|
+
|
|
202
|
+
# Your server, right before it pays:
|
|
203
|
+
tq.agents.redeem_approval(approved["approvalToken"], user: "ayse@acme.com", authorization_details: payment)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Also: mandates (`request_mandate`), the token vault (`tq.agents.vault`, `bot.get_connection_token`),
|
|
207
|
+
policies, blocks, the kill switch, offline token checks (`verify_approval_token`).
|
|
208
|
+
`Touchque::OrgAgent` is an organization's agent on Touchque SSO (DPoP tokens for one app, token
|
|
209
|
+
exchange, CIBA, the token vault, shared accounts; GitHub Actions and Kubernetes tokens instead of
|
|
210
|
+
a stored key). `Touchque::McpAuth` protects an MCP server whose authorization server is Touchque SSO.
|
|
211
|
+
|
|
212
|
+
### Cross App Access (ID-JAG) and A2A agent cards
|
|
213
|
+
|
|
214
|
+
An organization's identity provider (Touchque SSO) can let one of its apps reach another service for a member with no consent screen (Cross App Access, ID-JAG, an open IETF draft): the app trades the member's ID token for an ID-JAG, then the ID-JAG for an access token at the other service. If you run an authorization server, the verifier checks an ID-JAG (issuer, single audience, your client, expiry, `typ`) and uses it once. Before your agent trusts another agent's A2A card, check its signatures (A2A 1.0 §8.4: JWS over the RFC 8785 canonical card); keys named by `jku` are only fetched from the card's own host or hosts you trust.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
jag = Touchque::CrossAppAccess.request_id_jag(issuer: "https://sso.touchque.com", client_id: client_id, client_secret: secret,
|
|
218
|
+
id_token: id_token, audience: "https://auth.files.example")
|
|
219
|
+
tokens = Touchque::CrossAppAccess.redeem_id_jag(token_endpoint: "https://auth.files.example/token", client_id: "acme",
|
|
220
|
+
client_secret: files_secret, assertion: jag["assertion"])
|
|
221
|
+
|
|
222
|
+
# Your own authorization server (jwt-bearer grant):
|
|
223
|
+
claims = Touchque::IdJagVerifier.new(issuer: "https://sso.touchque.com", audience: "https://auth.files.example")
|
|
224
|
+
.verify(params[:assertion], client_id: authenticated_client_id)
|
|
225
|
+
|
|
226
|
+
Touchque::AgentCard.verify(card_json, card_url: "https://agent.example/.well-known/agent-card.json")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Shared Signals (security events from Touchque SSO)
|
|
230
|
+
|
|
231
|
+
When something happens to a member's account after sign-in (sessions revoked, credentials changed or compromised, a device out of compliance, the account disabled), Touchque SSO sends a Security Event Token (OpenID Shared Signals Framework 1.0) to the receivers the organization's admin adds (Organization > Continuous access). Your app ends the person's sessions or asks them to sign in again. The verifier reads the keys from the issuer's `/.well-known/ssf-configuration` and checks the signature, `typ`, issuer, audience and the one event. A SET does not expire: keep the `jti`s you handled and skip a repeat.
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
SSF = Touchque::SecurityEventVerifier.new(issuer: "https://sso.touchque.com", audience: "https://app.example.com")
|
|
235
|
+
|
|
236
|
+
# Push (RFC 8935): the SET is the request body (application/secevent+jwt).
|
|
237
|
+
def create
|
|
238
|
+
event = SSF.verify(request.raw_post) # "name": session-revoked, credential-change, account-disabled...
|
|
239
|
+
end_sessions_of(event["subject"]["email"]) if event["name"] == "session-revoked"
|
|
240
|
+
head :accepted
|
|
241
|
+
rescue Touchque::SecurityEventError => e
|
|
242
|
+
render json: { err: e.error, description: e.message }, status: e.error == "temporarily_unavailable" ? 503 : 400
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Poll (RFC 8936): acknowledge what you handled on the next call.
|
|
246
|
+
ack = []
|
|
247
|
+
loop do
|
|
248
|
+
out = Touchque::SecurityEvents.poll(url: poll_url, token: stream_token, ack: ack, max_events: 50, return_immediately: true)
|
|
249
|
+
ack = out["sets"].map do |jti, set|
|
|
250
|
+
handle(SSF.verify(set))
|
|
251
|
+
jti
|
|
252
|
+
end
|
|
253
|
+
break if ack.empty? && !out["more_available"]
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`verify` returns `"jti"`, `"issuer"`, `"audience"`, `"issued_at"`, `"txn"` (the case code of Touchque's decision), `"subject"`, `"type"`, `"name"` and `"claims"`, or raises `Touchque::SecurityEventError` whose `error` is the RFC 8935 code to answer (`invalid_request`, `invalid_key`, `invalid_issuer`, `invalid_audience`; `temporarily_unavailable` when the issuer's keys could not be read). Report a SET you refuse while polling with `set_errs: { jti => { "err" => e.error, "description" => e.message } }`.
|
|
258
|
+
|
|
259
|
+
## Touchque Login (hosted sign-in)
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# config/application.rb
|
|
263
|
+
config.touchque.login = { domain: "acme.login.touchque.com", client_id: "...", client_secret: "...",
|
|
264
|
+
scope: "openid profile email offline_access" }
|
|
265
|
+
|
|
266
|
+
# config/routes.rb
|
|
267
|
+
Touchque::Rails.login_routes(self) # GET /login, /callback, /logout
|
|
268
|
+
|
|
269
|
+
class AccountController < ApplicationController
|
|
270
|
+
include Touchque::Rails::HostedLogin
|
|
271
|
+
before_action :require_touchque_sign_in
|
|
272
|
+
def show = render(json: touchque_session["user"])
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The session lives in encrypted cookies (AES-256-GCM, split when large, no session store needed;
|
|
277
|
+
keyed from `config.touchque.session_secret` or `secret_key_base`) and is refreshed before the
|
|
278
|
+
access token expires. `Touchque::HostedLogin` is the OpenID Connect client underneath (PKCE, ID
|
|
279
|
+
and access token checks, refresh, token exchange, CIBA, the token vault, client credentials,
|
|
280
|
+
logout); your API checks access tokens with `verify_access_token(token, audience:, required_scopes:)`.
|
|
281
|
+
`login.my_account(access_token)` is the customer's own account API. The Management API is
|
|
282
|
+
`tq.identity` (users, roles, organizations, apps, connections, imports and exports, attack
|
|
283
|
+
protection, email and SMS providers).
|
|
284
|
+
|
|
285
|
+
## Mobile SDK (your own app approves)
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
e = tq.mobile.create_enrollment(user_id) # the app spends e["enrollmentToken"]
|
|
289
|
+
push = Touchque::Resources::Mobile.push_from_webhook(event) # a MOBILE_PUSH webhook → APNs payload / FCM data
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Or give Touchque your APNs key / FCM service account (`update_settings(apns:, fcm:)`) and it pushes for you.
|
|
293
|
+
|
|
294
|
+
## Pairing a browser by QR code
|
|
295
|
+
|
|
296
|
+
Your page shows a QR code, the person scans it with the Touchque app, and the phone that scanned is the one asked to approve, with number matching. Your page sends the thumbprint of its browser key (`@touchque/web` `browserKey.thumbprint()`); the nonce in the QR never leaves your server (Touchque keeps its hash).
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
p = tq.qr_pairings.create(key_thumbprint, client_ip: request.remote_ip, user_agent: request.user_agent)
|
|
300
|
+
# show p["uri"] (touchque://pair-browser?...) as a QR code; poll:
|
|
301
|
+
now = tq.qr_pairings.get(p["pairingId"]) # waiting -> scanned -> confirmed
|
|
302
|
+
tq.login.request(now["externalUsername"], qr_pairing: p["pairingId"]) if now["state"] == "confirmed"
|
|
303
|
+
# or: tq.qr_pairings.refuse(p["pairingId"], reason: "device_not_approved", message: "Ask IT to approve this computer.")
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`get` says `scanned` / `confirmed` once a phone holding one of your users' accounts read it (with `proximity`: same network, same country), `reported` when the person said someone told them to scan it (their 2FA freezes). `refuse` tells the phone why your own checks said no.
|
|
307
|
+
|
|
308
|
+
## A code typed back on the phone
|
|
309
|
+
|
|
310
|
+
When a sign-in needs a code another service sent the person (a text message, WhatsApp, e-mail), ask them to type it on the phone instead. The phone encrypts it to a one-time key you made for this request: Touchque only ever holds the ciphertext. Such a request lives 5 minutes.
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
key = Touchque::CodeReply.new_key # keep key["privateKey"] for this request
|
|
314
|
+
sent = tq.login.request(user, reply: { kind: "code", key: key["publicKey"], channel: "sms", target: "+**********26" })
|
|
315
|
+
status = tq.login.status(sent["requestId"]) # once CONFIRMED
|
|
316
|
+
code = status["reply"] && Touchque::CodeReply.open(key["privateKey"], sent["requestId"], status["reply"]["ct"])
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Safe Guard (Safe transactions approved on the phone)
|
|
320
|
+
|
|
321
|
+
TouchqueSafeGuard is a transaction guard for a Safe: a transaction runs only with approvals signed by the P-256 keys on its approvers' phones. You send the raw transaction; the phone decodes it itself, shows its own rows and signs what it computed with a key only its biometrics unlock. Delegatecalls, changes to the Safe and proxy upgrades are governance: scheduled, time-locked and vetoable. A phone without that key yet gets `409 crypto_key_missing`.
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
target = { "chainId" => 1, "safe" => safe, "guard" => guard,
|
|
325
|
+
"tx" => { "to" => to, "value" => 0, "data" => data, "operation" => 0, "nonce" => 42 } }
|
|
326
|
+
sent = tq.safe_guard.request("treasurer@acme.com", "safe_payment", target.merge( # an action type from your dashboard
|
|
327
|
+
"config" => { "threshold" => 2, "govThreshold" => 3, "govDelay" => 172_800, "exitDelay" => 259_200 },
|
|
328
|
+
"batchTargets" => [multi_send_call_only],
|
|
329
|
+
"approverIndex" => 0 # this approver's index in the guard
|
|
330
|
+
))
|
|
331
|
+
done = tq.safe_guard.wait_for_approval(sent["requestId"]) # 5 minutes at most
|
|
332
|
+
approval = done["safeTx"]["approval"]
|
|
333
|
+
|
|
334
|
+
# Check it yourself: your own transaction, the approver's key from the guard on chain, and what
|
|
335
|
+
# you are about to do with it: "approve" runs this transaction, "schedule" starts a governance change.
|
|
336
|
+
purpose = done["safeTx"]["purpose"] == "schedule" ? "schedule" : "approve"
|
|
337
|
+
check = Touchque::SafeApproval.verify(target, approval, approver_key, purpose: purpose, approver_index: 0)
|
|
338
|
+
raise "approval refused: #{check["reason"]}" unless check["valid"]
|
|
339
|
+
|
|
340
|
+
if purpose == "approve"
|
|
341
|
+
signatures = Touchque::SafeApproval.with_approvals(owner_signatures, [approval]) # execTransaction(..., signatures)
|
|
342
|
+
else
|
|
343
|
+
data = Touchque::SafeApproval.schedule_call(safe, check["governanceHash"], [approval]) # a call to the guard; runs after govDelay
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`Touchque::SafeApproval.verify` makes the guard's check off chain, so a co-signer or your own server never has to trust Touchque: it computes the Safe transaction hash (EIP-712) and the governance hash from the raw fields, rebuilds the 149-byte message the phone signed and checks the P-256 signature. `purpose:` defaults to `"approve"`: a schedule approval covers no Safe nonce or gas fields, so it is accepted only when you pass `purpose: "schedule"`, never as permission to execute. It refuses a high `s`, an approval that expired or is valid for more than a day, another approver index, and, when you pass `rows:` or `display_digest:` from your own decoding, rows the phone did not show. `"reason"` is `approval_malformed`, `public_key_invalid`, `approver_index_mismatch`, `approval_expired`, `rows_differ`, `high_s` or `signature_invalid`. `safe_tx_hash`, `governance_hash`, `message` and `display_digest` are public too.
|
|
348
|
+
|
|
349
|
+
## Errors, case codes and retries
|
|
350
|
+
|
|
351
|
+
| Error | When |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `Touchque::APIError` | Touchque answered with an error: `status`, `code` (`device_not_linked`, `already_used`...), `case_code`, `retry_after`, `data` |
|
|
354
|
+
| `Touchque::NetworkError` | the request never reached Touchque (safe to retry) |
|
|
355
|
+
| `Touchque::RejectedError`, `TimeoutError`, `PasskeyRequiredError` | `verify` outcomes |
|
|
356
|
+
| `Touchque::WebhookSignatureError`, `WebhookReplayError` | webhooks |
|
|
357
|
+
| `Touchque::ApprovalError`, `ApprovalMismatchError` | agent approvals |
|
|
358
|
+
| `Touchque::ConfigError` | a missing key, an unknown action type, a wrong argument |
|
|
359
|
+
|
|
360
|
+
A feature your organization cannot use here raises a `Touchque::FeatureError` (still an `APIError`, status 403) with `feature` (`device_risk`, `ai_agents`…) and `feature_reason`: `FeatureNotInPlanError`, `FeatureReadOnlyError` or `FeatureOffError` — not in its plan (an owner or admin adds it in the Touchque dashboard), being turned off (reads work for 14 days, changes are refused) or switched off on this Touchque deployment.
|
|
361
|
+
|
|
362
|
+
**"Why?" case codes.** Refusals and pushes carry a 6-character case code (`K7P-2QX`): show it
|
|
363
|
+
("Tell your support team: K7P-2QX"); your team opens the whole decision by it in the dashboard.
|
|
364
|
+
|
|
365
|
+
**Retries.** Every request is signed with a one-time nonce, so the SDK never replays one. Retry
|
|
366
|
+
`Touchque::NetworkError` at the call site; on 429 / 423 wait `retry_after` seconds. The client
|
|
367
|
+
never follows redirects and refuses plain `http://` except for localhost.
|
|
368
|
+
|
|
369
|
+
## Logging
|
|
370
|
+
|
|
371
|
+
The SDK writes nothing to `$stderr` on its own. To see its own problems (a misconfigured guard, a failed `on_challenge`, a relay without `get_login_user`), set a logger: `Touchque.logger = Rails.logger` (or any `Logger`). Lines carry an error's class and message, never the request.
|
|
372
|
+
|
|
373
|
+
## Development
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
bundle install
|
|
377
|
+
bundle exec rake test # needs Node.js: the tests run against testing/fake-touchque-api.mjs and fixtures/
|
|
378
|
+
gem build touchque.gemspec
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## License
|
|
382
|
+
|
|
383
|
+
MIT
|