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:
- Extracts shared validation -
validateAuthorizationParameters()is used by both PAR and direct auth - Creates request records on-the-fly - Direct auth creates the same DB record as PAR, just without DPoP binding
- Defers DPoP binding - For direct auth, DPoP is bound at token exchange time instead of request time
- Updates metadata - Sets
require_pushed_authorization_requests: falseto signal clients that PAR is optional - Maintains backwards compatibility - PAR flow continues to work exactly as before