Liking cljdoc? Tell your friends :D

oie logo

oie

Clojars CI License: Unlicense

Ring-based authentication and authorization library for Clojure. Strategy-based, composable middleware with pluggable storage.

Rationale

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 :authenticate function, not a class hierarchy or protocol implementation
  • Composable middleware: login flows (OAuth2, magic link) compose as outer middleware around a single wrap-authenticate that 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.

Installation

;; deps.edn
{:deps {sg.flybot/oie {:mvn/version "RELEASE"}
        ;; Required if you use wrap-oauth2:
        io.github.studer-l/ring-oauth2 {:git/sha "db75c05284aa2a3458025ebb124efe9d3c14439b"}}}

Quick Start

(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}))

Authentication Strategies

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 failed
  • nil — 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})
OptionDefaultDescription
:allow-anonymous?falseWhen 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.

Bearer Token

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?.

Session

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 Flows

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.

OAuth2

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)))

Deep links after login

A ?return-to query param on the launch URI is carried through the login in the session and takes precedence over :success-redirect-uri when the login completes:

GET /oauth2/google?return-to=/reports/42   →  ...OAuth flow...  →  302 Location: /reports/42

Only same-origin paths pass session/safe-return-path: one leading / (never //), then RFC 3986 path/query characters, [/] for nested query params, and Unicode letters, marks, and numbers. Anything else — absolute URLs, backslashes, whitespace, control characters, paths over 512 characters — is ignored, and the login redirects to :success-redirect-uri as usual.

The session value under session/return-to-key is rewritten on every launch and cleared once the landing URI is reached, whether the login succeeds (302), is rejected (403), or arrives without tokens (401).

Magic Link

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-ms

Session Helpers

Logout

POST-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}})})
OptionDefaultDescription
:redirect-uri"/"Redirect target after logout. Ignored when :response-fn is provided.
:response-fnZero-arg fn returning a Ring response map. Session is always cleared regardless of the response.

Session Timeout

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"}}

Authorization

(oie/get-identity request)              ;; => {:email "..." :roles #{:admin}} or nil
(authz/has-role? identity :admin)        ;; => true | false

has-role? checks the :roles key on the identity. Works with sets, vectors, or any seq.

Ring Middleware Dependencies

oie componentRequires upstreamWhy
session-strategywrap-sessionReads identity from :session
wrap-oauth2wrap-params, wrap-sessionring-oauth2 reads :query-params for state/code; stores tokens in :session
wrap-magic-linkwrap-params, wrap-sessionReads :query-params (verify) and :params (request); stores identity in :session
logout-handlerwrap-session, wrap-anti-forgeryClears :session; POST-only needs CSRF protection
bearer-token-strategyReads from :headers (always present in Ring requests)

Token Utilities

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 expired

SensitiveToken prints as #<sensitive-token> in REPL and logs to prevent secret leakage. Store only the hash — never the raw token.

Config Validation

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 not

Available 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.

Development

Start nREPL:

bb dev

Testing

Run all tests:

bb test

Run only rich comment tests:

bb rct

Formatting

Check:

bb fmt-check

Fix:

bb fmt-fix

Can you improve this documentation? These fine people already did:
chickendreanso & loicb
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close