Skip to content

Commit e9a6848

Browse files
committed
Add dynamic Node version and improve SAML SSO error handling
Dockerfiles and GitHub Actions workflow now use a dynamic Node.js version via build args, reading from .nvmrc for consistency. SAML SSO controller adds workspace ID validation, improved error handling, and clearer error responses for SSO initiation and ACS callback. Also documents REDIS_URL in environment types.
1 parent 76232df commit e9a6848

File tree

5 files changed

+194
-113
lines changed

5 files changed

+194
-113
lines changed

.github/workflows/build-and-push-docker-image.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,19 @@ jobs:
4848
type=semver,pattern={{version}}
4949
type=semver,pattern={{major}}.{{minor}}
5050
51+
- name: Read Node.js version from .nvmrc
52+
id: node_version
53+
run: |
54+
NODE_VERSION=$(cat api/.nvmrc | tr -d 'v')
55+
echo "version=${NODE_VERSION}" >> $GITHUB_OUTPUT
56+
5157
- name: Build and push image
5258
uses: docker/build-push-action@v3
5359
with:
54-
context: .
55-
file: docker/Dockerfile.prod
60+
context: ./api
61+
file: ./api/docker/Dockerfile.prod
62+
build-args: |
63+
NODE_VERSION=${{ steps.node_version.outputs.version }}
5664
tags: ${{ steps.meta.outputs.tags }}
5765
labels: ${{ steps.meta.outputs.labels }}
5866
push: ${{ github.ref == 'refs/heads/stage' || github.ref == 'refs/heads/prod' || startsWith(github.ref, 'refs/tags/v') }}

docker/Dockerfile.dev

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
FROM node:22-alpine as builder
1+
ARG NODE_VERSION=24.11.1
2+
FROM node:${NODE_VERSION}-alpine as builder
23

34
WORKDIR /usr/src/app
45
RUN apk add --no-cache git gcc g++ python3 make musl-dev
@@ -7,7 +8,7 @@ COPY package.json yarn.lock ./
78

89
RUN yarn install
910

10-
FROM node:22-alpine
11+
FROM node:${NODE_VERSION}-alpine
1112

1213
WORKDIR /usr/src/app
1314

docker/Dockerfile.prod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
FROM node:22-alpine as builder
1+
ARG NODE_VERSION=24.11.1
2+
FROM node:${NODE_VERSION}-alpine as builder
23

34
WORKDIR /usr/src/app
45
RUN apk add --no-cache git gcc g++ python3 make musl-dev
@@ -11,7 +12,7 @@ COPY . .
1112

1213
RUN yarn build
1314

14-
FROM node:22-alpine
15+
FROM node:${NODE_VERSION}-alpine
1516

1617
WORKDIR /usr/src/app
1718

src/sso/saml/controller.ts

Lines changed: 170 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from 'express';
22
import { v4 as uuid } from 'uuid';
3+
import { ObjectId } from 'mongodb';
34
import SamlService from './service';
45
import samlStore from './store';
56
import { ContextFactories } from '../../types/graphql';
@@ -26,6 +27,16 @@ export default class SamlController {
2627
this.factories = factories;
2728
}
2829

30+
/**
31+
* Validate workspace ID format
32+
*
33+
* @param workspaceId - workspace ID to validate
34+
* @returns true if valid, false otherwise
35+
*/
36+
private isValidWorkspaceId(workspaceId: string): boolean {
37+
return ObjectId.isValid(workspaceId);
38+
}
39+
2940
/**
3041
* Compose Assertion Consumer Service URL for workspace
3142
*
@@ -41,147 +52,199 @@ export default class SamlController {
4152
* Initiate SSO login (GET /auth/sso/saml/:workspaceId)
4253
*/
4354
public async initiateLogin(req: express.Request, res: express.Response): Promise<void> {
44-
const { workspaceId } = req.params;
45-
const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`;
55+
try {
56+
const { workspaceId } = req.params;
57+
const returnUrl = (req.query.returnUrl as string) || `/workspace/${workspaceId}`;
4658

47-
/**
48-
* 1. Check if workspace has SSO enabled
49-
*/
50-
const workspace = await this.factories.workspacesFactory.findById(workspaceId);
59+
/**
60+
* Validate workspace ID format
61+
*/
62+
if (!this.isValidWorkspaceId(workspaceId)) {
63+
res.status(400).json({ error: 'Invalid workspace ID' });
64+
return;
65+
}
5166

52-
if (!workspace || !workspace.sso?.enabled) {
53-
res.status(400).json({ error: 'SSO is not enabled for this workspace' });
54-
return;
55-
}
67+
/**
68+
* 1. Check if workspace has SSO enabled
69+
*/
70+
const workspace = await this.factories.workspacesFactory.findById(workspaceId);
5671

57-
/**
58-
* 2. Compose Assertion Consumer Service URL
59-
*/
60-
const acsUrl = this.getAcsUrl(workspaceId);
61-
const relayStateId = uuid();
72+
if (!workspace || !workspace.sso?.enabled) {
73+
res.status(400).json({ error: 'SSO is not enabled for this workspace' });
74+
return;
75+
}
6276

63-
/**
64-
* 3. Save RelayState to temporary storage
65-
*/
66-
samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId });
77+
/**
78+
* 2. Compose Assertion Consumer Service URL
79+
*/
80+
const acsUrl = this.getAcsUrl(workspaceId);
81+
const relayStateId = uuid();
6782

68-
/**
69-
* 4. Generate AuthnRequest
70-
*/
71-
const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest(
72-
workspaceId,
73-
acsUrl,
74-
relayStateId,
75-
workspace.sso.saml
76-
);
83+
/**
84+
* 3. Save RelayState to temporary storage
85+
*/
86+
samlStore.saveRelayState(relayStateId, { returnUrl, workspaceId });
7787

78-
/**
79-
* 5. Save AuthnRequest ID for InResponseTo validation
80-
*/
81-
samlStore.saveAuthnRequest(requestId, workspaceId);
88+
/**
89+
* 4. Generate AuthnRequest
90+
*/
91+
const { requestId, encodedRequest } = await this.samlService.generateAuthnRequest(
92+
workspaceId,
93+
acsUrl,
94+
relayStateId,
95+
workspace.sso.saml
96+
);
8297

83-
/**
84-
* 6. Redirect to IdP
85-
*/
86-
const redirectUrl = new URL(workspace.sso.saml.ssoUrl);
87-
redirectUrl.searchParams.set('SAMLRequest', encodedRequest);
88-
redirectUrl.searchParams.set('RelayState', relayStateId);
98+
/**
99+
* 5. Save AuthnRequest ID for InResponseTo validation
100+
*/
101+
samlStore.saveAuthnRequest(requestId, workspaceId);
102+
103+
/**
104+
* 6. Redirect to IdP
105+
*/
106+
const redirectUrl = new URL(workspace.sso.saml.ssoUrl);
107+
redirectUrl.searchParams.set('SAMLRequest', encodedRequest);
108+
redirectUrl.searchParams.set('RelayState', relayStateId);
89109

90-
res.redirect(redirectUrl.toString());
110+
res.redirect(redirectUrl.toString());
111+
} catch (error) {
112+
console.error('SSO initiation error:', {
113+
workspaceId: req.params.workspaceId,
114+
error: error instanceof Error ? error.message : 'Unknown error',
115+
});
116+
res.status(500).json({ error: 'Failed to initiate SSO login' });
117+
}
91118
}
92119

93120
/**
94121
* Handle ACS callback (POST /auth/sso/saml/:workspaceId/acs)
95122
*/
96123
public async handleAcs(req: express.Request, res: express.Response): Promise<void> {
97-
const { workspaceId } = req.params;
98-
const samlResponse = req.body.SAMLResponse as string;
99-
const relayStateId = req.body.RelayState as string;
100-
101-
/**
102-
* 1. Get workspace SSO configuration and check if SSO is enabled
103-
*/
104-
const workspace = await this.factories.workspacesFactory.findById(workspaceId);
105-
106-
if (!workspace || !workspace.sso?.enabled) {
107-
res.status(400).json({ error: 'SSO is not enabled' });
108-
return;
109-
}
124+
try {
125+
const { workspaceId } = req.params;
126+
const samlResponse = req.body.SAMLResponse as string;
127+
const relayStateId = req.body.RelayState as string;
110128

111-
/**
112-
* 2. Validate and parse SAML Response
113-
*/
114-
const acsUrl = this.getAcsUrl(workspaceId);
129+
/**
130+
* Validate workspace ID format
131+
*/
132+
if (!this.isValidWorkspaceId(workspaceId)) {
133+
res.status(400).json({ error: 'Invalid workspace ID' });
134+
return;
135+
}
115136

116-
let samlData: SamlResponseData;
137+
/**
138+
* Validate required SAML response
139+
*/
140+
if (!samlResponse) {
141+
res.status(400).json({ error: 'SAML response is required' });
142+
return;
143+
}
117144

118-
try {
119145
/**
120-
* Validate and parse SAML Response
121-
* Note: InResponseTo validation is done separately after parsing
146+
* 1. Get workspace SSO configuration and check if SSO is enabled
122147
*/
123-
samlData = await this.samlService.validateAndParseResponse(
124-
samlResponse,
125-
workspaceId,
126-
acsUrl,
127-
workspace.sso.saml
128-
);
148+
const workspace = await this.factories.workspacesFactory.findById(workspaceId);
149+
150+
if (!workspace || !workspace.sso?.enabled) {
151+
res.status(400).json({ error: 'SSO is not enabled for this workspace' });
152+
return;
153+
}
129154

130155
/**
131-
* Validate InResponseTo against stored AuthnRequest
156+
* 2. Validate and parse SAML Response
132157
*/
133-
if (samlData.inResponseTo) {
134-
const isValidRequest = samlStore.validateAndConsumeAuthnRequest(
135-
samlData.inResponseTo,
136-
workspaceId
158+
const acsUrl = this.getAcsUrl(workspaceId);
159+
160+
let samlData: SamlResponseData;
161+
162+
try {
163+
/**
164+
* Validate and parse SAML Response
165+
* Note: InResponseTo validation is done separately after parsing
166+
*/
167+
samlData = await this.samlService.validateAndParseResponse(
168+
samlResponse,
169+
workspaceId,
170+
acsUrl,
171+
workspace.sso.saml
137172
);
138173

139-
if (!isValidRequest) {
140-
res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' });
141-
return;
174+
/**
175+
* Validate InResponseTo against stored AuthnRequest
176+
*/
177+
if (samlData.inResponseTo) {
178+
const isValidRequest = samlStore.validateAndConsumeAuthnRequest(
179+
samlData.inResponseTo,
180+
workspaceId
181+
);
182+
183+
if (!isValidRequest) {
184+
res.status(400).json({ error: 'Invalid SAML response: InResponseTo validation failed' });
185+
return;
186+
}
142187
}
188+
} catch (error) {
189+
console.error('SAML validation error:', {
190+
workspaceId,
191+
error: error instanceof Error ? error.message : 'Unknown error',
192+
});
193+
res.status(400).json({ error: 'Invalid SAML response' });
194+
return;
143195
}
144-
} catch (error) {
145-
console.error('SAML validation error:', {
146-
workspaceId,
147-
error: error instanceof Error ? error.message : 'Unknown error',
148-
});
149-
res.status(400).json({ error: 'Invalid SAML response' });
150-
return;
151-
}
152196

153-
/**
154-
* 3. Find or create user
155-
*/
156-
let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId);
197+
/**
198+
* 3. Find or create user
199+
*/
200+
let user = await this.factories.usersFactory.findBySamlIdentity(workspaceId, samlData.nameId);
201+
202+
if (!user) {
203+
/**
204+
* JIT provisioning or invite-only policy
205+
*/
206+
user = await this.handleUserProvisioning(workspaceId, samlData, workspace);
207+
}
157208

158-
if (!user) {
159209
/**
160-
* JIT provisioning or invite-only policy
210+
* 4. Get RelayState for return URL (before consuming)
211+
* Note: RelayState is consumed after first use, so we need to get it before validation
161212
*/
162-
user = await this.handleUserProvisioning(workspaceId, samlData, workspace);
163-
}
213+
const relayState = samlStore.getRelayState(relayStateId);
214+
const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`;
164215

165-
/**
166-
* 4. Get RelayState for return URL (before consuming)
167-
* Note: RelayState is consumed after first use, so we need to get it before validation
168-
*/
169-
const relayState = samlStore.getRelayState(relayStateId);
170-
const finalReturnUrl = relayState?.returnUrl || `/workspace/${workspaceId}`;
216+
/**
217+
* 5. Create Hawk session
218+
*/
219+
const tokens = await user.generateTokensPair();
171220

172-
/**
173-
* 5. Create Hawk session
174-
*/
175-
const tokens = await user.generateTokensPair();
221+
/**
222+
* 6. Redirect to Garage with tokens
223+
*/
224+
const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000');
225+
frontendUrl.searchParams.set('access_token', tokens.accessToken);
226+
frontendUrl.searchParams.set('refresh_token', tokens.refreshToken);
176227

177-
/**
178-
* 6. Redirect to Garage with tokens
179-
*/
180-
const frontendUrl = new URL(finalReturnUrl, process.env.GARAGE_URL || 'http://localhost:3000');
181-
frontendUrl.searchParams.set('access_token', tokens.accessToken);
182-
frontendUrl.searchParams.set('refresh_token', tokens.refreshToken);
228+
res.redirect(frontendUrl.toString());
229+
} catch (error) {
230+
/**
231+
* Handle specific error types
232+
*/
233+
if (error instanceof Error && error.message.includes('SAML')) {
234+
console.error('SAML processing error:', {
235+
workspaceId: req.params.workspaceId,
236+
error: error.message,
237+
});
238+
res.status(400).json({ error: 'Invalid SAML response' });
239+
return;
240+
}
183241

184-
res.redirect(frontendUrl.toString());
242+
console.error('ACS callback error:', {
243+
workspaceId: req.params.workspaceId,
244+
error: error instanceof Error ? error.message : 'Unknown error',
245+
});
246+
res.status(500).json({ error: 'Failed to process SSO callback' });
247+
}
185248
}
186249

187250
/**

src/types/env.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,13 @@ declare namespace NodeJS {
3838
* @example "urn:hawk:tracker:saml"
3939
*/
4040
SSO_SP_ENTITY_ID: string;
41+
42+
/**
43+
* Redis connection URL
44+
* Used for caching and TimeSeries data
45+
*
46+
* @example "redis://redis:6379" (Docker) or "redis://localhost:6379" (local)
47+
*/
48+
REDIS_URL?: string;
4149
}
4250
}

0 commit comments

Comments
 (0)