A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

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

    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:

    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

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

  /**
   * 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

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:

  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

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:

  /**
   * 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

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:

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

With:

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

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:

      require_pushed_authorization_requests: true,

To:

      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:

      assert.strictEqual(data.require_pushed_authorization_requests, true);

To:

      assert.strictEqual(data.require_pushed_authorization_requests, false);

Step 3: Run tests

Run: npm test

Expected: All tests PASS

Step 4: Commit

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)

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