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
|
@@ -52,19 +52,7 @@ module BetterAuth
|
|
|
52
52
|
data,
|
|
53
53
|
provider_id: "auth0",
|
|
54
54
|
discovery_url: "https://#{domain}/.well-known/openid-configuration",
|
|
55
|
-
scopes: ["openid", "profile", "email"]
|
|
56
|
-
get_user_info: ->(tokens) {
|
|
57
|
-
profile = generic_oauth_fetch_json("https://#{domain}/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
|
|
58
|
-
return nil unless profile
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
id: fetch_value(profile, "sub"),
|
|
62
|
-
name: fetch_value(profile, "name") || fetch_value(profile, "nickname"),
|
|
63
|
-
email: fetch_value(profile, "email"),
|
|
64
|
-
image: fetch_value(profile, "picture"),
|
|
65
|
-
emailVerified: fetch_value(profile, "email_verified") || false
|
|
66
|
-
}
|
|
67
|
-
}
|
|
55
|
+
scopes: ["openid", "profile", "email"]
|
|
68
56
|
)
|
|
69
57
|
end
|
|
70
58
|
|
|
@@ -225,7 +213,19 @@ module BetterAuth
|
|
|
225
213
|
end
|
|
226
214
|
|
|
227
215
|
def sign_in_with_oauth2_endpoint(config)
|
|
228
|
-
Endpoint.new(
|
|
216
|
+
Endpoint.new(
|
|
217
|
+
path: "/sign-in/oauth2",
|
|
218
|
+
method: "POST",
|
|
219
|
+
metadata: {
|
|
220
|
+
openapi: {
|
|
221
|
+
operationId: "signInOAuth2",
|
|
222
|
+
description: "Sign in with OAuth2",
|
|
223
|
+
responses: {
|
|
224
|
+
"200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
) do |ctx|
|
|
229
229
|
body = normalize_hash(ctx.body)
|
|
230
230
|
provider_id = body[:provider_id].to_s
|
|
231
231
|
provider = generic_oauth_provider!(config, provider_id)
|
|
@@ -235,7 +235,19 @@ module BetterAuth
|
|
|
235
235
|
end
|
|
236
236
|
|
|
237
237
|
def o_auth2_link_account_endpoint(config)
|
|
238
|
-
Endpoint.new(
|
|
238
|
+
Endpoint.new(
|
|
239
|
+
path: "/oauth2/link",
|
|
240
|
+
method: "POST",
|
|
241
|
+
metadata: {
|
|
242
|
+
openapi: {
|
|
243
|
+
operationId: "linkOAuth2",
|
|
244
|
+
description: "Link an OAuth2 account to the current user session",
|
|
245
|
+
responses: {
|
|
246
|
+
"200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
) do |ctx|
|
|
239
251
|
session = Routes.current_session(ctx)
|
|
240
252
|
body = normalize_hash(ctx.body)
|
|
241
253
|
provider_id = body[:provider_id].to_s
|
|
@@ -255,8 +267,20 @@ module BetterAuth
|
|
|
255
267
|
def o_auth2_callback_endpoint(config)
|
|
256
268
|
Endpoint.new(
|
|
257
269
|
path: "/oauth2/callback/:providerId",
|
|
258
|
-
method:
|
|
259
|
-
metadata: {
|
|
270
|
+
method: "GET",
|
|
271
|
+
metadata: {
|
|
272
|
+
allowed_media_types: ["application/x-www-form-urlencoded", "application/json"],
|
|
273
|
+
openapi: {
|
|
274
|
+
operationId: "oauth2Callback",
|
|
275
|
+
description: "OAuth2 callback",
|
|
276
|
+
responses: {
|
|
277
|
+
"200" => OpenAPI.json_response(
|
|
278
|
+
"OAuth2 callback",
|
|
279
|
+
OpenAPI.object_schema({url: {type: "string"}}, required: ["url"])
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
260
284
|
) do |ctx|
|
|
261
285
|
query = normalize_hash(ctx.query)
|
|
262
286
|
provider_id = (fetch_value(ctx.params, "providerId") || query[:provider_id]).to_s
|
|
@@ -318,6 +342,16 @@ module BetterAuth
|
|
|
318
342
|
end
|
|
319
343
|
end
|
|
320
344
|
|
|
345
|
+
def generic_oauth_url_response_schema
|
|
346
|
+
OpenAPI.object_schema(
|
|
347
|
+
{
|
|
348
|
+
url: {type: "string"},
|
|
349
|
+
redirect: {type: "boolean"}
|
|
350
|
+
},
|
|
351
|
+
required: ["url", "redirect"]
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
321
355
|
def generic_oauth_authorization_url(ctx, provider, body, link:)
|
|
322
356
|
authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
|
|
323
357
|
token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
|
|
@@ -380,7 +414,7 @@ module BetterAuth
|
|
|
380
414
|
state = Crypto.random_string(32)
|
|
381
415
|
if strategy.to_s == "cookie"
|
|
382
416
|
cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
|
|
383
|
-
encrypted = Crypto.symmetric_encrypt(key: ctx.context.
|
|
417
|
+
encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: JSON.generate(state_data.merge("state" => state)))
|
|
384
418
|
ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
|
|
385
419
|
return state
|
|
386
420
|
end
|
|
@@ -428,7 +462,7 @@ module BetterAuth
|
|
|
428
462
|
end
|
|
429
463
|
|
|
430
464
|
begin
|
|
431
|
-
decrypted = Crypto.symmetric_decrypt(key: ctx.context.
|
|
465
|
+
decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
|
|
432
466
|
unless decrypted
|
|
433
467
|
Cookies.expire_cookie(ctx, cookie)
|
|
434
468
|
raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
|
|
@@ -537,7 +571,7 @@ module BetterAuth
|
|
|
537
571
|
return token if token.to_s.empty?
|
|
538
572
|
return token unless ctx.context.options.account[:encrypt_oauth_tokens]
|
|
539
573
|
|
|
540
|
-
Crypto.symmetric_encrypt(key: ctx.context.
|
|
574
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
|
|
541
575
|
end
|
|
542
576
|
|
|
543
577
|
def generic_oauth_set_account_cookie(ctx, provider_id, account_id, user_id)
|
|
@@ -688,24 +722,12 @@ module BetterAuth
|
|
|
688
722
|
nil
|
|
689
723
|
end
|
|
690
724
|
|
|
691
|
-
def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url,
|
|
725
|
+
def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, _user_info_url)
|
|
692
726
|
generic_oauth_provider_config(
|
|
693
727
|
options,
|
|
694
728
|
provider_id: provider_id,
|
|
695
729
|
discovery_url: discovery_url,
|
|
696
|
-
scopes: ["openid", "profile", "email"]
|
|
697
|
-
get_user_info: ->(tokens) {
|
|
698
|
-
profile = generic_oauth_fetch_json(user_info_url, authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
|
|
699
|
-
return nil unless profile
|
|
700
|
-
|
|
701
|
-
{
|
|
702
|
-
id: fetch_value(profile, "sub"),
|
|
703
|
-
name: fetch_value(profile, "name") || fetch_value(profile, "preferred_username"),
|
|
704
|
-
email: fetch_value(profile, "email"),
|
|
705
|
-
image: fetch_value(profile, "picture"),
|
|
706
|
-
emailVerified: fetch_value(profile, "email_verified") || false
|
|
707
|
-
}
|
|
708
|
-
}
|
|
730
|
+
scopes: ["openid", "profile", "email"]
|
|
709
731
|
)
|
|
710
732
|
end
|
|
711
733
|
|
|
@@ -730,12 +752,22 @@ module BetterAuth
|
|
|
730
752
|
result[provider_id.to_sym] = {
|
|
731
753
|
id: provider_id,
|
|
732
754
|
name: provider_id,
|
|
733
|
-
get_user_info: ->(tokens) {
|
|
755
|
+
get_user_info: ->(tokens) { generic_oauth_provider_user_info(provider, tokens) },
|
|
734
756
|
refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) }
|
|
735
757
|
}
|
|
736
758
|
end
|
|
737
759
|
end
|
|
738
760
|
|
|
761
|
+
def generic_oauth_provider_user_info(provider, tokens)
|
|
762
|
+
user_info = generic_oauth_user_info(provider, tokens)
|
|
763
|
+
return nil unless user_info
|
|
764
|
+
|
|
765
|
+
{
|
|
766
|
+
user: generic_oauth_map_user(provider, user_info),
|
|
767
|
+
data: user_info
|
|
768
|
+
}
|
|
769
|
+
end
|
|
770
|
+
|
|
739
771
|
def generic_oauth_refresh_access_token(ctx, provider, refresh_token)
|
|
740
772
|
token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
|
|
741
773
|
raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?
|
|
@@ -103,7 +103,25 @@ module BetterAuth
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def get_jwks_endpoint(config, path)
|
|
106
|
-
Endpoint.new(
|
|
106
|
+
Endpoint.new(
|
|
107
|
+
path: path,
|
|
108
|
+
method: "GET",
|
|
109
|
+
metadata: {
|
|
110
|
+
openapi: {
|
|
111
|
+
operationId: "getJSONWebKeySet",
|
|
112
|
+
description: "Get the JSON Web Key Set",
|
|
113
|
+
responses: {
|
|
114
|
+
"200" => OpenAPI.json_response(
|
|
115
|
+
"JSON Web Key Set retrieved successfully",
|
|
116
|
+
OpenAPI.object_schema(
|
|
117
|
+
{keys: {type: "array", description: "Array of public JSON Web Keys", items: {type: "object"}}},
|
|
118
|
+
required: ["keys"]
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
) do |ctx|
|
|
107
125
|
raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url)
|
|
108
126
|
|
|
109
127
|
create_jwk(ctx, config) if all_jwks(ctx, config).empty?
|
|
@@ -112,7 +130,22 @@ module BetterAuth
|
|
|
112
130
|
end
|
|
113
131
|
|
|
114
132
|
def get_token_endpoint(config)
|
|
115
|
-
Endpoint.new(
|
|
133
|
+
Endpoint.new(
|
|
134
|
+
path: "/token",
|
|
135
|
+
method: "GET",
|
|
136
|
+
metadata: {
|
|
137
|
+
openapi: {
|
|
138
|
+
operationId: "getJSONWebToken",
|
|
139
|
+
description: "Get a JWT token",
|
|
140
|
+
responses: {
|
|
141
|
+
"200" => OpenAPI.json_response(
|
|
142
|
+
"Success",
|
|
143
|
+
OpenAPI.object_schema({token: {type: "string"}}, required: ["token"])
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
) do |ctx|
|
|
116
149
|
session = Session.find_current(ctx)
|
|
117
150
|
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session
|
|
118
151
|
|
|
@@ -306,12 +339,12 @@ module BetterAuth
|
|
|
306
339
|
def jwk_private_key_for_storage(ctx, private_key, config)
|
|
307
340
|
return private_key if config.dig(:jwks, :disable_private_key_encryption)
|
|
308
341
|
|
|
309
|
-
Crypto.symmetric_encrypt(key: ctx.context.
|
|
342
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: private_key)
|
|
310
343
|
end
|
|
311
344
|
|
|
312
345
|
def jwk_private_key_value(ctx, key, _config)
|
|
313
346
|
value = key["privateKey"]
|
|
314
|
-
Crypto.symmetric_decrypt(key: ctx.context.
|
|
347
|
+
Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: value) || value
|
|
315
348
|
end
|
|
316
349
|
|
|
317
350
|
def jwt_payload_valid?(payload)
|
|
@@ -74,8 +74,8 @@ module BetterAuth
|
|
|
74
74
|
case path
|
|
75
75
|
when "/sign-in/email", "/sign-up/email"
|
|
76
76
|
"email"
|
|
77
|
-
when "/callback/:
|
|
78
|
-
fetch_value(ctx.params, "providerId")
|
|
77
|
+
when "/callback/:id"
|
|
78
|
+
fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")
|
|
79
79
|
when "/oauth2/callback/:providerId"
|
|
80
80
|
fetch_value(ctx.params, "providerId")
|
|
81
81
|
else
|
|
@@ -28,7 +28,32 @@ module BetterAuth
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def sign_in_magic_link_endpoint(config)
|
|
31
|
-
Endpoint.new(
|
|
31
|
+
Endpoint.new(
|
|
32
|
+
path: "/sign-in/magic-link",
|
|
33
|
+
method: "POST",
|
|
34
|
+
metadata: {
|
|
35
|
+
openapi: {
|
|
36
|
+
operationId: "signInMagicLink",
|
|
37
|
+
description: "Send a magic sign-in link",
|
|
38
|
+
requestBody: OpenAPI.json_request_body(
|
|
39
|
+
OpenAPI.object_schema(
|
|
40
|
+
{
|
|
41
|
+
email: {type: "string", description: "The email address to sign in"},
|
|
42
|
+
name: {type: ["string", "null"], description: "The user name to use when creating a new user"},
|
|
43
|
+
callbackURL: {type: ["string", "null"]},
|
|
44
|
+
errorCallbackURL: {type: ["string", "null"]},
|
|
45
|
+
newUserCallbackURL: {type: ["string", "null"]},
|
|
46
|
+
metadata: {type: ["object", "null"]}
|
|
47
|
+
},
|
|
48
|
+
required: ["email"]
|
|
49
|
+
)
|
|
50
|
+
),
|
|
51
|
+
responses: {
|
|
52
|
+
"200" => OpenAPI.json_response("Magic link sent", OpenAPI.status_response_schema)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
) do |ctx|
|
|
32
57
|
body = normalize_hash(ctx.body)
|
|
33
58
|
email = body[:email].to_s.downcase
|
|
34
59
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
|
|
@@ -51,7 +76,44 @@ module BetterAuth
|
|
|
51
76
|
end
|
|
52
77
|
|
|
53
78
|
def magic_link_verify_endpoint(config)
|
|
54
|
-
Endpoint.new(
|
|
79
|
+
Endpoint.new(
|
|
80
|
+
path: "/magic-link/verify",
|
|
81
|
+
method: "GET",
|
|
82
|
+
metadata: {
|
|
83
|
+
openapi: {
|
|
84
|
+
operationId: "magicLinkVerify",
|
|
85
|
+
description: "Verify a magic link token",
|
|
86
|
+
parameters: [
|
|
87
|
+
{
|
|
88
|
+
name: "token",
|
|
89
|
+
in: "query",
|
|
90
|
+
required: true,
|
|
91
|
+
schema: {type: "string"}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "callbackURL",
|
|
95
|
+
in: "query",
|
|
96
|
+
required: false,
|
|
97
|
+
schema: {type: "string"}
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
responses: {
|
|
101
|
+
"200" => OpenAPI.json_response(
|
|
102
|
+
"Magic link verified",
|
|
103
|
+
OpenAPI.object_schema(
|
|
104
|
+
{
|
|
105
|
+
token: {type: "string"},
|
|
106
|
+
user: {type: "object", "$ref": "#/components/schemas/User"},
|
|
107
|
+
session: {type: "object", "$ref": "#/components/schemas/Session"}
|
|
108
|
+
},
|
|
109
|
+
required: ["token", "user", "session"]
|
|
110
|
+
)
|
|
111
|
+
),
|
|
112
|
+
"302" => {description: "Redirects to callback URL when callbackURL is provided"}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
) do |ctx|
|
|
55
117
|
query = normalize_hash(ctx.query)
|
|
56
118
|
token = query[:token].to_s
|
|
57
119
|
callback_url = query[:callback_url] || "/"
|
|
@@ -97,7 +159,8 @@ module BetterAuth
|
|
|
97
159
|
user = ctx.context.internal_adapter.create_user(
|
|
98
160
|
email: email,
|
|
99
161
|
emailVerified: true,
|
|
100
|
-
name: name || ""
|
|
162
|
+
name: name || "",
|
|
163
|
+
context: ctx
|
|
101
164
|
)
|
|
102
165
|
new_user = true
|
|
103
166
|
redirect_with_error.call("failed_to_create_user") unless user
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module MCP
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def authorize(ctx, config)
|
|
9
|
+
set_cors_headers(ctx)
|
|
10
|
+
query = OAuthProtocol.stringify_keys(ctx.query)
|
|
11
|
+
session = Routes.current_session(ctx, allow_nil: true)
|
|
12
|
+
unless session
|
|
13
|
+
ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
|
|
14
|
+
raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
redirect_with_code(ctx, config, query, session)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def restore_login_prompt(ctx, config)
|
|
21
|
+
cookie = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
|
|
22
|
+
return unless cookie
|
|
23
|
+
|
|
24
|
+
session = ctx.context.new_session
|
|
25
|
+
return unless session && session[:session] && ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
|
|
26
|
+
|
|
27
|
+
query = parse_login_prompt(cookie)
|
|
28
|
+
return unless query
|
|
29
|
+
|
|
30
|
+
query["prompt"] = prompt_without_login(query["prompt"]) if query.key?("prompt")
|
|
31
|
+
ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
|
|
32
|
+
ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session)
|
|
33
|
+
[302, ctx.response_headers.merge("location" => authorization_redirect_uri(ctx, config, query, session)), [""]]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def redirect_with_code(ctx, config, query, session)
|
|
37
|
+
raise ctx.redirect(authorization_redirect_uri(ctx, config, query, session))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def authorization_redirect_uri(ctx, config, query, session)
|
|
41
|
+
query = OAuthProtocol.stringify_keys(query)
|
|
42
|
+
prompts = OAuthProtocol.parse_scopes(query["prompt"])
|
|
43
|
+
raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") if query["client_id"].to_s.empty?
|
|
44
|
+
unless query["response_type"]
|
|
45
|
+
raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
|
|
49
|
+
raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client
|
|
50
|
+
OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
|
|
51
|
+
client_data = OAuthProtocol.stringify_keys(client)
|
|
52
|
+
raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"]
|
|
53
|
+
raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type") unless query["response_type"] == "code"
|
|
54
|
+
|
|
55
|
+
scopes = OAuthProtocol.parse_scopes(query["scope"] || "openid")
|
|
56
|
+
allowed_scopes = OAuthProtocol.parse_scopes(client_data["scopes"])
|
|
57
|
+
allowed_scopes = OAuthProtocol.parse_scopes(config[:scopes]) if allowed_scopes.empty?
|
|
58
|
+
invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) && allowed_scopes.include?(scope) }
|
|
59
|
+
unless invalid_scopes.empty?
|
|
60
|
+
raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"]))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
pkce_error = OAuthProtocol.validate_authorize_pkce(client_data, scopes, query["code_challenge"], query["code_challenge_method"])
|
|
64
|
+
if pkce_error
|
|
65
|
+
description = (pkce_error == "PKCE is required") ? "pkce is required" : pkce_error
|
|
66
|
+
raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: description, state: query["state"]))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if prompts.include?("consent")
|
|
70
|
+
consent_code = Crypto.random_string(32)
|
|
71
|
+
config[:store][:consents][consent_code] = {
|
|
72
|
+
query: query,
|
|
73
|
+
session: session,
|
|
74
|
+
client: client,
|
|
75
|
+
scopes: scopes,
|
|
76
|
+
expires_at: Time.now + config[:code_expires_in].to_i
|
|
77
|
+
}
|
|
78
|
+
raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: consent_code, client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes)))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
code = Crypto.random_string(32)
|
|
82
|
+
OAuthProtocol.store_code(
|
|
83
|
+
config[:store],
|
|
84
|
+
code: code,
|
|
85
|
+
client_id: query["client_id"],
|
|
86
|
+
redirect_uri: query["redirect_uri"],
|
|
87
|
+
session: session,
|
|
88
|
+
scopes: scopes,
|
|
89
|
+
code_challenge: query["code_challenge"],
|
|
90
|
+
code_challenge_method: query["code_challenge_method"],
|
|
91
|
+
nonce: query["nonce"],
|
|
92
|
+
reference_id: client_data["referenceId"]
|
|
93
|
+
)
|
|
94
|
+
OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: validate_issuer_url(OAuthProtocol.issuer(ctx)))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prompt_without_login(value)
|
|
98
|
+
prompts = OAuthProtocol.parse_scopes(value)
|
|
99
|
+
prompts.delete("login")
|
|
100
|
+
OAuthProtocol.scope_string(prompts)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_login_prompt(value)
|
|
104
|
+
parsed = JSON.parse(value.to_s)
|
|
105
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module MCP
|
|
6
|
+
DEFAULT_SCOPES = %w[openid profile email offline_access].freeze
|
|
7
|
+
DEFAULT_GRANT_TYPES = [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def normalize_config(options)
|
|
12
|
+
incoming = BetterAuth::Plugins.normalize_hash(options || {})
|
|
13
|
+
oidc = BetterAuth::Plugins.normalize_hash(incoming[:oidc_config] || {})
|
|
14
|
+
base = {
|
|
15
|
+
login_page: "/login",
|
|
16
|
+
consent_page: "/oauth2/consent",
|
|
17
|
+
resource: nil,
|
|
18
|
+
scopes: DEFAULT_SCOPES,
|
|
19
|
+
grant_types: DEFAULT_GRANT_TYPES,
|
|
20
|
+
allow_dynamic_client_registration: true,
|
|
21
|
+
allow_unauthenticated_client_registration: true,
|
|
22
|
+
require_pkce: true,
|
|
23
|
+
code_expires_in: 600,
|
|
24
|
+
access_token_expires_in: 3600,
|
|
25
|
+
refresh_token_expires_in: 604_800,
|
|
26
|
+
m2m_access_token_expires_in: 3600,
|
|
27
|
+
store_client_secret: "plain",
|
|
28
|
+
prefix: {},
|
|
29
|
+
store: OAuthProtocol.stores
|
|
30
|
+
}
|
|
31
|
+
config = base.merge(oidc.except(:metadata)).merge(incoming)
|
|
32
|
+
config[:oidc_config] = oidc
|
|
33
|
+
config[:scopes] = (Array(base[:scopes]) + Array(oidc[:scopes]) + Array(incoming[:scopes])).compact.map(&:to_s).uniq
|
|
34
|
+
config[:grant_types] = Array(config[:grant_types]).map(&:to_s)
|
|
35
|
+
config[:prefix] = BetterAuth::Plugins.normalize_hash(config[:prefix] || {})
|
|
36
|
+
config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def set_cors_headers(ctx)
|
|
40
|
+
ctx.set_header("Access-Control-Allow-Origin", "*")
|
|
41
|
+
ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
42
|
+
ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
43
|
+
ctx.set_header("Access-Control-Max-Age", "86400")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def no_store_headers
|
|
47
|
+
{"Cache-Control" => "no-store", "Pragma" => "no-cache"}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module MCP
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def consent(ctx, config)
|
|
9
|
+
current_session = Routes.current_session(ctx)
|
|
10
|
+
body = OAuthProtocol.stringify_keys(ctx.body)
|
|
11
|
+
pending = config[:store][:consents].delete(body["consent_code"].to_s)
|
|
12
|
+
raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless pending
|
|
13
|
+
raise APIError.new("BAD_REQUEST", message: "expired consent_code") if pending[:expires_at] <= Time.now
|
|
14
|
+
|
|
15
|
+
query = pending[:query]
|
|
16
|
+
if body["accept"] == false || body["accept"].to_s == "false"
|
|
17
|
+
return {redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: validate_issuer_url(OAuthProtocol.issuer(ctx)))}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
granted_scopes = OAuthProtocol.parse_scopes(body["scope"] || body["scopes"])
|
|
21
|
+
granted_scopes = pending[:scopes] if granted_scopes.empty?
|
|
22
|
+
unless granted_scopes.all? { |scope| pending[:scopes].include?(scope) }
|
|
23
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
24
|
+
end
|
|
25
|
+
pending[:session] = current_session if current_session
|
|
26
|
+
query = query.merge("scope" => OAuthProtocol.scope_string(granted_scopes)).except("prompt")
|
|
27
|
+
{redirectURI: authorization_redirect_uri(ctx, config, query, pending[:session])}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module MCP
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def legacy_register_endpoint(config)
|
|
9
|
+
Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
|
|
10
|
+
ctx.json(register_client(ctx, config), status: 201, headers: no_store_headers)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def legacy_authorize_endpoint(config)
|
|
15
|
+
Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
|
|
16
|
+
authorize(ctx, config)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def legacy_token_endpoint(config)
|
|
21
|
+
Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
|
|
22
|
+
ctx.json(token(ctx, config), headers: no_store_headers)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def legacy_userinfo_endpoint(config)
|
|
27
|
+
Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
|
|
28
|
+
ctx.json(userinfo(ctx, config))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def legacy_jwks_endpoint(config)
|
|
33
|
+
Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
|
|
34
|
+
ctx.json(jwks(ctx, config))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Plugins
|
|
7
|
+
module MCP
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate_issuer_url(value)
|
|
11
|
+
uri = URI.parse(value.to_s)
|
|
12
|
+
uri.query = nil
|
|
13
|
+
uri.fragment = nil
|
|
14
|
+
if uri.scheme == "http" && !["localhost", "127.0.0.1", "::1"].include?(uri.hostname || uri.host)
|
|
15
|
+
uri.scheme = "https"
|
|
16
|
+
end
|
|
17
|
+
uri.to_s.sub(%r{/+\z}, "")
|
|
18
|
+
rescue URI::InvalidURIError
|
|
19
|
+
value.to_s.split(/[?#]/).first.sub(%r{/+\z}, "")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def oauth_metadata(ctx, config)
|
|
23
|
+
base = OAuthProtocol.endpoint_base(ctx)
|
|
24
|
+
{
|
|
25
|
+
issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
|
|
26
|
+
authorization_endpoint: "#{base}/oauth2/authorize",
|
|
27
|
+
token_endpoint: "#{base}/oauth2/token",
|
|
28
|
+
userinfo_endpoint: "#{base}/oauth2/userinfo",
|
|
29
|
+
registration_endpoint: "#{base}/oauth2/register",
|
|
30
|
+
introspection_endpoint: "#{base}/oauth2/introspect",
|
|
31
|
+
revocation_endpoint: "#{base}/oauth2/revoke",
|
|
32
|
+
jwks_uri: mcp_jwks_uri(ctx, config),
|
|
33
|
+
scopes_supported: config[:scopes],
|
|
34
|
+
response_types_supported: ["code"],
|
|
35
|
+
response_modes_supported: ["query"],
|
|
36
|
+
grant_types_supported: config[:grant_types],
|
|
37
|
+
subject_types_supported: ["public"],
|
|
38
|
+
id_token_signing_alg_values_supported: mcp_signing_algs(ctx, config),
|
|
39
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"],
|
|
40
|
+
introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
41
|
+
revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
42
|
+
code_challenge_methods_supported: ["S256"],
|
|
43
|
+
authorization_response_iss_parameter_supported: true,
|
|
44
|
+
claims_supported: %w[sub iss aud exp iat sid scope azp email email_verified name picture family_name given_name]
|
|
45
|
+
}.merge(BetterAuth::Plugins.normalize_hash(config.dig(:oidc_config, :metadata) || {}))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def protected_resource_metadata(ctx, config)
|
|
49
|
+
base = OAuthProtocol.endpoint_base(ctx)
|
|
50
|
+
origin = OAuthProtocol.origin_for(base)
|
|
51
|
+
{
|
|
52
|
+
resource: config[:resource] || origin,
|
|
53
|
+
authorization_servers: [origin],
|
|
54
|
+
jwks_uri: mcp_jwks_uri(ctx, config),
|
|
55
|
+
scopes_supported: config[:scopes],
|
|
56
|
+
bearer_methods_supported: ["header"],
|
|
57
|
+
resource_signing_alg_values_supported: mcp_signing_algs(ctx, config)
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mcp_jwks_uri(ctx, config)
|
|
62
|
+
config.dig(:oidc_config, :metadata, :jwks_uri) ||
|
|
63
|
+
config.dig(:advertised_metadata, :jwks_uri) ||
|
|
64
|
+
"#{OAuthProtocol.endpoint_base(ctx)}/oauth2/jwks"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def mcp_signing_algs(ctx, config)
|
|
68
|
+
jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
|
|
69
|
+
alg = config.dig(:jwt, :jwks, :key_pair_config, :alg) ||
|
|
70
|
+
jwt_plugin&.options&.dig(:jwks, :key_pair_config, :alg)
|
|
71
|
+
[alg || "EdDSA"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def jwks(ctx, config)
|
|
75
|
+
jwt_config = config[:jwt] || {}
|
|
76
|
+
BetterAuth::Plugins.create_jwk(ctx, jwt_config) if BetterAuth::Plugins.all_jwks(ctx, jwt_config).empty?
|
|
77
|
+
{keys: BetterAuth::Plugins.public_jwks(ctx, jwt_config).map { |key| BetterAuth::Plugins.public_jwk(key, jwt_config) }}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|