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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/green-banks-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Add static admin API key for programmatic access (ADMIN_API_KEY env var)
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ WORKOS_REDIRECT_URI=http://localhost:3000/auth/callback
# Users with these emails will have access to /admin/* endpoints
# ADMIN_EMAILS=admin@example.com,owner@example.com

# Admin API Key (for programmatic admin access without browser session)
# Generate with: node -e "console.log('aao_' + require('crypto').randomBytes(32).toString('base64url'))"
# Use as: Authorization: Bearer <key>
# ADMIN_API_KEY=aao_your_secret_key_here

# Allow insecure cookies for local development (HTTP instead of HTTPS)
# Set to true when running locally with Docker over HTTP
# ALLOW_INSECURE_COOKIES=true
Expand Down
268 changes: 70 additions & 198 deletions server/public/admin-api-keys.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,45 +31,6 @@
font-size: var(--text-sm);
margin-bottom: var(--space-6);
}
.org-selector {
margin-bottom: var(--space-6);
}
.org-selector label {
display: block;
margin-bottom: var(--space-2);
font-weight: 500;
color: var(--color-text-heading);
}
.org-selector select {
width: 100%;
max-width: 400px;
padding: var(--space-2.5) var(--space-3);
border: var(--border-1) solid var(--color-gray-200);
border-radius: var(--radius-md);
font-size: var(--text-sm);
background: var(--color-bg-card);
}
.widget-container {
min-height: 400px;
border: var(--border-1) solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--color-text-secondary);
}
.error-message {
background: var(--color-error-50);
border: var(--border-1) solid var(--color-error-200);
color: var(--color-error-700);
padding: var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.info-box {
background: var(--color-primary-50);
border: var(--border-1) solid var(--color-primary-200);
Expand All @@ -85,6 +46,35 @@
border-radius: var(--radius-sm);
font-family: var(--font-mono);
}
.code-block {
background: var(--color-gray-900);
color: var(--color-gray-100);
padding: var(--space-4);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--text-sm);
overflow-x: auto;
margin: var(--space-4) 0;
}
.code-block .comment {
color: var(--color-gray-500);
}
.code-block .string {
color: var(--color-success-400);
}
.section {
margin-bottom: var(--space-6);
}
.section h2 {
font-size: var(--text-lg);
color: var(--color-text-heading);
margin-bottom: var(--space-3);
}
.section p {
color: var(--color-text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.permissions-list {
margin-top: var(--space-4);
}
Expand Down Expand Up @@ -123,181 +113,63 @@
<div class="container">
<div class="card">
<h1>API Keys</h1>
<p class="subtitle">Manage API keys for programmatic access to the admin API</p>
<p class="subtitle">Programmatic access to the admin API</p>

<div class="info-box">
<strong>How API Keys Work:</strong>
<p style="margin: var(--space-2) 0 0 0;">
API keys are organization-scoped and can be used to access the admin API without a browser session.
API keys provide programmatic access to admin endpoints without requiring a browser session.
Use them in the <code>Authorization: Bearer &lt;key&gt;</code> header.
</p>
<div class="permissions-list">
<h3>Available Permissions</h3>
<ul>
<li><code>admin:*</code> - Full admin access (read and write)</li>
<li><code>admin:read</code> - Read-only admin access</li>
</ul>
</div>
</div>

<div id="dev-mode-warning" class="error-message" style="display: none; background: var(--color-warning-50); border-color: var(--color-warning-200); color: var(--color-warning-700);">
<strong>Dev Mode:</strong> API key management requires WorkOS authentication.
In production, go to the <a href="https://dashboard.workos.com" target="_blank" style="color: var(--color-warning-700); font-weight: 600;">WorkOS Dashboard</a> → Organizations → API Keys to create keys.
</div>
<div class="section">
<h2>Admin API Key</h2>
<p>
Set the <code>ADMIN_API_KEY</code> environment variable to enable programmatic admin access.
This key grants full admin permissions to all API endpoints.
</p>

<div class="org-selector">
<label for="org-select">Select Organization</label>
<select id="org-select">
<option value="">Loading organizations...</option>
</select>
<div class="code-block">
<span class="comment"># Generate a secure key</span><br>
node -e "console.log('aao_' + require('crypto').randomBytes(32).toString('base64url'))"<br><br>
<span class="comment"># Add to .env.local</span><br>
ADMIN_API_KEY=<span class="string">aao_your_generated_key</span><br><br>
<span class="comment"># Use in API requests</span><br>
curl -H <span class="string">"Authorization: Bearer $ADMIN_API_KEY"</span> \<br>
&nbsp;&nbsp;https://agenticadvertising.org/api/admin/users
</div>
</div>

<div id="widget-error" class="error-message" style="display: none;"></div>
<div class="section">
<h2>Usage Examples</h2>

<div class="code-block">
<span class="comment"># List all users</span><br>
curl -H <span class="string">"Authorization: Bearer $ADMIN_API_KEY"</span> \<br>
&nbsp;&nbsp;/api/admin/users<br><br>
<span class="comment"># Get organization details</span><br>
curl -H <span class="string">"Authorization: Bearer $ADMIN_API_KEY"</span> \<br>
&nbsp;&nbsp;/api/admin/organizations<br><br>
<span class="comment"># View analytics</span><br>
curl -H <span class="string">"Authorization: Bearer $ADMIN_API_KEY"</span> \<br>
&nbsp;&nbsp;/api/admin/analytics/dashboard
</div>
</div>

<div id="widget-container" class="widget-container">
<div class="loading">Select an organization to manage API keys</div>
<div class="permissions-list">
<h3>API Endpoints Available</h3>
<ul>
<li><code>GET /api/admin/users</code> - List all users</li>
<li><code>GET /api/admin/organizations</code> - List organizations</li>
<li><code>GET /api/admin/analytics/*</code> - Analytics data</li>
<li><code>GET /api/admin/working-groups</code> - Working groups</li>
<li><code>POST /api/admin/*</code> - All write operations</li>
</ul>
</div>
</div>
</div>
</main>
</div>

<script type="module">

let widgetToken = null;
let currentOrgId = null;

// Fetch organizations
async function loadOrganizations() {
try {
const response = await fetch('/api/admin/organizations');
if (!response.ok) throw new Error('Failed to load organizations');
const organizations = await response.json();

const select = document.getElementById('org-select');
select.innerHTML = '<option value="">Select an organization...</option>';

// API returns array directly
const orgList = Array.isArray(organizations) ? organizations : (organizations.organizations || []);
orgList.forEach(org => {
const option = document.createElement('option');
option.value = org.workos_organization_id;
option.textContent = org.name;
select.appendChild(option);
});
} catch (error) {
console.error('Error loading organizations:', error);
document.getElementById('org-select').innerHTML =
'<option value="">Error loading organizations</option>';
}
}

// Get widget token for selected organization
async function getWidgetToken(organizationId) {
try {
const response = await fetch('/api/admin/widgets/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
organizationId,
scope: 'widgets:api-keys:manage'
})
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to get widget token');
}

const data = await response.json();
return data.token;
} catch (error) {
console.error('Error getting widget token:', error);
throw error;
}
}

// Render the API Keys widget
async function renderWidget(organizationId) {
const container = document.getElementById('widget-container');
const errorDiv = document.getElementById('widget-error');
const devWarning = document.getElementById('dev-mode-warning');

errorDiv.style.display = 'none';
devWarning.style.display = 'none';
container.innerHTML = '<div class="loading">Loading API Keys...</div>';

try {
widgetToken = await getWidgetToken(organizationId);
currentOrgId = organizationId;

// Show success with link to WorkOS dashboard
container.innerHTML = `
<div style="text-align: center; padding: var(--space-8);">
<div style="font-size: 48px; margin-bottom: var(--space-4);">✅</div>
<p style="color: var(--color-text-heading); font-weight: 500; margin-bottom: var(--space-4);">
Widget token generated successfully!
</p>
<p style="color: var(--color-text-secondary); margin-bottom: var(--space-4);">
To manage API keys for this organization:
</p>
<a href="https://dashboard.workos.com/organizations/${organizationId}/api-keys"
target="_blank"
style="display: inline-block; background: var(--color-brand); color: white; padding: var(--space-3) var(--space-5); border-radius: var(--radius-md); text-decoration: none; font-weight: 500;">
Open WorkOS Dashboard →
</a>
<p style="color: var(--color-text-muted); margin-top: var(--space-6); font-size: var(--text-sm);">
Organization ID: <code style="background: var(--color-gray-100); padding: 2px 6px; border-radius: 4px;">${organizationId}</code>
</p>
</div>
`;
} catch (error) {
// Check if it's a dev mode / user membership error
if (error.message.includes('UserOrganizationMembership') || error.message.includes('not found')) {
devWarning.style.display = 'block';
container.innerHTML = `
<div style="text-align: center; padding: var(--space-8);">
<div style="font-size: 48px; margin-bottom: var(--space-4);">🔑</div>
<p style="color: var(--color-text-heading); font-weight: 500; margin-bottom: var(--space-4);">
Create API Keys in WorkOS Dashboard
</p>
<p style="color: var(--color-text-secondary); margin-bottom: var(--space-4); max-width: 500px; margin-left: auto; margin-right: auto;">
API keys are managed through WorkOS. Click below to go to the organization's API keys page.
</p>
<a href="https://dashboard.workos.com/organizations/${organizationId}/api-keys"
target="_blank"
style="display: inline-block; background: var(--color-brand); color: white; padding: var(--space-3) var(--space-5); border-radius: var(--radius-md); text-decoration: none; font-weight: 500;">
Open WorkOS Dashboard →
</a>
<div style="margin-top: var(--space-6); padding: var(--space-4); background: var(--color-gray-50); border-radius: var(--radius-md); text-align: left; max-width: 500px; margin-left: auto; margin-right: auto;">
<p style="font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0 0 var(--space-2) 0;"><strong>When creating an API key, add these permissions:</strong></p>
<ul style="margin: 0; padding-left: var(--space-5); font-size: var(--text-sm); color: var(--color-text-secondary);">
<li><code>admin:*</code> - Full admin API access</li>
<li><code>admin:read</code> - Read-only admin API access</li>
</ul>
</div>
</div>
`;
} else {
errorDiv.textContent = error.message;
errorDiv.style.display = 'block';
container.innerHTML = '<div class="loading">Failed to load widget</div>';
}
}
}

// Event listeners
document.getElementById('org-select').addEventListener('change', (e) => {
if (e.target.value) {
renderWidget(e.target.value);
} else {
document.getElementById('widget-container').innerHTML =
'<div class="loading">Select an organization to manage API keys</div>';
}
});

// Initialize
loadOrganizations();
</script>
</body>
</html>
48 changes: 47 additions & 1 deletion server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,17 +255,56 @@ function setSessionCookie(res: Response, sealedSession: string) {
});
}

// Static admin API key for internal tooling (bypasses WorkOS)
const ADMIN_API_KEY = process.env.ADMIN_API_KEY;
if (ADMIN_API_KEY) {
logger.info('Admin API key configured for programmatic access');
}

/**
* Check if request has a valid static admin API key
* Returns true if the Bearer token matches ADMIN_API_KEY
*/
function hasValidAdminApiKey(req: Request): boolean {
if (!ADMIN_API_KEY) return false;
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) return false;
const token = authHeader.slice(7);
// Don't match WorkOS API keys - those are handled separately
if (token.startsWith('wos_api_key_')) return false;
return token === ADMIN_API_KEY;
}

/**
* Middleware to require authentication
* Checks for WorkOS session cookie and loads user info
* Also accepts WorkOS API keys as Bearer token for programmatic access
* Also accepts static admin API key (ADMIN_API_KEY env var) for internal tooling
* Uses in-memory cache to reduce WorkOS API calls for session refresh
* Automatically refreshes expired access tokens using the refresh token
*/
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const isHtmlRequest = req.accepts('html') && !req.path.startsWith('/api/');

// Check for WorkOS API key first (for programmatic access)
// Check for static admin API key first (for internal tooling)
if (hasValidAdminApiKey(req)) {
logger.debug({ path: req.path }, 'Authenticated via static admin API key');
req.user = {
id: 'admin_api_key',
email: 'admin-api-key@internal',
firstName: 'Admin',
lastName: 'API Key',
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
req.accessToken = 'admin-api-key';
// Mark this request as using the static admin API key for requireAdmin check
(req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey = true;
return next();
}

// Check for WorkOS API key (for programmatic access)
const apiKey = await validateWorkOSApiKey(req);
if (apiKey) {
logger.debug({ path: req.path, apiKeyId: apiKey.id }, 'Authenticated via WorkOS API key');
Expand Down Expand Up @@ -587,12 +626,19 @@ export function requireRole(...allowedRoles: Array<'owner' | 'admin' | 'member'>
/**
* Middleware to require admin access
* Must be used after requireAuth
* Accepts static admin API key (ADMIN_API_KEY env var) for internal tooling
* Accepts WorkOS API keys with 'admin:*' permission for programmatic access
* Or checks if user's email is in ADMIN_EMAILS list
*/
export async function requireAdmin(req: Request, res: Response, next: NextFunction) {
const isHtmlRequest = req.accepts('html') && !req.path.startsWith('/api/');

// Check for static admin API key (set by requireAuth)
if ((req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey) {
logger.debug({ path: req.path, method: req.method }, 'Admin access via static admin API key');
return next();
}

// Check for WorkOS API key with admin permission
const apiKey = (req as Request & { apiKey?: ValidatedApiKey }).apiKey;
if (apiKey) {
Expand Down