@blendededge/doohickey-hush-sdk
v0.0.13
Published
SDK for integration applications to interact with the hush service
Maintainers
Readme
Doohickey Secret Management SDK
A PostgreSQL-based secret management library designed specifically for DBOS applications. This SDK provides secure, encrypted storage of secrets directly in your PostgreSQL database using envelope encryption with libsodium.
Overview
This library eliminates the need for external secret management services by storing encrypted secrets directly in your DBOS application's PostgreSQL database. It's built to work seamlessly with DBOS transaction decorators and provides enterprise-grade features including multi-tenant isolation, comprehensive audit logging, and automatic encryption.
Key Architecture:
- Direct PostgreSQL Storage: No external services required - all data stored in your DBOS database
- Envelope Encryption: Two-layer security with libsodium XSalsa20-Poly1305 AEAD encryption
- DBOS Integration: Native support for
@DBOS.transaction()and@DBOS.workflow()decorators - Multi-tenant: Row-level security with tenant isolation for SaaS applications
- OAuth2/OIDC: Built-in OAuth2 and OpenID Connect with PKCE support
- Audit Trail: SOC2-compliant logging with 7-year retention for compliance
For Developers: Technical implementation details, architecture specifications, database schemas, and contribution guidelines are available in DEVELOPER.md.
Features
- 🔒 Secure Encryption: Envelope encryption using libsodium XSalsa20-Poly1305 with rotating data encryption keys
- 🏢 Multi-tenant: Tenant-based isolation for SaaS applications with row-level security
- 📋 Audit Logging: SOC2-compliant audit trail with 7-year retention (2555 days)
- ⚡ DBOS Native: Seamless integration with DBOS transaction patterns and workflows
- 🔐 OAuth2/OIDC: Full OAuth2 and OpenID Connect support including Authorization Code (with PKCE), Client Credentials, Device Authorization, Resource Owner Password Credentials, and Generic Grant flows
- 🔄 Token Management: Automatic token refresh, secure state management, and multi-service support
- ✅ Type Safety: Full TypeScript support with comprehensive type definitions
- 🗃️ Database Schema: Automatic table creation, migration support, and optimized indexing
- 🔍 Search & Pagination: Built-in secret discovery, filtering, and management
- ⚠️ Error Handling: Comprehensive error handling, validation, and troubleshooting guidance
Installation
npm install @blendededge/doohickey-hush-sdkPeer Dependencies
This SDK requires the following peer dependencies:
{
"peerDependencies": {
"knex": ">=3.0.0 <4.0.0",
"libsodium-wrappers": "^0.7.11",
"openid-client": "^6.6.2",
}
}Note: In DBOS applications, knex is provided automatically via DBOS.knexClient. The other dependencies are included for encryption (libsodium-wrappers) and OAuth2/OIDC support (openid-client). UUIDs are generated using Node.js built-in crypto.randomUUID().
Quick Start
1. Generate Encryption Key
First, generate a root encryption key for your application:
# Generate a secure 32-byte base64-encoded key
node -e "console.log('ROOT_KEY_BASE64=' + require('crypto').randomBytes(32).toString('base64'))"Add this to your environment variables:
ROOT_KEY_BASE64=your-generated-key-here2. Initialize Database Schema
Set up the required database tables (run once during deployment):
import { DBOS } from '@dbos-inc/dbos-sdk';
import { PostgresSecretClient } from '@blendededge/doohickey-hush-sdk';
export class DatabaseSetup {
@DBOS.transaction()
static async initializeSecretTables() {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'setup', // Temporary tenant for schema setup
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initializeSchema();
console.log('Secret management tables created successfully');
}
}3. Basic Usage
import { DBOS } from '@dbos-inc/dbos-sdk';
import { PostgresSecretClient } from '@blendededge/doohickey-hush-sdk';
export class SecretWorkflow {
@DBOS.transaction()
static async saveApiKey() {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'tenant-123',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
// Save a secret
const result = await client.saveSecret('stripe_api_key', 'sk_test_...', {
description: 'Stripe API key for payments',
tags: ['production', 'payment']
});
return result;
}
@DBOS.transaction()
static async getApiKey(): Promise<string> {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'tenant-123',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
// Retrieve a secret
const secret = await client.getSecret('stripe_api_key');
return secret.value;
}
}4. Using Helper Functions
For simpler usage without managing client instances:
import { DBOS } from '@dbos-inc/dbos-sdk';
import { PostgresSecretWorkflowHelper } from '@blendededge/doohickey-hush-sdk';
export class QuickSecretAccess {
@DBOS.transaction()
static async saveAndRetrieve() {
const tenantId = 'tenant-456';
// Save
await PostgresSecretWorkflowHelper.saveSecret(
DBOS.knexClient,
tenantId,
'database_url',
'postgresql://user:pass@host:5432/db',
{ description: 'Production database URL' }
);
// Retrieve
const secret = await PostgresSecretWorkflowHelper.getSecret(
DBOS.knexClient,
tenantId,
'database_url'
);
return secret.value;
}
}DBOS Architecture Patterns
Critical: This SDK must follow DBOS architectural constraints for proper operation. Understanding these patterns is essential to avoid runtime errors.
DBOS Component Separation
DBOS enforces strict separation between different types of operations:
@DBOS.transaction(): Database operations only (PostgreSQL queries)@DBOS.step(): External API calls only (HTTP requests, third-party services)@DBOS.workflow(): Orchestrates between transactions and steps
❌ What NOT to Do
Never call transaction-decorated methods from within steps:
// ❌ WRONG - This will cause "Invalid call to a `transaction` function from within a `step`" error
export class BadExample {
@DBOS.step()
static async fetchDataFromAPI(): Promise<string> {
// External API call (correct for step)
const response = await fetch('https://api.example.com/data');
const data = await response.json();
// ❌ WRONG - Cannot call transaction from step
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'tenant',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize(); // This calls @DBOS.transaction() internally!
await client.saveSecret('api-data', data); // This will fail!
return data;
}
}✅ Correct Patterns
Pattern 1: Use transactions for secret operations, steps for API calls
export class CorrectExample {
// ✅ CORRECT - Workflow orchestrates between transaction and step
@DBOS.workflow()
static async processExternalData(): Promise<string> {
// Step 1: Get access token from database (transaction)
const accessToken = await CorrectExample.getAccessTokenTransaction();
// Step 2: Call external API with token (step)
const apiData = await CorrectExample.fetchDataFromAPIStep(accessToken);
// Step 3: Store result in secrets (transaction)
await CorrectExample.saveSecretTransaction('api-result', apiData);
return apiData;
}
// ✅ CORRECT - Transaction for database operations
@DBOS.transaction()
static async getAccessTokenTransaction(): Promise<string> {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'tenant',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
const secret = await client.getSecret('api-access-token');
return secret.value;
}
// ✅ CORRECT - Step for external API calls
@DBOS.step()
static async fetchDataFromAPIStep(accessToken: string): Promise<string> {
const response = await fetch('https://api.example.com/data', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
return await response.text();
}
// ✅ CORRECT - Transaction for database operations
@DBOS.transaction()
static async saveSecretTransaction(name: string, value: string): Promise<void> {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'tenant',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
await client.saveSecret(name, value, {
description: 'Data from external API',
tags: ['external', 'processed']
});
}
}Pattern 2: Pass data between decorators rather than calling across boundaries
export class DataFlowExample {
@DBOS.workflow()
static async authenticateAndCallService(): Promise<any> {
// 1. Get credentials (transaction)
const credentials = await DataFlowExample.getCredentialsTransaction();
// 2. Authenticate with service (step)
const authToken = await DataFlowExample.authenticateStep(credentials);
// 3. Store new token (transaction)
await DataFlowExample.updateTokenTransaction(authToken);
// 4. Call service with token (step)
return await DataFlowExample.callServiceStep(authToken);
}
@DBOS.transaction()
static async getCredentialsTransaction(): Promise<{clientId: string, clientSecret: string}> {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'auth',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
const clientId = await client.getSecret('oauth-client-id');
const clientSecret = await client.getSecret('oauth-client-secret');
return {
clientId: clientId.value,
clientSecret: clientSecret.value
};
}
@DBOS.step()
static async authenticateStep(credentials: {clientId: string, clientSecret: string}): Promise<string> {
const response = await fetch('https://auth.service.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: credentials.clientId,
client_secret: credentials.clientSecret
})
});
const tokenData = await response.json();
return tokenData.access_token;
}
@DBOS.transaction()
static async updateTokenTransaction(token: string): Promise<void> {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'auth',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
await client.saveSecret('current-access-token', token, {
description: 'Current OAuth access token',
tags: ['oauth', 'current']
});
}
@DBOS.step()
static async callServiceStep(token: string): Promise<any> {
const response = await fetch('https://api.service.com/data', {
headers: { 'Authorization': `Bearer ${token}` }
});
return await response.json();
}
}Key Principles
- Separate Concerns: Database operations in
@DBOS.transaction(), external calls in@DBOS.step() - Pass Data: Don't call across decorator boundaries, pass data through workflow parameters
- Orchestrate in Workflows: Use
@DBOS.workflow()to coordinate between transactions and steps - Error Handling: Each decorator type has its own retry and error handling semantics
Configuration
Configuration Interfaces
interface PostgresSecretClientConfig {
/** Tenant ID for multi-tenancy support (required) */
tenantId: string;
/** Base64-encoded root encryption key (required) */
rootKeyBase64: string;
/** Enable audit logging (default: true) */
auditEnabled?: boolean;
/** Audit log retention in days (default: 2555 / 7 years) */
retentionDays?: number;
/** Enable row-level security (default: false) */
enableRowLevelSecurity?: boolean;
/** Optional logger for debugging and monitoring */
logger?: Logger;
}
interface OAuthClientConfig {
/** OIDC issuer URL (for auto-discovery) OR manual endpoints */
issuerUrl?: string;
/** Manual OAuth2 endpoints (if not using OIDC discovery) */
authorizationEndpoint?: string;
tokenEndpoint?: string;
revocationEndpoint?: string;
userinfoEndpoint?: string;
/** OAuth2 client credentials */
clientId: string;
clientSecret: string;
/** Redirect URIs for OAuth callback (required for authorization_code flow) */
redirectUris: string[];
/** OAuth scopes to request */
scopes: string[];
/** Additional OAuth2/OIDC configuration */
responseTypes?: string[]; // default: ['code']
grantTypes?: string[]; // default: ['authorization_code']
/**
* Supported grant types for this client
* - 'authorization_code': Interactive web/mobile authentication with PKCE
* - 'client_credentials': Machine-to-machine authentication
* - 'device_code': Smart TV/IoT device authentication
* - 'password': Legacy username/password authentication (discouraged)
* - 'refresh_token': Token refresh capability
* - 'generic': Support for custom/future grant types
*/
supportedGrantTypes?: ('authorization_code' | 'client_credentials' | 'device_code' | 'password' | 'refresh_token' | 'generic')[];
}Configuration Examples
Google OAuth2/OIDC (Recommended)
const googleConfig: OAuthClientConfig = {
issuerUrl: 'https://accounts.google.com',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/google`,
scope: ['openid', 'profile', 'email'],
usePkce: true,
pkceMethod: 'S256'
};GitHub OAuth2 (Manual Configuration)
const githubConfig: OAuthClientConfig = {
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
userinfoEndpoint: 'https://api.github.com/user',
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/github`,
scope: ['user:email', 'read:user'],
usePkce: true,
tokenEndpointAuthMethod: 'client_secret_post'
};Microsoft Azure AD
const microsoftConfig: OAuthClientConfig = {
issuerUrl: 'https://login.microsoftonline.com/common/v2.0',
clientId: process.env.MICROSOFT_CLIENT_ID!,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/microsoft`,
scope: ['openid', 'profile', 'email', 'User.Read'],
usePkce: true
};Environment Variables
# Required: Root encryption key (32-byte base64-encoded)
ROOT_KEY_BASE64=base64-encoded-32-byte-key
# OAuth2/OIDC Configuration (optional)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
MICROSOFT_CLIENT_ID=your-microsoft-client-id
MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
SLACK_CLIENT_ID=your-slack-client-id
SLACK_CLIENT_SECRET=your-slack-client-secret
# Application configuration
BASE_URL=https://yourapp.com
# DBOS provides these automatically:
# DATABASE_URL=postgresql://...
# Knex client via DBOS.knexClient
# Optional: Logging and debugging
DEBUG=doohickey:*
LOG_LEVEL=infoKey Generation
import { SecretEncryption } from '@packages/secret-shared-lib';
// Generate a new root key programmatically
const rootKey = await SecretEncryption.generateRootKey();
console.log('ROOT_KEY_BASE64=' + rootKey);
// Validate an existing key
const isValid = await SecretEncryption.validateRootKey(process.env.ROOT_KEY_BASE64!);OAuth2/OIDC Integration
The SDK includes comprehensive OAuth2 and OpenID Connect support through the OAuthManager class:
OAuth Setup
import { DBOS } from '@dbos-inc/dbos-sdk';
import { OAuthManager } from '@blendededge/doohickey-hush-sdk';
export class OAuthSetup {
@DBOS.transaction()
static async initializeOAuth() {
// Initialize OAuth manager
await OAuthManager.initialize(DBOS.knexClient, {
tenantId: 'oauth-tenant',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
// Configure multiple OAuth providers
await OAuthManager.initializeAllClients({
google: {
issuerUrl: 'https://accounts.google.com',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/oauth/callback'
},
github: {
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/oauth/github/callback'
}
});
}
}OAuth Flow Implementation
export class OAuthFlow {
@DBOS.transaction()
static async startGoogleAuth() {
// Start OAuth flow with PKCE
const { authUrl, state } = await OAuthManager.startAuthFlow(
'google',
['profile', 'email', 'openid']
);
return { authUrl, state };
}
@DBOS.transaction()
static async handleOAuthCallback(callbackUrl: string) {
// Handle OAuth callback and get tokens
const tokenData = await OAuthManager.handleCallback('google', callbackUrl);
return {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresIn: tokenData.expires_in
};
}
@DBOS.transaction()
static async getValidToken(): Promise<string> {
// Get valid access token (automatically refreshes if expired)
return await OAuthManager.getValidAccessToken('google');
}
}OAuth Grant Types
The SDK supports all major OAuth2 grant types for different authentication scenarios:
1. Authorization Code Grant (Interactive Authentication)
Use Case: Web applications, mobile apps, and any scenario requiring user interaction.
Features:
- PKCE (Proof Key for Code Exchange) enabled by default for security
- Supports both OIDC discovery and manual endpoint configuration
- Automatic token refresh with stored refresh tokens
Complete Setup with Express Endpoints
import { DBOS, WorkflowQueue } from '@dbos-inc/dbos-sdk';
import { OAuthManager, PostgresSecretClient } from '@blendededge/doohickey-hush-sdk';
import express from 'express';
import session from 'express-session';
export const app = express();
app.use(express.json());
// Configure session middleware
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS in production
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
const oauthQueue = new WorkflowQueue('oauth_queue');
export class InteractiveAuth {
@DBOS.transaction()
static async initializeOAuthClient(): Promise<void> {
// Initialize OAuth manager
await OAuthManager.initialize(DBOS.knexClient, {
tenantId: 'oauth-system',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
// Configure client for authorization code flow
await OAuthManager.initializeClient('google', {
issuerUrl: 'https://accounts.google.com',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUris: ['https://yourapp.com/oauth/callback/google'],
scopes: ['profile', 'email', 'openid'],
supportedGrantTypes: ['authorization_code', 'refresh_token']
});
}
@DBOS.workflow()
static async startAuthWorkflow(userId?: string): Promise<{ authUrl: string; state: string }> {
// Start OAuth flow
const { authUrl, state } = await InteractiveAuth.startAuthTransaction();
// Optionally store user context with state
if (userId) {
await InteractiveAuth.storeUserContextTransaction(state, userId);
}
return { authUrl, state };
}
@DBOS.transaction()
static async startAuthTransaction(): Promise<{ authUrl: string; state: string }> {
return await OAuthManager.startAuthFlow('google', ['profile', 'email', 'openid']);
}
@DBOS.transaction()
static async storeUserContextTransaction(state: string, userId: string): Promise<void> {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'oauth-context',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
await client.saveSecret(`oauth-user-${state}`, userId, {
description: `User context for OAuth state ${state}`,
tags: ['oauth', 'temporary']
});
}
@DBOS.workflow()
static async handleCallbackWorkflow(callbackUrl: string, state: string): Promise<{
userId: string | null;
accessToken: string;
refreshToken?: string;
expiresAt?: number;
}> {
// Handle OAuth callback and exchange code for tokens
const tokens = await InteractiveAuth.handleCallbackTransaction(callbackUrl);
// Get user context if stored
const userId = await InteractiveAuth.getUserContextTransaction(state);
return {
userId,
accessToken: tokens.access_token!,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_at
};
}
@DBOS.transaction()
static async handleCallbackTransaction(callbackUrl: string): Promise<any> {
return await OAuthManager.handleCallback('google', callbackUrl);
}
@DBOS.transaction()
static async getUserContextTransaction(state: string): Promise<string | null> {
try {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'oauth-context',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
const userSecret = await client.getSecret(`oauth-user-${state}`);
const userId = userSecret.value;
// Clean up temporary state
await client.deleteSecret(`oauth-user-${state}`);
return userId;
} catch {
return null;
}
}
@DBOS.step()
static async getUserProfileStep(accessToken: string): Promise<any> {
// External API call to get user profile
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (!response.ok) {
throw new Error(`Failed to get user profile: ${response.statusText}`);
}
return await response.json();
}
@DBOS.workflow()
static async getUserProfileWorkflow(accessToken: string): Promise<any> {
return await InteractiveAuth.getUserProfileStep(accessToken);
}
}
// Express endpoints for OAuth flow
app.get('/auth/login', async (req: any, res: any): Promise<void> => {
try {
// Get user ID from session/request if available
const userId = req.session?.userId || req.query.user_id;
const handle = await DBOS.startWorkflow(InteractiveAuth, {queueName: oauthQueue.name})
.startAuthWorkflow(userId);
const result = await handle.getResult();
// Store state in session for security
req.session.oauthState = result.state;
// Redirect to OAuth provider
res.redirect(result.authUrl);
} catch (error) {
DBOS.logger.error(`OAuth login error: ${(error as Error).message}`);
res.status(500).json({ error: 'Failed to start OAuth flow' });
}
});
app.get('/oauth/callback/google', async (req: any, res: any): Promise<void> => {
try {
const callbackUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const state = req.query.state;
// Verify state matches what we stored in session
if (req.session.oauthState !== state) {
throw new Error('Invalid OAuth state');
}
// Clear state from session
delete req.session.oauthState;
// Handle the callback
const handle = await DBOS.startWorkflow(InteractiveAuth, {queueName: oauthQueue.name})
.handleCallbackWorkflow(callbackUrl, state);
const result = await handle.getResult();
if (result.userId) {
// Update user session with tokens
req.session.userId = result.userId;
req.session.accessToken = result.accessToken;
}
// Redirect to success page or dashboard
res.redirect('/dashboard?login=success');
} catch (error) {
DBOS.logger.error(`OAuth callback error: ${(error as Error).message}`);
res.redirect('/login?error=oauth_failed');
}
});
app.get('/auth/profile', async (req: any, res: any): Promise<void> => {
try {
if (!req.session.accessToken) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
// Get user profile using stored token
const handle = await DBOS.startWorkflow(InteractiveAuth, {queueName: oauthQueue.name})
.getUserProfileWorkflow(req.session.accessToken);
const profileData = await handle.getResult();
res.json(profileData);
} catch (error) {
DBOS.logger.error(`Profile error: ${(error as Error).message}`);
res.status(500).json({ error: 'Failed to get profile' });
}
});
app.get('/auth/logout', async (req: any, res: any): Promise<void> => {
try {
// Optional: Revoke tokens
if (req.session.accessToken) {
await OAuthManager.revokeTokens('google');
}
// Clear session
req.session.destroy((err: any) => {
if (err) {
DBOS.logger.error(`Session destroy error: ${err.message}`);
}
});
res.json({ message: 'Logged out successfully' });
} catch (error) {
DBOS.logger.error(`Logout error: ${(error as Error).message}`);
res.status(500).json({ error: 'Logout failed' });
}
});
async function main(): Promise<void> {
DBOS.setConfig({
name: 'oauth-app',
databaseUrl: process.env.DBOS_DATABASE_URL!
});
// Initialize OAuth client on startup
await InteractiveAuth.initializeOAuthClient();
await DBOS.launch({ expressApp: app });
const PORT = 3000;
app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
});
}
main().catch(console.log);Frontend Integration
// Frontend code to initiate OAuth flow
const startLogin = async (): Promise<void> => {
// Redirect to your login endpoint
window.location.href = '/auth/login';
};
// Check authentication status
const checkAuth = async (): Promise<any> => {
try {
const response = await fetch('/auth/profile');
if (response.ok) {
const profile = await response.json();
console.log('User authenticated:', profile);
return profile;
} else {
console.log('User not authenticated');
return null;
}
} catch (error) {
console.error('Auth check failed:', error);
return null;
}
};
// Logout
const logout = async (): Promise<void> => {
try {
await fetch('/auth/logout');
window.location.href = '/login';
} catch (error) {
console.error('Logout failed:', error);
}
};2. Client Credentials Grant (Machine-to-Machine Authentication)
Use Case: Server-to-server communication, API access, microservices, background jobs.
Features:
- No user interaction required
- Perfect for automated systems and service accounts
- Tokens are cached and automatically refreshed
export class ServiceAuth {
@DBOS.transaction()
static async setupClientCredentials(): Promise<void> {
// Configure client for client credentials flow
await OAuthManager.initializeClient('api-service', {
tokenEndpoint: 'https://auth.service.com/oauth/token',
clientId: process.env.SERVICE_CLIENT_ID!,
clientSecret: process.env.SERVICE_CLIENT_SECRET!,
redirectUris: [], // Not needed for client credentials
scopes: ['api:read', 'api:write'],
supportedGrantTypes: ['client_credentials']
});
}
@DBOS.transaction()
static async getServiceTokenTransaction(): Promise<string> {
// Get client credentials token
const tokens = await OAuthManager.getClientCredentialsToken('api-service', ['api:read', 'api:write']);
return tokens.access_token!;
}
@DBOS.step()
static async callProtectedAPIStep(accessToken: string, data: any): Promise<any> {
// Use token for API call (external service call in step)
const response = await fetch('https://api.service.com/data', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`API call failed: ${response.statusText}`);
}
return await response.json();
}
@DBOS.workflow()
static async processDataWorkflow(data: any): Promise<any> {
// Get valid token (automatically handles refresh)
const accessToken = await ServiceAuth.getServiceTokenTransaction();
// Call external API with token
const result = await ServiceAuth.callProtectedAPIStep(accessToken, data);
return result;
}
}3. Device Authorization Grant (Device Flow)
Use Case: Smart TVs, CLI tools, IoT devices, gaming consoles, devices without browsers.
Features:
- User enters code on a separate device (phone/computer)
- No need for embedded browser in the device
- Secure for devices with limited input capabilities
export class DeviceAuth {
@DBOS.transaction()
static async setupDeviceFlow(): Promise<void> {
// Configure client for device authorization flow
await OAuthManager.initializeClient('smart-tv', {
issuerUrl: 'https://accounts.google.com',
clientId: process.env.GOOGLE_TV_CLIENT_ID!,
clientSecret: process.env.GOOGLE_TV_CLIENT_SECRET!,
redirectUris: [], // Not needed for device flow
scopes: ['https://www.googleapis.com/auth/youtube.readonly'],
supportedGrantTypes: ['device_code']
});
}
@DBOS.transaction()
static async startDeviceAuthTransaction(): Promise<{
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
expiresIn: number;
deviceResponse: any;
}> {
// Initiate device flow
const deviceResponse = await OAuthManager.initiateDeviceFlow('smart-tv', ['https://www.googleapis.com/auth/youtube.readonly']);
return {
userCode: deviceResponse.user_code,
verificationUri: deviceResponse.verification_uri,
verificationUriComplete: deviceResponse.verification_uri_complete,
expiresIn: deviceResponse.expires_in,
deviceResponse // Store this for polling
};
}
@DBOS.transaction()
static async pollForApprovalTransaction(deviceResponse: any): Promise<{
success: boolean;
pending?: boolean;
expired?: boolean;
accessToken?: string;
error?: string;
}> {
try {
// Poll for authorization (user must approve on another device)
const tokens = await OAuthManager.pollDeviceToken('smart-tv', deviceResponse);
return {
success: true,
accessToken: tokens.access_token
};
} catch (error) {
const errorMessage = (error as Error).message;
if (errorMessage.includes('authorization_pending')) {
return { success: false, pending: true };
} else if (errorMessage.includes('expired_token')) {
return { success: false, expired: true };
} else {
return { success: false, error: errorMessage };
}
}
}
@DBOS.workflow()
static async deviceAuthWorkflow(): Promise<any> {
// Start device authentication
const deviceInfo = await DeviceAuth.startDeviceAuthTransaction();
// Display code to user (in real app, show on TV screen)
DBOS.logger.info(`Go to ${deviceInfo.verificationUri} and enter code: ${deviceInfo.userCode}`);
// Poll for user approval
let attempts = 0;
const maxAttempts = Math.floor(deviceInfo.expiresIn / 5); // Poll every 5 seconds
while (attempts < maxAttempts) {
await DBOS.sleep(5000); // Wait 5 seconds between polls
const result = await DeviceAuth.pollForApprovalTransaction(deviceInfo.deviceResponse);
if (result.success) {
return { success: true, accessToken: result.accessToken };
} else if (result.expired) {
return { success: false, error: 'Device code expired' };
} else if (!result.pending) {
return { success: false, error: result.error };
}
attempts++;
}
return { success: false, error: 'Timeout waiting for user approval' };
}
}4. Resource Owner Password Credentials (Legacy Authentication)
Use Case: Legacy system migration, trusted first-party applications, internal tools.
⚠️ Security Warning: This grant type should only be used when other flows are not possible, as it requires handling user passwords directly.
export class LegacyAuth {
@DBOS.transaction()
static async setupPasswordAuth(): Promise<void> {
// Configure client for password credentials flow
await OAuthManager.initializeClient('legacy-system', {
tokenEndpoint: 'https://legacy.service.com/oauth/token',
clientId: process.env.LEGACY_CLIENT_ID!,
clientSecret: process.env.LEGACY_CLIENT_SECRET!,
redirectUris: [], // Not needed for password flow
scopes: ['user:profile', 'user:data'],
supportedGrantTypes: ['password']
});
}
@DBOS.transaction()
static async authenticateUserTransaction(username: string, password: string): Promise<{
success: boolean;
accessToken?: string;
expiresAt?: number;
error?: string;
}> {
try {
// Get token using username/password
const tokens = await OAuthManager.getPasswordCredentialsToken(
'legacy-system',
username,
password,
['user:profile', 'user:data']
);
return {
success: true,
accessToken: tokens.access_token,
expiresAt: tokens.expires_at
};
} catch (error) {
return {
success: false,
error: 'Invalid credentials'
};
}
}
@DBOS.workflow()
static async legacyLoginWorkflow(username: string, password: string): Promise<any> {
return await LegacyAuth.authenticateUserTransaction(username, password);
}
}5. Generic Grant Request (Custom/Future Grant Types)
Use Case: Custom OAuth implementations, future grant types, proprietary authentication flows.
Features:
- Flexible parameter passing for any grant type
- Support for non-standard OAuth extensions
- Future-proof for new OAuth specifications
export class CustomAuth {
@DBOS.transaction()
static async setupCustomGrant(): Promise<void> {
// Configure client for generic grants
await OAuthManager.initializeClient('custom-service', {
tokenEndpoint: 'https://custom.service.com/oauth/token',
clientId: process.env.CUSTOM_CLIENT_ID!,
clientSecret: process.env.CUSTOM_CLIENT_SECRET!,
redirectUris: [],
scopes: ['custom:scope'],
supportedGrantTypes: ['generic']
});
}
@DBOS.transaction()
static async tokenExchangeTransaction(userToken: string, targetAudience: string): Promise<{
accessToken: string;
tokenType: string;
scope?: string;
}> {
// Use token exchange grant (RFC 8693)
const tokens = await OAuthManager.executeGenericGrant('custom-service', 'urn:ietf:params:oauth:grant-type:token-exchange', {
subject_token: userToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
audience: targetAudience,
scope: 'custom:scope'
});
return {
accessToken: tokens.access_token!,
tokenType: tokens.token_type!,
scope: tokens.scope
};
}
@DBOS.transaction()
static async jwtBearerGrantTransaction(jwtAssertion: string): Promise<any> {
// Use JWT bearer grant (RFC 7523)
return await OAuthManager.executeGenericGrant('custom-service', 'urn:ietf:params:oauth:grant-type:jwt-bearer', {
assertion: jwtAssertion,
scope: 'custom:scope'
});
}
@DBOS.workflow()
static async customAuthWorkflow(userToken: string, targetAudience: string): Promise<any> {
return await CustomAuth.tokenExchangeTransaction(userToken, targetAudience);
}
}Grant Type Selection Guide
Choose the appropriate grant type based on your use case:
| Use Case | Grant Type | Security | User Interaction | Best For | |----------|------------|----------|------------------|----------| | Web Apps | Authorization Code + PKCE | High | Required | User-facing applications | | Mobile Apps | Authorization Code + PKCE | High | Required | Native mobile applications | | API Services | Client Credentials | High | None | Server-to-server communication | | Smart TVs/IoT | Device Authorization | High | Limited | Devices without browsers | | Legacy Migration | Password Credentials | Low | Required | Migrating from legacy auth | | Custom Flows | Generic Grant | Varies | Varies | Non-standard implementations |
Security Best Practices
- Always use Authorization Code + PKCE for user-facing applications
- Use Client Credentials for service-to-service communication
- Avoid Password Credentials except for legacy migration scenarios
- Use Device Flow for input-constrained devices
- Validate all tokens and implement proper error handling
- Store tokens securely using the SDK's encrypted storage
- Implement token refresh logic for long-running applications
- Use HTTPS for all OAuth endpoints in production
- Validate OAuth state parameters to prevent CSRF attacks
- Set appropriate session timeouts for security
API Reference
PostgresSecretClient
Constructor
new PostgresSecretClient(knexClient: Knex, config: PostgresSecretClientConfig)Methods
Initialization
initialize(): Promise<void>- Initialize encryption and prepare for operationsinitializeSchema(): Promise<void>- Create database tables (run once per database)
Secret Management
saveSecret(name: string, value: string, metadata?: SecretCreationMetadata): Promise<SaveSecretResponse>getSecret(name: string): Promise<GenericSecret>fetchSecret(id: string): Promise<GenericSecret>updateSecret(name: string, updates: Partial<GenericSecret>): Promise<SaveSecretResponse>updateSecretById(id: string, updates: Partial<GenericSecret>): Promise<SaveSecretResponse>deleteSecret(name: string): Promise<void>deleteSecretById(id: string): Promise<void>
Discovery & Search
getAvailableSecrets(page?: number, per_page?: number): Promise<PaginatedResponse<SecretMetadata>>searchSecretsByName(name: string, page?: number, per_page?: number): Promise<PaginatedResponse<SecretMetadata>>secretExists(id: string): Promise<boolean>
Maintenance
cleanupAuditLogs(retentionDays?: number): Promise<number>- Remove old audit entries
OAuthManager
Static Methods
Initialization
// Initialize OAuth manager with database connection
static initialize(
knexClient: Knex,
config: PostgresSecretClientConfig,
logger?: Logger
): Promise<void>
// Initialize single OAuth client
static initializeClient(
service: string,
config: OAuthClientConfig
): Promise<openidClient.Configuration>
// Initialize multiple OAuth clients
static initializeAllClients(
oauthConfigs: Record<string, OAuthClientConfig>
): Promise<void>OAuth Flow Management
// Start OAuth authorization flow (Authorization Code Grant)
static startAuthFlow(
service: string,
scopes: string[],
redirectUri?: string
): Promise<{ authUrl: string; state: string }>
// Handle OAuth callback (Authorization Code Grant)
static handleCallback(
service: string,
callbackUrl: string
): Promise<TokenEndpointResponse>Client Credentials Grant
// Get access token using client credentials grant
static getClientCredentialsToken(
service: string,
scopes?: string[]
): Promise<TokenEndpointResponse>Device Authorization Grant
// Initiate device authorization flow
static initiateDeviceFlow(
service: string,
scopes?: string[]
): Promise<DeviceAuthorizationResponse>
// Poll for device authorization completion
static pollDeviceToken(
service: string,
deviceAuthorizationResponse: DeviceAuthorizationResponse
): Promise<TokenEndpointResponse>Password Credentials Grant
// Get access token using username/password (legacy systems only)
static getPasswordCredentialsToken(
service: string,
username: string,
password: string,
scopes?: string[]
): Promise<TokenEndpointResponse>Generic Grant Support
// Execute custom/future grant types
static executeGenericGrant(
service: string,
grantType: string,
parameters: Record<string, string>
): Promise<TokenEndpointResponse>Token Management
// Get valid access token (auto-refresh if needed)
static getValidAccessToken(service: string): Promise<string>
// Manually refresh access token
static refreshAccessToken(
service: string,
refreshToken: string
): Promise<TokenEndpointResponse>
// Revoke all tokens for service
static revokeTokens(service: string): Promise<void>State Management (Internal)
// Store OAuth state with PKCE verifier
static storeOAuthState(
stateId: string,
service: string,
codeVerifier?: string
): Promise<void>
// Retrieve OAuth state
static retrieveOAuthState(
stateId: string
): Promise<{ service: string; codeVerifier?: string }>OAuth Types
The SDK exports native openid-client types for OAuth token responses:
// Re-exported from openid-client for convenience
type TokenEndpointResponse = {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
id_token?: string;
[parameter: string]: unknown;
};
type DeviceAuthorizationResponse = {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
[parameter: string]: unknown;
};
// SDK configuration types
interface OAuthClientConfig {
// OIDC Discovery (optional)
issuerUrl?: string;
// Manual OAuth2 endpoints (required if issuerUrl not provided)
authorizationEndpoint?: string;
tokenEndpoint?: string;
revocationEndpoint?: string;
userinfoEndpoint?: string;
// Common OAuth2/OIDC fields
clientId: string;
clientSecret: string;
redirectUris: string[];
scopes: string[];
// Additional OAuth2 configuration
responseTypes?: string[];
grantTypes?: string[];
// Supported grant types
supportedGrantTypes?: (
'authorization_code' |
'client_credentials' |
'refresh_token' |
'device_code' |
'password' |
'generic'
)[];
}
interface Logger {
info(message: string): void;
warn(message: string): void;
error(message: string): void;
}PostgresSecretWorkflowHelper
Static helper methods for simple usage:
class PostgresSecretWorkflowHelper {
static async getSecret(knexClient: Knex, tenantId: string, name: string): Promise<GenericSecret>
static async saveSecret(knexClient: Knex, tenantId: string, name: string, value: string, metadata?: SecretCreationMetadata): Promise<SaveSecretResponse>
static async updateSecret(knexClient: Knex, tenantId: string, name: string, updates: Partial<GenericSecret>): Promise<SaveSecretResponse>
static async deleteSecret(knexClient: Knex, tenantId: string, name: string): Promise<void>
static async getAvailableSecrets(knexClient: Knex, tenantId: string, page?: number, per_page?: number): Promise<PaginatedResponse<SecretMetadata>>
static async searchSecretsByName(knexClient: Knex, tenantId: string, name: string, page?: number, per_page?: number): Promise<PaginatedResponse<SecretMetadata>>
static async initializeSchema(knexClient: Knex): Promise<void>
}Convenience Functions
// Direct exports for backward compatibility
export const getSecretFromPostgres = PostgresSecretWorkflowHelper.getSecret;
export const saveSecretToPostgres = PostgresSecretWorkflowHelper.saveSecret;
export const updateSecretInPostgres = PostgresSecretWorkflowHelper.updateSecret;
export const deleteSecretFromPostgres = PostgresSecretWorkflowHelper.deleteSecret;
export const getAvailableSecretsFromPostgres = PostgresSecretWorkflowHelper.getAvailableSecrets;
export const searchSecretsByNameFromPostgres = PostgresSecretWorkflowHelper.searchSecretsByName;
// OAuth Manager exports
export { OAuthManager } from './oauth-manager';
export type {
OAuthClientConfig,
TokenData,
OAuthState
} from './types';
// PostgreSQL Secret Client exports
export { PostgresSecretClient } from './postgres-secret-client';
export { PostgresSecretWorkflowHelper } from './postgres-secret-client';
export type {
PostgresSecretClientConfig,
GenericSecret,
SecretMetadata,
SaveSecretResponse,
PaginatedResponse,
SecretCreationMetadata
} from './types';Type Definitions
interface GenericSecret {
id: string;
tenant_id: string;
name: string;
value: string;
auth_type: string;
metadata?: {
description?: string;
tags?: string[];
created_by?: string;
[key: string]: any;
};
created_at: Date;
updated_at: Date;
is_deleted: boolean;
deleted_at?: Date;
}
interface SecretMetadata {
id: string;
name: string;
auth_type: string;
created_at: Date;
updated_at: Date;
metadata?: Record<string, any>;
}
interface SaveSecretResponse {
id: string;
tenant_id: string;
name: string;
created_at: Date;
updated_at: Date;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
per_page: number;
total: number;
total_pages: number;
};
}
interface SecretCreationMetadata {
description?: string;
tags?: string[];
created_by?: string;
[key: string]: any;
}Usage Patterns
Multi-tenant SaaS Application
export class TenantSecrets {
@DBOS.transaction()
static async setupTenantSecrets(tenantId: string, apiKeys: Record<string, string>) {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
// Save multiple secrets for a tenant
const results = [];
for (const [name, value] of Object.entries(apiKeys)) {
const result = await client.saveSecret(name, value, {
description: `${name} for tenant ${tenantId}`,
created_by: 'system',
tags: ['api-key', 'tenant-setup']
});
results.push(result);
}
return results;
}
@DBOS.transaction()
static async getTenantSecret(tenantId: string, secretName: string): Promise<string> {
const secret = await PostgresSecretWorkflowHelper.getSecret(
DBOS.knexClient,
tenantId,
secretName
);
return secret.value;
}
}Secret Rotation
export class SecretRotation {
@DBOS.transaction()
static async rotateApiKey(tenantId: string, secretName: string, newValue: string) {
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
// Update the secret with new value
const result = await client.updateSecret(secretName, {
value: newValue,
metadata: {
description: 'Rotated on ' + new Date().toISOString(),
tags: ['rotated']
}
});
return result;
}
}Batch Operations
export class BatchSecretOps {
@DBOS.transaction()
static async migrateSecrets(fromTenant: string, toTenant: string, secretNames: string[]) {
const sourceClient = new PostgresSecretClient(DBOS.knexClient, {
tenantId: fromTenant,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
const targetClient = new PostgresSecretClient(DBOS.knexClient, {
tenantId: toTenant,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await Promise.all([sourceClient.initialize(), targetClient.initialize()]);
const migrated = [];
for (const secretName of secretNames) {
try {
const secret = await sourceClient.getSecret(secretName);
const result = await targetClient.saveSecret(
secret.name,
secret.value,
{
...secret.metadata,
description: `Migrated from ${fromTenant}`
}
);
migrated.push(result);
} catch (error) {
console.warn(`Failed to migrate secret ${secretName}:`, error);
}
}
return migrated;
}
}Advanced Usage Patterns
OAuth Integration Workflows
Multi-Provider OAuth Setup
export class MultiProviderAuth {
@DBOS.transaction()
static async initializeAllProviders() {
// Initialize OAuth manager
await OAuthManager.initialize(DBOS.knexClient, {
tenantId: 'oauth-system',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
// Configure multiple providers
const oauthConfigs = {
google: {
issuerUrl: 'https://accounts.google.com',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/google`,
scope: ['profile', 'email', 'openid']
},
github: {
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
userinfoEndpoint: 'https://api.github.com/user',
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/github`,
scope: ['user:email', 'read:user']
},
microsoft: {
issuerUrl: 'https://login.microsoftonline.com/common/v2.0',
clientId: process.env.MICROSOFT_CLIENT_ID!,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/microsoft`,
scope: ['openid', 'profile', 'email']
},
slack: {
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
clientId: process.env.SLACK_CLIENT_ID!,
clientSecret: process.env.SLACK_CLIENT_SECRET!,
redirectUri: `${process.env.BASE_URL}/oauth/callback/slack`,
scope: ['users:read', 'channels:read']
}
};
await OAuthManager.initializeAllClients(oauthConfigs);
return Object.keys(oauthConfigs);
}
}OAuth Flow with User Context
export class UserOAuthFlow {
@DBOS.transaction()
static async initiateLogin(provider: string, userId: string) {
// Start OAuth flow
const { authUrl, state } = await OAuthManager.startAuthFlow(
provider,
provider === 'google' ? ['profile', 'email', 'openid'] :
provider === 'github' ? ['user:email', 'read:user'] :
['openid', 'profile', 'email']
);
// Store user context with state (using secrets for secure storage)
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'user-oauth-context',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
await client.saveSecret(`oauth-state-${state}`, JSON.stringify({
userId,
provider,
initiatedAt: new Date().toISOString()
}), {
description: `OAuth state for user ${userId}`,
tags: ['oauth', 'temporary']
});
return { authUrl, state };
}
@DBOS.transaction()
static async completeLogin(callbackUrl: string) {
// Extract state from callback URL
const url = new URL(callbackUrl);
const state = url.searchParams.get('state')!;
// Get user context
const client = new PostgresSecretClient(DBOS.knexClient, {
tenantId: 'user-oauth-context',
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await client.initialize();
const contextSecret = await client.getSecret(`oauth-state-${state}`);
const { userId, provider } = JSON.parse(contextSecret.value);
// Handle OAuth callback
const tokenData = await OAuthManager.handleCallback(provider, callbackUrl);
// Store tokens for user
const userClient = new PostgresSecretClient(DBOS.knexClient, {
tenantId: `user-${userId}`,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await userClient.initialize();
await userClient.saveSecret(`${provider}-tokens`, JSON.stringify(tokenData), {
description: `${provider} OAuth tokens`,
tags: ['oauth', 'tokens', provider]
});
// Clean up temporary state
await client.deleteSecret(`oauth-state-${state}`);
return { userId, provider, tokenData };
}
@DBOS.transaction()
static async getUserAccessToken(userId: string, provider: string): Promise<string> {
try {
// Try to get valid token directly from OAuth manager
return await OAuthManager.getValidAccessToken(provider);
} catch {
// Fallback to user-stored tokens
const userClient = new PostgresSecretClient(DBOS.knexClient, {
tenantId: `user-${userId}`,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await userClient.initialize();
const tokenSecret = await userClient.getSecret(`${provider}-tokens`);
const tokenData = JSON.parse(tokenSecret.value);
return tokenData.access_token;
}
}
}Service Integration with OAuth
export class ServiceIntegration {
@DBOS.transaction()
static async syncWithGoogleDrive(userId: string, folderId: string) {
// Get valid access token
const accessToken = await UserOAuthFlow.getUserAccessToken(userId, 'google');
// Use token with Google Drive API
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?parents=${folderId}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
throw new Error(`Google Drive API error: ${response.statusText}`);
}
const data = await response.json();
// Store results as secrets for caching
const userClient = new PostgresSecretClient(DBOS.knexClient, {
tenantId: `user-${userId}`,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await userClient.initialize();
await userClient.saveSecret(
`google-drive-cache-${folderId}`,
JSON.stringify(data),
{
description: `Google Drive folder ${folderId} cache`,
tags: ['cache', 'google-drive', 'temporary']
}
);
return data;
}
@DBOS.transaction()
static async postToSlack(userId: string, channel: string, message: string) {
const accessToken = await UserOAuthFlow.getUserAccessToken(userId, 'slack');
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
channel,
text: message
})
});
const result = await response.json();
if (!result.ok) {
throw new Error(`Slack API error: ${result.error}`);
}
return result;
}
}Token Lifecycle Management
export class TokenLifecycle {
@DBOS.transaction()
static async refreshAllExpiredTokens() {
const services = ['google', 'github', 'microsoft', 'slack'];
const results = [];
for (const service of services) {
try {
// This will automatically refresh if expired
const token = await OAuthManager.getValidAccessToken(service);
results.push({ service, status: 'valid', token });
} catch (error) {
results.push({ service, status: 'error', error: error.message });
}
}
return results;
}
@DBOS.transaction()
static async revokeUserTokens(userId: string, provider?: string) {
const userClient = new PostgresSecretClient(DBOS.knexClient, {
tenantId: `user-${userId}`,
rootKeyBase64: process.env.ROOT_KEY_BASE64!
});
await userClient.initialize();
if (provider) {
// Revoke specific provider tokens
try {
await OAuthManager.revokeTokens(provider);
await userClient.deleteSecret(`${provider}-tokens`);
} catch (error) {
console.warn(`Failed to revoke ${provider} tokens:`, error);
}
} else {
// Revoke all tokens for user
const providers = ['google', 'github', 'microsoft', 'slack'];
for (const prov of providers) {
try {
await OAuthManager.revokeTokens(prov);
await userClient.deleteSecret(`${prov}-tokens`);
} catch (error) {
console.warn(`Failed to revoke ${prov} tokens:`, error);
}
}
}
}
}Security Features
The SDK provides enterprise-grade security with the following features:
Encryption
- Envelope Encryption: Two-layer encryption using libsodium XSalsa20-Poly1305
- Individual Secret Keys: Each secret encrypted with its own unique key
- Root Key Management: Secure master key rotation without re-encrypting secrets
- AEAD Security: Authenticated encryption prevents tampering
Multi-tenant Security
- Tenant Isolation: All operations scoped to specific tenant ID
- Cross-tenant Protection: No data access across tenant boundaries
- Audit Separation: Complete audit trail isolation per tenant
- Row-level Security: Optional database-level tenant isolation
OAuth Security
- PKCE Implementation: Proof Key for Code Exchange prevents authorization code interception
- Secure Token Storage: OAuth tokens encrypted using same security as secrets
- Automatic Refresh: Transparent token renewal with fallback handling
- State Management: CSRF protection with secure random state generation
For detailed security architecture and implementation details, see DEVELOPER.md.
Database Integration
The SDK automatically creates and manages PostgreSQL tables:
secrets: Encrypted secret storage with tenant isolation and metadata supportsecret_audit_log: Comprehensive audit trail with 7-year retention- Automatic Schema Setup: Call
initializeSchema()once during deployment - Optimized Performance: Built-in indexing for fast tenant-scoped queries
- Row-level Security: Optional database-level tenant isolation
For complete database schema and optimization details, see DEVELOPER.md.
Testing
Run the comprehensive test suite:
# Run all tests
npm test
# Run with coverage report
npm run test:coverage
# Run PostgreSQL-specific tests
npm run test:postgres
# Run OAuth-specific tests
npm run test:oauth
# Run in watch mode during development
npm run test:watch
# Integration tests (requires running PostgreSQL)
npm run test:integrationTest Coverage
The SDK includes comprehensive testing with 81%+ code coverage:
- Unit Tests: All secret management and OAuth functionality
- Integration Tests: End-to-end workflows with PostgreSQL
- Security Tests: Encryption, tenant isolation, and OAuth flows
- Error Handling: Edge cases and recovery scenarios
- Performance Tests: Large dataset handling and optimization
For detailed testing infrastructure and development setup, see DEVELOPER.md.
Error Handling
The SDK provides comprehensive error handling:
try {
const secret = await client.getSecret('nonexistent');
} catch (error) {
if (error.message.includes('not found')) {
// Handle missing secret
} else if (error.message.includes('not initialized')) {
// Handle uninitialized client
} else {
// Handle other errors
}
}Common error scenarios:
- Uninitialized Client: Call
initialize()before operations - Missing Secrets: Check existence with
secretExists() - Tenant Isolation: Ensure correct tenant ID
- Encryption Errors: Validate root key format
- Database Errors: Check connection and permissions
Troubleshooting
Common Issues
1. "PostgresSecretClient not initialized"
// Always call initialize() first
await client.initialize();2. "ROOT_KEY_BASE64 environment variable is required"
# Generate and set the root key
ROOT_KEY_BASE64=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")
export ROOT_KEY_BASE643. "Secret not found"
// Check if secret exists first
const exists = await client.secretExists(secretId);
if (!exists) {
// Handle missing secret
}4. Database connection issues
- Ensure DBOS is properly configured with PostgreSQL
- Check that
DBOS.knexClientis available - Verify database permissions for table creation
5. OAuth configuration errors
// Validate OAuth configuration
const config = {
issuerUrl: 'https://accounts.google.com', // For OIDC
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/oauth/callback'
};6. Token management issues
// Handle token refresh errors
try {
const token = await OAuthManager.getValidAccessToken('google');
} catch (error) {
if (error.message.includes('refresh_token')) {
// User needs to re-authenticate
await OAuthManager.revokeTokens('google');
// Redirect to login
}
}Performance Tips
- Client Management: Create one client instance per tenant per workflow
- Batch Operations: Use DBOS transactions for multiple secret operations
- Pagination: Use appropriate page sizes (10-100) for large result sets
- Audit Cleanup: Regularly clean old audit logs with
cleanupAuditLogs() - Token Caching: OAuth tokens are automatically cached and refreshed
For detailed performance optimization strategies, see DEVELOPER.md.
