honin-client

A framework-agnostic OAuth2/PKCE client gem for apps authenticating against a Honin identity provider — Honin Cloud (honin.id), self-hosted instances, or any app running the honin engine in IDP mode.

Works with Rails, Sinatra, Hanami, or any Rack app. If you are already using OmniAuth, use omniauth-honin instead — it builds on this gem.

Installation

gem "honin-client"

Configuration

HoninClient.configure do |c|
  c.idp_url      = "https://honin.id"           # default
  c.client_id    = ENV["HONIN_CLIENT_ID"]
  c.client_secret = ENV["HONIN_CLIENT_SECRET"]
  c.redirect_uri = "https://myapp.com/auth/callback"
  c.scope        = "email profile"              # default
end

Self-hosted instance (sub-path mount)

If the Honin engine is mounted under a path — e.g. mount HoninEngine, at: "/auth" — set base_path. It is used to construct the OAuth endpoints and verify the iss claim in the JWT.

HoninClient.configure do |c|
  c.idp_url   = "https://id.acme.co"
  c.base_path = "/auth"
  # ...
end

Not needed for Honin Cloud or root-mounted instances.

Usage

Rails (opt-in concern)

# config/initializers/honin_client.rb
require "honin/client/rails"

HoninClient.configure do |c|
  c.client_id     = ENV["HONIN_CLIENT_ID"]
  c.client_secret = ENV["HONIN_CLIENT_SECRET"]
  c.redirect_uri  = "https://myapp.com/auth/callback"
end
# app/controllers/application_controller.rb
include HoninClient::Rails::Authentication
# config/routes.rb
get  "/auth/login",    to: "auth#login"
get  "/auth/callback", to: "auth#callback"
delete "/auth/logout", to: "auth#logout"
# app/controllers/auth_controller.rb
class AuthController < ApplicationController
  def 
    redirect_to honin_authorization_url, allow_other_host: true
  end

  def callback
    identity = handle_honin_callback
    redirect_to root_path, notice: "Signed in as #{identity.email}"
  rescue HoninClient::Error => e
    redirect_to root_path, alert: "Sign in failed: #{e.message}"
  end

  def logout
    sign_out_honin
    redirect_to root_path
  end
end
# protect any controller action
before_action :require_honin_authentication

The concern provides:

Method Description
current_honin_identity Returns the signed-in Identity, or nil
honin_identity_signed_in? Boolean
require_honin_authentication Redirects to Honin if not signed in
honin_authorization_url Generates the IDP redirect URL, stores PKCE state in session
handle_honin_callback Verifies state, exchanges code, stores identity in session, returns Identity
sign_out_honin Clears identity from session

Any framework (core API)

require "honin/client"

# 1. Start the flow — redirect the user to Honin
pkce = HoninClient::PKCE.new
session[:honin_pkce_verifier] = pkce.code_verifier
session[:honin_state]         = SecureRandom.hex(16)

url = HoninClient.flow.authorize_url(
  state:          session[:honin_state],
  code_challenge: pkce.code_challenge
)
redirect url

# 2. Handle the callback
identity = HoninClient.flow.exchange_code(
  code:          params[:code],
  code_verifier: session[:honin_pkce_verifier]
)
session[:honin_identity] = identity.to_h

Identity

exchange_code and handle_honin_callback both return a HoninClient::Identity:

identity.sub            # "abc123xyz456def789" — stable user ID, use as foreign key
identity.   # "standard" or "anonymous"
identity.email          # nil for anonymous or if scope not granted
identity.email_verified # true / false / nil
identity.display_name   # nil if "profile" scope not granted
identity.timezone       # nil if "timezone" scope not granted
identity.locale         # nil if "locale" scope not granted
identity.granted_scopes # ["email", "profile"]
identity.required_scopes # ["email"]

identity.standard?  # true
identity.anonymous? # false

identity["custom_claim"] # access any JWT claim by name
identity.to_h            # full claims hash — safe to store in session

Restore from session:

HoninClient::Identity.new(session[:honin_identity])

JWT verification

By default, exchange_code fetches the JWKS from <idp_url>/.well-known/jwks.json and verifies the RS256 signature. Keys are cached in memory for one hour and refreshed automatically on key rotation (unknown kid).

To use a pre-loaded key set instead (e.g. for a self-hosted instance with a known public key):

jwk_set = JWT::JWK::Set.new([JWT::JWK.new(your_public_key)])
cache   = HoninClient::JwksCache::Static.new(jwk_set)

verifier = HoninClient::TokenVerifier.new(
  jwks_cache: cache,
  issuer:     HoninClient.configuration.issuer,
  client_id:  HoninClient.configuration.client_id
)

identity = verifier.verify(raw_jwt)

Scopes

Honin supports email, profile, timezone, and locale. Scopes must be registered on your Honin Application. The default scope is "email profile".

HoninClient.configure do |c|
  c.scope = "email profile timezone locale"
end

How it works

  1. PKCE.new generates a cryptographically random code verifier and its S256 challenge.
  2. The user is redirected to the Honin authorize endpoint with the challenge and a random state.
  3. Honin authenticates the user, collects consent, and redirects back with an authorization code.
  4. exchange_code POSTs the code + verifier to /oauth/token using client_secret_post.
  5. Honin verifies PKCE, issues a signed RS256 JWT. The JWT is verified via JWKS and returned as an Identity.

No userinfo endpoint — all claims are in the JWT.

Development

bundle install
bundle exec rake test
bundle exec standardrb

License

MIT — see LICENSE.