better_auth 0.1.1 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,694 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "jwt"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+
9
+ module BetterAuth
10
+ module Plugins
11
+ module OAuthProtocol
12
+ AUTH_CODE_GRANT = "authorization_code"
13
+ REFRESH_GRANT = "refresh_token"
14
+ CLIENT_CREDENTIALS_GRANT = "client_credentials"
15
+ DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
16
+
17
+ module_function
18
+
19
+ def parse_scopes(value)
20
+ case value
21
+ when Array
22
+ value.map(&:to_s).reject(&:empty?)
23
+ else
24
+ value.to_s.split(/\s+/).reject(&:empty?)
25
+ end
26
+ end
27
+
28
+ def scope_string(value)
29
+ parse_scopes(value).join(" ")
30
+ end
31
+
32
+ def issuer(ctx)
33
+ ctx.context.options.base_url.to_s.empty? ? origin_for(ctx.context.base_url) : ctx.context.options.base_url
34
+ end
35
+
36
+ def endpoint_base(ctx)
37
+ ctx.context.base_url
38
+ end
39
+
40
+ def origin_for(url)
41
+ uri = URI.parse(url.to_s)
42
+ port = uri.port
43
+ default_port = (uri.scheme == "http" && port == 80) || (uri.scheme == "https" && port == 443)
44
+ default_port ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{port}"
45
+ end
46
+
47
+ def redirect_uri_with_params(uri, params)
48
+ parsed = URI.parse(uri.to_s)
49
+ existing = URI.decode_www_form(parsed.query.to_s)
50
+ params.each { |key, value| existing << [key.to_s, value.to_s] unless value.nil? }
51
+ parsed.query = URI.encode_www_form(existing)
52
+ parsed.to_s
53
+ end
54
+
55
+ def validate_redirect_uri!(client, redirect_uri)
56
+ redirects = client_redirect_uris(client)
57
+ return if redirects.include?(redirect_uri.to_s)
58
+ return if loopback_redirect_match?(redirects, redirect_uri)
59
+
60
+ raise APIError.new("BAD_REQUEST", message: "invalid redirect_uri")
61
+ end
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
+
86
+ def client_redirect_uris(client)
87
+ value = client["redirectUris"] || client["redirectUrls"] || client[:redirect_uris] || client[:redirectUrls]
88
+ return value if value.is_a?(Array)
89
+
90
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
91
+ end
92
+
93
+ def client_logout_redirect_uris(client)
94
+ value = client["postLogoutRedirectUris"] || client[:post_logout_redirect_uris]
95
+ return value if value.is_a?(Array)
96
+
97
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
98
+ end
99
+
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)
101
+ body = stringify_keys(body || {})
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
106
+ public_client = auth_method == "none"
107
+ client_id = Crypto.random_string(32)
108
+ client_secret = public_client ? nil : Crypto.random_string(32)
109
+ redirects = Array(body["redirect_uris"]).map(&:to_s)
110
+ raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
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
+
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
137
+ data = {
138
+ "clientId" => client_id,
139
+ "clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
140
+ "public" => public_client,
141
+ "type" => client_type,
142
+ "name" => body["client_name"] || body["name"] || "OAuth Client",
143
+ "icon" => body["logo_uri"],
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"],
151
+ "redirectUris" => redirects,
152
+ "redirectUrls" => redirects.join(","),
153
+ "postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
154
+ "clientSecretExpiresAt" => admin ? (body["client_secret_expires_at"] || 0) : nil,
155
+ "tokenEndpointAuthMethod" => auth_method,
156
+ "grantTypes" => grant_types,
157
+ "responseTypes" => response_types,
158
+ "scopes" => scopes,
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,
164
+ "disabled" => false
165
+ }
166
+ data["userId"] = owner_session[:user]["id"] if owner_session
167
+ created = ctx.context.adapter.create(model: model, data: data)
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
171
+ ).compact
172
+ response[:require_pkce] = require_pkce unless require_pkce.nil?
173
+ response[:client_secret_expires_at] = 0 if client_secret
174
+ response
175
+ end
176
+
177
+ def client_response(client, include_secret: true)
178
+ data = stringify_keys(client || {})
179
+ metadata = stringify_keys(data["metadata"] || {})
180
+ response = {
181
+ client_id: data["clientId"],
182
+ client_name: data["name"],
183
+ client_uri: data["uri"],
184
+ logo_uri: data["icon"],
185
+ redirect_uris: client_redirect_uris(data),
186
+ post_logout_redirect_uris: client_logout_redirect_uris(data),
187
+ token_endpoint_auth_method: data["tokenEndpointAuthMethod"] || "client_secret_basic",
188
+ grant_types: data["grantTypes"] || [],
189
+ response_types: data["responseTypes"] || [],
190
+ scope: scope_string(data["scopes"]),
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"]
205
+ }
206
+ response[:skip_consent] = true if data["skipConsent"]
207
+ metadata.each { |key, value| response[key.to_sym] = value }
208
+ response[:client_secret] = data["clientSecret"] if include_secret && data["clientSecret"]
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
254
+ end
255
+
256
+ def find_client(ctx, model, client_id)
257
+ ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
258
+ end
259
+
260
+ def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
261
+ body = stringify_keys(ctx.body || {})
262
+ client_id = body["client_id"]
263
+ client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
264
+
265
+ authorization = ctx.headers["authorization"]
266
+ if authorization.to_s.start_with?("Basic ") && client_id.to_s.empty?
267
+ decoded = Base64.decode64(authorization.delete_prefix("Basic "))
268
+ client_id, client_secret = decoded.split(":", 2)
269
+ end
270
+
271
+ client = find_client(ctx, model, client_id)
272
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
273
+
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
282
+ if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
283
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
284
+ end
285
+
286
+ client
287
+ rescue ArgumentError
288
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
289
+ end
290
+
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)
292
+ store[:codes][code] = {
293
+ client_id: client_id,
294
+ redirect_uri: redirect_uri,
295
+ session: session,
296
+ scopes: parse_scopes(scopes),
297
+ code_challenge: code_challenge,
298
+ code_challenge_method: code_challenge_method,
299
+ nonce: nonce,
300
+ reference_id: reference_id,
301
+ auth_time: auth_time || session_auth_time(session),
302
+ expires_at: Time.now + 600
303
+ }
304
+ end
305
+
306
+ def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil)
307
+ data = store[:codes].delete(code.to_s)
308
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
309
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
310
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
311
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:redirect_uri] == redirect_uri.to_s
312
+ verify_pkce!(data, code_verifier) if data[:code_challenge]
313
+
314
+ data
315
+ end
316
+
317
+ def verify_pkce!(code_data, verifier)
318
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") if verifier.to_s.empty?
319
+
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)
323
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless challenge == code_data[:code_challenge]
324
+ end
325
+
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)
346
+ data = stringify_keys(session || {})
347
+ user = stringify_keys(data["user"] || data[:user] || {})
348
+ session_data = stringify_keys(data["session"] || data[:session] || {})
349
+ client_data = stringify_keys(client)
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
356
+ scope = scope_string(scopes)
357
+ expires_at = Time.now + access_token_expires_in.to_i
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
402
+
403
+ response = {
404
+ access_token: access_token,
405
+ token_type: "Bearer",
406
+ expires_in: access_token_expires_in.to_i,
407
+ scope: scope
408
+ }
409
+ response[:audience] = audience if audience
410
+ response[:refresh_token] = refresh_token if refresh_token
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
416
+ response
417
+ end
418
+
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
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
+
429
+ requested = scopes ? parse_scopes(scopes) : data["scopes"]
430
+ unless requested.all? { |scope| data["scopes"].include?(scope) }
431
+ raise APIError.new("BAD_REQUEST", message: "invalid_scope")
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")
435
+
436
+ issue_tokens(
437
+ ctx,
438
+ store,
439
+ model: model,
440
+ client: client,
441
+ session: {"user" => data["user"], "session" => data["session"]},
442
+ scopes: requested,
443
+ include_refresh: true,
444
+ issuer: issuer,
445
+ access_token_expires_in: access_token_expires_in,
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"]
457
+ )
458
+ end
459
+
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
463
+ return nil unless data
464
+ return nil if data["revoked"]
465
+ return nil if data["expiresAt"] && data["expiresAt"] <= Time.now
466
+
467
+ data
468
+ end
469
+
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: {})
489
+ token = authorization.to_s.delete_prefix("Bearer ").strip
490
+ record = token_record(store, token, prefix: prefix)
491
+ raise APIError.new("UNAUTHORIZED", message: "invalid_token") unless record
492
+ user = stringify_keys(record["user"])
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"]}
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"]
501
+ if scopes.include?("email")
502
+ response[:email] = user["email"]
503
+ response[:email_verified] = !!user["emailVerified"]
504
+ end
505
+ if additional_claim.respond_to?(:call)
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
511
+ response.merge!(extra) if extra.is_a?(Hash)
512
+ end
513
+ response
514
+ end
515
+
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)
592
+ payload = {
593
+ sub: user["id"],
594
+ iss: issuer_value,
595
+ aud: audience || client_id,
596
+ email: user["email"],
597
+ email_verified: !!user["emailVerified"],
598
+ name: user["name"]
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
603
+ return signer.call(ctx, payload) if signer.respond_to?(:call)
604
+
605
+ Crypto.sign_jwt(
606
+ payload,
607
+ client_id.to_s.empty? ? "better-auth" : client_id.to_s,
608
+ expires_in: 3600
609
+ )
610
+ end
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
+
645
+ def store_client_secret_value(ctx, secret, mode)
646
+ mode = normalize_secret_storage_mode(mode)
647
+ return Crypto.sha256(secret, encoding: :base64url) if mode == "hashed"
648
+ return Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret) if mode == "encrypted"
649
+
650
+ if mode.is_a?(Hash)
651
+ return mode[:hash].call(secret) if mode[:hash].respond_to?(:call)
652
+ return mode[:encrypt].call(secret) if mode[:encrypt].respond_to?(:call)
653
+ end
654
+
655
+ secret
656
+ end
657
+
658
+ def verify_client_secret(ctx, stored_secret, provided_secret, mode)
659
+ mode = normalize_secret_storage_mode(mode)
660
+ return Crypto.constant_time_compare(Crypto.sha256(provided_secret, encoding: :base64url), stored_secret.to_s) if mode == "hashed"
661
+ return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_secret) == provided_secret.to_s if mode == "encrypted"
662
+
663
+ if mode.is_a?(Hash)
664
+ return mode[:hash].call(provided_secret).to_s == stored_secret.to_s if mode[:hash].respond_to?(:call)
665
+ return mode[:decrypt].call(stored_secret).to_s == provided_secret.to_s if mode[:decrypt].respond_to?(:call)
666
+ end
667
+
668
+ Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
669
+ end
670
+
671
+ def normalize_secret_storage_mode(mode)
672
+ return stringify_keys(mode).transform_keys(&:to_sym) if mode.is_a?(Hash)
673
+
674
+ mode.to_s
675
+ end
676
+
677
+ def stores
678
+ {
679
+ codes: {},
680
+ tokens: {},
681
+ refresh_tokens: {},
682
+ consents: {}
683
+ }
684
+ end
685
+
686
+ def stringify_keys(value)
687
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
688
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
689
+
690
+ value
691
+ end
692
+ end
693
+ end
694
+ end