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
Files:
resources/migrations/YYYYMMDDHHMMSS-add-oauth-pending-flow.up.sqlresources/migrations/YYYYMMDDHHMMSS-add-oauth-pending-flow.down.sqlStep 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"
Files:
resources/com/getorcha/config.ednStep 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"
Files:
src/com/getorcha/link/oauth/flow.cljtest/com/getorcha/link/oauth/flow_test.cljStep 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"
Files:
src/com/getorcha/oauth/providers/google.cljsrc/com/getorcha/oauth/providers/microsoft.cljStep 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"
Files:
src/com/getorcha/link/oauth/http.cljtest/com/getorcha/link/oauth/http_test.cljStep 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"
Files:
src/com/getorcha/link/oauth/http.cljStep 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:
pending-flows atomgenerate-state-keystore-flow!consume-flow!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"
Files:
test/com/getorcha/link/oauth/http_test.cljStep 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"
Files:
src/com/getorcha/link/oauth/http.cljsrc/com/getorcha/link/http.cljStep 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"
After implementation, manually complete:
Google Cloud Console:
https://link.getorcha.com/oauth/callback/google to authorized redirect URIsAzure AD App Registration:
https://link.getorcha.com/oauth/callback/microsoft to redirect URIsTest end-to-end:
https://link.getorcha.com/mcp