better_auth 0.2.0 → 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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +5 -3
- data/lib/better_auth/adapters/internal_adapter.rb +168 -18
- data/lib/better_auth/adapters/memory.rb +4 -1
- data/lib/better_auth/adapters/mongodb.rb +5 -365
- data/lib/better_auth/adapters/sql.rb +17 -1
- data/lib/better_auth/api.rb +1 -1
- data/lib/better_auth/context.rb +2 -1
- data/lib/better_auth/plugin.rb +14 -1
- data/lib/better_auth/plugins/oauth_protocol.rb +403 -57
- data/lib/better_auth/plugins/organization.rb +5 -0
- data/lib/better_auth/rate_limiter.rb +19 -2
- data/lib/better_auth/router.rb +14 -1
- data/lib/better_auth/routes/email_verification.rb +5 -2
- data/lib/better_auth/routes/password.rb +19 -0
- data/lib/better_auth/routes/session.rb +27 -4
- data/lib/better_auth/routes/sign_in.rb +1 -1
- data/lib/better_auth/routes/sign_up.rb +52 -1
- data/lib/better_auth/routes/social.rb +201 -22
- data/lib/better_auth/routes/user.rb +14 -2
- data/lib/better_auth/schema/sql.rb +11 -0
- data/lib/better_auth/schema.rb +16 -0
- 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/version.rb +1 -1
- data/lib/better_auth.rb +0 -1
- metadata +30 -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,34 @@ 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
|
+
return false unless loopback_host?(requested.host)
|
|
67
|
+
|
|
68
|
+
redirects.any? do |allowed|
|
|
69
|
+
allowed_uri = URI.parse(allowed.to_s)
|
|
70
|
+
allowed_uri.scheme == requested.scheme &&
|
|
71
|
+
loopback_host?(allowed_uri.host) &&
|
|
72
|
+
allowed_uri.host == requested.host &&
|
|
73
|
+
allowed_uri.path == requested.path &&
|
|
74
|
+
allowed_uri.query == requested.query
|
|
75
|
+
rescue URI::InvalidURIError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
rescue URI::InvalidURIError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def loopback_host?(host)
|
|
83
|
+
["127.0.0.1", "::1"].include?(host.to_s)
|
|
84
|
+
end
|
|
85
|
+
|
|
60
86
|
def client_redirect_uris(client)
|
|
61
87
|
value = client["redirectUris"] || client["redirectUrls"] || client[:redirect_uris] || client[:redirectUrls]
|
|
62
88
|
return value if value.is_a?(Array)
|
|
@@ -71,45 +97,86 @@ module BetterAuth
|
|
|
71
97
|
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
72
98
|
end
|
|
73
99
|
|
|
74
|
-
def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain")
|
|
100
|
+
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)
|
|
75
101
|
body = stringify_keys(body || {})
|
|
76
|
-
|
|
102
|
+
requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
|
|
103
|
+
validate_client_metadata_enums!(requested_auth_method, body)
|
|
104
|
+
validate_admin_only_fields!(body, admin: admin)
|
|
105
|
+
auth_method = unauthenticated ? "none" : requested_auth_method
|
|
77
106
|
public_client = auth_method == "none"
|
|
78
|
-
client_id =
|
|
79
|
-
client_secret = public_client ? nil :
|
|
107
|
+
client_id = Crypto.random_string(32)
|
|
108
|
+
client_secret = public_client ? nil : Crypto.random_string(32)
|
|
80
109
|
redirects = Array(body["redirect_uris"]).map(&:to_s)
|
|
81
110
|
raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
|
|
82
111
|
|
|
112
|
+
grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
|
|
113
|
+
response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
|
|
114
|
+
validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
|
|
115
|
+
|
|
83
116
|
scopes = parse_scopes(body["scope"] || body["scopes"])
|
|
117
|
+
scopes = parse_scopes(default_scopes) if scopes.empty? && default_scopes
|
|
118
|
+
allowed = parse_scopes(allowed_scopes)
|
|
119
|
+
unless allowed.empty? || scopes.all? { |scope| allowed.include?(scope) }
|
|
120
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
metadata = stringify_keys(body["metadata"] || {})
|
|
124
|
+
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
125
|
+
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
126
|
+
metadata["software_statement"] = body["software_statement"] if body["software_statement"]
|
|
127
|
+
metadata["tos_uri"] = body["tos_uri"] if body["tos_uri"]
|
|
128
|
+
metadata["policy_uri"] = body["policy_uri"] if body["policy_uri"]
|
|
129
|
+
require_pkce = body.key?("require_pkce") ? body["require_pkce"] : body["requirePKCE"]
|
|
130
|
+
require_pkce = true if dynamic_registration && require_pkce.nil?
|
|
131
|
+
|
|
132
|
+
client_type = if unauthenticated && public_client && body["type"] == "web"
|
|
133
|
+
nil
|
|
134
|
+
else
|
|
135
|
+
body["type"] || (public_client ? nil : "web")
|
|
136
|
+
end
|
|
84
137
|
data = {
|
|
85
138
|
"clientId" => client_id,
|
|
86
139
|
"clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
|
|
87
|
-
"
|
|
140
|
+
"public" => public_client,
|
|
141
|
+
"type" => client_type,
|
|
88
142
|
"name" => body["client_name"] || body["name"] || "OAuth Client",
|
|
89
143
|
"icon" => body["logo_uri"],
|
|
90
144
|
"uri" => body["client_uri"],
|
|
145
|
+
"contacts" => Array(body["contacts"]).map(&:to_s),
|
|
146
|
+
"tos" => body["tos_uri"],
|
|
147
|
+
"policy" => body["policy_uri"],
|
|
148
|
+
"softwareId" => body["software_id"] || metadata["software_id"],
|
|
149
|
+
"softwareVersion" => body["software_version"] || metadata["software_version"],
|
|
150
|
+
"softwareStatement" => body["software_statement"] || metadata["software_statement"],
|
|
91
151
|
"redirectUris" => redirects,
|
|
92
152
|
"redirectUrls" => redirects.join(","),
|
|
93
153
|
"postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
|
|
154
|
+
"clientSecretExpiresAt" => admin ? (body["client_secret_expires_at"] || 0) : nil,
|
|
94
155
|
"tokenEndpointAuthMethod" => auth_method,
|
|
95
|
-
"grantTypes" =>
|
|
96
|
-
"responseTypes" =>
|
|
156
|
+
"grantTypes" => grant_types,
|
|
157
|
+
"responseTypes" => response_types,
|
|
97
158
|
"scopes" => scopes,
|
|
98
|
-
"skipConsent" => body["skip_consent"] || body["skipConsent"]
|
|
99
|
-
"
|
|
159
|
+
"skipConsent" => unauthenticated ? false : !!(body["skip_consent"] || body["skipConsent"]),
|
|
160
|
+
"enableEndSession" => !!(body["enable_end_session"] || body["enableEndSession"]),
|
|
161
|
+
"requirePKCE" => require_pkce,
|
|
162
|
+
"subjectType" => body["subject_type"] || body["subjectType"],
|
|
163
|
+
"metadata" => metadata,
|
|
100
164
|
"disabled" => false
|
|
101
165
|
}
|
|
102
166
|
data["userId"] = owner_session[:user]["id"] if owner_session
|
|
103
167
|
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
|
|
168
|
+
response = client_response(created).merge(
|
|
169
|
+
client_secret: client_secret ? apply_prefix(client_secret, prefix, :client_secret) : nil,
|
|
170
|
+
client_id_issued_at: Time.now.to_i
|
|
108
171
|
).compact
|
|
172
|
+
response[:require_pkce] = require_pkce unless require_pkce.nil?
|
|
173
|
+
response[:client_secret_expires_at] = 0 if client_secret
|
|
174
|
+
response
|
|
109
175
|
end
|
|
110
176
|
|
|
111
177
|
def client_response(client, include_secret: true)
|
|
112
178
|
data = stringify_keys(client || {})
|
|
179
|
+
metadata = stringify_keys(data["metadata"] || {})
|
|
113
180
|
response = {
|
|
114
181
|
client_id: data["clientId"],
|
|
115
182
|
client_name: data["name"],
|
|
@@ -120,22 +187,80 @@ module BetterAuth
|
|
|
120
187
|
token_endpoint_auth_method: data["tokenEndpointAuthMethod"] || "client_secret_basic",
|
|
121
188
|
grant_types: data["grantTypes"] || [],
|
|
122
189
|
response_types: data["responseTypes"] || [],
|
|
123
|
-
skip_consent: !!data["skipConsent"],
|
|
124
190
|
scope: scope_string(data["scopes"]),
|
|
125
|
-
|
|
191
|
+
public: !!data["public"],
|
|
192
|
+
type: data["type"],
|
|
193
|
+
user_id: data["userId"],
|
|
194
|
+
reference_id: data["referenceId"],
|
|
195
|
+
require_pkce: data["requirePKCE"],
|
|
196
|
+
subject_type: data["subjectType"],
|
|
197
|
+
metadata: metadata,
|
|
198
|
+
contacts: data["contacts"] || [],
|
|
199
|
+
tos_uri: data["tos"],
|
|
200
|
+
policy_uri: data["policy"],
|
|
201
|
+
software_id: data["softwareId"],
|
|
202
|
+
software_version: data["softwareVersion"],
|
|
203
|
+
software_statement: data["softwareStatement"],
|
|
204
|
+
client_secret_expires_at: data["clientSecretExpiresAt"]
|
|
126
205
|
}
|
|
206
|
+
response[:skip_consent] = true if data["skipConsent"]
|
|
207
|
+
metadata.each { |key, value| response[key.to_sym] = value }
|
|
127
208
|
response[:client_secret] = data["clientSecret"] if include_secret && data["clientSecret"]
|
|
128
|
-
response
|
|
209
|
+
response.compact
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated:, dynamic_registration:)
|
|
213
|
+
public_client = auth_method == "none"
|
|
214
|
+
if dynamic_registration && (body["require_pkce"] == false || body["requirePKCE"] == false)
|
|
215
|
+
raise APIError.new("BAD_REQUEST", message: "pkce is required for registered clients")
|
|
216
|
+
end
|
|
217
|
+
if public_client && grant_types.include?(CLIENT_CREDENTIALS_GRANT)
|
|
218
|
+
raise APIError.new("BAD_REQUEST", message: "public clients cannot use client_credentials")
|
|
219
|
+
end
|
|
220
|
+
if grant_types.include?(AUTH_CODE_GRANT) && !response_types.include?("code")
|
|
221
|
+
raise APIError.new("BAD_REQUEST", message: "authorization_code clients must support code response_type")
|
|
222
|
+
end
|
|
223
|
+
if auth_method != "none" && ["native", "user-agent-based"].include?(body["type"])
|
|
224
|
+
raise APIError.new("BAD_REQUEST", message: "public client types must use token_endpoint_auth_method none")
|
|
225
|
+
end
|
|
226
|
+
if !unauthenticated && auth_method == "none" && body["type"] == "web"
|
|
227
|
+
raise APIError.new("BAD_REQUEST", message: "web clients must be confidential")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_client_metadata_enums!(auth_method, body)
|
|
232
|
+
unless ["client_secret_basic", "client_secret_post", "none"].include?(auth_method)
|
|
233
|
+
raise APIError.new("BAD_REQUEST", message: "invalid token_endpoint_auth_method")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
invalid_grant = Array(body["grant_types"]).map(&:to_s) - [AUTH_CODE_GRANT, CLIENT_CREDENTIALS_GRANT, REFRESH_GRANT]
|
|
237
|
+
raise APIError.new("BAD_REQUEST", message: "invalid grant_types") unless invalid_grant.empty?
|
|
238
|
+
|
|
239
|
+
invalid_response = Array(body["response_types"]).map(&:to_s) - ["code"]
|
|
240
|
+
raise APIError.new("BAD_REQUEST", message: "invalid response_types") unless invalid_response.empty?
|
|
241
|
+
|
|
242
|
+
client_type = body["type"]
|
|
243
|
+
if client_type && !["web", "native", "user-agent-based"].include?(client_type)
|
|
244
|
+
raise APIError.new("BAD_REQUEST", message: "invalid type")
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def validate_admin_only_fields!(body, admin:)
|
|
249
|
+
return if admin
|
|
250
|
+
|
|
251
|
+
%w[client_secret_expires_at clientSecretExpiresAt].each do |key|
|
|
252
|
+
raise APIError.new("BAD_REQUEST", message: "field #{key} is server-only") if body.key?(key)
|
|
253
|
+
end
|
|
129
254
|
end
|
|
130
255
|
|
|
131
256
|
def find_client(ctx, model, client_id)
|
|
132
257
|
ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
|
|
133
258
|
end
|
|
134
259
|
|
|
135
|
-
def authenticate_client!(ctx, model, store_client_secret: "plain")
|
|
260
|
+
def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
|
|
136
261
|
body = stringify_keys(ctx.body || {})
|
|
137
262
|
client_id = body["client_id"]
|
|
138
|
-
client_secret = body["client_secret"]
|
|
263
|
+
client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
|
|
139
264
|
|
|
140
265
|
authorization = ctx.headers["authorization"]
|
|
141
266
|
if authorization.to_s.start_with?("Basic ") && client_id.to_s.empty?
|
|
@@ -146,7 +271,14 @@ module BetterAuth
|
|
|
146
271
|
client = find_client(ctx, model, client_id)
|
|
147
272
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
|
|
148
273
|
|
|
149
|
-
|
|
274
|
+
client_data = stringify_keys(client)
|
|
275
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") if client_data["disabled"]
|
|
276
|
+
|
|
277
|
+
method = client_data["tokenEndpointAuthMethod"] || "client_secret_basic"
|
|
278
|
+
if method == "none"
|
|
279
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client_secret.to_s.empty?
|
|
280
|
+
return client
|
|
281
|
+
end
|
|
150
282
|
if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
|
|
151
283
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
152
284
|
end
|
|
@@ -156,7 +288,7 @@ module BetterAuth
|
|
|
156
288
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
157
289
|
end
|
|
158
290
|
|
|
159
|
-
def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil)
|
|
291
|
+
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
292
|
store[:codes][code] = {
|
|
161
293
|
client_id: client_id,
|
|
162
294
|
redirect_uri: redirect_uri,
|
|
@@ -164,6 +296,9 @@ module BetterAuth
|
|
|
164
296
|
scopes: parse_scopes(scopes),
|
|
165
297
|
code_challenge: code_challenge,
|
|
166
298
|
code_challenge_method: code_challenge_method,
|
|
299
|
+
nonce: nonce,
|
|
300
|
+
reference_id: reference_id,
|
|
301
|
+
auth_time: auth_time || session_auth_time(session),
|
|
167
302
|
expires_at: Time.now + 600
|
|
168
303
|
}
|
|
169
304
|
end
|
|
@@ -182,40 +317,88 @@ module BetterAuth
|
|
|
182
317
|
def verify_pkce!(code_data, verifier)
|
|
183
318
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if verifier.to_s.empty?
|
|
184
319
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
verifier.to_s
|
|
189
|
-
end
|
|
320
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless code_data[:code_challenge_method].to_s == "S256"
|
|
321
|
+
|
|
322
|
+
challenge = Base64.urlsafe_encode64(OpenSSL::Digest.digest("SHA256", verifier.to_s), padding: false)
|
|
190
323
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless challenge == code_data[:code_challenge]
|
|
191
324
|
end
|
|
192
325
|
|
|
193
|
-
def
|
|
326
|
+
def validate_authorize_pkce(client, scopes, code_challenge, code_challenge_method)
|
|
327
|
+
method = code_challenge_method.to_s
|
|
328
|
+
return "code_challenge_method must be S256" if !code_challenge.to_s.empty? && method != "S256"
|
|
329
|
+
|
|
330
|
+
return nil unless pkce_required?(client, scopes)
|
|
331
|
+
return "PKCE is required" if code_challenge.to_s.empty?
|
|
332
|
+
return "code_challenge_method must be S256" if method != "S256"
|
|
333
|
+
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def pkce_required?(client, scopes)
|
|
338
|
+
data = stringify_keys(client)
|
|
339
|
+
return true if parse_scopes(scopes).include?("offline_access")
|
|
340
|
+
return data["requirePKCE"] unless data["requirePKCE"].nil?
|
|
341
|
+
|
|
342
|
+
true
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
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, jwt_access_token: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil)
|
|
194
346
|
data = stringify_keys(session || {})
|
|
195
347
|
user = stringify_keys(data["user"] || data[:user] || {})
|
|
196
348
|
session_data = stringify_keys(data["session"] || data[:session] || {})
|
|
197
349
|
client_data = stringify_keys(client)
|
|
198
|
-
|
|
199
|
-
|
|
350
|
+
subject = subject_identifier(user["id"], client_data, pairwise_secret)
|
|
351
|
+
token_auth_time = auth_time || session_auth_time({"session" => session_data})
|
|
352
|
+
token_reference_id = reference_id || client_data["referenceId"]
|
|
353
|
+
access_token_value = Crypto.random_string(32)
|
|
354
|
+
refresh_token_value = include_refresh ? Crypto.random_string(32) : nil
|
|
355
|
+
refresh_token = refresh_token_value ? apply_prefix(refresh_token_value, prefix, :refresh_token) : nil
|
|
200
356
|
scope = scope_string(scopes)
|
|
201
357
|
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
|
-
|
|
358
|
+
access_token = if jwt_access_token && audience
|
|
359
|
+
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)
|
|
360
|
+
else
|
|
361
|
+
apply_prefix(access_token_value, prefix, :access_token)
|
|
362
|
+
end
|
|
363
|
+
refresh_record = nil
|
|
364
|
+
if refresh_token_value
|
|
365
|
+
refresh_record = {
|
|
366
|
+
"token" => refresh_token_value,
|
|
367
|
+
"clientId" => client_data["clientId"],
|
|
368
|
+
"sessionId" => session_data["id"],
|
|
369
|
+
"userId" => user["id"],
|
|
370
|
+
"referenceId" => token_reference_id,
|
|
371
|
+
"authTime" => token_auth_time,
|
|
372
|
+
"expiresAt" => Time.now + refresh_token_expires_in.to_i,
|
|
373
|
+
"createdAt" => Time.now,
|
|
374
|
+
"revoked" => nil,
|
|
375
|
+
"scopes" => parse_scopes(scope)
|
|
376
|
+
}
|
|
377
|
+
created_refresh = schema_model?(ctx, "oauthRefreshToken") ? ctx.context.adapter.create(model: "oauthRefreshToken", data: refresh_record) : nil
|
|
378
|
+
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
379
|
+
store[:refresh_tokens][refresh_token_value] = refresh_record
|
|
380
|
+
store[:refresh_tokens][refresh_token] = refresh_record
|
|
381
|
+
end
|
|
382
|
+
unless jwt_access_token && audience
|
|
383
|
+
record = {
|
|
384
|
+
"token" => access_token_value,
|
|
385
|
+
"expiresAt" => expires_at,
|
|
386
|
+
"clientId" => client_data["clientId"],
|
|
387
|
+
"userId" => user["id"],
|
|
388
|
+
"subject" => subject,
|
|
389
|
+
"sessionId" => session_data["id"],
|
|
390
|
+
"scopes" => parse_scopes(scope),
|
|
391
|
+
"revoked" => nil,
|
|
392
|
+
"referenceId" => token_reference_id,
|
|
393
|
+
"authTime" => token_auth_time,
|
|
394
|
+
"refreshId" => refresh_record && refresh_record["id"],
|
|
395
|
+
"audience" => audience
|
|
396
|
+
}
|
|
397
|
+
ctx.context.adapter.create(model: model, data: record)
|
|
398
|
+
stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
|
|
399
|
+
store[:tokens][access_token_value] = stored_record
|
|
400
|
+
store[:tokens][access_token] = stored_record
|
|
401
|
+
end
|
|
219
402
|
|
|
220
403
|
response = {
|
|
221
404
|
access_token: access_token,
|
|
@@ -223,18 +406,32 @@ module BetterAuth
|
|
|
223
406
|
expires_in: access_token_expires_in.to_i,
|
|
224
407
|
scope: scope
|
|
225
408
|
}
|
|
409
|
+
response[:audience] = audience if audience
|
|
226
410
|
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")
|
|
411
|
+
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) if parse_scopes(scope).include?("openid")
|
|
412
|
+
if custom_token_response_fields.respond_to?(:call)
|
|
413
|
+
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"] || {})})
|
|
414
|
+
response.merge!(extra) if extra.is_a?(Hash)
|
|
415
|
+
end
|
|
228
416
|
response
|
|
229
417
|
end
|
|
230
418
|
|
|
231
|
-
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, id_token_signer: nil)
|
|
232
|
-
|
|
419
|
+
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, jwt_access_token: false, pairwise_secret: nil)
|
|
420
|
+
refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
|
|
421
|
+
data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
|
|
233
422
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
|
|
423
|
+
if data["revoked"]
|
|
424
|
+
revoke_refresh_family!(ctx, store, data)
|
|
425
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant")
|
|
426
|
+
end
|
|
427
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data["expiresAt"] && data["expiresAt"] <= Time.now
|
|
428
|
+
|
|
234
429
|
requested = scopes ? parse_scopes(scopes) : data["scopes"]
|
|
235
430
|
unless requested.all? { |scope| data["scopes"].include?(scope) }
|
|
236
431
|
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
237
432
|
end
|
|
433
|
+
data["revoked"] = Time.now
|
|
434
|
+
ctx.context.adapter.update(model: "oauthRefreshToken", where: [{field: "id", value: data["id"]}], update: {revoked: data["revoked"]}) if data["id"] && schema_model?(ctx, "oauthRefreshToken")
|
|
238
435
|
|
|
239
436
|
issue_tokens(
|
|
240
437
|
ctx,
|
|
@@ -246,12 +443,23 @@ module BetterAuth
|
|
|
246
443
|
include_refresh: true,
|
|
247
444
|
issuer: issuer,
|
|
248
445
|
access_token_expires_in: access_token_expires_in,
|
|
249
|
-
|
|
446
|
+
refresh_token_expires_in: refresh_token_expires_in,
|
|
447
|
+
id_token_signer: id_token_signer,
|
|
448
|
+
prefix: prefix,
|
|
449
|
+
audience: audience,
|
|
450
|
+
grant_type: REFRESH_GRANT,
|
|
451
|
+
custom_token_response_fields: custom_token_response_fields,
|
|
452
|
+
custom_access_token_claims: custom_access_token_claims,
|
|
453
|
+
jwt_access_token: jwt_access_token,
|
|
454
|
+
pairwise_secret: pairwise_secret,
|
|
455
|
+
auth_time: data["authTime"],
|
|
456
|
+
reference_id: data["referenceId"]
|
|
250
457
|
)
|
|
251
458
|
end
|
|
252
459
|
|
|
253
|
-
def token_record(store, token)
|
|
254
|
-
|
|
460
|
+
def token_record(store, token, prefix: {})
|
|
461
|
+
token_value = strip_prefix(token, prefix, :access_token)
|
|
462
|
+
data = token_value ? store[:tokens][token_value] : nil
|
|
255
463
|
return nil unless data
|
|
256
464
|
return nil if data["revoked"]
|
|
257
465
|
return nil if data["expiresAt"] && data["expiresAt"] <= Time.now
|
|
@@ -259,26 +467,128 @@ module BetterAuth
|
|
|
259
467
|
data
|
|
260
468
|
end
|
|
261
469
|
|
|
262
|
-
def
|
|
470
|
+
def build_jwt_access_token(ctx, client, user, session, scope, audience, issuer_value, expires_at, custom_claims, reference_id: nil)
|
|
471
|
+
scopes = parse_scopes(scope)
|
|
472
|
+
extra = if custom_claims.respond_to?(:call)
|
|
473
|
+
custom_claims.call({user: user.empty? ? nil : user, scopes: scopes, resource: audience, reference_id: reference_id, metadata: stringify_keys(client["metadata"] || {})})
|
|
474
|
+
end
|
|
475
|
+
payload = (extra.is_a?(Hash) ? stringify_keys(extra) : {}).merge(
|
|
476
|
+
"sub" => user["id"],
|
|
477
|
+
"aud" => audience,
|
|
478
|
+
"azp" => client["clientId"],
|
|
479
|
+
"scope" => scope,
|
|
480
|
+
"sid" => session["id"],
|
|
481
|
+
"iss" => issuer_value,
|
|
482
|
+
"iat" => Time.now.to_i,
|
|
483
|
+
"exp" => expires_at.to_i
|
|
484
|
+
).compact
|
|
485
|
+
::JWT.encode(payload, ctx.context.secret, "HS256")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def userinfo(store, authorization, additional_claim: nil, prefix: {})
|
|
263
489
|
token = authorization.to_s.delete_prefix("Bearer ").strip
|
|
264
|
-
record = token_record(store, token)
|
|
490
|
+
record = token_record(store, token, prefix: prefix)
|
|
265
491
|
raise APIError.new("UNAUTHORIZED", message: "invalid_token") unless record
|
|
266
492
|
user = stringify_keys(record["user"])
|
|
267
|
-
scopes = parse_scopes(record["
|
|
268
|
-
|
|
493
|
+
scopes = parse_scopes(record["scopes"])
|
|
494
|
+
raise APIError.new("FORBIDDEN", message: "openid scope is required") unless scopes.include?("openid")
|
|
495
|
+
|
|
496
|
+
response = {sub: record["subject"] || user["id"]}
|
|
269
497
|
response[:name] = user["name"] if scopes.include?("profile")
|
|
498
|
+
response[:given_name] = user["name"].to_s.split(/\s+/, 2).first if scopes.include?("profile") && user["name"]
|
|
499
|
+
response[:family_name] = user["name"].to_s.split(/\s+/, 2).last if scopes.include?("profile") && user["name"].to_s.include?(" ")
|
|
500
|
+
response[:picture] = user["image"] if scopes.include?("profile") && user["image"]
|
|
270
501
|
if scopes.include?("email")
|
|
271
502
|
response[:email] = user["email"]
|
|
272
503
|
response[:email_verified] = !!user["emailVerified"]
|
|
273
504
|
end
|
|
274
505
|
if additional_claim.respond_to?(:call)
|
|
275
|
-
extra =
|
|
506
|
+
extra = begin
|
|
507
|
+
additional_claim.call({user: user, scopes: scopes, jwt: record, client: stringify_keys(record["client"] || {})})
|
|
508
|
+
rescue ArgumentError
|
|
509
|
+
additional_claim.call(user, scopes, stringify_keys(record["client"] || {}))
|
|
510
|
+
end
|
|
276
511
|
response.merge!(extra) if extra.is_a?(Hash)
|
|
277
512
|
end
|
|
278
513
|
response
|
|
279
514
|
end
|
|
280
515
|
|
|
281
|
-
def
|
|
516
|
+
def find_token_by_hint(store, token, hint, prefix: {})
|
|
517
|
+
access = -> { (value = strip_prefix(token, prefix, :access_token)) && store[:tokens][value] }
|
|
518
|
+
refresh = -> { (value = strip_prefix(token, prefix, :refresh_token)) && store[:refresh_tokens][value] }
|
|
519
|
+
|
|
520
|
+
case hint.to_s
|
|
521
|
+
when "access_token"
|
|
522
|
+
access.call
|
|
523
|
+
when "refresh_token"
|
|
524
|
+
refresh.call
|
|
525
|
+
else
|
|
526
|
+
access.call || refresh.call
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def revoke_refresh_family!(ctx, store, refresh_record)
|
|
531
|
+
client_id = refresh_record["clientId"]
|
|
532
|
+
user_id = refresh_record["userId"]
|
|
533
|
+
store[:refresh_tokens].delete_if { |_token, record| record["clientId"] == client_id && record["userId"] == user_id }
|
|
534
|
+
store[:tokens].delete_if { |_token, record| record["clientId"] == client_id && record["userId"] == user_id }
|
|
535
|
+
if schema_model?(ctx, "oauthRefreshToken")
|
|
536
|
+
refresh_ids = ctx.context.adapter.find_many(
|
|
537
|
+
model: "oauthRefreshToken",
|
|
538
|
+
where: [
|
|
539
|
+
{field: "clientId", value: client_id},
|
|
540
|
+
{field: "userId", value: user_id}
|
|
541
|
+
]
|
|
542
|
+
).map { |entry| stringify_keys(entry)["id"] }
|
|
543
|
+
|
|
544
|
+
ctx.context.adapter.delete_many(
|
|
545
|
+
model: "oauthRefreshToken",
|
|
546
|
+
where: [
|
|
547
|
+
{field: "clientId", value: client_id},
|
|
548
|
+
{field: "userId", value: user_id}
|
|
549
|
+
]
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if schema_model?(ctx, "oauthAccessToken")
|
|
553
|
+
refresh_ids.each do |refresh_id|
|
|
554
|
+
ctx.context.adapter.delete_many(model: "oauthAccessToken", where: [{field: "refreshId", value: refresh_id}])
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def schema_model?(ctx, model)
|
|
561
|
+
Schema.auth_tables(ctx.context.options).key?(model.to_s)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def apply_prefix(value, prefix, kind)
|
|
565
|
+
"#{token_prefix(prefix, kind)}#{value}"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def strip_prefix(value, prefix, kind)
|
|
569
|
+
token = value.to_s
|
|
570
|
+
expected = token_prefix(prefix, kind)
|
|
571
|
+
return token if expected.empty?
|
|
572
|
+
return token.delete_prefix(expected) if token.start_with?(expected)
|
|
573
|
+
|
|
574
|
+
nil
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def token_prefix(prefix, kind)
|
|
578
|
+
data = stringify_keys(prefix || {})
|
|
579
|
+
case kind
|
|
580
|
+
when :access_token
|
|
581
|
+
data["opaque_access_token"] || data["opaqueAccessToken"] || "ba_at_"
|
|
582
|
+
when :refresh_token
|
|
583
|
+
data["refresh_token"] || data["refreshToken"] || "ba_rt_"
|
|
584
|
+
when :client_secret
|
|
585
|
+
data["client_secret"] || data["clientSecret"] || ""
|
|
586
|
+
else
|
|
587
|
+
""
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil, session_id: nil, include_sid: false, nonce: nil, auth_time: nil)
|
|
282
592
|
payload = {
|
|
283
593
|
sub: user["id"],
|
|
284
594
|
iss: issuer_value,
|
|
@@ -287,6 +597,9 @@ module BetterAuth
|
|
|
287
597
|
email_verified: !!user["emailVerified"],
|
|
288
598
|
name: user["name"]
|
|
289
599
|
}
|
|
600
|
+
payload[:sid] = session_id if include_sid && session_id
|
|
601
|
+
payload[:nonce] = nonce if nonce
|
|
602
|
+
payload[:auth_time] = timestamp_seconds(auth_time) if auth_time
|
|
290
603
|
return signer.call(ctx, payload) if signer.respond_to?(:call)
|
|
291
604
|
|
|
292
605
|
Crypto.sign_jwt(
|
|
@@ -296,6 +609,39 @@ module BetterAuth
|
|
|
296
609
|
)
|
|
297
610
|
end
|
|
298
611
|
|
|
612
|
+
def subject_identifier(user_id, client, pairwise_secret)
|
|
613
|
+
data = stringify_keys(client)
|
|
614
|
+
return user_id unless data["subjectType"] == "pairwise" && pairwise_secret && user_id
|
|
615
|
+
|
|
616
|
+
OpenSSL::HMAC.hexdigest("SHA256", pairwise_secret.to_s, "#{sector_identifier(data)}.#{user_id}")
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def sector_identifier(client)
|
|
620
|
+
data = stringify_keys(client)
|
|
621
|
+
uri = client_redirect_uris(data).first
|
|
622
|
+
raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires redirect_uris") if uri.to_s.empty?
|
|
623
|
+
|
|
624
|
+
URI.parse(uri.to_s).host || data["clientId"]
|
|
625
|
+
rescue URI::InvalidURIError
|
|
626
|
+
data["clientId"]
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def session_auth_time(session)
|
|
630
|
+
data = stringify_keys(session || {})
|
|
631
|
+
session_data = stringify_keys(data["session"] || data[:session] || data)
|
|
632
|
+
session_data["createdAt"] || session_data["created_at"]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def timestamp_seconds(value)
|
|
636
|
+
return value.to_i if value.is_a?(Integer)
|
|
637
|
+
return value.to_i if value.is_a?(Float)
|
|
638
|
+
return value.to_i if value.respond_to?(:to_i) && !value.is_a?(String)
|
|
639
|
+
|
|
640
|
+
Time.parse(value.to_s).to_i
|
|
641
|
+
rescue ArgumentError, TypeError
|
|
642
|
+
nil
|
|
643
|
+
end
|
|
644
|
+
|
|
299
645
|
def store_client_secret_value(ctx, secret, mode)
|
|
300
646
|
mode = normalize_secret_storage_mode(mode)
|
|
301
647
|
return Crypto.sha256(secret, encoding: :base64url) if mode == "hashed"
|
|
@@ -370,6 +370,8 @@ module BetterAuth
|
|
|
370
370
|
ctx.context.adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
|
|
371
371
|
end
|
|
372
372
|
updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "accepted"})
|
|
373
|
+
organization = organization_by_id(ctx, invitation["organizationId"])
|
|
374
|
+
run_org_hook(config, :after_accept_invitation, {invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
|
|
373
375
|
ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)})
|
|
374
376
|
end
|
|
375
377
|
end
|
|
@@ -452,8 +454,11 @@ module BetterAuth
|
|
|
452
454
|
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member
|
|
453
455
|
require_org_permission!(ctx, config, session, member["organizationId"], {member: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER"))
|
|
454
456
|
ensure_not_last_owner!(ctx, member)
|
|
457
|
+
organization = organization_by_id(ctx, member["organizationId"])
|
|
458
|
+
user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
|
|
455
459
|
ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
|
|
456
460
|
ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}]) if org_truthy?(config.dig(:teams, :enabled))
|
|
461
|
+
run_org_hook(config, :after_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
|
|
457
462
|
ctx.json({status: true})
|
|
458
463
|
end
|
|
459
464
|
end
|