/**
* OIDC Authorization Endpoint
*
* Handles /oauth/authorize - the entry point for OIDC authentication
*/
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import type { DatabaseService } from '../../services/database.js';
import type { OIDCService } from '../../services/oidc/index.js';
import type { OAuthService } from '../../services/oauth.js';
import type { PasskeyService } from '../../services/passkey.js';
import { parseScopes, hasOpenIdScope, validateScopes } from '../../services/oidc/claims.js';
import { isValidCodeChallengeMethod } from '../../services/oidc/pkce.js';
import { checkAccess } from '../../utils/access-check.js';
import { notifyLogin, type WebhookConfig } from '../../utils/webhook.js';
export function createAuthorizeRouter(
db: DatabaseService,
oidcService: OIDCService,
oauthService: OAuthService,
passkeyService?: PasskeyService | null,
webhookConfig?: WebhookConfig
): Router {
const router = Router();
function esc(str: string): string {
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderLoginPage(clientName: string, authCode: string, state: string, errorMessage?: string, nonce?: string, passkeyEnabled?: boolean): string {
const errorHtml = errorMessage
? `
${esc(errorMessage)}
`
: '';
return `
Sign in - ATAuth
ATAuth
Sign in
Use your Bluesky or AT Protocol identity
Signing in to ${esc(clientName)}
${errorHtml}
${passkeyEnabled ? `
or
` : ''}
Bluesky will ask to authorize broad access -- this is a limitation of the AT Protocol OAuth standard. ${esc(clientName)} only reads your identity (handle). It will never post, follow, or access your data.
`;
}
/**
* GET/POST /oauth/authorize
* Start the authorization flow
*/
router.all('/authorize', async (req: Request, res: Response) => {
try {
// Extract parameters from query or body
const params = req.method === 'POST' ? req.body : req.query;
const {
response_type,
client_id,
redirect_uri,
scope,
state,
nonce,
code_challenge,
code_challenge_method,
} = params as {
response_type?: string;
client_id?: string;
redirect_uri?: string;
scope?: string;
state?: string;
nonce?: string;
code_challenge?: string;
code_challenge_method?: string;
};
// Validate required parameters
if (!response_type || !client_id || !redirect_uri || !scope || !state) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters: response_type, client_id, redirect_uri, scope, state',
});
}
// Only support authorization code flow
if (response_type !== 'code') {
return res.status(400).json({
error: 'unsupported_response_type',
error_description: 'Only response_type=code is supported',
});
}
// Get client configuration
const client = db.getOIDCClient(client_id);
if (!client) {
return res.status(400).json({
error: 'invalid_client',
error_description: 'Unknown client_id',
});
}
// Verify client is OIDC type
if (client.client_type !== 'oidc') {
return res.status(400).json({
error: 'invalid_client',
error_description: 'Client is not configured for OIDC',
});
}
// 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',
});
}
// Validate scopes
const requestedScopes = parseScopes(scope);
if (!hasOpenIdScope(requestedScopes)) {
return redirectWithError(res, redirect_uri, state, 'invalid_scope', 'openid scope is required');
}
const scopeValidation = validateScopes(requestedScopes, client.allowed_scopes);
if (!scopeValidation.valid) {
return redirectWithError(res, redirect_uri, state, 'invalid_scope', scopeValidation.error || 'Invalid scope');
}
// Check PKCE requirements
if (client.require_pkce && !code_challenge) {
return redirectWithError(res, redirect_uri, state, 'invalid_request', 'PKCE code_challenge is required');
}
// Validate code_challenge_method
if (code_challenge && code_challenge_method && !isValidCodeChallengeMethod(code_challenge_method)) {
return redirectWithError(res, redirect_uri, state, 'invalid_request', 'Invalid code_challenge_method');
}
// Generate authorization code
const authCode = crypto.randomBytes(32).toString('base64url');
// Store the authorization request
// We'll complete this after AT Protocol OAuth returns
const authState = {
code: authCode,
client_id,
redirect_uri,
scope: scopeValidation.scopes.join(' '),
nonce,
code_challenge,
code_challenge_method: code_challenge_method as 'S256' | 'plain' | undefined,
state,
created_at: Math.floor(Date.now() / 1000),
expires_at: Math.floor(Date.now() / 1000) + 600, // 10 minutes
};
// 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,
redirect_uri,
scope: scopeValidation.scopes.join(' '),
state,
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,
});
// Show login page asking for handle
res.type('html').send(renderLoginPage(client.name || client_id, authCode, state || '', undefined, res.locals.cspNonce, !!passkeyService));
} catch (error) {
console.error('[OIDC Authorize] Error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Internal server error',
});
}
});
/**
* 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 } = req.body;
if (!auth_code || !handle) {
return res.status(400).send('Missing required parameters');
}
// Sanitize handle: strip @, fix common typos, add .bsky.social if no domain
let sanitizedHandle = handle.trim().replace(/^@/, '');
// Replace colons with dots (common keyboard typo: "user:bsky.social")
sanitizedHandle = sanitizedHandle.replace(/:/g, '.');
// Strip email-style @domain (e.g. "user@bsky.social" → "user.bsky.social")
sanitizedHandle = sanitizedHandle.replace(/@/, '.');
// If no dots, assume .bsky.social
if (!sanitizedHandle.includes('.')) {
sanitizedHandle = sanitizedHandle + '.bsky.social';
}
// Validate handle format
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(sanitizedHandle)) {
return res.status(400).json({ error: 'invalid_handle', message: 'Invalid handle format' });
}
// 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
// Retry once on transient errors (bsky.social intermittently fails to fetch client metadata)
let atprotoAuth;
try {
atprotoAuth = await oauthService.generateAuthUrl(
authData.client_id,
sanitizedHandle,
`${oidcService.issuer}/oauth/callback`
);
} catch (firstErr) {
const msg = firstErr instanceof Error ? firstErr.message : '';
if (msg.includes('invalid_client_metadata')) {
console.log('[OIDC Login] Retrying after transient client_metadata error...');
await new Promise(r => setTimeout(r, 1000));
atprotoAuth = await oauthService.generateAuthUrl(
authData.client_id,
sanitizedHandle,
`${oidcService.issuer}/oauth/callback`
);
} else {
throw firstErr;
}
}
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),
});
// Return redirect URL — JSON for fetch clients, 302 for native form fallback
const isJsonRequest = req.is('json');
if (isJsonRequest) {
res.json({ redirect_url: atprotoAuth.url });
} else {
res.redirect(atprotoAuth.url);
}
} catch (error) {
console.error('[OIDC Login] Error:', error);
// Provide a user-friendly error for common handle issues
let userMessage = 'Authentication failed. Please try again.';
const errMsg = error instanceof Error ? error.message : '';
if (errMsg.includes('resolve identity') || errMsg.includes('Invalid handle')) {
userMessage = 'Could not find that handle. Enter your full handle (e.g. yourname.bsky.social or your.custom.domain).';
} else if (errMsg.includes('invalid_client_metadata')) {
userMessage = 'Bluesky login is temporarily unavailable. Please wait a moment and try again.';
}
const isJsonRequest = req.is('json');
if (isJsonRequest) {
res.status(400).json({ error: userMessage });
} else {
// Re-render login page with error for native form submissions
const { auth_code: errAuthCode } = req.body;
const errAuthData = errAuthCode ? db.getAuthorizationCode(errAuthCode) : null;
const errClient = errAuthData ? db.getApp(errAuthData.client_id) : null;
const errClientName = errClient?.name || errAuthData?.client_id || 'Unknown';
res.status(400).type('html').send(renderLoginPage(errClientName, errAuthCode || '', errAuthData?.state || '', userMessage, res.locals.cspNonce, !!passkeyService));
}
}
});
/**
* POST /oauth/authorize/passkey
* Authenticate via passkey and complete the OIDC authorization flow
*/
router.post('/authorize/passkey', async (req: Request, res: Response) => {
try {
if (!passkeyService) {
return res.status(404).json({
error: 'not_found',
error_description: 'Passkey authentication is not enabled',
});
}
const { auth_code, credential, challenge } = req.body;
if (!auth_code || !credential || !challenge) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters: auth_code, credential, challenge',
});
}
// Validate the pending authorization code
const authData = db.getAuthorizationCode(auth_code);
if (!authData) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Authorization request expired or invalid',
});
}
if (authData.used) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Authorization code already used',
});
}
// Verify the passkey
const result = await passkeyService.verifyAuthentication(credential, challenge);
if (!result.success || !result.did || !result.handle) {
return res.status(401).json({
error: 'authentication_failed',
error_description: result.error || 'Passkey authentication failed',
});
}
console.log(`[OIDC Passkey] Authenticated user: ${result.handle} (${result.did})`);
// Check client access rules
const oidcClient = db.getOIDCClient(authData.client_id);
if (oidcClient?.require_access_check) {
const rules = db.getClientAccessRulesForCheck(authData.client_id);
const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length;
if (totalRules > 0) {
const accessResult = checkAccess(result.did, result.handle, rules);
if (!accessResult.allowed) {
console.log(`[OIDC ACL] Access denied for ${result.handle} (${result.did}) to ${authData.client_id}: ${accessResult.reason}`);
return res.status(403).json({
error: 'access_denied',
error_description: 'You do not have access to this application',
});
}
}
}
// Update the authorization code with the user's identity
db.updateAuthorizationCodeUser(auth_code, result.did, result.handle);
// Send login notification (fire-and-forget)
if (webhookConfig?.enabled && webhookConfig.loginNotifyClients.includes(authData.client_id)) {
notifyLogin(webhookConfig, {
client_id: authData.client_id,
client_name: oidcClient?.name || authData.client_id,
did: result.did,
handle: result.handle,
}).catch(err => console.error('[Webhook] notification failed:', err.message));
}
// Build the redirect URL back to the original client
const clientRedirectUrl = new URL(authData.redirect_uri);
clientRedirectUrl.searchParams.set('code', auth_code);
if (authData.state) {
clientRedirectUrl.searchParams.set('state', authData.state);
}
res.json({ redirect_url: clientRedirectUrl.toString() });
} catch (error) {
console.error('[OIDC Passkey] Error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Internal server error',
});
}
});
/**
* GET /oauth/callback
* Handle the AT Protocol OAuth callback and complete the OIDC flow
*/
router.get('/callback', async (req: Request, res: Response) => {
try {
const { code, state, error, error_description, iss } = req.query as {
code?: string;
state?: string;
error?: string;
error_description?: string;
iss?: string;
};
console.log(`[OIDC Callback] Received callback with state: ${state}`);
// Handle AT Protocol OAuth errors
if (error) {
console.error(`[OIDC Callback] AT Protocol error: ${error} - ${error_description}`);
// We need to redirect back to the original client with the error
// But we need the state to find the original request
if (state) {
const savedState = db.getOAuthState(state);
if (savedState) {
return redirectWithError(
res,
savedState.redirect_uri,
undefined,
'access_denied',
error_description || 'Authentication failed'
);
}
}
return res.status(400).json({
error: 'access_denied',
error_description: error_description || 'Authentication failed',
});
}
if (!code || !state) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing code or state',
});
}
// Get the saved state
const savedState = db.getOAuthState(state);
if (!savedState) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid or expired state',
});
}
// Exchange the AT Protocol code for tokens
// Build URLSearchParams from the callback query parameters
const callbackParams = new URLSearchParams();
callbackParams.set('code', code);
callbackParams.set('state', state);
if (iss) callbackParams.set('iss', iss);
const callbackResult = await oauthService.handleCallback(
callbackParams,
`${oidcService.issuer}/oauth/callback`
);
if (!callbackResult) {
return res.status(500).json({
error: 'server_error',
error_description: 'Failed to complete authentication',
});
}
// Get the user's DID and handle from the AT Protocol session
const { did, handle, email } = callbackResult;
console.log(`[OIDC Callback] Authenticated user: ${handle} (${did})`);
// Get the OIDC authorization code we saved earlier (stored in code_verifier field)
const oidcAuthCode = savedState.code_verifier;
if (!oidcAuthCode) {
return res.status(500).json({
error: 'server_error',
error_description: 'OIDC authorization state not found',
});
}
// Update the authorization code with the user's identity
const authData = db.getAuthorizationCode(oidcAuthCode);
if (!authData) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'OIDC authorization expired',
});
}
// Update with user info
db.updateAuthorizationCodeUser(oidcAuthCode, did, handle);
// Cache PDS-provided email for userinfo endpoint
db.upsertOIDCUserEmail(did, authData.client_id, handle, email);
// Check client access rules
const oidcClient = db.getOIDCClient(authData.client_id);
if (oidcClient?.require_access_check) {
const rules = db.getClientAccessRulesForCheck(authData.client_id);
const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length;
if (totalRules > 0) {
const accessResult = checkAccess(did, handle, rules);
if (!accessResult.allowed) {
console.log(`[OIDC ACL] Access denied for ${handle} (${did}) to ${authData.client_id}: ${accessResult.reason}`);
db.deleteOAuthState(state);
return res.status(403).type('html').send(renderAccessDeniedPage(oidcClient.name || authData.client_id, res.locals.cspNonce));
}
}
}
// Send login notification (fire-and-forget)
if (webhookConfig?.enabled && webhookConfig.loginNotifyClients.includes(authData.client_id)) {
notifyLogin(webhookConfig, {
client_id: authData.client_id,
client_name: oidcClient?.name || authData.client_id,
did,
handle,
}).catch(err => console.error('[Webhook] notification failed:', err.message));
}
// Build the redirect URL back to the original client
const clientRedirectUrl = new URL(authData.redirect_uri);
clientRedirectUrl.searchParams.set('code', oidcAuthCode);
if (authData.state) {
clientRedirectUrl.searchParams.set('state', authData.state);
}
console.log(`[OIDC Callback] Redirecting to client: ${authData.redirect_uri}`);
// Clean up the AT Protocol state
db.deleteOAuthState(state);
// Redirect back to the OIDC client
res.redirect(clientRedirectUrl.toString());
} catch (error) {
console.error('[OIDC Callback] Error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Internal server error',
});
}
});
return router;
}
/**
* Render access denied page for OIDC clients
*/
function renderAccessDeniedPage(clientName: string, nonce?: string): string {
return `
Not Authorized - ATAuth
Not Authorized
Your account does not have access to ${clientName.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}.
Contact the administrator if you believe this is an error.