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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +168 -18
  5. data/lib/better_auth/adapters/memory.rb +4 -1
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +17 -1
  8. data/lib/better_auth/api.rb +1 -1
  9. data/lib/better_auth/context.rb +2 -1
  10. data/lib/better_auth/plugin.rb +14 -1
  11. data/lib/better_auth/plugins/oauth_protocol.rb +403 -57
  12. data/lib/better_auth/plugins/organization.rb +5 -0
  13. data/lib/better_auth/rate_limiter.rb +19 -2
  14. data/lib/better_auth/router.rb +14 -1
  15. data/lib/better_auth/routes/email_verification.rb +5 -2
  16. data/lib/better_auth/routes/password.rb +19 -0
  17. data/lib/better_auth/routes/session.rb +27 -4
  18. data/lib/better_auth/routes/sign_in.rb +1 -1
  19. data/lib/better_auth/routes/sign_up.rb +52 -1
  20. data/lib/better_auth/routes/social.rb +201 -22
  21. data/lib/better_auth/routes/user.rb +14 -2
  22. data/lib/better_auth/schema/sql.rb +11 -0
  23. data/lib/better_auth/schema.rb +16 -0
  24. data/lib/better_auth/social_providers/apple.rb +44 -8
  25. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  26. data/lib/better_auth/social_providers/base.rb +262 -4
  27. data/lib/better_auth/social_providers/cognito.rb +32 -0
  28. data/lib/better_auth/social_providers/discord.rb +27 -5
  29. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  30. data/lib/better_auth/social_providers/facebook.rb +35 -0
  31. data/lib/better_auth/social_providers/figma.rb +31 -0
  32. data/lib/better_auth/social_providers/github.rb +21 -6
  33. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  34. data/lib/better_auth/social_providers/google.rb +38 -13
  35. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  36. data/lib/better_auth/social_providers/kakao.rb +32 -0
  37. data/lib/better_auth/social_providers/kick.rb +32 -0
  38. data/lib/better_auth/social_providers/line.rb +33 -0
  39. data/lib/better_auth/social_providers/linear.rb +44 -0
  40. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  41. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  42. data/lib/better_auth/social_providers/naver.rb +31 -0
  43. data/lib/better_auth/social_providers/notion.rb +33 -0
  44. data/lib/better_auth/social_providers/paybin.rb +31 -0
  45. data/lib/better_auth/social_providers/paypal.rb +36 -0
  46. data/lib/better_auth/social_providers/polar.rb +31 -0
  47. data/lib/better_auth/social_providers/railway.rb +49 -0
  48. data/lib/better_auth/social_providers/reddit.rb +32 -0
  49. data/lib/better_auth/social_providers/roblox.rb +31 -0
  50. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  51. data/lib/better_auth/social_providers/slack.rb +30 -0
  52. data/lib/better_auth/social_providers/spotify.rb +31 -0
  53. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  54. data/lib/better_auth/social_providers/twitch.rb +39 -0
  55. data/lib/better_auth/social_providers/twitter.rb +32 -0
  56. data/lib/better_auth/social_providers/vercel.rb +47 -0
  57. data/lib/better_auth/social_providers/vk.rb +34 -0
  58. data/lib/better_auth/social_providers/wechat.rb +104 -0
  59. data/lib/better_auth/social_providers/zoom.rb +31 -0
  60. data/lib/better_auth/social_providers.rb +29 -0
  61. data/lib/better_auth/version.rb +1 -1
  62. data/lib/better_auth.rb +0 -1
  63. 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
- auth_method = body["token_endpoint_auth_method"] || default_auth_method
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 = body["client_id"] || Crypto.random_string(32)
79
- client_secret = public_client ? nil : (body["client_secret"] || Crypto.random_string(32))
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
- "type" => public_client ? "public" : "web",
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" => Array(body["grant_types"] || [AUTH_CODE_GRANT]),
96
- "responseTypes" => Array(body["response_types"] || ["code"]),
156
+ "grantTypes" => grant_types,
157
+ "responseTypes" => response_types,
97
158
  "scopes" => scopes,
98
- "skipConsent" => body["skip_consent"] || body["skipConsent"] || false,
99
- "metadata" => body["metadata"] || {},
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
- metadata: data["metadata"]
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
- method = stringify_keys(client)["tokenEndpointAuthMethod"] || "client_secret_basic"
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
- challenge = if code_data[:code_challenge_method].to_s == "S256"
186
- Base64.urlsafe_encode64(OpenSSL::Digest.digest("SHA256", verifier.to_s), padding: false)
187
- else
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 issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, id_token_signer: nil)
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
- access_token = "ba_at_#{Crypto.random_string(32)}"
199
- refresh_token = include_refresh ? "ba_rt_#{Crypto.random_string(32)}" : nil
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
- record = {
203
- "accessToken" => access_token,
204
- "token" => access_token,
205
- "refreshToken" => refresh_token,
206
- "accessTokenExpiresAt" => expires_at,
207
- "expiresAt" => expires_at,
208
- "clientId" => client_data["clientId"],
209
- "userId" => user["id"],
210
- "sessionId" => session_data["id"],
211
- "scope" => scope,
212
- "scopes" => parse_scopes(scope),
213
- "revoked" => nil
214
- }
215
- ctx.context.adapter.create(model: model, data: record)
216
- stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
217
- store[:tokens][access_token] = stored_record
218
- store[:refresh_tokens][refresh_token] = stored_record if refresh_token
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
- data = store[:refresh_tokens].delete(refresh_token.to_s)
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
- id_token_signer: id_token_signer
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
- data = store[:tokens][token.to_s]
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 userinfo(store, authorization, additional_claim: nil)
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["scope"] || record["scopes"])
268
- response = {sub: user["id"]}
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 = additional_claim.call(user, scopes, stringify_keys(record["client"] || {}))
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 id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil)
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