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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/backend/lambdas/users/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { APIGatewayProxyEvent } from 'aws-lambda';
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import db from './db';

// Load from environment variables
const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || '';
const COGNITO_REGION = process.env.AWS_REGION || 'us-east-2';
const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || '';

// Create verifier instance lazily (only when needed)
let verifier: any = null;

function getVerifier() {
if (!verifier) {
if (!COGNITO_USER_POOL_ID) {
throw new Error('COGNITO_USER_POOL_ID environment variable is not set');
}
verifier = CognitoJwtVerifier.create({
userPoolId: COGNITO_USER_POOL_ID,
tokenUse: 'access',
clientId: COGNITO_CLIENT_ID,
});
}
return verifier;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either you'll need env vars to run this, or jus tmmock cognito

}

export interface AuthenticatedUser {
cognitoSub: string;
userId?: number;
email?: string;
isAdmin: boolean;
cognitoGroups?: string[];
}

export interface AuthContext {
user?: AuthenticatedUser;
isAuthenticated: boolean;
}

/**
* Extract JWT token from Authorization header
*/
function extractToken(event: any): string | null {
const authHeader = event.headers?.Authorization || event.headers?.authorization;

if (!authHeader) {
return null;
}

const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
return parts[1];
}

return authHeader;
}

/**
* Verify and decode Cognito JWT token, then load user from database
*/
export async function authenticateRequest(event: any): Promise<AuthContext> {
const token = extractToken(event);

if (!token) {
return { isAuthenticated: false };
}

try {
const payload = await getVerifier().verify(token);

const dbUser = await db
.selectFrom('branch.users')
.where('cognito_sub', '=', payload.sub)
.selectAll()
.executeTakeFirst();

if (!dbUser) {
console.warn('User authenticated with Cognito but not found in database:', payload.sub);
return { isAuthenticated: false };
}

const user: AuthenticatedUser = {
cognitoSub: payload.sub,
userId: dbUser.user_id,
email: payload.email as string | undefined,
isAdmin: dbUser.is_admin === true,
cognitoGroups: payload['cognito:groups'] as string[] | undefined,
};

if (user.cognitoGroups?.includes('Admins')) {
user.isAdmin = true;
}

return {
user,
isAuthenticated: true,
};
} catch (error) {
console.error('Token verification failed:', error);
return { isAuthenticated: false };
}
}

/**
* Authorization helpers for different access levels
*/
export type AccessLevel = 'PUBLIC' | 'AUTHENTICATED' | 'ADMIN' | 'SELF' | 'ADMIN_OR_SELF';

export interface AuthorizationCheck {
allowed: boolean;
reason?: string;
}

/**
* Check if user is authorized for a given access level
* @param authContext - The authentication context
* @param requiredAccess - Required access level
* @param resourceUserId - The user_id of the resource being accessed (for SELF/ADMIN_OR_SELF checks)
*/
export function checkAuthorization(
authContext: AuthContext,
requiredAccess: AccessLevel,
resourceUserId?: number | string
): AuthorizationCheck {
if (requiredAccess === 'PUBLIC') {
return { allowed: true };
}

// All other access levels require authentication
if (!authContext.isAuthenticated || !authContext.user) {
return {
allowed: false,
reason: 'Authentication required'
};
}

const { user } = authContext;

switch (requiredAccess) {
case 'AUTHENTICATED':
return { allowed: true };

case 'ADMIN':
if (!user.isAdmin) {
return {
allowed: false,
reason: 'Admin access required'
};
}
return { allowed: true };

case 'SELF':
if (!resourceUserId) {
return {
allowed: false,
reason: 'Resource user ID required for SELF access check'
};
}
if (user.userId !== Number(resourceUserId)) {
return {
allowed: false,
reason: 'Can only access own resources'
};
}
return { allowed: true };

case 'ADMIN_OR_SELF':
if (!resourceUserId) {
return {
allowed: false,
reason: 'Resource user ID required for ADMIN_OR_SELF access check'
};
}
// Admin can access anything, or user can access their own resources
if (user.isAdmin || user.userId === Number(resourceUserId)) {
return { allowed: true };
}
return {
allowed: false,
reason: 'Admin access or resource ownership required'
};

default:
return {
allowed: false,
reason: 'Unknown access level'
};
}
}
1 change: 1 addition & 0 deletions apps/backend/lambdas/users/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface BranchProjects {
}

export interface BranchUsers {
cognito_sub: string | null;
created_at: Generated<Timestamp | null>;
email: string;
is_admin: Generated<boolean | null>;
Expand Down
32 changes: 30 additions & 2 deletions apps/backend/lambdas/users/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import db from './db'
import { authenticateRequest, checkAuthorization, AuthContext } from './auth';

function requireAuth(authContext: AuthContext, level: Parameters<typeof checkAuthorization>[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined {
const authCheck = checkAuthorization(authContext, level, resourceUserId);
if (!authCheck.allowed) {
return authContext.isAuthenticated
? json(403, { message: authCheck.reason || 'Forbidden' })
: json(401, { message: 'Authentication required' });
}
}


export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
Expand All @@ -21,12 +31,18 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(200, { ok: true, timestamp: new Date().toISOString() });
}

const authContext: AuthContext = await authenticateRequest(event);


// >>> ROUTES-START (do not remove this marker)
// CLI-generated routes will be inserted here


// GET /users
if ((normalizedPath === '/users' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') {
const authError = requireAuth(authContext, 'ADMIN');
if (authError) return authError;

// TODO: Add your business logic here
const queryParams = event.queryStringParameters || {};
const page = queryParams.page ? parseInt(queryParams.page, 10) : null;
Expand Down Expand Up @@ -72,7 +88,10 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// GET /{userId}
if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') {
const userId = normalizedPath.split('/')[1];
const userId = normalizedPath.split('/')[1];
const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId);
if (authError) return authError;

if (!userId) return json(400, { message: 'userId is required' });

const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst();
Expand All @@ -94,6 +113,9 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
// PATCH /{userId} (dev server strips /users prefix)
if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'PATCH') {
const userId = normalizedPath.split('/')[1];
const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId);
if (authError) return authError;

if (!userId) return json(400, { message: 'userId is required' });
const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};

Expand All @@ -120,7 +142,10 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// DELETE /users/{userId}
if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') {
const userId = normalizedPath.split('/')[1]; // Change from [2] to [1]
const authError = requireAuth(authContext, 'ADMIN');
if (authError) return authError;

const userId = normalizedPath.split('/')[1];
if (!userId) return json(400, { message: 'userId is required' });

const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute();
Expand All @@ -134,6 +159,9 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// POST /users
if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') {
const authError = requireAuth(authContext, 'ADMIN');
if (authError) return authError;

const body = event.body
? (JSON.parse(event.body) as Record<string, unknown>)
: {};
Expand Down
Loading
Loading