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;
}

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 @@ -60,6 +60,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
39 changes: 39 additions & 0 deletions apps/backend/lambdas/users/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import db from './db'
import { authenticateRequest, checkAuthorization, AuthContext } from './auth';


export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
Expand All @@ -21,12 +22,22 @@ 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 authCheck = checkAuthorization(authContext, 'ADMIN');
if (!authCheck.allowed) {
return authContext.isAuthenticated
? json(403, { message: authCheck.reason || 'Forbidden' })
: json(401, { message: 'Authentication required' });
}

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

// GET /{userId}
if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') {
const authCheck = checkAuthorization(authContext, 'ADMIN_OR_SELF');
if (!authCheck.allowed) {
return authContext.isAuthenticated
? json(403, { message: authCheck.reason || 'Forbidden' })
: json(401, { message: 'Authentication required' });
}

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

Expand All @@ -93,6 +111,13 @@ 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 authCheck = checkAuthorization(authContext, 'ADMIN_OR_SELF');
if (!authCheck.allowed) {
return authContext.isAuthenticated
? json(403, { message: authCheck.reason || 'Forbidden' })
: json(401, { message: 'Authentication required' });
}

const userId = normalizedPath.split('/')[1];
if (!userId) return json(400, { message: 'userId is required' });
const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};
Expand Down Expand Up @@ -120,6 +145,13 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// DELETE /users/{userId}
if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') {
const authCheck = checkAuthorization(authContext, 'ADMIN');
if (!authCheck.allowed) {
return authContext.isAuthenticated
? json(403, { message: authCheck.reason || 'Forbidden' })
: json(401, { message: 'Authentication required' });
}

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

Expand All @@ -134,6 +166,13 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// POST /users
if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') {
const authCheck = checkAuthorization(authContext, 'ADMIN');
if (!authCheck.allowed) {
return authContext.isAuthenticated
? json(403, { message: authCheck.reason || 'Forbidden' })
: json(401, { message: 'Authentication required' });
}

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