better_auth-oauth-provider 0.8.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70854da8e71bdd4804aa18677e334cbe7b62a2b44f18223153ea3106e8cf389c
4
- data.tar.gz: b1c6e8fe99ab5daa39b007ce5ac20ec18357d5d020e4aa979497215b9f4f2dba
3
+ metadata.gz: 0afe75d53fedd5aca49d956067e9c692c12422467b30c2344ed421bcbeb3ac50
4
+ data.tar.gz: dd9b3bfb6ee900376a1c98cf42ddc4db4044bd0a8838b8880dfe51e7d6799ee0
5
5
  SHA512:
6
- metadata.gz: a7336740e04e0e0f0c7f775a27a2f1b2fbe682688a4b893dbfa1bb00c5a37641089b334275599d3b734c220ee232d6488bb602fc049098b512183ddd385281e8
7
- data.tar.gz: 4b8b5b77f17240ae7351e1fe826b4400058854aec3e3f63e552a8c9bc5cb4ff9ea405f49d7132dc04c8f905ab2bdce0a7a1819e68c2176db2417d1ed615efe8f
6
+ metadata.gz: 3cd8ed966b7207a02dc8fad76f6e231084c43cee1e1fed5381d5bf1280b6b36e603220c848e74046ea6f6f2bb0a26ef94bb890bfed4a540649e6ac1642577be2
7
+ data.tar.gz: 8441b20dc28394bf3294d0527ff7818661cba1f485b83c218cdcf25a312cc0ac94d0b3825017305ad88b48d4131a4e867fb4f76d2d3bede870678f9361676b22
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.0 - 2026-05-21
6
+
7
+ - Changed OAuth provider defaults to hash stored client secrets and opaque OAuth tokens, with `store_tokens` support for custom token hashing.
8
+ - Hardened token, introspection, and revocation client authentication to enforce the registered auth method and reject public-client introspection/revocation.
9
+ - Aligned refresh-token issuance with upstream by requiring `offline_access`, and revoking descendant access tokens when a refresh token is revoked.
10
+ - Added no-store token response headers, default JWKS discovery metadata, authorization-code session revalidation, prompt/request-uri validation, MCP verifier error normalization, and OAuth hot-path schema indexes.
11
+ - Expanded adapter, authorization, registration, and rate-limit coverage for provider flows.
12
+
5
13
  ## 0.7.0 - 2026-05-05
6
14
 
7
15
  - Fixed OAuth provider consent approval, metadata, issuer normalization, revocation persistence, and endpoint-specific rate limits for hardening parity.
data/README.md CHANGED
@@ -108,6 +108,7 @@ Common options accepted by `BetterAuth::Plugins.oauth_provider`:
108
108
  - `client_registration_allowed_scopes`
109
109
  - `client_credential_grant_default_scopes`
110
110
  - `store_client_secret`
111
+ - `store_tokens`
111
112
  - `prefix`
112
113
  - `code_expires_in`
113
114
  - `id_token_expires_in`
@@ -131,6 +132,10 @@ Common options accepted by `BetterAuth::Plugins.oauth_provider`:
131
132
  - `disable_jwt_plugin`
132
133
  - `store`
133
134
 
135
+ `store_client_secret` defaults to `"hashed"`. Set `store_client_secret: "plain"` only when migrating an existing app that still depends on plaintext client secrets. `store_tokens` defaults to `"hashed"` for opaque access tokens, refresh tokens, and authorization codes; custom hash callbacks may be supplied with `hash: ->(token, type) { ... }`.
136
+
137
+ Token, introspection, and revocation client authentication is method-strict: `client_secret_basic` clients must authenticate with HTTP Basic credentials, `client_secret_post` clients must use body credentials, and public clients cannot authenticate to introspection or revocation. Authorization-code clients receive refresh tokens only when the granted scope includes `offline_access`.
138
+
134
139
  `rate_limit` accepts per-route overrides:
135
140
 
136
141
  ```ruby
@@ -150,6 +155,8 @@ Use `false` to disable a route-specific rule.
150
155
 
151
156
  `oauthAccessToken` now uses the upstream canonical columns `token`, `expiresAt`, `scopes`, `clientId`, `sessionId`, `userId`, `referenceId`, and `refreshId`. Legacy `access_token`, `refresh_token`, `access_token_expires_at`, and `scope` columns should be copied forward then dropped. `oauthConsent#consent_given` is also removed; a consent row means consent was granted.
152
157
 
158
+ After enabling the hashed defaults, newly created OAuth client secrets and opaque tokens are stored hashed. Existing plaintext rows continue to require either a migration that re-registers/rotates secrets and tokens or a temporary explicit `store_client_secret: "plain"` setting for old clients during rollout.
159
+
153
160
  For non-Rails SQL apps, run the equivalent of:
154
161
 
155
162
  ```sql
@@ -166,7 +173,7 @@ Then drop the legacy access-token and consent columns. Rails apps using `better_
166
173
 
167
174
  ## Ruby Adaptations
168
175
 
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.
176
+ 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, and discovery metadata publishes the active JWKS URI. If the JWT plugin is not registered, or `disable_jwt_plugin: true` is set, Ruby intentionally falls back to HS256 for compatibility; with hashed client-secret storage, that fallback uses a server-derived per-client key.
170
177
 
171
178
  Upstream `oauthProviderResourceClient` and MCP protected-resource helpers remain future API-boundary work for Ruby. This gem currently hardens authorization-server behavior only.
172
179
 
@@ -175,3 +182,13 @@ Route OpenAPI metadata blocks from upstream TypeScript are intentionally not por
175
182
  The upstream `@better-auth/oauth-provider/client`, React/Solid client plugins, dashboard UI, and browser helpers are not ported. Ruby apps call the JSON endpoints directly or wrap `auth.api.*`.
176
183
 
177
184
  OIDC provider remains a core `better_auth` plugin because upstream still exposes it from `better-auth/plugins`. OAuth provider is the newer standalone provider package.
185
+
186
+ ## Development
187
+
188
+ Run the package suite with:
189
+
190
+ ```sh
191
+ rbenv exec bundle exec rake test
192
+ ```
193
+
194
+ Adapter smoke tests skip when optional adapter gems or database services are not available. To install the optional adapter test bundle locally, enable Bundler's `adapter_test` group before running the suite.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module OAuthProvider
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -40,6 +40,9 @@ module BetterAuth
40
40
  scopes = OAuthProtocol.parse_scopes(query["scope"])
41
41
  scopes = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["scopes"] || config[:scopes]) if scopes.empty?
42
42
  prompts = OAuthProtocol.parse_scopes(query["prompt"])
43
+ if prompts.include?("none") && (prompts - ["none"]).any?
44
+ raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_request", "prompt none cannot be combined with other prompts"))
45
+ end
43
46
  client_data = OAuthProtocol.stringify_keys(client)
44
47
  if client_data["disabled"]
45
48
  raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_client", "client is disabled"))
@@ -65,7 +68,7 @@ module BetterAuth
65
68
  raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "login"))
66
69
  end
67
70
 
68
- if prompts.include?("login") && !continue_post_login
71
+ if oauth_requires_login?(session, prompts, query) && !continue_post_login
69
72
  raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "login"))
70
73
  end
71
74
 
@@ -111,6 +114,21 @@ module BetterAuth
111
114
  oauth_redirect_with_code(ctx, config, query, session, client, scopes, reference_id: consent_reference_id)
112
115
  end
113
116
 
117
+ def oauth_requires_login?(session, prompts, query)
118
+ return true if prompts.include?("login")
119
+ return false unless query.key?("max_age")
120
+
121
+ max_age = Integer(query["max_age"])
122
+ return false if max_age.negative?
123
+
124
+ auth_time = OAuthProvider::Utils.resolve_session_auth_time(session)
125
+ return false unless auth_time
126
+
127
+ (Time.now - auth_time) > max_age
128
+ rescue ArgumentError, TypeError
129
+ false
130
+ end
131
+
114
132
  def oauth_prompt_redirect(ctx, config, query, type, page: nil)
115
133
  target = page || oauth_prompt_page(config, type)
116
134
 
@@ -204,7 +222,9 @@ module BetterAuth
204
222
  resolved = resolver.call({request_uri: query["request_uri"], client_id: query["client_id"], context: ctx})
205
223
  return oauth_invalid_request_uri!(ctx, query, "request_uri is invalid or expired") unless resolved
206
224
 
207
- OAuthProtocol.stringify_keys(resolved)
225
+ resolved_query = OAuthProtocol.stringify_keys(resolved)
226
+ resolved_query["client_id"] = query["client_id"] if query["client_id"]
227
+ resolved_query
208
228
  end
209
229
 
210
230
  def oauth_invalid_request_uri!(ctx, query, description)
@@ -5,7 +5,7 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def oauth_consent_endpoint(config)
8
- Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
8
+ Endpoint.new(path: "/oauth2/consent", method: "POST", metadata: oauth_openapi_for(:consent)) do |ctx|
9
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)
@@ -49,7 +49,8 @@ module BetterAuth
49
49
  code_challenge_method: query["code_challenge_method"],
50
50
  nonce: query["nonce"],
51
51
  reference_id: reference_id || client_reference_id,
52
- expires_in: config[:code_expires_in]
52
+ expires_in: config[:code_expires_in],
53
+ store_tokens: config[:store_tokens]
53
54
  )
54
55
  OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)))
55
56
  end
@@ -5,7 +5,7 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def oauth_continue_endpoint(config)
8
- Endpoint.new(path: "/oauth2/continue", method: "POST") do |ctx|
8
+ Endpoint.new(path: "/oauth2/continue", method: "POST", metadata: oauth_openapi_for(:continue)) do |ctx|
9
9
  Routes.current_session(ctx)
10
10
  body = OAuthProtocol.stringify_keys(ctx.body)
11
11
  action = if body["selected"] == true
@@ -5,8 +5,8 @@ module BetterAuth
5
5
  module_function
6
6
 
7
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])
8
+ Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: oauth_openapi_for(:introspect).merge(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], require_confidential: true)
10
10
  client_id = OAuthProtocol.stringify_keys(client)["clientId"]
11
11
  body = OAuthProtocol.stringify_keys(ctx.body)
12
12
  token_value = body["token"].to_s.sub(/\ABearer\s+/i, "")
@@ -5,7 +5,7 @@ module BetterAuth
5
5
  module_function
6
6
 
7
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|
8
+ Endpoint.new(path: "/oauth2/end-session", method: ["GET", "POST"], metadata: oauth_openapi_for(:end_session).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])) do |ctx|
9
9
  input = OAuthProtocol.stringify_keys((ctx.method == "GET") ? ctx.query : ctx.body)
10
10
  id_token_hint = input["id_token_hint"].to_s
11
11
  raise APIError.new("UNAUTHORIZED", message: "invalid id token") if id_token_hint.empty?
@@ -58,6 +58,8 @@ module BetterAuth
58
58
  handler.call(request, jwt)
59
59
  rescue APIError => error
60
60
  handle_mcp_errors(error, resource, resource_metadata_mappings: resource_metadata_mappings)
61
+ rescue ::JWT::DecodeError
62
+ handle_mcp_errors(APIError.new("UNAUTHORIZED", message: "invalid token"), resource, resource_metadata_mappings: resource_metadata_mappings)
61
63
  end
62
64
  end
63
65
  end
@@ -24,7 +24,8 @@ module BetterAuth
24
24
  scopes_supported: config.dig(:advertised_metadata, :scopes_supported) || config[:scopes]
25
25
  }
26
26
  metadata[:registration_endpoint] = "#{base}/oauth2/register" if config[:allow_dynamic_client_registration]
27
- metadata[:jwks_uri] = oauth_jwks_uri(config) if oauth_jwks_uri(config)
27
+ jwks_uri = oauth_jwks_uri(ctx, config)
28
+ metadata[:jwks_uri] = jwks_uri if jwks_uri
28
29
  ctx.json(metadata, headers: oauth_metadata_headers)
29
30
  end
30
31
  end
@@ -60,7 +61,8 @@ module BetterAuth
60
61
  claims_supported: config.dig(:advertised_metadata, :claims_supported) || config[:claims] || []
61
62
  }
62
63
  metadata[:registration_endpoint] = "#{base}/oauth2/register" if config[:allow_dynamic_client_registration]
63
- metadata[:jwks_uri] = oauth_jwks_uri(config) if oauth_jwks_uri(config)
64
+ jwks_uri = oauth_jwks_uri(ctx, config)
65
+ metadata[:jwks_uri] = jwks_uri if jwks_uri
64
66
  ctx.json(metadata, headers: oauth_metadata_headers)
65
67
  end
66
68
  end
@@ -69,10 +71,21 @@ module BetterAuth
69
71
  {"Cache-Control" => "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400"}
70
72
  end
71
73
 
72
- def oauth_jwks_uri(config)
74
+ def oauth_jwks_uri(ctx, config)
73
75
  config.dig(:advertised_metadata, :jwks_uri) ||
74
76
  config[:jwks_uri] ||
75
- config.dig(:jwks, :remote_url)
77
+ config.dig(:jwks, :remote_url) ||
78
+ oauth_default_jwks_uri(ctx, config)
79
+ end
80
+
81
+ def oauth_default_jwks_uri(ctx, config)
82
+ return nil if config[:disable_jwt_plugin]
83
+
84
+ jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
85
+ return nil unless jwt_plugin
86
+
87
+ path = jwt_plugin.options&.dig(:jwks, :jwks_path) || "/jwks"
88
+ "#{OAuthProtocol.endpoint_base(ctx)}#{path}"
76
89
  end
77
90
 
78
91
  def oauth_token_auth_methods(config)
@@ -5,7 +5,7 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def oauth_create_client_endpoint(config)
8
- Endpoint.new(path: "/oauth2/create-client", method: "POST") do |ctx|
8
+ Endpoint.new(path: "/oauth2/create-client", method: "POST", metadata: oauth_openapi_for(:create_client)) do |ctx|
9
9
  session = Routes.current_session(ctx)
10
10
  oauth_assert_client_privilege!(ctx, config, session, "create")
11
11
  body = OAuthProtocol.stringify_keys(ctx.body)
@@ -54,7 +54,12 @@ module BetterAuth
54
54
  end
55
55
 
56
56
  def oauth_get_client_public_prelogin_endpoint(config)
57
- Endpoint.new(path: "/oauth2/public-client-prelogin", method: "POST") do |ctx|
57
+ Endpoint.new(
58
+ path: "/oauth2/public-client-prelogin",
59
+ method: "POST",
60
+ body_schema: ->(value) { value },
61
+ metadata: oauth_openapi_for(:public_client_prelogin)
62
+ ) do |ctx|
58
63
  input = OAuthProtocol.stringify_keys(ctx.body).merge(OAuthProtocol.stringify_keys(ctx.query))
59
64
  unless config[:allow_public_client_prelogin] || config[:allowPublicClientPrelogin]
60
65
  raise APIError.new("BAD_REQUEST")
@@ -86,7 +91,7 @@ module BetterAuth
86
91
  end
87
92
 
88
93
  def oauth_delete_client_endpoint(config)
89
- Endpoint.new(path: "/oauth2/delete-client", method: "POST") do |ctx|
94
+ Endpoint.new(path: "/oauth2/delete-client", method: "POST", metadata: oauth_openapi_for(:delete_client)) do |ctx|
90
95
  session = Routes.current_session(ctx)
91
96
  oauth_assert_client_privilege!(ctx, config, session, "delete")
92
97
  body = OAuthProtocol.stringify_keys(ctx.body)
@@ -99,7 +104,7 @@ module BetterAuth
99
104
  end
100
105
 
101
106
  def oauth_update_client_endpoint(config)
102
- Endpoint.new(path: "/oauth2/update-client", method: "POST") do |ctx|
107
+ Endpoint.new(path: "/oauth2/update-client", method: "POST", metadata: oauth_openapi_for(:update_client)) do |ctx|
103
108
  session = Routes.current_session(ctx)
104
109
  oauth_assert_client_privilege!(ctx, config, session, "update")
105
110
  body = OAuthProtocol.stringify_keys(ctx.body)
@@ -159,7 +164,7 @@ module BetterAuth
159
164
  end
160
165
 
161
166
  def oauth_rotate_client_secret_endpoint(config)
162
- Endpoint.new(path: "/oauth2/client/rotate-secret", method: "POST") do |ctx|
167
+ Endpoint.new(path: "/oauth2/client/rotate-secret", method: "POST", metadata: oauth_openapi_for(:rotate_client_secret)) do |ctx|
163
168
  session = Routes.current_session(ctx)
164
169
  oauth_assert_client_privilege!(ctx, config, session, "rotate")
165
170
  body = OAuthProtocol.stringify_keys(ctx.body)
@@ -210,7 +215,7 @@ module BetterAuth
210
215
  end
211
216
 
212
217
  def oauth_legacy_update_client_endpoint(config)
213
- Endpoint.new(path: "/oauth2/client", method: "PATCH") do |ctx|
218
+ Endpoint.new(path: "/oauth2/client", method: "PATCH", metadata: oauth_openapi_for(:update_client)) do |ctx|
214
219
  session = Routes.current_session(ctx)
215
220
  oauth_assert_client_privilege!(ctx, config, session, "update")
216
221
  body = OAuthProtocol.stringify_keys(ctx.body)
@@ -30,7 +30,7 @@ module BetterAuth
30
30
  end
31
31
 
32
32
  def oauth_update_consent_endpoint
33
- Endpoint.new(path: "/oauth2/update-consent", method: "POST") do |ctx|
33
+ Endpoint.new(path: "/oauth2/update-consent", method: "POST", metadata: oauth_openapi_for(:update_consent)) do |ctx|
34
34
  session = Routes.current_session(ctx)
35
35
  body = OAuthProtocol.stringify_keys(ctx.body)
36
36
  id = body["id"]
@@ -61,7 +61,7 @@ module BetterAuth
61
61
  end
62
62
 
63
63
  def oauth_delete_consent_endpoint
64
- Endpoint.new(path: "/oauth2/delete-consent", method: "POST") do |ctx|
64
+ Endpoint.new(path: "/oauth2/delete-consent", method: "POST", metadata: oauth_openapi_for(:delete_consent)) do |ctx|
65
65
  session = Routes.current_session(ctx)
66
66
  body = OAuthProtocol.stringify_keys(ctx.body)
67
67
  id = body["id"]
@@ -98,7 +98,7 @@ module BetterAuth
98
98
  end
99
99
 
100
100
  def oauth_legacy_update_consent_endpoint
101
- Endpoint.new(path: "/oauth2/consent", method: "PATCH") do |ctx|
101
+ Endpoint.new(path: "/oauth2/consent", method: "PATCH", metadata: oauth_openapi_for(:update_consent)) do |ctx|
102
102
  session = Routes.current_session(ctx)
103
103
  body = OAuthProtocol.stringify_keys(ctx.body)
104
104
  consent = oauth_find_user_consent(ctx, session, body["client_id"])
@@ -5,7 +5,12 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def oauth_register_client_endpoint(config)
8
- Endpoint.new(path: "/oauth2/register", method: "POST") do |ctx|
8
+ Endpoint.new(
9
+ path: "/oauth2/register",
10
+ method: "POST",
11
+ body_schema: ->(value) { value },
12
+ metadata: oauth_openapi_for(:register_client)
13
+ ) do |ctx|
9
14
  session = Routes.current_session(ctx, allow_nil: true)
10
15
  body = OAuthProtocol.stringify_keys(ctx.body)
11
16
  unless config[:allow_dynamic_client_registration]
@@ -5,8 +5,8 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def oauth_revoke_endpoint(config)
8
- Endpoint.new(path: "/oauth2/revoke", 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])
8
+ Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: oauth_openapi_for(:revoke).merge(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], require_confidential: true)
10
10
  client_id = OAuthProtocol.stringify_keys(client)["clientId"]
11
11
  body = OAuthProtocol.stringify_keys(ctx.body)
12
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])
@@ -39,7 +39,20 @@ module BetterAuth
39
39
 
40
40
  if is_refresh && OAuthProtocol.schema_model?(ctx, "oauthRefreshToken")
41
41
  ctx.context.adapter.update(model: "oauthRefreshToken", where: [{field: "id", value: token["id"]}], update: {revoked: token["revoked"]})
42
+ oauth_revoke_refresh_access_tokens(ctx, config[:store], token)
42
43
  end
43
44
  end
45
+
46
+ def oauth_revoke_refresh_access_tokens(ctx, store, refresh_token)
47
+ refresh_id = refresh_token["id"]
48
+ return if refresh_id.to_s.empty?
49
+
50
+ store[:tokens].each_value do |record|
51
+ record["revoked"] = refresh_token["revoked"] if record["refreshId"].to_s == refresh_id.to_s
52
+ end
53
+ return unless OAuthProtocol.schema_model?(ctx, "oauthAccessToken")
54
+
55
+ ctx.context.adapter.update_many(model: "oauthAccessToken", where: [{field: "refreshId", value: refresh_id}], update: {revoked: refresh_token["revoked"]})
56
+ end
44
57
  end
45
58
  end
@@ -7,7 +7,7 @@ module BetterAuth
7
7
  def oauth_provider_schema
8
8
  {
9
9
  oauthClient: {
10
- modelName: "oauthClient",
10
+ model_name: "oauth_clients",
11
11
  fields: {
12
12
  clientId: {type: "string", unique: true, required: true},
13
13
  clientSecret: {type: "string", required: false},
@@ -16,7 +16,7 @@ module BetterAuth
16
16
  enableEndSession: {type: "boolean", required: false},
17
17
  clientSecretExpiresAt: {type: "number", required: false},
18
18
  scopes: {type: "string[]", required: false},
19
- userId: {type: "string", required: false},
19
+ userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
20
20
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
21
21
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
22
22
  name: {type: "string", required: false},
@@ -37,17 +37,17 @@ module BetterAuth
37
37
  type: {type: "string", required: false},
38
38
  requirePKCE: {type: "boolean", required: false},
39
39
  subjectType: {type: "string", required: false},
40
- referenceId: {type: "string", required: false},
40
+ referenceId: {type: "string", required: false, index: true},
41
41
  metadata: {type: "json", required: false}
42
42
  }
43
43
  },
44
44
  oauthRefreshToken: {
45
45
  fields: {
46
46
  token: {type: "string", required: true},
47
- clientId: {type: "string", required: true},
48
- sessionId: {type: "string", required: false},
49
- userId: {type: "string", required: false},
50
- referenceId: {type: "string", required: false},
47
+ clientId: {type: "string", required: true, index: true, references: {model: "oauthClient", field: "clientId"}},
48
+ sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
49
+ userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
50
+ referenceId: {type: "string", required: false, index: true},
51
51
  authTime: {type: "date", required: false},
52
52
  expiresAt: {type: "date", required: false},
53
53
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
@@ -56,28 +56,28 @@ module BetterAuth
56
56
  }
57
57
  },
58
58
  oauthAccessToken: {
59
- modelName: "oauthAccessToken",
59
+ model_name: "oauth_access_tokens",
60
60
  fields: {
61
61
  token: {type: "string", unique: true, required: true},
62
62
  expiresAt: {type: "date", required: true},
63
- clientId: {type: "string", required: true},
64
- userId: {type: "string", required: false},
65
- sessionId: {type: "string", required: false},
63
+ clientId: {type: "string", required: true, index: true, references: {model: "oauthClient", field: "clientId"}},
64
+ userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
65
+ sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
66
66
  scopes: {type: "string[]", required: true},
67
67
  revoked: {type: "date", required: false},
68
68
  referenceId: {type: "string", required: false},
69
69
  authTime: {type: "date", required: false},
70
- refreshId: {type: "string", required: false},
70
+ refreshId: {type: "string", required: false, index: true, references: {model: "oauthRefreshToken", field: "id"}},
71
71
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
72
72
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
73
73
  }
74
74
  },
75
75
  oauthConsent: {
76
- modelName: "oauthConsent",
76
+ model_name: "oauth_consents",
77
77
  fields: {
78
- clientId: {type: "string", required: true},
79
- userId: {type: "string", required: false},
80
- referenceId: {type: "string", required: false},
78
+ clientId: {type: "string", required: true, index: true, references: {model: "oauthClient", field: "clientId"}},
79
+ userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
80
+ referenceId: {type: "string", required: false, index: true},
81
81
  scopes: {type: "string[]", required: true},
82
82
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
83
83
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
@@ -5,9 +5,15 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def oauth_token_endpoint(config)
8
- Endpoint.new(path: "/oauth2/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
9
- body = OAuthProtocol.stringify_keys(ctx.body)
8
+ Endpoint.new(
9
+ path: "/oauth2/token",
10
+ method: "POST",
11
+ body_schema: ->(value) { value },
12
+ metadata: oauth_openapi_for(:token).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
13
+ ) do |ctx|
14
+ body = OAuthProtocol.request_body!(ctx.body)
10
15
  client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
16
+ client_id = OAuthProtocol.stringify_keys(client)["clientId"]
11
17
  client_grants = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["grantTypes"])
12
18
  if client_grants.any? && !client_grants.include?(body["grant_type"].to_s)
13
19
  raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
@@ -17,19 +23,21 @@ module BetterAuth
17
23
  code = OAuthProtocol.consume_code!(
18
24
  config[:store],
19
25
  body["code"],
20
- client_id: body["client_id"],
26
+ client_id: client_id,
21
27
  redirect_uri: body["redirect_uri"],
22
- code_verifier: body["code_verifier"]
28
+ code_verifier: body["code_verifier"],
29
+ store_tokens: config[:store_tokens]
23
30
  )
31
+ session = oauth_active_authorization_session!(ctx, code[:session])
24
32
  audience = oauth_validate_resource!(ctx, config, body, code[:scopes])
25
33
  OAuthProtocol.issue_tokens(
26
34
  ctx,
27
35
  config[:store],
28
36
  model: "oauthAccessToken",
29
37
  client: client,
30
- session: code[:session],
38
+ session: session,
31
39
  scopes: code[:scopes],
32
- include_refresh: code[:scopes].include?("offline_access") || OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["grantTypes"]).include?(OAuthProtocol::REFRESH_GRANT),
40
+ include_refresh: code[:scopes].include?("offline_access"),
33
41
  issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
34
42
  prefix: config[:prefix],
35
43
  refresh_token_expires_in: config[:refresh_token_expires_in],
@@ -46,7 +54,8 @@ module BetterAuth
46
54
  nonce: code[:nonce],
47
55
  auth_time: code[:auth_time],
48
56
  reference_id: code[:reference_id],
49
- filter_id_token_claims_by_scope: true
57
+ filter_id_token_claims_by_scope: true,
58
+ store_tokens: config[:store_tokens]
50
59
  )
51
60
  when OAuthProtocol::CLIENT_CREDENTIALS_GRANT
52
61
  requested = OAuthProtocol.parse_scopes(body["scope"])
@@ -66,19 +75,38 @@ module BetterAuth
66
75
  end
67
76
 
68
77
  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)
78
+ 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, store_tokens: config[:store_tokens])
70
79
  when OAuthProtocol::REFRESH_GRANT
71
80
  refresh_record = OAuthProtocol.find_token_by_hint(config[:store], body["refresh_token"].to_s, "refresh_token", prefix: config[:prefix])
72
81
  refresh_scopes = OAuthProtocol.parse_scopes(body["scope"] || refresh_record&.fetch("scopes", nil))
73
82
  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)
83
+ 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, store_tokens: config[:store_tokens])
75
84
  else
76
85
  raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
77
86
  end
78
- ctx.json(response)
87
+ ctx.json(response, headers: oauth_no_store_headers)
79
88
  end
80
89
  end
81
90
 
91
+ def oauth_no_store_headers
92
+ {"Cache-Control" => "no-store", "Pragma" => "no-cache"}
93
+ end
94
+
95
+ def oauth_active_authorization_session!(ctx, stored_session)
96
+ data = OAuthProtocol.stringify_keys(stored_session || {})
97
+ session_snapshot = OAuthProtocol.stringify_keys(data["session"] || data[:session] || {})
98
+ user_snapshot = OAuthProtocol.stringify_keys(data["user"] || data[:user] || {})
99
+ session_id = session_snapshot["id"]
100
+ stored = session_id && ctx.context.adapter.find_one(model: "session", where: [{field: "id", value: session_id}])
101
+ raise APIError.new("BAD_REQUEST", message: "session no longer exists") unless stored
102
+ raise APIError.new("BAD_REQUEST", message: "session no longer exists") if stored["expiresAt"] && stored["expiresAt"] <= Time.now
103
+
104
+ user = ctx.context.internal_adapter.find_user_by_id(stored["userId"] || user_snapshot["id"])
105
+ raise APIError.new("BAD_REQUEST", message: "missing user, user may have been deleted") unless user
106
+
107
+ {"user" => user, "session" => stored}
108
+ end
109
+
82
110
  def oauth_validate_resource!(ctx, config, body, scopes)
83
111
  resources = Array(body["resource"]).compact.map(&:to_s)
84
112
  return nil if resources.empty?
@@ -36,6 +36,7 @@ module BetterAuth
36
36
  singleton_class.remove_method(:oauth_provider) if singleton_class.method_defined?(:oauth_provider) || singleton_class.private_method_defined?(:oauth_provider)
37
37
 
38
38
  def oauth_provider(options = {})
39
+ raw_options = normalize_hash(options)
39
40
  config = {
40
41
  login_page: "/login",
41
42
  consent_page: "/oauth2/consent",
@@ -48,7 +49,8 @@ module BetterAuth
48
49
  signup: {},
49
50
  select_account: {},
50
51
  post_login: {},
51
- store_client_secret: "plain",
52
+ store_client_secret: "hashed",
53
+ store_tokens: "hashed",
52
54
  prefix: {},
53
55
  code_expires_in: 600,
54
56
  id_token_expires_in: 36_000,
@@ -58,12 +60,15 @@ module BetterAuth
58
60
  client_credential_grant_default_scopes: nil,
59
61
  scope_expirations: {},
60
62
  store: OAuthProtocol.stores
61
- }.merge(normalize_hash(options))
63
+ }.merge(raw_options)
64
+
65
+ oauth_provider_validate_config!(config, raw_options)
62
66
 
63
67
  Plugin.new(
64
68
  id: "oauth-provider",
65
69
  version: BetterAuth::OAuthProvider::VERSION,
66
70
  init: oauth_provider_init(config),
71
+ hooks: oauth_provider_hooks(config),
67
72
  endpoints: oauth_provider_endpoints(config),
68
73
  schema: oauth_provider_schema,
69
74
  rate_limit: oauth_provider_rate_limits(config),
@@ -71,6 +76,101 @@ module BetterAuth
71
76
  )
72
77
  end
73
78
 
79
+ def oauth_provider_validate_config!(config, raw_options = {})
80
+ provider_scopes = OAuthProtocol.parse_scopes(config[:scopes])
81
+ [
82
+ [:client_registration_allowed_scopes, config[:client_registration_allowed_scopes]],
83
+ [:client_registration_default_scopes, config[:client_registration_default_scopes]]
84
+ ].each do |key, value|
85
+ next if value.nil?
86
+
87
+ missing = OAuthProtocol.parse_scopes(value) - provider_scopes
88
+ unless missing.empty?
89
+ raise APIError.new("BAD_REQUEST", message: "#{key} #{missing.first} not found in scopes")
90
+ end
91
+ end
92
+
93
+ grant_types = Array(config[:grant_types]).map(&:to_s)
94
+ if grant_types.include?(OAuthProtocol::REFRESH_GRANT) && !grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT)
95
+ raise APIError.new("BAD_REQUEST", message: "refresh_token grant requires authorization_code grant")
96
+ end
97
+
98
+ store_client_secret = config[:store_client_secret]
99
+ if config[:disable_jwt_plugin] && raw_options.key?(:store_client_secret) && oauth_hashed_secret_storage?(store_client_secret)
100
+ raise APIError.new("BAD_REQUEST", message: "unable to store hashed secrets because id tokens will be signed with client secret")
101
+ end
102
+ if !config[:disable_jwt_plugin] && oauth_encrypted_secret_storage?(store_client_secret)
103
+ raise APIError.new("BAD_REQUEST", message: "encrypted secret storage is not recommended, please use hashed secret storage with the JWT plugin")
104
+ end
105
+ end
106
+
107
+ def oauth_hashed_secret_storage?(value)
108
+ mode = value.is_a?(Hash) ? normalize_hash(value) : value.to_s
109
+ mode == "hashed" || (mode.is_a?(Hash) && mode[:hash].respond_to?(:call))
110
+ end
111
+
112
+ def oauth_encrypted_secret_storage?(value)
113
+ mode = value.is_a?(Hash) ? normalize_hash(value) : value.to_s
114
+ mode == "encrypted" || (mode.is_a?(Hash) && (mode[:encrypt].respond_to?(:call) || mode[:decrypt].respond_to?(:call)))
115
+ end
116
+
117
+ def oauth_provider_hooks(config)
118
+ {
119
+ before: [
120
+ {
121
+ matcher: ->(ctx) { ctx.path.start_with?("/sign-in/", "/sign-up/") && !!oauth_query_from_body(ctx.body) },
122
+ handler: ->(ctx) { oauth_validate_query_hook!(ctx) }
123
+ }
124
+ ],
125
+ after: [
126
+ {
127
+ matcher: ->(ctx) { oauth_resume_after_session_cookie?(ctx) },
128
+ handler: ->(ctx) { oauth_resume_after_session_cookie(ctx, config) }
129
+ }
130
+ ]
131
+ }
132
+ end
133
+
134
+ def oauth_validate_query_hook!(ctx)
135
+ oauth_query = oauth_query_from_body(ctx.body)
136
+ return unless oauth_query
137
+
138
+ unless OAuthProvider::Utils.verify_oauth_query_params(oauth_query, ctx.context.secret)
139
+ raise APIError.new("BAD_REQUEST", message: "invalid_signature", body: {error: "invalid_signature"})
140
+ end
141
+
142
+ nil
143
+ end
144
+
145
+ def oauth_resume_after_session_cookie?(ctx)
146
+ return false unless oauth_query_from_body(ctx.body)
147
+ return false unless ctx.path.start_with?("/sign-in/", "/sign-up/")
148
+
149
+ ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
150
+ end
151
+
152
+ def oauth_resume_after_session_cookie(ctx, config)
153
+ query = oauth_verified_query!(ctx, oauth_query_from_body(ctx.body))
154
+ ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session
155
+ location = oauth_redirect_location { oauth_authorize_flow(ctx, config, query, continue_post_login: true) }
156
+ [302, Endpoint::Result.merge_headers(ctx.response_headers, {"location" => location}), [""]]
157
+ rescue APIError => error
158
+ raise APIError.new(
159
+ error.status,
160
+ message: error.message,
161
+ headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers),
162
+ code: error.code,
163
+ body: error.body
164
+ )
165
+ end
166
+
167
+ def oauth_query_from_body(body)
168
+ return nil unless body.is_a?(Hash)
169
+
170
+ data = OAuthProtocol.stringify_keys(body || {})
171
+ data["oauth_query"] || data["oauthQuery"]
172
+ end
173
+
74
174
  def oauth_provider_init(config)
75
175
  lambda do |context|
76
176
  advertised_scopes = Array(config.dig(:advertised_metadata, :scopes_supported)).map(&:to_s)
@@ -129,5 +229,419 @@ module BetterAuth
129
229
  o_auth2_end_session: oauth_end_session_endpoint
130
230
  }
131
231
  end
232
+
233
+ def oauth_openapi_for(route)
234
+ {
235
+ register_client: oauth_client_registration_openapi("Register a new OAuth2 client", include_secret: true),
236
+ create_client: oauth_client_registration_openapi("Create a new OAuth2 client", include_secret: true),
237
+ public_client_prelogin: oauth_public_client_prelogin_openapi,
238
+ delete_client: oauth_delete_client_openapi,
239
+ update_client: oauth_update_client_openapi,
240
+ rotate_client_secret: oauth_rotate_client_secret_openapi,
241
+ update_consent: oauth_update_consent_openapi,
242
+ delete_consent: oauth_delete_consent_openapi,
243
+ continue: oauth_continue_openapi,
244
+ consent: oauth_consent_openapi,
245
+ token: oauth_token_openapi,
246
+ introspect: oauth_introspect_openapi,
247
+ revoke: oauth_revoke_openapi,
248
+ end_session: oauth_end_session_openapi
249
+ }.fetch(route)
250
+ end
251
+
252
+ def oauth_client_registration_openapi(description, include_secret:)
253
+ {
254
+ openapi: {
255
+ description: description,
256
+ requestBody: OpenAPI.json_request_body(oauth_client_registration_schema),
257
+ responses: {
258
+ "201" => OpenAPI.json_response("OAuth2 client created successfully", oauth_client_response_schema(include_secret: include_secret))
259
+ }
260
+ }
261
+ }
262
+ end
263
+
264
+ def oauth_public_client_prelogin_openapi
265
+ {
266
+ openapi: {
267
+ description: "Get public OAuth2 client metadata before login",
268
+ requestBody: OpenAPI.json_request_body(
269
+ OpenAPI.object_schema(
270
+ {
271
+ client_id: {type: "string", description: "OAuth2 client ID"},
272
+ oauth_query: {type: "string", description: "Signed OAuth query string"}
273
+ },
274
+ required: ["client_id", "oauth_query"]
275
+ )
276
+ ),
277
+ responses: {
278
+ "200" => OpenAPI.json_response("Public OAuth2 client metadata", oauth_public_client_schema)
279
+ }
280
+ }
281
+ }
282
+ end
283
+
284
+ def oauth_delete_client_openapi
285
+ {
286
+ openapi: {
287
+ description: "Delete an OAuth2 client",
288
+ requestBody: OpenAPI.json_request_body(oauth_client_id_body_schema),
289
+ responses: {
290
+ "200" => OpenAPI.json_response("OAuth2 client deleted", OpenAPI.object_schema({deleted: {type: "boolean"}}, required: ["deleted"]))
291
+ }
292
+ }
293
+ }
294
+ end
295
+
296
+ def oauth_update_client_openapi
297
+ {
298
+ openapi: {
299
+ description: "Update an OAuth2 client",
300
+ requestBody: OpenAPI.json_request_body(
301
+ OpenAPI.object_schema(
302
+ {
303
+ client_id: {type: "string", description: "OAuth2 client ID"},
304
+ update: oauth_client_registration_schema.merge(description: "Client metadata to update")
305
+ },
306
+ required: ["client_id", "update"]
307
+ )
308
+ ),
309
+ responses: {
310
+ "200" => OpenAPI.json_response("OAuth2 client updated", oauth_client_response_schema(include_secret: false))
311
+ }
312
+ }
313
+ }
314
+ end
315
+
316
+ def oauth_rotate_client_secret_openapi
317
+ {
318
+ openapi: {
319
+ description: "Rotate an OAuth2 client secret",
320
+ requestBody: OpenAPI.json_request_body(oauth_client_id_body_schema),
321
+ responses: {
322
+ "200" => OpenAPI.json_response("OAuth2 client secret rotated", oauth_client_response_schema(include_secret: true))
323
+ }
324
+ }
325
+ }
326
+ end
327
+
328
+ def oauth_update_consent_openapi
329
+ {
330
+ openapi: {
331
+ description: "Update OAuth2 consent scopes",
332
+ requestBody: OpenAPI.json_request_body(oauth_consent_mutation_schema(required_update: false)),
333
+ responses: {
334
+ "200" => OpenAPI.json_response("OAuth2 consent updated", oauth_consent_response_schema)
335
+ }
336
+ }
337
+ }
338
+ end
339
+
340
+ def oauth_delete_consent_openapi
341
+ {
342
+ openapi: {
343
+ description: "Delete OAuth2 consent",
344
+ requestBody: OpenAPI.json_request_body(oauth_consent_identifier_schema),
345
+ responses: {
346
+ "200" => OpenAPI.json_response("OAuth2 consent deleted", OpenAPI.object_schema({deleted: {type: "boolean"}}, required: ["deleted"]))
347
+ }
348
+ }
349
+ }
350
+ end
351
+
352
+ def oauth_continue_openapi
353
+ {
354
+ openapi: {
355
+ description: "Continue an OAuth2 authorization interaction",
356
+ requestBody: OpenAPI.json_request_body(
357
+ OpenAPI.object_schema(
358
+ {
359
+ oauth_query: {type: "string", description: "Signed OAuth query string"},
360
+ selected: {type: "boolean", description: "Continue after account selection"},
361
+ created: {type: "boolean", description: "Continue after account creation"},
362
+ postLogin: {type: "boolean", description: "Continue after post-login flow"},
363
+ post_login: {type: "boolean", description: "Continue after post-login flow"}
364
+ },
365
+ required: ["oauth_query"]
366
+ )
367
+ ),
368
+ responses: {
369
+ "200" => OpenAPI.json_response("OAuth2 authorization redirect", oauth_redirect_response_schema)
370
+ }
371
+ }
372
+ }
373
+ end
374
+
375
+ def oauth_consent_openapi
376
+ {
377
+ openapi: {
378
+ description: "Submit an OAuth2 consent decision",
379
+ requestBody: OpenAPI.json_request_body(
380
+ OpenAPI.object_schema(
381
+ {
382
+ consent_code: {type: "string", description: "Consent code issued by the authorization flow"},
383
+ accept: {type: "boolean", description: "Whether the user accepted the consent request"},
384
+ scope: {type: "string", description: "Granted scopes as a space-delimited string"},
385
+ scopes: {type: "array", items: {type: "string"}, description: "Granted scopes"}
386
+ },
387
+ required: ["consent_code"]
388
+ )
389
+ ),
390
+ responses: {
391
+ "200" => OpenAPI.json_response("OAuth2 consent redirect", OpenAPI.object_schema({redirectURI: {type: "string"}}, required: ["redirectURI"]))
392
+ }
393
+ }
394
+ }
395
+ end
396
+
397
+ def oauth_token_openapi
398
+ {
399
+ openapi: {
400
+ description: "Exchange an OAuth2 grant for tokens",
401
+ requestBody: OpenAPI.json_request_body(
402
+ OpenAPI.object_schema(
403
+ {
404
+ grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT, OAuthProtocol::REFRESH_GRANT]},
405
+ code: {type: "string", description: "Authorization code"},
406
+ redirect_uri: {type: "string", format: "uri"},
407
+ code_verifier: {type: "string"},
408
+ client_id: {type: "string"},
409
+ client_secret: {type: "string"},
410
+ refresh_token: {type: "string"},
411
+ scope: {type: "string"},
412
+ resource: {oneOf: [{type: "string"}, {type: "array", items: {type: "string"}}]}
413
+ },
414
+ required: ["grant_type"]
415
+ )
416
+ ),
417
+ responses: {
418
+ "200" => OpenAPI.json_response("OAuth2 tokens issued", oauth_token_response_schema)
419
+ }
420
+ }
421
+ }
422
+ end
423
+
424
+ def oauth_introspect_openapi
425
+ {
426
+ openapi: {
427
+ description: "Introspect an OAuth2 token",
428
+ requestBody: OpenAPI.json_request_body(
429
+ OpenAPI.object_schema(
430
+ {
431
+ token: {type: "string", description: "Token to introspect"},
432
+ token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}
433
+ },
434
+ required: ["token"]
435
+ )
436
+ ),
437
+ responses: {
438
+ "200" => OpenAPI.json_response("OAuth2 token introspection result", oauth_introspection_response_schema)
439
+ }
440
+ }
441
+ }
442
+ end
443
+
444
+ def oauth_revoke_openapi
445
+ {
446
+ openapi: {
447
+ description: "Revoke an OAuth2 token",
448
+ requestBody: OpenAPI.json_request_body(
449
+ OpenAPI.object_schema(
450
+ {
451
+ token: {type: "string", description: "Token to revoke"},
452
+ token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}
453
+ },
454
+ required: ["token"]
455
+ )
456
+ ),
457
+ responses: {
458
+ "200" => OpenAPI.json_response("OAuth2 token revoked", OpenAPI.object_schema({revoked: {type: "boolean"}}, required: ["revoked"]))
459
+ }
460
+ }
461
+ }
462
+ end
463
+
464
+ def oauth_end_session_openapi
465
+ {
466
+ openapi: {
467
+ description: "End an OpenID Connect session",
468
+ parameters: oauth_end_session_parameters,
469
+ requestBody: OpenAPI.json_request_body(oauth_end_session_body_schema, required: false),
470
+ responses: {
471
+ "200" => OpenAPI.json_response("OpenID Connect session ended", OpenAPI.status_response_schema)
472
+ }
473
+ }
474
+ }
475
+ end
476
+
477
+ def oauth_client_registration_schema
478
+ OpenAPI.object_schema(
479
+ {
480
+ redirect_uris: {type: "array", items: {type: "string", format: "uri"}, description: "Allowed redirect URIs"},
481
+ post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}, description: "Allowed post logout redirect URIs"},
482
+ client_name: {type: "string", description: "OAuth2 client name"},
483
+ client_uri: {type: "string", format: "uri"},
484
+ logo_uri: {type: "string", format: "uri"},
485
+ contacts: {type: "array", items: {type: "string"}},
486
+ tos_uri: {type: "string", format: "uri"},
487
+ policy_uri: {type: "string", format: "uri"},
488
+ software_id: {type: "string"},
489
+ software_version: {type: "string"},
490
+ software_statement: {type: "string"},
491
+ token_endpoint_auth_method: {type: "string", enum: ["client_secret_basic", "client_secret_post", "none"]},
492
+ grant_types: {type: "array", items: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT, OAuthProtocol::REFRESH_GRANT]}},
493
+ response_types: {type: "array", items: {type: "string", enum: ["code"]}},
494
+ scope: {type: "string"},
495
+ scopes: {type: "array", items: {type: "string"}},
496
+ type: {type: "string", enum: ["web", "native", "user-agent-based"]},
497
+ require_pkce: {type: "boolean"},
498
+ requirePKCE: {type: "boolean"},
499
+ subject_type: {type: "string", enum: ["public", "pairwise"]},
500
+ subjectType: {type: "string", enum: ["public", "pairwise"]},
501
+ enable_end_session: {type: "boolean"},
502
+ enableEndSession: {type: "boolean"},
503
+ skip_consent: {type: "boolean"},
504
+ skipConsent: {type: "boolean"},
505
+ metadata: {type: "object", additionalProperties: true}
506
+ },
507
+ required: ["redirect_uris"]
508
+ )
509
+ end
510
+
511
+ def oauth_client_id_body_schema
512
+ OpenAPI.object_schema(
513
+ {
514
+ client_id: {type: "string", description: "OAuth2 client ID"}
515
+ },
516
+ required: ["client_id"]
517
+ )
518
+ end
519
+
520
+ def oauth_client_response_schema(include_secret:)
521
+ properties = oauth_public_client_schema[:properties].merge(
522
+ redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
523
+ post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
524
+ token_endpoint_auth_method: {type: "string"},
525
+ grant_types: {type: "array", items: {type: "string"}},
526
+ response_types: {type: "array", items: {type: "string"}},
527
+ scope: {type: "string"},
528
+ public: {type: "boolean"},
529
+ type: {type: ["string", "null"]},
530
+ user_id: {type: ["string", "null"]},
531
+ reference_id: {type: ["string", "null"]},
532
+ require_pkce: {type: ["boolean", "null"]},
533
+ subject_type: {type: ["string", "null"]},
534
+ metadata: {type: "object", additionalProperties: true},
535
+ client_id_issued_at: {type: "number"},
536
+ client_secret_expires_at: {type: "number"}
537
+ )
538
+ properties[:client_secret] = {type: "string", description: "OAuth2 client secret"} if include_secret
539
+ OpenAPI.object_schema(properties)
540
+ end
541
+
542
+ def oauth_public_client_schema
543
+ OpenAPI.object_schema(
544
+ {
545
+ client_id: {type: "string"},
546
+ client_name: {type: "string"},
547
+ client_uri: {type: ["string", "null"], format: "uri"},
548
+ logo_uri: {type: ["string", "null"], format: "uri"},
549
+ contacts: {type: "array", items: {type: "string"}},
550
+ tos_uri: {type: ["string", "null"], format: "uri"},
551
+ policy_uri: {type: ["string", "null"], format: "uri"}
552
+ }
553
+ )
554
+ end
555
+
556
+ def oauth_consent_identifier_schema
557
+ OpenAPI.object_schema(
558
+ {
559
+ id: {type: "string", description: "OAuth2 consent ID"},
560
+ client_id: {type: "string", description: "OAuth2 client ID"}
561
+ }
562
+ )
563
+ end
564
+
565
+ def oauth_consent_mutation_schema(required_update:)
566
+ OpenAPI.object_schema(
567
+ oauth_consent_identifier_schema[:properties].merge(
568
+ update: OpenAPI.object_schema({scopes: {type: "array", items: {type: "string"}}}),
569
+ scope: {type: "string"},
570
+ scopes: {type: "array", items: {type: "string"}}
571
+ ),
572
+ required: required_update ? ["update"] : []
573
+ )
574
+ end
575
+
576
+ def oauth_consent_response_schema
577
+ OpenAPI.object_schema(
578
+ {
579
+ id: {type: "string"},
580
+ clientId: {type: "string"},
581
+ userId: {type: "string"},
582
+ scopes: {type: "array", items: {type: "string"}},
583
+ createdAt: {type: "string", format: "date-time"},
584
+ updatedAt: {type: "string", format: "date-time"}
585
+ }
586
+ )
587
+ end
588
+
589
+ def oauth_redirect_response_schema
590
+ OpenAPI.object_schema(
591
+ {
592
+ redirect: {type: "boolean", enum: [true]},
593
+ url: {type: "string", format: "uri"}
594
+ },
595
+ required: ["redirect", "url"]
596
+ )
597
+ end
598
+
599
+ def oauth_token_response_schema
600
+ OpenAPI.object_schema(
601
+ {
602
+ access_token: {type: "string"},
603
+ token_type: {type: "string"},
604
+ expires_in: {type: "number"},
605
+ refresh_token: {type: "string"},
606
+ scope: {type: "string"},
607
+ id_token: {type: "string"}
608
+ },
609
+ required: ["access_token", "token_type"]
610
+ )
611
+ end
612
+
613
+ def oauth_introspection_response_schema
614
+ OpenAPI.object_schema(
615
+ {
616
+ active: {type: "boolean"},
617
+ client_id: {type: "string"},
618
+ scope: {type: "string"},
619
+ sub: {type: "string"},
620
+ iss: {type: "string"},
621
+ iat: {type: "number"},
622
+ exp: {type: "number"},
623
+ sid: {type: "string"},
624
+ aud: {oneOf: [{type: "string"}, {type: "array", items: {type: "string"}}]}
625
+ },
626
+ required: ["active"]
627
+ )
628
+ end
629
+
630
+ def oauth_end_session_parameters
631
+ oauth_end_session_body_schema[:properties].keys.map do |name|
632
+ OpenAPI.query_parameter(name.to_s, required: false, schema: oauth_end_session_body_schema[:properties][name])
633
+ end
634
+ end
635
+
636
+ def oauth_end_session_body_schema
637
+ OpenAPI.object_schema(
638
+ {
639
+ id_token_hint: {type: "string"},
640
+ client_id: {type: "string"},
641
+ post_logout_redirect_uri: {type: "string", format: "uri"},
642
+ state: {type: "string"}
643
+ }
644
+ )
645
+ end
132
646
  end
133
647
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-oauth-provider
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -119,14 +119,14 @@ files:
119
119
  - lib/better_auth/plugins/oauth_provider/types/zod.rb
120
120
  - lib/better_auth/plugins/oauth_provider/userinfo.rb
121
121
  - lib/better_auth/plugins/oauth_provider/utils/index.rb
122
- homepage: https://github.com/sebasxsala/better-auth
122
+ homepage: https://github.com/sebasxsala/better-auth-rb
123
123
  licenses:
124
124
  - MIT
125
125
  metadata:
126
- homepage_uri: https://github.com/sebasxsala/better-auth
127
- source_code_uri: https://github.com/sebasxsala/better-auth
128
- changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-oauth-provider/CHANGELOG.md
129
- bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
126
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
127
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
128
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-oauth-provider/CHANGELOG.md
129
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
130
130
  rdoc_options: []
131
131
  require_paths:
132
132
  - lib