this repo has no description

feat: support direct authorization in handleOAuthAuthorizeGet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+96 -26
src
+96 -26
src/pds.js
··· 3903 3903 3904 3904 /** 3905 3905 * Handle GET /oauth/authorize - displays the consent UI. 3906 - * Validates the request_uri from PAR and renders a login/consent form. 3906 + * Supports both PAR (request_uri) and direct authorization parameters. 3907 3907 * @param {URL} url - Parsed request URL 3908 3908 * @returns {Promise<Response>} HTML consent page 3909 3909 */ 3910 3910 async handleOAuthAuthorizeGet(url) { 3911 + // Opportunistically clean up expired authorization requests 3912 + this.cleanupExpiredAuthorizationRequests(); 3913 + 3911 3914 const requestUri = url.searchParams.get('request_uri'); 3912 3915 const clientId = url.searchParams.get('client_id'); 3913 3916 3914 - if (!requestUri || !clientId) { 3915 - return new Response('Missing parameters', { status: 400 }); 3917 + // If request_uri is present, use PAR flow 3918 + if (requestUri) { 3919 + if (!clientId) { 3920 + return new Response('Missing client_id parameter', { status: 400 }); 3921 + } 3922 + 3923 + const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3924 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 3925 + 3926 + const rows = this.sql 3927 + .exec( 3928 + `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3929 + match[1], 3930 + clientId, 3931 + ) 3932 + .toArray(); 3933 + const authRequest = rows[0]; 3934 + 3935 + if (!authRequest) return new Response('Request not found', { status: 400 }); 3936 + if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3937 + return new Response('Request expired', { status: 400 }); 3938 + if (authRequest.code) 3939 + return new Response('Request already used', { status: 400 }); 3940 + 3941 + const clientMetadata = JSON.parse( 3942 + /** @type {string} */ (authRequest.client_metadata), 3943 + ); 3944 + const parameters = JSON.parse( 3945 + /** @type {string} */ (authRequest.parameters), 3946 + ); 3947 + 3948 + return new Response( 3949 + renderConsentPage({ 3950 + clientName: clientMetadata.client_name || clientId, 3951 + clientId: clientId || '', 3952 + scope: parameters.scope || 'atproto', 3953 + requestUri: requestUri || '', 3954 + }), 3955 + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3956 + ); 3916 3957 } 3917 3958 3918 - const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3919 - if (!match) return new Response('Invalid request_uri', { status: 400 }); 3959 + // Direct authorization flow - create request on-the-fly 3960 + if (!clientId) { 3961 + return new Response('Missing client_id parameter', { status: 400 }); 3962 + } 3920 3963 3921 - const rows = this.sql 3922 - .exec( 3923 - `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3924 - match[1], 3925 - clientId, 3926 - ) 3927 - .toArray(); 3928 - const authRequest = rows[0]; 3964 + const redirectUri = url.searchParams.get('redirect_uri'); 3965 + const responseType = url.searchParams.get('response_type'); 3966 + const responseMode = url.searchParams.get('response_mode'); 3967 + const scope = url.searchParams.get('scope'); 3968 + const state = url.searchParams.get('state'); 3969 + const codeChallenge = url.searchParams.get('code_challenge'); 3970 + const codeChallengeMethod = url.searchParams.get('code_challenge_method'); 3971 + const loginHint = url.searchParams.get('login_hint'); 3929 3972 3930 - if (!authRequest) return new Response('Request not found', { status: 400 }); 3931 - if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3932 - return new Response('Request expired', { status: 400 }); 3933 - if (authRequest.code) 3934 - return new Response('Request already used', { status: 400 }); 3973 + // Validate parameters using shared helper 3974 + const validationResult = await this.validateAuthorizationParameters({ 3975 + clientId, 3976 + redirectUri, 3977 + responseType, 3978 + codeChallenge, 3979 + codeChallengeMethod, 3980 + }); 3981 + if ('error' in validationResult) return validationResult.error; 3982 + const { clientMetadata } = validationResult; 3983 + 3984 + // Create authorization request record (same as PAR but without DPoP) 3985 + const requestId = crypto.randomUUID(); 3986 + const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 3987 + const expiresIn = 600; 3988 + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 3935 3989 3936 - const clientMetadata = JSON.parse( 3937 - /** @type {string} */ (authRequest.client_metadata), 3938 - ); 3939 - const parameters = JSON.parse( 3940 - /** @type {string} */ (authRequest.parameters), 3990 + this.sql.exec( 3991 + `INSERT INTO authorization_requests ( 3992 + id, client_id, client_metadata, parameters, 3993 + code_challenge, code_challenge_method, dpop_jkt, 3994 + expires_at, created_at 3995 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 3996 + requestId, 3997 + clientId, 3998 + JSON.stringify(clientMetadata), 3999 + JSON.stringify({ 4000 + redirect_uri: redirectUri, 4001 + scope, 4002 + state, 4003 + response_mode: responseMode, 4004 + login_hint: loginHint, 4005 + }), 4006 + codeChallenge, 4007 + codeChallengeMethod, 4008 + null, // No DPoP for direct authorization - will be bound at token exchange 4009 + expiresAt, 4010 + new Date().toISOString(), 3941 4011 ); 3942 4012 3943 4013 return new Response( 3944 4014 renderConsentPage({ 3945 4015 clientName: clientMetadata.client_name || clientId, 3946 - clientId: clientId || '', 3947 - scope: parameters.scope || 'atproto', 3948 - requestUri: requestUri || '', 4016 + clientId: clientId, 4017 + scope: scope || 'atproto', 4018 + requestUri: newRequestUri, 3949 4019 }), 3950 4020 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3951 4021 );