better_auth 0.1.1 → 0.2.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +106 -16
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +439 -0
  6. data/lib/better_auth/adapters/memory.rb +232 -0
  7. data/lib/better_auth/adapters/mongodb.rb +369 -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 +425 -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 +210 -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 +129 -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 +348 -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 +990 -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 +215 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +365 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +108 -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 +164 -0
  75. data/lib/better_auth/routes/session.rb +137 -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 +145 -0
  79. data/lib/better_auth/routes/social.rb +188 -0
  80. data/lib/better_auth/routes/user.rb +193 -0
  81. data/lib/better_auth/schema/sql.rb +191 -0
  82. data/lib/better_auth/schema.rb +275 -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 +55 -0
  86. data/lib/better_auth/social_providers/base.rb +67 -0
  87. data/lib/better_auth/social_providers/discord.rb +59 -0
  88. data/lib/better_auth/social_providers/github.rb +59 -0
  89. data/lib/better_auth/social_providers/gitlab.rb +54 -0
  90. data/lib/better_auth/social_providers/google.rb +65 -0
  91. data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
  92. data/lib/better_auth/social_providers.rb +9 -0
  93. data/lib/better_auth/version.rb +1 -1
  94. data/lib/better_auth.rb +87 -2
  95. metadata +218 -21
  96. data/.ruby-version +0 -1
  97. data/.standard.yml +0 -12
  98. data/.vscode/settings.json +0 -22
  99. data/AGENTS.md +0 -50
  100. data/CLAUDE.md +0 -1
  101. data/CODE_OF_CONDUCT.md +0 -173
  102. data/CONTRIBUTING.md +0 -187
  103. data/Gemfile +0 -12
  104. data/Makefile +0 -207
  105. data/Rakefile +0 -25
  106. data/SECURITY.md +0 -28
  107. data/docker-compose.yml +0 -63
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Plugins
9
+ module OAuthProtocol
10
+ AUTH_CODE_GRANT = "authorization_code"
11
+ REFRESH_GRANT = "refresh_token"
12
+ CLIENT_CREDENTIALS_GRANT = "client_credentials"
13
+ DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
14
+
15
+ module_function
16
+
17
+ def parse_scopes(value)
18
+ case value
19
+ when Array
20
+ value.map(&:to_s).reject(&:empty?)
21
+ else
22
+ value.to_s.split(/\s+/).reject(&:empty?)
23
+ end
24
+ end
25
+
26
+ def scope_string(value)
27
+ parse_scopes(value).join(" ")
28
+ end
29
+
30
+ def issuer(ctx)
31
+ ctx.context.options.base_url.to_s.empty? ? origin_for(ctx.context.base_url) : ctx.context.options.base_url
32
+ end
33
+
34
+ def endpoint_base(ctx)
35
+ ctx.context.base_url
36
+ end
37
+
38
+ def origin_for(url)
39
+ uri = URI.parse(url.to_s)
40
+ port = uri.port
41
+ default_port = (uri.scheme == "http" && port == 80) || (uri.scheme == "https" && port == 443)
42
+ default_port ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{port}"
43
+ end
44
+
45
+ def redirect_uri_with_params(uri, params)
46
+ parsed = URI.parse(uri.to_s)
47
+ existing = URI.decode_www_form(parsed.query.to_s)
48
+ params.each { |key, value| existing << [key.to_s, value.to_s] unless value.nil? }
49
+ parsed.query = URI.encode_www_form(existing)
50
+ parsed.to_s
51
+ end
52
+
53
+ def validate_redirect_uri!(client, redirect_uri)
54
+ redirects = client_redirect_uris(client)
55
+ return if redirects.include?(redirect_uri.to_s)
56
+
57
+ raise APIError.new("BAD_REQUEST", message: "invalid redirect_uri")
58
+ end
59
+
60
+ def client_redirect_uris(client)
61
+ value = client["redirectUris"] || client["redirectUrls"] || client[:redirect_uris] || client[:redirectUrls]
62
+ return value if value.is_a?(Array)
63
+
64
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
65
+ end
66
+
67
+ def client_logout_redirect_uris(client)
68
+ value = client["postLogoutRedirectUris"] || client[:post_logout_redirect_uris]
69
+ return value if value.is_a?(Array)
70
+
71
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
72
+ end
73
+
74
+ def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain")
75
+ body = stringify_keys(body || {})
76
+ auth_method = body["token_endpoint_auth_method"] || default_auth_method
77
+ 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))
80
+ redirects = Array(body["redirect_uris"]).map(&:to_s)
81
+ raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
82
+
83
+ scopes = parse_scopes(body["scope"] || body["scopes"])
84
+ data = {
85
+ "clientId" => client_id,
86
+ "clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
87
+ "type" => public_client ? "public" : "web",
88
+ "name" => body["client_name"] || body["name"] || "OAuth Client",
89
+ "icon" => body["logo_uri"],
90
+ "uri" => body["client_uri"],
91
+ "redirectUris" => redirects,
92
+ "redirectUrls" => redirects.join(","),
93
+ "postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
94
+ "tokenEndpointAuthMethod" => auth_method,
95
+ "grantTypes" => Array(body["grant_types"] || [AUTH_CODE_GRANT]),
96
+ "responseTypes" => Array(body["response_types"] || ["code"]),
97
+ "scopes" => scopes,
98
+ "skipConsent" => body["skip_consent"] || body["skipConsent"] || false,
99
+ "metadata" => body["metadata"] || {},
100
+ "disabled" => false
101
+ }
102
+ data["userId"] = owner_session[:user]["id"] if owner_session
103
+ 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
108
+ ).compact
109
+ end
110
+
111
+ def client_response(client, include_secret: true)
112
+ data = stringify_keys(client || {})
113
+ response = {
114
+ client_id: data["clientId"],
115
+ client_name: data["name"],
116
+ client_uri: data["uri"],
117
+ logo_uri: data["icon"],
118
+ redirect_uris: client_redirect_uris(data),
119
+ post_logout_redirect_uris: client_logout_redirect_uris(data),
120
+ token_endpoint_auth_method: data["tokenEndpointAuthMethod"] || "client_secret_basic",
121
+ grant_types: data["grantTypes"] || [],
122
+ response_types: data["responseTypes"] || [],
123
+ skip_consent: !!data["skipConsent"],
124
+ scope: scope_string(data["scopes"]),
125
+ metadata: data["metadata"]
126
+ }
127
+ response[:client_secret] = data["clientSecret"] if include_secret && data["clientSecret"]
128
+ response
129
+ end
130
+
131
+ def find_client(ctx, model, client_id)
132
+ ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
133
+ end
134
+
135
+ def authenticate_client!(ctx, model, store_client_secret: "plain")
136
+ body = stringify_keys(ctx.body || {})
137
+ client_id = body["client_id"]
138
+ client_secret = body["client_secret"]
139
+
140
+ authorization = ctx.headers["authorization"]
141
+ if authorization.to_s.start_with?("Basic ") && client_id.to_s.empty?
142
+ decoded = Base64.decode64(authorization.delete_prefix("Basic "))
143
+ client_id, client_secret = decoded.split(":", 2)
144
+ end
145
+
146
+ client = find_client(ctx, model, client_id)
147
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
148
+
149
+ method = stringify_keys(client)["tokenEndpointAuthMethod"] || "client_secret_basic"
150
+ if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
151
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
152
+ end
153
+
154
+ client
155
+ rescue ArgumentError
156
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
157
+ end
158
+
159
+ def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil)
160
+ store[:codes][code] = {
161
+ client_id: client_id,
162
+ redirect_uri: redirect_uri,
163
+ session: session,
164
+ scopes: parse_scopes(scopes),
165
+ code_challenge: code_challenge,
166
+ code_challenge_method: code_challenge_method,
167
+ expires_at: Time.now + 600
168
+ }
169
+ end
170
+
171
+ def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil)
172
+ data = store[:codes].delete(code.to_s)
173
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
174
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
175
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
176
+ 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]
178
+
179
+ data
180
+ end
181
+
182
+ def verify_pkce!(code_data, verifier)
183
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") if verifier.to_s.empty?
184
+
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
190
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless challenge == code_data[:code_challenge]
191
+ end
192
+
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)
194
+ data = stringify_keys(session || {})
195
+ user = stringify_keys(data["user"] || data[:user] || {})
196
+ session_data = stringify_keys(data["session"] || data[:session] || {})
197
+ 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
200
+ scope = scope_string(scopes)
201
+ 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
219
+
220
+ response = {
221
+ access_token: access_token,
222
+ token_type: "Bearer",
223
+ expires_in: access_token_expires_in.to_i,
224
+ scope: scope
225
+ }
226
+ 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")
228
+ response
229
+ end
230
+
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)
233
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
234
+ requested = scopes ? parse_scopes(scopes) : data["scopes"]
235
+ unless requested.all? { |scope| data["scopes"].include?(scope) }
236
+ raise APIError.new("BAD_REQUEST", message: "invalid_scope")
237
+ end
238
+
239
+ issue_tokens(
240
+ ctx,
241
+ store,
242
+ model: model,
243
+ client: client,
244
+ session: {"user" => data["user"], "session" => data["session"]},
245
+ scopes: requested,
246
+ include_refresh: true,
247
+ issuer: issuer,
248
+ access_token_expires_in: access_token_expires_in,
249
+ id_token_signer: id_token_signer
250
+ )
251
+ end
252
+
253
+ def token_record(store, token)
254
+ data = store[:tokens][token.to_s]
255
+ return nil unless data
256
+ return nil if data["revoked"]
257
+ return nil if data["expiresAt"] && data["expiresAt"] <= Time.now
258
+
259
+ data
260
+ end
261
+
262
+ def userinfo(store, authorization, additional_claim: nil)
263
+ token = authorization.to_s.delete_prefix("Bearer ").strip
264
+ record = token_record(store, token)
265
+ raise APIError.new("UNAUTHORIZED", message: "invalid_token") unless record
266
+ user = stringify_keys(record["user"])
267
+ scopes = parse_scopes(record["scope"] || record["scopes"])
268
+ response = {sub: user["id"]}
269
+ response[:name] = user["name"] if scopes.include?("profile")
270
+ if scopes.include?("email")
271
+ response[:email] = user["email"]
272
+ response[:email_verified] = !!user["emailVerified"]
273
+ end
274
+ if additional_claim.respond_to?(:call)
275
+ extra = additional_claim.call(user, scopes, stringify_keys(record["client"] || {}))
276
+ response.merge!(extra) if extra.is_a?(Hash)
277
+ end
278
+ response
279
+ end
280
+
281
+ def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil)
282
+ payload = {
283
+ sub: user["id"],
284
+ iss: issuer_value,
285
+ aud: audience || client_id,
286
+ email: user["email"],
287
+ email_verified: !!user["emailVerified"],
288
+ name: user["name"]
289
+ }
290
+ return signer.call(ctx, payload) if signer.respond_to?(:call)
291
+
292
+ Crypto.sign_jwt(
293
+ payload,
294
+ client_id.to_s.empty? ? "better-auth" : client_id.to_s,
295
+ expires_in: 3600
296
+ )
297
+ end
298
+
299
+ def store_client_secret_value(ctx, secret, mode)
300
+ mode = normalize_secret_storage_mode(mode)
301
+ return Crypto.sha256(secret, encoding: :base64url) if mode == "hashed"
302
+ return Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret) if mode == "encrypted"
303
+
304
+ if mode.is_a?(Hash)
305
+ return mode[:hash].call(secret) if mode[:hash].respond_to?(:call)
306
+ return mode[:encrypt].call(secret) if mode[:encrypt].respond_to?(:call)
307
+ end
308
+
309
+ secret
310
+ end
311
+
312
+ def verify_client_secret(ctx, stored_secret, provided_secret, mode)
313
+ mode = normalize_secret_storage_mode(mode)
314
+ return Crypto.constant_time_compare(Crypto.sha256(provided_secret, encoding: :base64url), stored_secret.to_s) if mode == "hashed"
315
+ return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_secret) == provided_secret.to_s if mode == "encrypted"
316
+
317
+ if mode.is_a?(Hash)
318
+ return mode[:hash].call(provided_secret).to_s == stored_secret.to_s if mode[:hash].respond_to?(:call)
319
+ return mode[:decrypt].call(stored_secret).to_s == provided_secret.to_s if mode[:decrypt].respond_to?(:call)
320
+ end
321
+
322
+ Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
323
+ end
324
+
325
+ def normalize_secret_storage_mode(mode)
326
+ return stringify_keys(mode).transform_keys(&:to_sym) if mode.is_a?(Hash)
327
+
328
+ mode.to_s
329
+ end
330
+
331
+ def stores
332
+ {
333
+ codes: {},
334
+ tokens: {},
335
+ refresh_tokens: {},
336
+ consents: {}
337
+ }
338
+ end
339
+
340
+ def stringify_keys(value)
341
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
342
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
343
+
344
+ value
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def oauth_provider(*args)
8
+ Kernel.require "better_auth/oauth_provider"
9
+ BetterAuth::Plugins.oauth_provider(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/oauth_provider"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`."
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack/utils"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Plugins
9
+ module_function
10
+
11
+ def oauth_proxy(options = {})
12
+ config = {max_age: 60}.merge(normalize_hash(options))
13
+
14
+ Plugin.new(
15
+ id: "oauth-proxy",
16
+ endpoints: {
17
+ o_auth_proxy: oauth_proxy_endpoint(config)
18
+ },
19
+ hooks: {
20
+ before: [
21
+ {
22
+ matcher: ->(ctx) { oauth_proxy_sign_in_path?(ctx.path) },
23
+ handler: ->(ctx) { oauth_proxy_before_sign_in(ctx, config) }
24
+ },
25
+ {
26
+ matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) },
27
+ handler: ->(ctx) { oauth_proxy_restore_state_package(ctx, config) }
28
+ }
29
+ ],
30
+ after: [
31
+ {
32
+ matcher: ->(ctx) { oauth_proxy_sign_in_path?(ctx.path) },
33
+ handler: ->(ctx) { oauth_proxy_after_sign_in(ctx, config) }
34
+ },
35
+ {
36
+ matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) },
37
+ handler: ->(ctx) { oauth_proxy_after_callback(ctx, config) }
38
+ }
39
+ ]
40
+ },
41
+ options: config
42
+ )
43
+ end
44
+
45
+ def oauth_proxy_endpoint(config)
46
+ Endpoint.new(path: "/oauth-proxy-callback", method: "GET") do |ctx|
47
+ query = normalize_hash(ctx.query)
48
+ callback_url = query[:callback_url] || "/"
49
+ oauth_proxy_validate_callback!(ctx, callback_url)
50
+
51
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: query[:cookies].to_s)
52
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid cookies or secret")) unless decrypted
53
+
54
+ payload = JSON.parse(decrypted)
55
+ cookies = payload["cookies"]
56
+ timestamp = payload["timestamp"]
57
+ unless cookies.is_a?(String) && timestamp.is_a?(Numeric)
58
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload structure"))
59
+ end
60
+
61
+ age = ((Time.now.to_f * 1000) - timestamp.to_f) / 1000
62
+ if age > config[:max_age].to_i || age < -10
63
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Payload expired or invalid"))
64
+ end
65
+
66
+ oauth_proxy_parse_set_cookie(cookies).each do |cookie|
67
+ ctx.set_cookie(cookie[:name], cookie[:value], cookie[:options])
68
+ end
69
+ raise ctx.redirect(callback_url)
70
+ rescue JSON::ParserError
71
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload format"))
72
+ end
73
+ end
74
+
75
+ def oauth_proxy_before_sign_in(ctx, config)
76
+ return if oauth_proxy_skip?(ctx, config)
77
+ return unless ctx.body.is_a?(Hash)
78
+
79
+ original_callback = ctx.body["callbackURL"] || ctx.body["callbackUrl"] || ctx.body["callback_url"] || ctx.body[:callbackURL] || ctx.body[:callback_url] || ctx.context.base_url
80
+ current = oauth_proxy_current_uri(ctx, config)
81
+ callback = "#{oauth_proxy_strip_trailing(current.origin)}#{ctx.context.options.base_path}/oauth-proxy-callback?callbackURL=#{URI.encode_www_form_component(original_callback)}"
82
+ ctx.body = ctx.body.merge("callbackURL" => callback, :callback_url => callback)
83
+ nil
84
+ end
85
+
86
+ def oauth_proxy_restore_state_package(ctx, _config)
87
+ state = fetch_value(ctx.query, "state") || fetch_value(ctx.body, "state")
88
+ return if state.to_s.empty?
89
+
90
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: state.to_s)
91
+ return unless decrypted
92
+
93
+ package = JSON.parse(decrypted)
94
+ return unless package["isOAuthProxy"] && package["state"] && package["stateCookie"]
95
+
96
+ cookie = ctx.context.create_auth_cookie("oauth_state")
97
+ current_cookie = ctx.headers["cookie"].to_s
98
+ restored_cookie = "#{cookie.name}=#{package["stateCookie"]}"
99
+ ctx.headers["cookie"] = current_cookie.empty? ? restored_cookie : "#{current_cookie}; #{restored_cookie}"
100
+ ctx.query = ctx.query.merge(:state => package["state"], "state" => package["state"])
101
+ ctx.body = ctx.body.merge(:state => package["state"], "state" => package["state"]) if ctx.body.is_a?(Hash)
102
+ nil
103
+ rescue JSON::ParserError
104
+ nil
105
+ end
106
+
107
+ def oauth_proxy_after_sign_in(ctx, config)
108
+ return if oauth_proxy_skip?(ctx, config)
109
+ return unless ctx.context.options.account[:store_state_strategy].to_s == "cookie"
110
+ return unless ctx.returned.is_a?(Hash)
111
+
112
+ provider_url = fetch_value(ctx.returned, "url").to_s
113
+ return if provider_url.empty?
114
+
115
+ uri = URI.parse(provider_url)
116
+ params = Rack::Utils.parse_query(uri.query)
117
+ original_state = params["state"]
118
+ return if original_state.to_s.empty?
119
+
120
+ state_cookie = oauth_proxy_state_cookie_value(ctx)
121
+ return if state_cookie.to_s.empty?
122
+
123
+ encrypted_package = Crypto.symmetric_encrypt(
124
+ key: ctx.context.secret,
125
+ data: JSON.generate({
126
+ state: original_state,
127
+ stateCookie: state_cookie,
128
+ isOAuthProxy: true
129
+ })
130
+ )
131
+ params["state"] = encrypted_package
132
+ uri.query = URI.encode_www_form(params)
133
+
134
+ response = ctx.returned.dup
135
+ if response.key?(:url)
136
+ response[:url] = uri.to_s
137
+ else
138
+ response["url"] = uri.to_s
139
+ end
140
+ ctx.returned = response
141
+ ctx.json(response)
142
+ rescue URI::InvalidURIError
143
+ nil
144
+ end
145
+
146
+ def oauth_proxy_after_callback(ctx, config)
147
+ location = ctx.response_headers["location"]
148
+ return unless location.to_s.include?("/oauth-proxy-callback?callbackURL")
149
+ return unless location.to_s.start_with?("http")
150
+
151
+ location_uri = URI.parse(location)
152
+ production = oauth_proxy_production_uri(ctx, config)
153
+ if location_uri.origin == production.origin
154
+ original = Rack::Utils.parse_query(location_uri.query).fetch("callbackURL", nil)
155
+ oauth_proxy_set_location(ctx, original) if original
156
+ return nil
157
+ end
158
+
159
+ set_cookie = ctx.response_headers["set-cookie"]
160
+ return if set_cookie.to_s.empty?
161
+
162
+ encrypted = Crypto.symmetric_encrypt(
163
+ key: ctx.context.secret,
164
+ data: JSON.generate({
165
+ cookies: set_cookie,
166
+ timestamp: (Time.now.to_f * 1000).to_i
167
+ })
168
+ )
169
+ separator = location.include?("?") ? "&" : "?"
170
+ oauth_proxy_set_location(ctx, "#{location}#{separator}cookies=#{URI.encode_www_form_component(encrypted)}")
171
+ nil
172
+ rescue URI::InvalidURIError
173
+ nil
174
+ end
175
+
176
+ def oauth_proxy_state_cookie_value(ctx)
177
+ cookie = ctx.context.create_auth_cookie("oauth_state")
178
+ parsed = oauth_proxy_parse_set_cookie(ctx.response_headers["set-cookie"])
179
+ exact = parsed.find { |entry| entry[:name] == cookie.name || entry[:name] == Cookies.strip_secure_cookie_prefix(cookie.name) }
180
+ exact && exact[:value]
181
+ end
182
+
183
+ def oauth_proxy_sign_in_path?(path)
184
+ path.to_s.start_with?("/sign-in/social", "/sign-in/oauth2")
185
+ end
186
+
187
+ def oauth_proxy_callback_path?(path)
188
+ path.to_s.start_with?("/callback", "/oauth2/callback")
189
+ end
190
+
191
+ def oauth_proxy_skip?(ctx, config)
192
+ current = oauth_proxy_current_uri(ctx, config)
193
+ production = oauth_proxy_production_uri(ctx, config)
194
+ current.origin == production.origin
195
+ rescue URI::InvalidURIError
196
+ false
197
+ end
198
+
199
+ def oauth_proxy_current_uri(ctx, config)
200
+ URI.parse((config[:current_url] || ctx.context.options.base_url || ctx.context.base_url).to_s)
201
+ end
202
+
203
+ def oauth_proxy_production_uri(ctx, config)
204
+ URI.parse((config[:production_url] || ctx.context.options.base_url || ctx.context.base_url).to_s)
205
+ end
206
+
207
+ def oauth_proxy_strip_trailing(value)
208
+ value.to_s.sub(%r{/+\z}, "")
209
+ end
210
+
211
+ def oauth_proxy_validate_callback!(ctx, callback_url)
212
+ return if callback_url.to_s.empty?
213
+ return if ctx.context.trusted_origin?(callback_url.to_s, allow_relative_paths: true)
214
+
215
+ raise APIError.new("FORBIDDEN", message: "Invalid callbackURL")
216
+ end
217
+
218
+ def oauth_proxy_error_url(ctx, message)
219
+ base = ctx.context.options.on_api_error[:error_url] || "#{oauth_proxy_strip_trailing(ctx.context.base_url)}/error"
220
+ uri = URI.parse(base)
221
+ params = URI.decode_www_form(uri.query.to_s)
222
+ params << ["error", message]
223
+ uri.query = URI.encode_www_form(params)
224
+ uri.to_s
225
+ end
226
+
227
+ def oauth_proxy_set_location(ctx, location)
228
+ ctx.set_header("location", location)
229
+ return unless ctx.returned.is_a?(APIError)
230
+
231
+ headers = ctx.returned.headers.merge("location" => location)
232
+ ctx.returned.instance_variable_set(:@headers, headers)
233
+ end
234
+
235
+ def oauth_proxy_parse_set_cookie(header)
236
+ header.to_s.split(/\n|,(?=\s*[^;,]+=)/).filter_map do |line|
237
+ parts = line.strip.split(/;\s*/)
238
+ name, value = parts.shift.to_s.split("=", 2)
239
+ next if name.to_s.empty?
240
+
241
+ options = {}
242
+ parts.each do |part|
243
+ key, option_value = part.split("=", 2)
244
+ case key.to_s.downcase
245
+ when "path" then options[:path] = option_value
246
+ when "expires" then options[:expires] = option_value
247
+ when "samesite" then options[:same_site] = option_value
248
+ when "httponly" then options[:http_only] = true
249
+ when "secure" then options[:secure] = true
250
+ when "max-age" then options[:max_age] = option_value
251
+ end
252
+ end
253
+ {name: Cookies.strip_secure_cookie_prefix(name), value: URI.decode_www_form_component(value.to_s), options: options}
254
+ end
255
+ end
256
+ end
257
+ end