Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Consolidate duplicated OAuth code across the project by reusing the oauth.* library where it was designed to be used, fixing a security bug (expired Microsoft tokens accepted), and extracting shared patterns.
Architecture: The oauth.* namespace tree is the canonical OAuth library (JWKS caching, OIDC providers, JWT validation). The app auth middleware predates it and uses hand-rolled equivalents. Tasks 1-3 migrate the middleware to the library. Tasks 4-7 extract duplicated patterns from email OAuth providers into shared utilities.
Tech Stack: Clojure, buddy-sign (JWT), hato (HTTP), AWS KMS, PostgreSQL, Integrant
| Namespace | Role |
|---|---|
oauth.core |
JWT issuance/validation, PKCE, refresh token hashing |
oauth.jwks |
JWKS fetching + TTL caching (protocol JWKSCache) |
oauth.providers.google |
Google OIDC: OIDCProvider protocol, ID token validation, JWKS |
oauth.providers.microsoft |
Microsoft OIDC: separate OIDCProvider protocol (duplicate!), multi-tenant |
oauth.storage |
Refresh token storage (PostgreSQL) |
oauth.authorization-code |
In-memory auth code store |
app.http.middleware.auth |
App auth middleware — hand-rolled JWKS + Microsoft validation |
app.email.gmail |
Gmail OAuth authorization flow (HMAC state, code exchange) |
app.email.outlook |
Outlook OAuth authorization flow (HMAC state, code exchange) — duplicates gmail |
workers.acquisition.email.gmail |
Gmail sync worker (token refresh, API client) |
workers.acquisition.email.outlook |
Outlook sync worker (token refresh, API client) |
email.oauth.tokens |
SSM token storage (shared by app + workers) |
link.oauth.http |
Link OAuth HTTP endpoints |
link.mcp.middleware |
Bearer token validation for MCP |
resources/com/getorcha/config.edn (lines 146-155 for app auth, 179-184 for link)test/com/getorcha/link/oauth_test.clj (Link OAuth integration tests)test/com/getorcha/app/http/oauth_test.clj (App OAuth DB simulation tests)clj -X:test:silent :nses '[com.getorcha.link.oauth-test]'
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'
clj-kondo --lint src test dev
OIDCProvider protocol and extract decode-jwt-headerBoth oauth.providers.google and oauth.providers.microsoft define their own OIDCProvider protocol with identical signatures. They also both contain an identical decode-jwt-header function. This task extracts both to shared locations.
Files:
src/com/getorcha/oauth/providers.cljsrc/com/getorcha/oauth/providers/google.cljsrc/com/getorcha/oauth/providers/microsoft.cljsrc/com/getorcha/oauth/core.cljsrc/com/getorcha/link/oauth/http.cljdecode-jwt-header to oauth.coreThis function is duplicated identically in both provider files. It belongs with other JWT utilities in oauth.core.
Add to src/com/getorcha/oauth/core.clj:
;; Add to requires:
[cheshire.core :as json] ;; already present
;; Add function after the PKCE section:
;; JWT Header Decoding
;; -----------------------------------------------------------------------------
(defn decode-jwt-header
"Decodes the JWT header without signature verification.
Used to extract the `kid` (key ID) for JWKS key lookup before
full validation. Only parses the unprotected header — the token
MUST still be verified with the corresponding public key.
Returns the header as a map (e.g., `{:alg \"RS256\", :kid \"abc\", :typ \"JWT\"}`),
or nil if the token is malformed."
[^String token]
(try
(let [[header-b64 _ _] (str/split token #"\." 3)
header-json (String. (.decode (java.util.Base64/getUrlDecoder)
header-b64))]
(json/parse-string header-json true))
(catch Exception _
nil)))
Note: clojure.string is already required as str in oauth.core. java.util.Base64 is already imported.
oauth.providers namespace with shared protocolCreate src/com/getorcha/oauth/providers.clj:
(ns com.getorcha.oauth.providers
"Shared protocol for OpenID Connect identity providers.
Implemented by Google and Microsoft provider records.
Allows polymorphic ID token validation across providers.")
(defprotocol OIDCProvider
"Protocol for OpenID Connect identity providers."
(get-issuer [this]
"Returns the expected issuer claim value (or pattern for multi-tenant).")
(get-authorization-url [this redirect-uri state scope]
"Builds the authorization URL for the IdP.")
(validate-id-token! [this id-token]
"Validates an ID token and returns claims on success, nil on failure."))
oauth.providers.google to use shared protocol and decode-jwt-headerIn src/com/getorcha/oauth/providers/google.clj:
(:require ... [cheshire.core :as json] ... [clojure.string :as str] ...) — remove cheshire.core and clojure.string if no longer needed after removing decode-jwt-header[com.getorcha.oauth.core :as oauth.core] and [com.getorcha.oauth.providers :as providers] to requiresOIDCProvider protocol definition (lines 105-112)decode-jwt-header function (lines 119-128)GoogleProvider defrecord to implement providers/OIDCProvider instead of OIDCProvidervalidate-id-token! to call oauth.core/decode-jwt-header instead of decode-jwt-headerAfter changes, the requires should be:
(:require [buddy.sign.jwt :as buddy.jwt]
[cheshire.core :as json]
[clojure.string :as str]
[clojure.tools.logging :as log]
[com.getorcha.oauth.core :as oauth.core]
[com.getorcha.oauth.jwks :as jwks]
[com.getorcha.oauth.providers :as providers]
[hato.client :as hato])
Note: cheshire.core and clojure.string are still needed by exchange-code! and validate-claims.
The defrecord becomes:
(defrecord GoogleProvider [client-id client-secret jwks-cache]
providers/OIDCProvider
(get-issuer [_this]
issuer)
(get-authorization-url [_this redirect-uri state scope]
(let [scope-str (if (sequential? scope)
(str/join " " scope)
scope)]
(str authorization-endpoint
"?client_id=" (java.net.URLEncoder/encode client-id "UTF-8")
"&response_type=code"
"&redirect_uri=" (java.net.URLEncoder/encode redirect-uri "UTF-8")
"&scope=" (java.net.URLEncoder/encode scope-str "UTF-8")
"&state=" (java.net.URLEncoder/encode state "UTF-8")
"&access_type=offline"
"&prompt=consent")))
(validate-id-token! [_this id-token]
(try
(let [{:keys [kid]} (oauth.core/decode-jwt-header id-token)]
(if-not kid
(do (log/warn "Google ID token missing kid in header")
nil)
(if-let [public-key (jwks/get-public-key! jwks-cache kid)]
(let [claims (buddy.jwt/unsign id-token public-key {:alg :rs256})]
(validate-claims claims issuer client-id))
(do (log/warn "Google JWKS key not found" {:kid kid})
nil))))
(catch Exception e
(log/warn e "Failed to validate Google ID token")
nil))))
oauth.providers.microsoft the same wayIn src/com/getorcha/oauth/providers/microsoft.clj:
[com.getorcha.oauth.core :as oauth.core] and [com.getorcha.oauth.providers :as providers] to requiresOIDCProvider protocol definition (lines 215-222)decode-jwt-header function (lines 149-158)MicrosoftProvider to implement providers/OIDCProvidervalidate-id-token! to call oauth.core/decode-jwt-headerSame pattern as Google. The defrecord becomes:
(defrecord MicrosoftProvider [client-id client-secret tenant jwks-cache]
providers/OIDCProvider
;; ... same methods, just use oauth.core/decode-jwt-header
link.oauth.http to use polymorphic dispatchIn src/com/getorcha/link/oauth/http.clj, add [com.getorcha.oauth.providers :as providers] to requires.
Replace the case-dispatched validation calls with protocol dispatch:
;; Before:
(let [provider-instance (case provider
:google (google/create-provider credentials)
:microsoft (microsoft/create-provider credentials))
claims (case provider
:google (google/validate-id-token! provider-instance id-token)
:microsoft (microsoft/validate-id-token! provider-instance id-token))]
;; After:
(let [provider-instance (case provider
:google (google/create-provider credentials)
:microsoft (microsoft/create-provider credentials))
claims (providers/validate-id-token! provider-instance id-token)]
Similarly for get-authorization-url calls if they use the same case-dispatch pattern.
clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.link.oauth-test]'
Expected: all pass, no lint errors.
git add src/com/getorcha/oauth/providers.clj \
src/com/getorcha/oauth/core.clj \
src/com/getorcha/oauth/providers/google.clj \
src/com/getorcha/oauth/providers/microsoft.clj \
src/com/getorcha/link/oauth/http.clj
git commit -m "refactor: unify OIDCProvider protocol and extract decode-jwt-header"
oauth.jwksThe app auth middleware (app.http.middleware.auth) hand-rolls two atom-based JWKS caches — one for Cognito, one for Microsoft. Neither has TTL, so a key rotation at the IdP requires a server restart. Replace both with oauth.jwks/create-jwks-cache which has proper TTL-based refresh.
Files:
src/com/getorcha/app/http/middleware/auth.cljThe current implementation (lines 40-65) fetches JWKS lazily from the request's :auth config. The oauth.jwks cache needs a URL at creation time. Since we can't construct the URL until we have config, we'll create the cache lazily on first use but use the proper InMemoryJWKSCache with TTL.
Replace the Cognito JWKS section in auth.clj:
;; Add to requires:
[com.getorcha.oauth.jwks :as jwks]
;; Remove buddy.core.keys import (no longer needed for manual jwk->public-key)
;; Keep buddy.sign.jwt for unsign
;; Replace the entire Cognito JWKS section (lines 40-65):
;; JWKS Caches
;; -----------------------------------------------------------------------------
(defonce ^:private cognito-jwks-cache
;; Lazily initialized on first request — requires Cognito config to build URL.
(atom nil))
(defn ^:private cognito-cache
"Returns the Cognito JWKS cache, creating it on first call."
[{{:keys [cognito]} :auth :as _request}]
(or @cognito-jwks-cache
(let [url (str "https://cognito-idp." (:region cognito) ".amazonaws.com/"
(:user-pool-id cognito) "/.well-known/jwks.json")
cache (jwks/create-jwks-cache url)]
(reset! cognito-jwks-cache cache)
cache)))
(defn ^:private public-key
"Gets Cognito public key for a key ID, fetching JWKS if needed."
[request kid]
(jwks/get-public-key! (cognito-cache request) kid))
Replace the Microsoft JWKS section (lines 69-99):
;; Replace Microsoft JWKS section with:
(defonce ^:private ms-jwks-cache
(jwks/create-jwks-cache jwks/microsoft-common-jwks-url))
(defn ^:private microsoft-public-key
"Gets Microsoft public key for a key ID, fetching JWKS if needed."
[kid]
(jwks/get-public-key! ms-jwks-cache kid))
The microsoft-jwks-url def (line 73) and fetch-microsoft-jwks! function (lines 82-90) are deleted.
After replacing both caches, remove buddy.core.keys from requires (it was used for buddy.keys/jwk->public-key in the hand-rolled caches). Keep all other requires.
clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'
Expected: all pass. App tests use bypass? true so they don't exercise JWKS at all, but lint catches require/import issues.
git add src/com/getorcha/app/http/middleware/auth.clj
git commit -m "refactor: replace hand-rolled JWKS caches with oauth.jwks in app auth middleware"
get-microsoft-claims! uses buddy.jwt/unsign with :skip-validation true, which bypasses exp checks. Expired Microsoft tokens are silently accepted. Fix by using the MicrosoftProvider from oauth.providers.microsoft, which does proper validation (exp, aud, issuer, tid).
Files:
src/com/getorcha/app/http/middleware/auth.cljget-microsoft-claims!The current implementation (lines 304-330) manually validates tokens with skip-validation true. Replace it to use MicrosoftProvider:
;; Add to requires:
[com.getorcha.oauth.providers.microsoft :as ms.provider]
;; Replace the microsoft-issuer-pattern def and valid-microsoft-issuer? function
;; (lines 102-114) — no longer needed.
;; Add a lazily-initialized Microsoft provider atom:
(defonce ^:private microsoft-provider
;; Lazily initialized — requires :microsoft auth config.
(atom nil))
(defn ^:private ms-provider
"Returns the Microsoft OIDC provider, creating it on first call."
[{:keys [auth] :as _request}]
(or @microsoft-provider
(let [config (:microsoft auth)
provider (ms.provider/create-provider
{:client-id (:client-id config)
:client-secret (:client-secret config)
:tenant "common"
:jwks-cache ms-jwks-cache})]
(reset! microsoft-provider provider)
provider)))
;; Replace get-microsoft-claims!:
(defn get-microsoft-claims!
"Extracts and validates JWT claims from a Microsoft ID token.
Uses the shared Microsoft OIDC provider for proper validation including
expiration, audience, issuer, and tenant checks.
Returns claims map on success, nil on failure."
[{:keys [auth] :as request} id-token]
(if (:bypass? auth)
{:sub dev-seed/cognito-sub
:email "dev@example.com"
:name "Dev User"}
(when-let [claims (providers/validate-id-token! (ms-provider request) id-token)]
;; Microsoft uses 'preferred_username' for email in some cases
(cond-> claims
(and (nil? (:email claims)) (:preferred_username claims))
(assoc :email (:preferred_username claims))))))
Add [com.getorcha.oauth.providers :as providers] to requires.
The microsoft-public-key function and ms-jwks-cache from Task 2 are still used by the provider (we pass the cache into create-provider). But microsoft-public-key is no longer called directly by the middleware — remove it if no other code uses it.
Actually, the MicrosoftProvider uses its internal jwks-cache field, so we pass ms-jwks-cache (from Task 2) to share the same cache instance. Remove the standalone microsoft-public-key function since the provider handles key lookup internally.
Remove from auth.clj:
microsoft-issuer-pattern def (line 102)valid-microsoft-issuer? function (lines 110-114)microsoft-public-key function (lines 93-99) — provider handles this internallyclj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'
git add src/com/getorcha/app/http/middleware/auth.clj
git commit -m "fix: validate Microsoft token expiration in app auth middleware
Previously used skip-validation which accepted expired tokens."
app.email.gmail (lines 35-98) and app.email.outlook (lines 34-97) contain 100% identical code for HMAC-signed OAuth state parameters: hmac-sha256, bytes->base64, url-base64->bytes, create-signed-state, verify-signed-state, url-encode. Extract to a shared namespace.
Files:
src/com/getorcha/email/oauth/state.cljsrc/com/getorcha/app/email/gmail.cljsrc/com/getorcha/app/email/outlook.cljCreate src/com/getorcha/email/oauth/state.clj:
(ns com.getorcha.email.oauth.state
"HMAC-signed OAuth state parameters for email provider authorization flows.
State parameters carry the legal-entity-id through the OAuth redirect chain.
They are HMAC-SHA256 signed with a per-provider secret and include a timestamp
to prevent replay attacks (10-minute expiry)."
(:require [clojure.string :as str]
[clojure.tools.logging :as log])
(:import (java.nio.charset StandardCharsets)
(java.util Arrays Base64)
(javax.crypto Mac)
(javax.crypto.spec SecretKeySpec)))
(defn ^:private hmac-sha256
"Computes HMAC-SHA256 of data using secret key."
^bytes [^String secret ^String data]
(let [mac (Mac/getInstance "HmacSHA256")
key (SecretKeySpec. (.getBytes secret StandardCharsets/UTF_8) "HmacSHA256")]
(.init mac key)
(.doFinal mac (.getBytes data StandardCharsets/UTF_8))))
(defn ^:private bytes->base64
"Encodes bytes as URL-safe Base64 string."
^String [^bytes b]
(.encodeToString (Base64/getUrlEncoder) b))
(defn ^:private url-base64->bytes
"Decodes URL-safe Base64 string to bytes."
^bytes [^String s]
(.decode (Base64/getUrlDecoder) s))
(defn create-signed-state
"Creates an HMAC-signed state parameter containing legal-entity-id.
Format: base64(legal-entity-id:timestamp).signature
The timestamp prevents replay attacks (states expire after 10 minutes)."
[state-secret legal-entity-id]
(let [timestamp (System/currentTimeMillis)
payload (str legal-entity-id ":" timestamp)
signature (bytes->base64 (hmac-sha256 state-secret payload))
data (bytes->base64 (.getBytes payload StandardCharsets/UTF_8))]
(str data "." signature)))
(defn verify-signed-state
"Verifies state parameter signature and extracts legal-entity-id.
Returns {:legal-entity-id uuid} on success, nil on failure.
States older than 10 minutes are rejected."
[state-secret state-param]
(try
(let [[data signature] (str/split state-param #"\." 2)
payload (String. (url-base64->bytes data) StandardCharsets/UTF_8)
[legal-entity-id-str ts] (str/split payload #":" 2)
timestamp (parse-long ts)
expected-sig (bytes->base64 (hmac-sha256 state-secret payload))
age-minutes (/ (- (System/currentTimeMillis) timestamp) 60000.0)]
(when (and (Arrays/equals (url-base64->bytes signature) (url-base64->bytes expected-sig))
(< age-minutes 10))
{:legal-entity-id (parse-uuid legal-entity-id-str)}))
(catch Exception e
(log/warn e "Failed to verify state parameter")
nil)))
app.email.gmail to use shared stateIn src/com/getorcha/app/email/gmail.clj:
[com.getorcha.email.oauth.state :as oauth.state] to requireshmac-sha256, bytes->base64, url-base64->bytes, create-signed-state, verify-signed-stateurl-encode (line 95-98) — use URLEncoder/encode inline where needed (only one callsite)Arrays, Base64, Mac, SecretKeySpecbuild-authorization-url to call oauth.state/create-signed-stateGmailOAuthProvider/verify-state to call oauth.state/verify-signed-stateAfter changes, build-authorization-url becomes:
(defn ^:private build-authorization-url
"Generates the OAuth authorization URL for user consent."
[config legal-entity-id]
(let [{:keys [client-id redirect-uri state-secret]} config
state (oauth.state/create-signed-state state-secret legal-entity-id)
scopes-str (str/join " " scopes)]
(str oauth-authorize-url "?"
"client_id=" (URLEncoder/encode client-id StandardCharsets/UTF_8)
"&response_type=code"
"&redirect_uri=" (URLEncoder/encode redirect-uri StandardCharsets/UTF_8)
"&scope=" (URLEncoder/encode scopes-str StandardCharsets/UTF_8)
"&state=" (URLEncoder/encode state StandardCharsets/UTF_8)
"&access_type=offline"
"&prompt=consent")))
And the protocol implementation:
(verify-state [_ state-param]
(oauth.state/verify-signed-state (:state-secret config) state-param))
app.email.outlook the same waySame changes as gmail. In src/com/getorcha/app/email/outlook.clj:
[com.getorcha.email.oauth.state :as oauth.state] to requiresurl-encode (lines 95-97)build-authorization-url and verify-state to use shared functionsclj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'
git add src/com/getorcha/email/oauth/state.clj \
src/com/getorcha/app/email/gmail.clj \
src/com/getorcha/app/email/outlook.clj
git commit -m "refactor: extract HMAC state signing to shared email.oauth.state namespace"
Six places POST to an OAuth token endpoint with form params and parse the JSON response. The pattern is identical — only URLs and params differ. Extract to a shared utility.
Callsites:
oauth.providers.google/exchange-code! — returns nil on erroroauth.providers.microsoft/exchange-code! — returns nil on errorapp.email.gmail/exchange-code-for-tokens! — throws on errorapp.email.outlook/exchange-code-for-tokens! — throws on errorworkers.acquisition.email.gmail/refresh-access-token! — throws on errorworkers.acquisition.email.outlook/refresh-access-token! — throws on errorFiles:
src/com/getorcha/oauth/core.cljsrc/com/getorcha/oauth/providers/google.cljsrc/com/getorcha/oauth/providers/microsoft.cljsrc/com/getorcha/app/email/gmail.cljsrc/com/getorcha/app/email/outlook.cljsrc/com/getorcha/workers/acquisition/email/gmail.cljsrc/com/getorcha/workers/acquisition/email/outlook.cljtoken-request! to oauth.coreAdd to src/com/getorcha/oauth/core.clj:
;; Add to requires:
[hato.client :as hato]
;; Add section:
;; Token Endpoint Requests
;; -----------------------------------------------------------------------------
(defn token-request!
"Makes a POST request to an OAuth token endpoint.
Standard OAuth token request: form-encoded POST, JSON response.
Throws ex-info on non-200 responses.
Arguments:
url - token endpoint URL
params - form parameters map (e.g., {:grant_type \"authorization_code\" ...})
Returns the parsed response body map on success.
Throws ex-info with :status and :body on failure."
[^String url params]
(let [{:keys [status body]} (hato/post url
{:form-params params
:as :json
:throw-exceptions? false
:socket-timeout 30000
:conn-timeout 10000})]
(if (= 200 status)
body
(throw (ex-info "Token endpoint request failed"
{:type :token-request-failed
:status status
:error (:error body)
:error-description (:error_description body)})))))
oauth.providers.google/exchange-code!Replace the body of exchange-code! in google.clj:
(defn exchange-code!
"Exchanges authorization code for tokens.
Returns map with :access_token, :id_token, :refresh_token, :expires_in
or nil on failure."
[{:keys [client-id client-secret]} code redirect-uri]
(try
(oauth.core/token-request! token-endpoint
{:client_id client-id
:client_secret client-secret
:code code
:redirect_uri redirect-uri
:grant_type "authorization_code"})
(catch Exception e
(log/warn e "Google token exchange failed"
{:error (-> e ex-data :error)})
nil)))
Remove hato.client from this file's requires if no longer used elsewhere. Check: hato is also not used elsewhere in this file after the exchange-code! change, so remove [hato.client :as hato].
oauth.providers.microsoft/exchange-code!Same pattern. Replace body:
(defn exchange-code!
"Exchanges authorization code for tokens.
Returns map with :access_token, :id_token, :refresh_token, :expires_in
or nil on failure."
([credentials code redirect-uri]
(exchange-code! credentials common-tenant code redirect-uri))
([{:keys [client-id client-secret]} tenant code redirect-uri]
(try
(oauth.core/token-request! (token-endpoint tenant)
{:client_id client-id
:client_secret client-secret
:code code
:redirect_uri redirect-uri
:grant_type "authorization_code"})
(catch Exception e
(log/warn e "Microsoft token exchange failed"
{:error (-> e ex-data :error)})
nil))))
Remove [hato.client :as hato] from requires if not used elsewhere in this file. Check: not used elsewhere, remove it.
app.email.gmail/exchange-code-for-tokens!Replace in gmail.clj. Note: uses com.getorcha.http.client (X-Ray traced). Since the token exchange is a server-to-server call that benefits from tracing, keep using the traced client for app code. For this reason, add an optional :http-fn parameter to token-request!.
Actually — simpler approach: since the email providers' exchange functions throw on error (same as token-request!), just call oauth.core/token-request! and transform the result. The tracing loss is acceptable for an infrequent operation (OAuth callback).
(defn ^:private exchange-code-for-tokens!
"Exchanges authorization code for access and refresh tokens."
[config code]
(let [{:keys [client-id client-secret redirect-uri]} config
body (oauth.core/token-request! token-endpoint
{:client_id client-id
:client_secret client-secret
:code code
:redirect_uri redirect-uri
:grant_type "authorization_code"})]
{:access-token (:access_token body)
:refresh-token (:refresh_token body)
:expires-at (.plusSeconds (Instant/now) (:expires_in body))}))
Add [com.getorcha.oauth.core :as oauth.core] to requires. Remove [com.getorcha.http.client :as http] if not used elsewhere in the file. Check: http is also used by fetch-user-email — keep it.
app.email.outlook/exchange-code-for-tokens!Same pattern:
(defn ^:private exchange-code-for-tokens!
"Exchanges authorization code for access and refresh tokens."
[config code]
(let [{:keys [client-id client-secret redirect-uri]} config
body (oauth.core/token-request! (str oauth-base-url "/token")
{:client_id client-id
:client_secret client-secret
:code code
:redirect_uri redirect-uri
:grant_type "authorization_code"})]
{:access-token (:access_token body)
:refresh-token (:refresh_token body)
:expires-at (.plusSeconds (Instant/now) (:expires_in body))}))
Add [com.getorcha.oauth.core :as oauth.core] to requires.
workers.acquisition.email.gmail/refresh-access-token!(defn refresh-access-token!
"Refreshes an access token using a refresh token.
Returns {:access-token string, :refresh-token string, :expires-at Instant}"
[config refresh-token]
(let [{:keys [client-id client-secret]} config]
(try
(let [body (oauth.core/token-request! token-endpoint
{:client_id client-id
:client_secret client-secret
:refresh_token refresh-token
:grant_type "refresh_token"})]
{:access-token (:access_token body)
;; Google doesn't return a new refresh token on refresh
:refresh-token refresh-token
:expires-at (.plusSeconds (Instant/now) (:expires_in body))})
(catch Exception e
(throw (ex-info "Failed to refresh access token"
{:type :token-refresh-failed
:status (:status (ex-data e))
:body (:body (ex-data e))}))))))
Add [com.getorcha.oauth.core :as oauth.core] to requires. Remove [hato.client :as hato] if not used elsewhere. Check: hato is used by gmail-request — keep it.
workers.acquisition.email.outlook/refresh-access-token!(defn refresh-access-token!
"Refreshes an access token using a refresh token.
Returns {:access-token string, :refresh-token string, :expires-at Instant}"
[config refresh-token]
(let [{:keys [client-id client-secret]} config]
(try
(let [body (oauth.core/token-request! (str oauth-base-url "/token")
{:client_id client-id
:client_secret client-secret
:refresh_token refresh-token
:grant_type "refresh_token"})]
{:access-token (:access_token body)
;; Microsoft may return a new refresh token or reuse the old one
:refresh-token (or (:refresh_token body) refresh-token)
:expires-at (.plusSeconds (Instant/now) (:expires_in body))})
(catch Exception e
(throw (ex-info "Failed to refresh access token"
{:type :token-refresh-failed
:status (:status (ex-data e))
:body (:body (ex-data e))}))))))
Add [com.getorcha.oauth.core :as oauth.core] to requires.
clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.link.oauth-test]'
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'
git add src/com/getorcha/oauth/core.clj \
src/com/getorcha/oauth/providers/google.clj \
src/com/getorcha/oauth/providers/microsoft.clj \
src/com/getorcha/app/email/gmail.clj \
src/com/getorcha/app/email/outlook.clj \
src/com/getorcha/workers/acquisition/email/gmail.clj \
src/com/getorcha/workers/acquisition/email/outlook.clj
git commit -m "refactor: extract shared OAuth token endpoint utility to oauth.core/token-request!"
ensure-access-token! from worker providersGmailSyncProvider/ensure-access-token! and OutlookSyncProvider/ensure-access-token! are structurally identical: load tokens from SSM, check validity, refresh if needed, store new tokens. The only difference is which refresh-access-token! is called. Extract the shared logic.
Files:
src/com/getorcha/email/oauth/tokens.cljsrc/com/getorcha/workers/acquisition/email/gmail.cljsrc/com/getorcha/workers/acquisition/email/outlook.cljensure-valid-access-token! to email.oauth.tokensAdd to src/com/getorcha/email/oauth/tokens.clj:
(defn ensure-valid-access-token!
"Returns a valid access token, refreshing if needed.
Arguments:
ssm-client - AWS SSM client
path-pattern - SSM path prefix
doc-source-id - UUID of the doc source
refresh-fn - function that takes [config refresh-token] and returns
{:access-token :refresh-token :expires-at}
config - provider config (passed to refresh-fn)
Returns access token string.
Throws ex-info with :type :token-refresh-failed if no tokens found or refresh fails."
[ssm-client path-pattern doc-source-id refresh-fn config]
(let [token-map (load-tokens ssm-client path-pattern doc-source-id)]
(when-not token-map
(throw (ex-info "No tokens found" {:type :token-refresh-failed
:doc-source-id doc-source-id})))
(if (token-valid-for? token-map 5)
(:access-token token-map)
(let [new-tokens (refresh-fn config (:refresh-token token-map))]
(store-tokens! ssm-client path-pattern doc-source-id new-tokens)
(:access-token new-tokens)))))
GmailSyncProvider/ensure-access-token!In workers.acquisition.email.gmail, replace the protocol method:
(ensure-access-token! [_ doc-source-email]
(tokens/ensure-valid-access-token!
ssm-client
(:token-path-pattern config)
(:doc-source-email/doc-source-id doc-source-email)
refresh-access-token!
config))
OutlookSyncProvider/ensure-access-token!In workers.acquisition.email.outlook, same pattern:
(ensure-access-token! [_ doc-source-email]
(tokens/ensure-valid-access-token!
ssm-client
(:token-path-pattern config)
(:doc-source-email/doc-source-id doc-source-email)
refresh-access-token!
config))
clj-kondo --lint src test dev
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Ran .* tests)"
git add src/com/getorcha/email/oauth/tokens.clj \
src/com/getorcha/workers/acquisition/email/gmail.clj \
src/com/getorcha/workers/acquisition/email/outlook.clj
git commit -m "refactor: extract ensure-access-token! to shared email.oauth.tokens"
Four places build OAuth authorization URLs via manual string concatenation with URLEncoder/encode. Extract a small utility.
Callsites:
oauth.providers.google/get-authorization-urloauth.providers.microsoft/get-authorization-urlapp.email.gmail/build-authorization-urlapp.email.outlook/build-authorization-urlFiles:
src/com/getorcha/oauth/core.cljsrc/com/getorcha/oauth/providers/google.cljsrc/com/getorcha/oauth/providers/microsoft.cljsrc/com/getorcha/app/email/gmail.cljsrc/com/getorcha/app/email/outlook.cljbuild-url to oauth.coreAdd to src/com/getorcha/oauth/core.clj:
;; Add import:
(java.net URLEncoder)
;; Add section:
;; URL Building
;; -----------------------------------------------------------------------------
(defn build-url
"Builds a URL with query parameters, encoding values.
Arguments:
base-url - base URL string
params - ordered sequence of [key value] pairs
Returns URL string with encoded query parameters."
^String [^String base-url params]
(let [query (->> params
(map (fn [[k v]]
(str (URLEncoder/encode (name k) "UTF-8")
"="
(URLEncoder/encode (str v) "UTF-8"))))
(str/join "&"))]
(str base-url "?" query)))
GoogleProvider/get-authorization-urlIn google.clj:
(get-authorization-url [_this redirect-uri state scope]
(let [scope-str (if (sequential? scope)
(str/join " " scope)
scope)]
(oauth.core/build-url authorization-endpoint
[[:client_id client-id]
[:response_type "code"]
[:redirect_uri redirect-uri]
[:scope scope-str]
[:state state]
[:access_type "offline"]
[:prompt "consent"]])))
MicrosoftProvider/get-authorization-urlIn microsoft.clj:
(get-authorization-url [_this redirect-uri state scope]
(let [scope-str (if (sequential? scope)
(str/join " " scope)
scope)]
(oauth.core/build-url (authorization-endpoint tenant)
[[:client_id client-id]
[:response_type "code"]
[:redirect_uri redirect-uri]
[:scope scope-str]
[:state state]
[:response_mode "query"]
[:prompt "consent"]])))
app.email.gmail/build-authorization-url(defn ^:private build-authorization-url
"Generates the OAuth authorization URL for user consent."
[config legal-entity-id]
(let [{:keys [client-id redirect-uri state-secret]} config
state (oauth.state/create-signed-state state-secret legal-entity-id)
scopes-str (str/join " " scopes)]
(oauth.core/build-url oauth-authorize-url
[[:client_id client-id]
[:response_type "code"]
[:redirect_uri redirect-uri]
[:scope scopes-str]
[:state state]
[:access_type "offline"]
[:prompt "consent"]])))
Remove URLEncoder import and url-encode function if still present.
app.email.outlook/build-authorization-url(defn ^:private build-authorization-url
"Generates the OAuth authorization URL for user consent."
[config legal-entity-id]
(let [{:keys [client-id redirect-uri state-secret]} config
state (oauth.state/create-signed-state state-secret legal-entity-id)
scopes-str (str/join " " scopes)]
(oauth.core/build-url (str oauth-base-url "/authorize")
[[:client_id client-id]
[:response_type "code"]
[:redirect_uri redirect-uri]
[:scope scopes-str]
[:state state]
[:response_mode "query"]])))
Remove URLEncoder import and url-encode function if still present.
clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.link.oauth-test]'
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'
git add src/com/getorcha/oauth/core.clj \
src/com/getorcha/oauth/providers/google.clj \
src/com/getorcha/oauth/providers/microsoft.clj \
src/com/getorcha/app/email/gmail.clj \
src/com/getorcha/app/email/outlook.clj
git commit -m "refactor: extract authorization URL builder to oauth.core/build-url"