Skip to content

Commit 02b2c3a

Browse files
authored
Merge pull request #3 from bcgov/alex-feature-work
front end, api libraries, auth
2 parents 88b1ae6 + cdcd528 commit 02b2c3a

39 files changed

Lines changed: 3687 additions & 1716 deletions

apps/backend-services/.env.sample

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
# Database Configuration
22
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ai_doc_intelligence?schema=public"
33

4-
# Application Configuration
5-
PORT=3002
6-
NODE_ENV=development
7-
8-
# CORS Configuration
4+
# BC Gov SSO Express Configuration
95
FRONTEND_URL=http://localhost:3000
10-
11-
# Storage Configuration
12-
UPLOAD_DESTINATION=./storage/uploads
13-
14-
# OCR Configuration (if needed)
15-
AZURE_FORM_RECOGNIZER_ENDPOINT=
16-
AZURE_FORM_RECOGNIZER_KEY=
6+
BACKEND_URL=http://localhost:3002
7+
SSO_AUTH_SERVER_URL=https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect
8+
SSO_REALM=standard
9+
SSO_CLIENT_ID=xxxxxxxxxxxxxxxx
10+
SSO_CLIENT_SECRET=xxxxxx

apps/backend-services/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,18 @@
2424
"db:seed": "tsx prisma/seed.ts"
2525
},
2626
"dependencies": {
27+
"@bcgov/citz-imb-sso-express": "^1.0.2",
2728
"@nestjs/common": "^11.0.1",
2829
"@nestjs/config": "^4.0.2",
2930
"@nestjs/core": "^11.0.1",
30-
"@nestjs/platform-fastify": "^11.0.1",
31+
"@nestjs/platform-express": "^11.1.9",
3132
"@prisma/client": "^6.19.0",
3233
"class-transformer": "^0.5.1",
3334
"class-validator": "^0.14.0",
35+
"cookie-parser": "^1.4.7",
3436
"dotenv": "^17.2.3",
35-
"fastify": "^5.0.0",
37+
"jsonwebtoken": "^9.0.2",
38+
"jwks-rsa": "^3.2.0",
3639
"pg": "^8.16.3",
3740
"prisma": "^6.19.0",
3841
"reflect-metadata": "^0.1.13",
@@ -46,6 +49,7 @@
4649
"@swc/cli": "^0.7.9",
4750
"@swc/core": "^1.15.0",
4851
"@types/jest": "^29.5.11",
52+
"@types/jsonwebtoken": "^9.0.10",
4953
"@types/node": "^22.0.0",
5054
"@types/uuid": "^9.0.8",
5155
"eslint-config-custom": "file:../../packages/eslint-config-custom",

apps/backend-services/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
3+
import { AuthModule } from './auth/auth.module';
34
import { UploadModule } from './upload/upload.module';
45
import { DatabaseModule } from './database/database.module';
56
import { DocumentModule } from './document/document.module';
@@ -13,6 +14,7 @@ import { OcrModule } from './ocr/ocr.module';
1314
envFilePath: '.env',
1415
cache: true,
1516
}),
17+
AuthModule,
1618
DatabaseModule,
1719
DocumentModule,
1820
QueueModule,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { APP_GUARD } from '@nestjs/core';
4+
import { BCGovAuthGuard } from './bcgov-auth.guard';
5+
import { RolesGuard } from './roles.guard';
6+
7+
@Module({
8+
imports: [ConfigModule],
9+
providers: [
10+
{
11+
provide: APP_GUARD,
12+
useClass: BCGovAuthGuard,
13+
},
14+
{
15+
provide: APP_GUARD,
16+
useClass: RolesGuard,
17+
},
18+
],
19+
})
20+
export class AuthModule {}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
UnauthorizedException,
6+
ForbiddenException,
7+
} from '@nestjs/common';
8+
import { Request } from 'express';
9+
import { Reflector } from '@nestjs/core';
10+
import { ConfigService } from '@nestjs/config';
11+
import * as jwt from 'jsonwebtoken';
12+
import { JwksClient } from 'jwks-rsa';
13+
import { IS_PUBLIC_KEY } from './public.decorator';
14+
15+
interface User {
16+
idir_username?: string;
17+
display_name?: string;
18+
email?: string;
19+
roles?: string[];
20+
[key: string]: unknown; // Allow additional properties from JWT
21+
}
22+
23+
declare module 'express' {
24+
interface Request {
25+
user?: User;
26+
}
27+
}
28+
29+
@Injectable()
30+
export class BCGovAuthGuard implements CanActivate {
31+
private jwksClient: JwksClient;
32+
33+
constructor(
34+
private configService: ConfigService,
35+
private reflector: Reflector,
36+
) {
37+
const ssoAuthServerUrl = this.configService.get<string>('SSO_AUTH_SERVER_URL');
38+
39+
// If SSO_AUTH_SERVER_URL includes the full OIDC path, extract the base realm URL
40+
let jwksUri: string;
41+
if (ssoAuthServerUrl.includes('/protocol/openid-connect')) {
42+
// SSO_AUTH_SERVER_URL is the full OIDC endpoint
43+
jwksUri = ssoAuthServerUrl.replace('/protocol/openid-connect', '') + '/protocol/openid-connect/certs';
44+
} else {
45+
// SSO_AUTH_SERVER_URL is the base Keycloak URL
46+
const realm = this.configService.get<string>('SSO_REALM');
47+
jwksUri = `${ssoAuthServerUrl}/realms/${realm}/protocol/openid-connect/certs`;
48+
}
49+
50+
this.jwksClient = new JwksClient({
51+
jwksUri,
52+
cache: true,
53+
cacheMaxAge: 86400000, // 24 hours
54+
});
55+
}
56+
57+
async canActivate(context: ExecutionContext): Promise<boolean> {
58+
// Check if route is public
59+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
60+
context.getHandler(),
61+
context.getClass(),
62+
]);
63+
64+
if (isPublic) {
65+
return true;
66+
}
67+
68+
const request = context.switchToHttp().getRequest<Request>();
69+
const authHeader = request.headers['authorization'];
70+
71+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
72+
throw new UnauthorizedException('No Bearer token provided');
73+
}
74+
75+
const token = authHeader.substring(7);
76+
77+
try {
78+
const user = await this.validateToken(token);
79+
// Attach user to request (like Express middleware does)
80+
request.user = user;
81+
return true;
82+
} catch {
83+
throw new ForbiddenException('Invalid token');
84+
}
85+
}
86+
87+
private async validateToken(token: string): Promise<User> {
88+
try {
89+
// Decode token header to get key ID
90+
const decoded = jwt.decode(token, { complete: true });
91+
if (!decoded || !decoded.header.kid) {
92+
throw new UnauthorizedException('Invalid token format');
93+
}
94+
95+
// Get signing key
96+
const key = await this.jwksClient.getSigningKey(decoded.header.kid);
97+
const signingKey = key.getPublicKey();
98+
99+
// Determine the correct issuer
100+
const ssoAuthServerUrl = this.configService.get<string>('SSO_AUTH_SERVER_URL');
101+
let expectedIssuer: string;
102+
if (ssoAuthServerUrl.includes('/protocol/openid-connect')) {
103+
// SSO_AUTH_SERVER_URL is the full OIDC endpoint, issuer is the realm URL
104+
expectedIssuer = ssoAuthServerUrl.replace('/protocol/openid-connect', '');
105+
} else {
106+
// SSO_AUTH_SERVER_URL is the base Keycloak URL
107+
const realm = this.configService.get<string>('SSO_REALM');
108+
expectedIssuer = `${ssoAuthServerUrl}/realms/${realm}`;
109+
}
110+
111+
// Verify and decode token
112+
const verified = jwt.verify(token, signingKey, {
113+
algorithms: ['RS256'],
114+
issuer: expectedIssuer,
115+
});
116+
117+
return verified as User;
118+
} catch {
119+
throw new UnauthorizedException('Token validation failed');
120+
}
121+
}
122+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { SetMetadata, MethodDecorator, ClassDecorator } from '@nestjs/common';
2+
3+
export const IS_PUBLIC_KEY = 'isPublic';
4+
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { SetMetadata, MethodDecorator, ClassDecorator } from '@nestjs/common';
2+
3+
export const ROLES_KEY = 'roles';
4+
export const Roles = (...roles: string[]): MethodDecorator & ClassDecorator => SetMetadata(ROLES_KEY, roles);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
2+
import { Reflector } from '@nestjs/core';
3+
import { ROLES_KEY } from './roles.decorator';
4+
5+
@Injectable()
6+
export class RolesGuard implements CanActivate {
7+
constructor(private reflector: Reflector) {}
8+
9+
canActivate(context: ExecutionContext): boolean {
10+
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
11+
context.getHandler(),
12+
context.getClass(),
13+
]);
14+
15+
if (!requiredRoles) {
16+
return true;
17+
}
18+
19+
const request = context.switchToHttp().getRequest();
20+
const user = request.user;
21+
22+
if (!user || !user.roles) {
23+
throw new ForbiddenException('User has no roles');
24+
}
25+
26+
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
27+
28+
if (!hasRole) {
29+
throw new ForbiddenException('Insufficient permissions');
30+
}
31+
32+
return true;
33+
}
34+
}

apps/backend-services/src/database/database.service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ export class DatabaseService implements OnModuleInit {
7272
}
7373
}
7474

75+
async findAllDocuments(): Promise<DocumentData[]> {
76+
this.logger.debug('=== DatabaseService.findAllDocuments ===');
77+
78+
try {
79+
const documents = await this.prisma.document.findMany({
80+
orderBy: { created_at: 'desc' },
81+
});
82+
83+
this.logger.debug(`Found ${documents.length} documents`);
84+
this.logger.debug('=== DatabaseService.findAllDocuments completed ===');
85+
86+
return documents;
87+
} catch (error) {
88+
this.logger.error(`Failed to find documents: ${error.message}`, error.stack);
89+
throw error;
90+
}
91+
}
92+
7593
async updateDocument(
7694
id: string,
7795
data: Partial<Omit<DocumentData, 'id' | 'created_at'>>,

apps/backend-services/src/document/document.controller.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
HttpStatus,
77
Logger,
88
NotFoundException,
9+
Req,
910
} from '@nestjs/common';
10-
import { DatabaseService } from '../database/database.service';
11+
import { Request } from 'express';
12+
import { Roles } from '../auth/roles.decorator';
13+
import { DatabaseService, DocumentData } from '../database/database.service';
1114
import { OcrResult } from '../ocr/ocr.service';
1215

1316
@Controller('api')
@@ -16,6 +19,71 @@ export class DocumentController {
1619

1720
constructor(private readonly databaseService: DatabaseService) {}
1821

22+
@Get('protected')
23+
getProtectedData(@Req() req: Request): {
24+
message: string;
25+
user: {
26+
idirUsername?: string;
27+
displayName?: string;
28+
email?: string;
29+
};
30+
} {
31+
const user = req.user; // Contains decoded token
32+
return {
33+
message: 'Protected data',
34+
user: {
35+
idirUsername: user?.idir_username,
36+
displayName: user?.display_name,
37+
email: user?.email,
38+
}
39+
};
40+
}
41+
42+
@Get('admin')
43+
@Roles('admin')
44+
getAdminData(@Req() req: Request): {
45+
message: string;
46+
user: {
47+
idirUsername?: string;
48+
displayName?: string;
49+
email?: string;
50+
roles: string[];
51+
};
52+
} {
53+
const user = req.user;
54+
return {
55+
message: 'Admin only data',
56+
user: {
57+
idirUsername: user?.idir_username,
58+
displayName: user?.display_name,
59+
email: user?.email,
60+
roles: user?.roles || [],
61+
}
62+
};
63+
}
64+
65+
@Get('documents')
66+
@HttpCode(HttpStatus.OK)
67+
async getAllDocuments(): Promise<DocumentData[]> {
68+
this.logger.debug('=== DocumentController.getAllDocuments ===');
69+
70+
try {
71+
const documents = await this.databaseService.findAllDocuments();
72+
73+
this.logger.debug(`Retrieved ${documents.length} documents`);
74+
this.logger.debug('=== DocumentController.getAllDocuments completed ===');
75+
76+
return documents;
77+
} catch (error) {
78+
this.logger.error(`Error retrieving documents: ${error.message}`);
79+
this.logger.error(`Stack: ${error.stack}`);
80+
81+
throw new NotFoundException(
82+
error.message || 'Failed to retrieve documents',
83+
);
84+
}
85+
}
86+
1987
@Get('documents/:documentId/ocr')
2088
@HttpCode(HttpStatus.OK)
2189
async getOcrResult(@Param('documentId') documentId: string): Promise<OcrResult> {

0 commit comments

Comments
 (0)