Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,11 @@ ENCRYPTION_KEY=
# Apache Tika Integration
# ONLY active if TIKA_URL is set
TIKA_URL=http://tika:9998

# --- Google OAuth (Gmail individual account connect) ---
# Create an OAuth 2.0 Client ID at console.cloud.google.com → APIs & Services → Credentials
# Set the authorized redirect URI to: <APP_URL>/v1/oauth/google/callback
# Enable the Gmail API under APIs & Services → Library
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:4000/v1/oauth/google/callback
159 changes: 159 additions & 0 deletions packages/backend/src/api/controllers/oauth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { Request, Response } from 'express';
import { google } from 'googleapis';
import { createHmac, randomBytes } from 'crypto';
import { IngestionService } from '../../services/IngestionService';
import { UserService } from '../../services/UserService';
import { logger } from '../../config/logger';

const SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/userinfo.email',
];

function getOAuth2Client() {
return new google.auth.OAuth2(
process.env.GOOGLE_OAUTH_CLIENT_ID,
process.env.GOOGLE_OAUTH_CLIENT_SECRET,
process.env.GOOGLE_OAUTH_REDIRECT_URI
);
}

function signState(payload: object): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64');
const sig = createHmac('sha256', process.env.JWT_SECRET!)
.update(data)
.digest('hex');
return `${data}.${sig}`;
}

function verifyState(state: string): { userId: string; name: string } | null {
try {
const [data, sig] = state.split('.');
const expected = createHmac('sha256', process.env.JWT_SECRET!)
.update(data)
.digest('hex');
if (sig !== expected) return null;
return JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
} catch {
return null;
}
}

export class OAuthController {
/**
* GET /v1/oauth/google/authorize?name=<source-name>
* Protected — user must be logged in.
* Redirects to Google OAuth consent screen.
*/
public googleAuthorize = async (req: Request, res: Response): Promise<void> => {
const { name } = req.query;
const userId = req.user?.sub;

if (!userId) {
res.status(401).json({ message: 'Unauthorized' });
return;
}

if (!name || typeof name !== 'string') {
res.status(400).json({ message: 'Missing required query parameter: name' });
return;
}

if (
!process.env.GOOGLE_OAUTH_CLIENT_ID ||
!process.env.GOOGLE_OAUTH_CLIENT_SECRET ||
!process.env.GOOGLE_OAUTH_REDIRECT_URI
) {
res.status(500).json({ message: 'Google OAuth is not configured on this server.' });
return;
}

const state = signState({ userId, name, nonce: randomBytes(8).toString('hex') });
const oauth2Client = getOAuth2Client();
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: SCOPES,
state,
});

res.status(200).json({ url });
};

/**
* GET /v1/oauth/google/callback?code=...&state=...
* Public — Google redirects here after consent.
* Creates an ingestion source and redirects to dashboard.
*/
public googleCallback = async (req: Request, res: Response): Promise<void> => {
const frontendUrl = process.env.APP_URL || 'http://localhost:3000';
const { code, state, error } = req.query;

if (error) {
logger.warn({ error }, 'Google OAuth consent was denied or cancelled.');
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_cancelled`);
return;
}

if (!code || typeof code !== 'string' || !state || typeof state !== 'string') {
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_invalid_response`);
return;
}

const payload = verifyState(state);
if (!payload) {
logger.warn('Google OAuth callback received invalid state parameter.');
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_invalid_state`);
return;
}

const { userId, name } = payload;

try {
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);

if (!tokens.access_token || !tokens.refresh_token) {
throw new Error('Google did not return required tokens.');
}

oauth2Client.setCredentials(tokens);

// Get the user's email address from Google
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const { data: userInfo } = await oauth2.userinfo.get();

if (!userInfo.email) {
throw new Error('Could not retrieve email from Google account.');
}

// Fetch the OpenArchiver user to pass as actor
const userService = new UserService();
const actor = await userService.findById(userId);
if (!actor) {
throw new Error('Could not find user account.');
}

await IngestionService.create(
{
name,
provider: 'google_oauth',
providerConfig: {
type: 'google_oauth',
email: userInfo.email,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
},
},
userId,
actor,
req.ip || 'unknown'
);

res.redirect(`${frontendUrl}/dashboard/ingestions?connected=google`);
} catch (err) {
logger.error({ err }, 'Google OAuth callback failed.');
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_failed`);
}
};
}
26 changes: 26 additions & 0 deletions packages/backend/src/api/routes/oauth.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Router } from 'express';
import { OAuthController } from '../controllers/oauth.controller';
import { requireAuth } from '../middleware/requireAuth';
import type { AuthService } from '../../services/AuthService';

export const createOAuthRouter = (authService: AuthService): Router => {
const router = Router();
const controller = new OAuthController();

/**
* @route GET /v1/oauth/google/authorize?name=<source-name>
* @description Initiates the Google OAuth flow for Gmail individual account connection.
* @access Protected (JWT required)
*/
router.get('/google/authorize', requireAuth(authService), controller.googleAuthorize);

/**
* @route GET /v1/oauth/google/callback
* @description Handles the Google OAuth callback, exchanges code for tokens,
* creates an ingestion source, and redirects to the dashboard.
* @access Public (called by Google)
*/
router.get('/google/callback', controller.googleCallback);

return router;
};
3 changes: 3 additions & 0 deletions packages/backend/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createSettingsRouter } from './routes/settings.routes';
import { apiKeyRoutes } from './routes/api-key.routes';
import { integrityRoutes } from './routes/integrity.routes';
import { createJobsRouter } from './routes/jobs.routes';
import { createOAuthRouter } from './routes/oauth.routes';
import { AuthService } from '../services/AuthService';
import { AuditService } from '../services/AuditService';
import { UserService } from '../services/UserService';
Expand Down Expand Up @@ -123,6 +124,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
const apiKeyRouter = apiKeyRoutes(authService);
const integrityRouter = integrityRoutes(authService);
const jobsRouter = createJobsRouter(authService);
const oauthRouter = createOAuthRouter(authService);

// Middleware for all other routes
app.use((req, res, next) => {
Expand Down Expand Up @@ -154,6 +156,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
app.use(`/${config.api.version}/api-keys`, apiKeyRouter);
app.use(`/${config.api.version}/integrity`, integrityRouter);
app.use(`/${config.api.version}/jobs`, jobsRouter);
app.use(`/${config.api.version}/oauth`, oauthRouter);

// Load all provided extension modules
for (const module of modules) {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/services/EmailProviderFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
IngestionSource,
GoogleWorkspaceCredentials,
GoogleOAuthCredentials,
Microsoft365Credentials,
GenericImapCredentials,
PSTImportCredentials,
Expand All @@ -11,6 +12,7 @@ import type {
MailboxUser,
} from '@open-archiver/types';
import { GoogleWorkspaceConnector } from './ingestion-connectors/GoogleWorkspaceConnector';
import { GoogleOAuthConnector } from './ingestion-connectors/GoogleOAuthConnector';
import { MicrosoftConnector } from './ingestion-connectors/MicrosoftConnector';
import { ImapConnector } from './ingestion-connectors/ImapConnector';
import { PSTConnector } from './ingestion-connectors/PSTConnector';
Expand Down Expand Up @@ -38,6 +40,8 @@ export class EmailProviderFactory {
switch (source.provider) {
case 'google_workspace':
return new GoogleWorkspaceConnector(credentials as GoogleWorkspaceCredentials);
case 'google_oauth':
return new GoogleOAuthConnector(credentials as GoogleOAuthCredentials);
case 'microsoft_365':
return new MicrosoftConnector(credentials as Microsoft365Credentials);
case 'generic_imap':
Expand Down
Loading
Loading