Ring-based authentication and authorization library for Clojure. Strategy-based, composable middleware with pluggable storage.
Authentication in Ring apps typically means either a heavyweight framework that imposes its own session/user model, or ad-hoc middleware scattered across your codebase. oie takes a different approach:
- Strategies as data: each auth mechanism is a plain map with an
:authenticatefunction, not a class hierarchy or protocol implementation - Composable middleware: login flows (OAuth2, magic link) compose as outer middleware around a single
wrap-authenticatethat tries strategies in order - Pluggable storage: all persistence is injected via callbacks (
verify-token,consume-nonce,login-fn), so the library never touches your database - Single session key: all session-aware middleware agrees on one namespace-qualified key, eliminating misconfiguration between components
By design: oie handles authentication (who are you?) and provides a predicate for authorization (can you do this?). It does not manage users, hash passwords, or send emails. Those are your app's concerns, injected via callbacks.
;; deps.edn
{:deps {sg.flybot/oie {:mvn/version "RELEASE"}
;; Required if you use wrap-oauth2:
io.github.studer-l/ring-oauth2 {:git/sha "db75c05284aa2a3458025ebb124efe9d3c14439b"}}}(ns my-app.core
(:require [flybot.oie.core :as oie]
[flybot.oie.oauth2 :as oauth2]
[flybot.oie.magic-link :as magic-link]
[flybot.oie.strategy.bearer :as bearer]
[flybot.oie.strategy.session :as session-strat]
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.session :refer [wrap-session]]))
;; Middleware ordering matters — outermost wraps first:
(-> app-handler
;; 3. Innermost: check auth on every request
(oie/wrap-authenticate [(bearer/bearer-token-strategy {:verify-token my-verify-fn})
(session-strat/session-strategy)])
;; 2. Login flows: intercept specific URIs, create sessions
(magic-link/wrap-magic-link {:verify-uri "/auth/magic-link" ...})
(oauth2/wrap-oauth2 {:google {...}})
;; 1. Outermost: Ring basics (params, session) must be available to everything above
(wrap-params)
(wrap-session {:store my-session-store}))Strategies are data maps passed to wrap-authenticate. Each strategy's :authenticate fn returns:
{:authenticated data}— success, identity assoc'd into request{:error error}— auth attempted but failednil— not applicable, try next strategy
First {:authenticated ...} wins. Strategies are tried in order.
wrap-authenticate accepts an optional third argument — an options map:
(oie/wrap-authenticate handler strategies {:allow-anonymous? true})| Option | Default | Description |
|---|---|---|
:allow-anonymous? |
false |
When true and no strategy is applicable (all returned nil), the request passes through to the handler without identity. A strategy error still produces a 401. |
Reads Authorization: Bearer <token> header. Hashes the raw token (SHA-256) before calling the injected lookup function.
(bearer/bearer-token-strategy
{:verify-token (fn [token-hash] ...) ;; -> token-data | nil
:clock (fn [] ...)}) ;; -> epoch-ms (optional, defaults to System/currentTimeMillis)verify-token receives a SHA-256 hex hash (64 chars), returns token data or nil. Token data must include :revoked-at (epoch-ms or nil) and :expires-at (epoch-ms), checked by token-active?.
Reads identity from the Ring session under key ::session/user.
(session-strat/session-strategy)
;; With optional verify fn for re-validation on every request:
(session-strat/session-strategy
{:verify (fn [identity] ...)}) ;; -> identity | nil (optional)verify is called with the session identity on every request. Return a (possibly enriched) identity to authenticate, or nil to treat the session as stale and skip to the next strategy. When omitted, the session identity is used as-is.
Login flow middleware composes as outer middleware around wrap-authenticate. They intercept specific URIs, authenticate the user, and create a session. Subsequent requests are then authenticated by session-strategy.
Wraps ring.middleware.oauth2/wrap-oauth2 and intercepts the landing URI to create a session.
(oauth2/wrap-oauth2 handler
{:google {:authorize-uri "https://accounts.google.com/o/oauth2/v2/auth"
:access-token-uri "https://oauth2.googleapis.com/token"
:client-id (System/getenv "GOOGLE_CLIENT_ID")
:client-secret (System/getenv "GOOGLE_CLIENT_SECRET")
:scopes [:openid :email :profile]
:launch-uri "/oauth2/google"
:redirect-uri "/oauth2/google/callback"
:landing-uri "/oauth2/google/success"
;; oie-specific keys:
:fetch-profile-fn (fn [tokens] ...) ;; -> profile map
:login-fn (fn [profile] ...) ;; -> identity | nil
:success-redirect-uri "/"}}) ;; string or (fn [req] -> uri)For OIDC providers, use decode-id-token to extract claims from the JWT instead of making an HTTP call:
:fetch-profile-fn (fn [tokens] (oauth2/decode-id-token (:id-token tokens)))Intercepts two URIs: verification (GET) and token request (POST).
(magic-link/wrap-magic-link handler
{:verify-uri "/auth/magic-link"
:request-uri "/auth/magic-link/request"
:secret (System/getenv "MAGIC_LINK_SECRET")
:token-ttl 600000 ;; 10 minutes in ms
:consume-nonce (fn [nonce] ...) ;; -> truthy if consumed, nil if already used
:store-nonce (fn [nonce email expires-at] ...)
:send-fn (fn [email token] ...) ;; deliver token to user (email, SMS, etc.)
:login-fn (fn [profile] ...) ;; -> identity | nil
:success-redirect-uri "/" ;; string or (fn [req] -> uri)
;; optional:
:token-param "token" ;; query param name for verification
:request-param "email" ;; param name for token request
:clock (fn [] ...)}) ;; -> epoch-msPOST-only handler that clears the session. Apply wrap-anti-forgery to protect against CSRF.
;; Redirect (default)
(session/logout-handler {:redirect-uri "/"})
;; SPA — custom response instead of redirect
(session/logout-handler {:response-fn (fn [] {:status 200 :body {:authenticated false}})})| Option | Default | Description |
|---|---|---|
:redirect-uri |
"/" |
Redirect target after logout. Ignored when :response-fn is provided. |
:response-fn |
— | Zero-arg fn returning a Ring response map. Session is always cleared regardless of the response. |
Returns 401 with a redirect hint for client-side re-auth. For use with ring.middleware.session-timeout/wrap-idle-session-timeout.
(session/session-timeout-handler {:redirect-uri "/login"})
;; => {:status 401, :body {:type :session-timeout, :redirect "/login"}}(oie/get-identity request) ;; => {:email "..." :roles #{:admin}} or nil
(authz/has-role? identity :admin) ;; => true | falsehas-role? checks the :roles key on the identity. Works with sets, vectors, or any seq.
| oie component | Requires upstream | Why |
|---|---|---|
session-strategy |
wrap-session |
Reads identity from :session |
wrap-oauth2 |
wrap-params, wrap-session |
ring-oauth2 reads :query-params for state/code; stores tokens in :session |
wrap-magic-link |
wrap-params, wrap-session |
Reads :query-params (verify) and :params (request); stores identity in :session |
logout-handler |
wrap-session, wrap-anti-forgery |
Clears :session; POST-only needs CSRF protection |
bearer-token-strategy |
— | Reads from :headers (always present in Ring requests) |
For bearer token management:
(token/generate-token "hb_live_") ;; => SensitiveToken (prefix + 32 base64url chars)
(token/hash-token raw-or-sensitive) ;; => 64-char SHA-256 hex string
(token/token-active? token-data now) ;; => true if non-nil, not revoked, not expiredSensitiveToken prints as #<sensitive-token> in REPL and logs to prevent secret leakage. Store only the hash — never the raw token.
Malli schemas for all middleware configs. Call at system startup for early error detection:
(require '[flybot.oie.schema :as schema])
(schema/validate-config schema/wrap-oauth2-schema my-config "wrap-oauth2")
;; => my-config if valid, throws ex-info with humanized errors if notAvailable schemas: strategy-schema, wrap-authenticate-schema, wrap-authenticate-opts-schema, bearer-token-strategy-schema, session-strategy-schema, logout-handler-schema, session-timeout-handler-schema, wrap-magic-link-schema, wrap-oauth2-schema.
Start nREPL:
bb devRun all tests:
bb testRun only rich comment tests:
bb rctCheck:
bb fmt-checkFix:
bb fmt-fix