authio 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 46dedee240bbb4a67ac3f37cc839f72a54ba31eec1524f0151887604cb90508c
4
+ data.tar.gz: b0e069d555664a0f4bc8c9f2f6db71cf93900602f0e094817bad05dc6e8efbf2
5
+ SHA512:
6
+ metadata.gz: 90564c750d071a6f39f29570e162ffc865985f30e23c4da7f16178951e039b445660594da25b025b670c346d2c5fe04a81d43d19208240ee656a7b6488cca5a3
7
+ data.tar.gz: 7663208ef407426ab496969f936c00c40455596e3ace42553794e5c9851f7798a1f24623200b78e2fd877a40fc60d635c8df068ae0ecf857ee159b5969f56522
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Authio
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,152 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.png">
4
+ <img alt="Authio" src=".github/logo-light.png" width="220">
5
+ </picture>
6
+ </p>
7
+
8
+ # authio (Ruby gem)
9
+
10
+ > Part of **[Authio Lobby](https://authio.com/products/lobby)** —
11
+ > Authio's drop-in passwordless authentication. Learn more at
12
+ > https://authio.com/products/lobby.
13
+
14
+ Authio Ruby SDK. Verifies session JWTs against Authio's JWKS, builds hosted
15
+ sign-in URLs, and slots into Rails controllers via a `before_action` helper.
16
+
17
+ ## What's new — 2026-05-23 WorkOS-parity sprint
18
+
19
+ The Authio API gained four new product surfaces this sprint
20
+ ([`authio_proto`](https://github.com/authio-com/authio_proto)). The Ruby gem already verifies the new JWT
21
+ shape end-to-end (`claims["roles"]` and `claims["permissions"]` ride
22
+ along on every customer session); idiomatic helpers for the new
23
+ write-side endpoints are queued for the next release. Reference docs:
24
+
25
+ - Embeddable widgets — [docs.authio.com/widgets/overview](https://docs.authio.com/widgets/overview)
26
+ - Synchronous Actions — [docs.authio.com/actions/overview](https://docs.authio.com/actions/overview)
27
+ (Sinatra signature verification snippet on
28
+ [docs.authio.com/actions/signature-verification](https://docs.authio.com/actions/signature-verification))
29
+ - DCR / CIMD / MCP — [docs.authio.com/concepts/dynamic-client-registration](https://docs.authio.com/concepts/dynamic-client-registration)
30
+ - Roles + permissions — [docs.authio.com/concepts/roles-and-permissions](https://docs.authio.com/concepts/roles-and-permissions)
31
+
32
+ ## Install
33
+
34
+ ```ruby
35
+ # Gemfile
36
+ gem "authio", "~> 0.2"
37
+ ```
38
+
39
+ ```bash
40
+ bundle install
41
+ ```
42
+
43
+ ## Configure
44
+
45
+ ```ruby
46
+ # config/initializers/authio.rb
47
+ Authio.configure do |config|
48
+ config.api_key = ENV.fetch("AUTHIO_SECRET_KEY", "")
49
+ # Management API (orgs, users, webhooks). JWT verify uses identity.authio.com.
50
+ config.api_url = ENV.fetch("AUTHIO_API_URL", "https://api.authio.com")
51
+ config.publishable_key = ENV.fetch("AUTHIO_PUBLISHABLE_KEY", "")
52
+ # JWKS + issuer for session JWT verify (EdDSA). Defaults to api_url if unset.
53
+ config.issuer = ENV.fetch("AUTHIO_ISSUER", "https://identity.authio.com")
54
+ end
55
+ ```
56
+
57
+ ## Use in a Rails controller
58
+
59
+ ```ruby
60
+ class ApplicationController < ActionController::Base
61
+ private
62
+
63
+ def authenticate_authio!
64
+ token = cookies[:authio_session]
65
+ @authio_session = token.present? ? Authio::Client.default.verify_token(token) : nil
66
+ unless @authio_session
67
+ redirect_to "/auth/sign-in?redirect_url=#{CGI.escape(request.fullpath)}"
68
+ end
69
+ end
70
+ helper_method :authio_session
71
+ attr_reader :authio_session
72
+ end
73
+
74
+ class DashboardController < ApplicationController
75
+ before_action :authenticate_authio!
76
+
77
+ def index
78
+ # @authio_session.user_id, @authio_session.org_id, @authio_session.role
79
+ end
80
+ end
81
+ ```
82
+
83
+ ## API
84
+
85
+ | Method | Description |
86
+ | ------------------------------------------------- | ------------------------------------------------- |
87
+ | `Authio.configure { |c| ... }` | Set api_key / api_url / publishable_key. |
88
+ | `Authio::Client.default` | Shared singleton. |
89
+ | `client.verify_token(token)` | Verify JWT vs cached JWKS. Returns `Session` or `nil`. |
90
+ | `client.sign_in_url(redirect_url: "...")` | Build the hosted-UI redirect URL. |
91
+ | `client.start_magic_link(email:, redirect_url:)` | POST `/v1/auth/magic-link/start`. Useful for tests. |
92
+ | `Authio::Passkeys.list_passkeys(...)` | List passkeys for the signed-in user. |
93
+ | `Authio::Passkeys.rename_passkey(...)` | Rename a passkey nickname. |
94
+ | `Authio::Passkeys.revoke_passkey(...)` | Revoke a passkey. |
95
+ | `Authio::Passkeys.enroll_passkey_url(...)` | Mint register-intent + build hosted-UI enroll URL. |
96
+
97
+ `Authio::Session` exposes `session_id`, `user_id`, `org_id`, `role`,
98
+ `expires_at`, `claims`, `impersonation?`, `impersonator_email`.
99
+
100
+ ## Passkeys after signup
101
+
102
+ The Ruby gem does not run WebAuthn ceremonies server-side. After a user
103
+ signs up and you have their session access token, redirect them to the
104
+ hosted sign-in UI to add a passkey:
105
+
106
+ ```ruby
107
+ require "authio"
108
+
109
+ token = cookies[:authio_session]
110
+ api_url = ENV.fetch("AUTHIO_API_URL", "https://api.authio.com")
111
+ project_id = ENV.fetch("AUTHIO_PROJECT_ID")
112
+ sign_in_url = ENV.fetch("AUTHIO_SIGN_IN_URL") # hosted UI base, e.g. https://auth.acme.com
113
+
114
+ # List / rename / revoke (server-side)
115
+ devices = Authio::Passkeys.list_passkeys(
116
+ access_token: token,
117
+ project_id: project_id,
118
+ api_url: api_url,
119
+ )
120
+ Authio::Passkeys.rename_passkey(
121
+ access_token: token,
122
+ project_id: project_id,
123
+ api_url: api_url,
124
+ credential_id: devices.first.id,
125
+ name: "Work Mac",
126
+ )
127
+
128
+ # Enroll — redirect the browser (primary path for Rails apps)
129
+ enroll_url = Authio::Passkeys.enroll_passkey_url(
130
+ access_token: token,
131
+ project_id: project_id,
132
+ api_url: api_url,
133
+ sign_in_url: sign_in_url,
134
+ email: current_user.email,
135
+ return_url: request.original_url,
136
+ next: "/account/security",
137
+ )
138
+ redirect_to enroll_url, allow_other_host: true
139
+ ```
140
+
141
+ Lower-level helpers: `mint_passkey_register_intent` and
142
+ `build_enroll_passkey_url` when you want to split intent minting from URL
143
+ construction.
144
+
145
+ ## Requirements
146
+
147
+ - Ruby 3.0+
148
+ - `jwt` ~> 2.7 (installed automatically)
149
+
150
+ ## License
151
+
152
+ MIT
data/authio.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/authio/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "authio"
7
+ spec.version = Authio::VERSION
8
+ spec.authors = ["Authio"]
9
+ spec.email = ["dev@authio.com"]
10
+
11
+ spec.summary = "Authio Ruby SDK — passwordless, multi-org auth for B2B Rails apps."
12
+ spec.description = "Verify Authio session JWTs against the JWKS, mint magic-link sign-in URLs, " \
13
+ "and integrate with Rails controllers via `before_action :authenticate_authio!`."
14
+ spec.homepage = "https://github.com/Authio-com/authio_ruby"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.0.0"
17
+
18
+ spec.metadata = {
19
+ "homepage_uri" => spec.homepage,
20
+ "source_code_uri" => spec.homepage,
21
+ "rubygems_mfa_required" => "true",
22
+ }
23
+
24
+ spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "authio.gemspec"]
25
+ spec.require_paths = ["lib"]
26
+
27
+ # >= 2.10.3 excludes the empty-key HMAC bypass (GHSA / CVE-2026-44351
28
+ # sibling); staying under 3.0 keeps the 2.x API for consumers.
29
+ spec.add_dependency "jwt", "~> 2.10", ">= 2.10.3"
30
+ spec.add_dependency "jwt-eddsa", "~> 0.9"
31
+
32
+ spec.add_development_dependency "rspec", "~> 3.12"
33
+ spec.add_development_dependency "webmock", "~> 3.18"
34
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "net/http"
6
+ require "time"
7
+ require "uri"
8
+
9
+ module Authio
10
+ class Client
11
+ RESERVED_CLAIMS = %w[
12
+ iss sub aud exp iat jti nbf scope scopes sid act_org act_role
13
+ client_id token_type project_id is_impersonation
14
+ impersonator_user_id impersonator_email imp_grant_id
15
+ ].freeze
16
+
17
+ def self.default
18
+ @default ||= new(Authio.configuration)
19
+ end
20
+
21
+ def self.reset_default!
22
+ @default = nil
23
+ end
24
+
25
+ attr_reader :configuration
26
+
27
+ def initialize(configuration = Authio.configuration)
28
+ @configuration = configuration
29
+ @verifier = JwksVerifier.new(
30
+ api_url: configuration.effective_api_url,
31
+ issuer: configuration.effective_issuer,
32
+ audience: configuration.audience,
33
+ project_id: configuration.project_id,
34
+ )
35
+ end
36
+
37
+ # Verify an Authio access token. Returns a {Session} or nil when the
38
+ # token is missing / expired / cryptographically invalid.
39
+ def verify_token(token)
40
+ return nil if token.nil? || token.empty?
41
+
42
+ begin
43
+ claims = @verifier.verify(token)
44
+ rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature, StandardError
45
+ return nil
46
+ end
47
+
48
+ merged = claims.reject { |k, _| RESERVED_CLAIMS.include?(k) }
49
+ Session.new(
50
+ session_id: claims["sid"].to_s,
51
+ user_id: claims["sub"].to_s,
52
+ org_id: (claims["act_org"] && !claims["act_org"].empty? ? claims["act_org"] : nil),
53
+ role: (claims["act_role"] && !claims["act_role"].empty? ? claims["act_role"] : nil),
54
+ expires_at: claims["exp"] ? Time.at(claims["exp"]).utc.iso8601 : Time.now.utc.iso8601,
55
+ claims: merged,
56
+ impersonation?: claims["is_impersonation"] == true,
57
+ impersonator_email: claims["impersonator_email"],
58
+ )
59
+ end
60
+
61
+ # Build the hosted sign-in URL the browser should send the user to.
62
+ # The hosted UI handles passkey / magic-link / OAuth and redirects
63
+ # back to `redirect_url` with `?access_token=…` on success.
64
+ def sign_in_url(redirect_url:)
65
+ base = configuration.effective_api_url
66
+ "#{base}/v1/auth/sign-in?publishable_key=#{CGI.escape(configuration.publishable_key)}&redirect_url=#{CGI.escape(redirect_url)}"
67
+ end
68
+
69
+ # Server-side magic-link starter — useful for testing.
70
+ def start_magic_link(email:, redirect_url:)
71
+ uri = URI("#{configuration.effective_api_url}/v1/auth/magic-link/start")
72
+ req = Net::HTTP::Post.new(uri, {
73
+ "content-type" => "application/json",
74
+ "x-publishable-key" => configuration.publishable_key,
75
+ })
76
+ req.body = JSON.generate({ email: email, redirect_url: redirect_url })
77
+ http = Net::HTTP.new(uri.host, uri.port)
78
+ http.use_ssl = uri.scheme == "https"
79
+ res = http.request(req)
80
+ raise Authio::Error.new(code: "magic_link_failed", message: res.body.to_s, status: res.code.to_i) unless res.is_a?(Net::HTTPSuccess)
81
+
82
+ JSON.parse(res.body) rescue {}
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authio
4
+ class Configuration
5
+ attr_accessor :api_key, :api_url, :publishable_key, :issuer, :audience,
6
+ :project_id
7
+
8
+ def initialize
9
+ @api_key = ""
10
+ @api_url = "https://api.authio.com"
11
+ @publishable_key = ""
12
+ @issuer = nil
13
+ @audience = "authio"
14
+ # Tenant binding. auth-core signs every tenant's tokens with one
15
+ # platform key under one fixed issuer/audience, so project_id is the
16
+ # only claim saying a token was minted for you rather than in
17
+ # someone else's Authio project — and sign-up is self-serve, so a
18
+ # valid foreign token is free to obtain. Defaults from the env var
19
+ # the docs already ask for.
20
+ @project_id = ENV["AUTHIO_PROJECT_ID"]&.strip
21
+ @project_id = nil if @project_id == ""
22
+ end
23
+
24
+ def effective_issuer
25
+ @issuer || JwksVerifier.strip_trailing_slashes(@api_url)
26
+ end
27
+
28
+ def effective_api_url
29
+ JwksVerifier.strip_trailing_slashes(@api_url)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authio
4
+ class Error < StandardError
5
+ attr_reader :code, :status
6
+
7
+ def initialize(code:, message:, status: 500)
8
+ super(message)
9
+ @code = code
10
+ @status = status
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jwt"
5
+ require "jwt/eddsa"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ module Authio
10
+ # Verifies Authio access JWTs against the cached JWKS.
11
+ #
12
+ # Authio's auth-core signs JWTs with EdDSA (Ed25519). JWT.decode is
13
+ # routed through the JWKS keyset; the keyset is fetched lazily and
14
+ # cached for ~10 minutes.
15
+ class JwksVerifier
16
+ CACHE_TTL = 600
17
+ COOLDOWN = 30
18
+
19
+ def initialize(api_url:, issuer:, audience:, project_id: nil, http: nil)
20
+ @api_url = self.class.strip_trailing_slashes(api_url)
21
+ @issuer = issuer
22
+ @audience = audience
23
+ @project_id = project_id
24
+ @http = http
25
+ @keys = nil
26
+ @fetched_at = 0
27
+ end
28
+
29
+
30
+ # Strip trailing slashes without a regex.
31
+ #
32
+ # `sub(%r{/+\z}, "")` backtracks polynomially on a string of many
33
+ # slashes (CodeQL rb/polynomial-redos). api_url is operator-configured
34
+ # rather than attacker-supplied, so the exposure is small, but a
35
+ # linear scan is just as short and cannot degrade.
36
+ def self.strip_trailing_slashes(value)
37
+ s = value.to_s
38
+ s = s[0..-2] while s.end_with?("/")
39
+ s
40
+ end
41
+
42
+ # @return [Hash] decoded JWT claims, e.g. { "sub" => "user_...", ... }
43
+ def verify(token)
44
+ keys = fetch_keys
45
+ payload, = JWT.decode(
46
+ token,
47
+ nil,
48
+ true,
49
+ algorithms: ["EdDSA"],
50
+ iss: @issuer,
51
+ aud: @audience,
52
+ verify_iss: true,
53
+ verify_aud: true,
54
+ jwks: { keys: keys },
55
+ )
56
+ raise "authio: token missing sub" if payload["sub"].nil? || payload["sub"].empty?
57
+
58
+ assert_tenant(payload)
59
+ payload
60
+ end
61
+
62
+ private
63
+
64
+ # Tenant binding (security audit 2026-09-18).
65
+ #
66
+ # Signature, issuer and audience prove a token came from Authio. They
67
+ # do NOT prove it was minted for THIS customer: auth-core signs every
68
+ # tenant with one platform key under one fixed issuer/audience, so
69
+ # project_id is the only claim that tells two tenants apart. Sign-up
70
+ # is self-serve, so anyone can create ceo@your-company.com in their
71
+ # own project and present the resulting token here.
72
+ #
73
+ # Unset project_id keeps the previous behaviour and warns once, so
74
+ # upgrading the gem cannot sign anyone out on its own.
75
+ def assert_tenant(payload)
76
+ if @project_id.nil? || @project_id.empty?
77
+ warn_once(
78
+ :no_project,
79
+ "authio: no project_id configured, so tokens are not checked against " \
80
+ "your tenant. Any Authio-issued token will verify here, including one " \
81
+ "minted in someone else's project. Set AUTHIO_PROJECT_ID or " \
82
+ "Authio.configuration.project_id.",
83
+ )
84
+ return
85
+ end
86
+
87
+ claimed = payload["project_id"]
88
+ return if claimed == @project_id
89
+
90
+ raise "authio: token was issued for project #{claimed.inspect}, not #{@project_id.inspect}"
91
+ end
92
+
93
+ def warn_once(key, message)
94
+ @warned ||= {}
95
+ return if @warned[key]
96
+
97
+ @warned[key] = true
98
+ warn(message)
99
+ end
100
+
101
+ def fetch_keys
102
+ now = Time.now.to_i
103
+ return @keys if @keys && now - @fetched_at < CACHE_TTL
104
+ return @keys if @keys && now - @fetched_at < COOLDOWN
105
+
106
+ uri = URI("#{@api_url}/v1/auth/.well-known/jwks.json")
107
+ body = @http ? @http.get(uri.to_s) : Net::HTTP.get(uri)
108
+ data = JSON.parse(body)
109
+ raise "authio: invalid JWKS at #{uri}" unless data["keys"].is_a?(Array)
110
+
111
+ @keys = data["keys"]
112
+ @fetched_at = now
113
+ @keys
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Authio
9
+ # Passkey management against auth-core `/v1/me/passkeys`.
10
+ #
11
+ # WebAuthn ceremonies are not available server-side — use
12
+ # {#enroll_passkey_url} to redirect the browser to the hosted sign-in UI
13
+ # (`mode=add_credential`).
14
+ module Passkeys
15
+ Passkey = Struct.new(
16
+ :id,
17
+ :nickname,
18
+ :aaguid,
19
+ :authenticator_name,
20
+ :transports,
21
+ :sign_count,
22
+ :last_used_at,
23
+ :created_at,
24
+ keyword_init: true,
25
+ ) do
26
+ def self.from_hash(row)
27
+ new(
28
+ id: row["id"],
29
+ nickname: row["nickname"],
30
+ aaguid: row["aaguid"],
31
+ authenticator_name: row["authenticator_name"],
32
+ transports: row["transports"] || [],
33
+ sign_count: row["sign_count"],
34
+ last_used_at: row["last_used_at"],
35
+ created_at: row["created_at"],
36
+ )
37
+ end
38
+ end
39
+
40
+ RegisterIntent = Struct.new(:token, :expires_in, keyword_init: true)
41
+
42
+ module_function
43
+
44
+ # List passkeys for the signed-in user (`GET /v1/me/passkeys`).
45
+ def list_passkeys(access_token:, project_id:, api_url:)
46
+ res = api_request(
47
+ access_token: access_token,
48
+ project_id: project_id,
49
+ api_url: api_url,
50
+ method: "GET",
51
+ path: "/v1/me/passkeys",
52
+ )
53
+ (res["data"] || []).map { |row| Passkey.from_hash(row) }
54
+ end
55
+
56
+ # Rename a passkey (`PATCH /v1/me/passkeys/{credential_id}`).
57
+ def rename_passkey(access_token:, project_id:, api_url:, credential_id:, name:)
58
+ api_request(
59
+ access_token: access_token,
60
+ project_id: project_id,
61
+ api_url: api_url,
62
+ method: "PATCH",
63
+ path: "/v1/me/passkeys/#{URI.encode_www_form_component(credential_id.to_s)}",
64
+ body: { nickname: name },
65
+ )
66
+ nil
67
+ end
68
+
69
+ # Revoke a passkey (`DELETE /v1/me/passkeys/{credential_id}`).
70
+ def revoke_passkey(access_token:, project_id:, api_url:, credential_id:)
71
+ api_request(
72
+ access_token: access_token,
73
+ project_id: project_id,
74
+ api_url: api_url,
75
+ method: "DELETE",
76
+ path: "/v1/me/passkeys/#{URI.encode_www_form_component(credential_id.to_s)}",
77
+ )
78
+ nil
79
+ end
80
+
81
+ # Mint a short-lived JWT for add-credential WebAuthn (`POST register-intent`).
82
+ def mint_passkey_register_intent(access_token:, project_id:, api_url:)
83
+ body = api_request(
84
+ access_token: access_token,
85
+ project_id: project_id,
86
+ api_url: api_url,
87
+ method: "POST",
88
+ path: "/v1/me/passkeys/register-intent",
89
+ body: {},
90
+ )
91
+ RegisterIntent.new(token: body["token"], expires_in: body["expires_in"])
92
+ end
93
+
94
+ # Build the hosted-UI URL for `mode=add_credential` passkey enrollment.
95
+ def build_enroll_passkey_url(sign_in_url:, project_id:, email:, register_token:, return_url:, next: nil)
96
+ next_path = binding.local_variable_get(:next)
97
+ base = JwksVerifier.strip_trailing_slashes(sign_in_url)
98
+ params = [
99
+ ["mode", "add_credential"],
100
+ ["project_id", project_id],
101
+ ["email", email],
102
+ ["token", register_token],
103
+ ["redirect_uri", return_url],
104
+ ]
105
+ params << ["next", next_path] unless next_path.nil?
106
+ query = params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v.to_s)}" }.join("&")
107
+ "#{base}?#{query}"
108
+ end
109
+
110
+ # Mint a register-intent token and return the hosted-UI enrollment URL.
111
+ #
112
+ # Redirect the browser to this URL — Ruby cannot run WebAuthn ceremonies.
113
+ def enroll_passkey_url(access_token:, project_id:, api_url:, sign_in_url:, email:, return_url:, next: nil)
114
+ next_path = binding.local_variable_get(:next)
115
+ intent = mint_passkey_register_intent(
116
+ access_token: access_token,
117
+ project_id: project_id,
118
+ api_url: api_url,
119
+ )
120
+ build_enroll_passkey_url(
121
+ sign_in_url: sign_in_url,
122
+ project_id: project_id,
123
+ email: email,
124
+ register_token: intent.token,
125
+ return_url: return_url,
126
+ next: next_path,
127
+ )
128
+ end
129
+
130
+ def api_request(access_token:, project_id:, api_url:, method:, path:, body: nil)
131
+ base = JwksVerifier.strip_trailing_slashes(api_url)
132
+ uri = URI("#{base}#{path}")
133
+ req_class = Net::HTTP.const_get(method.capitalize)
134
+ req = req_class.new(uri)
135
+ req["accept"] = "application/json"
136
+ req["content-type"] = "application/json"
137
+ req["authorization"] = "Bearer #{access_token}"
138
+ req["x-authio-project"] = project_id
139
+ req["x-authio-sdk"] = "authio-ruby/#{Authio::VERSION}"
140
+ req.body = JSON.generate(body) if body
141
+
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = uri.scheme == "https"
144
+ res = http.request(req)
145
+
146
+ unless res.is_a?(Net::HTTPSuccess)
147
+ parsed = JSON.parse(res.body) rescue {}
148
+ raise Authio::Error.new(
149
+ code: parsed["code"] || parsed["error"] || "request_failed",
150
+ message: parsed["message"] || res.body.to_s,
151
+ status: res.code.to_i,
152
+ )
153
+ end
154
+
155
+ return {} if res.body.nil? || res.body.empty?
156
+
157
+ JSON.parse(res.body)
158
+ end
159
+ private_class_method :api_request
160
+ end
161
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authio
4
+ # A verified Authio session. `user_id` is always set; `org_id` may be
5
+ # nil when the user authenticated but has not yet selected one of
6
+ # their memberships.
7
+ Session = Struct.new(
8
+ :session_id,
9
+ :user_id,
10
+ :org_id,
11
+ :role,
12
+ :expires_at,
13
+ :claims,
14
+ :impersonation?,
15
+ :impersonator_email,
16
+ keyword_init: true,
17
+ ) do
18
+ def to_h
19
+ {
20
+ session_id: session_id,
21
+ user_id: user_id,
22
+ org_id: org_id,
23
+ role: role,
24
+ expires_at: expires_at,
25
+ claims: claims || {},
26
+ is_impersonation: impersonation? == true,
27
+ impersonator_email: impersonator_email,
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authio
4
+ VERSION = "0.3.0"
5
+ end
data/lib/authio.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authio/version"
4
+ require_relative "authio/configuration"
5
+ require_relative "authio/session"
6
+ require_relative "authio/jwks_verifier"
7
+ require_relative "authio/client"
8
+ require_relative "authio/passkeys"
9
+ require_relative "authio/errors"
10
+
11
+ # Authio — passwordless, multi-org auth for B2B Ruby / Rails apps.
12
+ #
13
+ # Authio.configure do |c|
14
+ # c.api_key = ENV["AUTHIO_SECRET_KEY"]
15
+ # c.api_url = "https://api.authio.com"
16
+ # c.publishable_key = ENV["AUTHIO_PUBLISHABLE_KEY"]
17
+ # end
18
+ #
19
+ # session = Authio::Client.default.verify_token(token)
20
+ # session&.user_id # => "user_..."
21
+ # session&.org_id # => "org_..." or nil for users who haven't picked an org yet
22
+ module Authio
23
+ def self.configure
24
+ yield configuration
25
+ end
26
+
27
+ def self.configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
31
+ def self.reset_configuration!
32
+ @configuration = Configuration.new
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: authio
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Authio
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jwt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.10'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.10.3
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '2.10'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.10.3
33
+ - !ruby/object:Gem::Dependency
34
+ name: jwt-eddsa
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.12'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.12'
61
+ - !ruby/object:Gem::Dependency
62
+ name: webmock
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.18'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.18'
75
+ description: Verify Authio session JWTs against the JWKS, mint magic-link sign-in
76
+ URLs, and integrate with Rails controllers via `before_action :authenticate_authio!`.
77
+ email:
78
+ - dev@authio.com
79
+ executables: []
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - LICENSE
84
+ - README.md
85
+ - authio.gemspec
86
+ - lib/authio.rb
87
+ - lib/authio/client.rb
88
+ - lib/authio/configuration.rb
89
+ - lib/authio/errors.rb
90
+ - lib/authio/jwks_verifier.rb
91
+ - lib/authio/passkeys.rb
92
+ - lib/authio/session.rb
93
+ - lib/authio/version.rb
94
+ homepage: https://github.com/Authio-com/authio_ruby
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/Authio-com/authio_ruby
99
+ source_code_uri: https://github.com/Authio-com/authio_ruby
100
+ rubygems_mfa_required: 'true'
101
+ post_install_message:
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 3.0.0
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.5.22
117
+ signing_key:
118
+ specification_version: 4
119
+ summary: Authio Ruby SDK — passwordless, multi-org auth for B2B Rails apps.
120
+ test_files: []