better_auth-oauth-provider 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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/better_auth/oauth_provider/version.rb +1 -1
  3. data/lib/better_auth/plugins/oauth_provider/authorize.rb +217 -0
  4. data/lib/better_auth/plugins/oauth_provider/client.rb +28 -0
  5. data/lib/better_auth/plugins/oauth_provider/client_resource.rb +42 -0
  6. data/lib/better_auth/plugins/oauth_provider/consent.rb +100 -0
  7. data/lib/better_auth/plugins/oauth_provider/continue.rb +27 -0
  8. data/lib/better_auth/plugins/oauth_provider/introspect.rb +53 -0
  9. data/lib/better_auth/plugins/oauth_provider/logout.rb +45 -0
  10. data/lib/better_auth/plugins/oauth_provider/mcp.rb +66 -0
  11. data/lib/better_auth/plugins/oauth_provider/metadata.rb +97 -0
  12. data/lib/better_auth/plugins/oauth_provider/middleware/index.rb +25 -0
  13. data/lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb +229 -0
  14. data/lib/better_auth/plugins/oauth_provider/oauth_client/index.rb +73 -0
  15. data/lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb +130 -0
  16. data/lib/better_auth/plugins/oauth_provider/oauth_consent/index.rb +28 -0
  17. data/lib/better_auth/plugins/oauth_provider/rate_limit.rb +31 -0
  18. data/lib/better_auth/plugins/oauth_provider/register.rb +41 -0
  19. data/lib/better_auth/plugins/oauth_provider/revoke.rb +24 -0
  20. data/lib/better_auth/plugins/oauth_provider/schema.rb +89 -0
  21. data/lib/better_auth/plugins/oauth_provider/token.rb +108 -0
  22. data/lib/better_auth/plugins/oauth_provider/types/helpers.rb +21 -0
  23. data/lib/better_auth/plugins/oauth_provider/types/index.rb +10 -0
  24. data/lib/better_auth/plugins/oauth_provider/types/oauth.rb +28 -0
  25. data/lib/better_auth/plugins/oauth_provider/types/zod.rb +48 -0
  26. data/lib/better_auth/plugins/oauth_provider/userinfo.rb +13 -0
  27. data/lib/better_auth/plugins/oauth_provider/utils/index.rb +104 -0
  28. data/lib/better_auth/plugins/oauth_provider.rb +25 -1193
  29. metadata +26 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 064ea232e262c58fa331b01d271cacace56d0787690411c0c10509573e97575c
4
- data.tar.gz: fb803328e302b653dcdad4efbf0f40067a798964e0e0a6c3b830933eb297a942
3
+ metadata.gz: 83ab585e58d2f6a3559de17278ee3028ba8294cacc5d53b2dbe0e501e891499f
4
+ data.tar.gz: 11d0780bd5c1e4d87606a0572aca65e7dff9e2389b1ee45e748c4953efd4479d
5
5
  SHA512:
6
- metadata.gz: 531242dbca74547d088b8de6b7181a962e650725b6739743d1ccde257f28357f547b9f6d1c0fce32618dfaa74c94909a5fbeb8b86cb5bcc6e9a32bf585882b14
7
- data.tar.gz: c3d2ad67c5e0ba91433f410accd11575a8e64f4057962c4217dfa591c14f73112fa87a382f5a2009b4427d45a67b964c2339350bcf8fb7d34613062c03e0c6c1
6
+ metadata.gz: cf0c2712de1ac44420fd6a6b15b42c9d825e5f61b6d5e732afe53d73200251533b83a9e1c0b40ecd4dcadab93dcdf014646d851bbfa82119ea9b8d692e56f752
7
+ data.tar.gz: 5d86171dde17750573cc62c6ea7ee0f447cce96673d6af0c4915af8d5db9f93648aeb902ea933517becf170392e16122a6d1d297d404e94867ea22fab0a69e87
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module OAuthProvider
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module OAuthProvider
6
+ module_function
7
+
8
+ def validate_issuer_url(value)
9
+ uri = URI.parse(value.to_s)
10
+ uri.query = nil
11
+ uri.fragment = nil
12
+ if uri.scheme == "http" && !["localhost", "127.0.0.1", "::1"].include?(uri.hostname || uri.host)
13
+ uri.scheme = "https"
14
+ end
15
+ uri.to_s.sub(%r{/+\z}, "")
16
+ rescue URI::InvalidURIError
17
+ value.to_s.split(/[?#]/).first.sub(%r{/+\z}, "")
18
+ end
19
+ end
20
+
21
+ module_function
22
+
23
+ def oauth_authorize_endpoint(config)
24
+ Endpoint.new(path: "/oauth2/authorize", method: "GET") do |ctx|
25
+ oauth_authorize_flow(ctx, config, OAuthProtocol.stringify_keys(ctx.query))
26
+ end
27
+ end
28
+
29
+ def oauth_authorize_flow(ctx, config, query, continue_post_login: false)
30
+ query = oauth_resolve_request_uri!(ctx, config, query)
31
+ response_type = query["response_type"].to_s
32
+
33
+ client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
34
+ raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
35
+ OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
36
+ if response_type != "code"
37
+ raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "unsupported_response_type", "response_type must be code"))
38
+ end
39
+
40
+ scopes = OAuthProtocol.parse_scopes(query["scope"])
41
+ scopes = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["scopes"] || config[:scopes]) if scopes.empty?
42
+ prompts = OAuthProtocol.parse_scopes(query["prompt"])
43
+ client_data = OAuthProtocol.stringify_keys(client)
44
+ if client_data["disabled"]
45
+ raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_client", "client is disabled"))
46
+ end
47
+ allowed_scopes = OAuthProtocol.parse_scopes(client_data["scopes"])
48
+ allowed_scopes = OAuthProtocol.parse_scopes(config[:scopes]) if allowed_scopes.empty?
49
+ unless scopes.all? { |scope| allowed_scopes.include?(scope) }
50
+ raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_scope", "invalid scope"))
51
+ end
52
+ pkce_error = OAuthProtocol.validate_authorize_pkce(client_data, scopes, query["code_challenge"], query["code_challenge_method"])
53
+ raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_request", pkce_error)) if pkce_error
54
+
55
+ session = Routes.current_session(ctx, allow_nil: true)
56
+ unless session
57
+ if prompts.include?("none")
58
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "login_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
59
+ end
60
+
61
+ if prompts.include?("create")
62
+ raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "create"))
63
+ end
64
+
65
+ raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "login"))
66
+ end
67
+
68
+ if prompts.include?("login") && !continue_post_login
69
+ raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "login"))
70
+ end
71
+
72
+ if prompts.include?("select_account") && !continue_post_login
73
+ if prompts.include?("none")
74
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "account_selection_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
75
+ end
76
+
77
+ raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "select_account"))
78
+ end
79
+
80
+ if config.dig(:post_login, :should_redirect).respond_to?(:call) && !continue_post_login
81
+ should_redirect = config.dig(:post_login, :should_redirect).call({user: session[:user], session: session[:session], client: client_data, scopes: scopes})
82
+ if should_redirect
83
+ if prompts.include?("none")
84
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "interaction_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
85
+ end
86
+
87
+ raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "post_login", page: should_redirect.is_a?(String) ? should_redirect : nil))
88
+ end
89
+ end
90
+
91
+ consent_reference_id = oauth_consent_reference(config, session, scopes)
92
+ requires_consent = !client_data["skipConsent"] && (prompts.include?("consent") || !oauth_consent_granted?(ctx, client_data["clientId"], session[:user]["id"], scopes, consent_reference_id))
93
+
94
+ if requires_consent
95
+ if prompts.include?("none")
96
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "consent_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
97
+ end
98
+
99
+ consent_code = Crypto.random_string(32)
100
+ config[:store][:consents][consent_code] = {
101
+ query: query,
102
+ session: session,
103
+ client: client,
104
+ scopes: scopes,
105
+ reference_id: consent_reference_id,
106
+ expires_at: Time.now + 600
107
+ }
108
+ 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)))
109
+ end
110
+
111
+ oauth_redirect_with_code(ctx, config, query, session, client, scopes, reference_id: consent_reference_id)
112
+ end
113
+
114
+ def oauth_prompt_redirect(ctx, config, query, type, page: nil)
115
+ target = page || oauth_prompt_page(config, type)
116
+
117
+ "#{target}?#{oauth_signed_query(ctx, query)}"
118
+ end
119
+
120
+ def oauth_prompt_page(config, type)
121
+ case type
122
+ when "create"
123
+ config.dig(:signup, :page) || config[:login_page]
124
+ when "select_account"
125
+ config.dig(:select_account, :page) || config[:login_page]
126
+ when "post_login"
127
+ config.dig(:post_login, :page) || config[:login_page]
128
+ when "consent"
129
+ config[:consent_page]
130
+ else
131
+ config[:login_page]
132
+ end
133
+ end
134
+
135
+ def oauth_signed_query(ctx, query)
136
+ data = OAuthProtocol.stringify_keys(query).compact
137
+ data["exp"] = (Time.now.to_i + 600).to_s
138
+ unsigned = URI.encode_www_form(data)
139
+ signature = Crypto.hmac_signature(unsigned, ctx.context.secret, encoding: :base64url)
140
+ "#{unsigned}&#{URI.encode_www_form("sig" => signature)}"
141
+ end
142
+
143
+ def oauth_verified_query!(ctx, oauth_query)
144
+ raise APIError.new("BAD_REQUEST", message: "missing oauth query") if oauth_query.to_s.empty?
145
+
146
+ pairs = URI.decode_www_form(oauth_query.to_s)
147
+ signature = pairs.reverse_each.find { |key, _value| key == "sig" }&.last
148
+ unsigned_pairs = pairs.filter_map { |key, value| [key, value] unless key == "sig" }
149
+ unsigned = URI.encode_www_form(unsigned_pairs)
150
+ exp = unsigned_pairs.reverse_each.find { |key, _value| key == "exp" }&.last.to_i
151
+ unless signature && exp >= Time.now.to_i && Crypto.verify_hmac_signature(unsigned, signature, ctx.context.secret, encoding: :base64url)
152
+ raise APIError.new("BAD_REQUEST", message: "invalid oauth query")
153
+ end
154
+
155
+ unsigned_pairs.each_with_object({}) do |(key, value), result|
156
+ next if key == "exp"
157
+
158
+ result[key] = if result.key?(key)
159
+ Array(result[key]) << value
160
+ else
161
+ value
162
+ end
163
+ end
164
+ end
165
+
166
+ def oauth_delete_prompt!(query, prompt)
167
+ prompts = OAuthProtocol.parse_scopes(query["prompt"])
168
+ prompts.delete(prompt)
169
+ if prompts.empty?
170
+ query.delete("prompt")
171
+ else
172
+ query["prompt"] = OAuthProtocol.scope_string(prompts)
173
+ end
174
+ end
175
+
176
+ def oauth_redirect_location
177
+ yield
178
+ rescue APIError => error
179
+ location = error.headers["location"]
180
+ return location if location
181
+
182
+ raise
183
+ end
184
+
185
+ def oauth_authorize_error_redirect(ctx, query, error, description)
186
+ OAuthProtocol.redirect_uri_with_params(
187
+ query["redirect_uri"],
188
+ error: error,
189
+ error_description: description,
190
+ state: query["state"],
191
+ iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))
192
+ )
193
+ end
194
+
195
+ def oauth_resolve_request_uri!(ctx, config, query)
196
+ query = OAuthProtocol.stringify_keys(query)
197
+ return query if query["request_uri"].to_s.empty?
198
+
199
+ resolver = config[:request_uri_resolver]
200
+ unless resolver.respond_to?(:call)
201
+ return oauth_invalid_request_uri!(ctx, query, "request_uri not supported")
202
+ end
203
+
204
+ resolved = resolver.call({request_uri: query["request_uri"], client_id: query["client_id"], context: ctx})
205
+ return oauth_invalid_request_uri!(ctx, query, "request_uri is invalid or expired") unless resolved
206
+
207
+ OAuthProtocol.stringify_keys(resolved)
208
+ end
209
+
210
+ def oauth_invalid_request_uri!(ctx, query, description)
211
+ redirect_uri = query["redirect_uri"]
212
+ raise APIError.new("BAD_REQUEST", message: "invalid_request_uri") if redirect_uri.to_s.empty?
213
+
214
+ raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_request_uri", description))
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module OAuthProvider
6
+ module Client
7
+ ID = "oauth-provider-client"
8
+
9
+ module_function
10
+
11
+ def parse_signed_query(search)
12
+ query = search.to_s.sub(/\A\?/, "")
13
+ return nil if query.empty?
14
+
15
+ pairs = URI.decode_www_form(query)
16
+ return nil unless pairs.any? { |key, _value| key == "sig" }
17
+
18
+ signed_pairs = []
19
+ pairs.each do |key, value|
20
+ signed_pairs << [key, value]
21
+ break if key == "sig"
22
+ end
23
+ URI.encode_www_form(signed_pairs)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module OAuthProvider
6
+ module ClientResource
7
+ ID = "oauth-provider-resource-client"
8
+
9
+ module_function
10
+
11
+ def protected_resource_metadata(overrides = {}, authorization_server: nil, oauth_provider_options: nil, external_scopes: [])
12
+ data = OAuthProtocol.stringify_keys(overrides || {})
13
+ resource = data["resource"] || authorization_server
14
+ raise Error, "missing required resource" if resource.to_s.empty?
15
+
16
+ validate_resource_scopes!(data["scopes_supported"], oauth_provider_options, external_scopes)
17
+
18
+ response = {resource: resource}
19
+ response[:authorization_servers] = [authorization_server] if authorization_server
20
+ response.merge!(data.transform_keys(&:to_sym))
21
+ response[:resource] = resource
22
+ response
23
+ end
24
+
25
+ def validate_resource_scopes!(scopes_supported, oauth_provider_options, external_scopes)
26
+ scopes = OAuthProtocol.parse_scopes(scopes_supported)
27
+ return if scopes.empty?
28
+
29
+ allowed = OAuthProtocol.parse_scopes(oauth_provider_options && oauth_provider_options[:scopes]) + OAuthProtocol.parse_scopes(external_scopes)
30
+ scopes.each do |scope|
31
+ if scope == "openid"
32
+ raise Error, "Only the Auth Server should utilize the openid scope"
33
+ end
34
+ next if allowed.empty? || allowed.include?(scope)
35
+
36
+ raise Error, %(Unsupported scope #{scope}. If external, please add to "externalScopes")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def oauth_consent_endpoint(config)
8
+ Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
9
+ current_session = Routes.current_session(ctx)
10
+ body = OAuthProtocol.stringify_keys(ctx.body)
11
+ consent = config[:store][:consents].delete(body["consent_code"].to_s)
12
+ raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless consent
13
+ raise APIError.new("BAD_REQUEST", message: "expired consent_code") if consent[:expires_at] <= Time.now
14
+
15
+ query = consent[:query]
16
+ if body["accept"] == false || body["accept"].to_s == "false"
17
+ redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)))
18
+ next ctx.json({redirectURI: redirect})
19
+ end
20
+
21
+ granted_scopes = OAuthProtocol.parse_scopes(body["scope"] || body["scopes"])
22
+ granted_scopes = consent[:scopes] if granted_scopes.empty?
23
+ unless granted_scopes.all? { |scope| consent[:scopes].include?(scope) }
24
+ raise APIError.new("BAD_REQUEST", message: "invalid_scope")
25
+ end
26
+
27
+ reference_id = oauth_consent_reference(config, current_session, granted_scopes) || consent[:reference_id]
28
+ oauth_store_consent(ctx, consent[:client], consent[:session], granted_scopes, reference_id)
29
+ redirect = oauth_authorization_redirect(ctx, config, query, consent[:session], consent[:client], granted_scopes, reference_id: reference_id)
30
+ ctx.json({redirectURI: redirect})
31
+ end
32
+ end
33
+
34
+ def oauth_authorization_redirect(ctx, config, query, session, client, scopes, reference_id: nil)
35
+ code = Crypto.random_string(32)
36
+ client_reference_id = OAuthProtocol.stringify_keys(client)["referenceId"]
37
+ OAuthProtocol.store_code(
38
+ config[:store],
39
+ code: code,
40
+ client_id: query["client_id"],
41
+ redirect_uri: query["redirect_uri"],
42
+ session: session,
43
+ scopes: scopes,
44
+ code_challenge: query["code_challenge"],
45
+ code_challenge_method: query["code_challenge_method"],
46
+ nonce: query["nonce"],
47
+ reference_id: reference_id || client_reference_id
48
+ )
49
+ OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)))
50
+ end
51
+
52
+ def oauth_redirect_with_code(ctx, config, query, session, client, scopes, reference_id: nil)
53
+ raise ctx.redirect(oauth_authorization_redirect(ctx, config, query, session, client, scopes, reference_id: reference_id))
54
+ end
55
+
56
+ def oauth_consent_granted?(ctx, client_id, user_id, scopes, reference_id = nil)
57
+ where = [
58
+ {field: "clientId", value: client_id},
59
+ {field: "userId", value: user_id}
60
+ ]
61
+ where << {field: "referenceId", value: reference_id} if reference_id
62
+ consent = ctx.context.adapter.find_one(
63
+ model: "oauthConsent",
64
+ where: where
65
+ )
66
+ return false unless consent
67
+
68
+ granted = OAuthProtocol.parse_scopes(consent["scopes"])
69
+ scopes.all? { |scope| granted.include?(scope) }
70
+ end
71
+
72
+ def oauth_store_consent(ctx, client, session, scopes, reference_id = nil)
73
+ client_id = OAuthProtocol.stringify_keys(client)["clientId"]
74
+ user_id = session[:user]["id"]
75
+ where = [
76
+ {field: "clientId", value: client_id},
77
+ {field: "userId", value: user_id}
78
+ ]
79
+ where << {field: "referenceId", value: reference_id} if reference_id
80
+ existing = ctx.context.adapter.find_one(
81
+ model: "oauthConsent",
82
+ where: where
83
+ )
84
+ data = {clientId: client_id, userId: user_id, scopes: scopes}
85
+ data[:referenceId] = reference_id if reference_id
86
+ if existing
87
+ ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data)
88
+ else
89
+ ctx.context.adapter.create(model: "oauthConsent", data: data)
90
+ end
91
+ end
92
+
93
+ def oauth_consent_reference(config, session, scopes)
94
+ callback = config.dig(:post_login, :consent_reference_id) || config.dig(:post_login, :consentReferenceId)
95
+ return nil unless callback.respond_to?(:call)
96
+
97
+ callback.call({user: session[:user], session: session[:session], scopes: scopes})
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def oauth_continue_endpoint(config)
8
+ Endpoint.new(path: "/oauth2/continue", method: "POST") do |ctx|
9
+ Routes.current_session(ctx)
10
+ body = OAuthProtocol.stringify_keys(ctx.body)
11
+ action = if body["selected"] == true
12
+ "select_account"
13
+ elsif body["created"] == true
14
+ "create"
15
+ elsif body["postLogin"] == true || body["post_login"] == true
16
+ "post_login"
17
+ end
18
+ raise APIError.new("BAD_REQUEST", message: "Missing parameters") unless action
19
+
20
+ query = oauth_verified_query!(ctx, body["oauth_query"])
21
+ oauth_delete_prompt!(query, action) unless action == "post_login"
22
+ url = oauth_redirect_location { oauth_authorize_flow(ctx, config, query, continue_post_login: action == "post_login") }
23
+ ctx.json({redirect: true, url: url})
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def oauth_introspect_endpoint(config)
8
+ Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
9
+ client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
10
+ body = OAuthProtocol.stringify_keys(ctx.body)
11
+ token = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix])
12
+ active = token && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now)
13
+ if active
14
+ next ctx.json({
15
+ active: true,
16
+ client_id: token["clientId"],
17
+ scope: OAuthProtocol.scope_string(token["scope"] || token["scopes"]),
18
+ sub: token["subject"] || token.dig("user", "id"),
19
+ iss: token["issuer"],
20
+ iat: token["issuedAt"]&.to_i,
21
+ exp: token["expiresAt"]&.to_i,
22
+ sid: token["sessionId"],
23
+ aud: token["audience"]
24
+ })
25
+ end
26
+
27
+ jwt = oauth_introspect_jwt_access_token(ctx, client, body["token"].to_s)
28
+ ctx.json(jwt || {active: false})
29
+ end
30
+ end
31
+
32
+ def oauth_jwt_access_token?(config, audience)
33
+ !!audience && !config[:disable_jwt_plugin] && !config[:disable_jwt_access_tokens]
34
+ end
35
+
36
+ def oauth_introspect_jwt_access_token(ctx, client, token)
37
+ payload = ::JWT.decode(token, ctx.context.secret, true, algorithm: "HS256").first
38
+ client_data = OAuthProtocol.stringify_keys(client)
39
+ return nil unless payload["azp"] == client_data["clientId"]
40
+
41
+ {
42
+ active: true,
43
+ client_id: payload["azp"],
44
+ scope: payload["scope"],
45
+ sub: payload["sub"],
46
+ aud: payload["aud"],
47
+ exp: payload["exp"]
48
+ }.compact
49
+ rescue ::JWT::DecodeError
50
+ nil
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def oauth_end_session_endpoint
8
+ Endpoint.new(path: "/oauth2/end-session", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
9
+ input = OAuthProtocol.stringify_keys((ctx.method == "GET") ? ctx.query : ctx.body)
10
+ id_token_hint = input["id_token_hint"].to_s
11
+ raise APIError.new("UNAUTHORIZED", message: "invalid id token") if id_token_hint.empty?
12
+
13
+ decoded = ::JWT.decode(id_token_hint, nil, false).first
14
+ client_id = input["client_id"] || decoded["aud"]
15
+ client = OAuthProtocol.find_client(ctx, "oauthClient", client_id)
16
+ raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
17
+
18
+ client_data = OAuthProtocol.stringify_keys(client)
19
+ raise APIError.new("BAD_REQUEST", message: "invalid_client") if client_data["disabled"]
20
+ raise APIError.new("UNAUTHORIZED", message: "client unable to logout") unless client_data["enableEndSession"]
21
+
22
+ payload = Crypto.verify_jwt(id_token_hint, client_data["clientId"])
23
+ raise APIError.new("UNAUTHORIZED", message: "invalid id token") unless payload
24
+ raise APIError.new("BAD_REQUEST", message: "audience mismatch") if input["client_id"] && payload["aud"] != input["client_id"]
25
+
26
+ if payload["sid"]
27
+ ctx.context.adapter.delete(model: "session", where: [{field: "id", value: payload["sid"]}])
28
+ end
29
+
30
+ if input["post_logout_redirect_uri"]
31
+ unless OAuthProtocol.client_logout_redirect_uris(client_data).include?(input["post_logout_redirect_uri"])
32
+ raise APIError.new("BAD_REQUEST", message: "invalid post_logout_redirect_uri")
33
+ end
34
+
35
+ redirect = OAuthProtocol.redirect_uri_with_params(input["post_logout_redirect_uri"], state: input["state"])
36
+ raise ctx.redirect(redirect)
37
+ end
38
+
39
+ ctx.json({status: true})
40
+ rescue ::JWT::DecodeError
41
+ raise APIError.new("UNAUTHORIZED", message: "invalid id token")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module OAuthProvider
6
+ module MCP
7
+ module_function
8
+
9
+ def www_authenticate(resource, resource_metadata_mappings: {})
10
+ Array(resource).map do |value|
11
+ metadata_url = resource_metadata_url(value, resource_metadata_mappings)
12
+ %(Bearer resource_metadata="#{metadata_url}")
13
+ end.join(", ")
14
+ end
15
+
16
+ def resource_metadata_url(resource, mappings = {})
17
+ value = resource.to_s
18
+ uri = URI.parse(value)
19
+ if uri.scheme && uri.host
20
+ path = uri.path.to_s.end_with?("/") ? uri.path.to_s.delete_suffix("/") : uri.path.to_s
21
+ return "#{resource_origin(uri)}/.well-known/oauth-protected-resource#{path}"
22
+ end
23
+
24
+ mapped = OAuthProtocol.stringify_keys(mappings || {})[value] || mappings[value.to_sym]
25
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "missing resource_metadata mapping for #{value}") if mapped.to_s.empty?
26
+
27
+ mapped
28
+ rescue URI::InvalidURIError
29
+ mapped = OAuthProtocol.stringify_keys(mappings || {})[value] || mappings[value.to_sym]
30
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "missing resource_metadata mapping for #{value}") if mapped.to_s.empty?
31
+
32
+ mapped
33
+ end
34
+
35
+ def resource_origin(uri)
36
+ default_port = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
37
+ port = default_port ? "" : ":#{uri.port}"
38
+ "#{uri.scheme}://#{uri.host}#{port}"
39
+ end
40
+
41
+ def handle_mcp_errors(error, resource, resource_metadata_mappings: {})
42
+ raise error unless error.is_a?(APIError) && error.status_code == 401
43
+
44
+ raise APIError.new(
45
+ "UNAUTHORIZED",
46
+ message: error.message,
47
+ headers: {"WWW-Authenticate" => www_authenticate(resource, resource_metadata_mappings: resource_metadata_mappings)}
48
+ )
49
+ end
50
+
51
+ def mcp_handler(resource:, verifier:, resource_metadata_mappings: {}, &handler)
52
+ lambda do |request|
53
+ authorization = request.respond_to?(:headers) ? request.headers["authorization"] : nil
54
+ token = authorization.to_s.delete_prefix("Bearer ").strip
55
+ raise APIError.new("UNAUTHORIZED", message: "missing authorization header") if token.empty?
56
+
57
+ jwt = verifier.call(token)
58
+ handler.call(request, jwt)
59
+ rescue APIError => error
60
+ handle_mcp_errors(error, resource, resource_metadata_mappings: resource_metadata_mappings)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end