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 +4 -4
- data/README.md +184 -10
- data/app/controllers/standard_id/provider/discovery_controller.rb +89 -17
- data/app/controllers/standard_id/provider/revocation_controller.rb +11 -0
- data/config/routes.rb +7 -3
- data/lib/generators/standard_id/provider/install/install_generator.rb +138 -0
- data/lib/generators/standard_id/provider/install/templates/initializer.rb.erb +56 -0
- data/lib/standard_id/provider/config/schema.rb +8 -1
- data/lib/standard_id/provider/engine.rb +11 -0
- data/lib/standard_id/provider/extensions/introspections_controller_ext.rb +60 -0
- data/lib/standard_id/provider/version.rb +1 -1
- data/lib/standard_id/provider.rb +1 -0
- metadata +6 -4
- data/app/controllers/standard_id/provider/introspection_controller.rb +0 -50
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f96f308e0b1b4b60787511200833a782631a6e1934a2f53b421172651c0b6d84
|
|
4
|
+
data.tar.gz: 1fc3b9bb28515cabeee83feb4c796f16fa77313fbee2977ac309ac1759955c85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
data/lib/standard_id/provider.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|