init codebase

This commit is contained in:
2026-02-17 17:30:45 -05:00
parent a3b28549b4
commit f7e2755a91
175 changed files with 21600 additions and 232 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM clojure:temurin-21-tools-deps AS builder
WORKDIR /app
COPY deps.edn build.clj ./
COPY shared/ shared/
COPY auth-gw/ auth-gw/
RUN clj -T:build uber :module auth-gw
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/auth-gw/target/auth-gw.jar app.jar
EXPOSE 3000
CMD ["java", "-jar", "app.jar"]
+8
View File
@@ -0,0 +1,8 @@
{:tasks
{test
{:doc "Run all auth-gw module tests"
:task (shell {:dir ".."} "bb test:auth-gw")}
test:integration
{:doc "Run auth-gw integration tests"
:task (shell {:dir ".."} "bb test:auth-gw:integration")}}}
+4 -1
View File
@@ -3,7 +3,10 @@
http-kit/http-kit {:mvn/version "2.8.0"}
metosin/reitit {:mvn/version "0.7.2"}
ring/ring-core {:mvn/version "1.13.0"}
ajet/chat-shared {:local/root "../shared"}}
ajet/chat-shared {:local/root "../shared"}
hiccup/hiccup {:mvn/version "2.0.0-RC4"}
at.favre.lib/bcrypt {:mvn/version "0.10.2"}
ring/ring-codec {:mvn/version "1.2.0"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.auth-gw.core"]}
:dev {:extra-paths ["dev"]
+23
View File
@@ -0,0 +1,23 @@
{:server {:host "0.0.0.0" :port 3000}
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
:user "ajet" :password "ajet_dev" :pool-size 5}
:oauth {:github {:client-id "" :client-secret "" :enabled false}
:gitea {:client-id "" :client-secret "" :base-url "" :enabled false}
:oidc {:client-id "" :client-secret "" :issuer-url "" :enabled false}}
:services {:api {:host "localhost" :port 3001}
:web-sm {:host "localhost" :port 3002}
:tui-sm {:host "localhost" :port 3003}}
:session {:ttl-days 30
:cookie-name "ajet_session"
:cookie-secure false}
:rate-limit {:enabled true}
:cors {:allowed-origins ["http://localhost:3000" "http://localhost:3002"]
:allowed-methods [:get :post :put :delete :options]
:allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
:max-age 86400}
:profiles
{:test {:db {:host "localhost" :port 5433 :dbname "ajet_chat_test"
:password "ajet_test"}}
:prod {:session {:cookie-secure true}
:cors {:allowed-origins ["https://chat.example.com"]}}}}
+437
View File
@@ -0,0 +1,437 @@
(ns ajet.chat.auth-gw.auth
"Authentication — session and token validation, creation, and destruction.
Token format: 32 random bytes -> base64url encoded (43 chars).
Stored as bcrypt hash in the database.
Sessions use rolling expiry (default 30 days), extended on each valid request."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db])
(:import [at.favre.lib.crypto.bcrypt BCrypt]
[java.security SecureRandom]
[java.util Base64]
[java.time Instant Duration]
[java.sql Timestamp]))
;;; ---------------------------------------------------------------------------
;;; Token generation
;;; ---------------------------------------------------------------------------
(def ^:private secure-random (SecureRandom.))
(def ^:private token-byte-length 32)
(def ^:private bcrypt-cost 12)
(defn- generate-token-bytes
"Generate cryptographically random bytes."
^bytes []
(let [buf (byte-array token-byte-length)]
(.nextBytes secure-random buf)
buf))
(defn- base64url-encode
"Encode bytes to base64url string (no padding)."
^String [^bytes data]
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) data))
(defn- base64url-decode
"Decode a base64url string to bytes."
^bytes [^String s]
(.decode (Base64/getUrlDecoder) s))
(defn- bcrypt-hash-bytes
"Hash raw token chars with bcrypt, returning the hash as a string."
^String [^chars token-chars]
(.hashToString (BCrypt/withDefaults) bcrypt-cost token-chars))
(defn- bcrypt-verify
"Verify a raw token string against a bcrypt hash. Returns true if match."
[^String token ^String hash]
(let [result (.verify (BCrypt/verifyer) (.getBytes token "UTF-8") (.getBytes hash "UTF-8"))]
(.verified result)))
(defn- generate-raw-token
"Generate a random token. Returns the raw base64url-encoded string."
[]
(base64url-encode (generate-token-bytes)))
;;; ---------------------------------------------------------------------------
;;; Timestamp helpers
;;; ---------------------------------------------------------------------------
(defn- now-instant []
(Instant/now))
(defn- instant->timestamp [^Instant inst]
(Timestamp/from inst))
(defn- session-expiry
"Calculate session expiry from now + ttl-days."
[ttl-days]
(instant->timestamp (.plus (now-instant) (Duration/ofDays ttl-days))))
(defn- expired?
"Check if a timestamp is in the past."
[^Timestamp ts]
(.before ts (instant->timestamp (now-instant))))
;;; ---------------------------------------------------------------------------
;;; Session validation
;;; ---------------------------------------------------------------------------
(defn validate-session
"Extract and validate the session cookie.
Looks up the session by token hash in the sessions table, verifies the
bcrypt hash matches, and checks expiry.
Returns a map with user info on success:
{:user-id ... :user-role ... :session-id ...}
Returns nil on failure (invalid/expired/missing)."
[ds cookie-value]
(when (and cookie-value (not (empty? cookie-value)))
(try
;; Look up all non-expired sessions and verify against each
;; In practice, we rely on bcrypt verification being the gate
(let [sessions (db/execute! ds
{:select [:s.id :s.user-id :s.token-hash :s.expires-at
:u.username :u.display-name :u.email]
:from [[:sessions :s]]
:join [[:users :u] [:= :s.user-id :u.id]]
:where [:> :s.expires-at (instant->timestamp (now-instant))]})]
;; Find the session whose hash matches the provided token
(some (fn [session]
(when (bcrypt-verify cookie-value (:token-hash session))
{:session-id (:id session)
:user-id (str (:user-id session))
:username (:username session)
:display-name (:display-name session)
:email (:email session)}))
sessions))
(catch Exception e
(log/error e "Error validating session")
nil))))
(defn validate-api-token
"Extract and validate a Bearer token from the Authorization header.
Looks up the token in the api_tokens table, verifies bcrypt hash,
checks expiry.
Returns a map on success:
{:api-user-id ... :user-id ... :scopes [...]}
Returns nil on failure."
[ds authorization-header]
(when authorization-header
(let [parts (str/split authorization-header #"\s+" 2)]
(when (and (= "Bearer" (first parts)) (second parts))
(let [raw-token (second parts)]
(try
(let [tokens (db/execute! ds
{:select [:at.id :at.api-user-id :at.token-hash
:at.expires-at :at.scopes
:au.user-id :au.community-id]
:from [[:api_tokens :at]]
:join [[:api_users :au] [:= :at.api-user-id :au.id]]
:where [:or
[:= :at.expires-at nil]
[:> :at.expires-at (instant->timestamp (now-instant))]]})]
(some (fn [token-row]
(when (bcrypt-verify raw-token (:token-hash token-row))
{:api-user-id (str (:api-user-id token-row))
:user-id (str (:user-id token-row))
:community-id (str (:community-id token-row))
:scopes (:scopes token-row)}))
tokens))
(catch Exception e
(log/error e "Error validating API token")
nil)))))))
(defn validate-webhook-token
"Validate a Bearer token for webhook incoming requests.
Looks up the token in the webhooks table and verifies bcrypt hash.
Returns a map on success:
{:webhook-id ... :community-id ... :channel-id ...}
Returns nil on failure."
[ds authorization-header]
(when authorization-header
(let [parts (str/split authorization-header #"\s+" 2)]
(when (and (= "Bearer" (first parts)) (second parts))
(let [raw-token (second parts)]
(try
(let [webhooks (db/execute! ds
{:select [:id :community-id :channel-id
:token-hash :name]
:from [:webhooks]})]
(some (fn [wh]
(when (bcrypt-verify raw-token (:token-hash wh))
{:webhook-id (str (:id wh))
:community-id (str (:community-id wh))
:channel-id (str (:channel-id wh))
:webhook-name (:name wh)}))
webhooks))
(catch Exception e
(log/error e "Error validating webhook token")
nil)))))))
;;; ---------------------------------------------------------------------------
;;; Session TTL extension
;;; ---------------------------------------------------------------------------
(defn extend-session-ttl!
"Asynchronously extend the session's expires_at. Fire-and-forget via future."
[ds session-id ttl-days]
(future
(try
(db/execute! ds
{:update :sessions
:set {:expires-at (session-expiry ttl-days)}
:where [:= :id session-id]})
(catch Exception e
(log/warn e "Failed to extend session TTL for" session-id)))))
;;; ---------------------------------------------------------------------------
;;; Session creation
;;; ---------------------------------------------------------------------------
(defn create-session!
"Create a new session for a user.
Generates a random token, bcrypt hashes it, inserts into sessions table.
Returns the raw token (to be set as cookie value)."
[ds user-id ttl-days]
(let [raw-token (generate-raw-token)
token-hash (bcrypt-hash-bytes (.toCharArray raw-token))
session-id (java.util.UUID/randomUUID)
now-ts (instant->timestamp (now-instant))
expires (session-expiry ttl-days)]
(db/execute! ds
{:insert-into :sessions
:values [{:id session-id
:user-id (if (instance? java.util.UUID user-id)
user-id
(java.util.UUID/fromString (str user-id)))
:token-hash token-hash
:expires-at expires
:created-at now-ts}]})
(log/info "Created session" session-id "for user" user-id)
raw-token))
;;; ---------------------------------------------------------------------------
;;; Session destruction
;;; ---------------------------------------------------------------------------
(defn destroy-session!
"Destroy a session by deleting from the database.
Returns a Ring response map fragment with cleared cookie."
[ds cookie-value cookie-name cookie-secure?]
(when (and cookie-value (not (empty? cookie-value)))
;; Find and delete matching session
(try
(let [sessions (db/execute! ds
{:select [:id :token-hash]
:from [:sessions]})]
(doseq [session sessions]
(when (bcrypt-verify cookie-value (:token-hash session))
(db/execute! ds
{:delete-from :sessions
:where [:= :id (:id session)]})
(log/info "Destroyed session" (:id session)))))
(catch Exception e
(log/warn e "Error destroying session"))))
;; Return cleared cookie header
{:cookies {cookie-name {:value ""
:path "/"
:max-age 0
:http-only true
:secure cookie-secure?
:same-site :lax}}})
;;; ---------------------------------------------------------------------------
;;; User lookup / creation for OAuth
;;; ---------------------------------------------------------------------------
(defn find-user-by-oauth
"Look up a user by OAuth provider and provider user ID.
Returns the user map or nil."
[ds provider provider-user-id]
(db/execute-one! ds
{:select [:u.id :u.username :u.display-name :u.email :u.avatar-url :u.created-at]
:from [[:users :u]]
:join [[:oauth_accounts :oa] [:= :oa.user-id :u.id]]
:where [:and
[:= :oa.provider (name provider)]
[:= :oa.provider-user-id (str provider-user-id)]]}))
(defn create-user-from-oauth!
"Create a new user and linked OAuth account from provider profile data.
profile: {:username, :display-name, :email, :avatar-url,
:provider, :provider-user-id, :provider-username}
Returns the created user map."
[ds profile]
(let [user-id (java.util.UUID/randomUUID)
oauth-id (java.util.UUID/randomUUID)
now-ts (instant->timestamp (now-instant))
;; Ensure unique username — append random suffix if collision
base-username (:username profile)
user (db/execute-one! ds
{:insert-into :users
:values [{:id user-id
:username base-username
:display-name (or (:display-name profile) base-username)
:email (or (:email profile) "")
:avatar-url (:avatar-url profile)
:created-at now-ts}]
:returning [:*]})]
;; Create OAuth account link
(db/execute! ds
{:insert-into :oauth_accounts
:values [{:id oauth-id
:user-id user-id
:provider (name (:provider profile))
:provider-user-id (str (:provider-user-id profile))
:provider-username (:provider-username profile)
:created-at now-ts}]})
(log/info "Created user" user-id "via OAuth" (name (:provider profile))
"provider-user-id" (:provider-user-id profile))
user))
(defn count-users
"Return the total number of users in the database."
[ds]
(let [result (db/execute-one! ds
{:select [[[:count :*] :cnt]]
:from [:users]})]
(or (:cnt result) 0)))
(defn find-user-by-username
"Look up a user by username. Returns the user map or nil."
[ds username]
(db/execute-one! ds
{:select [:*]
:from [:users]
:where [:= :username username]}))
;;; ---------------------------------------------------------------------------
;;; OAuth Provider queries (DB-stored providers)
;;; ---------------------------------------------------------------------------
(defn list-oauth-providers
"Return all enabled OAuth providers from the database, ordered by sort_order."
[ds]
(db/execute! ds
{:select [:*]
:from [:oauth-providers]
:where [:= :enabled true]
:order-by [[:sort-order :asc] [:created-at :asc]]}))
(defn list-all-oauth-providers
"Return all OAuth providers from the database (including disabled)."
[ds]
(db/execute! ds
{:select [:*]
:from [:oauth-providers]
:order-by [[:sort-order :asc] [:created-at :asc]]}))
(defn count-oauth-providers
"Count enabled OAuth providers."
[ds]
(let [result (db/execute-one! ds
{:select [[[:count :*] :cnt]]
:from [:oauth-providers]
:where [:= :enabled true]})]
(or (:cnt result) 0)))
(defn find-oauth-provider-by-slug
"Look up an OAuth provider by slug. Returns the provider row or nil."
[ds slug]
(db/execute-one! ds
{:select [:*]
:from [:oauth-providers]
:where [:and [:= :slug slug] [:= :enabled true]]}))
(defn insert-oauth-provider!
"Insert a new OAuth provider. Returns the created row."
[ds provider-map]
(db/execute-one! ds
{:insert-into :oauth-providers
:values [provider-map]
:returning [:*]}))
(defn delete-oauth-provider!
"Delete an OAuth provider by ID."
[ds id]
(db/execute! ds
{:delete-from :oauth-providers
:where [:= :id id]}))
;;; ---------------------------------------------------------------------------
;;; System settings
;;; ---------------------------------------------------------------------------
(defn get-system-setting
"Read a system setting value by key. Returns the string value or nil."
[ds key]
(:value (db/execute-one! ds
{:select [:value]
:from [:system-settings]
:where [:= :key key]})))
(defn set-system-setting!
"Upsert a system setting."
[ds key value]
(db/execute! ds
{:insert-into :system-settings
:values [{:key key :value value :updated-at (instant->timestamp (now-instant))}]
:on-conflict [:key]
:do-update-set {:value value :updated-at (instant->timestamp (now-instant))}}))
;;; ---------------------------------------------------------------------------
;;; Invite helpers
;;; ---------------------------------------------------------------------------
(defn find-invite-by-code
"Look up an invite by code. Returns the invite map or nil."
[ds code]
(db/execute-one! ds
{:select [:i.id :i.community-id :i.code :i.max-uses :i.uses
:i.expires-at :i.created-at
:c.name :c.slug]
:from [[:invites :i]]
:join [[:communities :c] [:= :i.community-id :c.id]]
:where [:= :i.code code]}))
(defn invite-valid?
"Check if an invite is still valid (not expired, not exhausted)."
[invite]
(and invite
;; Not expired
(or (nil? (:expires-at invite))
(not (expired? (:expires-at invite))))
;; Not exhausted
(or (nil? (:max-uses invite))
(< (:uses invite) (:max-uses invite)))))
;;; ---------------------------------------------------------------------------
;;; Cookie helpers
;;; ---------------------------------------------------------------------------
(defn session-cookie
"Build a session cookie map for Ring."
[cookie-name raw-token ttl-days secure?]
{cookie-name {:value raw-token
:path "/"
:max-age (* ttl-days 24 60 60)
:http-only true
:secure secure?
:same-site :lax}})
(defn extract-session-cookie
"Extract the session token from Ring request cookies."
[request cookie-name]
(get-in request [:cookies cookie-name :value]))
+163 -2
View File
@@ -1,5 +1,166 @@
(ns ajet.chat.auth-gw.core
"Auth gateway — http-kit reverse proxy with authn/authz.")
"Auth Gateway — http-kit reverse proxy with authentication.
Single edge entry point for all client traffic. Terminates sessions,
validates tokens, and proxies authenticated requests to internal
services (API, Web SM, TUI SM).
System state held in a single atom for REPL-driven development."
(:refer-clojure :exclude [reset!])
(:require [clojure.tools.logging :as log]
[org.httpkit.server :as http-kit]
[ajet.chat.shared.config :as config]
[ajet.chat.shared.db :as db]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.routes :as routes]
[ajet.chat.auth-gw.rate-limit :as rate-limit]
[ajet.chat.auth-gw.setup :as setup])
(:gen-class))
(defonce system (atom nil))
;;; ---------------------------------------------------------------------------
;;; Provider migration (env config → DB)
;;; ---------------------------------------------------------------------------
(defn- migrate-config-providers-to-db!
"On first startup, if the DB has no OAuth providers but the config file
has enabled providers, auto-migrate them to the DB for backward compat."
[ds oauth-config]
(when (zero? (auth/count-oauth-providers ds))
(doseq [[provider-kw pcfg] oauth-config
:when (:enabled pcfg)]
(let [ptype (name provider-kw)]
(log/info "Auto-migrating OAuth provider from config to DB:" ptype)
(auth/insert-oauth-provider! ds
(cond-> {:provider-type ptype
:display-name (case provider-kw
:github "GitHub"
:gitea "Gitea"
:oidc "SSO"
ptype)
:slug ptype
:client-id (:client-id pcfg)
:client-secret (:client-secret pcfg)}
(:base-url pcfg) (assoc :base-url (:base-url pcfg))
(:issuer-url pcfg) (assoc :issuer-url (:issuer-url pcfg))))))))
;;; ---------------------------------------------------------------------------
;;; Lifecycle
;;; ---------------------------------------------------------------------------
(defn start!
"Start the Auth Gateway service.
1. Load config (EDN + env vars)
2. Create DB connection pool (HikariCP)
3. Run migrations
4. Initialize OAuth providers atom (from DB)
5. Initialize setup-complete? atom
6. Initialize rate limiter (in-memory atom)
7. Start http-kit server with reitit router
8. Log startup"
[& [{:keys [config-overrides]}]]
(when @system
(log/warn "System already started — call (stop!) first")
(throw (ex-info "System already running" {})))
(let [config (config/load-config {:resource "auth-gw-config.edn"})
config (if config-overrides
(merge config config-overrides)
config)
_ (log/info "Loaded config:" (config/redact config))
;; Database — Auth GW has direct PG access for sessions/tokens
ds (db/make-datasource (:db config))
_ (log/info "Database connection pool created")
_ (when (get-in config [:db :migrations :enabled] true)
(db/migrate! ds (get-in config [:db :migrations])))
;; Auto-migrate OAuth providers from config to DB (backward compat)
_ (migrate-config-providers-to-db! ds (:oauth config))
;; OAuth providers — loaded from DB, cached in atom
oauth-provs (atom (auth/list-oauth-providers ds))
_ (log/info "Loaded" (count @oauth-provs) "OAuth providers from DB")
;; Setup completion status — cached in atom
setup-atom (atom nil)
;; Rate limiter
limiter (rate-limit/make-limiter)
cleanup-fn (rate-limit/start-cleanup-task! limiter)
_ (log/info "Rate limiter initialized")
;; System map
sys {:config config
:ds ds
:limiter limiter
:oauth-providers-atom oauth-provs
:setup-complete-atom setup-atom}
;; HTTP server
handler (routes/app sys)
port (get-in config [:server :port] 3000)
host (get-in config [:server :host] "0.0.0.0")
server (http-kit/run-server handler
{:port port
:ip host
:max-body (* 12 1024 1024)
;; Don't buffer SSE responses
:server-header "ajet-auth-gw"})]
(clojure.core/reset! system (assoc sys
:server server
:port port
:cleanup-fn cleanup-fn))
(log/info (str "Auth Gateway started on " host ":" port))
@system))
(defn stop!
"Stop the Auth Gateway. Shuts down HTTP server, DB pool in order."
[]
(when-let [sys @system]
(log/info "Shutting down Auth Gateway...")
;; Stop HTTP server (wait up to 30s for in-flight requests)
(when-let [server (:server sys)]
(server :timeout 30000)
(log/info "HTTP server stopped"))
;; Cancel rate limiter cleanup task
(when-let [cleanup-fn (:cleanup-fn sys)]
(future-cancel cleanup-fn)
(log/info "Rate limiter cleanup task stopped"))
;; Close DB pool
(when-let [ds (:ds sys)]
(try
(db/close-datasource ds)
(log/info "Database connection pool closed")
(catch Exception e
(log/error e "Error closing database pool"))))
(clojure.core/reset! system nil)
(log/info "Auth Gateway stopped")))
(defn reset!
"Stop then start the system (REPL convenience)."
[]
(stop!)
(start!))
;;; ---------------------------------------------------------------------------
;;; Entry point
;;; ---------------------------------------------------------------------------
(defn -main [& _args]
(println "ajet-chat auth gateway starting..."))
(start!)
;; Graceful shutdown hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. ^Runnable (fn []
(log/info "Shutdown hook triggered")
(stop!))))
;; Block main thread
@(promise))
@@ -0,0 +1,174 @@
(ns ajet.chat.auth-gw.middleware
"Ring middleware for the Auth Gateway.
Pipeline order (outermost first):
1. wrap-exception-handler — catch-all error handler
2. wrap-cors — CORS headers and OPTIONS preflight
3. wrap-trace-id — ensure X-Trace-Id on every request
4. wrap-rate-limit — token-bucket rate limiting"
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.auth-gw.rate-limit :as rl]
[ajet.chat.auth-gw.pages :as pages])
(:import [java.io ByteArrayInputStream InputStream]))
;;; ---------------------------------------------------------------------------
;;; Body buffering (for proxy + wrap-params coexistence)
;;; ---------------------------------------------------------------------------
(defn wrap-buffer-body
"Buffer the request body so it can be read by both wrap-params and the proxy.
Without this, wrap-params consumes the InputStream and the proxy gets an empty body."
[handler]
(fn [request]
(let [body (:body request)]
(if (instance? InputStream body)
(let [bytes (.readAllBytes ^InputStream body)
request (assoc request
:body (ByteArrayInputStream. bytes)
:raw-body bytes)]
(handler request))
(handler request)))))
;;; ---------------------------------------------------------------------------
;;; Exception handler
;;; ---------------------------------------------------------------------------
(defn wrap-exception-handler
"Catch-all middleware that turns unhandled exceptions into 500 responses."
[handler]
(fn [request]
(try
(handler request)
(catch Exception e
(let [trace-id (get-in request [:headers "x-trace-id"] "unknown")]
(log/error e "Unhandled exception" {:trace-id trace-id
:uri (:uri request)
:method (:request-method request)})
{:status 500
:headers {"Content-Type" "text/html; charset=utf-8"
"X-Trace-Id" trace-id}
:body (pages/error-page {:status 500
:title "Internal Server Error"
:message "An unexpected error occurred. Please try again later."})})))))
;;; ---------------------------------------------------------------------------
;;; CORS
;;; ---------------------------------------------------------------------------
(defn- origin-allowed?
"Check if the request Origin is in the allowed list.
In dev mode, allow any localhost origin."
[origin allowed-origins dev-mode?]
(cond
(str/blank? origin) false
dev-mode? (or (str/starts-with? origin "http://localhost")
(str/starts-with? origin "http://127.0.0.1")
(contains? (set allowed-origins) origin))
:else (contains? (set allowed-origins) origin)))
(defn- cors-headers
"Build CORS response headers."
[origin config]
(let [methods (or (:allowed-methods config)
[:get :post :put :delete :options])
headers-list (or (:allowed-headers config)
["Content-Type" "Authorization" "X-Trace-Id"])
max-age (or (:max-age config) 86400)]
{"Access-Control-Allow-Origin" origin
"Access-Control-Allow-Methods" (str/join ", " (map #(str/upper-case (name %)) methods))
"Access-Control-Allow-Headers" (str/join ", " headers-list)
"Access-Control-Allow-Credentials" "true"
"Access-Control-Max-Age" (str max-age)}))
(defn wrap-cors
"CORS middleware — adds CORS headers and handles OPTIONS preflight requests."
[handler {:keys [cors] :as config}]
(let [allowed-origins (:allowed-origins cors)
dev-mode? (not (get-in config [:session :cookie-secure] true))]
(fn [request]
(let [origin (get-in request [:headers "origin"])]
(if (and (= :options (:request-method request)) origin)
;; Preflight request — respond immediately
(if (origin-allowed? origin allowed-origins dev-mode?)
{:status 204
:headers (cors-headers origin cors)
:body ""}
{:status 403
:headers {"Content-Type" "text/plain"}
:body "CORS origin not allowed"})
;; Normal request — add CORS headers to response
(let [response (handler request)]
(if (and origin (origin-allowed? origin allowed-origins dev-mode?))
(update response :headers merge (cors-headers origin cors))
response)))))))
;;; ---------------------------------------------------------------------------
;;; Trace ID
;;; ---------------------------------------------------------------------------
(defn wrap-trace-id
"Ensure every request has an X-Trace-Id header. Generates one if missing.
Also adds the trace ID to the response."
[handler]
(fn [request]
(let [existing (get-in request [:headers "x-trace-id"])
trace-id (or existing (str (java.util.UUID/randomUUID)))
request (if existing
request
(assoc-in request [:headers "x-trace-id"] trace-id))
response (handler request)]
(assoc-in response [:headers "X-Trace-Id"] trace-id))))
;;; ---------------------------------------------------------------------------
;;; Rate limiting
;;; ---------------------------------------------------------------------------
(defn wrap-rate-limit
"Apply rate limiting based on route classification.
The limiter-atom is created at startup and shared across all requests.
Rate limit categories are determined by the request URI and method."
[handler limiter-atom rate-limit-config]
(if-not (:enabled rate-limit-config)
handler ;; Rate limiting disabled
(fn [request]
(let [classification (rl/classify-request request)]
(if-not classification
;; No rate limit applies
(handler request)
(let [[category identity-key] classification
result (rl/check-rate-limit! limiter-atom category identity-key)]
(if (:allowed? result)
(handler request)
;; Rate limited
(let [retry-after-s (max 1 (long (Math/ceil (/ (:retry-after-ms result 1000) 1000.0))))]
(log/warn "Rate limited" category identity-key
"retry-after" retry-after-s "s")
{:status 429
:headers {"Content-Type" "text/html; charset=utf-8"
"Retry-After" (str retry-after-s)}
:body (pages/error-page
{:status 429
:title "Too Many Requests"
:message "You're making requests too quickly. Please slow down."
:retry-after retry-after-s})}))))))))
;;; ---------------------------------------------------------------------------
;;; Request logging
;;; ---------------------------------------------------------------------------
(defn wrap-request-logging
"Log each request with method, path, status, and duration."
[handler]
(fn [request]
(let [method (str/upper-case (name (:request-method request)))
path (:uri request)
trace-id (get-in request [:headers "x-trace-id"] "?")
start (System/nanoTime)
response (handler request)
duration (/ (- (System/nanoTime) start) 1e6)]
(log/info (format "[%s] %s %s %d (%.0fms)"
trace-id method path
(:status response 500) duration))
response)))
+520
View File
@@ -0,0 +1,520 @@
(ns ajet.chat.auth-gw.oauth
"OAuth login flows — GitHub, Gitea, and generic OIDC.
Handles the full OAuth2 authorization code flow:
1. Generate authorize URL with state parameter (CSRF protection)
2. Exchange authorization code for access token
3. Fetch user profile from provider
4. Find or create local user + oauth_account
5. Create session and redirect
Providers are loaded dynamically from the database (not static config)."
(:require [babashka.http-client :as http]
[clojure.data.json :as json]
[clojure.string :as str]
[clojure.tools.logging :as log]
[ring.util.codec :as codec]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.pages :as pages]
[ajet.chat.auth-gw.setup :as setup])
(:import [java.security SecureRandom]
[java.util Base64]))
;;; ---------------------------------------------------------------------------
;;; State parameter (CSRF protection)
;;; ---------------------------------------------------------------------------
(def ^:private secure-random (SecureRandom.))
(defn- generate-state
"Generate a random state parameter for CSRF protection."
[]
(let [buf (byte-array 16)]
(.nextBytes secure-random buf)
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) buf)))
;; In-memory state store with expiry (5 minutes)
(def ^:private state-store (atom {}))
(defn- store-state!
"Store a state parameter with metadata. Returns the state string."
[metadata]
(let [state (generate-state)
expiry (+ (System/currentTimeMillis) (* 5 60 1000))]
;; Clean up expired states while we're at it
(swap! state-store
(fn [store]
(let [now (System/currentTimeMillis)
cleaned (into {} (filter (fn [[_ v]] (> (:expiry v) now))) store)]
(assoc cleaned state (merge metadata {:expiry expiry})))))
state))
(defn- consume-state!
"Validate and consume a state parameter. Returns the metadata or nil."
[state]
(when state
(let [result (atom nil)]
(swap! state-store
(fn [store]
(let [entry (get store state)]
(if (and entry (> (:expiry entry) (System/currentTimeMillis)))
(do (reset! result (dissoc entry :expiry))
(dissoc store state))
(do (reset! result nil)
store)))))
@result)))
;;; ---------------------------------------------------------------------------
;;; GitHub OAuth
;;; ---------------------------------------------------------------------------
(def ^:private github-authorize-url "https://github.com/login/oauth/authorize")
(def ^:private github-token-url "https://github.com/login/oauth/access_token")
(def ^:private github-user-url "https://api.github.com/user")
(def ^:private github-emails-url "https://api.github.com/user/emails")
(defn- github-authorize-redirect
"Build the GitHub OAuth authorize redirect URL."
[client-id state redirect-uri]
(str github-authorize-url "?"
(codec/form-encode {"client_id" client-id
"redirect_uri" redirect-uri
"scope" "read:user user:email"
"state" state})))
(defn- github-exchange-code
"Exchange an authorization code for an access token with GitHub."
[client-id client-secret code redirect-uri]
(let [resp (http/post github-token-url
{:headers {"Accept" "application/json"
"Content-Type" "application/x-www-form-urlencoded"}
:body (codec/form-encode {"client_id" client-id
"client_secret" client-secret
"code" code
"redirect_uri" redirect-uri})
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [body (json/read-str (:body resp) :key-fn keyword)]
(:access_token body)))))
(defn- github-fetch-profile
"Fetch the user profile from GitHub using the access token."
[access-token]
(let [user-resp (http/get github-user-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})
emails-resp (http/get github-emails-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status user-resp))
(let [user (json/read-str (:body user-resp) :key-fn keyword)
emails (when (= 200 (:status emails-resp))
(json/read-str (:body emails-resp) :key-fn keyword))
primary-email (or (->> emails
(filter :primary)
first
:email)
(:email user))]
{:provider :github
:provider-user-id (str (:id user))
:provider-username (:login user)
:username (:login user)
:display-name (or (:name user) (:login user))
:email (or primary-email "")
:avatar-url (:avatar_url user)}))))
;;; ---------------------------------------------------------------------------
;;; Gitea OAuth
;;; ---------------------------------------------------------------------------
(defn- gitea-authorize-redirect
"Build the Gitea OAuth authorize redirect URL."
[base-url client-id state redirect-uri]
(let [authorize-url (str (str/replace base-url #"/+$" "") "/login/oauth/authorize")]
(str authorize-url "?"
(codec/form-encode {"client_id" client-id
"redirect_uri" redirect-uri
"response_type" "code"
"scope" ""
"state" state}))))
(defn- gitea-exchange-code
"Exchange an authorization code for an access token with Gitea."
[base-url client-id client-secret code redirect-uri]
(let [token-url (str (str/replace base-url #"/+$" "") "/login/oauth/access_token")
resp (http/post token-url
{:headers {"Accept" "application/json"
"Content-Type" "application/x-www-form-urlencoded"}
:body (codec/form-encode {"client_id" client-id
"client_secret" client-secret
"code" code
"grant_type" "authorization_code"
"redirect_uri" redirect-uri})
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [body (json/read-str (:body resp) :key-fn keyword)]
(:access_token body)))))
(defn- gitea-fetch-profile
"Fetch the user profile from Gitea using the access token."
[base-url access-token]
(let [user-url (str (str/replace base-url #"/+$" "") "/api/v1/user")
resp (http/get user-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [user (json/read-str (:body resp) :key-fn keyword)]
{:provider :gitea
:provider-user-id (str (:id user))
:provider-username (:login user)
:username (:login user)
:display-name (or (:full_name user) (:login user))
:email (or (:email user) "")
:avatar-url (:avatar_url user)}))))
;;; ---------------------------------------------------------------------------
;;; OIDC (OpenID Connect)
;;; ---------------------------------------------------------------------------
(defn- oidc-discover
"Fetch the OIDC discovery document from the issuer's well-known URL."
[issuer-url]
(let [discovery-url (str (str/replace issuer-url #"/+$" "") "/.well-known/openid-configuration")
resp (http/get discovery-url
{:headers {"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(json/read-str (:body resp) :key-fn keyword))))
(defn- oidc-authorize-redirect
"Build the OIDC authorize redirect URL."
[discovery client-id state redirect-uri]
(let [authorize-url (:authorization_endpoint discovery)]
(str authorize-url "?"
(codec/form-encode {"client_id" client-id
"redirect_uri" redirect-uri
"response_type" "code"
"scope" "openid profile email"
"state" state}))))
(defn- oidc-exchange-code
"Exchange an authorization code for tokens with the OIDC provider."
[discovery client-id client-secret code redirect-uri]
(let [token-url (:token_endpoint discovery)
resp (http/post token-url
{:headers {"Accept" "application/json"
"Content-Type" "application/x-www-form-urlencoded"}
:body (codec/form-encode {"client_id" client-id
"client_secret" client-secret
"code" code
"grant_type" "authorization_code"
"redirect_uri" redirect-uri})
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [body (json/read-str (:body resp) :key-fn keyword)]
(:access_token body)))))
(defn- oidc-fetch-profile
"Fetch the user profile from the OIDC userinfo endpoint."
[discovery access-token]
(let [userinfo-url (:userinfo_endpoint discovery)
resp (http/get userinfo-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [user (json/read-str (:body resp) :key-fn keyword)]
{:provider :oidc
:provider-user-id (str (:sub user))
:provider-username (or (:preferred_username user) (:sub user))
:username (or (:preferred_username user)
(:nickname user)
(first (str/split (or (:email user) "user") #"@")))
:display-name (or (:name user)
(:preferred_username user)
(:sub user))
:email (or (:email user) "")
:avatar-url (:picture user)}))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn login-page-handler
"Handle GET /auth/login — render the login page.
If setup is not complete, redirect to /setup.
If a `provider` query param is present, redirect to that provider's
authorize URL. Otherwise render the login page with provider buttons."
[{:keys [ds config oauth-providers-atom] :as sys} request]
(let [first-user? (zero? (auth/count-users ds))
setup-done? (setup/setup-complete? sys)
providers @oauth-providers-atom]
;; Redirect to setup wizard when: no users, no providers, setup not done
;; (If providers exist, show login page so admin can create their account via OAuth)
(if (and first-user? (not setup-done?) (empty? providers))
{:status 302
:headers {"Location" "/setup"}
:body ""}
;; Normal login page
(let [providers providers
params (:query-params request)
provider-slug (get params "provider")
error (get params "error")
invite-code (or (get params "invite")
(get-in request [:cookies "ajet_invite" :value]))
base-url (str "http"
(when (get-in config [:session :cookie-secure]) "s")
"://"
(get-in request [:headers "host"]))
invite-info (when invite-code
(let [invite (auth/find-invite-by-code ds invite-code)]
(when (auth/invite-valid? invite)
{:community-name (:name invite)})))]
(if provider-slug
;; Redirect to OAuth provider
(let [provider-row (some #(when (= (:slug %) provider-slug) %) providers)]
(if provider-row
(let [redirect-uri (str base-url "/auth/callback/" provider-slug)
state (store-state! {:provider-slug provider-slug
:invite-code invite-code})
redirect-url
(case (:provider-type provider-row)
"github" (github-authorize-redirect
(:client-id provider-row) state redirect-uri)
"gitea" (gitea-authorize-redirect
(:base-url provider-row)
(:client-id provider-row) state redirect-uri)
"oidc" (let [discovery (oidc-discover (:issuer-url provider-row))]
(if discovery
(oidc-authorize-redirect
discovery (:client-id provider-row) state redirect-uri)
(do (log/error "OIDC discovery failed for" (:issuer-url provider-row))
nil)))
nil)]
(if redirect-url
{:status 302
:headers {"Location" redirect-url}
:body ""}
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Provider configuration error"))}
:body ""}))
;; Unknown provider slug
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Unknown provider"))}
:body ""}))
;; Render login page
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-store"}
:body (pages/login-page {:providers providers
:error error
:invite-info invite-info
:first-user? first-user?
})})))))
(defn- handle-oauth-success
"Common handler after successful OAuth profile fetch.
Finds or creates the user, creates a session, handles invite flow,
and redirects appropriately."
[ds config sys profile invite-code]
(let [ttl-days (get-in config [:session :ttl-days] 30)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
cookie-secure (get-in config [:session :cookie-secure] true)
;; Find or create user
existing-user (auth/find-user-by-oauth ds (:provider profile) (:provider-user-id profile))
user (or existing-user
(auth/create-user-from-oauth! ds profile))
user-id (:id user)
;; Create session
raw-token (auth/create-session! ds user-id ttl-days)
;; Determine redirect
setup-done? (setup/setup-complete? sys)
first-user? (and (nil? existing-user)
(= 1 (auth/count-users ds)))
redirect-to (cond
;; First-user bootstrap: redirect to setup if not complete
(and first-user? (not setup-done?)) "/setup"
;; Invite flow: accept invite then redirect to community
invite-code (str "/invite/" (codec/url-encode invite-code) "/accept")
;; Normal: redirect to app
:else "/")]
(log/info "OAuth login success for user" user-id
"provider" (name (:provider profile))
(if existing-user "existing" "new") "user"
"redirect-to" redirect-to)
(cond-> {:status 302
:headers {"Location" redirect-to}
:body ""
:cookies (auth/session-cookie cookie-name raw-token ttl-days cookie-secure)}
;; Clear invite cookie after use
invite-code
(update :cookies assoc "ajet_invite" {:value ""
:path "/"
:max-age 0}))))
(defn callback-handler
"Handle GET /auth/callback/:provider — OAuth callback.
Validates the state parameter, exchanges the code for an access token,
fetches the user profile, and completes the login flow.
Looks up the provider by slug from the DB-backed atom."
[{:keys [ds config oauth-providers-atom] :as sys} request]
(let [provider-slug (get-in request [:path-params :provider])
params (:query-params request)
code (get params "code")
state (get params "state")
error-param (get params "error")
;; Look up provider from DB-backed atom
provider-row (some #(when (= (:slug %) provider-slug) %) @oauth-providers-atom)
base-url (str "http"
(when (get-in config [:session :cookie-secure]) "s")
"://"
(get-in request [:headers "host"]))
redirect-uri (str base-url "/auth/callback/" provider-slug)]
(cond
;; Provider returned an error
error-param
(do (log/warn "OAuth error from provider" provider-slug ":" error-param)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode (str "Login failed: " error-param)))}
:body ""})
;; Missing code or state
(or (str/blank? code) (str/blank? state))
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Invalid OAuth callback — missing parameters"))}
:body ""}
;; Unknown provider
(nil? provider-row)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Unknown OAuth provider"))}
:body ""}
;; Invalid or expired state (CSRF check)
:else
(let [state-meta (consume-state! state)]
(if-not state-meta
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Invalid or expired login session. Please try again."))}
:body ""}
;; Exchange code and fetch profile
(let [invite-code (:invite-code state-meta)
ptype (:provider-type provider-row)]
(try
(let [[access-token profile]
(case ptype
"github"
(let [token (github-exchange-code
(:client-id provider-row)
(:client-secret provider-row)
code redirect-uri)]
[token (when token (github-fetch-profile token))])
"gitea"
(let [;; Server-side URL may differ from browser-facing base-url
;; (e.g. Docker host.docker.internal vs localhost)
server-url (or (get-in config [:oauth :gitea-server-base-url])
(:base-url provider-row))
token (gitea-exchange-code
server-url
(:client-id provider-row)
(:client-secret provider-row)
code redirect-uri)]
[token (when token (gitea-fetch-profile server-url token))])
"oidc"
(let [discovery (oidc-discover (:issuer-url provider-row))
token (when discovery
(oidc-exchange-code
discovery
(:client-id provider-row)
(:client-secret provider-row)
code redirect-uri))]
[token (when token (oidc-fetch-profile discovery token))])
;; Unknown provider type
[nil nil])]
(if profile
(handle-oauth-success ds config sys profile invite-code)
(do (log/warn "OAuth flow failed — could not obtain profile from" provider-slug)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Could not authenticate with provider. Please try again."))}
:body ""})))
(catch Exception e
(log/error e "OAuth callback error for provider" provider-slug)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Authentication service error. Please try again."))}
:body ""}))))))))
(defn logout-handler
"Handle POST /auth/logout — destroy session and clear cookie."
[{:keys [ds config]} request]
(let [cookie-name (get-in config [:session :cookie-name] "ajet_session")
cookie-secure (get-in config [:session :cookie-secure] true)
cookie-value (auth/extract-session-cookie request cookie-name)
cookie-result (auth/destroy-session! ds cookie-value cookie-name cookie-secure)]
(log/info "User logged out")
(merge {:status 302
:headers {"Location" "/auth/login"}
:body ""}
cookie-result)))
(defn invite-landing-handler
"Handle GET /invite/:code — render invite landing page or error."
[{:keys [ds config]} request]
(let [code (get-in request [:path-params :code])
invite (auth/find-invite-by-code ds code)]
(cond
;; Invite not found
(nil? invite)
{:status 404
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (pages/invite-page {:community-name "Unknown"
:invite-code code
:error "This invite link is invalid or has been revoked."})}
;; Invite expired or exhausted
(not (auth/invite-valid? invite))
{:status 410
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (pages/invite-page {:community-name (:name invite)
:invite-code code
:error "This invite has expired or reached its maximum uses."})}
;; Valid invite — show landing page, set invite cookie
:else
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-store"}
:cookies {"ajet_invite" {:value code
:path "/"
:max-age (* 30 60) ;; 30 minutes
:http-only true
:same-site :lax}}
:body (pages/invite-page {:community-name (:name invite)
:invite-code code})})))
+321
View File
@@ -0,0 +1,321 @@
(ns ajet.chat.auth-gw.pages
"HTML pages rendered by the Auth Gateway using Hiccup.
Renders login, error, invite landing, and setup wizard pages.
Uses Tailwind CSS via CDN for styling."
(:require [hiccup2.core :as h]
[hiccup.util :as hu]))
;;; ---------------------------------------------------------------------------
;;; Layout
;;; ---------------------------------------------------------------------------
(defn- page-shell
"Base HTML shell with Tailwind CDN and dark theme."
[title & body]
(str
"<!DOCTYPE html>"
(h/html
[:html {:lang "en" :class "dark"}
[:head
[:meta {:charset "UTF-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1.0"}]
[:title (str title " - ajet chat")]
[:script {:src "https://cdn.tailwindcss.com"}]
[:script
(h/raw "tailwindcss.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81' }
}
}
}
}")]]
[:body {:class "bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center antialiased"}
[:div {:class "w-full max-w-md mx-auto px-4"}
body]]])))
(defn- provider-button
"Render an OAuth provider login button."
[slug label icon-svg]
[:a {:href (str "/auth/login?provider=" slug)
:class "flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 hover:border-gray-600 text-gray-100 font-medium transition-colors duration-150 no-underline"}
(h/raw icon-svg)
[:span (str "Continue with " label)]])
(def ^:private provider-icons
{"github" "<svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/></svg>"
"gitea" "<svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 6.407.140 7.954.140 7.954c.224.594.47.972.736 1.249-.192.294-.39.633-.543 1.049-.265.723-.264 1.528.007 2.238.271.71.755 1.295 1.379 1.695-.177.556-.193 1.173-.044 1.742.198.755.672 1.39 1.311 1.773.16.096.331.177.509.244-.077.394-.07.808.022 1.207.135.585.426 1.112.834 1.525.407.413.928.705 1.506.844.354.086.714.124 1.074.112a3.77 3.77 0 0 0 1.18-.262c.387.35.851.593 1.35.715.499.122 1.02.107 1.512-.044.397-.122.765-.327 1.073-.602.195.06.396.098.601.112.609.042 1.218-.109 1.728-.43.51-.32.905-.78 1.132-1.325a3.09 3.09 0 0 0 .226-1.195c.503-.21.95-.533 1.302-.946.352-.413.597-.907.711-1.433.115-.526.095-1.072-.057-1.587a3.19 3.19 0 0 0-.794-1.303 3.17 3.17 0 0 0 .382-1.665 3.19 3.19 0 0 0-.677-1.764 3.23 3.23 0 0 0-1.458-1.043 3.25 3.25 0 0 0-1.8-.138c-.39-.535-.903-.95-1.487-1.21a3.36 3.36 0 0 0-1.818-.279c-.594.073-1.154.318-1.618.696a3.22 3.22 0 0 0-1.028-.63 3.29 3.29 0 0 0-1.202-.168 3.27 3.27 0 0 0-1.558.519 3.24 3.24 0 0 0-1.074 1.263c-.203-.064-.412-.1-.622-.112zM12 6.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11z\"/></svg>"
"oidc" "<svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z\"/></svg>"})
(defn- icon-for-provider-type [provider-type]
(get provider-icons provider-type (get provider-icons "oidc")))
;;; ---------------------------------------------------------------------------
;;; Common UI components
;;; ---------------------------------------------------------------------------
(defn- error-banner [error]
(when error
[:div {:class "rounded-lg bg-red-900/40 border border-red-800 px-4 py-3 text-red-300 text-sm"}
error]))
(defn- text-input [{:keys [name type placeholder value required autocomplete]}]
[:input {:type (or type "text") :name name :required required
:placeholder placeholder :value (or value "")
:autocomplete (or autocomplete "off")
:class "w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-brand-500"}])
(defn- submit-button [label]
[:button {:type "submit"
:class "flex items-center justify-center w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors"}
label])
(defn- step-indicator
"Render a step progress indicator. current is 1-based."
[current total]
[:div {:class "flex items-center justify-center gap-2 mb-6"}
(for [i (range 1 (inc total))]
[:div {:class (str "w-2.5 h-2.5 rounded-full "
(if (= i current)
"bg-brand-500"
(if (< i current)
"bg-brand-700"
"bg-gray-700")))}])])
;;; ---------------------------------------------------------------------------
;;; Login Page
;;; ---------------------------------------------------------------------------
(defn login-page
"Render the login page with dynamic OAuth provider buttons and optional password form.
opts:
:providers - vector of provider maps from DB [{:slug :display-name :provider-type ...}]
:error - optional error message string
:invite-info - optional map {:community-name \"...\"} for invite flow
:first-user? - true if no users exist yet (bootstrap mode)"
[{:keys [providers error invite-info first-user?]}]
(page-shell "Sign In"
[:div {:class "text-center space-y-8 py-12"}
;; Logo / branding
[:div {:class "space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
[:p {:class "text-gray-400 text-sm"}
(if first-user?
"Welcome! Sign in to set up your community."
"Sign in to continue")]]
;; Error message
(error-banner error)
;; OAuth provider buttons (dynamic from DB)
(when (seq providers)
[:div {:class "space-y-3"}
(for [{:keys [slug display-name provider-type]} providers]
(provider-button slug display-name (icon-for-provider-type provider-type)))])
;; Invite info
(when invite-info
[:div {:class "border-t border-gray-800 pt-4 space-y-2"}
[:p {:class "text-gray-500 text-xs uppercase tracking-wider"} "Accepting invite"]
[:p {:class "text-gray-300 font-medium"}
(str "Joining: " (:community-name invite-info))]])
;; First user hint
(when first-user?
[:div {:class "border-t border-gray-800 pt-4"}
[:p {:class "text-gray-500 text-sm"}
"You'll be the first user and community owner."]])]))
;;; ---------------------------------------------------------------------------
;;; Error Page
;;; ---------------------------------------------------------------------------
(defn error-page
"Render a generic error page.
opts:
:status - HTTP status code (401, 403, 404, 429, 502, 503)
:title - error title
:message - error description
:retry-after - optional seconds for 429 responses"
[{:keys [status title message retry-after]}]
(let [status-text (case status
401 "Unauthorized"
403 "Forbidden"
404 "Not Found"
429 "Too Many Requests"
502 "Bad Gateway"
503 "Service Unavailable"
"Error")
display-title (or title status-text)]
(page-shell display-title
[:div {:class "text-center space-y-6 py-16"}
[:div {:class "space-y-2"}
[:p {:class "text-6xl font-bold text-gray-700"} (str status)]
[:h1 {:class "text-xl font-semibold text-gray-300"} display-title]]
[:p {:class "text-gray-500 text-sm max-w-sm mx-auto"}
(or message "Something went wrong. Please try again.")]
(when retry-after
[:div {:class "space-y-2"}
[:p {:class "text-gray-500 text-sm"}
(str "Please wait " retry-after " seconds before retrying.")]
[:div {:class "w-full bg-gray-800 rounded-full h-1.5"}
[:div {:class "bg-brand-500 h-1.5 rounded-full transition-all duration-1000"
:style "width: 0%"
:id "retry-bar"}]]
[:script
(h/raw (format "(() => { let s=%d, el=document.getElementById('retry-bar'), iv=setInterval(() => { s--; el.style.width=(((%d-s)/%d)*100)+'%%'; if(s<=0){clearInterval(iv);location.reload();}},1000);})()"
retry-after retry-after retry-after))]])
[:div {:class "pt-4 space-x-4"}
[:a {:href "/"
:class "inline-block px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors no-underline"}
"Go Home"]
(when (#{401 403} status)
[:a {:href "/auth/login"
:class "inline-block px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 text-sm font-medium transition-colors no-underline"}
"Sign In"])]])))
;;; ---------------------------------------------------------------------------
;;; Invite Landing Page
;;; ---------------------------------------------------------------------------
(defn invite-page
"Render the invite landing page.
opts:
:community-name - name of the community being joined
:invite-code - the invite code
:error - optional error message"
[{:keys [community-name invite-code error]}]
(page-shell "Join Community"
[:div {:class "text-center space-y-8 py-12"}
;; Logo
[:div {:class "space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]]
;; Error
(error-banner error)
;; Invite info
[:div {:class "space-y-4"}
[:div {:class "rounded-lg bg-gray-900 border border-gray-800 p-6 space-y-3"}
[:p {:class "text-gray-400 text-sm"} "You've been invited to join"]
[:p {:class "text-2xl font-bold text-gray-100"} community-name]]
[:a {:href (str "/auth/login?invite=" (hu/url-encode invite-code))
:class "inline-block w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors text-center no-underline"}
"Accept Invite & Sign In"]]]))
;;; ---------------------------------------------------------------------------
;;; Setup Wizard Pages
;;; ---------------------------------------------------------------------------
(defn setup-configure-providers-page
"Render the OAuth provider configuration form (wizard step 2).
Shows existing providers with delete buttons and a form to add new ones.
Providers require: type, display name, slug, client ID/secret, and
type-specific URLs (base-url for Gitea, issuer-url for OIDC).
opts:
:providers - vector of existing provider rows from DB
:error - optional validation error message"
[{:keys [providers error]}]
(page-shell "Setup - OAuth Providers"
[:div {:class "space-y-8 py-12"}
[:div {:class "text-center space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
[:p {:class "text-gray-400 text-sm"} "Configure at least one OAuth login provider to get started."]]
(step-indicator 1 2)
(error-banner error)
;; Existing providers
(when (seq providers)
[:div {:class "space-y-2"}
[:h2 {:class "text-sm font-medium text-gray-400 uppercase tracking-wider"} "Configured Providers"]
(for [{:keys [id display-name provider-type slug]} providers]
[:div {:class "flex items-center justify-between bg-gray-900 border border-gray-800 rounded-lg p-3"}
[:div {:class "flex items-center gap-3"}
[:span {:class "text-xs font-mono text-gray-500 uppercase"} provider-type]
[:span {:class "text-gray-100 font-medium"} display-name]
[:span {:class "text-gray-500 text-xs"} (str "(" slug ")")]]
[:form {:method "post" :action (str "/setup/providers/" id "/delete")}
[:button {:type "submit"
:class "text-red-400 hover:text-red-300 text-sm px-2 py-1 rounded hover:bg-red-900/30 transition-colors"}
"Remove"]]])])
;; Add provider form
[:div {:class "space-y-3 text-left"}
[:h2 {:class "text-sm font-medium text-gray-400 uppercase tracking-wider"} "Add Provider"]
[:form {:method "post" :action "/setup/providers" :class "space-y-3"}
[:div
[:label {:class "block text-sm text-gray-400 mb-1"} "Provider Type"]
[:select {:name "provider-type"
:class "w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 focus:outline-none focus:border-brand-500"}
[:option {:value "github"} "GitHub"]
[:option {:value "gitea"} "Gitea"]
[:option {:value "oidc"} "OIDC (OpenID Connect)"]]]
[:label {:class "block text-sm text-gray-400"} "Display Name"]
(text-input {:name "display-name" :placeholder "e.g. GitHub"})
[:label {:class "block text-sm text-gray-400"} "Slug (URL-safe identifier)"]
(text-input {:name "slug" :placeholder "e.g. github"})
[:label {:class "block text-sm text-gray-400"} "Client ID"]
(text-input {:name "client-id" :placeholder "OAuth client ID"})
[:label {:class "block text-sm text-gray-400"} "Client Secret"]
(text-input {:name "client-secret" :type "password" :placeholder "OAuth client secret"})
[:label {:class "block text-sm text-gray-400"} "Base URL (Gitea only)"]
(text-input {:name "base-url" :placeholder "e.g. https://gitea.example.com"})
[:label {:class "block text-sm text-gray-400"} "Issuer URL (OIDC only)"]
(text-input {:name "issuer-url" :placeholder "e.g. https://auth.example.com"})
(submit-button "Add Provider")]]
;; Navigation — require at least one provider before proceeding
(when (seq providers)
[:div {:class "pt-4"}
[:a {:href "/auth/login"
:class "flex items-center justify-center w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors no-underline"}
"Continue — Sign in with your provider"]])]))
(defn setup-create-community-page
"Render the community creation form (wizard step 3).
Collects community name and slug. Slug must be lowercase alphanumeric
with hyphens (min 2 chars). On success, completes the setup wizard.
opts:
:error - optional validation/API error message
:name - prefill community name on re-render after error
:slug - prefill community slug on re-render after error"
[{:keys [error name slug]}]
(page-shell "Setup - Create Community"
[:div {:class "text-center space-y-8 py-12"}
[:div {:class "space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
[:p {:class "text-gray-400 text-sm"} "Create your first community."]]
(step-indicator 2 2)
(error-banner error)
[:form {:method "post" :action "/setup/create-community" :class "space-y-3 text-left"}
[:label {:class "block text-sm text-gray-400"} "Community Name"]
(text-input {:name "name" :placeholder "My Team" :value name :required true})
[:label {:class "block text-sm text-gray-400 pt-2"} "Slug"]
(text-input {:name "slug" :placeholder "my-team" :value slug :required true})
[:p {:class "text-gray-500 text-xs"} "Lowercase letters, digits, and hyphens only."]
[:div {:class "pt-2"}
(submit-button "Create Community & Finish Setup")]]]))
+258
View File
@@ -0,0 +1,258 @@
(ns ajet.chat.auth-gw.proxy
"Reverse proxy — forwards authenticated requests to internal services.
Uses babashka.http-client for synchronous proxying and http-kit's
async channel for SSE pass-through streaming."
(:require [babashka.http-client :as http]
[clojure.string :as str]
[clojure.tools.logging :as log]
[org.httpkit.server :as hk])
(:import [java.io InputStream]))
;;; ---------------------------------------------------------------------------
;;; Service resolution
;;; ---------------------------------------------------------------------------
(defn- service-url
"Build the base URL for a service from config."
[{:keys [host port] :or {host "localhost"}}]
(str "http://" host ":" port))
(defn resolve-target
"Determine the target service and rewritten path for a request URI.
Returns [service-key path] or nil if no match.
Service routing:
- /app/* /sse/* /web/* / -> :web-sm
- /api/* -> :api
- /tui/* /tui/sse/* -> :tui-sm"
[uri]
(cond
;; Web SM routes
(= uri "/")
[:web-sm "/"]
(= uri "/setup")
[:web-sm "/setup"]
(= uri "/app")
[:web-sm "/app"]
(str/starts-with? uri "/app/")
[:web-sm uri]
(str/starts-with? uri "/sse/")
[:web-sm uri]
(str/starts-with? uri "/web/")
[:web-sm uri]
;; API routes
(str/starts-with? uri "/api/")
[:api uri]
;; TUI SM routes
(str/starts-with? uri "/tui/")
[:tui-sm uri]
:else nil))
(defn- build-target-url
"Build the full target URL from service config and request."
[services service-key path query-string]
(let [base (service-url (get services service-key))]
(if (str/blank? query-string)
(str base path)
(str base path "?" query-string))))
;;; ---------------------------------------------------------------------------
;;; Header manipulation
;;; ---------------------------------------------------------------------------
(def ^:private stripped-request-headers
"Headers to strip from proxied requests (prevent spoofing)."
#{"x-user-id" "x-user-role" "x-community-id" "x-trace-id"
"x-forwarded-for" "x-forwarded-proto" "x-forwarded-host"
"host" "connection" "keep-alive" "transfer-encoding"
"te" "trailer" "upgrade" "content-length"})
(defn- clean-request-headers
"Strip auth/hop-by-hop headers and normalize values for java.net.http."
[headers]
(into {}
(comp (remove (fn [[k _]] (contains? stripped-request-headers (str/lower-case k))))
(map (fn [[k v]] [k (str/replace (str v) #"\r?\n" ", ")])))
headers))
(defn- inject-proxy-headers
"Add auth and tracing headers for the upstream service."
[headers {:keys [user-id user-role community-id trace-id remote-addr]}]
(cond-> headers
user-id (assoc "X-User-Id" user-id)
user-role (assoc "X-User-Role" user-role)
community-id (assoc "X-Community-Id" community-id)
trace-id (assoc "X-Trace-Id" trace-id)
remote-addr (assoc "X-Forwarded-For" remote-addr)))
(defn- is-sse-request?
"Check if the request is for an SSE endpoint."
[uri]
(or (str/starts-with? uri "/sse/")
(str/starts-with? uri "/tui/sse/")))
;;; ---------------------------------------------------------------------------
;;; Synchronous proxy (non-SSE)
;;; ---------------------------------------------------------------------------
(def ^:private proxy-client
"HTTP client that never follows redirects — upstream redirects must be
passed through to the client untouched."
(http/client {:follow-redirects :never}))
(defn- proxy-sync
"Forward a non-SSE request synchronously via babashka.http-client."
[target-url method headers body]
(try
(let [opts (cond-> {:uri target-url
:method method
:headers headers
:throw false
:timeout 30000
:client proxy-client}
body (assoc :body body))
resp (http/request opts)]
{:status (:status resp)
:headers (-> (:headers resp)
(dissoc "transfer-encoding" "connection")
;; Ensure headers are string->string
(->> (into {}
(map (fn [[k v]]
[(name k)
(if (sequential? v) (str/join ", " v) (str v))])))))
:body (:body resp)})
(catch java.io.IOException e
;; Java 21's HttpClient rejects 204/304 responses that include
;; a Content-Length header (strict HTTP compliance). Detect and
;; return the correct no-content status directly.
(let [msg (str (.getMessage e))]
(if-let [[_ status] (re-find #"content length header with (\d+) response" msg)]
{:status (Integer/parseInt status)
:headers {}
:body nil}
(do (log/error e "Proxy IO error to" target-url)
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — upstream service unavailable"}))))
(catch Exception e
(log/error e "Proxy error to" target-url)
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — upstream service unavailable"})))
;;; ---------------------------------------------------------------------------
;;; SSE streaming proxy
;;; ---------------------------------------------------------------------------
(defn- proxy-sse
"Forward an SSE request using http-kit async channel for streaming.
Opens an http-kit async response, makes a streaming request to the
upstream, and pipes chunks through without buffering."
[target-url headers hk-channel]
(try
(let [resp (http/get target-url
{:headers (assoc headers "Accept" "text/event-stream")
:throw false
:as :stream})]
(if (= 200 (:status resp))
(do
;; Send initial response headers
(hk/send! hk-channel
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache, no-store"
"Connection" "keep-alive"
"X-Accel-Buffering" "no"}}
false)
;; Stream body in a background thread
(future
(try
(let [^InputStream is (:body resp)
buf (byte-array 4096)]
(loop []
(let [n (.read is buf)]
(when (and (pos? n) (hk/open? hk-channel))
(hk/send! hk-channel
(String. buf 0 n "UTF-8")
false)
(recur))))
;; Upstream closed
(hk/close hk-channel))
(catch Exception e
(when-not (instance? java.io.IOException e)
(log/warn e "SSE stream error"))
(hk/close hk-channel)))))
;; Upstream error
(do
(hk/send! hk-channel
{:status (:status resp)
:headers {"Content-Type" "text/plain"}
:body "Upstream SSE connection failed"}
true))))
(catch Exception e
(log/error e "SSE proxy connection error to" target-url)
(hk/send! hk-channel
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — upstream service unavailable"}
true))))
;;; ---------------------------------------------------------------------------
;;; Public API
;;; ---------------------------------------------------------------------------
(defn proxy-request
"Proxy a Ring request to the appropriate internal service.
services: map from config {:api {:host .. :port ..} :web-sm ... :tui-sm ...}
auth-info: map of {:user-id :user-role :community-id :trace-id :remote-addr}
request: Ring request map
Returns a Ring response map (or uses http-kit async for SSE)."
[services auth-info request]
(let [uri (:uri request)
resolution (resolve-target uri)]
(if-not resolution
{:status 404
:headers {"Content-Type" "text/plain"}
:body "Not Found"}
(let [[service-key path] resolution
service-cfg (get services service-key)]
(if-not service-cfg
(do (log/error "No service config for" service-key)
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — service not configured"})
(let [target-url (build-target-url services service-key path (:query-string request))
headers (-> (:headers request)
clean-request-headers
(inject-proxy-headers auth-info))]
(if (is-sse-request? uri)
;; SSE: use http-kit async channel
(hk/with-channel request hk-channel
(hk/on-close hk-channel
(fn [_status]
(log/debug "SSE client disconnected from" uri)))
(proxy-sse target-url headers hk-channel))
;; Non-SSE: synchronous proxy
;; Use :raw-body (buffered bytes) if available, otherwise :body
;; :raw-body is set by wrap-buffer-body so the proxy has the
;; original body even after wrap-params consumed the InputStream
(let [body (or (when-let [raw (:raw-body request)]
(when (pos? (alength raw)) raw))
(:body request))]
(proxy-sync target-url
(:request-method request)
headers
body)))))))))
@@ -0,0 +1,183 @@
(ns ajet.chat.auth-gw.rate-limit
"In-memory token bucket rate limiter.
Each bucket is keyed by [category identity-key] and tracks remaining tokens
plus the last refill timestamp. Expired/stale buckets are cleaned up
periodically by a background thread."
(:require [clojure.tools.logging :as log])
(:import [java.time Instant Duration]))
;;; ---------------------------------------------------------------------------
;;; Configuration
;;; ---------------------------------------------------------------------------
(def ^:private rate-rules
"Rate limit rules keyed by category keyword.
:capacity — max tokens (requests) in the window
:refill-ms — window size in milliseconds"
{:auth-callback {:capacity 10 :refill-ms 60000} ; 10/min per IP
:api-write {:capacity 60 :refill-ms 60000} ; 60/min per user
:api-read {:capacity 120 :refill-ms 60000} ; 120/min per user
:webhook {:capacity 30 :refill-ms 60000} ; 30/min per webhook
:sse {:capacity 5 :refill-ms 60000}}) ; 5/min per user
(def ^:private stale-threshold-ms
"Remove buckets that haven't been touched in 10 minutes."
600000)
;;; ---------------------------------------------------------------------------
;;; Bucket state
;;; ---------------------------------------------------------------------------
(defn make-limiter
"Create a new rate limiter state atom. Returns the atom."
[]
(atom {}))
(defn- now-ms []
(System/currentTimeMillis))
(defn- refill-bucket
"Refill tokens based on elapsed time since last refill."
[{:keys [tokens last-refill-ms] :as bucket} {:keys [capacity refill-ms]}]
(let [elapsed (- (now-ms) last-refill-ms)]
(if (>= elapsed refill-ms)
;; Full window elapsed — reset to capacity
(assoc bucket
:tokens capacity
:last-refill-ms (now-ms))
;; Partial refill — add proportional tokens
(let [fraction (/ (double elapsed) (double refill-ms))
added (* fraction capacity)
new-tokens (min capacity (+ tokens added))]
(assoc bucket
:tokens new-tokens
:last-refill-ms (now-ms))))))
(defn- get-or-create-bucket
"Get an existing bucket or create a fresh one at capacity."
[buckets bucket-key rule]
(or (get buckets bucket-key)
{:tokens (:capacity rule)
:last-refill-ms (now-ms)}))
(defn check-rate-limit!
"Check and consume a token from the bucket for [category identity-key].
Returns {:allowed? true} if the request is permitted, or
{:allowed? false :retry-after-ms N} if rate limited.
category: one of :auth-callback :api-write :api-read :webhook :sse
identity-key: string (IP address, user-id, or webhook-id)"
[limiter-atom category identity-key]
(let [rule (get rate-rules category)]
(if-not rule
;; Unknown category — allow (no rule defined)
{:allowed? true}
(let [bucket-key [category identity-key]
result (atom nil)]
(swap! limiter-atom
(fn [buckets]
(let [bucket (get-or-create-bucket buckets bucket-key rule)
refilled (refill-bucket bucket rule)
tokens (:tokens refilled)]
(if (>= tokens 1.0)
;; Allow — consume a token
(do (reset! result {:allowed? true})
(assoc buckets bucket-key
(assoc refilled
:tokens (dec tokens)
:last-access-ms (now-ms))))
;; Deny — calculate retry-after
(let [deficit (- 1.0 tokens)
refill-rate (/ (double (:capacity rule))
(double (:refill-ms rule)))
retry-after (long (Math/ceil (/ deficit refill-rate)))]
(reset! result {:allowed? false
:retry-after-ms retry-after})
(assoc buckets bucket-key
(assoc refilled :last-access-ms (now-ms))))))))
@result))))
;;; ---------------------------------------------------------------------------
;;; Cleanup
;;; ---------------------------------------------------------------------------
(defn cleanup-stale!
"Remove buckets that haven't been accessed in over `stale-threshold-ms`."
[limiter-atom]
(let [cutoff (- (now-ms) stale-threshold-ms)
removed (atom 0)]
(swap! limiter-atom
(fn [buckets]
(let [active (into {}
(filter (fn [[_ b]]
(> (get b :last-access-ms 0) cutoff)))
buckets)]
(reset! removed (- (count buckets) (count active)))
active)))
(when (pos? @removed)
(log/debug "Rate limiter cleanup: removed" @removed "stale buckets"))))
(defn start-cleanup-task!
"Start a background thread that periodically cleans up stale buckets.
Returns the future (cancel with `future-cancel`)."
[limiter-atom]
(future
(log/info "Rate limiter cleanup task started")
(try
(loop []
(Thread/sleep 60000) ;; every minute
(cleanup-stale! limiter-atom)
(recur))
(catch InterruptedException _
(log/info "Rate limiter cleanup task stopped"))
(catch Exception e
(log/error e "Rate limiter cleanup task error")))))
;;; ---------------------------------------------------------------------------
;;; Route classification
;;; ---------------------------------------------------------------------------
(defn classify-request
"Determine the rate limit category and identity key for a request.
Returns [category identity-key] or nil if no rate limit applies.
request: Ring request map (expects :uri, :request-method, and optionally
::user-id, ::remote-addr, ::webhook-id keys set by auth middleware)."
[request]
(let [uri (:uri request)
method (:request-method request)]
(cond
;; OAuth callback — rate limit by IP
(and (= method :post)
(re-matches #"/auth/callback/.*" uri))
[:auth-callback (or (::remote-addr request)
(get-in request [:headers "x-forwarded-for"])
(:remote-addr request)
"unknown")]
;; Webhook incoming — rate limit by webhook ID
(and (= method :post)
(re-matches #"/api/webhooks/.*/incoming" uri))
[:webhook (or (::webhook-id request) "unknown")]
;; SSE connections — rate limit by user
(and (= method :get)
(or (re-matches #"/sse/.*" uri)
(re-matches #"/tui/sse/.*" uri)))
[:sse (or (::user-id request) "anonymous")]
;; API writes — rate limit by user
(and (= method :post)
(re-matches #"/api/.*" uri))
[:api-write (or (::user-id request) "anonymous")]
;; API reads — rate limit by user
(and (= method :get)
(re-matches #"/api/.*" uri))
[:api-read (or (::user-id request) "anonymous")]
;; No rate limit for other routes
:else nil)))
+307
View File
@@ -0,0 +1,307 @@
(ns ajet.chat.auth-gw.routes
"Reitit router for the Auth Gateway.
Routes are divided into:
- Self-handled: auth pages, health check, invite landing, setup wizard
- Proxied: all other routes forwarded to internal services
with session/token validation and header injection"
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[reitit.ring :as ring]
[ring.middleware.cookies :refer [wrap-cookies]]
[ring.middleware.params :refer [wrap-params]]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.oauth :as oauth]
[ajet.chat.auth-gw.proxy :as proxy]
[ajet.chat.auth-gw.pages :as pages]
[ajet.chat.auth-gw.middleware :as mw]
[ajet.chat.auth-gw.rate-limit :as rl]
[ajet.chat.auth-gw.setup :as setup]))
;;; ---------------------------------------------------------------------------
;;; Auth helpers
;;; ---------------------------------------------------------------------------
(defn- extract-remote-addr
"Get the client's IP address from the request."
[request]
(or (get-in request [:headers "x-forwarded-for"])
(:remote-addr request)
"unknown"))
(defn- session-auth
"Validate session cookie. Returns auth-info map or nil."
[ds request cookie-name]
(let [token (auth/extract-session-cookie request cookie-name)]
(auth/validate-session ds token)))
(defn- api-token-auth
"Validate Bearer token from Authorization header. Returns auth-info map or nil."
[ds request]
(let [auth-header (get-in request [:headers "authorization"])]
(auth/validate-api-token ds auth-header)))
(defn- webhook-auth
"Validate webhook Bearer token. Returns auth-info map or nil."
[ds request]
(let [auth-header (get-in request [:headers "authorization"])]
(auth/validate-webhook-token ds auth-header)))
(defn- web-redirect-to-login
"Redirect to login page (for web browser requests that fail auth)."
[]
{:status 302
:headers {"Location" "/auth/login"}
:body ""})
(defn- json-401
"Return a 401 JSON response (for API/TUI requests that fail auth)."
[trace-id]
{:status 401
:headers {"Content-Type" "application/json"
"X-Trace-Id" (or trace-id "")}
:body "{\"error\":\"unauthorized\",\"message\":\"Invalid or missing authentication\"}"})
(defn- is-web-route?
"Check if the URI is a web-browser route (should redirect on auth failure)."
[uri]
(or (= uri "/")
(= uri "/app")
(= uri "/setup")
(str/starts-with? uri "/app/")
(str/starts-with? uri "/sse/")
(str/starts-with? uri "/web/")))
;;; ---------------------------------------------------------------------------
;;; Proxy handlers with auth
;;; ---------------------------------------------------------------------------
(defn- make-session-proxy-handler
"Create a handler that validates session auth and proxies to a service."
[{:keys [ds config]}]
(let [services (:services config)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
ttl-days (get-in config [:session :ttl-days] 30)]
(fn [request]
(let [auth-info (session-auth ds request cookie-name)
trace-id (get-in request [:headers "x-trace-id"])]
(if auth-info
(do
;; Extend session TTL asynchronously
(auth/extend-session-ttl! ds (:session-id auth-info) ttl-days)
;; Proxy the request
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request))
;; Auth failed
(if (is-web-route? (:uri request))
(web-redirect-to-login)
(json-401 trace-id)))))))
(defn- make-api-proxy-handler
"Create a handler that validates session OR API token auth and proxies to API."
[{:keys [ds config]}]
(let [services (:services config)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
ttl-days (get-in config [:session :ttl-days] 30)]
(fn [request]
(let [trace-id (get-in request [:headers "x-trace-id"])
;; Try session auth first, then API token
session (session-auth ds request cookie-name)
api-token (when-not session (api-token-auth ds request))
auth-info (or session api-token)]
(if auth-info
(do
;; Extend session TTL if session auth was used
(when (and session (:session-id session))
(auth/extend-session-ttl! ds (:session-id session) ttl-days))
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request))
(json-401 trace-id))))))
(defn- make-webhook-proxy-handler
"Create a handler that validates webhook token and proxies to API."
[{:keys [ds config]}]
(let [services (:services config)]
(fn [request]
(let [trace-id (get-in request [:headers "x-trace-id"])
auth-info (webhook-auth ds request)]
(if auth-info
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request)
(json-401 trace-id))))))
(defn- make-tui-proxy-handler
"Create a handler that validates session auth and proxies to TUI SM."
[{:keys [ds config]}]
(let [services (:services config)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
ttl-days (get-in config [:session :ttl-days] 30)]
(fn [request]
(let [auth-info (session-auth ds request cookie-name)
trace-id (get-in request [:headers "x-trace-id"])]
(if auth-info
(do
(auth/extend-session-ttl! ds (:session-id auth-info) ttl-days)
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request))
(json-401 trace-id))))))
;;; ---------------------------------------------------------------------------
;;; Health check
;;; ---------------------------------------------------------------------------
(defn- health-handler
"Health check endpoint. Verifies DB connectivity."
[{:keys [ds]}]
(fn [_request]
(try
(let [result (ajet.chat.shared.db/execute-one! ds
{:select [[[:raw "1"] :ok]]})]
{:status 200
:headers {"Content-Type" "application/json"}
:body "{\"status\":\"ok\",\"db\":\"connected\"}"})
(catch Exception e
(log/warn e "Health check — DB unreachable")
{:status 503
:headers {"Content-Type" "application/json"}
:body "{\"status\":\"degraded\",\"db\":\"disconnected\"}"}))))
;;; ---------------------------------------------------------------------------
;;; Router
;;; ---------------------------------------------------------------------------
(defn app
"Build the full Ring handler with reitit router and middleware stack.
sys: system map {:ds, :config, :limiter, :oauth-providers-atom, :setup-complete-atom}"
[sys]
(let [session-proxy (make-session-proxy-handler sys)
api-proxy (make-api-proxy-handler sys)
webhook-proxy (make-webhook-proxy-handler sys)
tui-proxy (make-tui-proxy-handler sys)
config (:config sys)
limiter (:limiter sys)]
(ring/ring-handler
(ring/router
[;; --- Self-handled: Auth ---
["/auth/login"
{:get {:handler (fn [request]
(oauth/login-page-handler sys request))}}]
["/auth/callback/:provider"
{:get {:handler (fn [request]
(oauth/callback-handler sys request))}}]
["/auth/logout"
{:post {:handler (fn [request]
(oauth/logout-handler sys request))}}]
;; --- Self-handled: Invite ---
["/invite/:code"
{:get {:handler (fn [request]
(oauth/invite-landing-handler sys request))}}]
;; --- Self-handled: Health ---
["/health"
{:get {:handler (health-handler sys)}}]
;; --- Self-handled: Setup Wizard ---
;; Conditional: if setup incomplete, Auth GW renders wizard pages.
;; If setup complete, /setup proxies to Web SM.
["/setup"
{:get {:handler (fn [request]
(if (setup/setup-complete? sys)
(session-proxy request)
(setup/wizard-page-handler sys request)))}}]
["/setup/create-community"
{:get {:handler (fn [request]
(setup/create-community-page-handler sys request))}
:post {:handler (fn [request]
(setup/create-community-handler sys request))}}]
["/setup/providers"
{:post {:handler (fn [request]
(setup/add-provider-handler sys request))}}]
["/setup/providers/:id/delete"
{:post {:handler (fn [request]
(setup/delete-provider-handler sys request))}}]
;; --- Webhook proxy (before /api/* to match first) ---
["/api/webhooks/:webhook-id/incoming"
{:post {:handler webhook-proxy}}]
;; --- API proxy (session or API token) ---
["/api/*"
{:get {:handler api-proxy}
:post {:handler api-proxy}
:put {:handler api-proxy}
:delete {:handler api-proxy}}]
;; --- TUI SM proxy ---
["/tui/sse/*"
{:get {:handler tui-proxy}}]
["/tui/*"
{:post {:handler tui-proxy}}]
;; --- Web SM proxy (SSE) ---
["/sse/*"
{:get {:handler session-proxy}}]
;; --- Web SM proxy (form posts) ---
["/web/*"
{:post {:handler session-proxy}}]
;; --- Web SM proxy (app pages) ---
["/app"
{:get {:handler session-proxy}}]
["/app/*"
{:get {:handler session-proxy}}]
;; --- Web SM proxy (root) ---
["/"
{:get {:handler session-proxy}}]]
;; Router options
{:conflicts nil}) ;; Suppress conflict warnings for catch-all routes
;; Default handler for unmatched routes
(ring/create-default-handler
{:not-found (constantly
{:status 404
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (pages/error-page {:status 404
:title "Not Found"
:message "The page you're looking for doesn't exist."})})})
;; Middleware stack (applied outermost first)
{:middleware [;; Parse cookies
wrap-cookies
;; Buffer body so both wrap-params and proxy can read it
mw/wrap-buffer-body
;; Parse query params and form body
wrap-params
;; Catch-all exception handler (outermost)
mw/wrap-exception-handler
;; CORS
[mw/wrap-cors config]
;; Trace ID generation
mw/wrap-trace-id
;; Request logging
mw/wrap-request-logging
;; Rate limiting
[mw/wrap-rate-limit limiter (get config :rate-limit {:enabled true})]]})))
+234
View File
@@ -0,0 +1,234 @@
(ns ajet.chat.auth-gw.setup
"Admin setup wizard — multi-step first-deployment bootstrap.
Flow:
1. Configure OAuth providers (no auth needed — no users exist yet)
2. Admin logs in via one of the configured providers (normal OAuth flow)
3. Create first community (auth required — admin is now logged in)
After community creation, setup_completed is set to true and /setup
proxies to Web SM for subsequent community creation."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[babashka.http-client :as http]
[clojure.data.json :as json]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.pages :as pages]))
;;; ---------------------------------------------------------------------------
;;; Setup state helpers
;;; ---------------------------------------------------------------------------
(defn setup-complete?
"Check if initial setup has been completed.
Uses the cached atom if available, otherwise queries the DB."
[{:keys [ds setup-complete-atom]}]
(if-let [cached @setup-complete-atom]
(= cached :true)
(let [val (auth/get-system-setting ds "setup_completed")
complete? (= val "true")]
(reset! setup-complete-atom (if complete? :true :false))
complete?)))
(defn- mark-setup-complete!
"Mark setup as completed in the database and update the cache."
[{:keys [ds setup-complete-atom]}]
(auth/set-system-setting! ds "setup_completed" "true")
(reset! setup-complete-atom :true)
(log/info "Setup wizard marked as complete"))
(defn reload-providers!
"Reload OAuth providers from DB into the cached atom."
[{:keys [ds oauth-providers-atom]}]
(let [providers (auth/list-oauth-providers ds)]
(reset! oauth-providers-atom providers)
(log/info "Reloaded" (count providers) "OAuth providers from DB")
providers))
;;; ---------------------------------------------------------------------------
;;; Wizard step determination
;;; ---------------------------------------------------------------------------
(defn- current-step
"Determine the current wizard step based on DB state.
- No enabled OAuth providers → :configure-providers
- Providers exist but no users → :awaiting-login (redirect to login)
- Users exist but setup not complete → :create-community"
[ds]
(let [provider-count (auth/count-oauth-providers ds)
user-count (auth/count-users ds)]
(cond
(zero? provider-count) :configure-providers
(zero? user-count) :awaiting-login
:else :create-community)))
;;; ---------------------------------------------------------------------------
;;; Response helpers
;;; ---------------------------------------------------------------------------
(defn- require-wizard-auth
"Validate session for authenticated wizard steps. Returns auth-info or nil."
[{:keys [ds config]} request]
(let [cookie-name (get-in config [:session :cookie-name] "ajet_session")]
(auth/validate-session ds (auth/extract-session-cookie request cookie-name))))
(def ^:private redirect-to-login
{:status 302 :headers {"Location" "/auth/login"} :body ""})
(def ^:private redirect-to-home
{:status 302 :headers {"Location" "/"} :body ""})
(def ^:private redirect-to-setup
{:status 302 :headers {"Location" "/setup"} :body ""})
(defn- html-response [body]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-store"}
:body body})
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn wizard-page-handler
"GET /setup — render the appropriate wizard step.
- No providers configured: show OAuth provider configuration form
- Providers exist, no users: redirect to /auth/login for first OAuth login
- User authenticated, setup incomplete: show community creation form"
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(let [step (current-step ds)]
(case step
:configure-providers
(let [providers (auth/list-all-oauth-providers ds)]
(html-response (pages/setup-configure-providers-page {:providers providers})))
:awaiting-login
redirect-to-login
:create-community
(if-let [_auth (require-wizard-auth sys request)]
(html-response (pages/setup-create-community-page {}))
redirect-to-login)))))
(defn create-community-page-handler
"GET /setup/create-community — render the community creation form directly.
Used when navigating from provider config step via 'Next' link."
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(if-let [_auth (require-wizard-auth sys request)]
(html-response (pages/setup-create-community-page {}))
redirect-to-login)))
(defn add-provider-handler
"POST /setup/providers — add an OAuth provider during initial setup.
No auth required (no users exist yet). Validates provider fields:
type must be github/gitea/oidc, display name + slug + client credentials
required, base-url required for Gitea, issuer-url required for OIDC."
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(let [params (:params request)
provider-type (get params "provider-type")
display-name (some-> (get params "display-name") str/trim)
slug (some-> (get params "slug") str/trim)
client-id (some-> (get params "client-id") str/trim)
client-secret (some-> (get params "client-secret") str/trim)
base-url (some-> (get params "base-url") str/trim)
issuer-url (some-> (get params "issuer-url") str/trim)
providers (auth/list-all-oauth-providers ds)
render-error (fn [msg]
(html-response
(pages/setup-configure-providers-page
{:providers providers :error msg})))]
(cond
(not (#{"github" "gitea" "oidc"} provider-type))
(render-error "Invalid provider type")
(or (str/blank? display-name) (str/blank? slug)
(str/blank? client-id) (str/blank? client-secret))
(render-error "Display name, slug, client ID, and client secret are required")
(and (= provider-type "gitea") (str/blank? base-url))
(render-error "Base URL is required for Gitea providers")
(and (= provider-type "oidc") (str/blank? issuer-url))
(render-error "Issuer URL is required for OIDC providers")
:else
(do
(auth/insert-oauth-provider! ds
(cond-> {:provider-type provider-type
:display-name display-name
:slug slug
:client-id client-id
:client-secret client-secret}
(not (str/blank? base-url)) (assoc :base-url base-url)
(not (str/blank? issuer-url)) (assoc :issuer-url issuer-url)))
(reload-providers! sys)
(log/info "Setup wizard: OAuth provider added" provider-type slug)
redirect-to-setup)))))
(defn delete-provider-handler
"POST /setup/providers/:id/delete — remove an OAuth provider during setup.
No auth required (called during initial setup before any users exist)."
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(let [provider-id (get-in request [:path-params :id])]
(auth/delete-oauth-provider! ds
(if (instance? java.util.UUID provider-id)
provider-id
(java.util.UUID/fromString (str provider-id))))
(reload-providers! sys)
(log/info "Setup wizard: OAuth provider deleted" provider-id)
redirect-to-setup)))
(defn create-community-handler
"POST /setup/create-community — create the first community and complete setup.
Requires authentication (admin logged in via OAuth). Validates community
name and slug, creates via internal API call, marks setup_completed=true."
[{:keys [ds config] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(if-let [auth-info (require-wizard-auth sys request)]
(let [params (:params request)
community-name (some-> (get params "name") str/trim)
community-slug (some-> (get params "slug") str/trim)]
(cond
(or (str/blank? community-name) (str/blank? community-slug))
(html-response (pages/setup-create-community-page
{:error "Community name and slug are required"}))
(not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" community-slug))
(html-response (pages/setup-create-community-page
{:error "Slug must be lowercase letters, digits, and hyphens (min 2 chars)"
:name community-name :slug community-slug}))
:else
(let [api-host (get-in config [:services :api :host] "localhost")
api-port (get-in config [:services :api :port] 3001)
api-url (str "http://" api-host ":" api-port "/api/communities")
resp (http/post api-url
{:headers {"Content-Type" "application/json"
"X-User-Id" (str (:user-id auth-info))}
:body (json/write-str {:name community-name
:slug community-slug})
:throw false
:timeout 10000})]
(if (= 201 (:status resp))
(do
(mark-setup-complete! sys)
(reload-providers! sys)
(log/info "Setup wizard: complete. Community created:" community-slug)
redirect-to-home)
(let [body (try (json/read-str (:body resp) :key-fn keyword) (catch Exception _ nil))
errmsg (or (get-in body [:error :message]) "Failed to create community")]
(html-response (pages/setup-create-community-page
{:error errmsg
:name community-name
:slug community-slug})))))))
redirect-to-login)))