feat: Add OAuth provider type support for SMTP configuration and enhance documentation

This commit is contained in:
Nawaz Dhandala
2026-01-12 11:36:00 +00:00
parent 07476f366c
commit 4c669000fa
9 changed files with 294 additions and 42 deletions

View File

@@ -3,6 +3,7 @@ import Email from "Common/Types/Email";
import EmailMessage from "Common/Types/Email/EmailMessage";
import EmailServer from "Common/Types/Email/EmailServer";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import OAuthProviderType from "Common/Types/Email/OAuthProviderType";
import SMTPAuthenticationType from "Common/Types/Email/SMTPAuthenticationType";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
@@ -52,6 +53,7 @@ router.post(
clientSecret: true,
tokenUrl: true,
scope: true,
oauthProviderType: true,
},
});
@@ -102,6 +104,7 @@ router.post(
clientSecret: config.clientSecret,
tokenUrl: config.tokenUrl ? URL.fromString(config.tokenUrl) : undefined,
scope: config.scope,
oauthProviderType: config.oauthProviderType as OAuthProviderType | undefined,
};
try {

View File

@@ -13,6 +13,7 @@ import Email from "Common/Types/Email";
import EmailMessage from "Common/Types/Email/EmailMessage";
import EmailServer from "Common/Types/Email/EmailServer";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import OAuthProviderType from "Common/Types/Email/OAuthProviderType";
import SMTPAuthenticationType from "Common/Types/Email/SMTPAuthenticationType";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
@@ -136,11 +137,14 @@ class TransporterPool {
}
// Get the access token using the generic OAuth service
// Provider type determines which grant flow to use (Client Credentials vs JWT Bearer)
const accessToken: string = await SMTPOAuthService.getAccessToken({
clientId: emailServer.clientId,
clientSecret: emailServer.clientSecret,
tokenUrl: emailServer.tokenUrl,
scope: emailServer.scope,
username: emailServer.username, // Required for JWT Bearer (user to impersonate)
providerType: emailServer.oauthProviderType || OAuthProviderType.ClientCredentials,
});
logger.debug("Creating OAuth transporter for SMTP");

View File

@@ -3,6 +3,8 @@ import logger from "Common/Server/Utils/Logger";
import LocalCache from "Common/Server/Infrastructure/LocalCache";
import { JSONObject } from "Common/Types/JSON";
import URL from "Common/Types/API/URL";
import OAuthProviderType from "Common/Types/Email/OAuthProviderType";
import jwt from "jsonwebtoken";
interface OAuthTokenResponse {
access_token: string;
@@ -18,40 +20,43 @@ interface CachedToken extends JSONObject {
export interface SMTPOAuthConfig {
clientId: string;
clientSecret: string;
tokenUrl: URL; // Full OAuth token endpoint URL
scope: string; // OAuth scope(s), space-separated if multiple
tokenUrl: URL;
scope: string;
username?: string; // Email address to send as (required for JWT Bearer to impersonate user)
providerType: OAuthProviderType; // The OAuth grant type to use
}
/**
* Generic service for fetching OAuth2 access tokens for SMTP authentication.
* Supports any OAuth 2.0 provider that implements the client credentials grant flow.
* Supports multiple OAuth 2.0 grant types:
*
* Common configurations:
* - Client Credentials (OAuthProviderType.ClientCredentials)
* Used by: Microsoft 365, Azure AD, and most OAuth 2.0 providers
* Required fields: Client ID, Client Secret, Token URL, Scope
*
* Microsoft 365:
* - tokenUrl: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
* - scope: https://outlook.office365.com/.default
*
* Google Workspace (requires service account):
* - tokenUrl: https://oauth2.googleapis.com/token
* - scope: https://mail.google.com/
*
* Custom OAuth Provider:
* - tokenUrl: Your provider's token endpoint
* - scope: Required scope(s) for SMTP access
* - JWT Bearer Assertion (OAuthProviderType.JWTBearer)
* Used by: Google Workspace service accounts
* Required fields:
* - Client ID: Service account email (client_email from JSON key)
* - Client Secret: Private key (private_key from JSON key)
* - Token URL: OAuth token endpoint
* - Scope: Required scopes
* - Username: Email address to impersonate
*/
export default class SMTPOAuthService {
private static readonly TOKEN_CACHE_NAMESPACE = "smtp-oauth-tokens";
private static readonly TOKEN_BUFFER_SECONDS = 300; // Refresh token 5 minutes before expiry
private static readonly JWT_EXPIRY_SECONDS = 3600; // JWTs are valid for max 1 hour
/**
* Get an access token for SMTP authentication using OAuth 2.0 client credentials flow.
* Get an access token for SMTP authentication.
* Uses the provider type specified in config to determine the grant type.
*
* @param config - OAuth configuration including clientId, clientSecret, tokenUrl, and scope
* @param config - OAuth configuration
* @returns The access token
*/
public static async getAccessToken(config: SMTPOAuthConfig): Promise<string> {
const cacheKey = `${config.tokenUrl.toString()}:${config.clientId}`;
const cacheKey = `${config.tokenUrl.toString()}:${config.clientId}:${config.username || ""}`;
// Check if we have a cached token that's still valid
if (LocalCache.hasValue(this.TOKEN_CACHE_NAMESPACE, cacheKey)) {
@@ -67,12 +72,147 @@ export default class SMTPOAuthService {
}
}
// Fetch a new token
const token: string = await this.fetchNewToken(config);
// Fetch a new token using the appropriate method based on provider type
let token: string;
switch (config.providerType) {
case OAuthProviderType.JWTBearer:
token = await this.fetchJWTBearerToken(config);
break;
case OAuthProviderType.ClientCredentials:
default:
token = await this.fetchClientCredentialsToken(config);
break;
}
return token;
}
private static async fetchNewToken(config: SMTPOAuthConfig): Promise<string> {
/**
* Fetch token using JWT Bearer assertion flow (RFC 7523).
* Used by Google Workspace service accounts and other providers that require signed JWTs.
*/
private static async fetchJWTBearerToken(config: SMTPOAuthConfig): Promise<string> {
if (!config.username) {
throw new BadDataException(
"Username (email address to impersonate) is required for JWT Bearer OAuth. " +
"This should be the email address that will send emails."
);
}
// Validate that clientSecret looks like a private key
if (!config.clientSecret.includes("-----BEGIN") || !config.clientSecret.includes("PRIVATE KEY")) {
throw new BadDataException(
"For JWT Bearer OAuth, the Client Secret must be a private key. " +
"It should start with '-----BEGIN PRIVATE KEY-----' or '-----BEGIN RSA PRIVATE KEY-----'."
);
}
try {
logger.debug("Creating JWT for OAuth token request");
const now = Math.floor(Date.now() / 1000);
// Create JWT claims
const jwtClaims = {
iss: config.clientId, // Issuer (service account email for Google)
sub: config.username, // Subject (user to impersonate)
scope: config.scope,
aud: config.tokenUrl.toString(),
iat: now,
exp: now + this.JWT_EXPIRY_SECONDS,
};
// Sign the JWT with the private key
const signedJwt = jwt.sign(jwtClaims, config.clientSecret, {
algorithm: "RS256",
});
logger.debug(`Fetching OAuth token from ${config.tokenUrl.toString()} using JWT Bearer`);
// Exchange JWT for access token
const params = new URLSearchParams();
params.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
params.append("assertion", signedJwt);
const response: Response = await fetch(config.tokenUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
const errorText: string = await response.text();
logger.error(`Failed to fetch OAuth token: ${response.status} - ${errorText}`);
// Provide helpful error messages for common issues
if (errorText.includes("invalid_grant")) {
throw new BadDataException(
`OAuth failed: invalid_grant. This usually means: ` +
`1) Domain-wide delegation is not enabled, ` +
`2) The service account is not authorized in your admin console, ` +
`3) The user email '${config.username}' doesn't exist or can't be impersonated, or ` +
`4) The scope '${config.scope}' is not authorized. ` +
`Please check your OAuth provider's admin console for domain-wide delegation settings.`
);
}
if (errorText.includes("unauthorized_client")) {
throw new BadDataException(
`OAuth failed: unauthorized_client. ` +
`The service account '${config.clientId}' is not authorized to impersonate users. ` +
`Please enable domain-wide delegation and authorize the client ID in your admin console.`
);
}
throw new BadDataException(
`Failed to authenticate with OAuth provider: ${response.status}. Error: ${errorText}`
);
}
const tokenData: OAuthTokenResponse = (await response.json()) as OAuthTokenResponse;
if (!tokenData.access_token) {
throw new BadDataException("OAuth response did not contain an access token");
}
// Cache the token
this.cacheToken(config, tokenData);
logger.debug("Successfully obtained and cached OAuth token via JWT Bearer");
return tokenData.access_token;
} catch (error) {
if (error instanceof BadDataException) {
throw error;
}
logger.error("Error fetching OAuth token via JWT Bearer:");
logger.error(error);
// Handle JWT signing errors
if (error instanceof Error && error.message.includes("PEM")) {
throw new BadDataException(
`Invalid private key format. Make sure you copied the entire private key, ` +
`including the '-----BEGIN PRIVATE KEY-----' and '-----END PRIVATE KEY-----' markers. ` +
`Error: ${error.message}`
);
}
throw new BadDataException(
`Failed to authenticate with OAuth provider: ${error instanceof Error ? error.message : "Unknown error"}. ` +
`Please verify your credentials and OAuth provider settings.`
);
}
}
/**
* Fetch token using OAuth 2.0 client credentials flow (RFC 6749).
* Used by Microsoft 365, Azure AD, and most OAuth 2.0 providers.
*/
private static async fetchClientCredentialsToken(config: SMTPOAuthConfig): Promise<string> {
const params = new URLSearchParams();
params.append("client_id", config.clientId);
params.append("client_secret", config.clientSecret);
@@ -80,7 +220,7 @@ export default class SMTPOAuthService {
params.append("grant_type", "client_credentials");
try {
logger.debug(`Fetching new OAuth token from ${config.tokenUrl.toString()}`);
logger.debug(`Fetching OAuth token from ${config.tokenUrl.toString()} using Client Credentials`);
const response: Response = await fetch(config.tokenUrl.toString(), {
method: "POST",
@@ -96,7 +236,9 @@ export default class SMTPOAuthService {
`Failed to fetch OAuth token: ${response.status} - ${errorText}`,
);
throw new BadDataException(
`Failed to authenticate with OAuth provider: ${response.status}. Please check your OAuth credentials (Client ID, Client Secret, Token URL, and Scope). Error: ${errorText}`,
`Failed to authenticate with OAuth provider: ${response.status}. ` +
`Please check your OAuth credentials (Client ID, Client Secret, Token URL, and Scope). ` +
`Error: ${errorText}`,
);
}
@@ -110,18 +252,9 @@ export default class SMTPOAuthService {
}
// Cache the token
const cacheKey = `${config.tokenUrl.toString()}:${config.clientId}`;
const expiresAt: number =
Date.now() + (tokenData.expires_in - this.TOKEN_BUFFER_SECONDS) * 1000;
this.cacheToken(config, tokenData);
const cachedToken: CachedToken = {
accessToken: tokenData.access_token,
expiresAt,
};
LocalCache.setJSON(this.TOKEN_CACHE_NAMESPACE, cacheKey, cachedToken);
logger.debug("Successfully obtained and cached OAuth token");
logger.debug("Successfully obtained and cached OAuth token via Client Credentials");
return tokenData.access_token;
} catch (error) {
@@ -129,15 +262,32 @@ export default class SMTPOAuthService {
throw error;
}
logger.error("Error fetching OAuth token:");
logger.error("Error fetching OAuth token via Client Credentials:");
logger.error(error);
throw new BadDataException(
`Failed to authenticate with OAuth provider: ${error instanceof Error ? error.message : "Unknown error"}. Please check your OAuth credentials and network connectivity.`,
`Failed to authenticate with OAuth provider: ${error instanceof Error ? error.message : "Unknown error"}. ` +
`Please check your OAuth credentials and network connectivity.`,
);
}
}
/**
* Cache the token for future use.
*/
private static cacheToken(config: SMTPOAuthConfig, tokenData: OAuthTokenResponse): void {
const cacheKey = `${config.tokenUrl.toString()}:${config.clientId}:${config.username || ""}`;
const expiresAt: number =
Date.now() + (tokenData.expires_in - this.TOKEN_BUFFER_SECONDS) * 1000;
const cachedToken: CachedToken = {
accessToken: tokenData.access_token,
expiresAt,
};
LocalCache.setJSON(this.TOKEN_CACHE_NAMESPACE, cacheKey, cachedToken);
}
/**
* Create the XOAUTH2 token string for SMTP authentication.
* Format: base64("user=" + userName + "^Aauth=Bearer " + accessToken + "^A^A")
@@ -178,4 +328,9 @@ export default class SMTPOAuthService {
* Default scope for Google Workspace SMTP.
*/
public static readonly GOOGLE_SMTP_SCOPE = "https://mail.google.com/";
/**
* Google OAuth token URL.
*/
public static readonly GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
}

View File

@@ -17,6 +17,7 @@ import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
import Email from "../../Types/Email";
import OAuthProviderType from "../../Types/Email/OAuthProviderType";
import SMTPAuthenticationType from "../../Types/Email/SMTPAuthenticationType";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
@@ -728,4 +729,37 @@ export default class ProjectSmtpConfig extends BaseModel {
length: ColumnLength.LongText,
})
public scope?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSMTPConfig,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSMTPConfig,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSMTPConfig,
],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
title: "OAuth Provider Type",
description:
"The OAuth grant type to use. 'Client Credentials' for Microsoft 365 and most providers. 'JWT Bearer' for Google Workspace service accounts.",
example: "Client Credentials",
})
@Column({
nullable: true,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public oauthProviderType?: OAuthProviderType = undefined;
}

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddOAuthProviderType1768217403078 implements MigrationInterface {
name = 'AddOAuthProviderType1768217403078'
public async up(queryRunner: QueryRunner): Promise<void> {
// Add oauthProviderType column to ProjectSMTPConfig table
// Values: 'Client Credentials' (for Microsoft 365, etc.) or 'JWT Bearer' (for Google Workspace)
await queryRunner.query(`ALTER TABLE "ProjectSMTPConfig" ADD "oauthProviderType" character varying(100)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "ProjectSMTPConfig" DROP COLUMN "oauthProviderType"`);
}
}

View File

@@ -218,6 +218,7 @@ import { RenameServiceCatalogToService1767966850199 } from "./1767966850199-Rena
import { MigrationName1767979055522 } from "./1767979055522-MigrationName";
import { MigrationName1767979448478 } from "./1767979448478-MigrationName";
import { IncreaseClientSecretLength1768216593272 } from "./1768216593272-IncreaseClientSecretLength";
import { AddOAuthProviderType1768217403078 } from "./1768217403078-AddOAuthProviderType";
export default [
InitialMigration,
@@ -440,4 +441,5 @@ export default [
MigrationName1767979448478,
MigrationName1767896933148,
IncreaseClientSecretLength1768216593272,
AddOAuthProviderType1768217403078,
];

View File

@@ -3,6 +3,7 @@ import URL from "../API/URL";
import Email from "../Email";
import ObjectID from "../ObjectID";
import Port from "../Port";
import OAuthProviderType from "./OAuthProviderType";
import SMTPAuthenticationType from "./SMTPAuthenticationType";
export default interface EmailServer {
@@ -21,4 +22,5 @@ export default interface EmailServer {
clientSecret?: string | undefined; // OAuth Application Client Secret
tokenUrl?: URL | undefined; // OAuth token endpoint URL (e.g., https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token)
scope?: string | undefined; // OAuth scope(s), space-separated (e.g., https://outlook.office365.com/.default)
oauthProviderType?: OAuthProviderType | undefined; // OAuth grant type: Client Credentials or JWT Bearer
}

View File

@@ -0,0 +1,28 @@
/**
* OAuth Provider Types for SMTP authentication.
* Different providers use different OAuth 2.0 grant types.
*/
enum OAuthProviderType {
/**
* Client Credentials Grant (RFC 6749)
* Used by: Microsoft 365, Azure AD, and most OAuth 2.0 providers
*
* Required fields: Client ID, Client Secret, Token URL, Scope
*/
ClientCredentials = "Client Credentials",
/**
* JWT Bearer Assertion Grant (RFC 7523)
* Used by: Google Workspace service accounts
*
* Required fields:
* - Client ID: Service account email (client_email from JSON key)
* - Client Secret: Private key (private_key from JSON key)
* - Token URL: OAuth token endpoint
* - Scope: Required scopes
* - Username: Email address to impersonate
*/
JWTBearer = "JWT Bearer",
}
export default OAuthProviderType;

View File

@@ -10,7 +10,10 @@ This guide covers how to configure OAuth 2.0 authentication for Microsoft 365 an
## OAuth 2.0 Authentication
OAuth 2.0 provides a more secure way to authenticate with email servers, especially for enterprise environments that have disabled basic authentication. OneUptime uses the **client credentials flow** which is ideal for server-to-server communication without user interaction.
OAuth 2.0 provides a more secure way to authenticate with email servers, especially for enterprise environments that have disabled basic authentication. OneUptime supports two OAuth grant types:
- **Client Credentials** - Used by Microsoft 365 and most OAuth providers
- **JWT Bearer** - Used by Google Workspace service accounts
### Required Fields for OAuth
@@ -22,8 +25,9 @@ When configuring SMTP with OAuth authentication in OneUptime, you'll need:
| **Port** | SMTP port (typically 587 for STARTTLS or 465 for implicit TLS) |
| **Username** | The email address to send from |
| **Authentication Type** | Select "OAuth" |
| **Client ID** | Application/Client ID from your OAuth provider |
| **Client Secret** | Client secret from your OAuth provider |
| **OAuth Provider Type** | Select "Client Credentials" for Microsoft 365, or "JWT Bearer" for Google Workspace |
| **Client ID** | Application/Client ID from your OAuth provider (for Google: service account email) |
| **Client Secret** | Client secret from your OAuth provider (for Google: private key) |
| **Token URL** | OAuth token endpoint URL |
| **Scope** | Required OAuth scope(s) for SMTP access |
@@ -110,6 +114,7 @@ In OneUptime, create or edit an SMTP configuration with these settings:
| Port | `587` |
| Username | The email address you granted permissions to (e.g., `sender@yourdomain.com`) |
| Authentication Type | `OAuth` |
| OAuth Provider Type | `Client Credentials` |
| Client ID | Your Application (client) ID from Step 1 |
| Client Secret | The secret value from Step 2 |
| Token URL | `https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token` |
@@ -189,15 +194,18 @@ In OneUptime, create or edit an SMTP configuration with these settings:
|-------|-------|
| Hostname | `smtp.gmail.com` |
| Port | `587` |
| Username | The Google Workspace email address to send from (e.g., `notifications@yourdomain.com`) |
| Username | The Google Workspace email address to send from (e.g., `notifications@yourdomain.com`). This user will be impersonated by the service account. |
| Authentication Type | `OAuth` |
| Client ID | The `client_id` from your service account JSON |
| Client Secret | The `private_key` from your service account JSON |
| OAuth Provider Type | `JWT Bearer` |
| Client ID | The `client_email` from your service account JSON (e.g., `your-service@your-project.iam.gserviceaccount.com`) |
| Client Secret | The `private_key` from your service account JSON (the entire key including `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`) |
| Token URL | `https://oauth2.googleapis.com/token` |
| Scope | `https://mail.google.com/` |
| From Email | Same as Username |
| Secure (TLS) | Enabled |
**Important:** For Google (JWT Bearer), the Client ID is the **service account email** (`client_email`), NOT the numerical `client_id`. The service account will impersonate the user specified in the Username field to send emails.
---
## Troubleshooting