better_auth 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +5 -3
- data/lib/better_auth/adapters/internal_adapter.rb +173 -20
- data/lib/better_auth/adapters/memory.rb +61 -12
- data/lib/better_auth/adapters/mongodb.rb +5 -365
- data/lib/better_auth/adapters/sql.rb +44 -3
- data/lib/better_auth/api.rb +7 -2
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/context.rb +2 -1
- data/lib/better_auth/database_hooks.rb +3 -3
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +5 -2
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugin.rb +14 -1
- data/lib/better_auth/plugins/email_otp.rb +16 -5
- data/lib/better_auth/plugins/generic_oauth.rb +14 -28
- data/lib/better_auth/plugins/oauth_protocol.rb +553 -64
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +56 -20
- data/lib/better_auth/plugins/two_factor.rb +53 -18
- data/lib/better_auth/rate_limiter.rb +37 -2
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/router.rb +14 -1
- data/lib/better_auth/routes/account.rb +16 -4
- data/lib/better_auth/routes/email_verification.rb +5 -2
- data/lib/better_auth/routes/password.rb +21 -1
- data/lib/better_auth/routes/session.rb +27 -4
- data/lib/better_auth/routes/sign_in.rb +3 -1
- data/lib/better_auth/routes/sign_up.rb +60 -1
- data/lib/better_auth/routes/social.rb +231 -22
- data/lib/better_auth/routes/user.rb +23 -5
- data/lib/better_auth/schema/sql.rb +11 -0
- data/lib/better_auth/schema.rb +16 -0
- data/lib/better_auth/session.rb +12 -1
- data/lib/better_auth/social_providers/apple.rb +44 -8
- data/lib/better_auth/social_providers/atlassian.rb +32 -0
- data/lib/better_auth/social_providers/base.rb +262 -4
- data/lib/better_auth/social_providers/cognito.rb +32 -0
- data/lib/better_auth/social_providers/discord.rb +27 -5
- data/lib/better_auth/social_providers/dropbox.rb +33 -0
- data/lib/better_auth/social_providers/facebook.rb +35 -0
- data/lib/better_auth/social_providers/figma.rb +31 -0
- data/lib/better_auth/social_providers/github.rb +21 -6
- data/lib/better_auth/social_providers/gitlab.rb +16 -3
- data/lib/better_auth/social_providers/google.rb +38 -13
- data/lib/better_auth/social_providers/huggingface.rb +31 -0
- data/lib/better_auth/social_providers/kakao.rb +32 -0
- data/lib/better_auth/social_providers/kick.rb +32 -0
- data/lib/better_auth/social_providers/line.rb +33 -0
- data/lib/better_auth/social_providers/linear.rb +44 -0
- data/lib/better_auth/social_providers/linkedin.rb +30 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
- data/lib/better_auth/social_providers/naver.rb +31 -0
- data/lib/better_auth/social_providers/notion.rb +33 -0
- data/lib/better_auth/social_providers/paybin.rb +31 -0
- data/lib/better_auth/social_providers/paypal.rb +36 -0
- data/lib/better_auth/social_providers/polar.rb +31 -0
- data/lib/better_auth/social_providers/railway.rb +49 -0
- data/lib/better_auth/social_providers/reddit.rb +32 -0
- data/lib/better_auth/social_providers/roblox.rb +31 -0
- data/lib/better_auth/social_providers/salesforce.rb +38 -0
- data/lib/better_auth/social_providers/slack.rb +30 -0
- data/lib/better_auth/social_providers/spotify.rb +31 -0
- data/lib/better_auth/social_providers/tiktok.rb +35 -0
- data/lib/better_auth/social_providers/twitch.rb +39 -0
- data/lib/better_auth/social_providers/twitter.rb +32 -0
- data/lib/better_auth/social_providers/vercel.rb +47 -0
- data/lib/better_auth/social_providers/vk.rb +34 -0
- data/lib/better_auth/social_providers/wechat.rb +104 -0
- data/lib/better_auth/social_providers/zoom.rb +31 -0
- data/lib/better_auth/social_providers.rb +29 -0
- data/lib/better_auth/url_helpers.rb +195 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +8 -1
- metadata +38 -15
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
+
require "jwt"
|
|
4
5
|
require "openssl"
|
|
6
|
+
require "time"
|
|
5
7
|
require "uri"
|
|
6
8
|
|
|
7
9
|
module BetterAuth
|
|
@@ -53,10 +55,36 @@ module BetterAuth
|
|
|
53
55
|
def validate_redirect_uri!(client, redirect_uri)
|
|
54
56
|
redirects = client_redirect_uris(client)
|
|
55
57
|
return if redirects.include?(redirect_uri.to_s)
|
|
58
|
+
return if loopback_redirect_match?(redirects, redirect_uri)
|
|
56
59
|
|
|
57
60
|
raise APIError.new("BAD_REQUEST", message: "invalid redirect_uri")
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
def loopback_redirect_match?(redirects, redirect_uri)
|
|
64
|
+
requested = URI.parse(redirect_uri.to_s)
|
|
65
|
+
return false unless ["http", "https"].include?(requested.scheme)
|
|
66
|
+
requested_host = requested.hostname || requested.host
|
|
67
|
+
return false unless loopback_host?(requested_host)
|
|
68
|
+
|
|
69
|
+
redirects.any? do |allowed|
|
|
70
|
+
allowed_uri = URI.parse(allowed.to_s)
|
|
71
|
+
allowed_host = allowed_uri.hostname || allowed_uri.host
|
|
72
|
+
allowed_uri.scheme == requested.scheme &&
|
|
73
|
+
loopback_host?(allowed_host) &&
|
|
74
|
+
allowed_host == requested_host &&
|
|
75
|
+
allowed_uri.path == requested.path &&
|
|
76
|
+
allowed_uri.query == requested.query
|
|
77
|
+
rescue URI::InvalidURIError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
rescue URI::InvalidURIError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def loopback_host?(host)
|
|
85
|
+
["127.0.0.1", "::1"].include?(host.to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
60
88
|
def client_redirect_uris(client)
|
|
61
89
|
value = client["redirectUris"] || client["redirectUrls"] || client[:redirect_uris] || client[:redirectUrls]
|
|
62
90
|
return value if value.is_a?(Array)
|
|
@@ -71,45 +99,90 @@ module BetterAuth
|
|
|
71
99
|
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
72
100
|
end
|
|
73
101
|
|
|
74
|
-
def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain")
|
|
102
|
+
def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain", unauthenticated: false, default_scopes: nil, allowed_scopes: nil, prefix: {}, dynamic_registration: false, admin: false, pairwise_secret: nil, strip_client_metadata: false, reference_id: nil)
|
|
75
103
|
body = stringify_keys(body || {})
|
|
76
|
-
|
|
104
|
+
requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
|
|
105
|
+
validate_client_metadata_enums!(requested_auth_method, body)
|
|
106
|
+
validate_admin_only_fields!(body, admin: admin)
|
|
107
|
+
auth_method = unauthenticated ? "none" : requested_auth_method
|
|
77
108
|
public_client = auth_method == "none"
|
|
78
|
-
client_id =
|
|
79
|
-
client_secret = public_client ? nil :
|
|
109
|
+
client_id = Crypto.random_string(32)
|
|
110
|
+
client_secret = public_client ? nil : Crypto.random_string(32)
|
|
80
111
|
redirects = Array(body["redirect_uris"]).map(&:to_s)
|
|
81
112
|
raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
|
|
113
|
+
redirects.each { |uri| validate_safe_url!(uri, field: "redirect_uris") }
|
|
114
|
+
Array(body["post_logout_redirect_uris"]).map(&:to_s).each { |uri| validate_safe_url!(uri, field: "post_logout_redirect_uris") }
|
|
115
|
+
|
|
116
|
+
grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
|
|
117
|
+
response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
|
|
118
|
+
validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
|
|
119
|
+
validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
82
120
|
|
|
83
121
|
scopes = parse_scopes(body["scope"] || body["scopes"])
|
|
122
|
+
scopes = parse_scopes(default_scopes) if scopes.empty? && default_scopes
|
|
123
|
+
allowed = parse_scopes(allowed_scopes)
|
|
124
|
+
unless allowed.empty? || scopes.all? { |scope| allowed.include?(scope) }
|
|
125
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
metadata = client_metadata(body, strip_unknown: strip_client_metadata)
|
|
129
|
+
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
130
|
+
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
131
|
+
metadata["software_statement"] = body["software_statement"] if body["software_statement"]
|
|
132
|
+
metadata["tos_uri"] = body["tos_uri"] if body["tos_uri"]
|
|
133
|
+
metadata["policy_uri"] = body["policy_uri"] if body["policy_uri"]
|
|
134
|
+
require_pkce = body.key?("require_pkce") ? body["require_pkce"] : body["requirePKCE"]
|
|
135
|
+
require_pkce = true if dynamic_registration && require_pkce.nil?
|
|
136
|
+
|
|
137
|
+
client_type = if unauthenticated && public_client && body["type"] == "web"
|
|
138
|
+
nil
|
|
139
|
+
else
|
|
140
|
+
body["type"] || (public_client ? nil : "web")
|
|
141
|
+
end
|
|
84
142
|
data = {
|
|
85
143
|
"clientId" => client_id,
|
|
86
144
|
"clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
|
|
87
|
-
"
|
|
145
|
+
"public" => public_client,
|
|
146
|
+
"type" => client_type,
|
|
88
147
|
"name" => body["client_name"] || body["name"] || "OAuth Client",
|
|
89
148
|
"icon" => body["logo_uri"],
|
|
90
149
|
"uri" => body["client_uri"],
|
|
150
|
+
"contacts" => Array(body["contacts"]).map(&:to_s),
|
|
151
|
+
"tos" => body["tos_uri"],
|
|
152
|
+
"policy" => body["policy_uri"],
|
|
153
|
+
"softwareId" => body["software_id"] || metadata["software_id"],
|
|
154
|
+
"softwareVersion" => body["software_version"] || metadata["software_version"],
|
|
155
|
+
"softwareStatement" => body["software_statement"] || metadata["software_statement"],
|
|
91
156
|
"redirectUris" => redirects,
|
|
92
157
|
"redirectUrls" => redirects.join(","),
|
|
93
158
|
"postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
|
|
159
|
+
"clientSecretExpiresAt" => admin ? (body["client_secret_expires_at"] || 0) : nil,
|
|
94
160
|
"tokenEndpointAuthMethod" => auth_method,
|
|
95
|
-
"grantTypes" =>
|
|
96
|
-
"responseTypes" =>
|
|
161
|
+
"grantTypes" => grant_types,
|
|
162
|
+
"responseTypes" => response_types,
|
|
97
163
|
"scopes" => scopes,
|
|
98
|
-
"skipConsent" => body["skip_consent"] || body["skipConsent"]
|
|
99
|
-
"
|
|
164
|
+
"skipConsent" => unauthenticated ? false : !!(body["skip_consent"] || body["skipConsent"]),
|
|
165
|
+
"enableEndSession" => !!(body["enable_end_session"] || body["enableEndSession"]),
|
|
166
|
+
"requirePKCE" => require_pkce,
|
|
167
|
+
"subjectType" => body["subject_type"] || body["subjectType"],
|
|
168
|
+
"metadata" => metadata,
|
|
100
169
|
"disabled" => false
|
|
101
170
|
}
|
|
102
|
-
data["
|
|
171
|
+
data["referenceId"] = reference_id if reference_id
|
|
172
|
+
data["userId"] = owner_session[:user]["id"] if owner_session && !reference_id
|
|
103
173
|
created = ctx.context.adapter.create(model: model, data: data)
|
|
104
|
-
client_response(created).merge(
|
|
105
|
-
client_secret: client_secret,
|
|
106
|
-
client_id_issued_at: Time.now.to_i
|
|
107
|
-
client_secret_expires_at: 0
|
|
174
|
+
response = client_response(created).merge(
|
|
175
|
+
client_secret: client_secret ? apply_prefix(client_secret, prefix, :client_secret) : nil,
|
|
176
|
+
client_id_issued_at: Time.now.to_i
|
|
108
177
|
).compact
|
|
178
|
+
response[:require_pkce] = require_pkce unless require_pkce.nil?
|
|
179
|
+
response[:client_secret_expires_at] = 0 if client_secret
|
|
180
|
+
response
|
|
109
181
|
end
|
|
110
182
|
|
|
111
183
|
def client_response(client, include_secret: true)
|
|
112
184
|
data = stringify_keys(client || {})
|
|
185
|
+
metadata = stringify_keys(data["metadata"] || {})
|
|
113
186
|
response = {
|
|
114
187
|
client_id: data["clientId"],
|
|
115
188
|
client_name: data["name"],
|
|
@@ -120,22 +193,130 @@ module BetterAuth
|
|
|
120
193
|
token_endpoint_auth_method: data["tokenEndpointAuthMethod"] || "client_secret_basic",
|
|
121
194
|
grant_types: data["grantTypes"] || [],
|
|
122
195
|
response_types: data["responseTypes"] || [],
|
|
123
|
-
skip_consent: !!data["skipConsent"],
|
|
124
196
|
scope: scope_string(data["scopes"]),
|
|
125
|
-
|
|
197
|
+
public: !!data["public"],
|
|
198
|
+
type: data["type"],
|
|
199
|
+
user_id: data["userId"],
|
|
200
|
+
reference_id: data["referenceId"],
|
|
201
|
+
require_pkce: client_require_pkce(data),
|
|
202
|
+
subject_type: data["subjectType"],
|
|
203
|
+
metadata: metadata,
|
|
204
|
+
contacts: data["contacts"] || [],
|
|
205
|
+
tos_uri: data["tos"],
|
|
206
|
+
policy_uri: data["policy"],
|
|
207
|
+
software_id: data["softwareId"],
|
|
208
|
+
software_version: data["softwareVersion"],
|
|
209
|
+
software_statement: data["softwareStatement"],
|
|
210
|
+
client_secret_expires_at: data["clientSecretExpiresAt"]
|
|
126
211
|
}
|
|
212
|
+
response[:skip_consent] = true if data["skipConsent"]
|
|
213
|
+
metadata.each { |key, value| response[key.to_sym] = value }
|
|
127
214
|
response[:client_secret] = data["clientSecret"] if include_secret && data["clientSecret"]
|
|
128
|
-
response
|
|
215
|
+
response.compact
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated:, dynamic_registration:)
|
|
219
|
+
public_client = auth_method == "none"
|
|
220
|
+
if dynamic_registration && (body["require_pkce"] == false || body["requirePKCE"] == false)
|
|
221
|
+
raise APIError.new("BAD_REQUEST", message: "pkce is required for registered clients")
|
|
222
|
+
end
|
|
223
|
+
if dynamic_registration && (body["enable_end_session"] || body["enableEndSession"])
|
|
224
|
+
raise APIError.new("BAD_REQUEST", message: "enable_end_session is not allowed during dynamic client registration")
|
|
225
|
+
end
|
|
226
|
+
if public_client && grant_types.include?(CLIENT_CREDENTIALS_GRANT)
|
|
227
|
+
raise APIError.new("BAD_REQUEST", message: "public clients cannot use client_credentials")
|
|
228
|
+
end
|
|
229
|
+
if grant_types.include?(AUTH_CODE_GRANT) && !response_types.include?("code")
|
|
230
|
+
raise APIError.new("BAD_REQUEST", message: "authorization_code clients must support code response_type")
|
|
231
|
+
end
|
|
232
|
+
if auth_method != "none" && ["native", "user-agent-based"].include?(body["type"])
|
|
233
|
+
raise APIError.new("BAD_REQUEST", message: "public client types must use token_endpoint_auth_method none")
|
|
234
|
+
end
|
|
235
|
+
if !unauthenticated && auth_method == "none" && body["type"] == "web"
|
|
236
|
+
raise APIError.new("BAD_REQUEST", message: "web clients must be confidential")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
241
|
+
subject_type = body["subject_type"] || body["subjectType"]
|
|
242
|
+
return unless subject_type == "pairwise"
|
|
243
|
+
|
|
244
|
+
raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires pairwise_secret") if pairwise_secret.to_s.empty?
|
|
245
|
+
|
|
246
|
+
hosts = redirects.map { |uri| URI.parse(uri).host }.uniq
|
|
247
|
+
raise APIError.new("BAD_REQUEST", message: "pairwise redirect_uris must share the same host") if hosts.length > 1
|
|
248
|
+
rescue URI::InvalidURIError
|
|
249
|
+
raise APIError.new("BAD_REQUEST", message: "invalid redirect_uris")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_safe_url!(value, field:)
|
|
253
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") if value.to_s.empty?
|
|
254
|
+
|
|
255
|
+
uri = URI.parse(value.to_s)
|
|
256
|
+
scheme = uri.scheme.to_s.downcase
|
|
257
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") if scheme.empty?
|
|
258
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") if %w[javascript data vbscript].include?(scheme)
|
|
259
|
+
|
|
260
|
+
if scheme == "http"
|
|
261
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") unless ["localhost", "127.0.0.1", "::1"].include?(uri.hostname || uri.host)
|
|
262
|
+
end
|
|
263
|
+
true
|
|
264
|
+
rescue URI::InvalidURIError
|
|
265
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def client_metadata(body, strip_unknown: false)
|
|
269
|
+
metadata = stringify_keys(body["metadata"] || {})
|
|
270
|
+
metadata = metadata.slice("software_id", "software_version", "software_statement", "tos_uri", "policy_uri") if strip_unknown
|
|
271
|
+
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
272
|
+
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
273
|
+
metadata["software_statement"] = body["software_statement"] if body["software_statement"]
|
|
274
|
+
metadata["tos_uri"] = body["tos_uri"] if body["tos_uri"]
|
|
275
|
+
metadata["policy_uri"] = body["policy_uri"] if body["policy_uri"]
|
|
276
|
+
metadata
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def client_require_pkce(data)
|
|
280
|
+
data = stringify_keys(data || {})
|
|
281
|
+
return data["requirePKCE"] if data.key?("requirePKCE")
|
|
282
|
+
return data["requirePkce"] if data.key?("requirePkce")
|
|
283
|
+
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_client_metadata_enums!(auth_method, body)
|
|
288
|
+
unless ["client_secret_basic", "client_secret_post", "none"].include?(auth_method)
|
|
289
|
+
raise APIError.new("BAD_REQUEST", message: "invalid token_endpoint_auth_method")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
invalid_grant = Array(body["grant_types"]).map(&:to_s) - [AUTH_CODE_GRANT, CLIENT_CREDENTIALS_GRANT, REFRESH_GRANT]
|
|
293
|
+
raise APIError.new("BAD_REQUEST", message: "invalid grant_types") unless invalid_grant.empty?
|
|
294
|
+
|
|
295
|
+
invalid_response = Array(body["response_types"]).map(&:to_s) - ["code"]
|
|
296
|
+
raise APIError.new("BAD_REQUEST", message: "invalid response_types") unless invalid_response.empty?
|
|
297
|
+
|
|
298
|
+
client_type = body["type"]
|
|
299
|
+
if client_type && !["web", "native", "user-agent-based"].include?(client_type)
|
|
300
|
+
raise APIError.new("BAD_REQUEST", message: "invalid type")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def validate_admin_only_fields!(body, admin:)
|
|
305
|
+
return if admin
|
|
306
|
+
|
|
307
|
+
%w[client_secret_expires_at clientSecretExpiresAt].each do |key|
|
|
308
|
+
raise APIError.new("BAD_REQUEST", message: "field #{key} is server-only") if body.key?(key)
|
|
309
|
+
end
|
|
129
310
|
end
|
|
130
311
|
|
|
131
312
|
def find_client(ctx, model, client_id)
|
|
132
313
|
ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
|
|
133
314
|
end
|
|
134
315
|
|
|
135
|
-
def authenticate_client!(ctx, model, store_client_secret: "plain")
|
|
316
|
+
def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
|
|
136
317
|
body = stringify_keys(ctx.body || {})
|
|
137
318
|
client_id = body["client_id"]
|
|
138
|
-
client_secret = body["client_secret"]
|
|
319
|
+
client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
|
|
139
320
|
|
|
140
321
|
authorization = ctx.headers["authorization"]
|
|
141
322
|
if authorization.to_s.start_with?("Basic ") && client_id.to_s.empty?
|
|
@@ -146,7 +327,14 @@ module BetterAuth
|
|
|
146
327
|
client = find_client(ctx, model, client_id)
|
|
147
328
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
|
|
148
329
|
|
|
149
|
-
|
|
330
|
+
client_data = stringify_keys(client)
|
|
331
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") if client_data["disabled"]
|
|
332
|
+
|
|
333
|
+
method = client_data["tokenEndpointAuthMethod"] || "client_secret_basic"
|
|
334
|
+
if method == "none"
|
|
335
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client_secret.to_s.empty?
|
|
336
|
+
return client
|
|
337
|
+
end
|
|
150
338
|
if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
|
|
151
339
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
152
340
|
end
|
|
@@ -156,7 +344,7 @@ module BetterAuth
|
|
|
156
344
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
157
345
|
end
|
|
158
346
|
|
|
159
|
-
def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil)
|
|
347
|
+
def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil)
|
|
160
348
|
store[:codes][code] = {
|
|
161
349
|
client_id: client_id,
|
|
162
350
|
redirect_uri: redirect_uri,
|
|
@@ -164,6 +352,9 @@ module BetterAuth
|
|
|
164
352
|
scopes: parse_scopes(scopes),
|
|
165
353
|
code_challenge: code_challenge,
|
|
166
354
|
code_challenge_method: code_challenge_method,
|
|
355
|
+
nonce: nonce,
|
|
356
|
+
reference_id: reference_id,
|
|
357
|
+
auth_time: auth_time || session_auth_time(session),
|
|
167
358
|
expires_at: Time.now + 600
|
|
168
359
|
}
|
|
169
360
|
end
|
|
@@ -174,7 +365,11 @@ module BetterAuth
|
|
|
174
365
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
|
|
175
366
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
|
|
176
367
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:redirect_uri] == redirect_uri.to_s
|
|
177
|
-
|
|
368
|
+
if data[:code_challenge]
|
|
369
|
+
verify_pkce!(data, code_verifier)
|
|
370
|
+
elsif !code_verifier.to_s.empty?
|
|
371
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant")
|
|
372
|
+
end
|
|
178
373
|
|
|
179
374
|
data
|
|
180
375
|
end
|
|
@@ -182,59 +377,129 @@ module BetterAuth
|
|
|
182
377
|
def verify_pkce!(code_data, verifier)
|
|
183
378
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if verifier.to_s.empty?
|
|
184
379
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
verifier.to_s
|
|
189
|
-
end
|
|
380
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless code_data[:code_challenge_method].to_s == "S256"
|
|
381
|
+
|
|
382
|
+
challenge = Base64.urlsafe_encode64(OpenSSL::Digest.digest("SHA256", verifier.to_s), padding: false)
|
|
190
383
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless challenge == code_data[:code_challenge]
|
|
191
384
|
end
|
|
192
385
|
|
|
193
|
-
def
|
|
386
|
+
def validate_authorize_pkce(client, scopes, code_challenge, code_challenge_method)
|
|
387
|
+
method = code_challenge_method.to_s
|
|
388
|
+
return "code_challenge_method must be S256" if !code_challenge.to_s.empty? && method != "S256"
|
|
389
|
+
|
|
390
|
+
return nil unless pkce_required?(client, scopes)
|
|
391
|
+
return "PKCE is required" if code_challenge.to_s.empty?
|
|
392
|
+
return "code_challenge_method must be S256" if method != "S256"
|
|
393
|
+
|
|
394
|
+
nil
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def pkce_required?(client, scopes)
|
|
398
|
+
data = stringify_keys(client)
|
|
399
|
+
return true if parse_scopes(scopes).include?("offline_access")
|
|
400
|
+
require_pkce = client_require_pkce(data)
|
|
401
|
+
return require_pkce unless require_pkce.nil?
|
|
402
|
+
|
|
403
|
+
true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
|
|
194
407
|
data = stringify_keys(session || {})
|
|
195
408
|
user = stringify_keys(data["user"] || data[:user] || {})
|
|
196
409
|
session_data = stringify_keys(data["session"] || data[:session] || {})
|
|
197
410
|
client_data = stringify_keys(client)
|
|
198
|
-
|
|
199
|
-
|
|
411
|
+
subject = subject_identifier(user["id"], client_data, pairwise_secret)
|
|
412
|
+
token_auth_time = auth_time || session_auth_time({"session" => session_data})
|
|
413
|
+
token_reference_id = reference_id || client_data["referenceId"]
|
|
414
|
+
access_token_value = Crypto.random_string(32)
|
|
415
|
+
refresh_token_value = include_refresh ? Crypto.random_string(32) : nil
|
|
416
|
+
refresh_token = refresh_token_value ? apply_prefix(refresh_token_value, prefix, :refresh_token) : nil
|
|
200
417
|
scope = scope_string(scopes)
|
|
201
418
|
expires_at = Time.now + access_token_expires_in.to_i
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
419
|
+
access_token = if jwt_access_token && audience
|
|
420
|
+
build_jwt_access_token(ctx, client_data, user, session_data, scope, audience, issuer || issuer(ctx), expires_at, custom_access_token_claims, reference_id: token_reference_id)
|
|
421
|
+
else
|
|
422
|
+
apply_prefix(access_token_value, prefix, :access_token)
|
|
423
|
+
end
|
|
424
|
+
refresh_record = nil
|
|
425
|
+
if refresh_token_value
|
|
426
|
+
refresh_record = {
|
|
427
|
+
"token" => refresh_token_value,
|
|
428
|
+
"clientId" => client_data["clientId"],
|
|
429
|
+
"sessionId" => session_data["id"],
|
|
430
|
+
"userId" => user["id"],
|
|
431
|
+
"referenceId" => token_reference_id,
|
|
432
|
+
"authTime" => token_auth_time,
|
|
433
|
+
"expiresAt" => Time.now + refresh_token_expires_in.to_i,
|
|
434
|
+
"createdAt" => Time.now,
|
|
435
|
+
"revoked" => nil,
|
|
436
|
+
"scopes" => parse_scopes(scope),
|
|
437
|
+
"subject" => subject,
|
|
438
|
+
"audience" => audience,
|
|
439
|
+
"issuer" => issuer || issuer(ctx),
|
|
440
|
+
"issuedAt" => Time.now
|
|
441
|
+
}
|
|
442
|
+
created_refresh = schema_model?(ctx, "oauthRefreshToken") ? ctx.context.adapter.create(model: "oauthRefreshToken", data: refresh_record) : nil
|
|
443
|
+
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
444
|
+
store[:refresh_tokens][refresh_token_value] = refresh_record
|
|
445
|
+
store[:refresh_tokens][refresh_token] = refresh_record
|
|
446
|
+
end
|
|
447
|
+
unless jwt_access_token && audience
|
|
448
|
+
record = {
|
|
449
|
+
"token" => access_token_value,
|
|
450
|
+
"expiresAt" => expires_at,
|
|
451
|
+
"clientId" => client_data["clientId"],
|
|
452
|
+
"userId" => user["id"],
|
|
453
|
+
"subject" => subject,
|
|
454
|
+
"sessionId" => session_data["id"],
|
|
455
|
+
"scopes" => parse_scopes(scope),
|
|
456
|
+
"revoked" => nil,
|
|
457
|
+
"referenceId" => token_reference_id,
|
|
458
|
+
"authTime" => token_auth_time,
|
|
459
|
+
"refreshId" => refresh_record && refresh_record["id"],
|
|
460
|
+
"audience" => audience,
|
|
461
|
+
"issuer" => issuer || issuer(ctx),
|
|
462
|
+
"issuedAt" => Time.now
|
|
463
|
+
}
|
|
464
|
+
ctx.context.adapter.create(model: model, data: record)
|
|
465
|
+
stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
|
|
466
|
+
store[:tokens][access_token_value] = stored_record
|
|
467
|
+
store[:tokens][access_token] = stored_record
|
|
468
|
+
end
|
|
219
469
|
|
|
220
470
|
response = {
|
|
221
471
|
access_token: access_token,
|
|
222
472
|
token_type: "Bearer",
|
|
223
473
|
expires_in: access_token_expires_in.to_i,
|
|
474
|
+
expires_at: expires_at.to_i,
|
|
224
475
|
scope: scope
|
|
225
476
|
}
|
|
477
|
+
response[:audience] = audience if audience
|
|
226
478
|
response[:refresh_token] = refresh_token if refresh_token
|
|
227
|
-
response[:id_token] = id_token(user, client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer) if parse_scopes(scope).include?("openid")
|
|
479
|
+
response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: client_data, filter_claims_by_scope: filter_id_token_claims_by_scope) if parse_scopes(scope).include?("openid")
|
|
480
|
+
if custom_token_response_fields.respond_to?(:call)
|
|
481
|
+
extra = custom_token_response_fields.call({grant_type: grant_type, user: user.empty? ? nil : user, scopes: parse_scopes(scope), metadata: stringify_keys(client_data["metadata"] || {})})
|
|
482
|
+
response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
|
|
483
|
+
end
|
|
228
484
|
response
|
|
229
485
|
end
|
|
230
486
|
|
|
231
|
-
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, id_token_signer: nil)
|
|
232
|
-
|
|
487
|
+
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
|
|
488
|
+
refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
|
|
489
|
+
data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
|
|
233
490
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
|
|
491
|
+
if data["revoked"]
|
|
492
|
+
revoke_refresh_family!(ctx, store, data)
|
|
493
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant")
|
|
494
|
+
end
|
|
495
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data["expiresAt"] && data["expiresAt"] <= Time.now
|
|
496
|
+
|
|
234
497
|
requested = scopes ? parse_scopes(scopes) : data["scopes"]
|
|
235
498
|
unless requested.all? { |scope| data["scopes"].include?(scope) }
|
|
236
499
|
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
237
500
|
end
|
|
501
|
+
data["revoked"] = Time.now
|
|
502
|
+
ctx.context.adapter.update(model: "oauthRefreshToken", where: [{field: "id", value: data["id"]}], update: {revoked: data["revoked"]}) if data["id"] && schema_model?(ctx, "oauthRefreshToken")
|
|
238
503
|
|
|
239
504
|
issue_tokens(
|
|
240
505
|
ctx,
|
|
@@ -246,12 +511,25 @@ module BetterAuth
|
|
|
246
511
|
include_refresh: true,
|
|
247
512
|
issuer: issuer,
|
|
248
513
|
access_token_expires_in: access_token_expires_in,
|
|
249
|
-
|
|
514
|
+
refresh_token_expires_in: refresh_token_expires_in,
|
|
515
|
+
id_token_signer: id_token_signer,
|
|
516
|
+
prefix: prefix,
|
|
517
|
+
audience: audience,
|
|
518
|
+
grant_type: REFRESH_GRANT,
|
|
519
|
+
custom_token_response_fields: custom_token_response_fields,
|
|
520
|
+
custom_access_token_claims: custom_access_token_claims,
|
|
521
|
+
custom_id_token_claims: custom_id_token_claims,
|
|
522
|
+
jwt_access_token: jwt_access_token,
|
|
523
|
+
pairwise_secret: pairwise_secret,
|
|
524
|
+
auth_time: data["authTime"],
|
|
525
|
+
reference_id: data["referenceId"],
|
|
526
|
+
filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
|
|
250
527
|
)
|
|
251
528
|
end
|
|
252
529
|
|
|
253
|
-
def token_record(store, token)
|
|
254
|
-
|
|
530
|
+
def token_record(store, token, prefix: {})
|
|
531
|
+
token_value = strip_prefix(token, prefix, :access_token)
|
|
532
|
+
data = token_value ? store[:tokens][token_value] : nil
|
|
255
533
|
return nil unless data
|
|
256
534
|
return nil if data["revoked"]
|
|
257
535
|
return nil if data["expiresAt"] && data["expiresAt"] <= Time.now
|
|
@@ -259,34 +537,193 @@ module BetterAuth
|
|
|
259
537
|
data
|
|
260
538
|
end
|
|
261
539
|
|
|
262
|
-
def
|
|
540
|
+
def build_jwt_access_token(ctx, client, user, session, scope, audience, issuer_value, expires_at, custom_claims, reference_id: nil)
|
|
541
|
+
scopes = parse_scopes(scope)
|
|
542
|
+
extra = if custom_claims.respond_to?(:call)
|
|
543
|
+
custom_claims.call({user: user.empty? ? nil : user, scopes: scopes, resource: audience, reference_id: reference_id, metadata: stringify_keys(client["metadata"] || {})})
|
|
544
|
+
end
|
|
545
|
+
payload = (extra.is_a?(Hash) ? stringify_keys(extra) : {}).merge(
|
|
546
|
+
"sub" => user["id"],
|
|
547
|
+
"aud" => audience,
|
|
548
|
+
"azp" => client["clientId"],
|
|
549
|
+
"scope" => scope,
|
|
550
|
+
"sid" => session["id"],
|
|
551
|
+
"name" => user["name"],
|
|
552
|
+
"email" => user["email"],
|
|
553
|
+
"email_verified" => user["emailVerified"],
|
|
554
|
+
"iss" => issuer_value,
|
|
555
|
+
"iat" => Time.now.to_i,
|
|
556
|
+
"exp" => expires_at.to_i
|
|
557
|
+
).compact
|
|
558
|
+
::JWT.encode(payload, ctx.context.secret, "HS256")
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def userinfo(store, authorization, additional_claim: nil, prefix: {}, jwt_secret: nil)
|
|
562
|
+
if authorization.to_s.strip.empty?
|
|
563
|
+
raise APIError.new(
|
|
564
|
+
"UNAUTHORIZED",
|
|
565
|
+
message: "authorization header not found",
|
|
566
|
+
body: {error: "invalid_request", error_description: "authorization header not found"}
|
|
567
|
+
)
|
|
568
|
+
end
|
|
263
569
|
token = authorization.to_s.delete_prefix("Bearer ").strip
|
|
264
|
-
record = token_record(store, token)
|
|
265
|
-
|
|
570
|
+
record = token_record(store, token, prefix: prefix)
|
|
571
|
+
return jwt_userinfo(token, jwt_secret, additional_claim: additional_claim) unless record
|
|
266
572
|
user = stringify_keys(record["user"])
|
|
267
|
-
scopes = parse_scopes(record["
|
|
268
|
-
|
|
573
|
+
scopes = parse_scopes(record["scopes"])
|
|
574
|
+
raise userinfo_openid_scope_error unless scopes.include?("openid")
|
|
575
|
+
|
|
576
|
+
response = {sub: record["subject"] || user["id"]}
|
|
269
577
|
response[:name] = user["name"] if scopes.include?("profile")
|
|
578
|
+
response[:given_name] = user["name"].to_s.split(/\s+/, 2).first if scopes.include?("profile") && user["name"]
|
|
579
|
+
response[:family_name] = user["name"].to_s.split(/\s+/, 2).last if scopes.include?("profile") && user["name"].to_s.include?(" ")
|
|
580
|
+
response[:picture] = user["image"] if scopes.include?("profile") && user["image"]
|
|
270
581
|
if scopes.include?("email")
|
|
271
582
|
response[:email] = user["email"]
|
|
272
583
|
response[:email_verified] = !!user["emailVerified"]
|
|
273
584
|
end
|
|
274
585
|
if additional_claim.respond_to?(:call)
|
|
275
|
-
extra =
|
|
586
|
+
extra = begin
|
|
587
|
+
additional_claim.call({user: user, scopes: scopes, jwt: record, client: stringify_keys(record["client"] || {})})
|
|
588
|
+
rescue ArgumentError
|
|
589
|
+
additional_claim.call(user, scopes, stringify_keys(record["client"] || {}))
|
|
590
|
+
end
|
|
276
591
|
response.merge!(extra) if extra.is_a?(Hash)
|
|
277
592
|
end
|
|
278
593
|
response
|
|
279
594
|
end
|
|
280
595
|
|
|
281
|
-
def
|
|
596
|
+
def jwt_userinfo(token, jwt_secret, additional_claim: nil)
|
|
597
|
+
payload = ::JWT.decode(token, jwt_secret.to_s, true, algorithm: "HS256").first
|
|
598
|
+
scopes = parse_scopes(payload["scope"])
|
|
599
|
+
raise userinfo_openid_scope_error unless scopes.include?("openid")
|
|
600
|
+
|
|
601
|
+
response = {sub: payload["sub"]}
|
|
602
|
+
if scopes.include?("profile")
|
|
603
|
+
response[:name] = payload["name"] if payload["name"]
|
|
604
|
+
response[:given_name] = payload["name"].to_s.split(/\s+/, 2).first if payload["name"]
|
|
605
|
+
response[:family_name] = payload["name"].to_s.split(/\s+/, 2).last if payload["name"].to_s.include?(" ")
|
|
606
|
+
end
|
|
607
|
+
if scopes.include?("email")
|
|
608
|
+
response[:email] = payload["email"]
|
|
609
|
+
response[:email_verified] = !!payload["email_verified"]
|
|
610
|
+
end
|
|
611
|
+
if additional_claim.respond_to?(:call)
|
|
612
|
+
extra = additional_claim.call({user: payload, scopes: scopes, jwt: payload, client: {}})
|
|
613
|
+
response.merge!(extra) if extra.is_a?(Hash)
|
|
614
|
+
end
|
|
615
|
+
response
|
|
616
|
+
rescue ::JWT::DecodeError
|
|
617
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_token")
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def userinfo_openid_scope_error
|
|
621
|
+
APIError.new(
|
|
622
|
+
"BAD_REQUEST",
|
|
623
|
+
message: "openid scope is required",
|
|
624
|
+
body: {error: "invalid_request", error_description: "openid scope is required"}
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def find_token_by_hint(store, token, hint, prefix: {})
|
|
629
|
+
access = -> { (value = strip_prefix(token, prefix, :access_token)) && store[:tokens][value] }
|
|
630
|
+
refresh = -> { (value = strip_prefix(token, prefix, :refresh_token)) && store[:refresh_tokens][value] }
|
|
631
|
+
|
|
632
|
+
case hint.to_s
|
|
633
|
+
when "access_token"
|
|
634
|
+
access.call
|
|
635
|
+
when "refresh_token"
|
|
636
|
+
refresh.call
|
|
637
|
+
else
|
|
638
|
+
access.call || refresh.call
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def revoke_refresh_family!(ctx, store, refresh_record)
|
|
643
|
+
client_id = refresh_record["clientId"]
|
|
644
|
+
user_id = refresh_record["userId"]
|
|
645
|
+
store[:refresh_tokens].delete_if { |_token, record| record["clientId"] == client_id && record["userId"] == user_id }
|
|
646
|
+
store[:tokens].delete_if { |_token, record| record["clientId"] == client_id && record["userId"] == user_id }
|
|
647
|
+
if schema_model?(ctx, "oauthRefreshToken")
|
|
648
|
+
refresh_ids = ctx.context.adapter.find_many(
|
|
649
|
+
model: "oauthRefreshToken",
|
|
650
|
+
where: [
|
|
651
|
+
{field: "clientId", value: client_id},
|
|
652
|
+
{field: "userId", value: user_id}
|
|
653
|
+
]
|
|
654
|
+
).map { |entry| stringify_keys(entry)["id"] }
|
|
655
|
+
|
|
656
|
+
ctx.context.adapter.delete_many(
|
|
657
|
+
model: "oauthRefreshToken",
|
|
658
|
+
where: [
|
|
659
|
+
{field: "clientId", value: client_id},
|
|
660
|
+
{field: "userId", value: user_id}
|
|
661
|
+
]
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if schema_model?(ctx, "oauthAccessToken")
|
|
665
|
+
refresh_ids.each do |refresh_id|
|
|
666
|
+
ctx.context.adapter.delete_many(model: "oauthAccessToken", where: [{field: "refreshId", value: refresh_id}])
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def schema_model?(ctx, model)
|
|
673
|
+
Schema.auth_tables(ctx.context.options).key?(model.to_s)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def apply_prefix(value, prefix, kind)
|
|
677
|
+
"#{token_prefix(prefix, kind)}#{value}"
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def strip_prefix(value, prefix, kind)
|
|
681
|
+
token = value.to_s
|
|
682
|
+
expected = token_prefix(prefix, kind)
|
|
683
|
+
return token if expected.empty?
|
|
684
|
+
return token.delete_prefix(expected) if token.start_with?(expected)
|
|
685
|
+
|
|
686
|
+
nil
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def token_prefix(prefix, kind)
|
|
690
|
+
data = stringify_keys(prefix || {})
|
|
691
|
+
case kind
|
|
692
|
+
when :access_token
|
|
693
|
+
data["opaque_access_token"] || data["opaqueAccessToken"] || "ba_at_"
|
|
694
|
+
when :refresh_token
|
|
695
|
+
data["refresh_token"] || data["refreshToken"] || "ba_rt_"
|
|
696
|
+
when :client_secret
|
|
697
|
+
data["client_secret"] || data["clientSecret"] || ""
|
|
698
|
+
else
|
|
699
|
+
""
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil, session_id: nil, include_sid: false, nonce: nil, auth_time: nil, custom_claims: nil, scopes: [], client: {}, filter_claims_by_scope: false)
|
|
704
|
+
requested_scopes = parse_scopes(scopes)
|
|
282
705
|
payload = {
|
|
283
706
|
sub: user["id"],
|
|
284
707
|
iss: issuer_value,
|
|
285
|
-
aud: audience || client_id
|
|
286
|
-
email: user["email"],
|
|
287
|
-
email_verified: !!user["emailVerified"],
|
|
288
|
-
name: user["name"]
|
|
708
|
+
aud: audience || client_id
|
|
289
709
|
}
|
|
710
|
+
include_profile_claims = !filter_claims_by_scope || requested_scopes.include?("profile")
|
|
711
|
+
include_email_claims = !filter_claims_by_scope || requested_scopes.include?("email")
|
|
712
|
+
payload[:name] = user["name"] if include_profile_claims
|
|
713
|
+
if include_email_claims
|
|
714
|
+
payload[:email] = user["email"]
|
|
715
|
+
payload[:email_verified] = !!user["emailVerified"]
|
|
716
|
+
end
|
|
717
|
+
payload[:sid] = session_id if include_sid && session_id
|
|
718
|
+
payload[:nonce] = nonce if nonce
|
|
719
|
+
payload[:auth_time] = timestamp_seconds(auth_time) if auth_time
|
|
720
|
+
if custom_claims.respond_to?(:call)
|
|
721
|
+
extra = custom_claims.call({user: user, scopes: requested_scopes, client: client})
|
|
722
|
+
if extra.is_a?(Hash)
|
|
723
|
+
pinned = %w[sub iss aud exp iat nonce sid]
|
|
724
|
+
payload.merge!(stringify_keys(extra).except(*pinned).transform_keys(&:to_sym))
|
|
725
|
+
end
|
|
726
|
+
end
|
|
290
727
|
return signer.call(ctx, payload) if signer.respond_to?(:call)
|
|
291
728
|
|
|
292
729
|
Crypto.sign_jwt(
|
|
@@ -296,6 +733,58 @@ module BetterAuth
|
|
|
296
733
|
)
|
|
297
734
|
end
|
|
298
735
|
|
|
736
|
+
def standard_token_response_field?(key)
|
|
737
|
+
%w[access_token token_type expires_in scope refresh_token id_token audience].include?(key.to_s)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def subject_identifier(user_id, client, pairwise_secret)
|
|
741
|
+
data = stringify_keys(client)
|
|
742
|
+
return user_id unless data["subjectType"] == "pairwise" && pairwise_secret && user_id
|
|
743
|
+
|
|
744
|
+
OpenSSL::HMAC.hexdigest("SHA256", pairwise_secret.to_s, "#{sector_identifier(data)}.#{user_id}")
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def sector_identifier(client)
|
|
748
|
+
data = stringify_keys(client)
|
|
749
|
+
uri = client_redirect_uris(data).first
|
|
750
|
+
raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires redirect_uris") if uri.to_s.empty?
|
|
751
|
+
|
|
752
|
+
URI.parse(uri.to_s).host || data["clientId"]
|
|
753
|
+
rescue URI::InvalidURIError
|
|
754
|
+
data["clientId"]
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def session_auth_time(session)
|
|
758
|
+
data = stringify_keys(session || {})
|
|
759
|
+
session_data = stringify_keys(data["session"] || data[:session] || data)
|
|
760
|
+
session_data["createdAt"] || session_data["created_at"]
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def timestamp_seconds(value)
|
|
764
|
+
if value.is_a?(Numeric)
|
|
765
|
+
return nil unless value.finite?
|
|
766
|
+
return nil if value.abs > 8_640_000_000_000_000
|
|
767
|
+
|
|
768
|
+
return (value / 1000.0).floor if value.abs >= 100_000_000_000
|
|
769
|
+
|
|
770
|
+
return value.to_i
|
|
771
|
+
end
|
|
772
|
+
if value.is_a?(String) && value.match?(/\A[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[-+]?\d+)?\z/i)
|
|
773
|
+
numeric = value.to_f
|
|
774
|
+
return nil unless numeric.finite?
|
|
775
|
+
return nil if numeric.abs > 8_640_000_000_000_000
|
|
776
|
+
|
|
777
|
+
return (numeric / 1000.0).floor if numeric.abs >= 100_000_000_000
|
|
778
|
+
|
|
779
|
+
return numeric.to_i
|
|
780
|
+
end
|
|
781
|
+
return timestamp_seconds(value.to_i) if value.respond_to?(:to_i) && !value.is_a?(String)
|
|
782
|
+
|
|
783
|
+
Time.parse(value.to_s).to_i
|
|
784
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
785
|
+
nil
|
|
786
|
+
end
|
|
787
|
+
|
|
299
788
|
def store_client_secret_value(ctx, secret, mode)
|
|
300
789
|
mode = normalize_secret_storage_mode(mode)
|
|
301
790
|
return Crypto.sha256(secret, encoding: :base64url) if mode == "hashed"
|