Skip to content

RP Initiated Logout

Kenta Ishizaki edited this page Aug 13, 2026 · 2 revisions

RP-Initiated Logout

This gem supports the server side of OpenID Connect RP-Initiated Logout 1.0 as follows: it advertises your logout endpoint in the discovery document, and it registers and validates per-client post_logout_redirect_uris (client metadata, doorkeeper-openid_connect >= 2.0). The end-session endpoint itself is implemented by the host application — ending a session is inseparable from however your app manages sessions (Devise, custom cookies, …), so the gem stays out of that part.

Advertising the endpoint

Configure end_session_endpoint with a block returning the URL of your logout endpoint. The block is executed in the controller's scope, so route helpers are available:

# config/initializers/doorkeeper_openid_connect.rb
Doorkeeper::OpenidConnect.configure do
  # ...
  end_session_endpoint -> { oauth_logout_url }
end

The URL is published as the end_session_endpoint member of /.well-known/openid-configuration, which is where client libraries such as oidc-client-js pick it up. When the setting is not configured, the member is omitted from the discovery document.

Registering post-logout redirect URIs (doorkeeper-openid_connect >= 2.0)

Per RP-Initiated Logout 1.0 §3, the OP must only redirect after logout to a post_logout_redirect_uri that the client registered beforehand. 2.0 stores these per client in a nullable post_logout_redirect_uris column on oauth_applications (newline-separated, like redirect_uri). New installations get the column from the regular install migration; existing installations add it with:

rails generate doorkeeper:openid_connect:add_post_logout_redirect_uris
rails db:migrate

On Doorkeeper::Application this provides:

  • post_logout_redirect_uris — returns the registered URIs as an array (empty array when none are registered, or when the column hasn't been added yet).
  • post_logout_redirect_uris= — accepts an array or a newline-separated string. Raises ActiveModel::MissingAttributeError with a pointer to the migration when the column is missing, so an assigned value can't be dropped silently.
  • valid_post_logout_redirect_uri?(uri) — whether the given URI has been registered; this is the check your logout endpoint calls before redirecting.

Registered values are validated with exactly the same rules Doorkeeper applies to redirect_uri (forbidden schemes, fragments, relative URIs, force_ssl_in_redirect_uri, …), by delegating to Doorkeeper's own RedirectUriValidator. Registration is optional per the spec, so a blank value is allowed. Dynamic Client Registration accepts and echoes post_logout_redirect_uris, rejecting invalid values with invalid_client_metadata.

Implementing the end-session endpoint

The logout request arrives as a GET (or POST) with the optional parameters id_token_hint, client_id, post_logout_redirect_uri and state (RP-Initiated Logout 1.0 §2). The rules your endpoint needs to follow: identify the client from id_token_hint (recommended) or client_id, end the local session, redirect only to a registered post_logout_redirect_uri — appending state when the client sent one — and fall back to a local page otherwise. Expired ID Tokens are still acceptable as hints; they were issued by this server, so they can be verified with the configured signing key.

# config/routes.rb
get "/oauth/logout", to: "logouts#show", as: :oauth_logout
# app/controllers/logouts_controller.rb
class LogoutsController < ApplicationController
  def show
    application = application_from_id_token_hint(params[:id_token_hint]) ||
                  application_from_client_id(params[:client_id])

    sign_out(current_user) if current_user # your session teardown here

    uri = params[:post_logout_redirect_uri]
    if application&.valid_post_logout_redirect_uri?(uri)
      redirect_to with_state_param(uri, params[:state]), allow_other_host: true
    else
      # Never redirect to an unregistered URI (RP-Initiated Logout 1.0 §3).
      redirect_to root_url
    end
  end

  private

  def application_from_client_id(client_id)
    Doorkeeper.config.application_model.by_uid(client_id) if client_id.present?
  end

  def application_from_id_token_hint(hint)
    return if hint.blank?

    payload, = JWT.decode(
      hint,
      Doorkeeper::OpenidConnect.signing_key.keypair,
      true,
      algorithms: [Doorkeeper::OpenidConnect.signing_algorithm.to_s],
      verify_expiration: false, # expired ID Tokens remain valid as hints
    )
    Doorkeeper.config.application_model.by_uid(payload["aud"])
  rescue JWT::DecodeError
    nil
  end

  def with_state_param(uri, state)
    return uri if state.blank?

    parsed = URI.parse(uri)
    parsed.query = [parsed.query, { state: state }.to_query].compact.join("&")
    parsed.to_s
  end
end

Note

The verification example assumes a single asymmetric signing key. If you use key rotation (signing_key as an array), try each entry of Doorkeeper::OpenidConnect.signing_keys in turn, so ID Tokens signed with a retired key are still accepted as hints during the rotation window.

See also

Clone this wiki locally