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 +7 -0
- data/LICENSE +21 -0
- data/README.md +152 -0
- data/authio.gemspec +34 -0
- data/lib/authio/client.rb +85 -0
- data/lib/authio/configuration.rb +32 -0
- data/lib/authio/errors.rb +13 -0
- data/lib/authio/jwks_verifier.rb +116 -0
- data/lib/authio/passkeys.rb +161 -0
- data/lib/authio/session.rb +31 -0
- data/lib/authio/version.rb +5 -0
- data/lib/authio.rb +34 -0
- metadata +120 -0
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,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
|
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: []
|