Skip to content

Commit 035fd72

Browse files
committed
feat: enhance MFA token handling and refactor HTTP client integration
- Updated the AuthService to conditionally set the MFA verified timestamp based on provided options. - Refactored the HTTP client plugin to streamline token management for MFA step-up responses, ensuring proper handling of access and refresh tokens. - Improved error handling for missing access tokens during MFA processes. - Enhanced comments and code structure for better readability and maintainability.
1 parent 52e19b7 commit 035fd72

4 files changed

Lines changed: 75 additions & 49 deletions

File tree

apps/api/src/core/auth/auth.service.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,11 @@ export class AuthService extends AbstractService implements OnModuleInit {
748748
const normalizedIdentity = typeof identity?.toObject === 'function' ? identity.toObject() : identity;
749749
const jwtid = `${identity._id}_${randomBytes(16).toString('hex')}`;
750750
const mfaVerified = !!options?.mfaVerified;
751-
const mfaVerifiedAt = mfaVerified ? Date.now() : null;
751+
const mfaVerifiedAt = mfaVerified
752+
? typeof options?.mfaVerifiedAt === 'number'
753+
? options.mfaVerifiedAt
754+
: Date.now()
755+
: null;
752756
const access_token = this.jwtService.sign(
753757
{
754758
identity: pick(normalizedIdentity, ['_id', 'username', 'email', 'token', 'roles']),
@@ -840,10 +844,16 @@ export class AuthService extends AbstractService implements OnModuleInit {
840844
> {
841845
const data = await this.redis.get([this.REFRESH_TOKEN_PREFIX, refresh_token].join(this.TOKEN_PATH_SEPARATOR));
842846
if (!data) throw new UnauthorizedException();
843-
const { identityId, mfaVerified } = JSON.parse(data);
847+
const { identityId, mfaVerified, mfaVerifiedAt } = JSON.parse(data);
844848
const identity = await this.agentsService.findOne<Agents>({ _id: identityId });
845849
if (!identity) throw new ForbiddenException();
846-
return [identity, await this.createTokens(omit(identity.toObject(), ['password']), refresh_token, { mfaVerified })];
850+
return [
851+
identity,
852+
await this.createTokens(omit(identity.toObject(), ['password']), refresh_token, {
853+
mfaVerified,
854+
mfaVerifiedAt: typeof mfaVerifiedAt === 'number' ? mfaVerifiedAt : undefined,
855+
}),
856+
];
847857
}
848858

849859
public async clearSession(jwt: string): Promise<void> {

apps/web/nuxt.config.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,15 +212,6 @@ export default defineNuxtConfig({
212212
vite: {
213213
server: {
214214
allowedHosts: ['localhost', ...SESAME_ALLOWED_HOSTS],
215-
// En dev, le navigateur parle à Vite : seul Vite peut faire l'upgrade WS (Nitro proxy.web → Invalid frame header).
216-
proxy: {
217-
'/api/socket.io': {
218-
target: API_PROXY_TARGET,
219-
changeOrigin: true,
220-
ws: true,
221-
rewrite: (path: string) => path.replace(/^\/api/, ''),
222-
},
223-
},
224215
},
225216
build: {
226217
// Avoid per-chunk CSS ordering differences between dev/prod.

apps/web/src/composables/useSocketIoClient.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import type { ManagerOptions, SocketOptions } from 'socket.io-client'
33
/** Même préfixe que le proxy `/api/**` → API (cf. `nuxt.config.ts` routeRules). */
44
export const SOCKET_IO_PATH = '/api/socket.io'
55

6-
/** Polling d'abord en dev (proxy Nitro HTTP) ; upgrade WS via Vite. Prod : WS en priorité. */
6+
/**
7+
* Dev : long-polling seul (le proxy Nuxt ne gère pas l'upgrade WS sur /api/socket.io).
8+
* Prod : WebSocket via le reverse-proxy, repli polling si besoin.
9+
*/
710
export function socketIoTransports(): ('websocket' | 'polling')[] {
8-
return import.meta.dev ? ['polling', 'websocket'] : ['websocket', 'polling']
11+
return import.meta.dev ? ['polling'] : ['websocket', 'polling']
912
}
1013

1114
export function buildSocketIoClientOptions(auth: { id: string; key: string }): Partial<ManagerOptions & SocketOptions> {
1215
return {
1316
path: SOCKET_IO_PATH,
1417
query: { id: String(auth.id), key: String(auth.key) },
1518
transports: socketIoTransports(),
19+
upgrade: !import.meta.dev,
1620
reconnectionAttempts: 10,
1721
}
1822
}

apps/web/src/plugins/http-mfa.client.ts

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,51 @@ type AuthUser = {
1616
totpEnabled?: boolean
1717
webAuthnEnabled?: boolean
1818
}
19-
type TokenSetter = (token: string) => void
19+
type AuthWithTokens = {
20+
setUserToken?: (token: string, refreshToken?: string) => Promise<unknown>
21+
tokenStrategy?: {
22+
token?: {
23+
set?: (token: string) => unknown
24+
sync?: () => unknown
25+
}
26+
refreshToken?: {
27+
set?: (token: string) => unknown
28+
}
29+
}
30+
}
2031

2132
function asRecord(value: unknown): Record<string, unknown> | undefined {
2233
return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
2334
}
2435

25-
function isTokenSetter(value: unknown): value is TokenSetter {
26-
return typeof value === 'function'
27-
}
28-
2936
function getAuthUser(auth: unknown): AuthUser {
3037
const user = asRecord(asRecord(auth)?.user)
3138
const unwrappedUser = asRecord(user?.value) || user
3239
return (unwrappedUser || {}) as AuthUser
3340
}
3441

35-
function setRefreshToken(auth: unknown, refreshToken: string): void {
36-
const tokenStrategy = asRecord(asRecord(auth)?.tokenStrategy)
37-
const refreshTokenStore = asRecord(tokenStrategy?.refreshToken)
38-
const set = refreshTokenStore?.set
39-
if (isTokenSetter(set)) set(refreshToken)
42+
function extractStepUpTokens(response: unknown): MfaStepUpResponse {
43+
const payload = extractHttpPayload(response)
44+
const access_token = typeof payload.access_token === 'string' ? payload.access_token.trim() : ''
45+
const refresh_token = typeof payload.refresh_token === 'string' ? payload.refresh_token.trim() : undefined
46+
return { access_token, refresh_token }
4047
}
4148

42-
function setAuthTokens(auth: unknown, response: MfaStepUpResponse): void {
43-
const tokenStrategy = asRecord(asRecord(auth)?.tokenStrategy)
44-
const tokenStore = asRecord(tokenStrategy?.token)
45-
const setAccessToken = tokenStore?.set
46-
if (isTokenSetter(setAccessToken)) setAccessToken(response.access_token)
47-
if (response.refresh_token) setRefreshToken(auth, response.refresh_token)
49+
async function setAuthTokens(auth: unknown, response: MfaStepUpResponse): Promise<void> {
50+
const authApi = auth as AuthWithTokens
51+
if (typeof authApi?.setUserToken === 'function') {
52+
await authApi.setUserToken(response.access_token, response.refresh_token)
53+
return
54+
}
55+
56+
const tokenStrategy = authApi?.tokenStrategy
57+
if (typeof tokenStrategy?.token?.set === 'function') {
58+
tokenStrategy.token.set(response.access_token)
59+
tokenStrategy.token.sync?.()
60+
}
61+
if (response.refresh_token && typeof tokenStrategy?.refreshToken?.set === 'function') {
62+
tokenStrategy.refreshToken.set(response.refresh_token)
63+
}
4864
}
4965

5066
function extractHttpPayload(response: unknown): Record<string, unknown> {
@@ -124,15 +140,16 @@ export default defineNuxtPlugin((nuxtApp) => {
124140
if (!options || !challengeId) throw new Error('Invalid WebAuthn step-up begin payload')
125141

126142
const response = (await startAuthentication(options)) as AuthenticationResponseJSON
127-
const finish = (await post('/core/auth/mfa/webauthn/finish', {
143+
const finish = await post('/core/auth/mfa/webauthn/finish', {
128144
body: {
129145
challengeId,
130146
response,
131147
},
132-
})) as MfaStepUpResponse
148+
})
149+
const finishTokens = extractStepUpTokens(finish)
133150

134-
if (!finish?.access_token) throw new Error('Invalid WebAuthn step-up response (missing access_token)')
135-
setAuthTokens(auth, finish)
151+
if (!finishTokens.access_token) throw new Error('Invalid WebAuthn step-up response (missing access_token)')
152+
await setAuthTokens(auth, finishTokens)
136153
return
137154
} catch (error) {
138155
if (user?.totpEnabled !== true) throw error
@@ -175,15 +192,16 @@ export default defineNuxtPlugin((nuxtApp) => {
175192
.onDismiss(() => reject(new Error('MFA step-up dismissed')))
176193
})
177194

178-
const response = (await post('/core/auth/mfa/step-up', {
195+
const response = await post('/core/auth/mfa/step-up', {
179196
body: {
180197
otpCode: String(otpCode || '').trim(),
181198
},
182-
})) as MfaStepUpResponse
199+
})
200+
const tokens = extractStepUpTokens(response)
183201

184-
if (!response?.access_token) throw new Error('Invalid step-up response (missing access_token)')
202+
if (!tokens.access_token) throw new Error('Invalid step-up response (missing access_token)')
185203

186-
setAuthTokens(auth, response)
204+
await setAuthTokens(auth, tokens)
187205
} else {
188206
const password = await new Promise<string>((resolve, reject) => {
189207
$q.dialog({
@@ -207,15 +225,16 @@ export default defineNuxtPlugin((nuxtApp) => {
207225
const post = rawHttpRef.$post
208226
if (typeof post !== 'function') throw new Error('HTTP client not ready')
209227

210-
const response = (await post('/core/auth/mfa/step-up', {
228+
const response = await post('/core/auth/mfa/step-up', {
211229
body: {
212230
password: String(password || ''),
213231
},
214-
})) as MfaStepUpResponse
232+
})
233+
const tokens = extractStepUpTokens(response)
215234

216-
if (!response?.access_token) throw new Error('Invalid step-up response (missing access_token)')
235+
if (!tokens.access_token) throw new Error('Invalid step-up response (missing access_token)')
217236

218-
setAuthTokens(auth, response)
237+
await setAuthTokens(auth, tokens)
219238
}
220239

221240
// Refresh user payload so subsequent UI has updated session info.
@@ -233,12 +252,13 @@ export default defineNuxtPlugin((nuxtApp) => {
233252
}
234253
}
235254

236-
const PATCH_MARK = '__sesameMfaPatched__'
255+
const WRAP_MARK = '__sesameMfaWrapped__'
237256

238257
const wrap = (fn: unknown, kind: 'read' | 'write') => {
239258
if (typeof fn !== 'function') return fn
240-
const httpFn = fn as HttpMethod
241-
return async (...args: unknown[]) => {
259+
const httpFn = fn as HttpMethod & { [WRAP_MARK]?: boolean }
260+
if (httpFn[WRAP_MARK]) return httpFn
261+
const wrapped = async (...args: unknown[]) => {
242262
const url = args?.[0]
243263
// Never intercept the step-up endpoint itself.
244264
if (
@@ -260,22 +280,23 @@ export default defineNuxtPlugin((nuxtApp) => {
260280
return await httpFn(...args)
261281
}
262282
}
283+
;(wrapped as HttpMethod & { [WRAP_MARK]?: boolean })[WRAP_MARK] = true
284+
return wrapped
263285
}
264286

265287
const install = () => {
266288
const rawHttp = (nuxtApp as unknown as { $http?: HttpClient }).$http
267289
if (!rawHttp) return
268290
rawHttpRef = rawHttp
269291

270-
if (rawHttp[PATCH_MARK]) return
271-
rawHttp[PATCH_MARK] = true
272-
273292
// Patch common methods in-place (no reassignment of `$http`, which is getter-only).
274293
// Important: we ONLY patch write methods so the step-up modal appears only on "save"-like actions.
294+
// Re-apply when module plugins replace $http methods after our first install.
275295
for (const key of ['$post', '$put', '$patch', '$delete', 'post', 'put', 'patch', 'delete']) {
276296
const method = rawHttp[key]
277297
if (typeof method === 'function') {
278-
rawHttp[key] = wrap((method as HttpMethod).bind(rawHttp), 'write')
298+
const bound = (method as HttpMethod).bind(rawHttp)
299+
rawHttp[key] = wrap(bound, 'write')
279300
}
280301
}
281302
}

0 commit comments

Comments
 (0)