better_auth-oauth-provider 0.6.2 → 0.7.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 +6 -0
- data/README.md +11 -1
- data/lib/better_auth/oauth_provider/version.rb +1 -1
- data/lib/better_auth/plugins/oauth_provider/consent.rb +8 -3
- data/lib/better_auth/plugins/oauth_provider/introspect.rb +6 -4
- data/lib/better_auth/plugins/oauth_provider/logout.rb +6 -1
- data/lib/better_auth/plugins/oauth_provider/metadata.rb +4 -2
- data/lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb +13 -3
- data/lib/better_auth/plugins/oauth_provider/oauth_client/index.rb +38 -0
- data/lib/better_auth/plugins/oauth_provider/rate_limit.rb +4 -1
- data/lib/better_auth/plugins/oauth_provider/revoke.rb +23 -2
- data/lib/better_auth/plugins/oauth_provider/token.rb +25 -9
- data/lib/better_auth/plugins/oauth_provider/userinfo.rb +1 -1
- data/lib/better_auth/plugins/oauth_provider.rb +3 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0b02bfe381c6895d73aca8a1084ee6e540da6e5f7d4bad032729d4695932e30
|
|
4
|
+
data.tar.gz: a24ba3d9aed24d1e65d338bfd2bf1d1627f2e4f6f78e24b6d44a2a7f6a973e55
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cebc495d6cef993a9d948b0f3bfe41706d26147b9b2f8d90b540ed4d2782ab28db1eaa23321d00ad76cb5aa36c5f41b8d74a5532cce4415e3620b0bffac8751
|
|
7
|
+
data.tar.gz: 1ee7480850340bb18bdccd9e933c3c03b15e168b33d3d3511525be5e6882473f05864d58c5d9e37559d86da906f157859e35861ca834192d35efe1e263d19c6b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0 - 2026-05-05
|
|
6
|
+
|
|
7
|
+
- Fixed OAuth provider consent approval, metadata, issuer normalization, revocation persistence, and endpoint-specific rate limits for hardening parity.
|
|
8
|
+
- Changed RP-initiated logout ID token validation to use the hardened HS256 ID token key; old ID tokens signed only with the public client id will no longer validate.
|
|
9
|
+
- Hardened OAuth client endpoints, token exchange, introspection, userinfo, and pairwise behavior with expanded parity coverage.
|
|
10
|
+
|
|
5
11
|
## 0.3.0 - 2026-04-30
|
|
6
12
|
|
|
7
13
|
- Added upstream-parity support for provider init validation, request URI resolution, prompt handling, consent reference IDs, client references, custom token/id-token claims, scope-specific access-token expiry, M2M token defaults, userinfo JWT verification, and expanded introspection fields.
|
data/README.md
CHANGED
|
@@ -57,6 +57,7 @@ tokens = auth.api.o_auth2_token(
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
When `resource` is present and valid, access tokens are JWTs. Without `resource`, access tokens are opaque and introspectable.
|
|
60
|
+
By default, valid JWT access-token audiences are the provider issuer URL and, when `openid` is granted, the UserInfo endpoint. Set `valid_audiences` to allow additional resource servers.
|
|
60
61
|
|
|
61
62
|
## Routes
|
|
62
63
|
|
|
@@ -105,11 +106,18 @@ Common options accepted by `BetterAuth::Plugins.oauth_provider`:
|
|
|
105
106
|
- `allow_unauthenticated_client_registration`
|
|
106
107
|
- `client_registration_default_scopes`
|
|
107
108
|
- `client_registration_allowed_scopes`
|
|
109
|
+
- `client_credential_grant_default_scopes`
|
|
108
110
|
- `store_client_secret`
|
|
109
111
|
- `prefix`
|
|
112
|
+
- `code_expires_in`
|
|
113
|
+
- `id_token_expires_in`
|
|
110
114
|
- `refresh_token_expires_in`
|
|
115
|
+
- `access_token_expires_in`
|
|
116
|
+
- `m2m_access_token_expires_in`
|
|
117
|
+
- `scope_expirations`
|
|
111
118
|
- `advertised_metadata`
|
|
112
119
|
- `valid_audiences`
|
|
120
|
+
- `allow_public_client_prelogin`
|
|
113
121
|
- `custom_token_response_fields`
|
|
114
122
|
- `custom_access_token_claims`
|
|
115
123
|
- `custom_user_info_claims`
|
|
@@ -158,7 +166,9 @@ Then drop the legacy access-token and consent columns. Rails apps using `better_
|
|
|
158
166
|
|
|
159
167
|
## Ruby Adaptations
|
|
160
168
|
|
|
161
|
-
When the JWT plugin is registered,
|
|
169
|
+
When the JWT plugin is registered, JWT access tokens and ID tokens use the JWT plugin's configured `jwks.key_pair_config.alg`, defaulting to `EdDSA` like upstream. If the JWT plugin is not registered, or `disable_jwt_plugin: true` is set, Ruby intentionally falls back to HS256 for compatibility.
|
|
170
|
+
|
|
171
|
+
Upstream `oauthProviderResourceClient` and MCP protected-resource helpers remain future API-boundary work for Ruby. This gem currently hardens authorization-server behavior only.
|
|
162
172
|
|
|
163
173
|
Route OpenAPI metadata blocks from upstream TypeScript are intentionally not ported into this package. Use the Ruby `open_api` plugin for generated OpenAPI output.
|
|
164
174
|
|
|
@@ -6,11 +6,15 @@ module BetterAuth
|
|
|
6
6
|
|
|
7
7
|
def oauth_consent_endpoint(config)
|
|
8
8
|
Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
|
|
9
|
-
current_session = Routes.current_session(ctx)
|
|
9
|
+
current_session = Routes.current_session(ctx, allow_nil: true)
|
|
10
10
|
body = OAuthProtocol.stringify_keys(ctx.body)
|
|
11
11
|
consent = config[:store][:consents].delete(body["consent_code"].to_s)
|
|
12
12
|
raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless consent
|
|
13
13
|
raise APIError.new("BAD_REQUEST", message: "expired consent_code") if consent[:expires_at] <= Time.now
|
|
14
|
+
raise APIError.new("UNAUTHORIZED", message: "session required") unless current_session
|
|
15
|
+
unless current_session[:user]["id"].to_s == consent[:session][:user]["id"].to_s
|
|
16
|
+
raise APIError.new("FORBIDDEN", message: "consent session mismatch")
|
|
17
|
+
end
|
|
14
18
|
|
|
15
19
|
query = consent[:query]
|
|
16
20
|
if body["accept"] == false || body["accept"].to_s == "false"
|
|
@@ -24,7 +28,7 @@ module BetterAuth
|
|
|
24
28
|
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
reference_id =
|
|
31
|
+
reference_id = consent[:reference_id]
|
|
28
32
|
oauth_store_consent(ctx, consent[:client], consent[:session], granted_scopes, reference_id)
|
|
29
33
|
redirect = oauth_authorization_redirect(ctx, config, query, consent[:session], consent[:client], granted_scopes, reference_id: reference_id)
|
|
30
34
|
ctx.json({redirectURI: redirect})
|
|
@@ -44,7 +48,8 @@ module BetterAuth
|
|
|
44
48
|
code_challenge: query["code_challenge"],
|
|
45
49
|
code_challenge_method: query["code_challenge_method"],
|
|
46
50
|
nonce: query["nonce"],
|
|
47
|
-
reference_id: reference_id || client_reference_id
|
|
51
|
+
reference_id: reference_id || client_reference_id,
|
|
52
|
+
expires_in: config[:code_expires_in]
|
|
48
53
|
)
|
|
49
54
|
OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)))
|
|
50
55
|
end
|
|
@@ -7,9 +7,11 @@ module BetterAuth
|
|
|
7
7
|
def oauth_introspect_endpoint(config)
|
|
8
8
|
Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
|
|
9
9
|
client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
|
|
10
|
+
client_id = OAuthProtocol.stringify_keys(client)["clientId"]
|
|
10
11
|
body = OAuthProtocol.stringify_keys(ctx.body)
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
token_value = body["token"].to_s.sub(/\ABearer\s+/i, "")
|
|
13
|
+
token = OAuthProtocol.find_token_by_hint(config[:store], token_value, body["token_type_hint"], prefix: config[:prefix])
|
|
14
|
+
active = token && token["clientId"].to_s == client_id.to_s && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now)
|
|
13
15
|
if active
|
|
14
16
|
next ctx.json({
|
|
15
17
|
active: true,
|
|
@@ -24,7 +26,7 @@ module BetterAuth
|
|
|
24
26
|
})
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
jwt = oauth_introspect_jwt_access_token(ctx, client,
|
|
29
|
+
jwt = oauth_introspect_jwt_access_token(ctx, client, token_value)
|
|
28
30
|
ctx.json(jwt || {active: false})
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -34,7 +36,7 @@ module BetterAuth
|
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def oauth_introspect_jwt_access_token(ctx, client, token)
|
|
37
|
-
payload =
|
|
39
|
+
payload = OAuthProtocol.verify_oauth_jwt(ctx, token, issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)), hs256_secret: ctx.context.secret)
|
|
38
40
|
client_data = OAuthProtocol.stringify_keys(client)
|
|
39
41
|
return nil unless payload["azp"] == client_data["clientId"]
|
|
40
42
|
|
|
@@ -19,7 +19,12 @@ module BetterAuth
|
|
|
19
19
|
raise APIError.new("BAD_REQUEST", message: "invalid_client") if client_data["disabled"]
|
|
20
20
|
raise APIError.new("UNAUTHORIZED", message: "client unable to logout") unless client_data["enableEndSession"]
|
|
21
21
|
|
|
22
|
-
payload =
|
|
22
|
+
payload = OAuthProtocol.verify_oauth_jwt(
|
|
23
|
+
ctx,
|
|
24
|
+
id_token_hint,
|
|
25
|
+
issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
|
|
26
|
+
hs256_secret: OAuthProtocol.id_token_hs256_key(ctx, client_data["clientId"], client_data["clientSecret"])
|
|
27
|
+
)
|
|
23
28
|
raise APIError.new("UNAUTHORIZED", message: "invalid id token") unless payload
|
|
24
29
|
raise APIError.new("BAD_REQUEST", message: "audience mismatch") if input["client_id"] && payload["aud"] != input["client_id"]
|
|
25
30
|
|
|
@@ -11,7 +11,6 @@ module BetterAuth
|
|
|
11
11
|
issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
|
|
12
12
|
authorization_endpoint: "#{base}/oauth2/authorize",
|
|
13
13
|
token_endpoint: "#{base}/oauth2/token",
|
|
14
|
-
registration_endpoint: "#{base}/oauth2/register",
|
|
15
14
|
introspection_endpoint: "#{base}/oauth2/introspect",
|
|
16
15
|
revocation_endpoint: "#{base}/oauth2/revoke",
|
|
17
16
|
response_types_supported: ["code"],
|
|
@@ -24,6 +23,7 @@ module BetterAuth
|
|
|
24
23
|
authorization_response_iss_parameter_supported: true,
|
|
25
24
|
scopes_supported: config.dig(:advertised_metadata, :scopes_supported) || config[:scopes]
|
|
26
25
|
}
|
|
26
|
+
metadata[:registration_endpoint] = "#{base}/oauth2/register" if config[:allow_dynamic_client_registration]
|
|
27
27
|
metadata[:jwks_uri] = oauth_jwks_uri(config) if oauth_jwks_uri(config)
|
|
28
28
|
ctx.json(metadata, headers: oauth_metadata_headers)
|
|
29
29
|
end
|
|
@@ -40,7 +40,6 @@ module BetterAuth
|
|
|
40
40
|
issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
|
|
41
41
|
authorization_endpoint: "#{base}/oauth2/authorize",
|
|
42
42
|
token_endpoint: "#{base}/oauth2/token",
|
|
43
|
-
registration_endpoint: "#{base}/oauth2/register",
|
|
44
43
|
introspection_endpoint: "#{base}/oauth2/introspect",
|
|
45
44
|
revocation_endpoint: "#{base}/oauth2/revoke",
|
|
46
45
|
response_types_supported: ["code"],
|
|
@@ -60,6 +59,7 @@ module BetterAuth
|
|
|
60
59
|
prompt_values_supported: oauth_prompt_values,
|
|
61
60
|
claims_supported: config.dig(:advertised_metadata, :claims_supported) || config[:claims] || []
|
|
62
61
|
}
|
|
62
|
+
metadata[:registration_endpoint] = "#{base}/oauth2/register" if config[:allow_dynamic_client_registration]
|
|
63
63
|
metadata[:jwks_uri] = oauth_jwks_uri(config) if oauth_jwks_uri(config)
|
|
64
64
|
ctx.json(metadata, headers: oauth_metadata_headers)
|
|
65
65
|
end
|
|
@@ -85,6 +85,8 @@ module BetterAuth
|
|
|
85
85
|
return ["HS256"] if config[:disable_jwt_plugin]
|
|
86
86
|
|
|
87
87
|
jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
|
|
88
|
+
return ["HS256"] unless jwt_plugin
|
|
89
|
+
|
|
88
90
|
alg = config.dig(:jwt, :jwks, :key_pair_config, :alg) ||
|
|
89
91
|
jwt_plugin&.options&.dig(:jwks, :key_pair_config, :alg)
|
|
90
92
|
alg ? [alg] : ["EdDSA"]
|
|
@@ -53,9 +53,16 @@ module BetterAuth
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
def oauth_get_client_public_prelogin_endpoint(
|
|
56
|
+
def oauth_get_client_public_prelogin_endpoint(config)
|
|
57
57
|
Endpoint.new(path: "/oauth2/public-client-prelogin", method: "POST") do |ctx|
|
|
58
58
|
input = OAuthProtocol.stringify_keys(ctx.body).merge(OAuthProtocol.stringify_keys(ctx.query))
|
|
59
|
+
unless config[:allow_public_client_prelogin] || config[:allowPublicClientPrelogin]
|
|
60
|
+
raise APIError.new("BAD_REQUEST")
|
|
61
|
+
end
|
|
62
|
+
unless OAuthProvider::Utils.verify_oauth_query_params(input["oauth_query"], ctx.context.secret)
|
|
63
|
+
raise APIError.new("UNAUTHORIZED", body: {error: "invalid_signature"})
|
|
64
|
+
end
|
|
65
|
+
|
|
59
66
|
client = OAuthProtocol.find_client(ctx, "oauthClient", input["client_id"])
|
|
60
67
|
raise APIError.new("NOT_FOUND", message: "client not found") unless client
|
|
61
68
|
raise APIError.new("NOT_FOUND", message: "client not found") if OAuthProtocol.stringify_keys(client)["disabled"]
|
|
@@ -101,6 +108,7 @@ module BetterAuth
|
|
|
101
108
|
oauth_assert_owned_client!(client, session, config)
|
|
102
109
|
|
|
103
110
|
update_source = OAuthProtocol.stringify_keys(body["update"] || {})
|
|
111
|
+
oauth_validate_client_update!(client, update_source, config, admin: false)
|
|
104
112
|
update = oauth_client_update_data(update_source)
|
|
105
113
|
updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}], update: update.merge(updatedAt: Time.now))
|
|
106
114
|
ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
|
|
@@ -136,13 +144,15 @@ module BetterAuth
|
|
|
136
144
|
end
|
|
137
145
|
end
|
|
138
146
|
|
|
139
|
-
def oauth_admin_update_client_endpoint(
|
|
147
|
+
def oauth_admin_update_client_endpoint(config)
|
|
140
148
|
Endpoint.new(path: "/admin/oauth2/update-client", method: "PATCH", metadata: {server_only: true}) do |ctx|
|
|
141
149
|
body = OAuthProtocol.stringify_keys(ctx.body)
|
|
142
150
|
client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
|
|
143
151
|
raise APIError.new("NOT_FOUND", message: "client not found") unless client
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
update_source = OAuthProtocol.stringify_keys(body["update"] || {})
|
|
154
|
+
oauth_validate_client_update!(client, update_source, config, admin: true)
|
|
155
|
+
update = oauth_client_update_data(update_source, admin: true)
|
|
146
156
|
updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}], update: update.merge(updatedAt: Time.now))
|
|
147
157
|
ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
|
|
148
158
|
end
|
|
@@ -46,9 +46,12 @@ module BetterAuth
|
|
|
46
46
|
update["redirectUrls"] = redirects.join(",")
|
|
47
47
|
end
|
|
48
48
|
update["postLogoutRedirectUris"] = Array(source["post_logout_redirect_uris"]).map(&:to_s) if source.key?("post_logout_redirect_uris")
|
|
49
|
+
update["tokenEndpointAuthMethod"] = source["token_endpoint_auth_method"] || source["tokenEndpointAuthMethod"] if admin && (source.key?("token_endpoint_auth_method") || source.key?("tokenEndpointAuthMethod"))
|
|
49
50
|
update["grantTypes"] = Array(source["grant_types"]).map(&:to_s) if source.key?("grant_types")
|
|
50
51
|
update["responseTypes"] = Array(source["response_types"]).map(&:to_s) if source.key?("response_types")
|
|
51
52
|
update["scopes"] = OAuthProtocol.parse_scopes(source["scope"] || source["scopes"]) if source.key?("scope") || source.key?("scopes")
|
|
53
|
+
update["type"] = source["type"] if admin && source.key?("type")
|
|
54
|
+
update["public"] = !!source["public"] if admin && source.key?("public")
|
|
52
55
|
update["enableEndSession"] = !!(source["enable_end_session"] || source["enableEndSession"]) if source.key?("enable_end_session") || source.key?("enableEndSession")
|
|
53
56
|
update["skipConsent"] = !!(source["skip_consent"] || source["skipConsent"]) if admin && (source.key?("skip_consent") || source.key?("skipConsent"))
|
|
54
57
|
update["clientSecretExpiresAt"] = source["client_secret_expires_at"] if admin && source.key?("client_secret_expires_at")
|
|
@@ -57,6 +60,41 @@ module BetterAuth
|
|
|
57
60
|
update
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
def oauth_validate_client_update!(client, source, config, admin:)
|
|
64
|
+
return if source.empty?
|
|
65
|
+
|
|
66
|
+
current = OAuthProtocol.stringify_keys(client)
|
|
67
|
+
source = source.except("public", "token_endpoint_auth_method", "tokenEndpointAuthMethod", "client_secret", "clientSecret", "type") unless admin
|
|
68
|
+
return if source.empty?
|
|
69
|
+
|
|
70
|
+
redirects = source.key?("redirect_uris") ? Array(source["redirect_uris"]).map(&:to_s) : OAuthProtocol.client_redirect_uris(current)
|
|
71
|
+
redirects.each { |uri| OAuthProtocol.validate_safe_url!(uri, field: "redirect_uris") }
|
|
72
|
+
if source.key?("post_logout_redirect_uris")
|
|
73
|
+
Array(source["post_logout_redirect_uris"]).map(&:to_s).each { |uri| OAuthProtocol.validate_safe_url!(uri, field: "post_logout_redirect_uris") }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
auth_method = source["token_endpoint_auth_method"] || source["tokenEndpointAuthMethod"] || current["tokenEndpointAuthMethod"] || "client_secret_basic"
|
|
77
|
+
body = {
|
|
78
|
+
"token_endpoint_auth_method" => auth_method,
|
|
79
|
+
"grant_types" => source.key?("grant_types") ? Array(source["grant_types"]).map(&:to_s) : Array(current["grantTypes"]).map(&:to_s),
|
|
80
|
+
"response_types" => source.key?("response_types") ? Array(source["response_types"]).map(&:to_s) : Array(current["responseTypes"]).map(&:to_s),
|
|
81
|
+
"type" => source.key?("type") ? source["type"] : current["type"],
|
|
82
|
+
"subject_type" => source["subject_type"] || source["subjectType"] || current["subjectType"]
|
|
83
|
+
}.compact
|
|
84
|
+
OAuthProtocol.validate_client_metadata_enums!(auth_method, body)
|
|
85
|
+
OAuthProtocol.validate_admin_only_fields!(source, admin: admin)
|
|
86
|
+
OAuthProtocol.validate_client_registration!(auth_method, body["grant_types"], body["response_types"], body, unauthenticated: false, dynamic_registration: false)
|
|
87
|
+
OAuthProtocol.validate_pairwise_client!(body, redirects, config[:pairwise_secret])
|
|
88
|
+
|
|
89
|
+
return unless source.key?("scope") || source.key?("scopes")
|
|
90
|
+
|
|
91
|
+
scopes = OAuthProtocol.parse_scopes(source["scope"] || source["scopes"])
|
|
92
|
+
allowed = OAuthProtocol.parse_scopes(config[:client_registration_allowed_scopes] || config[:scopes])
|
|
93
|
+
unless allowed.empty? || scopes.all? { |scope| allowed.include?(scope) }
|
|
94
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
60
98
|
def oauth_public_client_response(client)
|
|
61
99
|
data = OAuthProtocol.stringify_keys(client)
|
|
62
100
|
{
|
|
@@ -12,7 +12,10 @@ module BetterAuth
|
|
|
12
12
|
oauth_rate_limit_rule(rate_limit, :introspect, "/oauth2/introspect", window: 60, max: 100),
|
|
13
13
|
oauth_rate_limit_rule(rate_limit, :revoke, "/oauth2/revoke", window: 60, max: 30),
|
|
14
14
|
oauth_rate_limit_rule(rate_limit, :register, "/oauth2/register", window: 60, max: 5),
|
|
15
|
-
oauth_rate_limit_rule(rate_limit, :userinfo, "/oauth2/userinfo", window: 60, max: 60)
|
|
15
|
+
oauth_rate_limit_rule(rate_limit, :userinfo, "/oauth2/userinfo", window: 60, max: 60),
|
|
16
|
+
oauth_rate_limit_rule(rate_limit, :continue, "/oauth2/continue", window: 60, max: 40),
|
|
17
|
+
oauth_rate_limit_rule(rate_limit, :consent, "/oauth2/consent", window: 60, max: 40),
|
|
18
|
+
oauth_rate_limit_rule(rate_limit, :end_session, "/oauth2/end-session", window: 60, max: 30)
|
|
16
19
|
].compact
|
|
17
20
|
end
|
|
18
21
|
|
|
@@ -6,7 +6,8 @@ module BetterAuth
|
|
|
6
6
|
|
|
7
7
|
def oauth_revoke_endpoint(config)
|
|
8
8
|
Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
|
|
9
|
-
OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
|
|
9
|
+
client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
|
|
10
|
+
client_id = OAuthProtocol.stringify_keys(client)["clientId"]
|
|
10
11
|
body = OAuthProtocol.stringify_keys(ctx.body)
|
|
11
12
|
if body["token_type_hint"].to_s == "access_token" && OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, "refresh_token", prefix: config[:prefix])
|
|
12
13
|
raise APIError.new("BAD_REQUEST", message: "invalid_request")
|
|
@@ -14,11 +15,31 @@ module BetterAuth
|
|
|
14
15
|
if body["token_type_hint"].to_s == "refresh_token" && OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, "access_token", prefix: config[:prefix])
|
|
15
16
|
raise APIError.new("BAD_REQUEST", message: "invalid_request")
|
|
16
17
|
end
|
|
17
|
-
if (token = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix]))
|
|
18
|
+
if (token = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix])) && token["clientId"].to_s == client_id.to_s
|
|
18
19
|
token["revoked"] = Time.now
|
|
20
|
+
oauth_persist_token_revocation(ctx, config, body, token)
|
|
19
21
|
end
|
|
20
22
|
ctx.json({revoked: true})
|
|
21
23
|
end
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
def oauth_persist_token_revocation(ctx, config, body, token)
|
|
27
|
+
return unless token["id"]
|
|
28
|
+
|
|
29
|
+
hint = body["token_type_hint"].to_s
|
|
30
|
+
token_value = body["token"].to_s
|
|
31
|
+
access_value = OAuthProtocol.strip_prefix(token_value, config[:prefix], :access_token)
|
|
32
|
+
refresh_value = OAuthProtocol.strip_prefix(token_value, config[:prefix], :refresh_token)
|
|
33
|
+
is_access = hint == "access_token" || (access_value && config[:store][:tokens][access_value].equal?(token))
|
|
34
|
+
is_refresh = hint == "refresh_token" || (refresh_value && config[:store][:refresh_tokens][refresh_value].equal?(token))
|
|
35
|
+
|
|
36
|
+
if is_access && OAuthProtocol.schema_model?(ctx, "oauthAccessToken")
|
|
37
|
+
ctx.context.adapter.update(model: "oauthAccessToken", where: [{field: "id", value: token["id"]}], update: {revoked: token["revoked"]})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if is_refresh && OAuthProtocol.schema_model?(ctx, "oauthRefreshToken")
|
|
41
|
+
ctx.context.adapter.update(model: "oauthRefreshToken", where: [{field: "id", value: token["id"]}], update: {revoked: token["revoked"]})
|
|
42
|
+
end
|
|
43
|
+
end
|
|
23
44
|
end
|
|
24
45
|
end
|
|
@@ -12,8 +12,6 @@ module BetterAuth
|
|
|
12
12
|
if client_grants.any? && !client_grants.include?(body["grant_type"].to_s)
|
|
13
13
|
raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
|
|
14
14
|
end
|
|
15
|
-
audience = oauth_validate_resource!(ctx, config, body)
|
|
16
|
-
|
|
17
15
|
response = case body["grant_type"]
|
|
18
16
|
when OAuthProtocol::AUTH_CODE_GRANT
|
|
19
17
|
code = OAuthProtocol.consume_code!(
|
|
@@ -23,6 +21,7 @@ module BetterAuth
|
|
|
23
21
|
redirect_uri: body["redirect_uri"],
|
|
24
22
|
code_verifier: body["code_verifier"]
|
|
25
23
|
)
|
|
24
|
+
audience = oauth_validate_resource!(ctx, config, body, code[:scopes])
|
|
26
25
|
OAuthProtocol.issue_tokens(
|
|
27
26
|
ctx,
|
|
28
27
|
config[:store],
|
|
@@ -35,12 +34,14 @@ module BetterAuth
|
|
|
35
34
|
prefix: config[:prefix],
|
|
36
35
|
refresh_token_expires_in: config[:refresh_token_expires_in],
|
|
37
36
|
access_token_expires_in: oauth_access_token_expires_in(config, code[:scopes], machine: false),
|
|
37
|
+
id_token_expires_in: config[:id_token_expires_in],
|
|
38
38
|
audience: audience,
|
|
39
39
|
grant_type: OAuthProtocol::AUTH_CODE_GRANT,
|
|
40
40
|
custom_token_response_fields: config[:custom_token_response_fields],
|
|
41
41
|
custom_access_token_claims: config[:custom_access_token_claims],
|
|
42
42
|
custom_id_token_claims: config[:custom_id_token_claims],
|
|
43
43
|
jwt_access_token: oauth_jwt_access_token?(config, audience),
|
|
44
|
+
use_jwt_plugin: !config[:disable_jwt_plugin],
|
|
44
45
|
pairwise_secret: config[:pairwise_secret],
|
|
45
46
|
nonce: code[:nonce],
|
|
46
47
|
auth_time: code[:auth_time],
|
|
@@ -49,17 +50,28 @@ module BetterAuth
|
|
|
49
50
|
)
|
|
50
51
|
when OAuthProtocol::CLIENT_CREDENTIALS_GRANT
|
|
51
52
|
requested = OAuthProtocol.parse_scopes(body["scope"])
|
|
52
|
-
|
|
53
|
+
oidc_scopes = %w[openid profile email offline_access]
|
|
54
|
+
unless (requested & oidc_scopes).empty?
|
|
55
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
56
|
+
end
|
|
57
|
+
client_data = OAuthProtocol.stringify_keys(client)
|
|
58
|
+
allowed = if client_data.key?("scopes") && !client_data["scopes"].nil?
|
|
59
|
+
OAuthProtocol.parse_scopes(client_data["scopes"])
|
|
60
|
+
else
|
|
61
|
+
OAuthProtocol.parse_scopes(config[:client_credential_grant_default_scopes] || config[:scopes])
|
|
62
|
+
end
|
|
53
63
|
requested = allowed if requested.empty?
|
|
54
64
|
unless requested.all? { |scope| allowed.include?(scope) }
|
|
55
65
|
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
56
66
|
end
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
audience = oauth_validate_resource!(ctx, config, body, requested)
|
|
69
|
+
OAuthProtocol.issue_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, session: {"user" => {}, "session" => {}}, scopes: requested, include_refresh: false, issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)), prefix: config[:prefix], audience: audience, grant_type: OAuthProtocol::CLIENT_CREDENTIALS_GRANT, custom_token_response_fields: config[:custom_token_response_fields], custom_access_token_claims: config[:custom_access_token_claims], custom_id_token_claims: config[:custom_id_token_claims], jwt_access_token: oauth_jwt_access_token?(config, audience), use_jwt_plugin: !config[:disable_jwt_plugin], pairwise_secret: config[:pairwise_secret], access_token_expires_in: oauth_access_token_expires_in(config, requested, machine: true), id_token_expires_in: config[:id_token_expires_in], filter_id_token_claims_by_scope: true)
|
|
59
70
|
when OAuthProtocol::REFRESH_GRANT
|
|
60
71
|
refresh_record = OAuthProtocol.find_token_by_hint(config[:store], body["refresh_token"].to_s, "refresh_token", prefix: config[:prefix])
|
|
61
72
|
refresh_scopes = OAuthProtocol.parse_scopes(body["scope"] || refresh_record&.fetch("scopes", nil))
|
|
62
|
-
|
|
73
|
+
audience = oauth_validate_resource!(ctx, config, body, refresh_scopes)
|
|
74
|
+
OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)), prefix: config[:prefix], refresh_token_expires_in: config[:refresh_token_expires_in], audience: audience, custom_token_response_fields: config[:custom_token_response_fields], custom_access_token_claims: config[:custom_access_token_claims], custom_id_token_claims: config[:custom_id_token_claims], jwt_access_token: oauth_jwt_access_token?(config, audience), use_jwt_plugin: !config[:disable_jwt_plugin], pairwise_secret: config[:pairwise_secret], access_token_expires_in: oauth_access_token_expires_in(config, refresh_scopes, machine: false), id_token_expires_in: config[:id_token_expires_in], filter_id_token_claims_by_scope: true)
|
|
63
75
|
else
|
|
64
76
|
raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
|
|
65
77
|
end
|
|
@@ -67,17 +79,21 @@ module BetterAuth
|
|
|
67
79
|
end
|
|
68
80
|
end
|
|
69
81
|
|
|
70
|
-
def oauth_validate_resource!(ctx, config, body)
|
|
82
|
+
def oauth_validate_resource!(ctx, config, body, scopes)
|
|
71
83
|
resources = Array(body["resource"]).compact.map(&:to_s)
|
|
72
84
|
return nil if resources.empty?
|
|
73
85
|
|
|
86
|
+
userinfo_audience = "#{OAuthProtocol.endpoint_base(ctx)}/oauth2/userinfo"
|
|
87
|
+
requested = resources.dup
|
|
88
|
+
requested << userinfo_audience if OAuthProtocol.parse_scopes(scopes).include?("openid") && !requested.include?(userinfo_audience)
|
|
74
89
|
valid = Array(config[:valid_audiences]).map(&:to_s)
|
|
75
|
-
|
|
90
|
+
valid = [OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))] if valid.empty?
|
|
91
|
+
valid << userinfo_audience if OAuthProtocol.parse_scopes(scopes).include?("openid") && !valid.include?(userinfo_audience)
|
|
76
92
|
|
|
77
|
-
|
|
93
|
+
requested.each do |resource|
|
|
78
94
|
raise APIError.new("BAD_REQUEST", message: "requested resource invalid") unless valid.include?(resource)
|
|
79
95
|
end
|
|
80
|
-
(
|
|
96
|
+
(requested.length == 1) ? requested.first : requested
|
|
81
97
|
end
|
|
82
98
|
|
|
83
99
|
def oauth_access_token_expires_in(config, scopes, machine:)
|
|
@@ -6,7 +6,7 @@ module BetterAuth
|
|
|
6
6
|
|
|
7
7
|
def oauth_userinfo_endpoint(config)
|
|
8
8
|
Endpoint.new(path: "/oauth2/userinfo", method: "GET") do |ctx|
|
|
9
|
-
ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:custom_user_info_claims] || config[:additional_claim], prefix: config[:prefix], jwt_secret: ctx.context.secret))
|
|
9
|
+
ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:custom_user_info_claims] || config[:additional_claim], prefix: config[:prefix], jwt_secret: ctx.context.secret, ctx: ctx, issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -50,9 +50,12 @@ module BetterAuth
|
|
|
50
50
|
post_login: {},
|
|
51
51
|
store_client_secret: "plain",
|
|
52
52
|
prefix: {},
|
|
53
|
+
code_expires_in: 600,
|
|
54
|
+
id_token_expires_in: 36_000,
|
|
53
55
|
refresh_token_expires_in: 2_592_000,
|
|
54
56
|
access_token_expires_in: 3600,
|
|
55
57
|
m2m_access_token_expires_in: 3600,
|
|
58
|
+
client_credential_grant_default_scopes: nil,
|
|
56
59
|
scope_expirations: {},
|
|
57
60
|
store: OAuthProtocol.stores
|
|
58
61
|
}.merge(normalize_hash(options))
|