From e248301a701b8a38effc61d9c32f45315ee1cd30 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Wed, 26 Nov 2025 00:08:59 +0100 Subject: [PATCH] feat: implement device authorization flow for CLI authentication - Add device code service for managing device authorization codes - Add POST /api/auth/device/code endpoint to request device codes - Add POST /api/auth/device/token endpoint for polling token status - Add POST /api/auth/device/authorize endpoint for authorizing devices - Add GET /api/auth/device/verify endpoint for validating user codes - Update GitHub OAuth flow to support device authorization state - Add /device page for users to enter device codes - Add /device/authorize page for OAuth callback handling - Add DeviceCodeDocument type to shared types - Add TTL index for automatic device code cleanup - Add unit tests for device code service Implements OAuth 2.0 Device Authorization Grant (RFC 8628) for GitHub only --- packages/backend/src/routes/auth/index.ts | 226 +++++++++++++- packages/backend/src/services/app.services.ts | 7 + .../device-code/device-code.service.test.ts | 184 +++++++++++ .../device-code/device-code.service.ts | 287 ++++++++++++++++++ .../backend/src/services/device-code/index.ts | 1 + packages/backend/src/services/index.ts | 2 + packages/backend/src/services/typed-db.ts | 16 +- .../src/app/device/authorize/page.tsx | 163 ++++++++++ packages/frontend/src/app/device/page.tsx | 161 ++++++++++ packages/shared/src/index.ts | 3 + .../shared/src/types/device-auth.types.ts | 95 ++++++ 11 files changed, 1142 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/services/device-code/device-code.service.test.ts create mode 100644 packages/backend/src/services/device-code/device-code.service.ts create mode 100644 packages/backend/src/services/device-code/index.ts create mode 100644 packages/frontend/src/app/device/authorize/page.tsx create mode 100644 packages/frontend/src/app/device/page.tsx create mode 100644 packages/shared/src/types/device-auth.types.ts diff --git a/packages/backend/src/routes/auth/index.ts b/packages/backend/src/routes/auth/index.ts index e63f472..0610e6f 100644 --- a/packages/backend/src/routes/auth/index.ts +++ b/packages/backend/src/routes/auth/index.ts @@ -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'; @@ -32,12 +34,23 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = 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); } @@ -68,9 +81,29 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = 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) { @@ -290,5 +323,194 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = } }); + // =================================================================== + // 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; }; diff --git a/packages/backend/src/services/app.services.ts b/packages/backend/src/services/app.services.ts index a0bec09..1627b72 100644 --- a/packages/backend/src/services/app.services.ts +++ b/packages/backend/src/services/app.services.ts @@ -45,6 +45,7 @@ export interface AppServiceMap extends Record { 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; @@ -262,6 +263,7 @@ export function createAppServiceProvider(): ServiceProvider { // 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'); @@ -273,15 +275,20 @@ export function createAppServiceProvider(): ServiceProvider { 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(); diff --git a/packages/backend/src/services/device-code/device-code.service.test.ts b/packages/backend/src/services/device-code/device-code.service.test.ts new file mode 100644 index 0000000..82d407d --- /dev/null +++ b/packages/backend/src/services/device-code/device-code.service.test.ts @@ -0,0 +1,184 @@ +/** + * Device Code Service Tests + */ + +import { createDeviceCodeService, DeviceCodeService } from './device-code.service'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Mock dependencies +const mockMongo = { + getDb: () => ({ + collection: jest.fn(() => ({ + insertOne: jest.fn().mockResolvedValue({ insertedId: 'test-id' }), + findOne: jest.fn().mockResolvedValue(null), + updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), + createIndex: jest.fn().mockResolvedValue('index'), + })), + getRawDb: () => ({ + collection: jest.fn(() => ({ + createIndex: jest.fn().mockResolvedValue('index'), + })), + }), + }), + initialize: jest.fn(), + healthCheck: jest.fn(), + disconnect: jest.fn(), +}; + +const mockJwtService = { + generateToken: jest.fn().mockReturnValue('mock-jwt-token'), + verifyToken: jest.fn(), + decodeToken: jest.fn(), + initialize: jest.fn(), +}; + +const mockUserService = { + getUserById: jest.fn().mockResolvedValue({ + _id: 'user-123', + email: 'test@example.com', + name: 'Test User', + avatar: 'https://example.com/avatar.jpg', + role: 'user', + }), + findOrCreateUser: jest.fn(), + linkProvider: jest.fn(), + unlinkProvider: jest.fn(), + getProviders: jest.fn(), + updateLastLogin: jest.fn(), + initialize: jest.fn(), +}; + +const mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + initialize: jest.fn(), +}; + +const mockConfig = { + apiFqdn: 'localhost:3000', +}; + +describe('DeviceCodeService', () => { + let service: DeviceCodeService; + + beforeEach(() => { + jest.clearAllMocks(); + service = createDeviceCodeService( + mockMongo as any, + mockJwtService as any, + mockUserService as any, + mockLogger as any, + mockConfig + ); + }); + + describe('createDeviceCode', () => { + it('should create a device code with correct format', async () => { + const result = await service.createDeviceCode('github'); + + expect(result).toHaveProperty('device_code'); + expect(result).toHaveProperty('user_code'); + expect(result).toHaveProperty('verification_uri'); + expect(result).toHaveProperty('verification_uri_complete'); + expect(result).toHaveProperty('expires_in'); + expect(result).toHaveProperty('interval'); + + // User code should be in XXXX-XXXX format + expect(result.user_code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/); + + // Device code should be base64url encoded + expect(result.device_code.length).toBeGreaterThan(20); + + // Verification URIs should include the code + expect(result.verification_uri_complete).toContain(result.user_code); + + // Default values + expect(result.expires_in).toBe(900); + expect(result.interval).toBe(5); + }); + }); + + describe('pollForToken', () => { + it('should return null when authorization is pending', async () => { + const mockCollection = { + findOne: jest.fn().mockResolvedValue({ + deviceCode: 'test-code', + userCode: 'ABCD-1234', + expiresAt: new Date(Date.now() + 60000), + authorizedAt: null, + }), + }; + (mockMongo as any).getDb = () => ({ + collection: () => mockCollection as any, + getRawDb: () => ({ collection: () => ({ createIndex: jest.fn() }) }), + }); + + const result = await service.pollForToken('test-code'); + expect(result).toBeNull(); + }); + + it('should throw expired_token error when code is expired', async () => { + const mockCollection = { + findOne: jest.fn().mockResolvedValue({ + deviceCode: 'test-code', + userCode: 'ABCD-1234', + expiresAt: new Date(Date.now() - 60000), // Expired + }), + }; + (mockMongo as any).getDb = () => ({ + collection: () => mockCollection as any, + getRawDb: () => ({ collection: () => ({ createIndex: jest.fn() }) }), + }); + + await expect(service.pollForToken('test-code')).rejects.toMatchObject({ + error: 'expired_token', + }); + }); + + it('should throw invalid_grant error when code not found', async () => { + const mockCollection = { + findOne: jest.fn().mockResolvedValue(null), + }; + (mockMongo as any).getDb = () => ({ + collection: () => mockCollection as any, + getRawDb: () => ({ collection: () => ({ createIndex: jest.fn() }) }), + }); + + await expect(service.pollForToken('invalid-code')).rejects.toMatchObject({ + error: 'invalid_grant', + }); + }); + + it('should return token when authorization is complete', async () => { + const mockCollection = { + findOne: jest.fn().mockResolvedValue({ + deviceCode: 'test-code', + userCode: 'ABCD-1234', + expiresAt: new Date(Date.now() + 60000), + authorizedAt: new Date(), + userId: 'user-123', + accessToken: 'access-token-123', + }), + }; + (mockMongo as any).getDb = () => ({ + collection: () => mockCollection as any, + getRawDb: () => ({ collection: () => ({ createIndex: jest.fn() }) }), + }); + + const result = await service.pollForToken('test-code'); + + expect(result).toMatchObject({ + access_token: 'access-token-123', + token_type: 'Bearer', + user: { + id: 'user-123', + email: 'test@example.com', + }, + }); + }); + }); +}); diff --git a/packages/backend/src/services/device-code/device-code.service.ts b/packages/backend/src/services/device-code/device-code.service.ts new file mode 100644 index 0000000..abf2a3e --- /dev/null +++ b/packages/backend/src/services/device-code/device-code.service.ts @@ -0,0 +1,287 @@ +/** + * Device Code Service + * Manages device authorization codes for CLI authentication + * Implements OAuth 2.0 Device Authorization Grant (RFC 8628) + */ + +import type { DeviceCodeDocument, DeviceCodeResponse, DeviceTokenResponse } from '@agentage/shared'; +import { randomBytes, randomUUID } from 'crypto'; +import type { LoggerService, MongoService, Service } from '../app.services'; +import type { JwtService } from '../jwt'; +import type { UserService } from '../user'; + +export interface DeviceCodeService extends Service { + /** + * Create a new device code for CLI authentication + */ + createDeviceCode(provider: 'github'): Promise; + + /** + * Get device code by device code string + */ + getDeviceCode(deviceCode: string): Promise; + + /** + * Get device code by user code + */ + getDeviceCodeByUserCode(userCode: string): Promise; + + /** + * Authorize a device code after successful OAuth + */ + authorizeDeviceCode(userCode: string, userId: string): Promise; + + /** + * Poll for token - returns token if authorized, null if pending, throws if error + */ + pollForToken(deviceCode: string): Promise; + + /** + * Clean up expired device codes + */ + cleanupExpiredCodes(): Promise; +} + +const EXPIRES_IN_SECONDS = 900; // 15 minutes +const POLLING_INTERVAL_SECONDS = 5; + +/** + * Generate a cryptographically random device code (32+ bytes base64url) + */ +function generateDeviceCode(): string { + return randomBytes(32).toString('base64url'); +} + +/** + * Generate a user-friendly code: XXXX-XXXX + * Uses only unambiguous characters (no 0/O, 1/I/L) + */ +function generateUserCode(): string { + const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + let code = ''; + const bytes = randomBytes(8); + for (let i = 0; i < 8; i++) { + code += chars[bytes[i] % chars.length]; + if (i === 3) code += '-'; + } + return code; +} + +export const createDeviceCodeService = ( + mongo: MongoService, + jwtService: JwtService, + userService: UserService, + logger: LoggerService, + config: { apiFqdn: string } +): DeviceCodeService => { + const getCollection = () => { + const db = mongo.getDb(); + return db.collection('device_codes'); + }; + + return { + async initialize() { + const db = mongo.getDb(); + // Access raw collection for index creation + const rawDb = db.getRawDb(); + const collection = rawDb.collection('device_codes'); + + // Create TTL index for automatic cleanup + try { + await collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + await collection.createIndex({ deviceCode: 1 }, { unique: true }); + await collection.createIndex({ userCode: 1 }, { unique: true }); + logger.info('Device code service initialized with TTL index'); + } catch (error) { + // Indexes might already exist + logger.debug('Device code indexes already exist or creation failed', { error }); + } + }, + + async createDeviceCode(provider: 'github'): Promise { + const collection = getCollection(); + + const deviceCode = generateDeviceCode(); + const userCode = generateUserCode(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + EXPIRES_IN_SECONDS * 1000); + + const doc: DeviceCodeDocument = { + _id: randomUUID(), + deviceCode, + userCode, + provider, + expiresAt, + createdAt: now, + }; + + await collection.insertOne(doc); + + logger.info('Device code created', { + userCode, + provider, + expiresAt: expiresAt.toISOString(), + }); + + const apiFqdn = config.apiFqdn; + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + + return { + device_code: deviceCode, + user_code: userCode, + verification_uri: `${protocol}://${apiFqdn}/device`, + verification_uri_complete: `${protocol}://${apiFqdn}/device?code=${userCode}`, + expires_in: EXPIRES_IN_SECONDS, + interval: POLLING_INTERVAL_SECONDS, + }; + }, + + async getDeviceCode(deviceCode: string): Promise { + const collection = getCollection(); + return collection.findOne({ deviceCode }); + }, + + async getDeviceCodeByUserCode(userCode: string): Promise { + const collection = getCollection(); + // Normalize user code (uppercase, with hyphen) + const normalizedCode = userCode.toUpperCase().replace(/\s/g, ''); + const formattedCode = normalizedCode.includes('-') + ? normalizedCode + : `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`; + + return collection.findOne({ userCode: formattedCode }); + }, + + async authorizeDeviceCode( + userCode: string, + userId: string + ): Promise { + const collection = getCollection(); + + // Find the device code + const normalizedCode = userCode.toUpperCase().replace(/\s/g, ''); + const formattedCode = normalizedCode.includes('-') + ? normalizedCode + : `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`; + + const deviceCodeDoc = await collection.findOne({ userCode: formattedCode }); + + if (!deviceCodeDoc) { + logger.warn('Device code not found for authorization', { userCode: formattedCode }); + return null; + } + + // Check if expired + if (deviceCodeDoc.expiresAt < new Date()) { + logger.warn('Device code expired', { userCode: formattedCode }); + return null; + } + + // Check if already authorized + if (deviceCodeDoc.authorizedAt) { + logger.warn('Device code already authorized', { userCode: formattedCode }); + return null; + } + + // Get user info + const user = await userService.getUserById(userId); + if (!user) { + logger.error('User not found for device authorization', { userId }); + return null; + } + + // Generate JWT token + const accessToken = jwtService.generateToken({ + userId: user._id!, + email: user.email, + role: user.role, + }); + + // Update device code with authorization + await collection.updateOne( + { _id: deviceCodeDoc._id }, + { + $set: { + authorizedAt: new Date(), + userId: user._id!, + accessToken, + }, + } + ); + + logger.info('Device code authorized', { + userCode: formattedCode, + userId: user._id, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: 86400, // 24 hours (or whatever JWT_EXPIRES_IN is) + user: { + id: user._id!, + email: user.email, + name: user.name, + avatar: user.avatar, + }, + }; + }, + + async pollForToken(deviceCode: string): Promise { + const collection = getCollection(); + const deviceCodeDoc = await collection.findOne({ deviceCode }); + + if (!deviceCodeDoc) { + throw { error: 'invalid_grant', error_description: 'Invalid or unknown device code' }; + } + + // Check if expired + if (deviceCodeDoc.expiresAt < new Date()) { + throw { + error: 'expired_token', + error_description: 'The device code has expired. Please restart the login process.', + }; + } + + // Check if authorized + if (!deviceCodeDoc.authorizedAt || !deviceCodeDoc.accessToken || !deviceCodeDoc.userId) { + // Still pending + return null; + } + + // Get user info + const user = await userService.getUserById(deviceCodeDoc.userId); + if (!user) { + throw { error: 'server_error', error_description: 'User not found' }; + } + + logger.info('Token retrieved via device code', { + userId: user._id, + userCode: deviceCodeDoc.userCode, + }); + + return { + access_token: deviceCodeDoc.accessToken, + token_type: 'Bearer', + expires_in: 86400, + user: { + id: user._id!, + email: user.email, + name: user.name, + avatar: user.avatar, + }, + }; + }, + + async cleanupExpiredCodes(): Promise { + const collection = getCollection(); + const result = await collection.deleteMany({ + expiresAt: { $lt: new Date() }, + }); + if (result.deletedCount > 0) { + logger.info('Cleaned up expired device codes', { count: result.deletedCount }); + } + return result.deletedCount; + }, + }; +}; diff --git a/packages/backend/src/services/device-code/index.ts b/packages/backend/src/services/device-code/index.ts new file mode 100644 index 0000000..f3708d6 --- /dev/null +++ b/packages/backend/src/services/device-code/index.ts @@ -0,0 +1 @@ +export * from './device-code.service'; diff --git a/packages/backend/src/services/index.ts b/packages/backend/src/services/index.ts index c6acdd3..6deab06 100644 --- a/packages/backend/src/services/index.ts +++ b/packages/backend/src/services/index.ts @@ -8,6 +8,7 @@ export * from './agent.service'; export * from './app.services'; // Services +export * from './device-code'; export * from './jwt'; export * from './oauth'; export * from './user'; @@ -23,6 +24,7 @@ export type { } from './app.services'; export type { AgentService } from './agent.service'; +export type { DeviceCodeService } from './device-code'; export type { JwtService } from './jwt'; export type { OAuthService } from './oauth'; export type { UserService } from './user'; diff --git a/packages/backend/src/services/typed-db.ts b/packages/backend/src/services/typed-db.ts index b8eaaf3..7139eaf 100644 --- a/packages/backend/src/services/typed-db.ts +++ b/packages/backend/src/services/typed-db.ts @@ -22,7 +22,12 @@ * ``` */ -import type { AgentDocument, AgentVersionDocument, UserDocument } from '@agentage/shared'; +import type { + AgentDocument, + AgentVersionDocument, + DeviceCodeDocument, + UserDocument, +} from '@agentage/shared'; import type { Collection, Db } from 'mongodb'; /** @@ -33,6 +38,7 @@ export interface TypedCollections { agents: AgentDocument; agent_versions: AgentVersionDocument; users: UserDocument; + device_codes: DeviceCodeDocument; // Add other collections here as they are defined // sessions: SessionDocument; } @@ -52,4 +58,12 @@ export class TypedDb { collection(name: K): Collection { return this.db.collection(name) as Collection; } + + /** + * Get the raw MongoDB database for operations that need direct access + * (e.g., index creation) + */ + getRawDb(): Db { + return this.db; + } } diff --git a/packages/frontend/src/app/device/authorize/page.tsx b/packages/frontend/src/app/device/authorize/page.tsx new file mode 100644 index 0000000..2314b5d --- /dev/null +++ b/packages/frontend/src/app/device/authorize/page.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; +import { authApi } from '../../../lib/auth-api'; + +const getApiBaseUrl = () => '/api'; + +function DeviceAuthorizeContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [error, setError] = useState(null); + + useEffect(() => { + const authorizeDevice = async () => { + const token = searchParams.get('token'); + const userCode = searchParams.get('code'); + + if (!token || !userCode) { + setError('Missing token or device code'); + setStatus('error'); + return; + } + + try { + // Store the token first + authApi.storeToken(token); + + // Authorize the device code + const response = await fetch(`${getApiBaseUrl()}/auth/device/authorize`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ user_code: userCode }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error_description || 'Failed to authorize device'); + setStatus('error'); + return; + } + + setStatus('success'); + } catch (err) { + setError('An error occurred while authorizing the device'); + setStatus('error'); + } + }; + + authorizeDevice(); + }, [searchParams]); + + if (status === 'loading') { + return ( +
+
+
+

Authorizing your device...

+
+
+ ); + } + + if (status === 'error') { + return ( +
+
+
+
+ + + +
+

Authorization Failed

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+
+
+ + + +
+

Device Authorized!

+

+ You can now close this window and return to your CLI. +
+ Your device has been successfully connected. +

+
+ + +
+
+
+
+ ); +} + +export default function DeviceAuthorizePage() { + return ( + +
+
+

Loading...

+
+ + } + > + +
+ ); +} diff --git a/packages/frontend/src/app/device/page.tsx b/packages/frontend/src/app/device/page.tsx new file mode 100644 index 0000000..a7449a3 --- /dev/null +++ b/packages/frontend/src/app/device/page.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; + +const getApiBaseUrl = () => '/api'; + +function DevicePageContent() { + const searchParams = useSearchParams(); + const [userCode, setUserCode] = useState(''); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + // Pre-fill code from URL if provided + useEffect(() => { + const codeFromUrl = searchParams.get('code'); + if (codeFromUrl) { + setUserCode(codeFromUrl.toUpperCase()); + // Auto-verify if code is provided in URL + verifyAndRedirect(codeFromUrl); + } + }, [searchParams]); + + const formatCode = (input: string): string => { + // Remove non-alphanumeric characters and convert to uppercase + const clean = input.replace(/[^A-Za-z0-9]/g, '').toUpperCase(); + // Add hyphen after 4 characters + if (clean.length > 4) { + return `${clean.slice(0, 4)}-${clean.slice(4, 8)}`; + } + return clean; + }; + + const handleCodeChange = (e: React.ChangeEvent) => { + const formatted = formatCode(e.target.value); + setUserCode(formatted); + setError(null); + }; + + const verifyAndRedirect = async (code: string) => { + setIsVerifying(true); + setError(null); + + try { + const response = await fetch( + `${getApiBaseUrl()}/auth/device/verify?code=${encodeURIComponent(code)}` + ); + const data = await response.json(); + + if (!response.ok) { + setError(data.error_description || 'Invalid code'); + setIsVerifying(false); + return; + } + + // Code is valid, redirect to GitHub OAuth with the device code + window.location.href = `${getApiBaseUrl()}/auth/github?device_code=${encodeURIComponent(code)}`; + } catch (err) { + setError('Failed to verify code. Please try again.'); + setIsVerifying(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (userCode.length < 9) { + // XXXX-XXXX = 9 chars + setError('Please enter a valid 8-character code'); + return; + } + + await verifyAndRedirect(userCode); + }; + + return ( +
+
+
+

Connect Your Device

+

Enter the code displayed in your CLI to authenticate

+
+ +
+ {isVerifying ? ( +
+
+

Verifying code...

+
+ ) : ( +
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ )} + +
+

This will connect your CLI to your GitHub account

+
+
+ +
+

+ Don't have a code?{' '} + + Learn how to get started with the CLI + +

+
+
+
+ ); +} + +export default function DevicePage() { + return ( + +
+
+

Loading...

+
+ + } + > + +
+ ); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7601c59..3c31088 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,6 +12,9 @@ export * from './types/jwt.types'; // User types export * from './types/user.types'; +// Device authorization types +export * from './types/device-auth.types'; + // Status types export * from './types/status'; diff --git a/packages/shared/src/types/device-auth.types.ts b/packages/shared/src/types/device-auth.types.ts new file mode 100644 index 0000000..ebae590 --- /dev/null +++ b/packages/shared/src/types/device-auth.types.ts @@ -0,0 +1,95 @@ +/** + * Device Authorization Types + * Types for OAuth 2.0 Device Authorization Grant (RFC 8628) + */ + +import { z } from 'zod'; + +/** + * Device code document structure in MongoDB + */ +export interface DeviceCodeDocument { + _id?: string; + deviceCode: string; // Unique server-side identifier + userCode: string; // User-facing code (e.g., "ABCD-1234") + provider: 'github'; // OAuth provider (github only for now) + expiresAt: Date; // TTL for cleanup + authorizedAt?: Date; // Set when user completes OAuth + userId?: string; // Set when user completes OAuth + accessToken?: string; // Generated token for CLI + createdAt: Date; +} + +/** + * Request to initiate device authorization + */ +export const deviceCodeRequestSchema = z.object({ + provider: z.enum(['github']).optional().default('github'), +}); + +export type DeviceCodeRequest = z.infer; + +/** + * Response from POST /api/auth/device/code + */ +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +/** + * Request to poll for token + */ +export const deviceTokenRequestSchema = z.object({ + device_code: z.string().min(1), +}); + +export type DeviceTokenRequest = z.infer; + +/** + * Successful token response + */ +export interface DeviceTokenResponse { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + user: { + id: string; + email: string; + name?: string; + avatar?: string; + }; +} + +/** + * Error response for device auth + */ +export interface DeviceAuthError { + error: + | 'authorization_pending' + | 'slow_down' + | 'access_denied' + | 'expired_token' + | 'invalid_grant' + | 'invalid_request' + | 'server_error'; + error_description: string; +} + +/** + * User code format: XXXX-XXXX (8 alphanumeric characters with hyphen) + */ +export const USER_CODE_LENGTH = 8; +export const USER_CODE_PATTERN = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/; + +/** + * Default configuration values + */ +export const DEVICE_CODE_DEFAULTS = { + EXPIRES_IN_SECONDS: 900, // 15 minutes + POLLING_INTERVAL_SECONDS: 5, +} as const;