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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +173 -20
  5. data/lib/better_auth/adapters/memory.rb +61 -12
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +44 -3
  8. data/lib/better_auth/api.rb +7 -2
  9. data/lib/better_auth/async.rb +70 -0
  10. data/lib/better_auth/context.rb +2 -1
  11. data/lib/better_auth/database_hooks.rb +3 -3
  12. data/lib/better_auth/deprecate.rb +28 -0
  13. data/lib/better_auth/endpoint.rb +5 -2
  14. data/lib/better_auth/host.rb +166 -0
  15. data/lib/better_auth/instrumentation.rb +74 -0
  16. data/lib/better_auth/logger.rb +31 -0
  17. data/lib/better_auth/middleware/origin_check.rb +2 -2
  18. data/lib/better_auth/oauth2.rb +94 -0
  19. data/lib/better_auth/plugin.rb +14 -1
  20. data/lib/better_auth/plugins/email_otp.rb +16 -5
  21. data/lib/better_auth/plugins/generic_oauth.rb +14 -28
  22. data/lib/better_auth/plugins/oauth_protocol.rb +553 -64
  23. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  24. data/lib/better_auth/plugins/organization.rb +56 -20
  25. data/lib/better_auth/plugins/two_factor.rb +53 -18
  26. data/lib/better_auth/rate_limiter.rb +37 -2
  27. data/lib/better_auth/request_state.rb +44 -0
  28. data/lib/better_auth/router.rb +14 -1
  29. data/lib/better_auth/routes/account.rb +16 -4
  30. data/lib/better_auth/routes/email_verification.rb +5 -2
  31. data/lib/better_auth/routes/password.rb +21 -1
  32. data/lib/better_auth/routes/session.rb +27 -4
  33. data/lib/better_auth/routes/sign_in.rb +3 -1
  34. data/lib/better_auth/routes/sign_up.rb +60 -1
  35. data/lib/better_auth/routes/social.rb +231 -22
  36. data/lib/better_auth/routes/user.rb +23 -5
  37. data/lib/better_auth/schema/sql.rb +11 -0
  38. data/lib/better_auth/schema.rb +16 -0
  39. data/lib/better_auth/session.rb +12 -1
  40. data/lib/better_auth/social_providers/apple.rb +44 -8
  41. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  42. data/lib/better_auth/social_providers/base.rb +262 -4
  43. data/lib/better_auth/social_providers/cognito.rb +32 -0
  44. data/lib/better_auth/social_providers/discord.rb +27 -5
  45. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  46. data/lib/better_auth/social_providers/facebook.rb +35 -0
  47. data/lib/better_auth/social_providers/figma.rb +31 -0
  48. data/lib/better_auth/social_providers/github.rb +21 -6
  49. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  50. data/lib/better_auth/social_providers/google.rb +38 -13
  51. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  52. data/lib/better_auth/social_providers/kakao.rb +32 -0
  53. data/lib/better_auth/social_providers/kick.rb +32 -0
  54. data/lib/better_auth/social_providers/line.rb +33 -0
  55. data/lib/better_auth/social_providers/linear.rb +44 -0
  56. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  57. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  58. data/lib/better_auth/social_providers/naver.rb +31 -0
  59. data/lib/better_auth/social_providers/notion.rb +33 -0
  60. data/lib/better_auth/social_providers/paybin.rb +31 -0
  61. data/lib/better_auth/social_providers/paypal.rb +36 -0
  62. data/lib/better_auth/social_providers/polar.rb +31 -0
  63. data/lib/better_auth/social_providers/railway.rb +49 -0
  64. data/lib/better_auth/social_providers/reddit.rb +32 -0
  65. data/lib/better_auth/social_providers/roblox.rb +31 -0
  66. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  67. data/lib/better_auth/social_providers/slack.rb +30 -0
  68. data/lib/better_auth/social_providers/spotify.rb +31 -0
  69. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  70. data/lib/better_auth/social_providers/twitch.rb +39 -0
  71. data/lib/better_auth/social_providers/twitter.rb +32 -0
  72. data/lib/better_auth/social_providers/vercel.rb +47 -0
  73. data/lib/better_auth/social_providers/vk.rb +34 -0
  74. data/lib/better_auth/social_providers/wechat.rb +104 -0
  75. data/lib/better_auth/social_providers/zoom.rb +31 -0
  76. data/lib/better_auth/social_providers.rb +29 -0
  77. data/lib/better_auth/url_helpers.rb +195 -0
  78. data/lib/better_auth/version.rb +1 -1
  79. data/lib/better_auth.rb +8 -1
  80. 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
- auth_method = body["token_endpoint_auth_method"] || default_auth_method
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 = body["client_id"] || Crypto.random_string(32)
79
- client_secret = public_client ? nil : (body["client_secret"] || Crypto.random_string(32))
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
- "type" => public_client ? "public" : "web",
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" => Array(body["grant_types"] || [AUTH_CODE_GRANT]),
96
- "responseTypes" => Array(body["response_types"] || ["code"]),
161
+ "grantTypes" => grant_types,
162
+ "responseTypes" => response_types,
97
163
  "scopes" => scopes,
98
- "skipConsent" => body["skip_consent"] || body["skipConsent"] || false,
99
- "metadata" => body["metadata"] || {},
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["userId"] = owner_session[:user]["id"] if owner_session
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
- metadata: data["metadata"]
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
- method = stringify_keys(client)["tokenEndpointAuthMethod"] || "client_secret_basic"
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
- verify_pkce!(data, code_verifier) if data[:code_challenge]
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
- 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
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 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)
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
- access_token = "ba_at_#{Crypto.random_string(32)}"
199
- refresh_token = include_refresh ? "ba_rt_#{Crypto.random_string(32)}" : nil
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
- 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
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
- data = store[:refresh_tokens].delete(refresh_token.to_s)
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
- id_token_signer: id_token_signer
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
- data = store[:tokens][token.to_s]
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 userinfo(store, authorization, additional_claim: nil)
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
- raise APIError.new("UNAUTHORIZED", message: "invalid_token") unless record
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["scope"] || record["scopes"])
268
- response = {sub: user["id"]}
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 = additional_claim.call(user, scopes, stringify_keys(record["client"] || {}))
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 id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil)
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"