Link OAuth IdP Integration Design

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

Context

The Link service has OAuth 2.1 endpoints for MCP client authentication, but the IdP callback handlers (/oauth/callback/google, /oauth/callback/microsoft) are stubs returning 501 Not Implemented. Users see provider selection buttons but clicking them fails.

Decisions

Decision Choice Rationale
Flow state storage PostgreSQL table Survives restarts, multi-instance safe, debuggable
Cleanup strategy Hybrid lazy cleanup Expiry check on read + periodic delete of expired rows
Callback pattern Single endpoint per provider Detect IdP return by presence of code param
Credentials Reuse existing SSM params Same Google/Microsoft OAuth apps as ERP
Identity model Must exist in identity table Same users and permissions as ERP

Flow

MCP Client                    Link Service                  Google/Microsoft
    │                              │                              │
    │ GET /oauth/authorize         │                              │
    │─────────────────────────────>│                              │
    │                              │ INSERT oauth_pending_flow    │
    │                              │                              │
    │ HTML: provider selection     │                              │
    │<─────────────────────────────│                              │
    │                              │                              │
    │ GET /oauth/callback/google?state=...                        │
    │─────────────────────────────>│                              │
    │                              │ 302 to Google                │
    │                              │─────────────────────────────>│
    │                              │                              │
    │                              │    User authenticates        │
    │                              │                              │
    │                              │ 302 ?code=...&state=...      │
    │                              │<─────────────────────────────│
    │                              │                              │
    │                              │ Validate ID token            │
    │                              │ Lookup identity by email     │
    │                              │ Issue authorization code     │
    │                              │ DELETE oauth_pending_flow    │
    │                              │                              │
    │ 302 redirect_uri?code=...    │                              │
    │<─────────────────────────────│                              │
    │                              │                              │
    │ POST /oauth/token            │                              │
    │─────────────────────────────>│                              │
    │                              │                              │
    │ {access_token: ...}          │                              │
    │<─────────────────────────────│                              │

Database Schema

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

Configuration

;; Top-level shared provider credentials
: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"}}

;; ERP - merge provider credentials into :auth :microsoft
:com.getorcha.erp.http/handler
{...
 :auth {:microsoft #merge [#ref [:com.getorcha/auth-providers :microsoft]
                           {:state-secret #orcha/param "/v1-orcha/microsoft-auth-state-secret"}]
        ...}}

;; Link - refs shared 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]}

Handler Logic

Single endpoint per provider, detecting direction by code param:

(defn google-callback-handler [request respond raise]
  (let [{:keys [code state]} (-> request :parameters :query)]
    (if code
      (handle-idp-return ...)    ; Google sent us back
      (handle-idp-redirect ...)))) ; User clicked button

handle-idp-redirect:

  1. Load flow from DB by state_key
  2. Build IdP authorization URL via com.getorcha.oauth.providers.google
  3. Redirect to IdP

handle-idp-return:

  1. Exchange code for tokens with IdP
  2. Validate ID token via provider module
  3. Extract email, lookup in identity table
  4. If not found → redirect with error=access_denied
  5. If found → issue authorization code, delete flow, redirect to client

Error Handling

Scenario Response
Invalid/expired state 400 {"error": "invalid_state"}
IdP returns error Redirect: ?error=access_denied&error_description=...
Token exchange fails Redirect: ?error=server_error
ID token validation fails Redirect: ?error=server_error
User not in identity table Redirect: ?error=access_denied&error_description=user_not_registered
DB errors 500, log error

Testing

Integration tests:

External Setup Required

  1. Add https://link.getorcha.com/oauth/callback/google to Google OAuth app redirect URIs
  2. Add https://link.getorcha.com/oauth/callback/microsoft to Azure AD app redirect URIs