better_auth 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +10 -7
- data/lib/better_auth/adapters/memory.rb +57 -11
- data/lib/better_auth/adapters/sql.rb +123 -20
- data/lib/better_auth/api.rb +114 -9
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/configuration.rb +97 -7
- data/lib/better_auth/context.rb +165 -12
- data/lib/better_auth/cookies.rb +6 -4
- data/lib/better_auth/core.rb +2 -0
- data/lib/better_auth/crypto/jwe.rb +27 -5
- data/lib/better_auth/crypto.rb +32 -0
- data/lib/better_auth/database_hooks.rb +8 -8
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +92 -5
- data/lib/better_auth/error.rb +8 -1
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugins/admin/schema.rb +2 -2
- data/lib/better_auth/plugins/admin.rb +344 -16
- data/lib/better_auth/plugins/anonymous.rb +37 -3
- data/lib/better_auth/plugins/device_authorization.rb +102 -5
- data/lib/better_auth/plugins/dub.rb +148 -0
- data/lib/better_auth/plugins/email_otp.rb +261 -19
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +67 -35
- data/lib/better_auth/plugins/jwt.rb +37 -4
- data/lib/better_auth/plugins/last_login_method.rb +2 -2
- data/lib/better_auth/plugins/magic_link.rb +66 -3
- data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
- data/lib/better_auth/plugins/mcp/config.rb +51 -0
- data/lib/better_auth/plugins/mcp/consent.rb +31 -0
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
- data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
- data/lib/better_auth/plugins/mcp/registration.rb +31 -0
- data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
- data/lib/better_auth/plugins/mcp/schema.rb +91 -0
- data/lib/better_auth/plugins/mcp/token.rb +108 -0
- data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
- data/lib/better_auth/plugins/mcp.rb +111 -263
- data/lib/better_auth/plugins/multi_session.rb +61 -3
- data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
- data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
- data/lib/better_auth/plugins/oidc_provider.rb +118 -14
- data/lib/better_auth/plugins/one_tap.rb +7 -2
- data/lib/better_auth/plugins/one_time_token.rb +42 -2
- data/lib/better_auth/plugins/open_api.rb +163 -318
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +186 -56
- data/lib/better_auth/plugins/phone_number.rb +141 -6
- data/lib/better_auth/plugins/siwe.rb +69 -3
- data/lib/better_auth/plugins/two_factor.rb +118 -41
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +38 -0
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +220 -42
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +126 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +26 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +70 -4
- data/lib/better_auth/routes/social.rb +132 -7
- data/lib/better_auth/routes/user.rb +228 -20
- data/lib/better_auth/routes/validation.rb +50 -0
- data/lib/better_auth/secret_config.rb +115 -0
- data/lib/better_auth/session.rb +13 -2
- data/lib/better_auth/url_helpers.rb +206 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +12 -0
- metadata +23 -1
|
@@ -53,7 +53,34 @@ module BetterAuth
|
|
|
53
53
|
allowed_media_types: [
|
|
54
54
|
"application/x-www-form-urlencoded",
|
|
55
55
|
"application/json"
|
|
56
|
-
]
|
|
56
|
+
],
|
|
57
|
+
openapi: {
|
|
58
|
+
operationId: "signInUsername",
|
|
59
|
+
description: "Sign in with username and password",
|
|
60
|
+
requestBody: OpenAPI.json_request_body(
|
|
61
|
+
OpenAPI.object_schema(
|
|
62
|
+
{
|
|
63
|
+
username: {type: "string"},
|
|
64
|
+
password: {type: "string"},
|
|
65
|
+
callbackURL: {type: ["string", "null"]},
|
|
66
|
+
rememberMe: {type: ["boolean", "null"]}
|
|
67
|
+
},
|
|
68
|
+
required: ["username", "password"]
|
|
69
|
+
)
|
|
70
|
+
),
|
|
71
|
+
responses: {
|
|
72
|
+
"200" => OpenAPI.json_response(
|
|
73
|
+
"Signed in",
|
|
74
|
+
OpenAPI.object_schema(
|
|
75
|
+
{
|
|
76
|
+
token: {type: "string"},
|
|
77
|
+
user: {type: "object", "$ref": "#/components/schemas/User"}
|
|
78
|
+
},
|
|
79
|
+
required: ["token", "user"]
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
57
84
|
}
|
|
58
85
|
) do |ctx|
|
|
59
86
|
body = normalize_hash(ctx.body)
|
|
@@ -115,7 +142,35 @@ module BetterAuth
|
|
|
115
142
|
end
|
|
116
143
|
|
|
117
144
|
def is_username_available_endpoint(config)
|
|
118
|
-
Endpoint.new(
|
|
145
|
+
Endpoint.new(
|
|
146
|
+
path: "/is-username-available",
|
|
147
|
+
method: "POST",
|
|
148
|
+
metadata: {
|
|
149
|
+
openapi: {
|
|
150
|
+
operationId: "isUsernameAvailable",
|
|
151
|
+
description: "Check whether a username is available",
|
|
152
|
+
requestBody: OpenAPI.json_request_body(
|
|
153
|
+
OpenAPI.object_schema(
|
|
154
|
+
{
|
|
155
|
+
username: {type: "string"}
|
|
156
|
+
},
|
|
157
|
+
required: ["username"]
|
|
158
|
+
)
|
|
159
|
+
),
|
|
160
|
+
responses: {
|
|
161
|
+
"200" => OpenAPI.json_response(
|
|
162
|
+
"Username availability",
|
|
163
|
+
OpenAPI.object_schema(
|
|
164
|
+
{
|
|
165
|
+
available: {type: "boolean"}
|
|
166
|
+
},
|
|
167
|
+
required: ["available"]
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
) do |ctx|
|
|
119
174
|
body = normalize_hash(ctx.body)
|
|
120
175
|
username = body[:username].to_s
|
|
121
176
|
raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty?
|
|
@@ -4,6 +4,9 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module BetterAuth
|
|
6
6
|
class RateLimiter
|
|
7
|
+
MISSING_CLIENT_IP_WARNING = "Rate limiting skipped: could not determine client IP address. " \
|
|
8
|
+
"Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed."
|
|
9
|
+
|
|
7
10
|
class MemoryStore
|
|
8
11
|
def initialize
|
|
9
12
|
@entries = {}
|
|
@@ -36,6 +39,7 @@ module BetterAuth
|
|
|
36
39
|
|
|
37
40
|
def initialize
|
|
38
41
|
@memory_store = MemoryStore.new
|
|
42
|
+
@warned_missing_client_ip = false
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def call(request, context, path)
|
|
@@ -43,6 +47,7 @@ module BetterAuth
|
|
|
43
47
|
return unless config[:enabled]
|
|
44
48
|
|
|
45
49
|
ip = client_ip(request, context.options)
|
|
50
|
+
warn_missing_client_ip(context) unless ip
|
|
46
51
|
return unless ip
|
|
47
52
|
|
|
48
53
|
rule = rate_limit_rule(request, context, config, path)
|
|
@@ -137,6 +142,7 @@ module BetterAuth
|
|
|
137
142
|
|
|
138
143
|
def storage_for(context, config)
|
|
139
144
|
return [:custom, config[:custom_storage]] if config[:custom_storage]
|
|
145
|
+
return [:database, context.internal_adapter.adapter] if config[:storage] == "database"
|
|
140
146
|
|
|
141
147
|
if config[:storage] == "secondary-storage" && context.options.secondary_storage
|
|
142
148
|
return [:secondary, context.options.secondary_storage]
|
|
@@ -146,6 +152,8 @@ module BetterAuth
|
|
|
146
152
|
end
|
|
147
153
|
|
|
148
154
|
def read_storage((type, storage), key)
|
|
155
|
+
return read_database_storage(storage, key) if type == :database
|
|
156
|
+
|
|
149
157
|
data = storage.get(key)
|
|
150
158
|
data = JSON.parse(data) if type == :secondary && data.is_a?(String)
|
|
151
159
|
normalize_rate_limit_data(symbolize_keys(data))
|
|
@@ -154,12 +162,29 @@ module BetterAuth
|
|
|
154
162
|
end
|
|
155
163
|
|
|
156
164
|
def write_storage((type, storage), key, data, ttl:, update:)
|
|
165
|
+
return write_database_storage(storage, key, data) if type == :database
|
|
166
|
+
|
|
157
167
|
value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
|
|
158
168
|
return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
|
|
159
169
|
|
|
160
170
|
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
161
171
|
end
|
|
162
172
|
|
|
173
|
+
def read_database_storage(adapter, key)
|
|
174
|
+
data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
175
|
+
normalize_rate_limit_data(symbolize_keys(data))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def write_database_storage(adapter, key, data)
|
|
179
|
+
value = secondary_storage_data(data)
|
|
180
|
+
existing = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
181
|
+
if existing
|
|
182
|
+
adapter.update(model: "rateLimit", where: [{field: "key", value: key}], update: value)
|
|
183
|
+
else
|
|
184
|
+
adapter.create(model: "rateLimit", data: value)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
163
188
|
def secondary_storage_data(data)
|
|
164
189
|
{
|
|
165
190
|
key: data[:key],
|
|
@@ -213,6 +238,19 @@ module BetterAuth
|
|
|
213
238
|
RequestIP.client_ip(request, options)
|
|
214
239
|
end
|
|
215
240
|
|
|
241
|
+
def warn_missing_client_ip(context)
|
|
242
|
+
return if @warned_missing_client_ip
|
|
243
|
+
return if context.options.advanced.dig(:ip_address, :disable_ip_tracking)
|
|
244
|
+
|
|
245
|
+
@warned_missing_client_ip = true
|
|
246
|
+
logger = context.logger
|
|
247
|
+
if logger.respond_to?(:call)
|
|
248
|
+
logger.call(:warn, MISSING_CLIENT_IP_WARNING)
|
|
249
|
+
elsif logger.respond_to?(:warn)
|
|
250
|
+
logger.warn(MISSING_CLIENT_IP_WARNING)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
216
254
|
def matching_plugin_rule(context, path)
|
|
217
255
|
context.options.plugins
|
|
218
256
|
.flat_map { |plugin| Array(plugin[:rate_limit]) }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module RequestState
|
|
5
|
+
THREAD_KEY = :better_auth_request_state_stack
|
|
6
|
+
|
|
7
|
+
State = Struct.new(:ref, :initializer) do
|
|
8
|
+
def get
|
|
9
|
+
store = RequestState.current_store
|
|
10
|
+
store[ref] = initializer.call unless store.key?(ref)
|
|
11
|
+
store[ref]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def set(value)
|
|
15
|
+
RequestState.current_store[ref] = value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def run(store = {}, &block)
|
|
22
|
+
stack.push(store)
|
|
23
|
+
block.call
|
|
24
|
+
ensure
|
|
25
|
+
stack.pop
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def present?
|
|
29
|
+
!stack.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def current_store
|
|
33
|
+
stack.last || raise("No request state found. Please make sure you are calling this function within a `run` callback.")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def define(&initializer)
|
|
37
|
+
State.new(Object.new.freeze, initializer || -> {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stack
|
|
41
|
+
Thread.current[THREAD_KEY] ||= []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
class Response
|
|
7
|
+
attr_reader :status, :headers, :body
|
|
8
|
+
|
|
9
|
+
def initialize(status:, headers:, body:)
|
|
10
|
+
@status = status
|
|
11
|
+
@headers = headers
|
|
12
|
+
@body = body
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_rack(tuple)
|
|
16
|
+
status, headers, body = tuple
|
|
17
|
+
new(status: status, headers: headers, body: body)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_a
|
|
21
|
+
[status, headers, body]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
alias_method :to_ary, :to_a
|
|
25
|
+
|
|
26
|
+
def each(&block)
|
|
27
|
+
to_a.each(&block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def [](index)
|
|
31
|
+
to_a[index]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def first
|
|
35
|
+
status
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def json(**options)
|
|
39
|
+
JSON.parse(body.join, **options)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/better_auth/router.rb
CHANGED
|
@@ -88,6 +88,8 @@ module BetterAuth
|
|
|
88
88
|
error_response(error)
|
|
89
89
|
rescue JSON::ParserError
|
|
90
90
|
error_response(APIError.new("BAD_REQUEST", message: "Invalid JSON body"))
|
|
91
|
+
ensure
|
|
92
|
+
context.clear_runtime! if context.respond_to?(:clear_runtime!)
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
def self.conflicting_methods(entries)
|
|
@@ -360,7 +362,11 @@ module BetterAuth
|
|
|
360
362
|
end
|
|
361
363
|
|
|
362
364
|
def server_only?(endpoint)
|
|
363
|
-
endpoint.metadata[:server_only] ||
|
|
365
|
+
endpoint.metadata[:server_only] ||
|
|
366
|
+
endpoint.metadata[:SERVER_ONLY] ||
|
|
367
|
+
endpoint.metadata["SERVER_ONLY"] ||
|
|
368
|
+
endpoint.metadata[:scope].to_s == "server" ||
|
|
369
|
+
endpoint.metadata["scope"].to_s == "server"
|
|
364
370
|
end
|
|
365
371
|
|
|
366
372
|
def error_response(error, headers: {})
|
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
module BetterAuth
|
|
4
4
|
module Routes
|
|
5
5
|
def self.list_accounts
|
|
6
|
-
Endpoint.new(
|
|
6
|
+
Endpoint.new(
|
|
7
|
+
path: "/list-accounts",
|
|
8
|
+
method: "GET",
|
|
9
|
+
metadata: {
|
|
10
|
+
openapi: {
|
|
11
|
+
operationId: "listUserAccounts",
|
|
12
|
+
description: "List linked accounts for the current user",
|
|
13
|
+
responses: {
|
|
14
|
+
"200" => OpenAPI.json_response(
|
|
15
|
+
"Linked accounts",
|
|
16
|
+
{type: "array", items: {type: "object", "$ref": "#/components/schemas/Account"}}
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
) do |ctx|
|
|
7
22
|
session = current_session(ctx)
|
|
8
23
|
accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).map do |account|
|
|
9
24
|
parsed = Schema.parse_output(ctx.context.options, "account", account)
|
|
@@ -15,8 +30,33 @@ module BetterAuth
|
|
|
15
30
|
end
|
|
16
31
|
|
|
17
32
|
def self.unlink_account
|
|
18
|
-
Endpoint.new(
|
|
19
|
-
|
|
33
|
+
Endpoint.new(
|
|
34
|
+
path: "/unlink-account",
|
|
35
|
+
method: "POST",
|
|
36
|
+
body_schema: request_body_schema(
|
|
37
|
+
required_strings: %w[providerId],
|
|
38
|
+
optional_strings: %w[accountId]
|
|
39
|
+
),
|
|
40
|
+
metadata: {
|
|
41
|
+
openapi: {
|
|
42
|
+
operationId: "unlinkAccount",
|
|
43
|
+
description: "Unlink an account from the current user",
|
|
44
|
+
requestBody: OpenAPI.json_request_body(
|
|
45
|
+
OpenAPI.object_schema(
|
|
46
|
+
{
|
|
47
|
+
providerId: {type: "string"},
|
|
48
|
+
accountId: {type: ["string", "null"]}
|
|
49
|
+
},
|
|
50
|
+
required: ["providerId"]
|
|
51
|
+
)
|
|
52
|
+
),
|
|
53
|
+
responses: {
|
|
54
|
+
"200" => OpenAPI.json_response("Account unlinked", OpenAPI.status_response_schema)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
) do |ctx|
|
|
59
|
+
session = current_session(ctx, sensitive: true, fresh: true)
|
|
20
60
|
body = normalize_hash(ctx.body)
|
|
21
61
|
accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"])
|
|
22
62
|
if accounts.length == 1 && !ctx.context.options.account.dig(:account_linking, :allow_unlinking_all)
|
|
@@ -24,6 +64,8 @@ module BetterAuth
|
|
|
24
64
|
end
|
|
25
65
|
|
|
26
66
|
provider_id = body["providerId"] || body["provider_id"]
|
|
67
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
|
|
68
|
+
|
|
27
69
|
account_id = body["accountId"] || body["account_id"]
|
|
28
70
|
account = accounts.find do |entry|
|
|
29
71
|
entry["providerId"] == provider_id && (account_id.to_s.empty? || entry["accountId"] == account_id)
|
|
@@ -36,44 +78,108 @@ module BetterAuth
|
|
|
36
78
|
end
|
|
37
79
|
|
|
38
80
|
def self.get_access_token
|
|
39
|
-
Endpoint.new(
|
|
81
|
+
Endpoint.new(
|
|
82
|
+
path: "/get-access-token",
|
|
83
|
+
method: "POST",
|
|
84
|
+
body_schema: request_body_schema(
|
|
85
|
+
required_strings: %w[providerId],
|
|
86
|
+
optional_strings: %w[accountId userId]
|
|
87
|
+
),
|
|
88
|
+
metadata: {
|
|
89
|
+
openapi: {
|
|
90
|
+
operationId: "getAccessToken",
|
|
91
|
+
description: "Get an access token for a linked provider account",
|
|
92
|
+
requestBody: OpenAPI.json_request_body(
|
|
93
|
+
OpenAPI.object_schema(
|
|
94
|
+
{
|
|
95
|
+
providerId: {type: "string"},
|
|
96
|
+
accountId: {type: ["string", "null"]},
|
|
97
|
+
userId: {type: ["string", "null"]}
|
|
98
|
+
},
|
|
99
|
+
required: ["providerId"]
|
|
100
|
+
)
|
|
101
|
+
),
|
|
102
|
+
responses: {
|
|
103
|
+
"200" => OpenAPI.json_response(
|
|
104
|
+
"Provider access token",
|
|
105
|
+
OpenAPI.object_schema(
|
|
106
|
+
{
|
|
107
|
+
accessToken: {type: ["string", "null"]},
|
|
108
|
+
accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
|
|
109
|
+
scopes: {type: "array", items: {type: "string"}},
|
|
110
|
+
idToken: {type: ["string", "null"]}
|
|
111
|
+
},
|
|
112
|
+
required: ["scopes"]
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
) do |ctx|
|
|
40
119
|
session = current_session(ctx, allow_nil: true)
|
|
41
120
|
body = normalize_hash(ctx.body)
|
|
42
121
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
43
122
|
raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
|
|
44
123
|
|
|
45
124
|
provider_id = body["providerId"] || body["provider_id"]
|
|
46
|
-
|
|
47
|
-
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
|
|
125
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
|
|
48
126
|
|
|
49
127
|
account_id = body["accountId"] || body["account_id"]
|
|
50
|
-
|
|
51
|
-
raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
|
|
52
|
-
|
|
53
|
-
if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
|
|
54
|
-
tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
|
|
55
|
-
updated = update_account_tokens(ctx, account, tokens)
|
|
56
|
-
account = account.merge(token_hash(tokens))
|
|
57
|
-
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
ctx.json({
|
|
61
|
-
accessToken: oauth_token_value(ctx, account["accessToken"]),
|
|
62
|
-
accessTokenExpiresAt: account["accessTokenExpiresAt"],
|
|
63
|
-
scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
|
|
64
|
-
idToken: account["idToken"]
|
|
65
|
-
})
|
|
128
|
+
ctx.json(access_token_response(ctx, user_id: user_id, provider_id: provider_id, account_id: account_id))
|
|
66
129
|
end
|
|
67
130
|
end
|
|
68
131
|
|
|
69
132
|
def self.refresh_token
|
|
70
|
-
Endpoint.new(
|
|
133
|
+
Endpoint.new(
|
|
134
|
+
path: "/refresh-token",
|
|
135
|
+
method: "POST",
|
|
136
|
+
body_schema: request_body_schema(
|
|
137
|
+
required_strings: %w[providerId],
|
|
138
|
+
optional_strings: %w[accountId userId]
|
|
139
|
+
),
|
|
140
|
+
metadata: {
|
|
141
|
+
openapi: {
|
|
142
|
+
operationId: "refreshToken",
|
|
143
|
+
description: "Refresh an OAuth provider access token",
|
|
144
|
+
requestBody: OpenAPI.json_request_body(
|
|
145
|
+
OpenAPI.object_schema(
|
|
146
|
+
{
|
|
147
|
+
providerId: {type: "string"},
|
|
148
|
+
accountId: {type: ["string", "null"]},
|
|
149
|
+
userId: {type: ["string", "null"]}
|
|
150
|
+
},
|
|
151
|
+
required: ["providerId"]
|
|
152
|
+
)
|
|
153
|
+
),
|
|
154
|
+
responses: {
|
|
155
|
+
"200" => OpenAPI.json_response(
|
|
156
|
+
"Refreshed provider tokens",
|
|
157
|
+
OpenAPI.object_schema(
|
|
158
|
+
{
|
|
159
|
+
accessToken: {type: ["string", "null"]},
|
|
160
|
+
refreshToken: {type: ["string", "null"]},
|
|
161
|
+
accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
|
|
162
|
+
refreshTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
|
|
163
|
+
scope: {type: ["string", "null"]},
|
|
164
|
+
idToken: {type: ["string", "null"]},
|
|
165
|
+
providerId: {type: "string"},
|
|
166
|
+
accountId: {type: "string"}
|
|
167
|
+
},
|
|
168
|
+
required: ["providerId", "accountId"]
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
) do |ctx|
|
|
71
175
|
session = current_session(ctx, allow_nil: true)
|
|
72
176
|
body = normalize_hash(ctx.body)
|
|
73
177
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
74
178
|
raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
|
|
75
179
|
|
|
76
180
|
provider_id = body["providerId"] || body["provider_id"]
|
|
181
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
|
|
182
|
+
|
|
77
183
|
provider = social_provider(ctx.context, provider_id)
|
|
78
184
|
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} not found.") unless provider
|
|
79
185
|
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} does not support token refreshing.") unless provider_callable(provider, :refresh_access_token)
|
|
@@ -84,16 +190,21 @@ module BetterAuth
|
|
|
84
190
|
refresh_token = oauth_token_value(ctx, account["refreshToken"])
|
|
85
191
|
raise APIError.new("BAD_REQUEST", message: "Refresh token not found") if refresh_token.to_s.empty?
|
|
86
192
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
begin
|
|
194
|
+
tokens = call_provider(provider, :refresh_access_token, refresh_token)
|
|
195
|
+
updated = update_account_tokens(ctx, account, tokens)
|
|
196
|
+
values = token_hash(tokens)
|
|
197
|
+
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
198
|
+
rescue => error
|
|
199
|
+
log(ctx.context, :error, "FAILED_TO_REFRESH_ACCESS_TOKEN #{error.message}")
|
|
200
|
+
raise APIError.new("BAD_REQUEST", code: "FAILED_TO_REFRESH_ACCESS_TOKEN", message: "Failed to refresh access token")
|
|
201
|
+
end
|
|
91
202
|
ctx.json({
|
|
92
203
|
accessToken: values["accessToken"],
|
|
93
|
-
refreshToken: values["refreshToken"],
|
|
204
|
+
refreshToken: values["refreshToken"] || refresh_token,
|
|
94
205
|
accessTokenExpiresAt: values["accessTokenExpiresAt"],
|
|
95
|
-
refreshTokenExpiresAt: values["refreshTokenExpiresAt"],
|
|
96
|
-
scope:
|
|
206
|
+
refreshTokenExpiresAt: values["refreshTokenExpiresAt"] || account["refreshTokenExpiresAt"],
|
|
207
|
+
scope: values["scope"] || account["scope"],
|
|
97
208
|
idToken: values["idToken"] || account["idToken"],
|
|
98
209
|
providerId: account["providerId"],
|
|
99
210
|
accountId: account["accountId"]
|
|
@@ -102,31 +213,85 @@ module BetterAuth
|
|
|
102
213
|
end
|
|
103
214
|
|
|
104
215
|
def self.account_info
|
|
105
|
-
Endpoint.new(
|
|
216
|
+
Endpoint.new(
|
|
217
|
+
path: "/account-info",
|
|
218
|
+
method: "GET",
|
|
219
|
+
metadata: {
|
|
220
|
+
openapi: {
|
|
221
|
+
operationId: "accountInfo",
|
|
222
|
+
description: "Get user info from a linked provider account",
|
|
223
|
+
parameters: [
|
|
224
|
+
{
|
|
225
|
+
name: "accountId",
|
|
226
|
+
in: "query",
|
|
227
|
+
required: false,
|
|
228
|
+
schema: {type: "string"}
|
|
229
|
+
}
|
|
230
|
+
],
|
|
231
|
+
responses: {
|
|
232
|
+
"200" => OpenAPI.json_response("Provider user info", {type: "object"})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
) do |ctx|
|
|
106
237
|
session = current_session(ctx)
|
|
107
238
|
account_id = fetch_value(ctx.query, "accountId")
|
|
108
239
|
account = if account_id
|
|
109
240
|
ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
|
|
110
241
|
entry["id"] == account_id || entry["accountId"] == account_id
|
|
111
242
|
end
|
|
243
|
+
else
|
|
244
|
+
account_cookie(ctx, nil, nil, session[:user]["id"])
|
|
112
245
|
end
|
|
113
246
|
raise APIError.new("BAD_REQUEST", message: "Account not found") unless account && account["userId"] == session[:user]["id"]
|
|
114
247
|
|
|
115
248
|
provider = social_provider(ctx.context, account["providerId"])
|
|
116
249
|
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{account["providerId"]} but it is not configured") unless provider
|
|
117
|
-
raise APIError.new("BAD_REQUEST", message: "Access token not found") if account["accessToken"].to_s.empty?
|
|
118
250
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
251
|
+
tokens = access_token_response(
|
|
252
|
+
ctx,
|
|
253
|
+
user_id: session[:user]["id"],
|
|
254
|
+
provider_id: account["providerId"],
|
|
255
|
+
account_id: account["accountId"],
|
|
256
|
+
provider: provider
|
|
257
|
+
)
|
|
258
|
+
raise APIError.new("BAD_REQUEST", message: "Access token not found") if tokens[:accessToken].to_s.empty?
|
|
259
|
+
|
|
260
|
+
info = call_provider(provider, :get_user_info, tokens.merge(access_token: tokens[:accessToken]))
|
|
125
261
|
ctx.json(info)
|
|
126
262
|
end
|
|
127
263
|
end
|
|
128
264
|
|
|
265
|
+
def self.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil)
|
|
266
|
+
provider ||= social_provider(ctx.context, provider_id)
|
|
267
|
+
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
|
|
268
|
+
|
|
269
|
+
account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
|
|
270
|
+
raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
|
|
271
|
+
|
|
272
|
+
if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
|
|
273
|
+
begin
|
|
274
|
+
tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
|
|
275
|
+
updated = update_account_tokens(ctx, account, tokens)
|
|
276
|
+
account = account.merge(token_hash(tokens))
|
|
277
|
+
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
278
|
+
rescue => error
|
|
279
|
+
log(ctx.context, :error, "FAILED_TO_GET_ACCESS_TOKEN #{error.message}")
|
|
280
|
+
raise APIError.new("BAD_REQUEST", code: "FAILED_TO_GET_ACCESS_TOKEN", message: "Failed to get a valid access token")
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
accessToken: oauth_token_value(ctx, account["accessToken"]),
|
|
286
|
+
accessTokenExpiresAt: account["accessTokenExpiresAt"],
|
|
287
|
+
scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
|
|
288
|
+
idToken: account["idToken"]
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
129
292
|
def self.social_provider(context, provider_id)
|
|
293
|
+
return nil if provider_id.to_s.empty?
|
|
294
|
+
|
|
130
295
|
provider = context.social_providers[provider_id.to_sym] || context.social_providers[provider_id.to_s]
|
|
131
296
|
return provider.merge(id: provider_id.to_s) if provider.is_a?(Hash) && !provider.key?(:id) && !provider.key?("id")
|
|
132
297
|
|
|
@@ -143,7 +308,8 @@ module BetterAuth
|
|
|
143
308
|
return nil unless ctx.context.options.account[:store_account_cookie]
|
|
144
309
|
|
|
145
310
|
account = Cookies.get_account_cookie(ctx)
|
|
146
|
-
return nil unless account
|
|
311
|
+
return nil unless account
|
|
312
|
+
return nil if provider_id && account["providerId"] != provider_id
|
|
147
313
|
return nil unless account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id
|
|
148
314
|
return nil unless user_id.to_s.empty? || account["userId"].to_s.empty? || account["userId"] == user_id
|
|
149
315
|
|
|
@@ -167,7 +333,10 @@ module BetterAuth
|
|
|
167
333
|
def self.update_account_tokens(ctx, account, tokens)
|
|
168
334
|
return nil if account["id"].to_s.empty?
|
|
169
335
|
|
|
170
|
-
|
|
336
|
+
data = account_token_update_hash(ctx, tokens)
|
|
337
|
+
return nil if data.empty?
|
|
338
|
+
|
|
339
|
+
ctx.context.internal_adapter.update_account(account["id"], data)
|
|
171
340
|
end
|
|
172
341
|
|
|
173
342
|
def self.token_hash(tokens)
|
|
@@ -183,18 +352,27 @@ module BetterAuth
|
|
|
183
352
|
data
|
|
184
353
|
end
|
|
185
354
|
|
|
355
|
+
def self.account_token_update_hash(ctx, tokens)
|
|
356
|
+
account_storage_fields(token_hash_for_storage(ctx, tokens))
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def self.account_storage_fields(data)
|
|
360
|
+
allowed = %w[accessToken refreshToken idToken accessTokenExpiresAt refreshTokenExpiresAt scope]
|
|
361
|
+
token_hash(data).select { |key, value| allowed.include?(key) && !value.nil? }
|
|
362
|
+
end
|
|
363
|
+
|
|
186
364
|
def self.oauth_token_for_storage(ctx, token)
|
|
187
365
|
return token if token.to_s.empty?
|
|
188
366
|
return token unless ctx.context.options.account[:encrypt_oauth_tokens]
|
|
189
367
|
|
|
190
|
-
Crypto.symmetric_encrypt(key: ctx.context.
|
|
368
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
|
|
191
369
|
end
|
|
192
370
|
|
|
193
371
|
def self.oauth_token_value(ctx, token)
|
|
194
372
|
return token if token.to_s.empty?
|
|
195
373
|
return token unless ctx.context.options.account[:encrypt_oauth_tokens]
|
|
196
374
|
|
|
197
|
-
Crypto.symmetric_decrypt(key: ctx.context.
|
|
375
|
+
Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: token) || token
|
|
198
376
|
end
|
|
199
377
|
|
|
200
378
|
def self.provider_callable(provider, key)
|