Skip to content

Commit a01f811

Browse files
authored
Mantine UI and standard auth (#7)
1 parent da3d295 commit a01f811

24 files changed

Lines changed: 1790 additions & 608 deletions

apps/backend-services/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"db:seed": "tsx prisma/seed.ts"
2626
},
2727
"dependencies": {
28-
"@bcgov/citz-imb-sso-express": "^1.0.2",
2928
"@nestjs/axios": "4.0.1",
3029
"@nestjs/common": "^11.0.1",
3130
"@nestjs/config": "^4.0.2",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Auth Module Overview
2+
3+
This folder contains the NestJS pieces that implement the confidential OAuth 2.0 Authorization Code flow against Keycloak. The backend acts as the confidential client and handles all exchanges with the identity provider; the frontend only ever receives the provider-issued tokens after the server validates and temporarily stores them.
4+
5+
## Flow Summary
6+
7+
1. `GET /auth/login` creates a signed `state` token + nonce and redirects the browser to Keycloak.
8+
2. Keycloak redirects back to `GET /auth/callback` with `code` and `state`.
9+
3. `AuthService.handleCallback` verifies `state`, exchanges the code for tokens, validates the ID token, and saves the token bundle in `AuthSessionStore`, returning an opaque `resultId`.
10+
4. The controller redirects the browser to the SPA with `?auth_result=resultId`.
11+
5. The SPA immediately calls `GET /auth/result?result=resultId` to retrieve the tokens; the entry is deleted after this call.
12+
6. Refreshes happen via `POST /auth/refresh`, which proxies the refresh token to Keycloak.
13+
7. Protected routes rely on `BCGovAuthGuard` and (optionally) `RolesGuard` to validate bearer tokens and enforce RBAC.
14+
15+
## Key Components
16+
17+
| File | Responsibility |
18+
| --- | --- |
19+
| `auth.service.ts` | Builds Keycloak URLs, exchanges codes, validates ID tokens, and manages one-time auth results. |
20+
| `auth.controller.ts` | Exposes `/auth/login`, `/auth/callback`, `/auth/result`, `/auth/refresh`, and `/auth/logout`. |
21+
| `auth-session.store.ts` | In-memory, short-lived cache used to hand provider tokens to the SPA exactly once. |
22+
| `bcgov-auth.guard.ts` | Validates incoming bearer tokens via JWKS, enforces issuer+audience, and projects normalized Keycloak roles onto the request. |
23+
| `roles.guard.ts` | Enforces role metadata from `@Roles` decorators. |
24+
| `dto/*.ts` | DTOs that validate every auth route payload/query via Nest's ValidationPipe. |
25+
26+
## Configuration Flags
27+
28+
- `SSO_*` variables configure the Keycloak realm, client id/secret, and redirect URIs.
29+
- `FRONTEND_URL` is used for redirecting the browser back to the SPA after login/logout.
30+
- `AUTH_STATE_SECRET` signs the state JWT; defaults to the client secret.
31+
- `AUTH_RESULT_TTL_SECONDS` controls how long an auth result can be redeemed (default 60).
32+
33+
## Validation & Guardrails
34+
35+
- Every auth controller route accepts a DTO (body or query) that is validated by the global `ValidationPipe`, preventing malformed requests from reaching the business logic.
36+
- `BCGovAuthGuard` now verifies both the issuer and the `aud` claim (matching `SSO_CLIENT_ID`) and normalizes Keycloak's realm/resource role claims into a single `roles` array for downstream guards.
37+
38+
## Testing Tips
39+
40+
- Ensure the Keycloak client redirect URI points to `/auth/callback` on the backend.
41+
- Watch backend logs during login; failures will emit `callback_failed` redirects.
42+
- Because `AuthSessionStore` is in-memory, a backend restart invalidates in-flight `auth_result` ids.
43+
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Injectable, NotFoundException } from "@nestjs/common";
2+
import { ConfigService } from "@nestjs/config";
3+
import { randomUUID } from "crypto";
4+
import type { TokenResponse } from "./auth.service";
5+
6+
interface StoredTokens {
7+
tokens: TokenResponse;
8+
expiresAt: number;
9+
}
10+
11+
/**
12+
* Lightweight in-memory store for short-lived auth results.
13+
* Each entry is consumed exactly once by the SPA immediately after OAuth redirect.
14+
* This avoids persisting sessions server-side while still preventing token leakage in URLs.
15+
*/
16+
@Injectable()
17+
export class AuthSessionStore {
18+
private readonly ttlMs: number;
19+
private readonly store = new Map<string, StoredTokens>();
20+
21+
constructor(private configService: ConfigService) {
22+
const ttlSeconds = Number(
23+
this.configService.get<string>("AUTH_RESULT_TTL_SECONDS") ?? "60",
24+
);
25+
this.ttlMs = ttlSeconds * 1000;
26+
27+
// Background sweeper keeps the map from growing unbounded if the SPA never redeems an id.
28+
setInterval(() => this.cleanupExpired(), this.ttlMs).unref();
29+
}
30+
31+
/**
32+
* Saves provider tokens and returns a one-time opaque identifier.
33+
*/
34+
save(tokens: TokenResponse): string {
35+
const id = randomUUID();
36+
this.store.set(id, {
37+
tokens,
38+
expiresAt: Date.now() + this.ttlMs,
39+
});
40+
return id;
41+
}
42+
43+
/**
44+
* Reads and immediately deletes a stored token bundle.
45+
*/
46+
consume(id: string): TokenResponse {
47+
const entry = this.store.get(id);
48+
if (!entry || entry.expiresAt < Date.now()) {
49+
this.store.delete(id);
50+
throw new NotFoundException("Auth result expired or invalid");
51+
}
52+
53+
this.store.delete(id);
54+
return entry.tokens;
55+
}
56+
57+
private cleanupExpired(): void {
58+
const now = Date.now();
59+
for (const [id, entry] of this.store.entries()) {
60+
if (entry.expiresAt < now) {
61+
this.store.delete(id);
62+
}
63+
}
64+
}
65+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
Controller,
3+
Post,
4+
Body,
5+
Get,
6+
Query,
7+
Res,
8+
HttpStatus,
9+
} from "@nestjs/common";
10+
import { Response } from "express";
11+
import { AuthService } from "./auth.service";
12+
import { Public } from "./public.decorator";
13+
import {
14+
AuthResultQueryDto,
15+
LogoutQueryDto,
16+
OAuthCallbackQueryDto,
17+
RefreshTokenDto,
18+
} from "./dto";
19+
20+
/**
21+
* Thin HTTP layer that exposes the OAuth entrypoints to the frontend.
22+
* All routes are public because authorization happens via bearer tokens on other controllers.
23+
*/
24+
@Controller("auth")
25+
export class AuthController {
26+
constructor(private readonly authService: AuthService) {}
27+
28+
/**
29+
* Backend endpoint the SPA uses to refresh provider tokens.
30+
* Delegates to AuthService so only the backend interacts with Keycloak using the client secret.
31+
*/
32+
@Public()
33+
@Post("refresh")
34+
async refreshToken(@Body() body: RefreshTokenDto) {
35+
const tokens = await this.authService.refreshAccessToken(
36+
body.refresh_token,
37+
);
38+
return {
39+
access_token: tokens.access_token,
40+
refresh_token: tokens.refresh_token,
41+
id_token: tokens.id_token,
42+
expires_in: tokens.expires_in,
43+
};
44+
}
45+
46+
/**
47+
* Redirects the browser to the Keycloak authorization endpoint.
48+
* Using a backend redirect keeps client_id/secret pairing server-side (no exposed secrets).
49+
*/
50+
@Public()
51+
@Get("login")
52+
async getLoginUrl(@Res() res: Response) {
53+
try {
54+
const loginUrl = this.authService.getLoginUrl();
55+
res.redirect(loginUrl);
56+
} catch {
57+
res
58+
.status(HttpStatus.INTERNAL_SERVER_ERROR)
59+
.json({ error: "Failed to generate login URL" });
60+
}
61+
}
62+
63+
/**
64+
* Drives the browser through the Keycloak logout endpoint (if an ID token hint is provided).
65+
* This ensures realm sessions are terminated and the SPA gets a clean slate.
66+
*/
67+
@Public()
68+
@Get("logout")
69+
async logout(@Query() query: LogoutQueryDto, @Res() res: Response) {
70+
try {
71+
const logoutUrl = this.authService.getLogoutUrl(query.id_token_hint);
72+
res.redirect(logoutUrl);
73+
} catch {
74+
res
75+
.status(HttpStatus.INTERNAL_SERVER_ERROR)
76+
.json({ error: "Failed to generate logout URL" });
77+
}
78+
}
79+
80+
/**
81+
* Receives the redirect from Keycloak after the user authenticates.
82+
* Converts the authorization code into tokens and then bounces the browser back to the SPA
83+
* with an opaque `auth_result` identifier.
84+
*/
85+
@Public()
86+
@Get("callback")
87+
async oauthCallback(
88+
@Query() query: OAuthCallbackQueryDto,
89+
@Res() res: Response,
90+
) {
91+
try {
92+
const resultId = await this.authService.handleCallback(
93+
query.code,
94+
query.state,
95+
);
96+
const redirectUrl = this.authService.buildAuthResultRedirect(resultId);
97+
return res.redirect(redirectUrl);
98+
} catch (error) {
99+
console.error("OAuth callback handling failed:", error);
100+
const redirectUrl =
101+
this.authService.buildErrorRedirect("callback_failed");
102+
return res.redirect(redirectUrl);
103+
}
104+
}
105+
106+
/**
107+
* One-time endpoint the SPA calls immediately after redirect to retrieve the provider tokens.
108+
* The `resultId` is invalidated after the first successful read to keep the flow stateless.
109+
*/
110+
@Public()
111+
@Get("result")
112+
async consumeResult(@Query() query: AuthResultQueryDto) {
113+
return this.authService.consumeAuthResult(query.result);
114+
}
115+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ import { ConfigModule } from "@nestjs/config";
33
import { APP_GUARD } from "@nestjs/core";
44
import { BCGovAuthGuard } from "./bcgov-auth.guard";
55
import { RolesGuard } from "./roles.guard";
6+
import { AuthController } from "./auth.controller";
7+
import { AuthService } from "./auth.service";
8+
import { AuthSessionStore } from "./auth-session.store";
69

710
@Module({
811
imports: [ConfigModule],
12+
controllers: [AuthController],
913
providers: [
14+
AuthService,
15+
AuthSessionStore,
1016
{
1117
provide: APP_GUARD,
1218
useClass: BCGovAuthGuard,
@@ -16,5 +22,6 @@ import { RolesGuard } from "./roles.guard";
1622
useClass: RolesGuard,
1723
},
1824
],
25+
exports: [AuthService],
1926
})
2027
export class AuthModule {}

0 commit comments

Comments
 (0)