UniAuth.ID

Rails Quickstart

Integrate UniAuth with Rails using OmniAuth and omniauth_openid_connect. Full OIDC discovery, PKCE, JWKS verification, back-channel logout.

Discovery-first: the strategy auto-fetches every endpoint from https://uniauth.id/.well-known/openid-configuration when you set discovery: true.

1. Install

# Gemfile
gem 'omniauth'
gem 'omniauth_openid_connect'
gem 'omniauth-rails_csrf_protection'   # CSRF for omniauth POST requests
bundle install

2. Environment

# .env / Rails credentials
UNIAUTH_CLIENT_ID=uni_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
UNIAUTH_CLIENT_SECRET=unis_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

3. OmniAuth initializer

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :openid_connect,
    name: :uniauth,
    scope: [:openid, :profile, :email, :groups],
    response_type: :code,
    uid_field: 'sub',
    discovery: true,                           # ← auto-fetch all endpoints
    pkce: true,                                # ← required by UniAuth
    client_options: {
      identifier:     ENV['UNIAUTH_CLIENT_ID'],
      secret:         ENV['UNIAUTH_CLIENT_SECRET'],
      redirect_uri:   'https://yourapp.com/auth/uniauth/callback',
      # Only issuer needed — discovery fills in the rest
      scheme:         'https',
      host:           'uniauth.id',
      port:           443,
    },
    issuer: 'https://uniauth.id'
end

4. Routes

# config/routes.rb
Rails.application.routes.draw do
  get  '/auth/uniauth/callback', to: 'sessions#create'
  post '/auth/uniauth',          to: 'sessions#passthru'   # login trigger (POST for CSRF)
  delete '/logout',              to: 'sessions#destroy'
  post '/logout/backchannel',    to: 'sessions#backchannel_logout'
end

5. SessionsController

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:backchannel_logout]

  # GET /auth/uniauth/callback
  def create
    auth = request.env['omniauth.auth']
    # omniauth_openid_connect already verified the ID token signature
    # via UniAuth's JWKS, checked iss/aud/exp/nonce.

    info   = auth.info                    # email, name, image…
    claims = auth.extra.raw_info          # full ID token claims (groups, etc.)

    user = User.find_or_create_by(uniauth_sub: auth.uid) do |u|
      u.email        = info.email
      u.display_name = info.name
    end

    # Map UniAuth 'groups' claim to Rails role / admin flag
    groups = Array(claims['groups'])
    user.update!(
      email:    info.email,
      is_admin: groups.include?('admin'),
      role:     groups.include?('admin') ? 'admin' :
                groups.include?('staff') ? 'staff' : 'user',
    )

    session[:user_id] = user.id
    session[:id_token] = auth.credentials.id_token   # needed for RP-initiated logout
    redirect_to root_path, notice: 'Signed in'
  end

  # DELETE /logout — RP-initiated logout per OIDC RP-Initiated Logout 1.0
  def destroy
    id_token = session[:id_token]
    reset_session
    redirect_to(
      'https://uniauth.id/api/oauth/end-session?' +
      { id_token_hint: id_token,
        post_logout_redirect_uri: 'https://yourapp.com/' }.to_query,
      allow_other_host: true
    )
  end

  # POST /logout/backchannel — receive logout tokens from UniAuth
  def backchannel_logout
    token = params[:logout_token].to_s
    claims = decode_logout_token(token)
    return head :bad_request unless claims

    # Kill all sessions for this user
    sub = claims['sub']
    User.find_by(uniauth_sub: sub)&.sessions&.delete_all
    head :ok
  end

  private

  def decode_logout_token(token)
    jwks = Rails.cache.fetch('uniauth_jwks', expires_in: 1.hour) do
      JSON.parse(Net::HTTP.get(URI('https://uniauth.id/.well-known/jwks.json')))
    end

    payload, _header = JWT.decode(
      token, nil, true,
      algorithms: ['RS256'],
      iss: 'https://uniauth.id', verify_iss: true,
      aud: ENV['UNIAUTH_CLIENT_ID'], verify_aud: true,
      jwks: { keys: jwks['keys'] },
    )

    # OIDC Back-Channel Logout 1.0 extra checks
    return nil if payload['nonce']                                          # MUST NOT contain nonce
    return nil unless payload.dig('events', 'http://schemas.openid.net/event/backchannel-logout')
    payload
  rescue JWT::DecodeError
    nil
  end
end

6. Authorization helper

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_user, :signed_in?

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def signed_in?
    current_user.present?
  end

  def require_login
    redirect_to root_path, alert: 'Sign in required' unless signed_in?
  end

  def require_admin
    head :forbidden unless current_user&.is_admin?
  end
end

7. Migration

class AddUniAuthToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :uniauth_sub, :string, null: false
    add_index  :users, :uniauth_sub, unique: true
    add_column :users, :display_name, :string
    add_column :users, :is_admin, :boolean, default: false, null: false
    add_column :users, :role, :string, default: 'user', null: false
  end
end

Register https://yourapp.com/logout/backchannel as backchannel_logout_uri in the Developer Console.

Next