Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 224 additions & 2 deletions packages/backend/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
* Authentication Routes
* OAuth authentication with JWT token generation
* Implements stateless JWT-based authentication flow
* Includes Device Authorization Grant (RFC 8628) for CLI
*/

import type { DeviceAuthError } from '@agentage/shared';
import { Request, Response, Router } from 'express';
import passport from 'passport';
import { createJwtAuthMiddleware } from '../../middleware/jwt-auth.middleware';
Expand Down Expand Up @@ -32,12 +34,23 @@ export const getAuthRouter = (serviceProvider: ServiceProvider<AppServiceMap>) =
router.get('/github', async (req: Request, res: Response, next) => {
try {
const logger = await serviceProvider.get('logger');

// Store device code in session state if coming from device flow
const deviceUserCode = req.query.device_code as string | undefined;

logger.info('GitHub OAuth initiated', {
ip: req.ip,
userAgent: req.get('User-Agent'),
isDeviceFlow: !!deviceUserCode,
});

passport.authenticate('github', { scope: ['user:email'] })(req, res, next);
// Pass device code via state parameter
const state = deviceUserCode ? JSON.stringify({ device_code: deviceUserCode }) : undefined;

passport.authenticate('github', {
scope: ['user:email'],
state,
})(req, res, next);
} catch (error) {
next(error);
}
Expand Down Expand Up @@ -68,9 +81,29 @@ export const getAuthRouter = (serviceProvider: ServiceProvider<AppServiceMap>) =
email: req.user.email,
});

// Redirect to frontend with token
// Check if this is from device flow
let deviceUserCode: string | undefined;
try {
const state = req.query.state as string;
if (state) {
const stateData = JSON.parse(state);
deviceUserCode = stateData.device_code;
}
} catch {
// Not a device flow or invalid state
}

const frontendFqdn = config.get('FRONTEND_FQDN', 'localhost:3000');
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';

if (deviceUserCode) {
// Redirect to device authorization page with token
const redirectUrl = `${protocol}://${frontendFqdn}/device/authorize?token=${token}&code=${deviceUserCode}`;
logger.info('Redirecting to device authorization', { deviceUserCode });
return res.redirect(redirectUrl);
}

// Regular OAuth flow - redirect to frontend with token
const redirectUrl = `${protocol}://${frontendFqdn}/auth/callback?token=${token}`;
res.redirect(redirectUrl);
} catch (error) {
Expand Down Expand Up @@ -290,5 +323,194 @@ export const getAuthRouter = (serviceProvider: ServiceProvider<AppServiceMap>) =
}
});

// ===================================================================
// Device Authorization Routes (RFC 8628) - For CLI Authentication
// ===================================================================

/**
* POST /api/auth/device/code
* Initiates device authorization flow by requesting a device code
*/
router.post('/device/code', async (req: Request, res: Response, next) => {
try {
const logger = await serviceProvider.get('logger');
const deviceCodeService = await serviceProvider.get('deviceCode');

const provider = req.body?.provider || 'github';

// Only support GitHub for now
if (provider !== 'github') {
const error: DeviceAuthError = {
error: 'invalid_request',
error_description: 'Invalid provider specified. Only "github" is supported.',
};
return res.status(400).json(error);
}

logger.info('Device code requested', {
ip: req.ip,
userAgent: req.get('User-Agent'),
provider,
});

const deviceCodeResponse = await deviceCodeService.createDeviceCode(provider);

res.json(deviceCodeResponse);
} catch (error) {
next(error);
}
});

/**
* POST /api/auth/device/token
* Poll this endpoint to check if user has completed authentication
*/
router.post('/device/token', async (req: Request, res: Response, next) => {
try {
const logger = await serviceProvider.get('logger');
const deviceCodeService = await serviceProvider.get('deviceCode');

const deviceCode = req.body?.device_code;

if (!deviceCode) {
const error: DeviceAuthError = {
error: 'invalid_request',
error_description: 'device_code is required',
};
return res.status(400).json(error);
}

try {
const tokenResponse = await deviceCodeService.pollForToken(deviceCode);

if (!tokenResponse) {
// Still pending authorization
const error: DeviceAuthError = {
error: 'authorization_pending',
error_description: 'The user has not yet completed authorization',
};
return res.status(400).json(error);
}

logger.info('Device token issued', {
userId: tokenResponse.user.id,
});

res.json(tokenResponse);
} catch (pollError: unknown) {
// Handle specific device auth errors
const err = pollError as DeviceAuthError;
if (err.error && err.error_description) {
return res.status(400).json(err);
}
throw pollError;
}
} catch (error) {
next(error);
}
});

/**
* POST /api/auth/device/authorize
* Called by the frontend to authorize a device code after OAuth login
*/
router.post('/device/authorize', async (req: Request, res: Response, next) => {
try {
const middleware = await initMiddleware();

await middleware.requireAuth(req, res, async () => {
const logger = await serviceProvider.get('logger');
const deviceCodeService = await serviceProvider.get('deviceCode');

const userCode = req.body?.user_code;

if (!userCode) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'user_code is required',
});
}

if (!req.user?.userId) {
return res.status(401).json({
error: 'unauthorized',
error_description: 'User not authenticated',
});
}

const result = await deviceCodeService.authorizeDeviceCode(userCode, req.user.userId);

if (!result) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Invalid, expired, or already used device code',
});
}

logger.info('Device code authorized via API', {
userId: req.user.userId,
userCode,
});

res.json({
success: true,
message: 'Device authorized successfully',
});
});
} catch (error) {
next(error);
}
});

/**
* GET /api/auth/device/verify
* Verify a user code exists and is valid (used by frontend before OAuth redirect)
*/
router.get('/device/verify', async (req: Request, res: Response, next) => {
try {
const deviceCodeService = await serviceProvider.get('deviceCode');

const userCode = req.query?.code as string;

if (!userCode) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code query parameter is required',
});
}

const deviceCodeDoc = await deviceCodeService.getDeviceCodeByUserCode(userCode);

if (!deviceCodeDoc) {
return res.status(404).json({
error: 'invalid_grant',
error_description: 'Invalid or unknown device code',
});
}

if (deviceCodeDoc.expiresAt < new Date()) {
return res.status(400).json({
error: 'expired_token',
error_description: 'The device code has expired',
});
}

if (deviceCodeDoc.authorizedAt) {
return res.status(400).json({
error: 'access_denied',
error_description: 'The device code has already been used',
});
}

res.json({
valid: true,
user_code: deviceCodeDoc.userCode,
expires_in: Math.floor((deviceCodeDoc.expiresAt.getTime() - Date.now()) / 1000),
});
} catch (error) {
next(error);
}
});

return router;
};
7 changes: 7 additions & 0 deletions packages/backend/src/services/app.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface AppServiceMap extends Record<string, Service> {
logger: LoggerService;
mongo: MongoService;
agent: import('./agent.service').AgentService;
deviceCode: import('./device-code').DeviceCodeService;
jwt: import('./jwt').JwtService;
oauth: import('./oauth').OAuthService;
user: import('./user').UserService;
Expand Down Expand Up @@ -262,6 +263,7 @@ export function createAppServiceProvider(): ServiceProvider<AppServiceMap> {
// Register services after mongo is initialized
if (!servicesRegistered) {
const { createAgentService } = require('./agent.service');
const { createDeviceCodeService } = require('./device-code');
const { createJwtService } = require('./jwt');
const { createUserService } = require('./user');
const { createOAuthService } = require('./oauth');
Expand All @@ -273,15 +275,20 @@ export function createAppServiceProvider(): ServiceProvider<AppServiceMap> {
const jwtService = createJwtService(config, logger);
const userService = createUserService(mongo, logger);
const oauthService = createOAuthService(config, userService, logger);
const deviceCodeService = createDeviceCodeService(mongo, jwtService, userService, logger, {
apiFqdn: config.get('FRONTEND_FQDN', 'localhost:3000'),
});

// Register services
provider.register('agent', agentService);
provider.register('deviceCode', deviceCodeService);
provider.register('jwt', jwtService);
provider.register('user', userService);
provider.register('oauth', oauthService);

// Initialize new services
await agentService.initialize();
await deviceCodeService.initialize();
await jwtService.initialize();
await userService.initialize();
await oauthService.initialize();
Expand Down
Loading
Loading