StandardId::Provider
OpenID Connect Identity Provider addon for standard_id.
standard_id gives you OAuth 2.0 — authorization codes, token exchange, refresh
tokens, sessions. This engine adds the parts that make it an OpenID Connect
identity provider:
| | |
|---|---|
| ID tokens | Signed OIDC ID tokens issued alongside access tokens, with nonce, auth_time, at_hash and c_hash |
| Consent | Per-(account, client) scope grants, checked on subsequent authorization requests |
| Access-token revocation | An RFC 7009 endpoint backed by a jti denylist — the thing that makes revoking a stateless access token actually bite |
| Discovery | /.well-known/openid-configuration, built from core's document builder so it cannot drift |
Read this before adopting it
This engine prepends into non-public standard_id internals. That is not
an implementation detail you can ignore — it is the coupling you are taking on.
It prepends into five classes, none of which are part of standard_id's public
API and none of which carry a stability guarantee:
| Class | Why |
|---|---|
StandardId::Oauth::TokenGrantFlow |
issue an ID token with the access token; stamp jti |
StandardId::Oauth::AuthorizationCodeAuthorizationFlow |
carry nonce / consent through authorization |
StandardId::Oauth::Subflows::TraditionalCodeGrant |
ditto, for the traditional code grant |
StandardId::Oauth::AuthorizationCodeFlow |
ditto |
StandardId::Api::Oauth::IntrospectionsController |
contribute the revocation denylist into core's RFC 7662 endpoint (a private method) |
What that means in practice:
- A
standard_idminor release can break this gem without breaking its own semver contract, because the coupling is to internals rather than API. - The gemspec therefore pins
standard_idto~> 0.33— the oldest version this gem's suite has actually been run against. - A
compatCI job resolves against the latest publishedstandard_idand runs the full suite against it on every push, so a break surfaces here before it surfaces in your app.
If you want OIDC without that coupling, you want standard_id on its own plus
your own ID-token issuance. If you want the coupling handled for you, this is
the gem.
Installation
# Gemfile
gem "standard_id-provider"
bundle install
rails generate standard_id:provider:install
rails db:migrate
The generator:
- copies the engine's migrations (
consent_grants,revoked_tokens) intodb/migrate/ - writes
config/initializers/standard_id_provider.rb - mounts the engine in
config/routes.rb
It is idempotent — re-running skips anything already installed and says so.
| Flag | Effect |
|---|---|
--skip-migrations |
leave db/migrate/ alone |
--skip-initializer |
do not write the initializer |
--skip-routes |
do not touch config/routes.rb |
--mount-path PATH |
mount somewhere other than / |
--force |
overwrite an existing initializer |
Mounting
# config/routes.rb
Rails.application.routes.draw do
mount StandardId::Provider::Engine => "/"
mount StandardId::WebEngine => "/", as: :standard_id_web
scope "api" do
mount StandardId::ApiEngine => "/", as: :standard_id_api
end
end
The engine serves, relative to its own mount:
| Route | What | ||
|---|---|---|---|
GET /.well-known/openid-configuration |
OIDC discovery document | ||
POST /api/provider/revoke |
RFC 7009 revocation — writes the jti denylist |
||
| `GET\ | POST\ | DELETE /api/provider/consent` | consent grant management |
Introspection is not here — see below.
Configuration
The generator writes a fully commented initializer. Two settings are load-bearing and easy to miss.
discovery_endpoint_base — required
StandardId.configure do |c|
c.issuer = "https://auth.example.com"
c.oauth.discovery_endpoint_base = ->(request:) { "#{request.base_url}/api" }
end
The issuer and the endpoint base are different things. The issuer is a
stable security identifier (RFC 8414 §2) that clients match byte-for-byte
against their discovery URL and against the iss claim of issued tokens. The
endpoint base is merely where the endpoints live.
This engine's discovery document is served from the Provider engine's mount.
Core's own well-known controllers sit inside the ApiEngine mount and can read
it out of SCRIPT_NAME; this one cannot. If you do not name the base, the
document will advertise URLs that 404.
Use discovery_metadata_overrides for members that cannot be derived — a
host-owned authorization shim, a deliberately narrower scope list. They are
applied last and win over everything else. Setting issuer there raises.
introspection_enabled — opt-in, and it is core's endpoint
c.oauth.introspection_enabled = true
RFC 7662 introspection is core's endpoint (POST /oauth/introspect), not
this engine's. It is off by default and 404s until you opt in.
This engine used to ship a second one. It no longer does, because core's is
strictly more conformant — RFC 7662 §2.2 requires every failure mode to render
{"active": false} and nothing else, and this gem's answered 401 on bad
client credentials and 429 when throttled, which let a caller distinguish
"your credentials are wrong" and "you are throttled" from "that token is not
valid". The 429 in particular was a token-validity oracle.
What this engine contributes instead is the one thing core cannot do alone.
Access tokens are stateless, so core "cannot invalidate a stateless JWT before
its exp". This engine's revocation endpoint maintains a jti denylist, and
IntrospectionsControllerExt teaches core's endpoint to consult it — so a
revoked access token introspects as inactive immediately rather than staying
active until it expires.
Provider settings
c.provider.id_token_lifetime = 3600
c.provider.scopes_supported = %w[openid profile email offline_access]
c.provider.claims_supported = %w[sub iss aud exp iat nonce auth_time at_hash email name email_verified]
c.provider.subject_types_supported = %w[public]
c.provider.revocation_enabled = true # false renders /api/provider/revoke as 404
The *_supported lists are advertised in the discovery document. They describe
what you are willing to issue — narrow them rather than widen them.
Development
bin/rails app:db:test:prepare # build the dummy app's test database
bundle exec rspec
bundle exec rubocop --config .rubocop.yml
bundle exec brakeman --no-pager --force
bundle exec bundler-audit --update
Specs run against spec/dummy, a host app mounting this engine alongside
StandardId::WebEngine and StandardId::ApiEngine. spec/dummy/db/schema.rb
and the vendored spec/dummy/db/migrate/*.standard_id.rb are committed;
refresh them with:
bin/rails app:standard_id:install:migrations
bin/rails app:db:migrate
spec/dummy/config/database.yml sets migrations_paths explicitly. Leave it
that way — without it Rails falls back to the relative default "db/migrate",
which resolves against RSpec's working directory rather than the dummy app, and
the suite aborts on permanently-pending migrations.
Contributing
Bug reports and pull requests are welcome at https://github.com/rarebit-one/standard_id-provider.
Work happens in a git worktree, never the main checkout — see CLAUDE.md.
Commits are signed. CI runs RuboCop, Brakeman, bundler-audit, the full suite on
Ruby 4.0.0–4.0.4, and the compat job against the latest published
standard_id.
License
Available as open source under the terms of the MIT License.