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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
3
|
+
class RevokedToken < ApplicationRecord
|
|
4
|
+
self.table_name = "standard_id_revoked_tokens"
|
|
5
|
+
|
|
6
|
+
validates :jti, presence: true, uniqueness: true
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where("expires_at > ?", Time.current) }
|
|
9
|
+
|
|
10
|
+
def self.revoke!(jti:, client_id: nil, token_type: nil, expires_at: nil)
|
|
11
|
+
create!(
|
|
12
|
+
jti: jti,
|
|
13
|
+
client_id: client_id,
|
|
14
|
+
token_type: token_type,
|
|
15
|
+
revoked_at: Time.current,
|
|
16
|
+
expires_at: expires_at || 1.day.from_now
|
|
17
|
+
)
|
|
18
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
19
|
+
# Token already revoked — idempotent
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.revoked?(jti)
|
|
23
|
+
where(jti: jti).exists?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.cleanup_expired!
|
|
27
|
+
where("expires_at <= ?", Time.current).delete_all
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Standard id provider</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "standard_id/oidc/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
StandardId::Oidc::Engine.routes.draw do
|
|
2
|
+
scope ".well-known" do
|
|
3
|
+
get "openid-configuration", to: "discovery#show", as: :openid_configuration
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
scope "api/provider" do
|
|
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::Oidc::Extensions::IntrospectionsControllerExt.
|
|
12
|
+
post "revoke", to: "revocation#create", as: :revoke
|
|
13
|
+
resource :consent, only: [ :show, :create, :destroy ], controller: :consent
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class CreateStandardIdConsentGrants < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :standard_id_consent_grants do |t|
|
|
4
|
+
t.references :account, null: false
|
|
5
|
+
t.references :client_application, null: false
|
|
6
|
+
t.string :scopes, null: false
|
|
7
|
+
t.datetime :granted_at, null: false
|
|
8
|
+
t.datetime :revoked_at
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :standard_id_consent_grants,
|
|
14
|
+
[ :account_id, :client_application_id ],
|
|
15
|
+
unique: true,
|
|
16
|
+
where: "revoked_at IS NULL",
|
|
17
|
+
name: "idx_consent_grants_active_unique"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateStandardIdRevokedTokens < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :standard_id_revoked_tokens do |t|
|
|
4
|
+
t.string :jti, null: false
|
|
5
|
+
t.string :token_type
|
|
6
|
+
t.string :client_id
|
|
7
|
+
t.datetime :revoked_at, null: false
|
|
8
|
+
t.datetime :expires_at, null: false
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :standard_id_revoked_tokens, :jti, unique: true
|
|
14
|
+
add_index :standard_id_revoked_tokens, :expires_at
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
module Oidc
|
|
5
|
+
module Generators
|
|
6
|
+
# Installs StandardId::Oidc 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_oidc.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::Oidc. By default this:
|
|
22
|
+
* copies the engine's migrations into db/migrate/
|
|
23
|
+
* writes config/initializers/standard_id_oidc.rb
|
|
24
|
+
* mounts StandardId::Oidc::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
|
+
LEGACY_INITIALIZER = "config/initializers/standard_id_provider.rb"
|
|
35
|
+
|
|
36
|
+
class_option :skip_migrations, type: :boolean, default: false,
|
|
37
|
+
desc: "Do not copy the engine's migrations into db/migrate"
|
|
38
|
+
class_option :skip_initializer, type: :boolean, default: false,
|
|
39
|
+
desc: "Do not write config/initializers/standard_id_oidc.rb"
|
|
40
|
+
class_option :skip_routes, type: :boolean, default: false,
|
|
41
|
+
desc: "Do not mount StandardId::Oidc::Engine in config/routes.rb"
|
|
42
|
+
class_option :mount_path, type: :string, default: "/",
|
|
43
|
+
desc: "Path to mount StandardId::Oidc::Engine at"
|
|
44
|
+
class_option :force, type: :boolean, default: false,
|
|
45
|
+
desc: "Overwrite config/initializers/standard_id_oidc.rb if it already exists"
|
|
46
|
+
|
|
47
|
+
# Copies the engine's migrations with the same `.standard_id_oidc`
|
|
48
|
+
# suffix `rails standard_id_oidc:install:migrations` uses, so the
|
|
49
|
+
# two are interchangeable and neither double-installs the other's work.
|
|
50
|
+
# Done in-process rather than by shelling out to that rake task so the
|
|
51
|
+
# generator stays testable and does not need a booted host app.
|
|
52
|
+
# Migrations copied under the gem's former name (`.standard_id_provider`
|
|
53
|
+
# suffix) count as installed, so an upgraded host is not double-migrated.
|
|
54
|
+
def copy_migrations
|
|
55
|
+
if options[:skip_migrations]
|
|
56
|
+
say_status("skip", "db/migrate (--skip-migrations)", :yellow)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if existing_migrations.any?
|
|
61
|
+
say_status(
|
|
62
|
+
"identical",
|
|
63
|
+
"StandardId::Oidc migrations already present (#{existing_migrations.size}), skipping",
|
|
64
|
+
:blue
|
|
65
|
+
)
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
engine_migrations.each_with_index do |source, index|
|
|
70
|
+
name = File.basename(source, ".rb").sub(/\A\d+_/, "")
|
|
71
|
+
copy_file source, "db/migrate/#{migration_number(index)}_#{name}.standard_id_oidc.rb"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def copy_initializer
|
|
76
|
+
path = "config/initializers/standard_id_oidc.rb"
|
|
77
|
+
|
|
78
|
+
if options[:skip_initializer]
|
|
79
|
+
say_status("skip", "#{path} (--skip-initializer)", :yellow)
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if File.exist?(File.join(destination_root, LEGACY_INITIALIZER)) && !options[:force]
|
|
84
|
+
say_status("identical", "#{LEGACY_INITIALIZER} (former name; rename it at your leisure)", :blue)
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if File.exist?(File.join(destination_root, path)) && !options[:force]
|
|
89
|
+
say_status("identical", "#{path} (already exists; pass --force to overwrite)", :blue)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
template "initializer.rb.erb", path, force: options[:force]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def mount_engine
|
|
97
|
+
routes_path = "config/routes.rb"
|
|
98
|
+
|
|
99
|
+
if options[:skip_routes]
|
|
100
|
+
say_status("skip", "#{routes_path} (--skip-routes)", :yellow)
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
unless File.exist?(File.join(destination_root, routes_path))
|
|
105
|
+
say_status("warn", "#{routes_path} not found; mount the engine yourself", :red)
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# StandardId::Provider is an alias of StandardId::Oidc, so a legacy
|
|
110
|
+
# mount is the same engine; mounting it again would duplicate routes.
|
|
111
|
+
if File.read(File.join(destination_root, routes_path)).match?(/StandardId::(Oidc|Provider)::Engine/)
|
|
112
|
+
say_status("identical", "#{routes_path} (engine already mounted)", :blue)
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
route(%(mount StandardId::Oidc::Engine => "#{options[:mount_path]}"))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def print_next_steps
|
|
120
|
+
say ""
|
|
121
|
+
say "StandardId::Oidc installed. Next:", :green
|
|
122
|
+
say " 1. rails db:migrate"
|
|
123
|
+
say " 2. Set config.oauth.discovery_endpoint_base in your StandardId"
|
|
124
|
+
say " initializer — this engine's discovery document cannot detect"
|
|
125
|
+
say " the ApiEngine mount on its own. See the README."
|
|
126
|
+
say " 3. Set config.oauth.introspection_enabled = true if you want the"
|
|
127
|
+
say " RFC 7662 endpoint (it is off by default in standard_id)."
|
|
128
|
+
say ""
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
no_commands do
|
|
132
|
+
def existing_migrations
|
|
133
|
+
Dir.glob(File.join(destination_root, "db/migrate/*.{standard_id_oidc,standard_id_provider}.rb"))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def engine_migrations
|
|
137
|
+
Dir.glob(File.expand_path("../../../../../db/migrate/*.rb", __dir__)).sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Migrations are ordered among themselves and must not collide, hence
|
|
141
|
+
# the index offset. `install:migrations` does the same thing.
|
|
142
|
+
def migration_number(index)
|
|
143
|
+
(Time.now.utc + index).strftime("%Y%m%d%H%M%S")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# StandardId::Oidc — 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
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
StandardId::ConfigSchema.define do
|
|
2
|
+
scope :provider do
|
|
3
|
+
field :id_token_lifetime, type: :integer, default: 3600
|
|
4
|
+
field :scopes_supported, type: :array, default: -> { %w[openid profile email offline_access] }
|
|
5
|
+
field :claims_supported, type: :array, default: -> { %w[sub iss aud exp iat nonce auth_time at_hash email name email_verified] }
|
|
6
|
+
field :subject_types_supported, type: :array, default: -> { %w[public] }
|
|
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::Oidc::RevocationController.
|
|
15
|
+
field :revocation_enabled, type: :boolean, default: true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
isolate_namespace StandardId::Oidc
|
|
5
|
+
|
|
6
|
+
initializer "standard_id_oidc.extend_core" do
|
|
7
|
+
StandardId::Oauth::TokenGrantFlow.prepend(
|
|
8
|
+
StandardId::Oidc::Extensions::TokenGrantFlowExt
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
StandardId::Oauth::AuthorizationCodeAuthorizationFlow.prepend(
|
|
12
|
+
StandardId::Oidc::Extensions::AuthorizationFlowExt
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
StandardId::Oauth::Subflows::TraditionalCodeGrant.prepend(
|
|
16
|
+
StandardId::Oidc::Extensions::TraditionalCodeGrantExt
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
StandardId::Oauth::AuthorizationCodeFlow.prepend(
|
|
20
|
+
StandardId::Oidc::Extensions::AuthorizationCodeFlowExt
|
|
21
|
+
)
|
|
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::Oidc::Extensions::IntrospectionsControllerExt
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
3
|
+
module Extensions
|
|
4
|
+
module AuthorizationFlowExt
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def generate_authorization_response
|
|
8
|
+
if consent_required? && !consent_granted?
|
|
9
|
+
return redirect_to_consent
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def common_subflow_params(flow_params)
|
|
16
|
+
super.merge(nonce: flow_params[:nonce])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def consent_required?
|
|
20
|
+
@client&.require_consent? && openid_scope_requested?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def openid_scope_requested?
|
|
24
|
+
scope.to_s.split(/\s+/).include?("openid")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def consent_granted?
|
|
28
|
+
return false unless current_account
|
|
29
|
+
|
|
30
|
+
grant = StandardId::Oidc::ConsentGrant.active
|
|
31
|
+
.find_by(account: current_account, client_application: @client)
|
|
32
|
+
|
|
33
|
+
return false unless grant
|
|
34
|
+
|
|
35
|
+
requested = scope.to_s.split(/\s+/).sort
|
|
36
|
+
granted = grant.scopes.to_s.split(/\s+/).sort
|
|
37
|
+
(requested - granted).empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def redirect_to_consent
|
|
41
|
+
consent_params = {
|
|
42
|
+
client_id: params[:client_id],
|
|
43
|
+
redirect_uri: redirect_uri,
|
|
44
|
+
scope: scope,
|
|
45
|
+
state: state,
|
|
46
|
+
audience: audience,
|
|
47
|
+
nonce: params[:nonce],
|
|
48
|
+
response_type: params[:response_type],
|
|
49
|
+
code_challenge: params[:code_challenge],
|
|
50
|
+
code_challenge_method: params[:code_challenge_method]
|
|
51
|
+
}.compact
|
|
52
|
+
|
|
53
|
+
consent_url = StandardId::Oidc::Engine.routes.url_helpers.consent_path(consent_params)
|
|
54
|
+
|
|
55
|
+
{ redirect_to: consent_url, status: :found }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
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::Oidc::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::Oidc::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::Oidc::RevokedToken.revoked?(jti)
|
|
54
|
+
|
|
55
|
+
payload
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
3
|
+
module Extensions
|
|
4
|
+
module TokenGrantFlowExt
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def generate_token_response
|
|
8
|
+
response = super
|
|
9
|
+
|
|
10
|
+
if openid_scope_requested? && subject_id.present?
|
|
11
|
+
response[:id_token] = Oidc::IdTokenService.generate(
|
|
12
|
+
subject_id: subject_id,
|
|
13
|
+
client_id: client_id,
|
|
14
|
+
nonce: id_token_nonce,
|
|
15
|
+
access_token: response[:access_token],
|
|
16
|
+
auth_time: id_token_auth_time
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
response
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build_jwt_payload(expires_in)
|
|
24
|
+
payload = super
|
|
25
|
+
payload[:jti] = SecureRandom.uuid
|
|
26
|
+
payload
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def openid_scope_requested?
|
|
30
|
+
current_scopes.include?("openid")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def id_token_nonce = nil
|
|
34
|
+
def id_token_auth_time = nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
3
|
+
module Extensions
|
|
4
|
+
module TraditionalCodeGrantExt
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def store_authorization_code
|
|
8
|
+
StandardId::AuthorizationCode.issue!(
|
|
9
|
+
plaintext_code: authorization_code,
|
|
10
|
+
client_id: params[:client_id],
|
|
11
|
+
redirect_uri: params[:redirect_uri],
|
|
12
|
+
scope: params[:scope],
|
|
13
|
+
audience: params[:audience],
|
|
14
|
+
account: params[:current_account],
|
|
15
|
+
code_challenge: params[:code_challenge],
|
|
16
|
+
code_challenge_method: params[:code_challenge_method],
|
|
17
|
+
nonce: params[:nonce],
|
|
18
|
+
metadata: { state: params[:state] }.compact
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oidc
|
|
3
|
+
class IdTokenService
|
|
4
|
+
class << self
|
|
5
|
+
def generate(subject_id:, client_id:, nonce: nil, access_token: nil, authorization_code: nil, auth_time: nil, extra_claims: {})
|
|
6
|
+
payload = {
|
|
7
|
+
sub: subject_id,
|
|
8
|
+
aud: client_id,
|
|
9
|
+
nonce: nonce,
|
|
10
|
+
auth_time: auth_time&.to_i
|
|
11
|
+
}.compact
|
|
12
|
+
|
|
13
|
+
payload[:at_hash] = compute_half_hash(access_token) if access_token.present?
|
|
14
|
+
payload[:c_hash] = compute_half_hash(authorization_code) if authorization_code.present?
|
|
15
|
+
payload.merge!(extra_claims.symbolize_keys) if extra_claims.present?
|
|
16
|
+
|
|
17
|
+
expires_in = StandardId.config.provider.id_token_lifetime.seconds
|
|
18
|
+
|
|
19
|
+
StandardId::JwtService.encode(payload, expires_in: expires_in)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def compute_half_hash(value)
|
|
25
|
+
digest = hash_algorithm.digest(value)
|
|
26
|
+
Base64.urlsafe_encode64(digest[0, digest.length / 2], padding: false)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def hash_algorithm
|
|
30
|
+
case StandardId::JwtService.algorithm
|
|
31
|
+
when /256$/ then OpenSSL::Digest::SHA256
|
|
32
|
+
when /384$/ then OpenSSL::Digest::SHA384
|
|
33
|
+
when /512$/ then OpenSSL::Digest::SHA512
|
|
34
|
+
else OpenSSL::Digest::SHA256
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "standard_id"
|
|
2
|
+
|
|
3
|
+
require "standard_id/oidc/version"
|
|
4
|
+
require "standard_id/oidc/config/schema"
|
|
5
|
+
require "standard_id/oidc/id_token_service"
|
|
6
|
+
require "standard_id/oidc/extensions/token_grant_flow_ext"
|
|
7
|
+
require "standard_id/oidc/extensions/authorization_flow_ext"
|
|
8
|
+
require "standard_id/oidc/extensions/traditional_code_grant_ext"
|
|
9
|
+
require "standard_id/oidc/extensions/authorization_code_flow_ext"
|
|
10
|
+
require "standard_id/oidc/extensions/introspections_controller_ext"
|
|
11
|
+
require "standard_id/oidc/engine"
|
|
12
|
+
|
|
13
|
+
module StandardId
|
|
14
|
+
module Oidc
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Former name (the gem was standard_id-provider). Kept so existing host code
|
|
18
|
+
# (`StandardId::Provider::Engine` in routes, `StandardId::Provider::ConsentGrant`)
|
|
19
|
+
# keeps loading; deprecated, and removed in a future minor release.
|
|
20
|
+
Provider = Oidc
|
|
21
|
+
deprecate_constant :Provider
|
|
22
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Compatibility shim for the gem's former name, standard_id-provider.
|
|
2
|
+
#
|
|
3
|
+
# `require "standard_id/provider"` keeps working. The `StandardId::Provider`
|
|
4
|
+
# constant alias itself lives in standard_id/oidc.rb, so it is available however
|
|
5
|
+
# the gem was loaded. Both are deprecated: use `require "standard_id/oidc"` and
|
|
6
|
+
# `StandardId::Oidc`. This shim will be removed in a future minor release.
|
|
7
|
+
warn "[standard_id-oidc] `require \"standard_id/provider\"` is deprecated; " \
|
|
8
|
+
"use `require \"standard_id/oidc\"` instead.", uplevel: 1, category: :deprecated
|
|
9
|
+
|
|
10
|
+
require "standard_id/oidc"
|