standard_id-provider 0.3.0 → 0.4.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: b9592cd119ffb18ff4e2cf19a9feaca0058aee05edfe5ab24a01f18fbd3c4b1b
4
- data.tar.gz: dc8fce05e463d51f56bfe482e7589ab274a183b58fa914ceca08fa53ebdbce42
3
+ metadata.gz: f96f308e0b1b4b60787511200833a782631a6e1934a2f53b421172651c0b6d84
4
+ data.tar.gz: 1fc3b9bb28515cabeee83feb4c796f16fa77313fbee2977ac309ac1759955c85
5
5
  SHA512:
6
- metadata.gz: f6c57e6ee827fb92f2a48266c01579a272fb5b8c6605da5ed6c078d3aeb796ad4e6dfb2ebb501c75eb99239426628019f26f80aed085dc536794829e3854e86e
7
- data.tar.gz: 1d3eb26ad72aa0b2c4958771f2f008355329213eb4863ab9a03cbecf5807f3e230eef1382813da5d68c6a39e287ac90f8c358a63e22978db1a008e341ed96f94
6
+ metadata.gz: 1246b085ad43df34f775c8c953123cc2e4bb00e0327591d5b7f18bfed36cc43da6fd468e7b96677f499433e4841f0634ade7eff44f98fed59700e0cfa5e97e5b
7
+ data.tar.gz: 72391d0a9c8b01c9f2b50b53f0c5582dede2987cec347d0451f26d2086944b228fcaa408b482f23539a71968e5f408ff8901ae06561daa31236587aa8692ca91
data/README.md CHANGED
@@ -1,28 +1,202 @@
1
1
  # StandardId::Provider
2
- Short description and motivation.
3
2
 
4
- ## Usage
5
- How to use my plugin.
3
+ OpenID Connect Identity Provider addon for [`standard_id`](https://github.com/rarebit-one/standard_id).
4
+
5
+ `standard_id` gives you OAuth 2.0 — authorization codes, token exchange, refresh
6
+ tokens, sessions. This engine adds the parts that make it an **OpenID Connect
7
+ identity provider**:
8
+
9
+ | | |
10
+ |---|---|
11
+ | **ID tokens** | Signed OIDC ID tokens issued alongside access tokens, with `nonce`, `auth_time`, `at_hash` and `c_hash` |
12
+ | **Consent** | Per-`(account, client)` scope grants, checked on subsequent authorization requests |
13
+ | **Access-token revocation** | An RFC 7009 endpoint backed by a `jti` denylist — the thing that makes revoking a *stateless* access token actually bite |
14
+ | **Discovery** | `/.well-known/openid-configuration`, built from core's document builder so it cannot drift |
15
+
16
+ ## Read this before adopting it
17
+
18
+ **This engine `prepend`s into non-public `standard_id` internals.** That is not
19
+ an implementation detail you can ignore — it is the coupling you are taking on.
20
+
21
+ It prepends into five classes, none of which are part of `standard_id`'s public
22
+ API and none of which carry a stability guarantee:
23
+
24
+ | Class | Why |
25
+ |---|---|
26
+ | `StandardId::Oauth::TokenGrantFlow` | issue an ID token with the access token; stamp `jti` |
27
+ | `StandardId::Oauth::AuthorizationCodeAuthorizationFlow` | carry `nonce` / consent through authorization |
28
+ | `StandardId::Oauth::Subflows::TraditionalCodeGrant` | ditto, for the traditional code grant |
29
+ | `StandardId::Oauth::AuthorizationCodeFlow` | ditto |
30
+ | `StandardId::Api::Oauth::IntrospectionsController` | contribute the revocation denylist into core's RFC 7662 endpoint (a **private** method) |
31
+
32
+ What that means in practice:
33
+
34
+ * **A `standard_id` minor release can break this gem** without breaking its own
35
+ semver contract, because the coupling is to internals rather than API.
36
+ * The gemspec therefore pins `standard_id` to `~> 0.33` — the oldest version
37
+ this gem's suite has actually been run against.
38
+ * A **`compat` CI job** resolves against the latest *published* `standard_id`
39
+ and runs the full suite against it on every push, so a break surfaces here
40
+ before it surfaces in your app.
41
+
42
+ If you want OIDC without that coupling, you want `standard_id` on its own plus
43
+ your own ID-token issuance. If you want the coupling handled for you, this is
44
+ the gem.
6
45
 
7
46
  ## Installation
8
- Add this line to your application's Gemfile:
9
47
 
10
48
  ```ruby
49
+ # Gemfile
11
50
  gem "standard_id-provider"
12
51
  ```
13
52
 
14
- And then execute:
15
53
  ```bash
16
- $ bundle
54
+ bundle install
55
+ rails generate standard_id:provider:install
56
+ rails db:migrate
57
+ ```
58
+
59
+ The generator:
60
+
61
+ * copies the engine's migrations (`consent_grants`, `revoked_tokens`) into
62
+ `db/migrate/`
63
+ * writes `config/initializers/standard_id_provider.rb`
64
+ * mounts the engine in `config/routes.rb`
65
+
66
+ It is **idempotent** — re-running skips anything already installed and says so.
67
+
68
+ | Flag | Effect |
69
+ |---|---|
70
+ | `--skip-migrations` | leave `db/migrate/` alone |
71
+ | `--skip-initializer` | do not write the initializer |
72
+ | `--skip-routes` | do not touch `config/routes.rb` |
73
+ | `--mount-path PATH` | mount somewhere other than `/` |
74
+ | `--force` | overwrite an existing initializer |
75
+
76
+ ## Mounting
77
+
78
+ ```ruby
79
+ # config/routes.rb
80
+ Rails.application.routes.draw do
81
+ mount StandardId::Provider::Engine => "/"
82
+ mount StandardId::WebEngine => "/", as: :standard_id_web
83
+
84
+ scope "api" do
85
+ mount StandardId::ApiEngine => "/", as: :standard_id_api
86
+ end
87
+ end
17
88
  ```
18
89
 
19
- Or install it yourself as:
90
+ The engine serves, relative to its own mount:
91
+
92
+ | Route | What |
93
+ |---|---|
94
+ | `GET /.well-known/openid-configuration` | OIDC discovery document |
95
+ | `POST /api/provider/revoke` | RFC 7009 revocation — writes the `jti` denylist |
96
+ | `GET\|POST\|DELETE /api/provider/consent` | consent grant management |
97
+
98
+ Introspection is **not** here — see below.
99
+
100
+ ## Configuration
101
+
102
+ The generator writes a fully commented initializer. Two settings are load-bearing
103
+ and easy to miss.
104
+
105
+ ### `discovery_endpoint_base` — required
106
+
107
+ ```ruby
108
+ StandardId.configure do |c|
109
+ c.issuer = "https://auth.example.com"
110
+ c.oauth.discovery_endpoint_base = ->(request:) { "#{request.base_url}/api" }
111
+ end
112
+ ```
113
+
114
+ The **issuer** and the **endpoint base** are different things. The issuer is a
115
+ stable security identifier (RFC 8414 §2) that clients match byte-for-byte
116
+ against their discovery URL and against the `iss` claim of issued tokens. The
117
+ endpoint base is merely where the endpoints live.
118
+
119
+ This engine's discovery document is served from the *Provider* engine's mount.
120
+ Core's own well-known controllers sit inside the `ApiEngine` mount and can read
121
+ it out of `SCRIPT_NAME`; this one cannot. If you do not name the base, the
122
+ document will advertise URLs that 404.
123
+
124
+ Use `discovery_metadata_overrides` for members that cannot be derived — a
125
+ host-owned authorization shim, a deliberately narrower scope list. They are
126
+ applied last and win over everything else. Setting `issuer` there raises.
127
+
128
+ ### `introspection_enabled` — opt-in, and it is core's endpoint
129
+
130
+ ```ruby
131
+ c.oauth.introspection_enabled = true
132
+ ```
133
+
134
+ RFC 7662 introspection is **core's** endpoint (`POST /oauth/introspect`), not
135
+ this engine's. It is off by default and 404s until you opt in.
136
+
137
+ This engine used to ship a second one. It no longer does, because core's is
138
+ strictly more conformant — RFC 7662 §2.2 requires every failure mode to render
139
+ `{"active": false}` and nothing else, and this gem's answered `401` on bad
140
+ client credentials and `429` when throttled, which let a caller distinguish
141
+ "your credentials are wrong" and "you are throttled" from "that token is not
142
+ valid". The 429 in particular was a token-validity oracle.
143
+
144
+ What this engine contributes instead is the one thing core cannot do alone.
145
+ Access tokens are stateless, so core "cannot invalidate a stateless JWT before
146
+ its `exp`". This engine's revocation endpoint maintains a `jti` denylist, and
147
+ `IntrospectionsControllerExt` teaches core's endpoint to consult it — so a
148
+ revoked access token introspects as inactive immediately rather than staying
149
+ active until it expires.
150
+
151
+ ### Provider settings
152
+
153
+ ```ruby
154
+ c.provider.id_token_lifetime = 3600
155
+ c.provider.scopes_supported = %w[openid profile email offline_access]
156
+ c.provider.claims_supported = %w[sub iss aud exp iat nonce auth_time at_hash email name email_verified]
157
+ c.provider.subject_types_supported = %w[public]
158
+ c.provider.revocation_enabled = true # false renders /api/provider/revoke as 404
159
+ ```
160
+
161
+ The `*_supported` lists are advertised in the discovery document. They describe
162
+ what you are willing to issue — narrow them rather than widen them.
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ bin/rails app:db:test:prepare # build the dummy app's test database
168
+ bundle exec rspec
169
+ bundle exec rubocop --config .rubocop.yml
170
+ bundle exec brakeman --no-pager --force
171
+ bundle exec bundler-audit --update
172
+ ```
173
+
174
+ Specs run against `spec/dummy`, a host app mounting this engine alongside
175
+ `StandardId::WebEngine` and `StandardId::ApiEngine`. `spec/dummy/db/schema.rb`
176
+ and the vendored `spec/dummy/db/migrate/*.standard_id.rb` are committed;
177
+ refresh them with:
178
+
20
179
  ```bash
21
- $ gem install standard_id-provider
180
+ bin/rails app:standard_id:install:migrations
181
+ bin/rails app:db:migrate
22
182
  ```
23
183
 
184
+ `spec/dummy/config/database.yml` sets `migrations_paths` explicitly. **Leave it
185
+ that way** — without it Rails falls back to the relative default `"db/migrate"`,
186
+ which resolves against RSpec's working directory rather than the dummy app, and
187
+ the suite aborts on permanently-pending migrations.
188
+
24
189
  ## Contributing
25
- Contribution directions go here.
190
+
191
+ Bug reports and pull requests are welcome at
192
+ <https://github.com/rarebit-one/standard_id-provider>.
193
+
194
+ Work happens in a git worktree, never the main checkout — see `CLAUDE.md`.
195
+ Commits are signed. CI runs RuboCop, Brakeman, bundler-audit, the full suite on
196
+ Ruby 4.0.0–4.0.4, and the `compat` job against the latest published
197
+ `standard_id`.
26
198
 
27
199
  ## License
28
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
200
+
201
+ Available as open source under the terms of the
202
+ [MIT License](https://opensource.org/licenses/MIT).
@@ -1,33 +1,105 @@
1
1
  module StandardId
2
2
  module Provider
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.
3
44
  class DiscoveryController < ApplicationController
4
45
  def show
5
- render json: openid_configuration
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)
6
55
  end
7
56
 
8
57
  private
9
58
 
10
- def openid_configuration
11
- issuer = StandardId.config.issuer
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::Provider::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
12
93
  provider_config = StandardId.config.provider
13
94
 
14
95
  {
15
- issuer: issuer,
16
- authorization_endpoint: "#{issuer}/api/authorize",
17
- token_endpoint: "#{issuer}/api/oauth/token",
18
- userinfo_endpoint: "#{issuer}/api/userinfo",
19
- jwks_uri: "#{issuer}/api/.well-known/jwks.json",
20
- introspection_endpoint: "#{issuer}/api/provider/introspect",
21
- revocation_endpoint: "#{issuer}/api/provider/revoke",
96
+ revocation_endpoint: standard_id_provider.revoke_url(
97
+ host: request.host_with_port, protocol: request.protocol
98
+ ),
22
99
  scopes_supported: provider_config.scopes_supported,
23
- response_types_supported: %w[code token],
24
- grant_types_supported: %w[authorization_code client_credentials refresh_token],
25
100
  subject_types_supported: provider_config.subject_types_supported,
26
- id_token_signing_alg_values_supported: [ StandardId::JwtService.algorithm ],
27
- token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
28
- claims_supported: provider_config.claims_supported,
29
- code_challenge_methods_supported: %w[S256 plain]
30
- }.compact
101
+ claims_supported: provider_config.claims_supported
102
+ }
31
103
  end
32
104
  end
33
105
  end
@@ -10,6 +10,7 @@ module StandardId
10
10
  name: "provider-revoke-ip",
11
11
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
12
12
 
13
+ before_action :require_revocation_enabled!
13
14
  before_action :authenticate_client!
14
15
 
15
16
  def create
@@ -33,6 +34,16 @@ module StandardId
33
34
 
34
35
  head :ok
35
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
36
47
  end
37
48
  end
38
49
  end
data/config/routes.rb CHANGED
@@ -1,11 +1,15 @@
1
1
  StandardId::Provider::Engine.routes.draw do
2
2
  scope ".well-known" do
3
- get "openid-configuration", to: "discovery#show"
3
+ get "openid-configuration", to: "discovery#show", as: :openid_configuration
4
4
  end
5
5
 
6
6
  scope "api/provider" do
7
- post "introspect", to: "introspection#create"
8
- post "revoke", to: "revocation#create"
7
+ # NOTE: there is deliberately no `introspect` route here any more. Core's
8
+ # RFC 7662 endpoint (POST /oauth/introspect, gated on
9
+ # `config.oauth.introspection_enabled`) is the one endpoint, and this engine
10
+ # contributes its denylist into it — see
11
+ # StandardId::Provider::Extensions::IntrospectionsControllerExt.
12
+ post "revoke", to: "revocation#create", as: :revoke
9
13
  resource :consent, only: [ :show, :create, :destroy ], controller: :consent
10
14
  end
11
15
  end
@@ -0,0 +1,138 @@
1
+ require "rails/generators"
2
+
3
+ module StandardId
4
+ module Provider
5
+ module Generators
6
+ # Installs StandardId::Provider in a host Rails application.
7
+ #
8
+ # Three steps, each independently skippable:
9
+ #
10
+ # * copies the engine's migrations into db/migrate/
11
+ # * writes config/initializers/standard_id_provider.rb
12
+ # * mounts the engine in config/routes.rb
13
+ #
14
+ # Idempotent: re-running skips pieces that are already installed and says
15
+ # so. Pass `--skip-*` to opt out of individual steps and `--force` to
16
+ # overwrite an existing initializer.
17
+ class InstallGenerator < Rails::Generators::Base
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ desc <<~DESC
21
+ Installs StandardId::Provider. By default this:
22
+ * copies the engine's migrations into db/migrate/
23
+ * writes config/initializers/standard_id_provider.rb
24
+ * mounts StandardId::Provider::Engine in config/routes.rb
25
+
26
+ Use --skip-* flags to opt out of individual steps when re-running on
27
+ an existing install. The generator is idempotent — already-installed
28
+ pieces are skipped with a clear message. Pass --force to overwrite an
29
+ existing initializer.
30
+
31
+ Run `rails db:migrate` afterwards.
32
+ DESC
33
+
34
+ class_option :skip_migrations, type: :boolean, default: false,
35
+ desc: "Do not copy the engine's migrations into db/migrate"
36
+ class_option :skip_initializer, type: :boolean, default: false,
37
+ desc: "Do not write config/initializers/standard_id_provider.rb"
38
+ class_option :skip_routes, type: :boolean, default: false,
39
+ desc: "Do not mount StandardId::Provider::Engine in config/routes.rb"
40
+ class_option :mount_path, type: :string, default: "/",
41
+ desc: "Path to mount StandardId::Provider::Engine at"
42
+ class_option :force, type: :boolean, default: false,
43
+ desc: "Overwrite config/initializers/standard_id_provider.rb if it already exists"
44
+
45
+ # Copies the engine's migrations with the same `.standard_id_provider`
46
+ # suffix `rails standard_id_provider:install:migrations` uses, so the
47
+ # two are interchangeable and neither double-installs the other's work.
48
+ # Done in-process rather than by shelling out to that rake task so the
49
+ # generator stays testable and does not need a booted host app.
50
+ def copy_migrations
51
+ if options[:skip_migrations]
52
+ say_status("skip", "db/migrate (--skip-migrations)", :yellow)
53
+ return
54
+ end
55
+
56
+ if existing_migrations.any?
57
+ say_status(
58
+ "identical",
59
+ "StandardId::Provider migrations already present (#{existing_migrations.size}), skipping",
60
+ :blue
61
+ )
62
+ return
63
+ end
64
+
65
+ engine_migrations.each_with_index do |source, index|
66
+ name = File.basename(source, ".rb").sub(/\A\d+_/, "")
67
+ copy_file source, "db/migrate/#{migration_number(index)}_#{name}.standard_id_provider.rb"
68
+ end
69
+ end
70
+
71
+ def copy_initializer
72
+ path = "config/initializers/standard_id_provider.rb"
73
+
74
+ if options[:skip_initializer]
75
+ say_status("skip", "#{path} (--skip-initializer)", :yellow)
76
+ return
77
+ end
78
+
79
+ if File.exist?(File.join(destination_root, path)) && !options[:force]
80
+ say_status("identical", "#{path} (already exists; pass --force to overwrite)", :blue)
81
+ return
82
+ end
83
+
84
+ template "initializer.rb.erb", path, force: options[:force]
85
+ end
86
+
87
+ def mount_engine
88
+ routes_path = "config/routes.rb"
89
+
90
+ if options[:skip_routes]
91
+ say_status("skip", "#{routes_path} (--skip-routes)", :yellow)
92
+ return
93
+ end
94
+
95
+ unless File.exist?(File.join(destination_root, routes_path))
96
+ say_status("warn", "#{routes_path} not found; mount the engine yourself", :red)
97
+ return
98
+ end
99
+
100
+ if File.read(File.join(destination_root, routes_path)).include?("StandardId::Provider::Engine")
101
+ say_status("identical", "#{routes_path} (engine already mounted)", :blue)
102
+ return
103
+ end
104
+
105
+ route(%(mount StandardId::Provider::Engine => "#{options[:mount_path]}"))
106
+ end
107
+
108
+ def print_next_steps
109
+ say ""
110
+ say "StandardId::Provider installed. Next:", :green
111
+ say " 1. rails db:migrate"
112
+ say " 2. Set config.oauth.discovery_endpoint_base in your StandardId"
113
+ say " initializer — this engine's discovery document cannot detect"
114
+ say " the ApiEngine mount on its own. See the README."
115
+ say " 3. Set config.oauth.introspection_enabled = true if you want the"
116
+ say " RFC 7662 endpoint (it is off by default in standard_id)."
117
+ say ""
118
+ end
119
+
120
+ no_commands do
121
+ def existing_migrations
122
+ Dir.glob(File.join(destination_root, "db/migrate/*.standard_id_provider.rb"))
123
+ end
124
+
125
+ def engine_migrations
126
+ Dir.glob(File.expand_path("../../../../../db/migrate/*.rb", __dir__)).sort
127
+ end
128
+
129
+ # Migrations are ordered among themselves and must not collide, hence
130
+ # the index offset. `install:migrations` does the same thing.
131
+ def migration_number(index)
132
+ (Time.now.utc + index).strftime("%Y%m%d%H%M%S")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,56 @@
1
+ # StandardId::Provider — OpenID Connect Identity Provider addon for StandardId.
2
+ #
3
+ # This engine EXTENDS standard_id rather than standing alone: it prepends into
4
+ # StandardId's OAuth flow classes and contributes its token denylist into
5
+ # StandardId's introspection endpoint. Your `standard_id.rb` initializer is
6
+ # still the primary configuration surface; the settings below are the ones this
7
+ # engine adds, plus the two core settings a provider deployment must revisit.
8
+
9
+ StandardId.configure do |c|
10
+ # --- Settings this engine adds -------------------------------------------
11
+
12
+ # Lifetime of issued ID tokens, in seconds.
13
+ # c.provider.id_token_lifetime = 3600
14
+
15
+ # Advertised in the discovery document. These describe what YOU are willing
16
+ # to issue — narrow them rather than widen them.
17
+ # c.provider.scopes_supported = %w[openid profile email offline_access]
18
+ # c.provider.claims_supported = %w[sub iss aud exp iat nonce auth_time at_hash email name email_verified]
19
+ # c.provider.subject_types_supported = %w[public]
20
+
21
+ # RFC 7009 token revocation at POST <provider mount>/api/provider/revoke.
22
+ # When false the endpoint renders 404, indistinguishable from one that does
23
+ # not exist. This is the endpoint that writes the RevokedToken denylist, so
24
+ # turning it off means access tokens cannot be revoked before their exp.
25
+ # c.provider.revocation_enabled = true
26
+
27
+ # --- Core settings a provider deployment must revisit --------------------
28
+
29
+ # REQUIRED for discovery. The issuer is a stable security identifier
30
+ # (RFC 8414 §2); clients match it byte-for-byte against their discovery URL
31
+ # and against the `iss` claim of issued tokens.
32
+ # c.issuer = "https://auth.example.com"
33
+
34
+ # REQUIRED for discovery, and easy to miss. This engine's discovery document
35
+ # is served from the PROVIDER engine's mount, so — unlike core's own
36
+ # well-known controllers — it cannot read the ApiEngine mount out of
37
+ # SCRIPT_NAME. Tell it where the endpoints actually live, or the document
38
+ # will advertise URLs that 404.
39
+ #
40
+ # c.oauth.discovery_endpoint_base = ->(request:) { "#{request.base_url}/api" }
41
+ # # or, for a fixed deployment:
42
+ # c.oauth.discovery_endpoint_base = "https://auth.example.com/api"
43
+
44
+ # RFC 7662 introspection lives on CORE's endpoint (POST /oauth/introspect),
45
+ # which is OFF by default — it 404s until you opt in. This engine contributes
46
+ # its RevokedToken denylist into it, so a revoked access token introspects as
47
+ # inactive rather than staying active until its exp.
48
+ # c.oauth.introspection_enabled = true
49
+
50
+ # Members that cannot be derived — a host-owned authorization shim, a
51
+ # deliberately narrower scope list. Applied LAST, so they win over everything
52
+ # above. Setting `issuer` here raises; use c.issuer.
53
+ # c.oauth.discovery_metadata_overrides = {
54
+ # authorization_endpoint: ->(ctx) { "#{ctx[:origin]}/oauth/authorize" }
55
+ # }
56
+ end
@@ -4,7 +4,14 @@ StandardId::ConfigSchema.define do
4
4
  field :scopes_supported, type: :array, default: -> { %w[openid profile email offline_access] }
5
5
  field :claims_supported, type: :array, default: -> { %w[sub iss aud exp iat nonce auth_time at_hash email name email_verified] }
6
6
  field :subject_types_supported, type: :array, default: -> { %w[public] }
7
- field :introspection_enabled, type: :boolean, default: true
7
+ # NOTE: there is deliberately no `introspection_enabled` here any more.
8
+ # It was declared, documented as a switch, and never read by anything —
9
+ # the endpoint it claimed to gate was always on. Introspection is now
10
+ # core's endpoint, gated by `StandardId.config.oauth.introspection_enabled`
11
+ # (which defaults to FALSE — a provider deployment must opt in).
12
+ #
13
+ # `revocation_enabled` had the same defect and is now actually enforced by
14
+ # StandardId::Provider::RevocationController.
8
15
  field :revocation_enabled, type: :boolean, default: true
9
16
  end
10
17
  end
@@ -20,6 +20,17 @@ module StandardId
20
20
  StandardId::Provider::Extensions::AuthorizationCodeFlowExt
21
21
  )
22
22
  end
23
+
24
+ # Controllers are reloadable, so this cannot live in the initializer
25
+ # above: referencing the constant there would pin the boot-time class and
26
+ # every `reload!` in development would drop the extension. `to_prepare`
27
+ # runs on each reload, and `prepend` of an already-prepended module is a
28
+ # no-op, so re-running it is free.
29
+ config.to_prepare do
30
+ StandardId::Api::Oauth::IntrospectionsController.prepend(
31
+ StandardId::Provider::Extensions::IntrospectionsControllerExt
32
+ )
33
+ end
23
34
  end
24
35
  end
25
36
  end
@@ -0,0 +1,60 @@
1
+ module StandardId
2
+ module Provider
3
+ module Extensions
4
+ # Teaches core's RFC 7662 endpoint (`POST /oauth/introspect`,
5
+ # StandardId::Api::Oauth::IntrospectionsController) about this engine's
6
+ # access-token denylist.
7
+ #
8
+ # ## Why this replaced a second introspection endpoint
9
+ #
10
+ # This gem used to ship its own `POST /api/provider/introspect`. Since
11
+ # standard_id 0.33.0 core has one too, and core's is strictly more
12
+ # RFC-conformant:
13
+ #
14
+ # * RFC 7662 §2.2 — every failure mode renders `{"active": false}` and
15
+ # NOTHING else. The provider's raised InvalidClientError on bad client
16
+ # credentials, answering 401 with an error body: a caller could tell
17
+ # "your credentials are wrong" from "that token is not valid".
18
+ # * The provider's rate limiter answered 429, which turns the throttle
19
+ # into a token-validity oracle — probe until throttled, then read the
20
+ # status code rather than the body. Core's renders the ordinary
21
+ # inactive response instead, deliberately.
22
+ # * Core's is 404-gated behind `config.oauth.introspection_enabled`, so
23
+ # an endpoint that answers questions about other people's tokens is
24
+ # not exposed by accident. The provider's was always on — and its own
25
+ # `config.provider.introspection_enabled` flag was never read.
26
+ #
27
+ # Only one thing was worth keeping: core cannot see
28
+ # StandardId::Provider::RevokedToken. Core says so itself — access tokens
29
+ # are stateless, "this engine cannot invalidate a stateless JWT before its
30
+ # exp". This engine's revocation endpoint maintains exactly the jti
31
+ # denylist that makes that possible. So rather than run two endpoints with
32
+ # different answers, the denylist is contributed INTO core's.
33
+ #
34
+ # ## Coupling
35
+ #
36
+ # `#decode_token` is a private method of a non-public controller. That is
37
+ # the same bargain the rest of this gem already makes (see
38
+ # StandardId::Provider::Engine), and the reason the gemspec pins
39
+ # `standard_id` to `~> 0.33` with a `compat` CI job watching it.
40
+ #
41
+ # Returning nil is the whole mechanism: core already treats a nil decode
42
+ # as inactive and renders the bare `{"active": false}`, so a denylisted
43
+ # token is indistinguishable from a forged one — which is what RFC 7662
44
+ # §2.2 wants.
45
+ module IntrospectionsControllerExt
46
+ private
47
+
48
+ def decode_token(token)
49
+ payload = super
50
+ return nil if payload.nil?
51
+
52
+ jti = payload[:jti]
53
+ return nil if jti.present? && StandardId::Provider::RevokedToken.revoked?(jti)
54
+
55
+ payload
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,5 @@
1
1
  module StandardId
2
2
  module Provider
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
@@ -7,6 +7,7 @@ require "standard_id/provider/extensions/token_grant_flow_ext"
7
7
  require "standard_id/provider/extensions/authorization_flow_ext"
8
8
  require "standard_id/provider/extensions/traditional_code_grant_ext"
9
9
  require "standard_id/provider/extensions/authorization_code_flow_ext"
10
+ require "standard_id/provider/extensions/introspections_controller_ext"
10
11
  require "standard_id/provider/engine"
11
12
 
12
13
  module StandardId
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id-provider
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.3'
32
+ version: '0.33'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.3'
39
+ version: '0.33'
40
40
  description: 'Extends StandardId with full OIDC Identity Provider capabilities: ID
41
41
  tokens, consent management, token introspection, token revocation, and discovery.'
42
42
  email:
@@ -52,7 +52,6 @@ files:
52
52
  - app/controllers/standard_id/provider/application_controller.rb
53
53
  - app/controllers/standard_id/provider/consent_controller.rb
54
54
  - app/controllers/standard_id/provider/discovery_controller.rb
55
- - app/controllers/standard_id/provider/introspection_controller.rb
56
55
  - app/controllers/standard_id/provider/revocation_controller.rb
57
56
  - app/helpers/standard_id/provider/application_helper.rb
58
57
  - app/jobs/standard_id/provider/application_job.rb
@@ -64,11 +63,14 @@ files:
64
63
  - config/routes.rb
65
64
  - db/migrate/20260312000001_create_standard_id_consent_grants.rb
66
65
  - db/migrate/20260312000002_create_standard_id_revoked_tokens.rb
66
+ - lib/generators/standard_id/provider/install/install_generator.rb
67
+ - lib/generators/standard_id/provider/install/templates/initializer.rb.erb
67
68
  - lib/standard_id/provider.rb
68
69
  - lib/standard_id/provider/config/schema.rb
69
70
  - lib/standard_id/provider/engine.rb
70
71
  - lib/standard_id/provider/extensions/authorization_code_flow_ext.rb
71
72
  - lib/standard_id/provider/extensions/authorization_flow_ext.rb
73
+ - lib/standard_id/provider/extensions/introspections_controller_ext.rb
72
74
  - lib/standard_id/provider/extensions/token_grant_flow_ext.rb
73
75
  - lib/standard_id/provider/extensions/traditional_code_grant_ext.rb
74
76
  - lib/standard_id/provider/id_token_service.rb
@@ -1,50 +0,0 @@
1
- module StandardId
2
- module Provider
3
- class IntrospectionController < ApplicationController
4
- # Throttle by IP (30 per 15 min) BEFORE client authentication so this
5
- # credential-guessable RFC 7662 endpoint can't be brute-forced. Override
6
- # with RATE_LIMIT_INTROSPECT_PER_IP.
7
- rate_limit to: (ENV["RATE_LIMIT_INTROSPECT_PER_IP"] || 30).to_i,
8
- within: 15.minutes,
9
- by: -> { request.remote_ip },
10
- name: "provider-introspect-ip",
11
- store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
12
-
13
- before_action :authenticate_client!
14
-
15
- def create
16
- token = params[:token]
17
-
18
- if token.blank?
19
- render json: { active: false }
20
- return
21
- end
22
-
23
- payload = StandardId::JwtService.decode(token)
24
-
25
- if payload.nil?
26
- render json: { active: false }
27
- return
28
- end
29
-
30
- if payload[:jti].present? && RevokedToken.revoked?(payload[:jti])
31
- render json: { active: false }
32
- return
33
- end
34
-
35
- render json: {
36
- active: true,
37
- sub: payload[:sub],
38
- client_id: payload[:client_id],
39
- scope: payload[:scope],
40
- iss: payload[:iss],
41
- exp: payload[:exp],
42
- iat: payload[:iat],
43
- jti: payload[:jti],
44
- aud: payload[:aud],
45
- token_type: "Bearer"
46
- }.compact
47
- end
48
- end
49
- end
50
- end