standard_id-oidc 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/README.md +231 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/standard_id/oidc/application.css +15 -0
  6. data/app/controllers/standard_id/oidc/application_controller.rb +45 -0
  7. data/app/controllers/standard_id/oidc/consent_controller.rb +91 -0
  8. data/app/controllers/standard_id/oidc/discovery_controller.rb +106 -0
  9. data/app/controllers/standard_id/oidc/revocation_controller.rb +49 -0
  10. data/app/helpers/standard_id/oidc/application_helper.rb +6 -0
  11. data/app/jobs/standard_id/oidc/application_job.rb +6 -0
  12. data/app/mailers/standard_id/oidc/application_mailer.rb +8 -0
  13. data/app/models/standard_id/oidc/application_record.rb +7 -0
  14. data/app/models/standard_id/oidc/consent_grant.rb +41 -0
  15. data/app/models/standard_id/oidc/revoked_token.rb +31 -0
  16. data/app/views/layouts/standard_id/oidc/application.html.erb +17 -0
  17. data/config/routes.rb +15 -0
  18. data/db/migrate/20260312000001_create_standard_id_consent_grants.rb +19 -0
  19. data/db/migrate/20260312000002_create_standard_id_revoked_tokens.rb +16 -0
  20. data/lib/generators/standard_id/oidc/install/install_generator.rb +149 -0
  21. data/lib/generators/standard_id/oidc/install/templates/initializer.rb.erb +56 -0
  22. data/lib/standard_id/oidc/config/schema.rb +17 -0
  23. data/lib/standard_id/oidc/engine.rb +36 -0
  24. data/lib/standard_id/oidc/extensions/authorization_code_flow_ext.rb +13 -0
  25. data/lib/standard_id/oidc/extensions/authorization_flow_ext.rb +60 -0
  26. data/lib/standard_id/oidc/extensions/introspections_controller_ext.rb +60 -0
  27. data/lib/standard_id/oidc/extensions/token_grant_flow_ext.rb +38 -0
  28. data/lib/standard_id/oidc/extensions/traditional_code_grant_ext.rb +24 -0
  29. data/lib/standard_id/oidc/id_token_service.rb +40 -0
  30. data/lib/standard_id/oidc/version.rb +5 -0
  31. data/lib/standard_id/oidc.rb +22 -0
  32. data/lib/standard_id/provider.rb +10 -0
  33. data/lib/tasks/standard_id/oidc_tasks.rake +4 -0
  34. metadata +107 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0def62e4e3706d23a1c7725241984627119723c47ab8b37a6b18cd2612263fe6
4
+ data.tar.gz: 14b29713e5737c10d314829fd7ae89e8fa2a3099d2c15ebefe72bd0b68f994f0
5
+ SHA512:
6
+ metadata.gz: d05a49f2f2b26e11ed1215a603809669854ea3d8b7d2d5cea08e2e32dcbce2b2df3d1fbac0eebdba8e7761bbf8780c82460a24cd34b9822600d83b5b619a474b
7
+ data.tar.gz: e83d15c6f6a92da90678c4712985104ccaa57e335313271e1ec009cc2b45befd886b77ae14d9d17b695367d335bdbfc21aa30c284ffd26f2958ba40bdb167e42
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rarebit One
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # StandardId::Oidc
2
+
3
+ > [!WARNING]
4
+ > **Experimental.** This engine is not used in production by any rarebit-one
5
+ > app, `prepend`s into non-public `standard_id` internals (see below), and its
6
+ > API may change in any minor release. Pin an exact version if you adopt it.
7
+
8
+ > [!NOTE]
9
+ > **Formerly `standard_id-provider`.** That gem's final release is 0.5.0. To
10
+ > migrate, swap `gem "standard_id-provider"` for `gem "standard_id-oidc"`.
11
+ > Existing code keeps loading for now. `require "standard_id/provider"` still
12
+ > works, and `StandardId::Provider` is a deprecated alias of `StandardId::Oidc`
13
+ > (that includes `StandardId::Provider::Engine` in your routes). Both will be
14
+ > removed in a future minor release. Then rename:
15
+ >
16
+ > | Before | After |
17
+ > |---|---|
18
+ > | `StandardId::Provider::…` | `StandardId::Oidc::…` |
19
+ > | `rails g standard_id:provider:install` | `rails g standard_id:oidc:install` (the old generator name has **no** compat path: it fails with "Could not find generator") |
20
+ > | `config/initializers/standard_id_provider.rb` | `config/initializers/standard_id_oidc.rb` (the file name is cosmetic) |
21
+ >
22
+ > The shim covers runtime code only (the constant and the require path), not
23
+ > the generator name. You don't need to re-run the generator to upgrade. If you
24
+ > do, it treats `.standard_id_provider` migrations, the old initializer file and
25
+ > a `StandardId::Provider::Engine` mount as already installed.
26
+ >
27
+ > Unchanged: the `c.provider.*` settings (they configure the identity-provider
28
+ > role), the table names (`standard_id_consent_grants`,
29
+ > `standard_id_revoked_tokens`), and the migrations. Rails skips re-copying a
30
+ > migration whose name already exists.
31
+
32
+ OpenID Connect Identity Provider addon for [`standard_id`](https://github.com/rarebit-one/standard_id).
33
+
34
+ `standard_id` gives you OAuth 2.0 — authorization codes, token exchange, refresh
35
+ tokens, sessions. This engine adds the parts that make it an **OpenID Connect
36
+ identity provider**:
37
+
38
+ | | |
39
+ |---|---|
40
+ | **ID tokens** | Signed OIDC ID tokens issued alongside access tokens, with `nonce`, `auth_time`, `at_hash` and `c_hash` |
41
+ | **Consent** | Per-`(account, client)` scope grants, checked on subsequent authorization requests |
42
+ | **Access-token revocation** | An RFC 7009 endpoint backed by a `jti` denylist — the thing that makes revoking a *stateless* access token actually bite |
43
+ | **Discovery** | `/.well-known/openid-configuration`, built from core's document builder so it cannot drift |
44
+
45
+ ## Read this before adopting it
46
+
47
+ **This engine `prepend`s into non-public `standard_id` internals.** That is not
48
+ an implementation detail you can ignore — it is the coupling you are taking on.
49
+
50
+ It prepends into five classes, none of which are part of `standard_id`'s public
51
+ API and none of which carry a stability guarantee:
52
+
53
+ | Class | Why |
54
+ |---|---|
55
+ | `StandardId::Oauth::TokenGrantFlow` | issue an ID token with the access token; stamp `jti` |
56
+ | `StandardId::Oauth::AuthorizationCodeAuthorizationFlow` | carry `nonce` / consent through authorization |
57
+ | `StandardId::Oauth::Subflows::TraditionalCodeGrant` | ditto, for the traditional code grant |
58
+ | `StandardId::Oauth::AuthorizationCodeFlow` | ditto |
59
+ | `StandardId::Api::Oauth::IntrospectionsController` | contribute the revocation denylist into core's RFC 7662 endpoint (a **private** method) |
60
+
61
+ What that means in practice:
62
+
63
+ * **A `standard_id` minor release can break this gem** without breaking its own
64
+ semver contract, because the coupling is to internals rather than API.
65
+ * The gemspec therefore pins `standard_id` to `~> 0.33` — the oldest version
66
+ this gem's suite has actually been run against.
67
+ * A **`compat` CI job** resolves against the latest *published* `standard_id`
68
+ and runs the full suite against it on every push, so a break surfaces here
69
+ before it surfaces in your app.
70
+
71
+ If you want OIDC without that coupling, you want `standard_id` on its own plus
72
+ your own ID-token issuance. If you want the coupling handled for you, this is
73
+ the gem.
74
+
75
+ ## Installation
76
+
77
+ ```ruby
78
+ # Gemfile
79
+ gem "standard_id-oidc"
80
+ ```
81
+
82
+ ```bash
83
+ bundle install
84
+ rails generate standard_id:oidc:install
85
+ rails db:migrate
86
+ ```
87
+
88
+ The generator:
89
+
90
+ * copies the engine's migrations (`consent_grants`, `revoked_tokens`) into
91
+ `db/migrate/`
92
+ * writes `config/initializers/standard_id_oidc.rb`
93
+ * mounts the engine in `config/routes.rb`
94
+
95
+ It is **idempotent** — re-running skips anything already installed and says so.
96
+
97
+ | Flag | Effect |
98
+ |---|---|
99
+ | `--skip-migrations` | leave `db/migrate/` alone |
100
+ | `--skip-initializer` | do not write the initializer |
101
+ | `--skip-routes` | do not touch `config/routes.rb` |
102
+ | `--mount-path PATH` | mount somewhere other than `/` |
103
+ | `--force` | overwrite an existing initializer |
104
+
105
+ ## Mounting
106
+
107
+ ```ruby
108
+ # config/routes.rb
109
+ Rails.application.routes.draw do
110
+ mount StandardId::Oidc::Engine => "/"
111
+ mount StandardId::WebEngine => "/", as: :standard_id_web
112
+
113
+ scope "api" do
114
+ mount StandardId::ApiEngine => "/", as: :standard_id_api
115
+ end
116
+ end
117
+ ```
118
+
119
+ The engine serves, relative to its own mount:
120
+
121
+ | Route | What |
122
+ |---|---|
123
+ | `GET /.well-known/openid-configuration` | OIDC discovery document |
124
+ | `POST /api/provider/revoke` | RFC 7009 revocation — writes the `jti` denylist |
125
+ | `GET\|POST\|DELETE /api/provider/consent` | consent grant management |
126
+
127
+ Introspection is **not** here — see below.
128
+
129
+ ## Configuration
130
+
131
+ The generator writes a fully commented initializer. Two settings are load-bearing
132
+ and easy to miss.
133
+
134
+ ### `discovery_endpoint_base` — required
135
+
136
+ ```ruby
137
+ StandardId.configure do |c|
138
+ c.issuer = "https://auth.example.com"
139
+ c.oauth.discovery_endpoint_base = ->(request:) { "#{request.base_url}/api" }
140
+ end
141
+ ```
142
+
143
+ The **issuer** and the **endpoint base** are different things. The issuer is a
144
+ stable security identifier (RFC 8414 §2) that clients match byte-for-byte
145
+ against their discovery URL and against the `iss` claim of issued tokens. The
146
+ endpoint base is merely where the endpoints live.
147
+
148
+ This engine's discovery document is served from the *Provider* engine's mount.
149
+ Core's own well-known controllers sit inside the `ApiEngine` mount and can read
150
+ it out of `SCRIPT_NAME`; this one cannot. If you do not name the base, the
151
+ document will advertise URLs that 404.
152
+
153
+ Use `discovery_metadata_overrides` for members that cannot be derived — a
154
+ host-owned authorization shim, a deliberately narrower scope list. They are
155
+ applied last and win over everything else. Setting `issuer` there raises.
156
+
157
+ ### `introspection_enabled` — opt-in, and it is core's endpoint
158
+
159
+ ```ruby
160
+ c.oauth.introspection_enabled = true
161
+ ```
162
+
163
+ RFC 7662 introspection is **core's** endpoint (`POST /oauth/introspect`), not
164
+ this engine's. It is off by default and 404s until you opt in.
165
+
166
+ This engine used to ship a second one. It no longer does, because core's is
167
+ strictly more conformant — RFC 7662 §2.2 requires every failure mode to render
168
+ `{"active": false}` and nothing else, and this gem's answered `401` on bad
169
+ client credentials and `429` when throttled, which let a caller distinguish
170
+ "your credentials are wrong" and "you are throttled" from "that token is not
171
+ valid". The 429 in particular was a token-validity oracle.
172
+
173
+ What this engine contributes instead is the one thing core cannot do alone.
174
+ Access tokens are stateless, so core "cannot invalidate a stateless JWT before
175
+ its `exp`". This engine's revocation endpoint maintains a `jti` denylist, and
176
+ `IntrospectionsControllerExt` teaches core's endpoint to consult it — so a
177
+ revoked access token introspects as inactive immediately rather than staying
178
+ active until it expires.
179
+
180
+ ### Provider settings
181
+
182
+ ```ruby
183
+ c.provider.id_token_lifetime = 3600
184
+ c.provider.scopes_supported = %w[openid profile email offline_access]
185
+ c.provider.claims_supported = %w[sub iss aud exp iat nonce auth_time at_hash email name email_verified]
186
+ c.provider.subject_types_supported = %w[public]
187
+ c.provider.revocation_enabled = true # false renders /api/provider/revoke as 404
188
+ ```
189
+
190
+ The `*_supported` lists are advertised in the discovery document. They describe
191
+ what you are willing to issue — narrow them rather than widen them.
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ bin/rails app:db:test:prepare # build the dummy app's test database
197
+ bundle exec rspec
198
+ bundle exec rubocop --config .rubocop.yml
199
+ bundle exec brakeman --no-pager --force
200
+ bundle exec bundler-audit --update
201
+ ```
202
+
203
+ Specs run against `spec/dummy`, a host app mounting this engine alongside
204
+ `StandardId::WebEngine` and `StandardId::ApiEngine`. `spec/dummy/db/schema.rb`
205
+ and the vendored `spec/dummy/db/migrate/*.standard_id.rb` are committed;
206
+ refresh them with:
207
+
208
+ ```bash
209
+ bin/rails app:standard_id:install:migrations
210
+ bin/rails app:db:migrate
211
+ ```
212
+
213
+ `spec/dummy/config/database.yml` sets `migrations_paths` explicitly. **Leave it
214
+ that way** — without it Rails falls back to the relative default `"db/migrate"`,
215
+ which resolves against RSpec's working directory rather than the dummy app, and
216
+ the suite aborts on permanently-pending migrations.
217
+
218
+ ## Contributing
219
+
220
+ Bug reports and pull requests are welcome at
221
+ <https://github.com/rarebit-one/standard_id-oidc>.
222
+
223
+ Work happens in a git worktree, never the main checkout — see `CLAUDE.md`.
224
+ Commits are signed. CI runs RuboCop, Brakeman, bundler-audit, the full suite on
225
+ Ruby 4.0.0–4.0.4, and the `compat` job against the latest published
226
+ `standard_id`.
227
+
228
+ ## License
229
+
230
+ Available as open source under the terms of the
231
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,45 @@
1
+ module StandardId
2
+ module Oidc
3
+ class ApplicationController < ActionController::API
4
+ # Provides the shared rate-limit store + the TooManyRequests -> JSON 429
5
+ # handler (Retry-After header included), matching the standard_id engine's
6
+ # rate-limit response shape. Individual controllers declare their limits.
7
+ include StandardId::RateLimitHandling
8
+
9
+ rescue_from StandardId::OAuthError do |e|
10
+ render json: { error: e.oauth_error_code, error_description: e.message }, status: e.http_status
11
+ end
12
+
13
+ private
14
+
15
+ def authenticate_client!
16
+ extract_client_credentials_from_basic_auth
17
+
18
+ @client_credential = StandardId::ClientSecretCredential.active.find_by(client_id: params[:client_id])
19
+
20
+ unless @client_credential&.authenticate_client_secret(params[:client_secret])
21
+ raise StandardId::InvalidClientError, "Client authentication failed"
22
+ end
23
+
24
+ @client_credential
25
+ end
26
+
27
+ def extract_client_credentials_from_basic_auth
28
+ auth_header = request.headers["Authorization"]
29
+ return unless auth_header&.start_with?("Basic ")
30
+
31
+ if params[:client_id].present? || params[:client_secret].present?
32
+ raise StandardId::InvalidRequestError,
33
+ "Client credentials must be sent via Authorization header OR request body, not both"
34
+ end
35
+
36
+ decoded = Base64.strict_decode64(auth_header.split(" ", 2).last)
37
+ client_id, client_secret = decoded.split(":", 2)
38
+ params[:client_id] = CGI.unescape(client_id)
39
+ params[:client_secret] = CGI.unescape(client_secret)
40
+ rescue ArgumentError
41
+ raise StandardId::InvalidClientError, "Invalid Basic authentication encoding"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,91 @@
1
+ module StandardId
2
+ module Oidc
3
+ class ConsentController < ApplicationController
4
+ include ActionController::Cookies
5
+
6
+ before_action :require_authentication
7
+ before_action :load_client
8
+
9
+ def show
10
+ render json: {
11
+ client: { name: @client.name, description: @client.description },
12
+ scopes: requested_scopes,
13
+ authorization_params: authorization_params
14
+ }
15
+ end
16
+
17
+ def create
18
+ ConsentGrant.grant!(
19
+ account: current_account,
20
+ client_application: @client,
21
+ scopes: authorization_params[:scope]
22
+ )
23
+
24
+ authorize_url = build_authorize_redirect
25
+ redirect_to authorize_url, allow_other_host: true, status: :found
26
+ end
27
+
28
+ def destroy
29
+ redirect_uri = authorization_params[:redirect_uri]
30
+
31
+ if redirect_uri.present?
32
+ deny_url = build_error_redirect(redirect_uri, "access_denied", "The user denied the consent request")
33
+ redirect_to deny_url, allow_other_host: true, status: :found
34
+ else
35
+ render json: { error: "access_denied", error_description: "The user denied the consent request" }, status: :forbidden
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def require_authentication
42
+ return if current_account.present?
43
+
44
+ render json: { error: "login_required", error_description: "Authentication is required" }, status: :unauthorized
45
+ end
46
+
47
+ def current_account
48
+ @current_account ||= begin
49
+ token_manager = StandardId::Web::TokenManager.new(request)
50
+ session_manager = StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
51
+ session_manager.current_account
52
+ end
53
+ end
54
+
55
+ def load_client
56
+ @client = StandardId::ClientApplication.active.find_by(client_id: authorization_params[:client_id])
57
+
58
+ unless @client
59
+ raise StandardId::InvalidClientError, "Invalid client_id"
60
+ end
61
+ end
62
+
63
+ def authorization_params
64
+ @authorization_params ||= params.permit(
65
+ :client_id, :redirect_uri, :scope, :state, :audience,
66
+ :nonce, :response_type, :code_challenge, :code_challenge_method
67
+ ).to_h.symbolize_keys
68
+ end
69
+
70
+ def requested_scopes
71
+ authorization_params[:scope].to_s.split(/\s+/)
72
+ end
73
+
74
+ def build_authorize_redirect
75
+ uri = URI.parse("#{StandardId.config.issuer}/api/authorize")
76
+ uri.query = URI.encode_www_form(authorization_params.compact)
77
+ uri.to_s
78
+ end
79
+
80
+ def build_error_redirect(redirect_uri, error, description)
81
+ uri = URI.parse(redirect_uri)
82
+ query = URI.decode_www_form(uri.query || "")
83
+ query << [ "error", error ]
84
+ query << [ "error_description", description ]
85
+ query << [ "state", authorization_params[:state] ] if authorization_params[:state].present?
86
+ uri.query = URI.encode_www_form(query)
87
+ uri.to_s
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,106 @@
1
+ module StandardId
2
+ module Oidc
3
+ # OpenID Connect Discovery (`GET /.well-known/openid-configuration`).
4
+ #
5
+ # ## Why this no longer builds the document by hand
6
+ #
7
+ # It used to interpolate every endpoint off the issuer with a hardcoded
8
+ # `/api` segment:
9
+ #
10
+ # authorization_endpoint: "#{issuer}/api/authorize"
11
+ #
12
+ # That is the same defect core fixed in standard_id 0.33.0. It is wrong two
13
+ # ways at once: it assumes ApiEngine is mounted at exactly `/api`, and it
14
+ # conflates the ISSUER with the ENDPOINT BASE. Those are different things —
15
+ # the issuer is a stable security identifier (RFC 8414 §2) that clients
16
+ # match byte-for-byte against their discovery URL and against the `iss`
17
+ # claim, while the endpoint base is merely where the endpoints happen to
18
+ # live. An app whose issuer does not carry the mount path got a document
19
+ # advertising URLs that 404.
20
+ #
21
+ # Resolution is now core's: StandardId::Oauth::DiscoveryResolver reads
22
+ # `config.oauth.discovery_endpoint_base` and
23
+ # `config.oauth.discovery_metadata_overrides`, and the document itself is
24
+ # built by StandardId::Oauth::DiscoveryDocument, so this document cannot
25
+ # drift from the two core serves.
26
+ #
27
+ # ## Setting `discovery_endpoint_base` is not optional for this engine
28
+ #
29
+ # Core's own well-known controllers can derive the base from the request,
30
+ # because they are served from INSIDE the ApiEngine mount, where
31
+ # `request.script_name` IS the mount path. This controller is served from
32
+ # the PROVIDER engine's mount, which is a different mount (typically `/`).
33
+ # `:request` would therefore resolve to the origin root and advertise
34
+ # `<origin>/oauth/token` for an ApiEngine mounted at `/api`.
35
+ #
36
+ # So a host serving this document must say where ApiEngine actually is:
37
+ #
38
+ # c.oauth.discovery_endpoint_base = ->(request:) { "#{request.base_url}/api" }
39
+ # # or, for a fixed deployment:
40
+ # c.oauth.discovery_endpoint_base = "https://auth.example.com/api"
41
+ #
42
+ # That is the hardcoded `/api` moving out of gem code and into host config,
43
+ # which is the point.
44
+ class DiscoveryController < ApplicationController
45
+ def show
46
+ resolved = StandardId::Oauth::DiscoveryResolver.resolve(request: request)
47
+
48
+ if resolved[:issuer].blank?
49
+ render json: { error: "Issuer not configured" }, status: :not_found
50
+ return
51
+ end
52
+
53
+ response.headers["Cache-Control"] = "public, max-age=3600"
54
+ render json: openid_configuration(resolved)
55
+ end
56
+
57
+ private
58
+
59
+ def openid_configuration(resolved)
60
+ doc = StandardId::Oauth::DiscoveryDocument.build(
61
+ resolved[:issuer],
62
+ endpoint_base: resolved[:endpoint_base],
63
+ registration_enabled: StandardId.config.oauth.dynamic_registration_enabled,
64
+ introspection_enabled: StandardId.config.oauth.introspection_enabled
65
+ )
66
+
67
+ doc.merge!(provider_members)
68
+
69
+ # Host overrides are applied LAST so they win over the provider members
70
+ # too, matching how core's controllers treat them.
71
+ StandardId::Oauth::DiscoveryDocument.apply_overrides!(doc, resolved[:overrides])
72
+ end
73
+
74
+ # Only the OIDC IdP members core has no opinion on.
75
+ #
76
+ # Deliberately NOT re-stated here, though the old hand-built document did:
77
+ #
78
+ # * `response_types_supported: %w[code token]` — this engine extends the
79
+ # authorization-code flows only. Advertising the implicit flow's
80
+ # `token` promised something nothing implements.
81
+ # * `code_challenge_methods_supported: %w[S256 plain]` — core enforces
82
+ # PKCE with S256 and does not accept `plain`. Advertising it invites a
83
+ # downgrade attempt that is then rejected.
84
+ # * `id_token_signing_alg_values_supported` — core derives it from
85
+ # `config.oauth.signing_algorithm`, which is the same value.
86
+ #
87
+ # `revocation_endpoint` IS replaced: core advertises its own
88
+ # `/oauth/revoke`, but THIS engine's revoke action is the one that writes
89
+ # the StandardId::Oidc::RevokedToken denylist that introspection then
90
+ # consults. Built from the engine's own route helper so it follows the
91
+ # mount rather than hardcoding a prefix.
92
+ def provider_members
93
+ provider_config = StandardId.config.provider
94
+
95
+ {
96
+ revocation_endpoint: standard_id_oidc.revoke_url(
97
+ host: request.host_with_port, protocol: request.protocol
98
+ ),
99
+ scopes_supported: provider_config.scopes_supported,
100
+ subject_types_supported: provider_config.subject_types_supported,
101
+ claims_supported: provider_config.claims_supported
102
+ }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,49 @@
1
+ module StandardId
2
+ module Oidc
3
+ class RevocationController < ApplicationController
4
+ # Throttle by IP (30 per 15 min) BEFORE client authentication so this
5
+ # credential-guessable RFC 7009 endpoint can't be brute-forced. Override
6
+ # with RATE_LIMIT_REVOKE_PER_IP.
7
+ rate_limit to: (ENV["RATE_LIMIT_REVOKE_PER_IP"] || 30).to_i,
8
+ within: 15.minutes,
9
+ by: -> { request.remote_ip },
10
+ name: "provider-revoke-ip",
11
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
12
+
13
+ before_action :require_revocation_enabled!
14
+ before_action :authenticate_client!
15
+
16
+ def create
17
+ token = params[:token]
18
+ return head :ok if token.blank?
19
+
20
+ payload = StandardId::JwtService.decode(token)
21
+ return head :ok if payload.nil?
22
+
23
+ jti = payload[:jti]
24
+ return head :ok if jti.blank?
25
+
26
+ expires_at = payload[:exp] ? Time.at(payload[:exp]) : 1.day.from_now
27
+
28
+ RevokedToken.revoke!(
29
+ jti: jti,
30
+ client_id: @client_credential.client_id,
31
+ token_type: params[:token_type_hint],
32
+ expires_at: expires_at
33
+ )
34
+
35
+ head :ok
36
+ end
37
+
38
+ private
39
+
40
+ # `config.provider.revocation_enabled` was declared from the start and
41
+ # read by nothing, so the switch silently did not work. Enforced here,
42
+ # 404 rather than 403 so a disabled endpoint is indistinguishable from
43
+ # one that does not exist — matching how core gates introspection.
44
+ def require_revocation_enabled!
45
+ head(:not_found) unless StandardId.config.provider.revocation_enabled
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,6 @@
1
+ module StandardId
2
+ module Oidc
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module StandardId
2
+ module Oidc
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module StandardId
2
+ module Oidc
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module StandardId
2
+ module Oidc
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ module StandardId
2
+ module Oidc
3
+ class ConsentGrant < ApplicationRecord
4
+ self.table_name = "standard_id_consent_grants"
5
+
6
+ belongs_to :account, class_name: StandardId.config.account_class_name
7
+ belongs_to :client_application, class_name: "StandardId::ClientApplication"
8
+
9
+ validates :scopes, presence: true
10
+
11
+ scope :active, -> { where(revoked_at: nil) }
12
+ scope :revoked, -> { where.not(revoked_at: nil) }
13
+
14
+ before_validation :set_granted_at, on: :create
15
+
16
+ def self.grant!(account:, client_application:, scopes:)
17
+ transaction do
18
+ active.where(account: account, client_application: client_application).each(&:revoke!)
19
+ create!(account: account, client_application: client_application, scopes: scopes)
20
+ end
21
+ rescue ActiveRecord::RecordNotUnique
22
+ retry
23
+ end
24
+
25
+ def revoke!
26
+ update!(revoked_at: Time.current)
27
+ end
28
+
29
+ def covers_scopes?(requested_scopes)
30
+ granted = scopes.to_s.split(/\s+/)
31
+ (requested_scopes - granted).empty?
32
+ end
33
+
34
+ private
35
+
36
+ def set_granted_at
37
+ self.granted_at ||= Time.current
38
+ end
39
+ end
40
+ end
41
+ end