init codebase
This commit is contained in:
@@ -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"]
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]}}}}
|
||||
@@ -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]))
|
||||
@@ -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)))
|
||||
@@ -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})})))
|
||||
@@ -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")]]]))
|
||||
@@ -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)))
|
||||
@@ -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})]]})))
|
||||
@@ -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)))
|
||||
Reference in New Issue
Block a user