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,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Plugins
8
+ module_function
9
+
10
+ DEVICE_AUTHORIZATION_ERROR_CODES = {
11
+ "INVALID_DEVICE_CODE" => "Invalid device code",
12
+ "EXPIRED_DEVICE_CODE" => "Device code has expired",
13
+ "EXPIRED_USER_CODE" => "User code has expired",
14
+ "AUTHORIZATION_PENDING" => "Authorization pending",
15
+ "ACCESS_DENIED" => "Access denied",
16
+ "INVALID_USER_CODE" => "Invalid user code",
17
+ "DEVICE_CODE_ALREADY_PROCESSED" => "Device code already processed",
18
+ "POLLING_TOO_FREQUENTLY" => "Polling too frequently",
19
+ "USER_NOT_FOUND" => "User not found",
20
+ "FAILED_TO_CREATE_SESSION" => "Failed to create session",
21
+ "INVALID_DEVICE_CODE_STATUS" => "Invalid device code status",
22
+ "AUTHENTICATION_REQUIRED" => "Authentication required"
23
+ }.freeze
24
+
25
+ def device_authorization(options = {})
26
+ config = {
27
+ expires_in: "30m",
28
+ interval: "5s",
29
+ device_code_length: 40,
30
+ user_code_length: 8
31
+ }.merge(normalize_hash(options))
32
+ validate_device_authorization_options!(config)
33
+
34
+ Plugin.new(
35
+ id: "device-authorization",
36
+ endpoints: {
37
+ device_code: device_code_endpoint(config),
38
+ device_token: device_token_endpoint(config),
39
+ device_verify: device_verify_endpoint,
40
+ device_approve: device_approve_endpoint,
41
+ device_deny: device_deny_endpoint
42
+ },
43
+ schema: device_authorization_schema(config[:schema]),
44
+ error_codes: DEVICE_AUTHORIZATION_ERROR_CODES,
45
+ options: config
46
+ )
47
+ end
48
+
49
+ def device_code_endpoint(config)
50
+ Endpoint.new(path: "/device/code", method: "POST") do |ctx|
51
+ body = OAuthProtocol.stringify_keys(ctx.body)
52
+ client_id = body["client_id"]
53
+ if config[:validate_client] && !config[:validate_client].call(client_id)
54
+ raise device_authorization_error("BAD_REQUEST", "invalid_client", "Invalid client ID")
55
+ end
56
+
57
+ config[:on_device_auth_request].call(client_id, body["scope"]) if config[:on_device_auth_request].respond_to?(:call)
58
+
59
+ device_code = generate_device_authorization_device_code(config)
60
+ user_code = generate_device_authorization_user_code(config)
61
+ expires_in = duration_seconds(config[:expires_in])
62
+ interval = duration_seconds(config[:interval])
63
+ ctx.context.adapter.create(
64
+ model: "deviceCode",
65
+ data: {
66
+ "deviceCode" => device_code,
67
+ "userCode" => user_code,
68
+ "expiresAt" => Time.now + expires_in,
69
+ "status" => "pending",
70
+ "pollingInterval" => interval * 1000,
71
+ "clientId" => client_id,
72
+ "scope" => body["scope"]
73
+ }
74
+ )
75
+ verification_uri = verification_uri(ctx, config)
76
+ complete = OAuthProtocol.redirect_uri_with_params(verification_uri, user_code: user_code)
77
+ ctx.json({
78
+ device_code: device_code,
79
+ user_code: user_code,
80
+ verification_uri: verification_uri,
81
+ verification_uri_complete: complete,
82
+ expires_in: expires_in,
83
+ interval: interval
84
+ }, headers: {"Cache-Control" => "no-store"})
85
+ end
86
+ end
87
+
88
+ def device_token_endpoint(config)
89
+ Endpoint.new(path: "/device/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
90
+ body = OAuthProtocol.stringify_keys(ctx.body)
91
+ raise device_authorization_error("BAD_REQUEST", "invalid_request", "Unsupported grant type") unless body["grant_type"] == OAuthProtocol::DEVICE_CODE_GRANT
92
+ if config[:validate_client] && !config[:validate_client].call(body["client_id"])
93
+ raise device_authorization_error("BAD_REQUEST", "invalid_grant", "Invalid client ID")
94
+ end
95
+
96
+ record = find_device_code(ctx, body["device_code"])
97
+ raise device_authorization_error("BAD_REQUEST", "invalid_grant", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE"]) unless record
98
+ record = OAuthProtocol.stringify_keys(record)
99
+ if record["clientId"] && record["clientId"] != body["client_id"]
100
+ raise device_authorization_error("BAD_REQUEST", "invalid_grant", "Client ID mismatch")
101
+ end
102
+
103
+ if record["lastPolledAt"] && record["pollingInterval"].to_i.positive?
104
+ elapsed = ((Time.now - device_authorization_time(record["lastPolledAt"])) * 1000).to_i
105
+ raise device_authorization_error("BAD_REQUEST", "slow_down", DEVICE_AUTHORIZATION_ERROR_CODES["POLLING_TOO_FREQUENTLY"]) if elapsed < record["pollingInterval"].to_i
106
+ end
107
+
108
+ ctx.context.adapter.update(model: "deviceCode", where: [{field: "id", value: record["id"]}], update: {"lastPolledAt" => Time.now})
109
+
110
+ if device_authorization_time(record["expiresAt"]) <= Time.now
111
+ ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
112
+ raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_DEVICE_CODE"])
113
+ end
114
+
115
+ case record["status"]
116
+ when "pending"
117
+ raise device_authorization_error("BAD_REQUEST", "authorization_pending", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHORIZATION_PENDING"])
118
+ when "denied"
119
+ ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
120
+ raise device_authorization_error("BAD_REQUEST", "access_denied", DEVICE_AUTHORIZATION_ERROR_CODES["ACCESS_DENIED"])
121
+ when "approved"
122
+ user = ctx.context.internal_adapter.find_user_by_id(record["userId"])
123
+ raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["USER_NOT_FOUND"]) unless user
124
+
125
+ session = ctx.context.internal_adapter.create_session(user["id"])
126
+ raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
127
+
128
+ session_data = {session: session, user: user}
129
+ ctx.context.set_new_session(session_data) if ctx.context.respond_to?(:set_new_session)
130
+ ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
131
+ ctx.json({
132
+ access_token: session["token"],
133
+ token_type: "Bearer",
134
+ expires_in: [session["expiresAt"].to_i - Time.now.to_i, 0].max,
135
+ scope: record["scope"].to_s
136
+ }, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
137
+ else
138
+ raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE_STATUS"])
139
+ end
140
+ end
141
+ end
142
+
143
+ def device_verify_endpoint
144
+ Endpoint.new(path: "/device", method: "GET") do |ctx|
145
+ code = normalize_user_code(OAuthProtocol.stringify_keys(ctx.query)["user_code"])
146
+ record = find_device_user_code(ctx, code)
147
+ raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_USER_CODE"]) unless record
148
+ record = OAuthProtocol.stringify_keys(record)
149
+ raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if device_authorization_time(record["expiresAt"]) <= Time.now
150
+
151
+ ctx.json({user_code: code, status: record["status"]})
152
+ end
153
+ end
154
+
155
+ def device_approve_endpoint
156
+ Endpoint.new(path: "/device/approve", method: "POST") do |ctx|
157
+ session = Routes.current_session(ctx, allow_nil: true)
158
+ raise device_authorization_error("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session
159
+
160
+ process_device_decision(ctx, session, "approved")
161
+ end
162
+ end
163
+
164
+ def device_deny_endpoint
165
+ Endpoint.new(path: "/device/deny", method: "POST") do |ctx|
166
+ session = Routes.current_session(ctx, allow_nil: true)
167
+ raise device_authorization_error("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session
168
+
169
+ process_device_decision(ctx, session, "denied")
170
+ end
171
+ end
172
+
173
+ def process_device_decision(ctx, session, status)
174
+ body = OAuthProtocol.stringify_keys(ctx.body)
175
+ code = normalize_user_code(body["userCode"] || body["user_code"])
176
+ record = find_device_user_code(ctx, code)
177
+ action = (status == "approved") ? "approve" : "deny"
178
+ raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_USER_CODE"]) unless record
179
+ record = OAuthProtocol.stringify_keys(record)
180
+ raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if device_authorization_time(record["expiresAt"]) <= Time.now
181
+ raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless record["status"] == "pending"
182
+ if record["userId"] && record["userId"] != session[:user]["id"]
183
+ raise device_authorization_error("FORBIDDEN", "access_denied", "You are not authorized to #{action} this device authorization")
184
+ end
185
+
186
+ ctx.context.adapter.update(
187
+ model: "deviceCode",
188
+ where: [{field: "id", value: record["id"]}],
189
+ update: {"status" => status, "userId" => record["userId"] || session[:user]["id"]}
190
+ )
191
+ ctx.json({success: true})
192
+ end
193
+
194
+ def find_device_code(ctx, code)
195
+ ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "deviceCode", value: code.to_s}])
196
+ end
197
+
198
+ def find_device_user_code(ctx, code)
199
+ device_authorization_user_code_candidates(code).each do |candidate|
200
+ record = ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "userCode", value: candidate}])
201
+ return record if record
202
+ end
203
+ nil
204
+ end
205
+
206
+ def normalize_user_code(value)
207
+ value.to_s
208
+ end
209
+
210
+ def generate_device_authorization_device_code(config)
211
+ return config[:generate_device_code].call.to_s if config[:generate_device_code].respond_to?(:call)
212
+
213
+ SecureRandom.alphanumeric(config[:device_code_length].to_i)
214
+ end
215
+
216
+ def generate_device_authorization_user_code(config)
217
+ return config[:generate_user_code].call.to_s if config[:generate_user_code].respond_to?(:call)
218
+
219
+ charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
220
+ Array.new(config[:user_code_length].to_i) { charset[SecureRandom.random_number(charset.length)] }.join
221
+ end
222
+
223
+ def verification_uri(ctx, config)
224
+ uri = config[:verification_uri] || "/device"
225
+ return uri if uri.to_s.start_with?("http://", "https://")
226
+
227
+ "#{OAuthProtocol.endpoint_base(ctx)}#{uri.to_s.start_with?("/") ? uri : "/#{uri}"}"
228
+ end
229
+
230
+ def duration_seconds(value)
231
+ return value if value.is_a?(Integer)
232
+
233
+ match = value.to_s.match(/\A(\d+)(ms|s|m|min|h|d)?\z/)
234
+ raise Error, "Invalid time string" unless match
235
+
236
+ amount = match[1].to_i
237
+ case match[2]
238
+ when "ms" then (amount / 1000.0).ceil
239
+ when "m", "min" then amount * 60
240
+ when "h" then amount * 3600
241
+ when "d" then amount * 86_400
242
+ else amount
243
+ end
244
+ end
245
+
246
+ def validate_device_authorization_options!(config)
247
+ duration_seconds(config[:expires_in])
248
+ duration_seconds(config[:interval])
249
+ raise Error, "device_code_length must be a positive integer" unless positive_integer?(config[:device_code_length])
250
+ raise Error, "user_code_length must be a positive integer" unless positive_integer?(config[:user_code_length])
251
+ raise Error, "generate_device_code must be callable" if config.key?(:generate_device_code) && !config[:generate_device_code].respond_to?(:call)
252
+ raise Error, "generate_user_code must be callable" if config.key?(:generate_user_code) && !config[:generate_user_code].respond_to?(:call)
253
+ raise Error, "validate_client must be callable" if config.key?(:validate_client) && !config[:validate_client].respond_to?(:call)
254
+ raise Error, "on_device_auth_request must be callable" if config.key?(:on_device_auth_request) && !config[:on_device_auth_request].respond_to?(:call)
255
+ raise Error, "verification_uri must be a string" if config.key?(:verification_uri) && !config[:verification_uri].is_a?(String)
256
+ end
257
+
258
+ def positive_integer?(value)
259
+ value.is_a?(Integer) && value.positive?
260
+ end
261
+
262
+ def device_authorization_user_code_candidates(value)
263
+ original = value.to_s
264
+ upper = original.upcase
265
+ clean = original.delete("-")
266
+ upper_clean = upper.delete("-")
267
+ dashed = (upper_clean.length == 8) ? "#{upper_clean[0, 4]}-#{upper_clean[4, 4]}" : upper_clean
268
+ [original, upper, clean, upper_clean, dashed].uniq
269
+ end
270
+
271
+ def device_authorization_error(status, error, description)
272
+ APIError.new(status, code: error, message: description, body: {error: error, error_description: description})
273
+ end
274
+
275
+ def device_authorization_time(value)
276
+ return value if value.is_a?(Time)
277
+
278
+ Time.parse(value.to_s)
279
+ end
280
+
281
+ def device_authorization_schema(custom_schema = nil)
282
+ base = {
283
+ deviceCode: {
284
+ fields: {
285
+ deviceCode: {type: "string", required: true},
286
+ userCode: {type: "string", required: true},
287
+ userId: {type: "string", required: false},
288
+ expiresAt: {type: "date", required: true},
289
+ status: {type: "string", required: true},
290
+ lastPolledAt: {type: "date", required: false},
291
+ pollingInterval: {type: "number", required: false},
292
+ clientId: {type: "string", required: false},
293
+ scope: {type: "string", required: false}
294
+ }
295
+ }
296
+ }
297
+ return base unless custom_schema.is_a?(Hash)
298
+
299
+ deep_merge_hashes(base, normalize_hash(custom_schema))
300
+ end
301
+ end
302
+ end