~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
633 lines · 19.8 KB Raw History
# Direct Authorization Support Implementation Plan

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

**Goal:** Support direct OAuth authorization requests (without PAR) to match the official AT Protocol PDS behavior.

**Architecture:** When `/oauth/authorize` receives direct parameters instead of a `request_uri`, create an authorization request record on-the-fly (same as PAR does internally), then render the consent page. The token endpoint will bind DPoP at exchange time for direct auth flows.

**Tech Stack:** JavaScript, Cloudflare Workers, SQLite

---

## Task 1: Add Tests for Direct Authorization

**Files:**
- Modify: `test/e2e.test.js`

**Step 1: Write failing test for direct authorization GET**

Add this test in the `OAuth endpoints` describe block (after existing OAuth tests around line 1452):

```javascript
    it('supports direct authorization without PAR', async () => {
      const clientId = `http://localhost:${mockClientPort}/client-metadata.json`;
      const redirectUri = `http://localhost:${mockClientPort}/callback`;
      const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!';
      const codeChallenge = await generateCodeChallenge(codeVerifier);
      const state = 'test-direct-auth-state';

      // Step 1: GET authorize with direct parameters (no PAR)
      const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
      authorizeUrl.searchParams.set('client_id', clientId);
      authorizeUrl.searchParams.set('redirect_uri', redirectUri);
      authorizeUrl.searchParams.set('response_type', 'code');
      authorizeUrl.searchParams.set('scope', 'atproto');
      authorizeUrl.searchParams.set('code_challenge', codeChallenge);
      authorizeUrl.searchParams.set('code_challenge_method', 'S256');
      authorizeUrl.searchParams.set('state', state);
      authorizeUrl.searchParams.set('login_hint', DID);

      const getRes = await fetch(authorizeUrl.toString());
      assert.strictEqual(getRes.status, 200, 'Direct authorize GET should succeed');

      const html = await getRes.text();
      assert.ok(html.includes('Authorize'), 'Should show consent page');
      assert.ok(html.includes('request_uri'), 'Should include request_uri in form');
    });
```

**Step 2: Run test to verify it fails**

Run: `npm test -- --grep "supports direct authorization"`

Expected: FAIL with "Direct authorize GET should succeed" - status will be 400 "Missing parameters"

**Step 3: Add test for full direct auth flow**

Add after the previous test:

```javascript
    it('completes full direct authorization flow', async () => {
      const clientId = `http://localhost:${mockClientPort}/client-metadata.json`;
      const redirectUri = `http://localhost:${mockClientPort}/callback`;
      const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!';
      const codeChallenge = await generateCodeChallenge(codeVerifier);
      const state = 'test-direct-auth-state';

      // Step 1: GET authorize with direct parameters
      const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
      authorizeUrl.searchParams.set('client_id', clientId);
      authorizeUrl.searchParams.set('redirect_uri', redirectUri);
      authorizeUrl.searchParams.set('response_type', 'code');
      authorizeUrl.searchParams.set('scope', 'atproto');
      authorizeUrl.searchParams.set('code_challenge', codeChallenge);
      authorizeUrl.searchParams.set('code_challenge_method', 'S256');
      authorizeUrl.searchParams.set('state', state);
      authorizeUrl.searchParams.set('login_hint', DID);

      const getRes = await fetch(authorizeUrl.toString());
      assert.strictEqual(getRes.status, 200);
      const html = await getRes.text();

      // Extract request_uri from the form
      const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/);
      assert.ok(requestUriMatch, 'Should have request_uri in form');
      const requestUri = requestUriMatch[1];

      // Step 2: POST to authorize (user approval)
      const authRes = await fetch(`${BASE}/oauth/authorize`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          request_uri: requestUri,
          client_id: clientId,
          password: PASSWORD,
        }).toString(),
        redirect: 'manual',
      });

      assert.strictEqual(authRes.status, 302, 'Should redirect after approval');
      const location = authRes.headers.get('location');
      assert.ok(location, 'Should have Location header');
      const locationUrl = new URL(location);
      const code = locationUrl.searchParams.get('code');
      assert.ok(code, 'Should have authorization code');
      assert.strictEqual(locationUrl.searchParams.get('state'), state);

      // Step 3: Exchange code for tokens
      const { privateKey: dpopPrivateKey, publicJwk: dpopPublicJwk } =
        await generateDpopKeyPair();
      const dpopProof = await createDpopProof(
        dpopPrivateKey,
        dpopPublicJwk,
        'POST',
        `${BASE}/oauth/token`,
      );

      const tokenRes = await fetch(`${BASE}/oauth/token`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          DPoP: dpopProof,
        },
        body: new URLSearchParams({
          grant_type: 'authorization_code',
          code,
          redirect_uri: redirectUri,
          client_id: clientId,
          code_verifier: codeVerifier,
        }).toString(),
      });

      assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed');
      const tokenData = await tokenRes.json();
      assert.ok(tokenData.access_token, 'Should have access_token');
      assert.strictEqual(tokenData.token_type, 'DPoP');
    });
```

**Step 4: Run tests to verify they fail**

Run: `npm test -- --grep "direct authorization"`

Expected: Both tests FAIL

**Step 5: Commit test file**

```bash
git add test/e2e.test.js
git commit -m "test: add failing tests for direct OAuth authorization flow"
```

---

## Task 2: Extract Shared Validation Logic

**Files:**
- Modify: `src/pds.js:3737-3845` (handleOAuthPar method)

**Step 1: Create validateAuthorizationParameters helper**

Add this new method to the PersonalDataServer class, before `handleOAuthPar` (around line 3730):

```javascript
  /**
   * Validate OAuth authorization request parameters.
   * Shared between PAR and direct authorization flows.
   * @param {Object} params - The authorization parameters
   * @param {string} params.clientId - The client_id
   * @param {string} params.redirectUri - The redirect_uri
   * @param {string} params.responseType - The response_type
   * @param {string} [params.responseMode] - The response_mode
   * @param {string} [params.scope] - The scope
   * @param {string} [params.state] - The state
   * @param {string} params.codeChallenge - The code_challenge
   * @param {string} params.codeChallengeMethod - The code_challenge_method
   * @param {string} [params.loginHint] - The login_hint
   * @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>}
   */
  async validateAuthorizationParameters({
    clientId,
    redirectUri,
    responseType,
    codeChallenge,
    codeChallengeMethod,
  }) {
    if (!clientId) {
      return { error: errorResponse('invalid_request', 'client_id required', 400) };
    }
    if (!redirectUri) {
      return { error: errorResponse('invalid_request', 'redirect_uri required', 400) };
    }
    if (responseType !== 'code') {
      return {
        error: errorResponse(
          'unsupported_response_type',
          'response_type must be code',
          400,
        ),
      };
    }
    if (!codeChallenge || codeChallengeMethod !== 'S256') {
      return { error: errorResponse('invalid_request', 'PKCE with S256 required', 400) };
    }

    let clientMetadata;
    try {
      clientMetadata = await getClientMetadata(clientId);
    } catch (err) {
      return { error: errorResponse('invalid_client', err.message, 400) };
    }

    // Validate redirect_uri against registered URIs
    const isLoopback =
      clientId.startsWith('http://localhost') ||
      clientId.startsWith('http://127.0.0.1');
    const redirectUriValid = clientMetadata.redirect_uris.some((uri) => {
      if (isLoopback) {
        try {
          const registered = new URL(uri);
          const requested = new URL(redirectUri);
          return registered.origin === requested.origin;
        } catch {
          return false;
        }
      }
      return uri === redirectUri;
    });
    if (!redirectUriValid) {
      return {
        error: errorResponse(
          'invalid_request',
          'redirect_uri not registered for this client',
          400,
        ),
      };
    }

    return { clientMetadata };
  }
```

**Step 2: Run existing tests to verify nothing broke**

Run: `npm test`

Expected: All existing tests PASS (new method not called yet)

**Step 3: Commit**

```bash
git add src/pds.js
git commit -m "refactor: extract validateAuthorizationParameters helper"
```

---

## Task 3: Refactor handleOAuthPar to Use Shared Validation

**Files:**
- Modify: `src/pds.js:3737-3845` (handleOAuthPar method)

**Step 1: Update handleOAuthPar to use the new helper**

Replace the validation section in `handleOAuthPar` (lines ~3760-3815) with:

```javascript
  async handleOAuthPar(request, url) {
    // Opportunistically clean up expired authorization requests
    this.cleanupExpiredAuthorizationRequests();

    const issuer = `${url.protocol}//${url.host}`;

    const dpopResult = await this.validateRequiredDpop(
      request,
      'POST',
      `${issuer}/oauth/par`,
    );
    if ('error' in dpopResult) return dpopResult.error;
    const { dpop } = dpopResult;

    // Parse body - support both JSON and form-encoded
    /** @type {Record<string, string|undefined>} */
    let data;
    try {
      data = await parseRequestBody(request);
    } catch {
      return errorResponse('invalid_request', 'Invalid JSON body', 400);
    }

    const clientId = data.client_id;
    const redirectUri = data.redirect_uri;
    const responseType = data.response_type;
    const responseMode = data.response_mode;
    const scope = data.scope;
    const state = data.state;
    const codeChallenge = data.code_challenge;
    const codeChallengeMethod = data.code_challenge_method;
    const loginHint = data.login_hint;

    // Use shared validation
    const validationResult = await this.validateAuthorizationParameters({
      clientId,
      redirectUri,
      responseType,
      codeChallenge,
      codeChallengeMethod,
    });
    if ('error' in validationResult) return validationResult.error;
    const { clientMetadata } = validationResult;

    const requestId = crypto.randomUUID();
    const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
    const expiresIn = 600;
    const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();

    this.sql.exec(
      `INSERT INTO authorization_requests (
        id, client_id, client_metadata, parameters,
        code_challenge, code_challenge_method, dpop_jkt,
        expires_at, created_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      requestId,
      clientId,
      JSON.stringify(clientMetadata),
      JSON.stringify({
        redirect_uri: redirectUri,
        scope,
        state,
        response_mode: responseMode,
        login_hint: loginHint,
      }),
      codeChallenge,
      codeChallengeMethod,
      dpop.jkt,
      expiresAt,
      new Date().toISOString(),
    );

    return Response.json({ request_uri: requestUri, expires_in: expiresIn });
  }
```

**Step 2: Run all OAuth tests to verify PAR still works**

Run: `npm test -- --grep OAuth`

Expected: All existing OAuth tests PASS

**Step 3: Commit**

```bash
git add src/pds.js
git commit -m "refactor: use validateAuthorizationParameters in handleOAuthPar"
```

---

## Task 4: Implement Direct Authorization in handleOAuthAuthorizeGet

**Files:**
- Modify: `src/pds.js:3869-3911` (handleOAuthAuthorizeGet method)

**Step 1: Update handleOAuthAuthorizeGet to handle direct parameters**

Replace the entire `handleOAuthAuthorizeGet` method:

```javascript
  /**
   * Handle GET /oauth/authorize - displays the consent UI.
   * Supports both PAR (request_uri) and direct authorization parameters.
   * @param {URL} url - Parsed request URL
   * @returns {Promise<Response>} HTML consent page
   */
  async handleOAuthAuthorizeGet(url) {
    // Opportunistically clean up expired authorization requests
    this.cleanupExpiredAuthorizationRequests();

    const requestUri = url.searchParams.get('request_uri');
    const clientId = url.searchParams.get('client_id');

    // If request_uri is present, use PAR flow
    if (requestUri) {
      if (!clientId) {
        return new Response('Missing client_id parameter', { status: 400 });
      }

      const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/);
      if (!match) return new Response('Invalid request_uri', { status: 400 });

      const rows = this.sql
        .exec(
          `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`,
          match[1],
          clientId,
        )
        .toArray();
      const authRequest = rows[0];

      if (!authRequest) return new Response('Request not found', { status: 400 });
      if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date())
        return new Response('Request expired', { status: 400 });
      if (authRequest.code)
        return new Response('Request already used', { status: 400 });

      const clientMetadata = JSON.parse(
        /** @type {string} */ (authRequest.client_metadata),
      );
      const parameters = JSON.parse(
        /** @type {string} */ (authRequest.parameters),
      );

      return new Response(
        renderConsentPage({
          clientName: clientMetadata.client_name || clientId,
          clientId: clientId || '',
          scope: parameters.scope || 'atproto',
          requestUri: requestUri || '',
        }),
        { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
      );
    }

    // Direct authorization flow - create request on-the-fly
    if (!clientId) {
      return new Response('Missing client_id parameter', { status: 400 });
    }

    const redirectUri = url.searchParams.get('redirect_uri');
    const responseType = url.searchParams.get('response_type');
    const responseMode = url.searchParams.get('response_mode');
    const scope = url.searchParams.get('scope');
    const state = url.searchParams.get('state');
    const codeChallenge = url.searchParams.get('code_challenge');
    const codeChallengeMethod = url.searchParams.get('code_challenge_method');
    const loginHint = url.searchParams.get('login_hint');

    // Validate parameters using shared helper
    const validationResult = await this.validateAuthorizationParameters({
      clientId,
      redirectUri,
      responseType,
      codeChallenge,
      codeChallengeMethod,
    });
    if ('error' in validationResult) return validationResult.error;
    const { clientMetadata } = validationResult;

    // Create authorization request record (same as PAR but without DPoP)
    const requestId = crypto.randomUUID();
    const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
    const expiresIn = 600;
    const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();

    this.sql.exec(
      `INSERT INTO authorization_requests (
        id, client_id, client_metadata, parameters,
        code_challenge, code_challenge_method, dpop_jkt,
        expires_at, created_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      requestId,
      clientId,
      JSON.stringify(clientMetadata),
      JSON.stringify({
        redirect_uri: redirectUri,
        scope,
        state,
        response_mode: responseMode,
        login_hint: loginHint,
      }),
      codeChallenge,
      codeChallengeMethod,
      null, // No DPoP for direct authorization - will be bound at token exchange
      expiresAt,
      new Date().toISOString(),
    );

    return new Response(
      renderConsentPage({
        clientName: clientMetadata.client_name || clientId,
        clientId: clientId,
        scope: scope || 'atproto',
        requestUri: newRequestUri,
      }),
      { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
    );
  }
```

**Step 2: Run the first direct auth test**

Run: `npm test -- --grep "supports direct authorization without PAR"`

Expected: PASS

**Step 3: Commit**

```bash
git add src/pds.js
git commit -m "feat: support direct authorization in handleOAuthAuthorizeGet"
```

---

## Task 5: Update Token Endpoint for Null DPoP Binding

**Files:**
- Modify: `src/pds.js:4097-4098` (handleAuthCodeGrant method)

**Step 1: Update DPoP validation to handle null dpop_jkt**

Find the DPoP check in `handleAuthCodeGrant` (around line 4097) and replace:

```javascript
    if (authRequest.dpop_jkt !== dpop.jkt)
      return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400);
```

With:

```javascript
    // For PAR flow, dpop_jkt is set at PAR time and must match
    // For direct authorization, dpop_jkt is null and we bind to the token request's DPoP
    if (authRequest.dpop_jkt !== null && authRequest.dpop_jkt !== dpop.jkt) {
      return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400);
    }
```

**Step 2: Run full direct auth flow test**

Run: `npm test -- --grep "completes full direct authorization flow"`

Expected: PASS

**Step 3: Run all OAuth tests to verify nothing broke**

Run: `npm test -- --grep OAuth`

Expected: All OAuth tests PASS

**Step 4: Commit**

```bash
git add src/pds.js
git commit -m "feat: allow null dpop_jkt binding for direct authorization"
```

---

## Task 6: Update AS Metadata

**Files:**
- Modify: `src/pds.js:3695` (handleOAuthAuthServerMetadata method)

**Step 1: Change require_pushed_authorization_requests to false**

Find line 3695 and change:

```javascript
      require_pushed_authorization_requests: true,
```

To:

```javascript
      require_pushed_authorization_requests: false,
```

**Step 2: Update the e2e test expectation**

Find the AS metadata test in `test/e2e.test.js` (around line 541) and change:

```javascript
      assert.strictEqual(data.require_pushed_authorization_requests, true);
```

To:

```javascript
      assert.strictEqual(data.require_pushed_authorization_requests, false);
```

**Step 3: Run tests**

Run: `npm test`

Expected: All tests PASS

**Step 4: Commit**

```bash
git add src/pds.js test/e2e.test.js
git commit -m "feat: set require_pushed_authorization_requests to false"
```

---

## Task 7: Final Verification

**Step 1: Run all tests**

Run: `npm test`

Expected: All tests PASS

**Step 2: Manual verification with the original URL**

Test that the original failing URL now works by deploying to your worker and visiting:

```
https://chad-pds.chad-53c.workers.dev/oauth/authorize?client_id=https%3A%2F%2Fquickslice-production-9cf4.up.railway.app%2Foauth-client-metadata.json&redirect_uri=https%3A%2F%2Fquickslice-production-9cf4.up.railway.app%2Foauth%2Fatp%2Fcallback&response_type=code&code_challenge=v9w-ACgE-QauiZkLpSDeZTjgGDmGdVHbegFe18dkQSw&code_challenge_method=S256&state=QkxYNYrf73X0rLaU6XBUyg&scope=atproto%20...&login_hint=did%3Aplc%3Ac6vxslynzebnlk5kw2orx37o
```

Expected: Should show consent page instead of "Missing parameters" error

**Step 3: Final commit (if any cleanup needed)**

```bash
git add -A
git commit -m "chore: cleanup after direct authorization implementation"
```

---

## Summary

This implementation:

1. **Extracts shared validation** - `validateAuthorizationParameters()` is used by both PAR and direct auth
2. **Creates request records on-the-fly** - Direct auth creates the same DB record as PAR, just without DPoP binding
3. **Defers DPoP binding** - For direct auth, DPoP is bound at token exchange time instead of request time
4. **Updates metadata** - Sets `require_pushed_authorization_requests: false` to signal clients that PAR is optional
5. **Maintains backwards compatibility** - PAR flow continues to work exactly as before