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.
- checksums.yaml +4 -4
- data/lib/better_auth/oauth_provider/version.rb +1 -1
- data/lib/better_auth/plugins/oauth_provider/authorize.rb +217 -0
- data/lib/better_auth/plugins/oauth_provider/client.rb +28 -0
- data/lib/better_auth/plugins/oauth_provider/client_resource.rb +42 -0
- data/lib/better_auth/plugins/oauth_provider/consent.rb +100 -0
- data/lib/better_auth/plugins/oauth_provider/continue.rb +27 -0
- data/lib/better_auth/plugins/oauth_provider/introspect.rb +53 -0
- data/lib/better_auth/plugins/oauth_provider/logout.rb +45 -0
- data/lib/better_auth/plugins/oauth_provider/mcp.rb +66 -0
- data/lib/better_auth/plugins/oauth_provider/metadata.rb +97 -0
- data/lib/better_auth/plugins/oauth_provider/middleware/index.rb +25 -0
- data/lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb +229 -0
- data/lib/better_auth/plugins/oauth_provider/oauth_client/index.rb +73 -0
- data/lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb +130 -0
- data/lib/better_auth/plugins/oauth_provider/oauth_consent/index.rb +28 -0
- data/lib/better_auth/plugins/oauth_provider/rate_limit.rb +31 -0
- data/lib/better_auth/plugins/oauth_provider/register.rb +41 -0
- data/lib/better_auth/plugins/oauth_provider/revoke.rb +24 -0
- data/lib/better_auth/plugins/oauth_provider/schema.rb +89 -0
- data/lib/better_auth/plugins/oauth_provider/token.rb +108 -0
- data/lib/better_auth/plugins/oauth_provider/types/helpers.rb +21 -0
- data/lib/better_auth/plugins/oauth_provider/types/index.rb +10 -0
- data/lib/better_auth/plugins/oauth_provider/types/oauth.rb +28 -0
- data/lib/better_auth/plugins/oauth_provider/types/zod.rb +48 -0
- data/lib/better_auth/plugins/oauth_provider/userinfo.rb +13 -0
- data/lib/better_auth/plugins/oauth_provider/utils/index.rb +104 -0
- data/lib/better_auth/plugins/oauth_provider.rb +25 -1193
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83ab585e58d2f6a3559de17278ee3028ba8294cacc5d53b2dbe0e501e891499f
|
|
4
|
+
data.tar.gz: 11d0780bd5c1e4d87606a0572aca65e7dff9e2389b1ee45e748c4953efd4479d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf0c2712de1ac44420fd6a6b15b42c9d825e5f61b6d5e732afe53d73200251533b83a9e1c0b40ecd4dcadab93dcdf014646d851bbfa82119ea9b8d692e56f752
|
|
7
|
+
data.tar.gz: 5d86171dde17750573cc62c6ea7ee0f447cce96673d6af0c4915af8d5db9f93648aeb902ea933517becf170392e16122a6d1d297d404e94867ea22fab0a69e87
|
|
@@ -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
|