Link OAuth IdP Integration Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Complete the OAuth 2.1 authorization flow in Link by implementing Google and Microsoft identity provider callbacks.

Architecture: Replace in-memory atom storage with PostgreSQL table for OAuth flow state. Implement single-endpoint callback handlers that detect IdP return by presence of code param. Reuse existing com.getorcha.oauth.providers.{google,microsoft} modules for token exchange and validation.

Tech Stack: Clojure, PostgreSQL, HoneySQL, Reitit, hato (HTTP client), existing OIDC provider modules


Task 1: Create Database Migration

Files:

Step 1: Generate migration timestamp

Run: bb migrate create "add-oauth-pending-flow"

Expected: Two new migration files created in resources/migrations/

Step 2: Write the up migration

CREATE TABLE oauth_pending_flow (
    id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    state_key             TEXT NOT NULL UNIQUE,
    client_id             TEXT NOT NULL,
    redirect_uri          TEXT NOT NULL,
    scope                 TEXT NOT NULL,
    client_state          TEXT,
    code_challenge        TEXT NOT NULL,
    code_challenge_method TEXT NOT NULL,
    client_name           TEXT,
    expires_at            TIMESTAMPTZ NOT NULL,
    created_at            TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_oauth_pending_flow_expires_at ON oauth_pending_flow(expires_at);

Step 3: Write the down migration

DROP INDEX IF EXISTS idx_oauth_pending_flow_expires_at;
DROP TABLE IF EXISTS oauth_pending_flow;

Step 4: Run migration locally

Run: clj -M:dev -e "(require '[integrant.repl :refer [reset]]) (reset)" (with 30s timeout)

Expected: System reloads, migration applied automatically

Step 5: Verify table exists

Run: psql -h localhost -U postgres -d orcha -c "\d oauth_pending_flow"

Expected: Table schema displayed

Step 6: Commit

git add resources/migrations/*oauth-pending-flow*
git commit -m "Add oauth_pending_flow table for Link OAuth state storage"

Task 2: Add Shared Auth Providers Config

Files:

Step 1: Add top-level auth-providers config

Add after :com.getorcha/link-base-url:

:com.getorcha/auth-providers
{:google    {:client-id     #orcha/param "/v1-orcha/cognito-google-client-id"
             :client-secret #orcha/param "/v1-orcha/cognito-google-client-secret"}
 :microsoft {:client-id     #orcha/param "/v1-orcha/microsoft-auth-client-id"
             :client-secret #orcha/param "/v1-orcha/microsoft-auth-client-secret"}}

Step 2: Update Link handler config

Change :com.getorcha.link.http/handler to add :auth-providers:

:com.getorcha.link.http/handler
{:base-url       #ref [:com.getorcha/link-base-url]
 :db-pool        #integrant/ref :com.getorcha.db/pool
 :aws            #integrant/ref :com.getorcha.aws/state
 :key-arn        #orcha/param "/v1-orcha/oauth-signing-key-arn"
 :auth-providers #ref [:com.getorcha/auth-providers]}

Step 3: Update ERP handler config to use shared Microsoft credentials

Change the :microsoft key inside :auth to:

:microsoft #merge [#ref [:com.getorcha/auth-providers :microsoft]
                   {:state-secret #orcha/param "/v1-orcha/microsoft-auth-state-secret"}]

Step 4: Add test SSM params for Google auth

In test/com/getorcha/test/fixtures.clj, add to ssm-params:

"/v1-orcha/cognito-google-client-id"     "test-google-auth-client"
"/v1-orcha/cognito-google-client-secret" "test-google-auth-secret"

Step 5: Verify config loads

Run: clj -M:dev -e "(require '[com.getorcha.system :as sys]) (println (keys (sys/ig-config {:config-file \"com/getorcha/config.edn\" :profile :local-dev})))"

Expected: No errors, keys printed

Step 6: Commit

git add resources/com/getorcha/config.edn test/com/getorcha/test/fixtures.clj
git commit -m "Add shared auth-providers config for Google and Microsoft OAuth"

Task 3: Create Flow Storage Namespace

Files:

Step 1: Write failing test for store-flow!

(ns com.getorcha.link.oauth.flow-test
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.link.oauth.flow :as flow]
            [com.getorcha.test.fixtures :as fixtures]))

(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)


(deftest store-flow!-test
  (testing "stores flow and returns state key"
    (let [flow-data {:client-id             "test-client"
                     :redirect-uri          "https://example.com/callback"
                     :scope                 "mcp:read"
                     :client-state          "client-xyz"
                     :code-challenge        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
                     :code-challenge-method "S256"
                     :client-name           "Test App"}
          state-key (flow/store-flow! fixtures/*db* flow-data)]
      (is (string? state-key))
      (is (>= (count state-key) 32)))))

Step 2: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.link.oauth.flow-test]'

Expected: FAIL - namespace not found

Step 3: Write minimal flow namespace

(ns com.getorcha.link.oauth.flow
  "Database-backed OAuth flow state storage for Link service.

   Stores pending OAuth authorization flows in PostgreSQL with TTL.
   Uses lazy cleanup: expired flows filtered on read, deleted opportunistically."
  (:require [com.getorcha.db.sql :as db.sql])
  (:import (java.security SecureRandom)
           (java.time Instant)
           (java.util Base64)))


(def ^:const flow-lifetime-seconds
  "OAuth flow lifetime: 10 minutes."
  600)


(defn ^:private generate-state-key
  "Generates a cryptographically random state key (32 bytes, base64url)."
  ^String []
  (let [bytes (byte-array 32)]
    (.nextBytes (SecureRandom.) bytes)
    (-> (Base64/getUrlEncoder)
        (.withoutPadding)
        (.encodeToString bytes))))


(defn store-flow!
  "Stores authorization flow data in database, returns state key.

   Arguments:
     db        - database connection
     flow-data - map with :client-id, :redirect-uri, :scope, :client-state,
                 :code-challenge, :code-challenge-method, :client-name"
  ^String [db {:keys [client-id redirect-uri scope client-state
                      code-challenge code-challenge-method client-name]
               :as   _flow-data}]
  {:pre [(some? client-id)
         (some? redirect-uri)
         (some? code-challenge)]}
  (let [state-key  (generate-state-key)
        expires-at (.plusSeconds (Instant/now) flow-lifetime-seconds)]
    (db.sql/execute-one! db
      {:insert-into :oauth-pending-flow
       :values [{:state-key             state-key
                 :client-id             client-id
                 :redirect-uri          redirect-uri
                 :scope                 (or scope "mcp:read")
                 :client-state          client-state
                 :code-challenge        code-challenge
                 :code-challenge-method (or code-challenge-method "S256")
                 :client-name           client-name
                 :expires-at            expires-at}]})
    state-key))

Step 4: Run test to verify it passes

Run: clj -X:test:silent :nses '[com.getorcha.link.oauth.flow-test]'

Expected: PASS

Step 5: Write failing test for get-flow!

Add to flow_test.clj:

(deftest get-flow!-test
  (testing "retrieves valid flow by state key"
    (let [flow-data {:client-id             "test-client"
                     :redirect-uri          "https://example.com/callback"
                     :scope                 "mcp:read"
                     :client-state          "client-xyz"
                     :code-challenge        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
                     :code-challenge-method "S256"
                     :client-name           "Test App"}
          state-key (flow/store-flow! fixtures/*db* flow-data)
          retrieved (flow/get-flow! fixtures/*db* state-key)]
      (is (some? retrieved))
      (is (= "test-client" (:oauth-pending-flow/client-id retrieved)))
      (is (= "client-xyz" (:oauth-pending-flow/client-state retrieved)))))

  (testing "returns nil for non-existent state key"
    (is (nil? (flow/get-flow! fixtures/*db* "nonexistent-key"))))

  (testing "returns nil for expired flow"
    (let [state-key (flow/store-flow! fixtures/*db*
                      {:client-id      "test"
                       :redirect-uri   "https://x.com/cb"
                       :code-challenge "abc"})]
      ;; Manually expire it
      (db.sql/execute-one! fixtures/*db*
        {:update :oauth-pending-flow
         :set    {:expires-at (.minusSeconds (Instant/now) 100)}
         :where  [:= :state-key state-key]})
      (is (nil? (flow/get-flow! fixtures/*db* state-key))))))

Add require for Instant and db.sql:

(:require ...
          [com.getorcha.db.sql :as db.sql])
(:import (java.time Instant))

Step 6: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.link.oauth.flow-test]'

Expected: FAIL - get-flow! not found

Step 7: Implement get-flow!

Add to flow.clj:

(defn get-flow!
  "Retrieves flow by state key if not expired.

   Performs lazy cleanup: deletes expired flow if encountered.
   Returns flow map or nil."
  [db ^String state-key]
  (when-let [flow (db.sql/execute-one! db
                    {:select [:*]
                     :from   [:oauth-pending-flow]
                     :where  [:= :state-key state-key]})]
    (if (.isAfter (:oauth-pending-flow/expires-at flow) (Instant/now))
      flow
      ;; Expired - delete and return nil
      (do
        (db.sql/execute-one! db
          {:delete-from :oauth-pending-flow
           :where       [:= :state-key state-key]})
        nil))))

Step 8: Run test to verify it passes

Run: clj -X:test:silent :nses '[com.getorcha.link.oauth.flow-test]'

Expected: PASS

Step 9: Write failing test for delete-flow!

Add to flow_test.clj:

(deftest delete-flow!-test
  (testing "deletes flow by state key"
    (let [state-key (flow/store-flow! fixtures/*db*
                      {:client-id      "test"
                       :redirect-uri   "https://x.com/cb"
                       :code-challenge "abc"})]
      (is (some? (flow/get-flow! fixtures/*db* state-key)))
      (flow/delete-flow! fixtures/*db* state-key)
      (is (nil? (flow/get-flow! fixtures/*db* state-key))))))

Step 10: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.link.oauth.flow-test]'

Expected: FAIL - delete-flow! not found

Step 11: Implement delete-flow!

Add to flow.clj:

(defn delete-flow!
  "Deletes flow by state key."
  [db ^String state-key]
  (db.sql/execute-one! db
    {:delete-from :oauth-pending-flow
     :where       [:= :state-key state-key]}))

Step 12: Run all flow tests

Run: clj -X:test:silent :nses '[com.getorcha.link.oauth.flow-test]'

Expected: All PASS

Step 13: Commit

git add src/com/getorcha/link/oauth/flow.clj test/com/getorcha/link/oauth/flow_test.clj
git commit -m "Add database-backed OAuth flow storage for Link"

Task 4: Add Token Exchange to Provider Modules

Files:

Step 1: Add exchange-code! to Google provider

Add to google.clj after revocation-endpoint:

(defn exchange-code!
  "Exchanges authorization code for tokens.

   Arguments:
     credentials  - map with :client-id, :client-secret
     code         - authorization code from callback
     redirect-uri - must match the URI used in authorize request

   Returns map with :access_token, :id_token, :refresh_token, :expires_in
   or nil on failure."
  [{:keys [client-id client-secret]} code redirect-uri]
  (try
    (let [response (hato/post token-endpoint
                     {:form-params {:client_id     client-id
                                    :client_secret client-secret
                                    :code          code
                                    :redirect_uri  redirect-uri
                                    :grant_type    "authorization_code"}
                      :as                :json
                      :throw-exceptions? false
                      :socket-timeout    30000
                      :conn-timeout      10000})]
      (when (= 200 (:status response))
        (:body response)))
    (catch Exception e
      (log/warn e "Google token exchange failed")
      nil)))

Add to requires: [hato.client :as hato]

Step 2: Add exchange-code! to Microsoft provider

Add to microsoft.clj after default-scopes:

(defn exchange-code!
  "Exchanges authorization code for tokens.

   Arguments:
     credentials  - map with :client-id, :client-secret
     tenant       - Azure AD tenant (default: 'common')
     code         - authorization code from callback
     redirect-uri - must match the URI used in authorize request

   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
     (let [response (hato/post (token-endpoint tenant)
                      {:form-params {:client_id     client-id
                                     :client_secret client-secret
                                     :code          code
                                     :redirect_uri  redirect-uri
                                     :grant_type    "authorization_code"}
                       :as                :json
                       :throw-exceptions? false
                       :socket-timeout    30000
                       :conn-timeout      10000})]
       (when (= 200 (:status response))
         (:body response)))
     (catch Exception e
       (log/warn e "Microsoft token exchange failed")
       nil))))

Add to requires: [hato.client :as hato]

Step 3: Verify compiles

Run: clj -M -e "(require '[com.getorcha.oauth.providers.google]) (require '[com.getorcha.oauth.providers.microsoft])"

Expected: No errors

Step 4: Commit

git add src/com/getorcha/oauth/providers/google.clj src/com/getorcha/oauth/providers/microsoft.clj
git commit -m "Add token exchange functions to Google and Microsoft OAuth providers"

Task 5: Implement Callback Handlers

Files:

Step 1: Write failing integration test for Google callback redirect

Create or add to test/com/getorcha/link/oauth/http_test.clj:

(ns com.getorcha.link.oauth.http-test
  (:require [clojure.string :as str]
            [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.link.http :as link.http]
            [com.getorcha.link.oauth.flow :as flow]
            [com.getorcha.test.fixtures :as fixtures]
            [hato.client :as hato]
            [reitit.core :as r]
            [reitit.ring :as r.ring])
  (:import (org.eclipse.jetty.server Server)))


(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)


(defn- link-request
  "Makes HTTP request to Link server."
  [{:keys [route] :as opts}]
  (let [server  ^Server (::link.http/server fixtures/*system*)
        handler (::link.http/handler fixtures/*system*)
        router  (r.ring/get-router handler)
        path    (if (string? route)
                  route
                  (r/match->path (r/match-by-name router route)))
        url     (str (str/replace (str (.getURI server)) #"/+$" "") path)]
    (hato/request (-> opts
                      (dissoc :route)
                      (assoc :url url)
                      (update :throw-exceptions? #(if (nil? %) false %))))))


(deftest google-callback-redirect-test
  (testing "redirects to Google when no code param (initial click)"
    (let [;; Store a pending flow
          state-key (flow/store-flow! fixtures/*db*
                      {:client-id             "test-client"
                       :redirect-uri          "https://example.com/callback"
                       :scope                 "mcp:read"
                       :code-challenge        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
                       :code-challenge-method "S256"})
          response  (link-request
                      {:method          :get
                       :route           (str "/oauth/callback/google?state=" state-key)
                       :follow-redirects false})]
      (is (= 302 (:status response)))
      (is (str/includes? (get-in response [:headers "location"])
                         "accounts.google.com")))))

Step 2: Run test to verify it fails

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

Expected: FAIL - returns 501 instead of 302

Step 3: Update http.clj requires

Add to requires in com.getorcha.link.oauth.http:

[com.getorcha.link.oauth.flow :as flow]
[com.getorcha.oauth.providers.google :as google]
[com.getorcha.oauth.providers.microsoft :as microsoft]
[com.getorcha.db.sql :as db.sql]
[clojure.tools.logging :as log]

Step 4: Implement google-callback-handler

Replace the existing google-callback-handler:

(defn google-callback-handler
  "Handles GET /oauth/callback/google.

   Two modes based on query params:
   1. No 'code' param: User clicked Google button - redirect to Google
   2. Has 'code' param: Google returned - validate and issue auth code"
  [{:keys [parameters db-pool auth-providers base-url] :as request} respond raise]
  (let [{:keys [code state error]} (:query parameters)]
    (cond
      ;; IdP returned an error
      error
      (if-let [flow-data (flow/get-flow! db-pool state)]
        (let [redirect-uri (:oauth-pending-flow/redirect-uri flow-data)
              client-state (:oauth-pending-flow/client-state flow-data)]
          (flow/delete-flow! db-pool state)
          (respond
           (ring.resp/found
            (str redirect-uri
                 "?error=access_denied"
                 "&error_description=" (url-encode (or error "User denied access"))
                 (when client-state (str "&state=" (url-encode client-state)))))))
        (respond (ring.resp/bad-request {:error "invalid_state"})))

      ;; IdP returned with code - validate and issue our auth code
      code
      (handle-idp-return request respond raise
        {:provider    :google
         :code        code
         :state       state
         :db-pool     db-pool
         :credentials (:google auth-providers)
         :base-url    base-url})

      ;; Initial click - redirect to Google
      :else
      (if-let [flow-data (flow/get-flow! db-pool state)]
        (let [callback-uri  (str base-url "/oauth/callback/google")
              scope         (or (:oauth-pending-flow/scope flow-data) "openid email profile")
              code-challenge (:oauth-pending-flow/code-challenge flow-data)
              auth-url      (google/get-authorization-url
                              (google/create-provider (:google auth-providers))
                              callback-uri
                              state
                              code-challenge
                              scope)]
          (respond (ring.resp/found auth-url)))
        (respond (ring.resp/bad-request {:error "invalid_state"}))))))

Step 5: Implement handle-idp-return helper

Add before google-callback-handler:

(defn ^:private handle-idp-return
  "Common handler for IdP callback with authorization code.

   1. Exchange code for tokens
   2. Validate ID token
   3. Look up identity by email
   4. Issue our authorization code
   5. Redirect to client"
  [request respond _raise
   {:keys [provider code state db-pool credentials base-url]}]
  (let [callback-uri (str base-url "/oauth/callback/" (name provider))]
    (if-let [flow-data (flow/get-flow! db-pool state)]
      (let [;; Exchange code for tokens
            tokens (case provider
                     :google    (google/exchange-code! credentials code callback-uri)
                     :microsoft (microsoft/exchange-code! credentials code callback-uri))
            id-token (:id_token tokens)]
        (if-not id-token
          ;; Token exchange failed
          (let [redirect-uri (:oauth-pending-flow/redirect-uri flow-data)
                client-state (:oauth-pending-flow/client-state flow-data)]
            (flow/delete-flow! db-pool state)
            (respond
             (ring.resp/found
              (str redirect-uri "?error=server_error"
                   (when client-state (str "&state=" (url-encode client-state)))))))
          ;; Validate ID token
          (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))]
            (if-not claims
              ;; Invalid token
              (let [redirect-uri (:oauth-pending-flow/redirect-uri flow-data)
                    client-state (:oauth-pending-flow/client-state flow-data)]
                (flow/delete-flow! db-pool state)
                (respond
                 (ring.resp/found
                  (str redirect-uri "?error=server_error"
                       (when client-state (str "&state=" (url-encode client-state)))))))
              ;; Look up identity by email
              (let [email (or (:email claims) (:preferred_username claims))]
                (if-let [identity (db.sql/execute-one! db-pool
                                    {:select [:identity.id]
                                     :from   [:identity]
                                     :where  [:= :identity.email [:lower email]]})]
                  ;; Success - issue authorization code
                  (let [{:oauth-pending-flow/keys [client-id redirect-uri scope
                                                   code-challenge code-challenge-method
                                                   client-state]} flow-data
                        auth-code (auth-code/store-code!
                                    {:client-id             client-id
                                     :identity-id           (:identity/id identity)
                                     :redirect-uri          redirect-uri
                                     :scope                 scope
                                     :code-challenge        code-challenge
                                     :code-challenge-method code-challenge-method})]
                    (flow/delete-flow! db-pool state)
                    (respond
                     (ring.resp/found
                      (str redirect-uri "?code=" (url-encode auth-code)
                           (when client-state (str "&state=" (url-encode client-state)))))))
                  ;; User not registered
                  (let [redirect-uri (:oauth-pending-flow/redirect-uri flow-data)
                        client-state (:oauth-pending-flow/client-state flow-data)]
                    (log/warn "OAuth login attempt from unregistered user" {:email email :provider provider})
                    (flow/delete-flow! db-pool state)
                    (respond
                     (ring.resp/found
                      (str redirect-uri "?error=access_denied"
                           "&error_description=" (url-encode "user_not_registered")
                           (when client-state (str "&state=" (url-encode client-state)))))))))))))
      ;; Invalid state
      (respond (ring.resp/bad-request {:error "invalid_state"})))))

Step 6: Run test to verify redirect works

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

Expected: PASS

Step 7: Write test for Microsoft callback

Add to http_test.clj:

(deftest microsoft-callback-redirect-test
  (testing "redirects to Microsoft when no code param"
    (let [state-key (flow/store-flow! fixtures/*db*
                      {:client-id             "test-client"
                       :redirect-uri          "https://example.com/callback"
                       :scope                 "mcp:read"
                       :code-challenge        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
                       :code-challenge-method "S256"})
          response  (link-request
                      {:method          :get
                       :route           (str "/oauth/callback/microsoft?state=" state-key)
                       :follow-redirects false})]
      (is (= 302 (:status response)))
      (is (str/includes? (get-in response [:headers "location"])
                         "login.microsoftonline.com")))))

Step 8: Implement microsoft-callback-handler

Replace the existing microsoft-callback-handler:

(defn microsoft-callback-handler
  "Handles GET /oauth/callback/microsoft.

   Two modes based on query params:
   1. No 'code' param: User clicked Microsoft button - redirect to Microsoft
   2. Has 'code' param: Microsoft returned - validate and issue auth code"
  [{:keys [parameters db-pool auth-providers base-url] :as request} respond raise]
  (let [{:keys [code state error]} (:query parameters)]
    (cond
      ;; IdP returned an error
      error
      (if-let [flow-data (flow/get-flow! db-pool state)]
        (let [redirect-uri (:oauth-pending-flow/redirect-uri flow-data)
              client-state (:oauth-pending-flow/client-state flow-data)]
          (flow/delete-flow! db-pool state)
          (respond
           (ring.resp/found
            (str redirect-uri
                 "?error=access_denied"
                 "&error_description=" (url-encode (or error "User denied access"))
                 (when client-state (str "&state=" (url-encode client-state)))))))
        (respond (ring.resp/bad-request {:error "invalid_state"})))

      ;; IdP returned with code - validate and issue our auth code
      code
      (handle-idp-return request respond raise
        {:provider    :microsoft
         :code        code
         :state       state
         :db-pool     db-pool
         :credentials (:microsoft auth-providers)
         :base-url    base-url})

      ;; Initial click - redirect to Microsoft
      :else
      (if-let [flow-data (flow/get-flow! db-pool state)]
        (let [callback-uri  (str base-url "/oauth/callback/microsoft")
              scope         (or (:oauth-pending-flow/scope flow-data) "openid email profile")
              code-challenge (:oauth-pending-flow/code-challenge flow-data)
              auth-url      (microsoft/get-authorization-url
                              (microsoft/create-provider (:microsoft auth-providers))
                              callback-uri
                              state
                              code-challenge
                              scope)]
          (respond (ring.resp/found auth-url)))
        (respond (ring.resp/bad-request {:error "invalid_state"}))))))

Step 9: Run all callback tests

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

Expected: All PASS

Step 10: Commit

git add src/com/getorcha/link/oauth/http.clj test/com/getorcha/link/oauth/http_test.clj
git commit -m "Implement Google and Microsoft OAuth callback handlers for Link"

Task 6: Update Authorize Handler to Use DB Storage

Files:

Step 1: Update authorize handler to use flow/store-flow!

Find the authorize-handler function and replace the store-flow! call with:

(let [state-key (flow/store-flow! db-pool
                  {:client-id             client-id
                   :redirect-uri          redirect_uri
                   :scope                 scope
                   :client-state          state
                   :code-challenge        code_challenge
                   :code-challenge-method code_challenge_method
                   :client-name           client-name})]
  ...)

Note: The state param from the client becomes client-state in storage.

Step 2: Remove old atom-based storage functions

Delete these functions from http.clj:

Keep state-lifetime-seconds constant if used elsewhere, or move to flow.clj.

Step 3: Verify all tests pass

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

Expected: All PASS

Step 4: Commit

git add src/com/getorcha/link/oauth/http.clj
git commit -m "Switch Link OAuth authorize to database-backed flow storage"

Task 7: Add Integration Test for Full Flow

Files:

Step 1: Write full flow integration test with mocked IdP

Add to http_test.clj:

(deftest full-oauth-flow-test
  (testing "complete OAuth flow with mocked Google response"
    ;; Create a test identity
    (let [identity-id (random-uuid)]
      (db.sql/execute-one! fixtures/*db*
        {:insert-into :identity
         :values [{:id           identity-id
                   :email        "test@example.com"
                   :display-name "Test User"}]})

      ;; Create tenant and membership for the identity
      (let [tenant-id (random-uuid)]
        (db.sql/execute-one! fixtures/*db*
          {:insert-into :tenant
           :values [{:id tenant-id :name "Test Tenant"}]})
        (db.sql/execute-one! fixtures/*db*
          {:insert-into :tenant-membership
           :values [{:tenant-id tenant-id :identity-id identity-id :role "admin"}]}))

      ;; Store a pending flow
      (let [state-key (flow/store-flow! fixtures/*db*
                        {:client-id             "test-client"
                         :redirect-uri          "https://example.com/callback"
                         :scope                 "mcp:read"
                         :client-state          "client-state-123"
                         :code-challenge        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
                         :code-challenge-method "S256"})]

        ;; Mock Google token exchange and validation
        (with-redefs [google/exchange-code!
                      (fn [_ _ _]
                        {:access_token "mock-access"
                         :id_token     "mock-id-token"
                         :expires_in   3600})

                      google/validate-id-token!
                      (fn [_ _]
                        {:sub   "google-user-123"
                         :email "test@example.com"
                         :name  "Test User"})]

          ;; Simulate Google callback with code
          (let [response (link-request
                           {:method           :get
                            :route            (str "/oauth/callback/google?code=mock-code&state=" state-key)
                            :follow-redirects false})]
            (is (= 302 (:status response)))
            (let [location (get-in response [:headers "location"])]
              (is (str/starts-with? location "https://example.com/callback?"))
              (is (str/includes? location "code="))
              (is (str/includes? location "state=client-state-123")))))))))

Add requires:

[com.getorcha.oauth.providers.google :as google]

Step 2: Run test

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

Expected: PASS

Step 3: Add test for unregistered user error

(deftest unregistered-user-error-test
  (testing "returns access_denied for unregistered email"
    (let [state-key (flow/store-flow! fixtures/*db*
                      {:client-id             "test-client"
                       :redirect-uri          "https://example.com/callback"
                       :scope                 "mcp:read"
                       :client-state          "client-xyz"
                       :code-challenge        "abc123"
                       :code-challenge-method "S256"})]

      (with-redefs [google/exchange-code!
                    (fn [_ _ _]
                      {:access_token "mock"
                       :id_token     "mock"})

                    google/validate-id-token!
                    (fn [_ _]
                      {:sub   "unknown"
                       :email "nobody@example.com"})]

        (let [response (link-request
                         {:method           :get
                          :route            (str "/oauth/callback/google?code=x&state=" state-key)
                          :follow-redirects false})
              location (get-in response [:headers "location"])]
          (is (= 302 (:status response)))
          (is (str/includes? location "error=access_denied"))
          (is (str/includes? location "user_not_registered")))))))

Step 4: Run all tests

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

Expected: All PASS

Step 5: Commit

git add test/com/getorcha/link/oauth/http_test.clj
git commit -m "Add integration tests for Link OAuth IdP flow"

Task 8: Final Cleanup and Documentation

Files:

Step 1: Update http.clj init-key to pass new config

In src/com/getorcha/link/http.clj, ensure the handler receives auth-providers:

(defmethod ig/init-key ::handler
  [_ {:keys [base-url db-pool aws key-arn auth-providers] :as config}]
  ...)

And inject into routes middleware.

Step 2: Run full test suite

Run: clj -X:test:silent 2>&1 | grep -E "(FAIL|ERROR|Ran)"

Expected: All tests pass

Step 3: Final commit

git add -A
git commit -m "Complete Link OAuth IdP integration"

External Setup Checklist

After implementation, manually complete:

  1. Google Cloud Console:

  2. Azure AD App Registration:

  3. Test end-to-end: