omniauth-authify 0.1.1
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/.release-please-manifest.json +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +94 -0
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/.yardopts +5 -0
- data/AGENTS.md +51 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +123 -0
- data/Rakefile +24 -0
- data/lib/omniauth/authify/errors.rb +12 -0
- data/lib/omniauth/authify/jwt_validator.rb +237 -0
- data/lib/omniauth/authify/version.rb +7 -0
- data/lib/omniauth/authify.rb +18 -0
- data/lib/omniauth/strategies/authify.rb +287 -0
- data/lib/omniauth-authify.rb +3 -0
- data/release-please-config.json +15 -0
- data/sig/omniauth/authify.rbs +86 -0
- data/sig/omniauth/strategies/authify.rbs +78 -0
- data/sig/shims.rbs +67 -0
- metadata +262 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "jwt"
|
|
8
|
+
require "omniauth/authify/errors"
|
|
9
|
+
|
|
10
|
+
module OmniAuth
|
|
11
|
+
module Authify
|
|
12
|
+
# Validates OpenID Connect ID tokens issued by Authify.
|
|
13
|
+
#
|
|
14
|
+
# Verifies the RS256 signature against the signing keys published at the
|
|
15
|
+
# organization's JWKS endpoint, and the standard ID token claims:
|
|
16
|
+
# iss, sub, aud, exp, iat, auth_time (when max_age applies), and nonce.
|
|
17
|
+
class JwtValidator
|
|
18
|
+
# ID token signature algorithms this validator accepts
|
|
19
|
+
SUPPORTED_ALGORITHMS = %w[RS256].freeze
|
|
20
|
+
# Claims that must be present in every ID token
|
|
21
|
+
REQUIRED_CLAIMS = %w[iss sub aud exp iat].freeze
|
|
22
|
+
# Network failures that map to a {TokenValidationError} during JWKS fetches
|
|
23
|
+
NETWORK_ERRORS = [SocketError, Errno::ECONNREFUSED, Timeout::Error, Net::OpenTimeout,
|
|
24
|
+
OpenSSL::SSL::SSLError].freeze
|
|
25
|
+
|
|
26
|
+
# Creates a validator
|
|
27
|
+
#
|
|
28
|
+
# @param client_id [String] the OAuth2 client ID (expected `aud` value)
|
|
29
|
+
# @param issuer [String] the expected value of the ID token `iss` claim
|
|
30
|
+
# @param jwks_uri [String] the organization's JWKS endpoint URL
|
|
31
|
+
def initialize(client_id:, issuer:, jwks_uri:)
|
|
32
|
+
@client_id = client_id
|
|
33
|
+
@issuer = issuer
|
|
34
|
+
@jwks_uri = URI(jwks_uri)
|
|
35
|
+
@jwks = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Decodes an ID token and verifies its signature and claims.
|
|
39
|
+
#
|
|
40
|
+
# @param jwt [String] the ID token
|
|
41
|
+
# @param authorize_params [Hash] per-login parameters stored at the
|
|
42
|
+
# start of the flow; may contain +nonce+, +leeway+, +max_age+ and
|
|
43
|
+
# +issuer+ (string or symbol keys)
|
|
44
|
+
# @return [Hash] the verified claims
|
|
45
|
+
# @raise [TokenValidationError] when the token cannot be verified
|
|
46
|
+
def verify(jwt, authorize_params = {})
|
|
47
|
+
raise TokenValidationError, "ID token is required but missing" if jwt.to_s.empty?
|
|
48
|
+
|
|
49
|
+
params = authorize_params || {}
|
|
50
|
+
claims, = decode(jwt)
|
|
51
|
+
verify_iss(claims, params)
|
|
52
|
+
verify_sub(claims)
|
|
53
|
+
verify_aud(claims)
|
|
54
|
+
verify_expiration(claims, params)
|
|
55
|
+
verify_iat(claims, params)
|
|
56
|
+
verify_auth_time(claims, params)
|
|
57
|
+
verify_nonce(claims, params)
|
|
58
|
+
claims
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Decodes an ID token, verifying its signature but skipping claim checks.
|
|
62
|
+
#
|
|
63
|
+
# @param jwt [String] the ID token
|
|
64
|
+
# @return [Array(Hash, Hash)] the claims and the JOSE header
|
|
65
|
+
# @raise [TokenValidationError] when the signature cannot be verified
|
|
66
|
+
def decode(jwt)
|
|
67
|
+
header = token_head(jwt)
|
|
68
|
+
algorithm = header["alg"]
|
|
69
|
+
unless SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
70
|
+
raise TokenValidationError,
|
|
71
|
+
"Signature algorithm of #{algorithm.inspect} is not supported. " \
|
|
72
|
+
"Expected the ID token to be signed with RS256"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
claims, = JWT.decode(
|
|
76
|
+
jwt,
|
|
77
|
+
nil,
|
|
78
|
+
true,
|
|
79
|
+
jwks: jwks_loader,
|
|
80
|
+
algorithms: [algorithm],
|
|
81
|
+
required_claims: REQUIRED_CLAIMS,
|
|
82
|
+
verify_iss: false,
|
|
83
|
+
verify_aud: false,
|
|
84
|
+
verify_expiration: false,
|
|
85
|
+
verify_iat: false
|
|
86
|
+
)
|
|
87
|
+
[claims, header]
|
|
88
|
+
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::JWKError => e
|
|
89
|
+
raise TokenValidationError, "ID token could not be verified: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def verify_iss(claims, params)
|
|
95
|
+
expected = params[:issuer] || params["issuer"] || @issuer
|
|
96
|
+
return if claims["iss"] == expected
|
|
97
|
+
|
|
98
|
+
raise TokenValidationError,
|
|
99
|
+
"Issuer (iss) claim mismatch in the ID token, expected (#{expected}), " \
|
|
100
|
+
"found (#{claims["iss"].inspect})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def verify_sub(claims)
|
|
104
|
+
subject = claims["sub"]
|
|
105
|
+
return if subject.is_a?(String) && !subject.empty?
|
|
106
|
+
|
|
107
|
+
raise TokenValidationError,
|
|
108
|
+
"Subject (sub) claim must be a string present in the ID token"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verify_aud(claims)
|
|
112
|
+
audience = claims["aud"]
|
|
113
|
+
audiences = Array(audience)
|
|
114
|
+
if audience.to_s.empty?
|
|
115
|
+
raise TokenValidationError,
|
|
116
|
+
"Audience (aud) claim must be a string or array of strings present in the ID token"
|
|
117
|
+
end
|
|
118
|
+
return if audiences.include?(@client_id)
|
|
119
|
+
|
|
120
|
+
raise TokenValidationError,
|
|
121
|
+
"Audience (aud) claim mismatch in the ID token; expected (#{@client_id}), " \
|
|
122
|
+
"found (#{audiences.join(", ")})"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def verify_expiration(claims, params)
|
|
126
|
+
expiration = claims["exp"]
|
|
127
|
+
if !expiration.is_a?(Integer)
|
|
128
|
+
raise TokenValidationError,
|
|
129
|
+
"Expiration time (exp) claim must be a number present in the ID token"
|
|
130
|
+
elsif expiration <= Time.now.to_i - leeway(params)
|
|
131
|
+
raise TokenValidationError,
|
|
132
|
+
"Expiration time (exp) claim error in the ID token; " \
|
|
133
|
+
"current time (#{Time.now}) is after expiration time " \
|
|
134
|
+
"(#{Time.at(expiration + leeway(params))})"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def verify_iat(claims, params)
|
|
139
|
+
issued_at = claims["iat"]
|
|
140
|
+
if !issued_at.is_a?(Integer)
|
|
141
|
+
raise TokenValidationError,
|
|
142
|
+
"Issued At (iat) claim must be a number present in the ID token"
|
|
143
|
+
elsif issued_at > Time.now.to_i + leeway(params)
|
|
144
|
+
raise TokenValidationError,
|
|
145
|
+
"Issued At (iat) claim error in the ID token; issued in the future " \
|
|
146
|
+
"at #{Time.at(issued_at)} (current time #{Time.now})"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def verify_auth_time(claims, params)
|
|
151
|
+
max_age = params[:max_age] || params["max_age"]
|
|
152
|
+
return unless max_age
|
|
153
|
+
|
|
154
|
+
auth_time = claims["auth_time"]
|
|
155
|
+
if !auth_time.is_a?(Integer)
|
|
156
|
+
raise TokenValidationError,
|
|
157
|
+
"Authentication Time (auth_time) claim must be a number present in " \
|
|
158
|
+
"the ID token when Max Age (max_age) is specified"
|
|
159
|
+
elsif Time.now.to_i > auth_time + max_age.to_i + leeway(params)
|
|
160
|
+
raise TokenValidationError,
|
|
161
|
+
"Authentication Time (auth_time) claim in the ID token indicates that " \
|
|
162
|
+
"too much time has passed since the last end-user authentication"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def verify_nonce(claims, params)
|
|
167
|
+
nonce = params[:nonce] || params["nonce"]
|
|
168
|
+
return unless nonce
|
|
169
|
+
|
|
170
|
+
received = claims["nonce"]
|
|
171
|
+
if !received.is_a?(String) || received.empty?
|
|
172
|
+
raise TokenValidationError,
|
|
173
|
+
"Nonce (nonce) claim must be a string present in the ID token"
|
|
174
|
+
elsif received != nonce
|
|
175
|
+
raise TokenValidationError,
|
|
176
|
+
"Nonce (nonce) claim value mismatch in the ID token; " \
|
|
177
|
+
"expected (#{nonce}), found (#{received})"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def leeway(params)
|
|
182
|
+
(params[:leeway] || params["leeway"] || 60).to_i
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def token_head(jwt)
|
|
186
|
+
parts = jwt.to_s.split(".")
|
|
187
|
+
raise TokenValidationError, "ID token could not be decoded" if parts.length != 3
|
|
188
|
+
|
|
189
|
+
JSON.parse(Base64.urlsafe_decode64(parts[0]))
|
|
190
|
+
rescue ArgumentError, JSON::ParserError => e
|
|
191
|
+
raise TokenValidationError, "ID token could not be decoded: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Returns a proc usable as JWT's +jwks+ option. Fetches the JWKS from the
|
|
195
|
+
# organization's endpoint (once) and refetches when the verification
|
|
196
|
+
# machinery reports a `kid` missing from the cached key set (rotated
|
|
197
|
+
# signing keys).
|
|
198
|
+
def jwks_loader
|
|
199
|
+
lambda do |options|
|
|
200
|
+
options ||= {}
|
|
201
|
+
refetch = options[:invalidate] || options[:kid_not_found]
|
|
202
|
+
{ keys: fetch_keys(force: refetch) }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def fetch_keys(force: false)
|
|
207
|
+
@jwks = parse_jwks(fetch_jwks_response) if force || !@jwks
|
|
208
|
+
@jwks
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def fetch_jwks_response
|
|
212
|
+
Net::HTTP.start(@jwks_uri.host, @jwks_uri.port,
|
|
213
|
+
use_ssl: @jwks_uri.scheme == "https") do |http|
|
|
214
|
+
request = Net::HTTP::Get.new(@jwks_uri.request_uri)
|
|
215
|
+
request["accept"] = "application/json"
|
|
216
|
+
http.request(request)
|
|
217
|
+
end
|
|
218
|
+
rescue *NETWORK_ERRORS => e
|
|
219
|
+
raise TokenValidationError,
|
|
220
|
+
"Could not fetch the Authify JWKS from #{@jwks_uri}: #{e.message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_jwks(response)
|
|
224
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
225
|
+
raise TokenValidationError,
|
|
226
|
+
"Could not fetch the Authify JWKS from #{@jwks_uri}: HTTP #{response.code}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
parsed = JSON.parse(response.body, symbolize_names: true)
|
|
230
|
+
parsed[:keys] || []
|
|
231
|
+
rescue JSON::ParserError => e
|
|
232
|
+
raise TokenValidationError,
|
|
233
|
+
"Could not parse the Authify JWKS from #{@jwks_uri}: #{e.message}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Root namespace for the OmniAuth framework. Third-party strategies such as
|
|
4
|
+
# this gem attach under {OmniAuth::Strategies} and may carry helper classes
|
|
5
|
+
# in their own namespace (here: {OmniAuth::Authify}).
|
|
6
|
+
module OmniAuth
|
|
7
|
+
# OmniAuth strategy support for [Authify](https://github.com/authify/authify),
|
|
8
|
+
# a self-hosted, multi-tenant identity provider implementing OpenID Connect.
|
|
9
|
+
#
|
|
10
|
+
# See {OmniAuth::Strategies::Authify} for the strategy itself.
|
|
11
|
+
module Authify
|
|
12
|
+
autoload :ConfigurationError, "omniauth/authify/errors"
|
|
13
|
+
autoload :TokenValidationError, "omniauth/authify/errors"
|
|
14
|
+
autoload :JwtValidator, "omniauth/authify/jwt_validator"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require "omniauth/strategies/authify"
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "omniauth-oauth2"
|
|
5
|
+
require "omniauth/authify/errors"
|
|
6
|
+
require "omniauth/authify/jwt_validator"
|
|
7
|
+
|
|
8
|
+
module OmniAuth
|
|
9
|
+
# Namespace for all OmniAuth strategies (core and third-party)
|
|
10
|
+
module Strategies
|
|
11
|
+
# OmniAuth strategy for Authify, a self-hosted, multi-tenant identity
|
|
12
|
+
# provider implementing OpenID Connect on top of OAuth 2.0.
|
|
13
|
+
#
|
|
14
|
+
# Since Authify is multi-tenant, both the server base URL and the
|
|
15
|
+
# organization slug are required; all endpoints (authorize, token,
|
|
16
|
+
# userinfo and JWKS) are scoped to the organization.
|
|
17
|
+
#
|
|
18
|
+
# @example Rails/Devise usage
|
|
19
|
+
# provider :authify,
|
|
20
|
+
# ENV["AUTHIFY_CLIENT_ID"],
|
|
21
|
+
# ENV["AUTHIFY_CLIENT_SECRET"],
|
|
22
|
+
# site: "https://authify.example.com",
|
|
23
|
+
# organization: "my-org"
|
|
24
|
+
#
|
|
25
|
+
# @example Sinatra usage
|
|
26
|
+
# use OmniAuth::Builder do
|
|
27
|
+
# provider :authify, ENV["AUTHIFY_CLIENT_ID"], ENV["AUTHIFY_CLIENT_SECRET"],
|
|
28
|
+
# site: "https://authify.example.com", organization: "my-org"
|
|
29
|
+
# end
|
|
30
|
+
class Authify < OmniAuth::Strategies::OAuth2
|
|
31
|
+
# Scopes requested when the user does not configure any
|
|
32
|
+
DEFAULT_SCOPE = "openid profile email"
|
|
33
|
+
|
|
34
|
+
option :name, "authify"
|
|
35
|
+
|
|
36
|
+
args %i[client_id client_secret site organization]
|
|
37
|
+
|
|
38
|
+
option :client_id, nil
|
|
39
|
+
option :client_secret, nil
|
|
40
|
+
option :site, nil
|
|
41
|
+
option :organization, nil
|
|
42
|
+
|
|
43
|
+
# Set to +false+ to skip ID token signature/claim verification and rely
|
|
44
|
+
# solely on the userinfo endpoint (not recommended).
|
|
45
|
+
option :verify_id_token, true
|
|
46
|
+
|
|
47
|
+
# Leeway (in seconds) allowed when validating time-based claims.
|
|
48
|
+
option :leeway, 60
|
|
49
|
+
|
|
50
|
+
# Set to +false+ to disable PKCE (S256). PKCE is enabled by default.
|
|
51
|
+
option :pkce, true
|
|
52
|
+
|
|
53
|
+
# Scopes to request from Authify; "openid" is required for an ID token.
|
|
54
|
+
option :scope, DEFAULT_SCOPE
|
|
55
|
+
|
|
56
|
+
option :client_options, {
|
|
57
|
+
site: nil,
|
|
58
|
+
authorize_url: nil,
|
|
59
|
+
token_url: nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Configure the underlying OAuth2 client URLs for the organization.
|
|
63
|
+
def client
|
|
64
|
+
validate_configuration!
|
|
65
|
+
|
|
66
|
+
base = org_base
|
|
67
|
+
options.client_options.site = base
|
|
68
|
+
options.client_options.authorize_url = "#{base}/oauth/authorize"
|
|
69
|
+
options.client_options.token_url = "#{base}/oauth/token"
|
|
70
|
+
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
uid { raw_info["sub"] }
|
|
75
|
+
|
|
76
|
+
info do
|
|
77
|
+
{
|
|
78
|
+
name: raw_info["name"],
|
|
79
|
+
email: raw_info["email"],
|
|
80
|
+
image: raw_info["picture"],
|
|
81
|
+
nickname: raw_info["preferred_username"],
|
|
82
|
+
first_name: raw_info["given_name"],
|
|
83
|
+
last_name: raw_info["family_name"],
|
|
84
|
+
location: raw_info["zoneinfo"],
|
|
85
|
+
phone: raw_info["phone_number"],
|
|
86
|
+
urls: raw_info["website"] ? { website: raw_info["website"] } : {}
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
credentials do
|
|
91
|
+
creds = {
|
|
92
|
+
"token" => access_token.token,
|
|
93
|
+
"expires" => access_token.expires?
|
|
94
|
+
}
|
|
95
|
+
creds["expires_at"] = access_token.expires_at if access_token.expires?
|
|
96
|
+
creds["refresh_token"] = access_token.refresh_token if access_token.refresh_token
|
|
97
|
+
creds["id_token"] = id_token if id_token
|
|
98
|
+
|
|
99
|
+
# Full ID token verification: signature (via JWKS), issuer, audience,
|
|
100
|
+
# times, and the per-login nonce. Raises TokenValidationError on
|
|
101
|
+
# failure, surfacing as an OmniAuth failure via callback_phase.
|
|
102
|
+
if options.verify_id_token
|
|
103
|
+
if id_token.nil?
|
|
104
|
+
raise ::OmniAuth::Authify::TokenValidationError,
|
|
105
|
+
"ID token is required but missing"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@verified_claims = jwt_validator.verify(id_token, stored_authorize_params)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
creds
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
extra do
|
|
115
|
+
extras = { raw_info: raw_info }
|
|
116
|
+
extras[:id_info] = @verified_claims if @verified_claims
|
|
117
|
+
extras
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Builds authorize parameters; generates and stores a nonce for this
|
|
121
|
+
# login so the returned ID token can be bound to the request.
|
|
122
|
+
#
|
|
123
|
+
# An OIDC +prompt+ parameter on the request phase (e.g. "consent",
|
|
124
|
+
# "login", "none") is forwarded to Authify.
|
|
125
|
+
def authorize_params
|
|
126
|
+
params = super
|
|
127
|
+
params[:nonce] = SecureRandom.hex(16)
|
|
128
|
+
params[:leeway] = options.leeway if options.leeway
|
|
129
|
+
params[:prompt] = request.params["prompt"] if request.params.key?("prompt")
|
|
130
|
+
|
|
131
|
+
store_authorize_params(params)
|
|
132
|
+
|
|
133
|
+
params
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Initiates the authorization redirect after validating configuration.
|
|
137
|
+
#
|
|
138
|
+
# @return [Array] a Rack redirect response to Authify's authorize
|
|
139
|
+
# endpoint, or an OmniAuth failure when misconfigured
|
|
140
|
+
def request_phase
|
|
141
|
+
if missing_configuration?
|
|
142
|
+
fail!(:missing_configuration, CallbackError.new(
|
|
143
|
+
:missing_configuration,
|
|
144
|
+
"The :site and :organization options are required"
|
|
145
|
+
))
|
|
146
|
+
else
|
|
147
|
+
super
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Completes the login: exchanges the code for tokens (via the
|
|
152
|
+
# OmniAuth::Strategies::OAuth2 machinery), verifies the ID token while
|
|
153
|
+
# building the credentials hash, and translates any validation failure
|
|
154
|
+
# into an OmniAuth `invalid_credentials` failure.
|
|
155
|
+
#
|
|
156
|
+
# The OAuth2 state check is performed here first because the parent
|
|
157
|
+
# class's `secure_compare` raises NoMethodError (on nil) rather than
|
|
158
|
+
# failing cleanly when the callback carries a state param but no state
|
|
159
|
+
# exists in the session (stale or replayed callbacks). The parent's own
|
|
160
|
+
# check is skipped for this invocation because ours is equivalent (the
|
|
161
|
+
# same constant-time comparison) minus the crash.
|
|
162
|
+
def callback_phase
|
|
163
|
+
return state_failure unless callback_state_matches?
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
base_ignores_state = options.provider_ignores_state
|
|
167
|
+
options.provider_ignores_state = true
|
|
168
|
+
super
|
|
169
|
+
ensure
|
|
170
|
+
options.provider_ignores_state = base_ignores_state
|
|
171
|
+
end
|
|
172
|
+
rescue ::OmniAuth::Authify::TokenValidationError, ::OAuth2::Error, CallbackError => e
|
|
173
|
+
fail!(:invalid_credentials, e)
|
|
174
|
+
rescue Timeout::Error, Errno::ETIMEDOUT, ::OAuth2::TimeoutError,
|
|
175
|
+
::OAuth2::ConnectionError => e
|
|
176
|
+
fail!(:timeout, e)
|
|
177
|
+
rescue SocketError => e
|
|
178
|
+
fail!(:failed_to_connect, e)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
# Constant-time comparison of the callback's state parameter against
|
|
184
|
+
# the state stored in the session at the start of the flow. Returns
|
|
185
|
+
# +true+ for blank values when +provider_ignores_state+ is set.
|
|
186
|
+
def callback_state_matches?
|
|
187
|
+
return true if options.provider_ignores_state
|
|
188
|
+
|
|
189
|
+
callback_state = request.params["state"].to_s
|
|
190
|
+
session_state = session.delete("omniauth.state").to_s
|
|
191
|
+
return false if callback_state.empty? || session_state.empty?
|
|
192
|
+
|
|
193
|
+
constant_time_equal?(callback_state, session_state)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Constant-time comparison of two strings; returns false for blank or
|
|
197
|
+
# differently-sized inputs.
|
|
198
|
+
def constant_time_equal?(string_a, string_b)
|
|
199
|
+
return false unless string_a.bytesize == string_b.bytesize
|
|
200
|
+
|
|
201
|
+
l = string_a.unpack("C#{string_a.bytesize}")
|
|
202
|
+
|
|
203
|
+
res = 0
|
|
204
|
+
string_b.each_byte { |byte| res |= byte ^ l.shift }
|
|
205
|
+
res.zero?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def state_failure
|
|
209
|
+
fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Persists per-login parameters (nonce, leeway) in the session so they
|
|
213
|
+
# can be checked against the ID token at the callback.
|
|
214
|
+
def store_authorize_params(params)
|
|
215
|
+
stored = { nonce: params[:nonce] }
|
|
216
|
+
stored[:leeway] = params[:leeway] if params[:leeway]
|
|
217
|
+
stored[:max_age] = params[:max_age] if params[:max_age]
|
|
218
|
+
session["omniauth.authify.authorize_params"] = stored
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def stored_authorize_params
|
|
222
|
+
@stored_authorize_params ||=
|
|
223
|
+
(session.delete("omniauth.authify.authorize_params") || {}).transform_keys(&:to_sym)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def missing_configuration?
|
|
227
|
+
[options.site, options.organization].any? { |value| value.nil? || value.to_s.strip.empty? }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def id_token
|
|
231
|
+
return @id_token if defined?(@id_token)
|
|
232
|
+
|
|
233
|
+
@id_token = normalized_id_token
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def jwt_validator
|
|
237
|
+
@jwt_validator ||= ::OmniAuth::Authify::JwtValidator.new(
|
|
238
|
+
client_id: options.client_id,
|
|
239
|
+
issuer: normalized_base,
|
|
240
|
+
jwks_uri: "#{normalized_base}/.well-known/jwks"
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def org_base
|
|
245
|
+
"#{options.site.to_s.strip.sub(%r{/+\z}, "")}/#{options.organization.to_s.strip}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def validate_configuration!
|
|
249
|
+
return unless missing_configuration?
|
|
250
|
+
|
|
251
|
+
raise ::OmniAuth::Authify::ConfigurationError,
|
|
252
|
+
"Authify strategy requires both :site and :organization options"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def normalized_base
|
|
256
|
+
org_base
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def normalized_id_token
|
|
260
|
+
value = access_token&.params&.[]("id_token") ||
|
|
261
|
+
access_token&.[]("id_token") ||
|
|
262
|
+
access_token&.[](:id_token)
|
|
263
|
+
value.to_s.empty? ? nil : value
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Identity claims for the auth hash. Prefers (signature-verified) ID
|
|
267
|
+
# token claims and falls back to the userinfo endpoint.
|
|
268
|
+
def raw_info
|
|
269
|
+
return @raw_info if @raw_info
|
|
270
|
+
|
|
271
|
+
@raw_info = if options.verify_id_token && id_token
|
|
272
|
+
jwt_validator.decode(id_token).first
|
|
273
|
+
else
|
|
274
|
+
access_token.get(userinfo_url, headers: {
|
|
275
|
+
"accept" => "application/json"
|
|
276
|
+
}).parsed || {}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def userinfo_url
|
|
281
|
+
"#{normalized_base}/oauth/userinfo"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
OmniAuth.config.add_camelization "authify", "Authify"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"packages": {
|
|
3
|
+
".": {
|
|
4
|
+
"package-name": "omniauth-authify",
|
|
5
|
+
"changelog-path": "CHANGELOG.md",
|
|
6
|
+
"release-type": "ruby",
|
|
7
|
+
"bump-minor-pre-major": true,
|
|
8
|
+
"bump-patch-for-minor-pre-major": true,
|
|
9
|
+
"draft": false,
|
|
10
|
+
"prerelease": false,
|
|
11
|
+
"version-file": "lib/omniauth/authify/version.rb"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module OmniAuth
|
|
2
|
+
module Authify
|
|
3
|
+
# The gem's current version
|
|
4
|
+
VERSION: String
|
|
5
|
+
|
|
6
|
+
# Raised for configuration errors within the strategy itself
|
|
7
|
+
class ConfigurationError < StandardError
|
|
8
|
+
def initialize: (String message) -> void
|
|
9
|
+
|
|
10
|
+
def message: () -> String
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Raised when an Authify-issued ID token (or the signing keys used to
|
|
14
|
+
# verify it) cannot be validated
|
|
15
|
+
class TokenValidationError < StandardError
|
|
16
|
+
def initialize: (String message) -> void
|
|
17
|
+
|
|
18
|
+
def message: () -> String
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Validates OpenID Connect ID tokens issued by Authify
|
|
22
|
+
class JwtValidator
|
|
23
|
+
# ID token signature algorithms this validator accepts
|
|
24
|
+
SUPPORTED_ALGORITHMS: Array[String]
|
|
25
|
+
# Claims that must be present in every ID token
|
|
26
|
+
REQUIRED_CLAIMS: Array[String]
|
|
27
|
+
# Network failures that map to a TokenValidationError during JWKS fetches
|
|
28
|
+
NETWORK_ERRORS: Array[singleton(StandardError)]
|
|
29
|
+
|
|
30
|
+
# Creates a validator
|
|
31
|
+
#
|
|
32
|
+
# - client_id: the OAuth2 client ID (expected `aud` value)
|
|
33
|
+
# - issuer: the expected value of the ID token `iss` claim
|
|
34
|
+
# - jwks_uri: the organization's JWKS endpoint URL
|
|
35
|
+
def initialize: (client_id: String, issuer: String, jwks_uri: String) -> void
|
|
36
|
+
|
|
37
|
+
# Decodes an ID token and verifies its signature and claims
|
|
38
|
+
#
|
|
39
|
+
# - jwt: the ID token
|
|
40
|
+
# - authorize_params: per-login parameters stored at the start of the
|
|
41
|
+
# flow; may contain +nonce+, +leeway+, +max_age+ and +issuer+
|
|
42
|
+
# (string or symbol keys)
|
|
43
|
+
#
|
|
44
|
+
# Returns the verified claims
|
|
45
|
+
# Raises TokenValidationError when the token cannot be verified
|
|
46
|
+
def verify: (?Hash[Symbol | String, untyped] authorize_params) -> Hash[String, untyped]
|
|
47
|
+
|
|
48
|
+
# Decodes an ID token, verifying its signature but skipping claim
|
|
49
|
+
# checks
|
|
50
|
+
#
|
|
51
|
+
# - jwt: the ID token
|
|
52
|
+
#
|
|
53
|
+
# Returns a tuple of the claims and the JOSE header
|
|
54
|
+
# Raises TokenValidationError when the signature cannot be verified
|
|
55
|
+
def decode: () -> [Hash[String, untyped], Hash[String, untyped]]
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def verify_iss: (Hash[String, untyped] claims, Hash[Symbol | String, untyped] params) -> void
|
|
60
|
+
|
|
61
|
+
def verify_sub: (Hash[String, untyped] claims) -> void
|
|
62
|
+
|
|
63
|
+
def verify_aud: (Hash[String, untyped] claims) -> void
|
|
64
|
+
|
|
65
|
+
def verify_expiration: (Hash[String, untyped] claims, Hash[Symbol | String, untyped] params) -> void
|
|
66
|
+
|
|
67
|
+
def verify_iat: (Hash[String, untyped] claims, Hash[Symbol | String, untyped] params) -> void
|
|
68
|
+
|
|
69
|
+
def verify_auth_time: (Hash[String, untyped] claims, Hash[Symbol | String, untyped] params) -> void
|
|
70
|
+
|
|
71
|
+
def verify_nonce: (Hash[String, untyped] claims, Hash[Symbol | String, untyped] params) -> void
|
|
72
|
+
|
|
73
|
+
def leeway: (Hash[Symbol | String, untyped] params) -> Integer
|
|
74
|
+
|
|
75
|
+
def token_head: (String? jwt) -> Hash[String, untyped]
|
|
76
|
+
|
|
77
|
+
def jwks_loader: () -> ^(?Hash[Symbol, untyped]) -> Hash[Symbol, Array[Hash[Symbol, untyped]]]
|
|
78
|
+
|
|
79
|
+
def fetch_keys: (?force: bool) -> Array[Hash[Symbol, untyped]]
|
|
80
|
+
|
|
81
|
+
def fetch_jwks_response: () -> (Net::HTTPSuccess | Net::HTTPResponse)
|
|
82
|
+
|
|
83
|
+
def parse_jwks: ((Net::HTTPSuccess | Net::HTTPResponse) response) -> Array[Hash[Symbol, untyped]]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|