torii-backend 0.0.2

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 (30) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +120 -0
  3. data/spec/server-v1.json +1 -0
  4. data/src/torii/backend/auth.rb +28 -0
  5. data/src/torii/backend/authenticate_request.rb +56 -0
  6. data/src/torii/backend/client.rb +232 -0
  7. data/src/torii/backend/errors.rb +34 -0
  8. data/src/torii/backend/generated/lib/torii-backend-generated/api/server_sessions_api.rb +217 -0
  9. data/src/torii/backend/generated/lib/torii-backend-generated/api/server_users_api.rb +486 -0
  10. data/src/torii/backend/generated/lib/torii-backend-generated/api_client.rb +396 -0
  11. data/src/torii/backend/generated/lib/torii-backend-generated/api_error.rb +58 -0
  12. data/src/torii/backend/generated/lib/torii-backend-generated/api_model_base.rb +88 -0
  13. data/src/torii/backend/generated/lib/torii-backend-generated/configuration.rb +301 -0
  14. data/src/torii/backend/generated/lib/torii-backend-generated/models/create_user_request.rb +205 -0
  15. data/src/torii/backend/generated/lib/torii-backend-generated/models/cursor_page_response_user_response.rb +206 -0
  16. data/src/torii/backend/generated/lib/torii-backend-generated/models/problem_detail.rb +194 -0
  17. data/src/torii/backend/generated/lib/torii-backend-generated/models/server_user_search_request.rb +217 -0
  18. data/src/torii/backend/generated/lib/torii-backend-generated/models/update_user_request.rb +228 -0
  19. data/src/torii/backend/generated/lib/torii-backend-generated/models/user_response.rb +387 -0
  20. data/src/torii/backend/generated/lib/torii-backend-generated/models/user_session_response.rb +323 -0
  21. data/src/torii/backend/generated/lib/torii-backend-generated/version.rb +15 -0
  22. data/src/torii/backend/generated/lib/torii-backend-generated.rb +49 -0
  23. data/src/torii/backend/patch.rb +23 -0
  24. data/src/torii/backend/rack.rb +69 -0
  25. data/src/torii/backend/verify.rb +162 -0
  26. data/src/torii/backend/version.rb +7 -0
  27. data/src/torii/backend/webhook.rb +19 -0
  28. data/src/torii/backend.rb +22 -0
  29. data/src/torii-backend-generated.rb +26 -0
  30. metadata +163 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 69b936da488f917cf7110687e96fa696678e689e75e82bd209ea829a26ffc73d
4
+ data.tar.gz: ce1f78461f54b62eed2a7f926a0aab7d427bef3b6d101ab6c44331da262a0680
5
+ SHA512:
6
+ metadata.gz: 4a9f56628fc8f49fae8667c8f11d716189853daa1a87bc6b839c93dd75c496ded4a8c47340c3ff682c67ce31eb62c3a775872468bb4538463e74dbf04f0d3bfe
7
+ data.tar.gz: 3eab77aaa2315b52295d8ca1bc895d87c44c8569879b45a4183c614c9949c3202da11efb83b16705866ad03f71d5aa44d8117f5c387d2793d1221c5df0cca12e
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # torii-backend (Ruby)
2
+
3
+ Backend SDK for [torii](https://torii.so) — verify end-user JWTs without a per-request round trip and manage users from your Ruby server.
4
+
5
+ > **v0.x — API may still change.**
6
+
7
+ ## Setup
8
+
9
+ 1. Sign in to [app.torii.so](https://app.torii.so) and from your dashboard copy:
10
+ - your **issuer URL** (e.g. `https://acme.torii.so`)
11
+ - a **secret key** (`sk_test_…` for development, `sk_live_…` for production)
12
+
13
+ 2. Install the gem:
14
+
15
+ ```sh
16
+ gem install torii-backend
17
+ ```
18
+
19
+ or in your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem 'torii-backend'
23
+ ```
24
+
25
+ Requires Ruby 3.1+.
26
+
27
+ 3. Verify an end-user JWT:
28
+
29
+ ```ruby
30
+ require 'torii/backend'
31
+
32
+ auth = Torii::Backend.verify_token(token, issuer: 'https://acme.torii.so')
33
+ auth.user_id # => "user_abc"
34
+ auth.environment_id # => "env_xyz"
35
+ auth.email_verified # => true
36
+ ```
37
+
38
+ The first call fetches the issuer's JWKS at `{issuer}/_torii/.well-known/jwks.json`; subsequent calls reuse a process-wide cache (5-minute TTL, automatic refresh on `kid` miss). ES256 only.
39
+
40
+ 4. Call the backend REST API:
41
+
42
+ ```ruby
43
+ torii = Torii::Backend::Client.new(secret_key: ENV.fetch('TORII_SECRET_KEY'))
44
+ user = torii.users.get(user_id)
45
+ ```
46
+
47
+ Default base URL is `https://api.torii.so`. Override with `api_url:` for staging or self-hosted.
48
+
49
+ ## Rack middleware (Rails / Sinatra / Roda)
50
+
51
+ ```ruby
52
+ # config/application.rb (Rails)
53
+ config.middleware.use Torii::Backend::Rack::RequireAuth,
54
+ issuer: 'https://acme.torii.so'
55
+ ```
56
+
57
+ ```ruby
58
+ # config.ru (Sinatra / plain Rack)
59
+ use Torii::Backend::Rack::RequireAuth, issuer: 'https://acme.torii.so'
60
+ run MyApp
61
+ ```
62
+
63
+ On success the verified `Torii::Backend::Auth` is placed at `env['torii.auth']`. On failure the middleware short-circuits with a `401` JSON body:
64
+
65
+ ```json
66
+ { "error": { "code": "authentication_failed", "message": "..." } }
67
+ ```
68
+
69
+ For ad-hoc verification outside Rack:
70
+
71
+ ```ruby
72
+ auth = Torii::Backend.authenticate_request(
73
+ request.env,
74
+ issuer: 'https://acme.torii.so',
75
+ )
76
+ ```
77
+
78
+ `authenticate_request` accepts a Rack `env`, a plain `Hash` of headers (string or symbol keys), or anything that responds to `#each` with `[name, value]` pairs.
79
+
80
+ ## Backend API (REST client)
81
+
82
+ ```ruby
83
+ page = torii.users.list(limit: 50)
84
+ page[:items] # => [{ id: "...", ... }, ...]
85
+ page[:next_cursor] # => "cursor_..." or nil
86
+ page[:has_more] # => true / false
87
+
88
+ user = torii.users.create(email: 'x@y.com')
89
+ torii.users.update(user[:id], name: Torii::Backend::Patch.set('New name'))
90
+ torii.users.ban(user[:id])
91
+
92
+ sessions = torii.sessions.list_for_user(user[:id])
93
+ torii.sessions.revoke_all_for_user(user[:id])
94
+ ```
95
+
96
+ The REST client is generated from the OpenAPI spec at `spec/server-v1.json` via [openapi-generator-cli](https://openapi-generator.tech/) (target: `ruby`); hand-written wrappers in `lib/torii/backend/client.rb` give it the Ruby-idiomatic surface above.
97
+
98
+ ### Partial updates
99
+
100
+ PATCH fields are tri-state: set to a value, clear (JSON null on the wire), or leave alone. Ruby keyword args can't express that on their own (a literal `nil` can't be told apart from "absent"), so every kwarg accepted by `update` must be wrapped in `Torii::Backend::Patch`:
101
+
102
+ ```ruby
103
+ torii.users.update(user_id,
104
+ name: Torii::Backend::Patch.set('New name'), # update the name
105
+ phone: Torii::Backend::Patch.set(nil), # null on the wire
106
+ # locale, address, date_of_birth omitted -> untouched
107
+ )
108
+ ```
109
+
110
+ `Patch.set(value)` updates the field; `Patch.set(nil)` clears it; omitted kwargs are left untouched on the server.
111
+
112
+ ## Errors
113
+
114
+ * `Torii::Backend::Error` — base.
115
+ * `Torii::Backend::AuthError` — raised by `verify_token` / `authenticate_request`.
116
+ * `Torii::Backend::ApiError` — raised by REST calls on non-2xx. Inspect `status`, `code`, `support_id`, `body`.
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1 @@
1
+ {"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:52334","description":"Generated server url"}],"tags":[{"name":"Server Users","description":"Server user management endpoints (secret key auth)"},{"name":"Server Sessions","description":"Server session management endpoints (secret key auth)"}],"paths":{"/api/server/v1/users":{"post":{"tags":["Server Users"],"summary":"Create user","description":"Creates an end-user in your environment. All body fields are optional; supply at minimum an email if you want the user to be able to sign in via email + password.","operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"201":{"description":"The created user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"400":{"description":"Invalid body (e.g. weak password, malformed email).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"409":{"description":"Another user with this email already exists in the environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}},"/api/server/v1/users/{userId}/unban":{"post":{"tags":["Server Users"],"summary":"Unban user","description":"Reverses a previous ban. The user can sign in again on next request.","operationId":"unbanUser","parameters":[{"name":"userId","in":"path","description":"Identifier of the user to unban.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"responses":{"200":{"description":"The (now active) user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"404":{"description":"No user with this id.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}},"/api/server/v1/users/{userId}/ban":{"post":{"tags":["Server Users"],"summary":"Ban user","description":"Marks the user as banned and revokes all their active sessions.","operationId":"banUser","parameters":[{"name":"userId","in":"path","description":"Identifier of the user to ban.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"responses":{"200":{"description":"The (now banned) user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"404":{"description":"No user with this id.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}},"/api/server/v1/users/search":{"post":{"tags":["Server Users"],"summary":"Search users","description":"Returns a cursor-paginated page of end-users in the environment matching the optional filters. Filters use the same tri-state PATCH semantics as `UpdateUserRequest`: omit a field to skip that filter, send a value to require it, send null to require null. Uses POST so the filter body can be sent without URL-encoding.","operationId":"searchUsers","parameters":[{"name":"limit","in":"query","description":"Maximum number of items in the returned page (default 20).","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":50},{"name":"cursor","in":"query","description":"Opaque cursor returned by the previous page's `nextCursor`. Omit to fetch the first page.","required":false,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerUserSearchRequest"}}}},"responses":{"200":{"description":"Page of matching users.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CursorPageResponseUserResponse"}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}},"/api/server/v1/users/{userId}":{"get":{"tags":["Server Users"],"summary":"Get user","description":"Returns the full profile for one end-user.","operationId":"getUser","parameters":[{"name":"userId","in":"path","description":"Identifier of the user to fetch.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"responses":{"200":{"description":"The user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"404":{"description":"No user with this id.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}},"delete":{"tags":["Server Users"],"summary":"Delete user","description":"Soft-deletes the user. Not idempotent at the HTTP layer: the authorization grant for the user is revoked on the first successful delete, so a subsequent DELETE for the same id returns 403 rather than 204. Treat 403 from a retry as a confirmation that the user is already deleted.","operationId":"deleteUser","parameters":[{"name":"userId","in":"path","description":"Identifier of the user to delete.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"responses":{"204":{"description":"User deleted."},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"404":{"description":"No user with this id.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}},"patch":{"tags":["Server Users"],"summary":"Update user","description":"Partial update with tri-state PATCH semantics. Every field in `UpdateUserRequest` is tri-state: omit the key to leave the field unchanged, send a non-null value to set it, or send JSON null to clear it.","operationId":"updateUser","parameters":[{"name":"userId","in":"path","description":"Identifier of the user to update.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"The updated user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"400":{"description":"Invalid body.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"404":{"description":"No user with this id.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}},"/api/server/v1/users/{userId}/sessions":{"get":{"tags":["Server Sessions"],"summary":"List user sessions","description":"Returns all active (unexpired, unrevoked) sessions for the user, ordered by most recently used.","operationId":"listSessions","parameters":[{"name":"userId","in":"path","description":"Identifier of the user whose sessions to list.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"responses":{"200":{"description":"All active sessions for the user.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserSessionResponse"}}}}},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}},"delete":{"tags":["Server Sessions"],"summary":"Revoke all sessions","description":"Immediately revokes every active session for the user. Idempotent.","operationId":"revokeAllSessions","parameters":[{"name":"userId","in":"path","description":"Identifier of the user whose sessions to revoke.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"}],"responses":{"204":{"description":"Sessions revoked."},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}},"/api/server/v1/users/{userId}/sessions/{sessionId}":{"delete":{"tags":["Server Sessions"],"summary":"Revoke specific session","description":"Revokes a single session by id. Idempotent: returns 204 even if the session was already revoked or expired.","operationId":"revokeSession","parameters":[{"name":"userId","in":"path","description":"Identifier of the user who owns the session.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a73-8b00-7000-8000-000000000000"},{"name":"sessionId","in":"path","description":"Identifier of the session to revoke.","required":true,"schema":{"type":"string","format":"uuid"},"example":"01931a74-1234-7000-8000-000000000000"}],"responses":{"204":{"description":"Session revoked."},"401":{"description":"Missing or invalid secret key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}},"403":{"description":"User or session belongs to a different environment.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetail"}}}}}}}},"components":{"schemas":{"CreateUserRequest":{"type":"object","description":"Request body for creating an end-user in your environment. All fields are optional; supply at minimum an email if you want the user to be able to sign in via email + password.","properties":{"email":{"type":["string","null"],"description":"Primary email for the new user. If omitted, the user is created without a sign-in identity.","example":"ada@example.com"},"password":{"type":["string","null"],"description":"Initial password. Subject to the environment's password policy. Omit to create a passwordless user (e.g. social-only).","example":"correct horse battery staple"},"name":{"type":["string","null"],"description":"Display name to seed on the profile.","example":"Ada Lovelace"},"phone":{"type":["string","null"],"description":"Phone number to seed on the profile.","example":"+15555550100"},"address":{"type":["string","null"],"description":"Postal address to seed on the profile.","example":"221B Baker Street, London"},"dateOfBirth":{"type":["string","null"],"format":"date","description":"Date of birth in ISO-8601 (YYYY-MM-DD).","example":"1815-12-10"}}},"ProblemDetail":{"type":"object","properties":{"type":{"type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer","format":"int32"},"detail":{"type":"string"},"instance":{"type":"string","format":"uri"},"properties":{"type":"object","additionalProperties":{}}}},"UserResponse":{"type":"object","description":"An end-user belonging to one of your environments.","properties":{"id":{"type":"string","format":"uuid","description":"Unique identifier for this user.","example":"01931a73-8b00-7000-8000-000000000000"},"environmentId":{"type":"string","format":"uuid","description":"Identifier of the environment this user belongs to.","example":"01931a72-0000-7000-8000-000000000000"},"name":{"type":["string","null"],"description":"Full name on the profile, if any.","example":"Ada Lovelace"},"phone":{"type":["string","null"],"description":"Phone number on the profile, if any. Not guaranteed to be verified.","example":"+15555550100"},"locale":{"type":["string","null"],"description":"Preferred locale for emails and UI messages.","enum":["en","da"]},"address":{"type":["string","null"],"description":"Free-form address string, if provided.","example":"221B Baker Street, London"},"dateOfBirth":{"type":["string","null"],"format":"date","description":"Date of birth in ISO-8601 (YYYY-MM-DD), if provided.","example":"1815-12-10"},"status":{"type":"string","description":"Lifecycle status of the user (e.g. active, banned).","enum":["pending_verification","active","banned","deleted"]},"createdAt":{"type":"string","format":"date-time","description":"When this user was created (ISO-8601 UTC).","example":"2026-05-16T09:30:00Z"},"updatedAt":{"type":"string","format":"date-time","description":"When this user was last modified (ISO-8601 UTC).","example":"2026-05-16T10:00:00Z"},"email":{"type":["string","null"],"description":"Primary email on the profile, if any. Not guaranteed to be verified.","example":"ada@example.com"},"deletedAt":{"type":["string","null"],"format":"date-time","description":"When this user was deleted, if soft-deleted. Null for active users.","example":"2026-05-20T12:00:00Z"}},"required":["createdAt","environmentId","id","status","updatedAt"]},"ServerUserSearchRequest":{"type":"object","description":"Optional filter body for `POST /users/search`. Every field is tri-state: omit to skip that filter, send a value to require it. Fields whose inner type is nullable (currently `name`, `email`) additionally accept JSON null to filter for users where that column is null; the non-nullable `statuses` field rejects null.","properties":{"name":{"type":["string","null"],"description":"Filter by name (case-insensitive substring match). Send null to require users with no name.","example":"Ada"},"email":{"type":["string","null"],"description":"Filter by primary email (case-insensitive substring match). Send null to require users with no email.","example":"@example.com"},"statuses":{"type":"array","description":"Filter by user status. Returns users matching any of the supplied statuses.","items":{"type":"string","enum":["pending_verification","active","banned","deleted"]},"uniqueItems":true},"createdAfter":{"type":["string","null"],"format":"date-time","description":"Only return users created at or after this instant (ISO-8601 UTC).","example":"2026-01-01T00:00:00Z"},"createdBefore":{"type":["string","null"],"format":"date-time","description":"Only return users created at or before this instant (ISO-8601 UTC).","example":"2026-12-31T23:59:59Z"}}},"CursorPageResponseUserResponse":{"type":"object","description":"A single page of results in a cursor-paginated list. Pass `nextCursor` as the `cursor` query parameter to fetch the following page.","properties":{"items":{"type":"array","description":"Items in this page, in stable order.","items":{"$ref":"#/components/schemas/UserResponse"}},"nextCursor":{"type":["string","null"],"format":"uuid","description":"Cursor to pass to fetch the next page. Null when this is the last page.","example":"01931a73-8b00-7000-8000-000000000000"},"hasMore":{"type":"boolean","description":"True if more pages are available (equivalent to `nextCursor != null`).","example":true}},"required":["hasMore","items"]},"UpdateUserRequest":{"type":"object","description":"PATCH body for updating an end-user. Every field is tri-state: omit the key entirely to leave the field unchanged, send a non-null value to set it, or send JSON null to clear it.","properties":{"name":{"type":["string","null"],"description":"New display name. Send null to clear; omit to leave unchanged.","example":"Ada Lovelace"},"phone":{"type":["string","null"],"description":"New phone number. Send null to clear; omit to leave unchanged.","example":"+15555550100"},"locale":{"type":["string","null"],"description":"New preferred locale. Send null to clear; omit to leave unchanged.","enum":["en","da"]},"address":{"type":["string","null"],"description":"New postal address. Send null to clear; omit to leave unchanged.","example":"221B Baker Street, London"},"dateOfBirth":{"type":["string","null"],"format":"date","description":"New date of birth (YYYY-MM-DD). Send null to clear; omit to leave unchanged.","example":"1815-12-10"}}},"UserSessionResponse":{"type":"object","description":"An active end-user session in your environment.","properties":{"id":{"type":"string","format":"uuid","description":"Unique identifier for this session.","example":"01931a74-1234-7000-8000-000000000000"},"userId":{"type":"string","format":"uuid","description":"Identifier of the end-user this session belongs to.","example":"01931a73-8b00-7000-8000-000000000000"},"environmentId":{"type":"string","format":"uuid","description":"Identifier of the environment this session belongs to.","example":"01931a72-0000-7000-8000-000000000000"},"userAgent":{"type":["string","null"],"description":"Raw User-Agent string captured when the session was created.","example":"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36"},"ipAddress":{"type":["string","null"],"description":"IP address captured when the session was created.","example":"203.0.113.42"},"createdAt":{"type":"string","format":"date-time","description":"When this session was created (ISO-8601 UTC).","example":"2026-05-16T09:30:00Z"},"expiresAt":{"type":"string","format":"date-time","description":"When this session expires (ISO-8601 UTC).","example":"2026-05-23T09:30:00Z"},"lastUsedAt":{"type":"string","format":"date-time","description":"When this session was last seen by the API (ISO-8601 UTC).","example":"2026-05-16T11:42:00Z"}},"required":["createdAt","environmentId","expiresAt","id","lastUsedAt","userId"]}}}}
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torii
4
+ module Backend
5
+ # Subset of fields the backend SDK exposes from a verified torii JWT.
6
+ # For full claim access (custom claims, audience, etc.) read +raw+.
7
+ #
8
+ # Kept as a plain Struct for Ruby 3.1 compatibility (Data.define is
9
+ # 3.2+). Behaviour is the same — frozen value object, positional or
10
+ # keyword construction, hash-like access.
11
+ Auth = Struct.new(
12
+ :user_id,
13
+ :environment_id,
14
+ :issuer,
15
+ :email_verified,
16
+ :profile_complete,
17
+ :impersonating,
18
+ :locale,
19
+ :raw,
20
+ keyword_init: true,
21
+ ) do
22
+ def initialize(*) # rubocop:disable Lint/MissingSuper
23
+ super
24
+ freeze
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'verify'
5
+
6
+ module Torii
7
+ module Backend
8
+ module_function
9
+
10
+ # Extract a bearer token from a Rack +env+ hash (or any plain header
11
+ # hash) and verify it. Accepts either:
12
+ #
13
+ # * a Rack environment, in which case the +header+ option is matched
14
+ # against the +HTTP_*+ keys (default: +HTTP_AUTHORIZATION+);
15
+ # * a plain hash of headers (string keys like +"authorization"+ or
16
+ # symbol keys like +:authorization+).
17
+ #
18
+ # The header name match is case-insensitive and tolerant of the Rack
19
+ # +HTTP_AUTHORIZATION+ convention.
20
+ #
21
+ # @return [Torii::Backend::Auth]
22
+ # @raise [Torii::Backend::AuthError]
23
+ def authenticate_request(env_or_headers, issuer:, audience: nil, leeway: 30, header: 'authorization')
24
+ raw = extract_header(env_or_headers, header)
25
+ raise AuthError, "Missing #{header} header" if raw.nil? || raw.empty?
26
+
27
+ match = /\ABearer\s+(.+)\z/i.match(raw)
28
+ raise AuthError, "#{header} header is not in 'Bearer <token>' form" unless match
29
+
30
+ verify_token(match[1].strip, issuer: issuer, audience: audience, leeway: leeway)
31
+ end
32
+
33
+ # Internal: pull a header value out of a Rack env or a plain hash.
34
+ # Handles the +HTTP_*+ rack convention, dashes-vs-underscores, and
35
+ # both string and symbol keys.
36
+ def extract_header(env_or_headers, name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
37
+ return nil unless env_or_headers.respond_to?(:each_pair) || env_or_headers.respond_to?(:each)
38
+
39
+ lower = name.to_s.downcase
40
+ rack_key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
41
+ # Rack always uses HTTP_AUTHORIZATION even when the input header is
42
+ # 'authorization', so check that explicitly too.
43
+ rack_key = 'HTTP_AUTHORIZATION' if lower == 'authorization'
44
+
45
+ env_or_headers.each do |key, value|
46
+ key_str = key.to_s
47
+ next unless key_str == rack_key ||
48
+ key_str.downcase == lower ||
49
+ key_str.downcase.tr('_', '-') == lower
50
+
51
+ return value.is_a?(Array) ? value.first : value
52
+ end
53
+ nil
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative '../../torii-backend-generated'
6
+ require_relative 'errors'
7
+ require_relative 'patch'
8
+ require_relative 'version'
9
+
10
+ module Torii
11
+ module Backend
12
+ # Default torii API base URL. Override via +api_url+ for staging or
13
+ # self-hosted.
14
+ DEFAULT_API_URL = 'https://api.torii.so'
15
+
16
+ # Top-level entry point for the REST surface.
17
+ #
18
+ # Construct with +Torii::Backend::Client.new(secret_key: '...')+.
19
+ # Reuse the instance across requests — the underlying generated
20
+ # client (which wraps Faraday) maintains a connection-keeping
21
+ # configuration.
22
+ class Client
23
+ attr_reader :users, :sessions
24
+
25
+ # @param secret_key [String] backend secret key, e.g. +sk_live_*+
26
+ # or +sk_test_*+. Required.
27
+ # @param api_url [String] backend base URL. Defaults to
28
+ # +https://api.torii.so+. Override for staging/self-hosted.
29
+ # @param http_adapter [Object, nil] optional callable used to stub
30
+ # requests in tests. When provided, it must respond to
31
+ # +#call(method, url, headers, body, query)+ and return a Hash
32
+ # with +:status+, +:headers+, +:body+. The generated client uses
33
+ # Typhoeus by default; the stub is only consulted when the
34
+ # underlying +ApiClient+ is replaced by a test helper.
35
+ def initialize(secret_key:, api_url: DEFAULT_API_URL, http_adapter: nil)
36
+ raise ArgumentError, 'secret_key is required' if !secret_key.is_a?(String) || secret_key.empty?
37
+
38
+ @config = build_config(secret_key: secret_key, api_url: api_url)
39
+ @api_client = ToriiBackendGenerated::ApiClient.new(@config)
40
+ @http_adapter = http_adapter
41
+ @users = UsersClient.new(@api_client)
42
+ @sessions = SessionsClient.new(@api_client)
43
+ end
44
+
45
+ # @return [ToriiBackendGenerated::ApiClient] the underlying
46
+ # generated client. Exposed for advanced callers who need to
47
+ # tweak Faraday config; most code should not need this.
48
+ attr_reader :api_client
49
+
50
+ private
51
+
52
+ def build_config(secret_key:, api_url:)
53
+ config = ToriiBackendGenerated::Configuration.new
54
+ uri = URI.parse(api_url)
55
+ config.scheme = uri.scheme || 'https'
56
+ config.host = [uri.host, uri.port].compact.join(':')
57
+ config.base_path = uri.path.to_s.sub(%r{/+\z}, '')
58
+ # The generated client treats +access_token+ as a Bearer token
59
+ # but only applies it when an operation declares +bearerAuth+ as
60
+ # an auth_setting. Our spec doesn't (yet), so the per-call
61
+ # wrappers below inject the header directly.
62
+ config.access_token = secret_key
63
+ config
64
+ end
65
+ end
66
+
67
+ # Hand-written wrapper around the generated +ServerUsersApi+. The
68
+ # wrapper:
69
+ #
70
+ # * keeps a Pythonic / idiomatic-Ruby keyword surface that matches
71
+ # the surface promised in the SDK docs across languages;
72
+ # * sets the secret-key header on every call (the generated client
73
+ # does not, see comment in Client#build_config);
74
+ # * deserialises generated model instances to plain hashes so
75
+ # callers don't need to learn the generated namespace.
76
+ class UsersClient
77
+ def initialize(api_client)
78
+ @api_client = api_client
79
+ @api = ToriiBackendGenerated::ServerUsersApi.new(api_client)
80
+ end
81
+
82
+ # Search users. Server-side cursor-paginated; call repeatedly with
83
+ # +cursor: page[:next_cursor]+ until +page[:has_more]+ is false.
84
+ def list(limit: nil, cursor: nil, name: nil, email: nil, statuses: nil, created_after: nil, created_before: nil)
85
+ body = ToriiBackendGenerated::ServerUserSearchRequest.new(
86
+ name: name,
87
+ email: email,
88
+ statuses: statuses,
89
+ created_after: created_after,
90
+ created_before: created_before,
91
+ )
92
+ opts = { server_user_search_request: body, header_params: auth_headers }
93
+ opts[:limit] = limit unless limit.nil?
94
+ opts[:cursor] = cursor unless cursor.nil?
95
+ result = @api.search_users(opts)
96
+ page_to_hash(result)
97
+ end
98
+
99
+ def get(user_id)
100
+ model_to_hash(@api.get_user(user_id, header_params: auth_headers))
101
+ end
102
+
103
+ def create(email: nil, name: nil, phone: nil, password: nil, address: nil, date_of_birth: nil)
104
+ body = ToriiBackendGenerated::CreateUserRequest.new(
105
+ email: email,
106
+ name: name,
107
+ phone: phone,
108
+ password: password,
109
+ address: address,
110
+ date_of_birth: date_of_birth,
111
+ )
112
+ model_to_hash(@api.create_user(body, header_params: auth_headers))
113
+ end
114
+
115
+ # PATCH a user. Each kwarg must be a {Torii::Backend::Patch}
116
+ # instance — Ruby keyword args can't distinguish "absent" from
117
+ # "explicit nil" on their own, so we use a wrapper:
118
+ #
119
+ # client.users.update(user_id,
120
+ # name: Torii::Backend::Patch.set("Ada"), # set field
121
+ # phone: Torii::Backend::Patch.set(nil), # null on the wire (clear)
122
+ # )
123
+ #
124
+ # Omitted kwargs are left untouched on the server. Field names map
125
+ # to the JSON keys the server expects (camelCase).
126
+ def update(user_id, **patches)
127
+ body = {}
128
+ patches.each do |field, patch|
129
+ unless patch.is_a?(Patch)
130
+ raise ArgumentError, "kwarg #{field} must be a Torii::Backend::Patch (got #{patch.class})"
131
+ end
132
+
133
+ json_key = PATCH_FIELD_MAP.fetch(field) do
134
+ raise ArgumentError, "unknown PATCH field: #{field}. Valid: #{PATCH_FIELD_MAP.keys.inspect}"
135
+ end
136
+
137
+ # Patch.set(value) emits the key; nil value → JSON null (clear).
138
+ body[json_key] = patch.value
139
+ end
140
+
141
+ # +debug_body+ on the generated client is the escape hatch for
142
+ # sending a pre-rendered request body. The generated
143
+ # +UpdateUserRequest+ model strips nil-valued attributes when
144
+ # serialising, which would defeat the +Patch.clear+ case, so we
145
+ # bypass it and ship our hand-built JSON instead.
146
+ result = @api.update_user(
147
+ user_id,
148
+ ToriiBackendGenerated::UpdateUserRequest.new,
149
+ debug_body: body.to_json,
150
+ header_params: auth_headers,
151
+ )
152
+ model_to_hash(result)
153
+ end
154
+
155
+ # Map of Ruby snake_case kwargs to the JSON keys the server
156
+ # expects on the PATCH body. Centralised so +update+ can validate
157
+ # field names with a single +fetch+.
158
+ PATCH_FIELD_MAP = {
159
+ name: 'name',
160
+ phone: 'phone',
161
+ locale: 'locale',
162
+ address: 'address',
163
+ date_of_birth: 'dateOfBirth',
164
+ }.freeze
165
+
166
+ def delete(user_id)
167
+ @api.delete_user(user_id, header_params: auth_headers)
168
+ nil
169
+ end
170
+
171
+ def ban(user_id)
172
+ model_to_hash(@api.ban_user(user_id, header_params: auth_headers))
173
+ end
174
+
175
+ def unban(user_id)
176
+ model_to_hash(@api.unban_user(user_id, header_params: auth_headers))
177
+ end
178
+
179
+ private
180
+
181
+ def auth_headers
182
+ { 'Authorization' => "Bearer #{@api_client.config.access_token}" }
183
+ end
184
+
185
+ def model_to_hash(model)
186
+ return model if model.nil? || model.is_a?(Hash)
187
+
188
+ model.respond_to?(:to_hash) ? model.to_hash : model
189
+ end
190
+
191
+ def page_to_hash(page)
192
+ return page if page.nil? || page.is_a?(Hash)
193
+
194
+ {
195
+ items: (page.items || []).map { |u| model_to_hash(u) },
196
+ next_cursor: page.respond_to?(:next_cursor) ? page.next_cursor : nil,
197
+ has_more: page.respond_to?(:has_more) ? page.has_more : false,
198
+ }
199
+ end
200
+ end
201
+
202
+ # Hand-written wrapper around the generated +ServerSessionsApi+.
203
+ # See the +UsersClient+ docstring for the rationale.
204
+ class SessionsClient
205
+ def initialize(api_client)
206
+ @api_client = api_client
207
+ @api = ToriiBackendGenerated::ServerSessionsApi.new(api_client)
208
+ end
209
+
210
+ def list_for_user(user_id)
211
+ result = @api.list_sessions(user_id, header_params: auth_headers)
212
+ (result || []).map { |s| s.respond_to?(:to_hash) ? s.to_hash : s }
213
+ end
214
+
215
+ def revoke_all_for_user(user_id)
216
+ @api.revoke_all_sessions(user_id, header_params: auth_headers)
217
+ nil
218
+ end
219
+
220
+ def revoke(user_id, session_id)
221
+ @api.revoke_session(user_id, session_id, header_params: auth_headers)
222
+ nil
223
+ end
224
+
225
+ private
226
+
227
+ def auth_headers
228
+ { 'Authorization' => "Bearer #{@api_client.config.access_token}" }
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torii
4
+ module Backend
5
+ # Base class for all errors raised by torii-backend.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when /api/server/v1/** responds non-2xx. Inspect +status+, +code+
9
+ # (from the error body if present), and +support_id+ (echoed correlation
10
+ # id) for diagnostics. +body+ is the raw parsed response.
11
+ class ApiError < Error
12
+ attr_reader :status, :code, :support_id, :body
13
+
14
+ def initialize(message, status:, body: nil)
15
+ super(message)
16
+ @status = status
17
+ @body = body
18
+ @code = body['code'] if body.is_a?(Hash) && body['code'].is_a?(String)
19
+ @support_id = (body['supportId'] || body['support_id']) if body.is_a?(Hash)
20
+ end
21
+ end
22
+
23
+ # Raised by verify_token / authenticate_request when a token cannot be
24
+ # verified (bad signature, wrong issuer, missing claim, expired, ...).
25
+ class AuthError < Error
26
+ attr_reader :cause
27
+
28
+ def initialize(message, cause: nil)
29
+ super(message)
30
+ @cause = cause
31
+ end
32
+ end
33
+ end
34
+ end