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
7 changes: 4 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@
"version": "0.0.1",
"author": "",
"dependencies": {
"@prisma/client": "^6.13.0",
"prisma": "^6.13.0",
"@aws-sdk/client-s3": "^3.859.0",
"@aws-sdk/s3-request-presigner": "^3.859.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.1.5",
"@nestjs/swagger": "^11.2.0",
"@trycompai/db": "^1.3.7",
"@prisma/client": "^6.13.0",
"@trycompai/db": "^1.3.17",
"archiver": "^7.0.1",
"axios": "^1.12.2",
"better-auth": "^1.3.27",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
"jose": "^6.0.12",
"prisma": "^6.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PeopleModule } from './people/people.module';
import { DevicesModule } from './devices/devices.module';
import { DeviceAgentModule } from './device-agent/device-agent.module';
import { awsConfig } from './config/aws.config';
import { betterAuthConfig } from './config/better-auth.config';
import { HealthModule } from './health/health.module';
import { OrganizationModule } from './organization/organization.module';
import { PoliciesModule } from './policies/policies.module';
Expand All @@ -23,7 +24,8 @@ import { TaskTemplateModule } from './framework-editor/task-template/task-templa
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [awsConfig],
// .env file is loaded manually in main.ts before NestJS starts
load: [awsConfig, betterAuthConfig],
validationOptions: {
allowUnknown: true,
abortEarly: true,
Expand Down
122 changes: 113 additions & 9 deletions apps/api/src/auth/hybrid-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,32 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { db } from '@trycompai/db';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { ApiKeyService } from './api-key.service';
import type { BetterAuthConfig } from '../config/better-auth.config';
import { AuthenticatedRequest } from './types';

@Injectable()
export class HybridAuthGuard implements CanActivate {
constructor(private readonly apiKeyService: ApiKeyService) {}
private readonly betterAuthUrl: string;

constructor(
private readonly apiKeyService: ApiKeyService,
private readonly configService: ConfigService,
) {
const betterAuthConfig =
this.configService.get<BetterAuthConfig>('betterAuth');
this.betterAuthUrl =
betterAuthConfig?.url || process.env.BETTER_AUTH_URL || '';

if (!this.betterAuthUrl) {
console.warn(
'[HybridAuthGuard] BETTER_AUTH_URL not configured. JWT authentication will fail.',
);
}
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
Expand Down Expand Up @@ -61,20 +79,71 @@ export class HybridAuthGuard implements CanActivate {
authHeader: string,
): Promise<boolean> {
try {
// Validate BETTER_AUTH_URL is configured
if (!this.betterAuthUrl) {
console.error(
'[HybridAuthGuard] BETTER_AUTH_URL environment variable is not set',
);
throw new UnauthorizedException(
'Authentication configuration error: BETTER_AUTH_URL not configured',
);
}

// Extract token from "Bearer <token>"
const token = authHeader.substring(7);

// Create JWKS for token verification using Better Auth endpoint
const JWKS = createRemoteJWKSet(
new URL(`${process.env.BETTER_AUTH_URL}/api/auth/jwks`),
);
const jwksUrl = `${this.betterAuthUrl}/api/auth/jwks`;

// Verify JWT token
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.BETTER_AUTH_URL,
audience: process.env.BETTER_AUTH_URL,
// Create JWKS for token verification using Better Auth endpoint
// Use shorter cache time to handle key rotation better
const JWKS = createRemoteJWKSet(new URL(jwksUrl), {
cacheMaxAge: 60000, // 1 minute cache (default is 5 minutes)
cooldownDuration: 10000, // 10 seconds cooldown before refetching
});

// Verify JWT token with automatic retry on key mismatch
let payload;
try {
payload = (
await jwtVerify(token, JWKS, {
issuer: this.betterAuthUrl,
audience: this.betterAuthUrl,
})
).payload;
} catch (verifyError: any) {
// If we get a key mismatch error, retry with a fresh JWKS fetch
if (
verifyError.code === 'ERR_JWKS_NO_MATCHING_KEY' ||
verifyError.message?.includes('no applicable key found') ||
verifyError.message?.includes('JWKSNoMatchingKey')
) {
console.log(
'[HybridAuthGuard] Key mismatch detected, fetching fresh JWKS and retrying...',
);

// Create a fresh JWKS instance with no cache to force immediate fetch
const freshJWKS = createRemoteJWKSet(new URL(jwksUrl), {
cacheMaxAge: 0, // No cache - force fresh fetch
cooldownDuration: 0, // No cooldown - allow immediate retry
});

// Retry verification with fresh keys
payload = (
await jwtVerify(token, freshJWKS, {
issuer: this.betterAuthUrl,
audience: this.betterAuthUrl,
})
).payload;

console.log(
'[HybridAuthGuard] Successfully verified token with fresh JWKS',
);
} else {
// Re-throw if it's not a key mismatch error
throw verifyError;
}
}

// Extract user information from JWT payload (user data is directly in payload for Better Auth JWT)
const userId = payload.id as string;
const userEmail = payload.email as string;
Expand Down Expand Up @@ -112,6 +181,41 @@ export class HybridAuthGuard implements CanActivate {
return true;
} catch (error) {
console.error('JWT verification failed:', error);

// Provide more helpful error messages
if (error instanceof Error) {
// Connection errors
if (
error.message.includes('ECONNREFUSED') ||
error.message.includes('fetch failed')
) {
console.error(
`[HybridAuthGuard] Cannot connect to Better Auth JWKS endpoint at ${this.betterAuthUrl}/api/auth/jwks`,
);
console.error(
'[HybridAuthGuard] Make sure BETTER_AUTH_URL is set correctly and the Better Auth server is running',
);
throw new UnauthorizedException(
`Cannot connect to authentication service. Please check BETTER_AUTH_URL configuration.`,
);
}

// Key mismatch errors should have been handled by retry logic above
// If we still get one here, it means the retry also failed (token truly invalid)
if (
(error as any).code === 'ERR_JWKS_NO_MATCHING_KEY' ||
error.message.includes('no applicable key found') ||
error.message.includes('JWKSNoMatchingKey')
) {
console.error(
'[HybridAuthGuard] Token key not found even after fetching fresh JWKS. Token may be from a different environment or truly invalid.',
);
throw new UnauthorizedException(
'Authentication token is invalid. Please log out and log back in to refresh your session.',
);
}
}

throw new UnauthorizedException('Invalid or expired JWT token');
}
}
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/config/better-auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { registerAs } from '@nestjs/config';
import { z } from 'zod';

const betterAuthConfigSchema = z.object({
url: z.string().url('BETTER_AUTH_URL must be a valid URL'),
});

export type BetterAuthConfig = z.infer<typeof betterAuthConfigSchema>;

export const betterAuthConfig = registerAs(
'betterAuth',
(): BetterAuthConfig => {
const url = process.env.BETTER_AUTH_URL;

if (!url) {
throw new Error('BETTER_AUTH_URL environment variable is required');
}

const config = { url };

// Validate configuration at startup
const result = betterAuthConfigSchema.safeParse(config);

if (!result.success) {
throw new Error(
`Better Auth configuration validation failed: ${result.error.issues
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')}`,
);
}

return result.data;
},
);
17 changes: 16 additions & 1 deletion apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@ import { NestFactory } from '@nestjs/core';
import type { OpenAPIObject } from '@nestjs/swagger';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import { config } from 'dotenv';
import path from 'path';
import { AppModule } from './app.module';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import path from 'path';

// Load .env file from apps/api directory before anything else
// This ensures .env values override any shell environment variables
// __dirname in compiled code is dist/src, so go up two levels to apps/api
const envPath = path.join(__dirname, '..', '..', '.env');
if (existsSync(envPath)) {
config({ path: envPath, override: true });
} else {
// Fallback: try current working directory (when run from apps/api)
const cwdEnvPath = path.join(process.cwd(), '.env');
if (existsSync(cwdEnvPath)) {
config({ path: cwdEnvPath, override: true });
}
}

async function bootstrap(): Promise<void> {
const app: INestApplication = await NestFactory.create(AppModule);
Expand Down
4 changes: 2 additions & 2 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^2.0.0",
"@ai-sdk/groq": "^2.0.0",
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/openai": "^2.0.65",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/react": "^2.0.60",
"@ai-sdk/rsc": "^1.0.0",
Expand Down Expand Up @@ -51,7 +51,7 @@
"@tiptap/extension-table-row": "^3.4.4",
"@trigger.dev/react-hooks": "4.0.6",
"@trigger.dev/sdk": "4.0.6",
"@trycompai/db": "^1.3.7",
"@trycompai/db": "^1.3.17",
"@trycompai/email": "workspace:*",
"@types/canvas-confetti": "^1.9.0",
"@types/react-syntax-highlighter": "^15.5.13",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/actions/organization/invite-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ActionResponse } from '../types';

const inviteMemberSchema = z.object({
email: z.string().email(),
role: z.enum(['owner', 'admin', 'auditor', 'employee']),
role: z.enum(['owner', 'admin', 'auditor', 'employee', 'contractor']),
});

export const inviteMember = authActionClient
Expand Down
7 changes: 4 additions & 3 deletions apps/app/src/actions/policies/publish-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ export const publishAllPoliciesAction = authActionClient
where: {
organizationId: parsedInput.organizationId,
isActive: true,
role: {
contains: Role.employee,
},
OR: [
{ role: { contains: Role.employee } },
{ role: { contains: Role.contractor } },
],
},
include: {
user: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow
const canRemove = !isOwner;

const isEmployee = currentRoles.includes('employee');
const isContractor = currentRoles.includes('contractor');

const handleDialogItemSelect = () => {
focusRef.current = dropdownTriggerRef.current;
Expand Down Expand Up @@ -142,7 +143,7 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow
<div className="min-w-0 flex-1 gap-2">
<div className="flex items-center flex-wrap gap-1.5">
<span className="truncate text-sm font-medium">{memberName}</span>
{isEmployee && (
{(isEmployee || isContractor) && (
<Link
href={`/${orgId}/people/${memberId}`}
className="text-xs text-blue-600 hover:underline flex-shrink-0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,7 @@ export function MultiRoleCombobox({
selectedRoles.length > 0 ? `${selectedRoles.length} selected` : placeholder || 'Select role(s)';

const filteredRoles = availableRoles.filter((role) => {
const label = (() => {
switch (role.value) {
case 'admin':
return 'Admin';
case 'auditor':
return 'Auditor';
case 'employee':
return 'Employee';
case 'contractor':
return 'Contractor';
case 'owner':
return 'Owner';
default:
return role.value;
}
})();
const label = getRoleLabel(role.value);
return label.toLowerCase().includes(searchTerm.toLowerCase());
});

Expand Down
Loading
Loading