Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

OAuth Consolidation Implementation Plan

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


Context

Namespace map

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

Key files

Running tests

clj -X:test:silent :nses '[com.getorcha.link.oauth-test]'
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'

Linting

clj-kondo --lint src test dev

Task 1: Unify OIDCProvider protocol and extract decode-jwt-header

Both 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:

Step 1: Add decode-jwt-header to oauth.core

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

Step 2: Create oauth.providers namespace with shared protocol

Create 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."))

Step 3: Update oauth.providers.google to use shared protocol and decode-jwt-header

In src/com/getorcha/oauth/providers/google.clj:

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

Step 4: Update oauth.providers.microsoft the same way

In src/com/getorcha/oauth/providers/microsoft.clj:

Same 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

Step 5: Update link.oauth.http to use polymorphic dispatch

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

Step 6: Lint and run tests

clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.link.oauth-test]'

Expected: all pass, no lint errors.

Step 7: Commit

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"

Task 2: Migrate app auth middleware JWKS caches to oauth.jwks

The 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:

Step 1: Replace Cognito JWKS cache

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

Step 2: Replace Microsoft JWKS cache

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.

Step 3: Remove unused imports

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.

Step 4: Lint and run tests

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.

Step 5: Commit

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"

Task 3: Fix Microsoft token validation in app auth middleware (security)

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:

Step 1: Replace get-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.

Step 2: Clean up unused code

Remove from auth.clj:

Step 3: Lint and run tests

clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'

Step 4: Commit

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

Task 4: Extract HMAC state signing from email providers

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:

Step 1: Create shared state namespace

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

Step 2: Update app.email.gmail to use shared state

In src/com/getorcha/app/email/gmail.clj:

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

Step 3: Update app.email.outlook the same way

Same changes as gmail. In src/com/getorcha/app/email/outlook.clj:

Step 4: Lint and run tests

clj-kondo --lint src test dev
clj -X:test:silent :nses '[com.getorcha.app.http.oauth-test]'

Step 5: Commit

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"

Task 5: Create shared OAuth token endpoint utility

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:

  1. oauth.providers.google/exchange-code! — returns nil on error
  2. oauth.providers.microsoft/exchange-code! — returns nil on error
  3. app.email.gmail/exchange-code-for-tokens! — throws on error
  4. app.email.outlook/exchange-code-for-tokens! — throws on error
  5. workers.acquisition.email.gmail/refresh-access-token! — throws on error
  6. workers.acquisition.email.outlook/refresh-access-token! — throws on error

Files:

Step 1: Add token-request! to oauth.core

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

Step 2: Update 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].

Step 3: Update 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.

Step 4: Update 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.

Step 5: Update 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.

Step 6: Update 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.

Step 7: Update 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.

Step 8: Lint and run tests

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]'

Step 9: Commit

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

Task 6: Extract ensure-access-token! from worker providers

GmailSyncProvider/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:

Step 1: Add ensure-valid-access-token! to email.oauth.tokens

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

Step 2: Update 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))

Step 3: Update 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))

Step 4: Lint and run tests

clj-kondo --lint src test dev
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Ran .* tests)"

Step 5: Commit

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"

Task 7: Authorization URL builder utility

Four places build OAuth authorization URLs via manual string concatenation with URLEncoder/encode. Extract a small utility.

Callsites:

  1. oauth.providers.google/get-authorization-url
  2. oauth.providers.microsoft/get-authorization-url
  3. app.email.gmail/build-authorization-url
  4. app.email.outlook/build-authorization-url

Files:

Step 1: Add build-url to oauth.core

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

Step 2: Update GoogleProvider/get-authorization-url

In 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"]])))

Step 3: Update MicrosoftProvider/get-authorization-url

In 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"]])))

Step 4: Update 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.

Step 5: Update 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.

Step 6: Lint and run tests

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]'

Step 7: Commit

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"