From 723d6ae6e2d93da7f0908dd5878eec63a92ff1f9 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Wed, 4 Feb 2026 18:58:31 -0600 Subject: [PATCH] feat: add login page to OIDC authorize flow The OIDC authorize endpoint now shows a login page asking for the user's AT Protocol handle before redirecting to their PDS for OAuth. This fixes the "Handle is required" error when starting OIDC auth. --- gateway/src/routes/oidc/authorize.ts | 265 +++++++++++++++++++++++---- 1 file changed, 228 insertions(+), 37 deletions(-) diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 7aacb10..ca9ccb2 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -82,7 +82,10 @@ export function createAuthorizeRouter( } // Validate redirect_uri + console.log(`[OIDC] Validating redirect_uri: "${redirect_uri}"`); + console.log(`[OIDC] Allowed redirect_uris: ${JSON.stringify(client.redirect_uris)}`); if (!client.redirect_uris.includes(redirect_uri)) { + console.log(`[OIDC] redirect_uri mismatch!`); return res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri', @@ -128,49 +131,187 @@ export function createAuthorizeRouter( expires_at: Math.floor(Date.now() / 1000) + 600, // 10 minutes }; - // Store state for callback - // We use the AT Protocol OAuth flow as the identity provider - // Generate AT Protocol OAuth URL - const atprotoAuth = await oauthService.generateAuthUrl( + // Store the OIDC authorization request and show login page + // We need the user's handle before we can start AT Protocol OAuth + + // Save the pending authorization request + db.saveAuthorizationCode({ + code: authCode, client_id, - '', // No handle yet - will be filled by user - `${oidcService.issuer}/oauth/callback` - ); + redirect_uri, + scope: scopeValidation.scopes.join(' '), + nonce, + code_challenge, + code_challenge_method: code_challenge_method as 'S256' | 'plain' | undefined, + did: '', // Will be filled on callback + handle: '', // Will be filled on callback + created_at: authState.created_at, + expires_at: authState.expires_at, + used: false, + }); - if (!atprotoAuth) { - return redirectWithError(res, redirect_uri, state, 'server_error', 'Failed to generate auth URL'); - } + // Show login page asking for handle + const loginPageHtml = ` + + + + + + Sign in - ATAuth + + + +
+ +

Sign in with ATAuth

+

Use your Bluesky or AT Protocol identity

- // Store our OIDC state in the database, keyed by AT Protocol state - const atprotoState = atprotoAuth.state; - if (atprotoState) { - db.saveAuthorizationCode({ - code: authCode, - client_id, - redirect_uri, - scope: scopeValidation.scopes.join(' '), - nonce, - code_challenge, - code_challenge_method: code_challenge_method as 'S256' | 'plain' | undefined, - did: '', // Will be filled on callback - handle: '', // Will be filled on callback - created_at: authState.created_at, - expires_at: authState.expires_at, - used: false, - }); +
+ Signing in to ${client.name || client_id} +
- // Store mapping from AT Protocol state to our auth code - db.saveOAuthState({ - state: atprotoState, - code_verifier: authCode, // Reuse this field to store our auth code - app_id: client_id, - redirect_uri, - created_at: authState.created_at, - }); +
+ +
+ + + + + +

Enter your Bluesky handle or custom domain

+ + +
+
+ + + +`; - // Redirect user to AT Protocol OAuth - res.redirect(atprotoAuth.url); + res.type('html').send(loginPageHtml); } catch (error) { console.error('[OIDC Authorize] Error:', error); res.status(500).json({ @@ -180,6 +321,56 @@ export function createAuthorizeRouter( } }); + /** + * POST /oauth/authorize/login + * Handle the login form submission with user's handle + */ + router.post('/authorize/login', async (req: Request, res: Response) => { + try { + const { auth_code, handle, state } = req.body; + + if (!auth_code || !handle) { + return res.status(400).send('Missing required parameters'); + } + + // Get the pending authorization + const authData = db.getAuthorizationCode(auth_code); + if (!authData) { + return res.status(400).send('Authorization request expired or invalid'); + } + + if (authData.used) { + return res.status(400).send('Authorization code already used'); + } + + // Start AT Protocol OAuth with the user's handle + const atprotoAuth = await oauthService.generateAuthUrl( + authData.client_id, + handle.trim(), + `${oidcService.issuer}/oauth/callback` + ); + + if (!atprotoAuth) { + return res.status(500).send('Failed to start authentication'); + } + + // Store mapping from AT Protocol state to our auth code + db.saveOAuthState({ + state: atprotoAuth.state, + code_verifier: auth_code, // Store our auth code here for the callback + app_id: authData.client_id, + redirect_uri: authData.redirect_uri, + created_at: Math.floor(Date.now() / 1000), + }); + + // Redirect to AT Protocol OAuth + res.redirect(atprotoAuth.url); + } catch (error) { + console.error('[OIDC Login] Error:', error); + res.status(500).send('Authentication failed: ' + (error instanceof Error ? error.message : 'Unknown error')); + } + }); + return router; } -- knotmirror 0.0.1