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.
- checksums.yaml +7 -0
- data/LICENSE +22 -0
- data/README.md +231 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/standard_id/oidc/application.css +15 -0
- data/app/controllers/standard_id/oidc/application_controller.rb +45 -0
- data/app/controllers/standard_id/oidc/consent_controller.rb +91 -0
- data/app/controllers/standard_id/oidc/discovery_controller.rb +106 -0
- data/app/controllers/standard_id/oidc/revocation_controller.rb +49 -0
- data/app/helpers/standard_id/oidc/application_helper.rb +6 -0
- data/app/jobs/standard_id/oidc/application_job.rb +6 -0
- data/app/mailers/standard_id/oidc/application_mailer.rb +8 -0
- data/app/models/standard_id/oidc/application_record.rb +7 -0
- data/app/models/standard_id/oidc/consent_grant.rb +41 -0
- data/app/models/standard_id/oidc/revoked_token.rb +31 -0
- data/app/views/layouts/standard_id/oidc/application.html.erb +17 -0
- data/config/routes.rb +15 -0
- data/db/migrate/20260312000001_create_standard_id_consent_grants.rb +19 -0
- data/db/migrate/20260312000002_create_standard_id_revoked_tokens.rb +16 -0
- data/lib/generators/standard_id/oidc/install/install_generator.rb +149 -0
- data/lib/generators/standard_id/oidc/install/templates/initializer.rb.erb +56 -0
- data/lib/standard_id/oidc/config/schema.rb +17 -0
- data/lib/standard_id/oidc/engine.rb +36 -0
- data/lib/standard_id/oidc/extensions/authorization_code_flow_ext.rb +13 -0
- data/lib/standard_id/oidc/extensions/authorization_flow_ext.rb +60 -0
- data/lib/standard_id/oidc/extensions/introspections_controller_ext.rb +60 -0
- data/lib/standard_id/oidc/extensions/token_grant_flow_ext.rb +38 -0
- data/lib/standard_id/oidc/extensions/traditional_code_grant_ext.rb +24 -0
- data/lib/standard_id/oidc/id_token_service.rb +40 -0
- data/lib/standard_id/oidc/version.rb +5 -0
- data/lib/standard_id/oidc.rb +22 -0
- data/lib/standard_id/provider.rb +10 -0
- data/lib/tasks/standard_id/oidc_tasks.rake +4 -0
- 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,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,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
|