nexus-shared 1.1.18 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/client.ts +2 -1
  3. package/src/components/documents/button.tsx +5 -1
  4. package/src/components/inputs/input-box.tsx +4 -2
  5. package/src/components/inputs/input-form.tsx +126 -14
  6. package/src/helpers/browser-helpers.tsx +41 -1
  7. package/src/helpers/input-helpers.tsx +34 -4
  8. package/src/helpers/validation-helpers.tsx +302 -0
  9. package/src/index.ts +2 -18
  10. package/src/interface.ts +4 -1
  11. package/src/interfaces/button-interfaces.tsx +2 -2
  12. package/src/interfaces/http-interfaces.tsx +89 -8
  13. package/src/interfaces/input-interfaces.tsx +61 -11
  14. package/src/interfaces/message-interfaces.tsx +32 -0
  15. package/src/interfaces/storage-interfaces.tsx +1 -0
  16. package/src/interfaces/type-interfaces.tsx +8 -2
  17. package/src/interfaces/user-interfaces.tsx +17 -0
  18. package/src/nexus.environments.tsx +6 -1
  19. package/src/proxy-api/proxy-backend.tsx +60 -0
  20. package/src/proxy-api/proxy-constants.tsx +28 -0
  21. package/src/proxy-api/proxy-helpers.tsx +91 -0
  22. package/src/proxy-api-client.tsx +1 -0
  23. package/src/proxy-api-server.tsx +2 -0
  24. package/src/services/http-client-services.tsx +26 -11
  25. package/src/services/http-services.tsx +37 -22
  26. package/src/services/loader-service.tsx +5 -4
  27. package/src/services/localstorage-service.tsx +59 -0
  28. package/src/services/message-services.tsx +383 -0
  29. package/src/sso-config/auth-token-validation.ts +20 -0
  30. package/src/sso-config/callback-route.tsx +34 -0
  31. package/src/sso-config/cookie-encryption.ts +51 -0
  32. package/src/sso-config/cookie-helpers.tsx +77 -0
  33. package/src/sso-config/forgot-password-route.tsx +36 -0
  34. package/src/sso-config/oauth-callback-state.ts +58 -0
  35. package/src/sso-config/pkce-helpers.ts +16 -0
  36. package/src/sso-config/provider-complete-route.tsx +15 -0
  37. package/src/sso-config/redirect-context-actions.tsx +46 -0
  38. package/src/sso-config/redirect-context-helpers.tsx +144 -0
  39. package/src/sso-config/redirect-context-persist.tsx +18 -0
  40. package/src/sso-config/redirect-context-route.tsx +45 -0
  41. package/src/sso-config/refresh-route.tsx +22 -0
  42. package/src/sso-config/sign-in-route.tsx +69 -0
  43. package/src/sso-config/sign-out-route.tsx +53 -0
  44. package/src/sso-config/sign-up-route.tsx +46 -0
  45. package/src/sso-config/sso-backend-flow.ts +45 -0
  46. package/src/sso-config/sso-complete-route.tsx +46 -0
  47. package/src/sso-config/sso-config.ts +57 -0
  48. package/src/{interfaces → sso-config}/sso-interfaces.tsx +17 -1
  49. package/src/sso-config/sso-response-helpers.tsx +20 -0
  50. package/src/sso-server.tsx +21 -1
  51. package/src/styles/nexus.dialog.css +3 -0
  52. package/src/load-env.ts +0 -2
@@ -0,0 +1,383 @@
1
+ import { DEFAULT_ANIMATION_DURATION, DEFAULT_ICON_ELEMENT_NAME, EAnimationClasses, EBackgrounds, ECallbackTypes, EIcons, EIconTypes, EMessageBoxButtons, EMessageIcons, EMessageResult, EMessageTypes, EOpacity, ESizes, PopupMessageCallback, UN } from "nexus-shared";
2
+ import { CreateIconBox, IsBrowser, PxToRem, RemoveElement } from "nexus-shared/client";
3
+
4
+ const MESSAGE_IDENTITY = "nexus-message";
5
+ const MESSAGE_GAP_REM = 0.8;
6
+ const MESSAGE_TOP_REM = 1;
7
+ const PROGRESS_BAR_HEIGHT_REM = 0.2;
8
+
9
+ /** Newest first — index 0 is the top message. */
10
+ const activeMessages: HTMLElement[] = [];
11
+ const messageProgressCleanups = new WeakMap<HTMLElement, () => void>();
12
+ let reflowScheduled = false;
13
+
14
+ const stopMessageProgress = (messageElement: HTMLElement) => {
15
+ messageProgressCleanups.get(messageElement)?.();
16
+ messageProgressCleanups.delete(messageElement);
17
+ };
18
+
19
+ const startMessageProgress = (messageElement: HTMLElement, progressFillElement: HTMLElement, durationMilliseconds: number, onComplete: () => void) => {
20
+ let elapsedMilliseconds = 0;
21
+ let lastFrameTimestamp: number | null = null;
22
+ let animationFrameId = 0;
23
+
24
+ const cancelAnimation = () => {
25
+ if (animationFrameId) cancelAnimationFrame(animationFrameId);
26
+ animationFrameId = 0;
27
+ };
28
+
29
+ const finishProgress = () => {
30
+ cancelAnimation();
31
+ messageProgressCleanups.delete(messageElement);
32
+ onComplete();
33
+ };
34
+
35
+ const updateProgress = (frameTimestamp: number) => {
36
+ animationFrameId = 0;
37
+ if (lastFrameTimestamp !== null) {
38
+ elapsedMilliseconds += frameTimestamp - lastFrameTimestamp;
39
+ progressFillElement.style.width = `${Math.min((elapsedMilliseconds / durationMilliseconds) * 100, 100)}%`;
40
+ if (elapsedMilliseconds >= durationMilliseconds) return finishProgress();
41
+ }
42
+ lastFrameTimestamp = frameTimestamp;
43
+ animationFrameId = requestAnimationFrame(updateProgress);
44
+ };
45
+
46
+ const onMouseEnter = () => {
47
+ lastFrameTimestamp = null;
48
+ cancelAnimation();
49
+ };
50
+ const onMouseLeave = () => {
51
+ if (!animationFrameId) animationFrameId = requestAnimationFrame(updateProgress);
52
+ };
53
+
54
+ messageElement.addEventListener("mouseenter", onMouseEnter);
55
+ messageElement.addEventListener("mouseleave", onMouseLeave);
56
+ messageProgressCleanups.set(messageElement, () => {
57
+ cancelAnimation();
58
+ messageElement.removeEventListener("mouseenter", onMouseEnter);
59
+ messageElement.removeEventListener("mouseleave", onMouseLeave);
60
+ });
61
+ animationFrameId = requestAnimationFrame(updateProgress);
62
+ };
63
+
64
+ const scheduleReflow = () => {
65
+ if (reflowScheduled) return;
66
+ reflowScheduled = true;
67
+ requestAnimationFrame(() => {
68
+ reflowScheduled = false;
69
+ let offset = MESSAGE_TOP_REM;
70
+ for (const el of activeMessages) {
71
+ el.style.top = `${offset}rem`;
72
+ offset += PxToRem(el.offsetHeight) + MESSAGE_GAP_REM;
73
+ }
74
+ });
75
+ };
76
+
77
+ const dismissMessage = (el: HTMLElement, callback?: PopupMessageCallback) => {
78
+ const index = activeMessages.indexOf(el);
79
+ if (index === -1) return;
80
+
81
+ stopMessageProgress(el);
82
+ activeMessages.splice(index, 1);
83
+ el.style.right = "-100%";
84
+ el.style.top = "100%";
85
+ RemoveElement(el, DEFAULT_ANIMATION_DURATION, EAnimationClasses.FADE_OUT);
86
+ scheduleReflow();
87
+ callback?.(ECallbackTypes.Destroy);
88
+ };
89
+
90
+ const _showMessage = (title: string, message: string, messageType: EMessageTypes, delay?: number | UN, duration?: number | UN, icon?: EMessageIcons | UN, callback?: PopupMessageCallback) => {
91
+ if (!IsBrowser()) return;
92
+
93
+ delay = delay ?? 0;
94
+ duration = duration ?? 3000;
95
+
96
+ const createAndShow = () => {
97
+ /** Root message element */
98
+ const messageRootElement = document.createElement("div");
99
+ const messageBodyElement = document.createElement("div");
100
+ const messageHandleElement = document.createElement("div");
101
+ const messageContentElement = document.createElement("div");
102
+ const messageIconElement = document.createElement("div");
103
+ const progressTrackElement = document.createElement("div");
104
+ const progressFillElement = document.createElement("div");
105
+
106
+ document.body.appendChild(messageRootElement);
107
+ messageRootElement.appendChild(messageBodyElement);
108
+ messageBodyElement.appendChild(messageHandleElement);
109
+ messageBodyElement.appendChild(messageIconElement);
110
+ messageBodyElement.appendChild(messageContentElement);
111
+ messageRootElement.appendChild(progressTrackElement);
112
+ progressTrackElement.appendChild(progressFillElement);
113
+
114
+ messageRootElement.className = `${MESSAGE_IDENTITY} pf bs3 r bg1 anim df ddc oh`;
115
+ messageBodyElement.className = "df ac w100";
116
+ messageHandleElement.className = messageType;
117
+ messageIconElement.className = `ib ml4 sm rr ${messageType}`;
118
+ messageContentElement.className = `p4 pr2 fg`;
119
+ progressTrackElement.className = "w100";
120
+ progressFillElement.className = messageType;
121
+
122
+ messageRootElement.style.right = "-100%";
123
+ messageRootElement.style.transitionDuration = `${DEFAULT_ANIMATION_DURATION * 1.5}ms`;
124
+
125
+ messageHandleElement.style.width = "0.3rem";
126
+ messageHandleElement.style.alignSelf = "stretch";
127
+ messageHandleElement.style.flexShrink = "0";
128
+
129
+ progressTrackElement.style.height = `${PROGRESS_BAR_HEIGHT_REM}rem`;
130
+ progressTrackElement.style.flexShrink = "0";
131
+ progressFillElement.style.height = "100%";
132
+ progressFillElement.style.width = "0%";
133
+
134
+ messageIconElement.innerHTML = `<${DEFAULT_ICON_ELEMENT_NAME} class="icon" name="${icon}" />`;
135
+
136
+ const _TM = setTimeout(() => {
137
+ clearTimeout(_TM);
138
+ const WINDOW_WIDTH = window.innerWidth;
139
+ if (WINDOW_WIDTH > 1000) {
140
+ messageRootElement.style.right = "1rem";
141
+ messageRootElement.style.maxWidth = "35rem";
142
+ messageRootElement.style.minWidth = "16rem";
143
+ } else {
144
+ messageRootElement.style.maxWidth = "calc(100vw - 1rem)";
145
+ messageRootElement.style.minWidth = "15rem";
146
+ messageRootElement.style.right = "0.5rem";
147
+ }
148
+ }, 0);
149
+
150
+ const titleElement = document.createElement("p");
151
+ const messageElement = document.createElement("p");
152
+ titleElement.className = "mb1 fw600 w100 df ac";
153
+ messageElement.className = "o08 fs-09 pr3";
154
+ titleElement.innerHTML = `<p class="w100 fg">${title}</p>`;
155
+ messageElement.innerHTML = message;
156
+ messageContentElement.appendChild(titleElement);
157
+ messageContentElement.appendChild(messageElement);
158
+
159
+ const dismiss = () => dismissMessage(messageRootElement, callback);
160
+ CreateIconBox(EIcons.CLOSE, titleElement, null, null, EIconTypes.FILLED, EBackgrounds.Background1, ESizes.VeryTiny, ESizes.Tiny, null, null, null, null, EOpacity.O05, "Clear Message", null, null, null, dismiss);
161
+
162
+ activeMessages.unshift(messageRootElement);
163
+ scheduleReflow();
164
+ requestAnimationFrame(scheduleReflow);
165
+ callback?.(ECallbackTypes.Initialize);
166
+
167
+ if (duration > 0) startMessageProgress(messageRootElement, progressFillElement, duration, dismiss);
168
+ };
169
+
170
+ if (typeof delay === "number" && delay > 0) {
171
+ const _TM = window.setTimeout(() => {
172
+ clearTimeout(_TM);
173
+ createAndShow();
174
+ }, delay);
175
+ } else createAndShow();
176
+ };
177
+
178
+ export const SuccessMessage = (title: string, message: string, delay?: number | UN, duration?: number | UN, callback?: PopupMessageCallback, icon?: EMessageIcons | UN) => {
179
+ return _showMessage(title, message, EMessageTypes.Success, delay, duration, icon ?? EMessageIcons.Success, callback);
180
+ };
181
+ export const ErrorMessage = (title: string, message: string, delay?: number | UN, duration?: number | UN, callback?: PopupMessageCallback, icon?: EMessageIcons | UN) => {
182
+ return _showMessage(title, message, EMessageTypes.Error, delay, duration, icon ?? EMessageIcons.Error, callback);
183
+ };
184
+ export const WarningMessage = (title: string, message: string, delay?: number | UN, duration?: number | UN, callback?: PopupMessageCallback, icon?: EMessageIcons | UN) => {
185
+ return _showMessage(title, message, EMessageTypes.Warning, delay, duration, icon ?? EMessageIcons.Warning, callback);
186
+ };
187
+ export const InformationMessage = (title: string, message: string, delay?: number | UN, duration?: number | UN, callback?: PopupMessageCallback, icon?: EMessageIcons | UN) => {
188
+ return _showMessage(title, message, EMessageTypes.Information, delay, duration, icon ?? EMessageIcons.Information, callback);
189
+ };
190
+ export const QuestionMessage = (title: string, message: string, delay?: number | UN, duration?: number | UN, callback?: PopupMessageCallback, icon?: EMessageIcons | UN) => {
191
+ return _showMessage(title, message, EMessageTypes.Information, delay, duration, icon ?? EMessageIcons.Question, callback);
192
+ };
193
+
194
+ type MessageBoxButton = {
195
+ result: EMessageResult;
196
+ label: string;
197
+ themeBackgroundClass?: EBackgrounds | string;
198
+ };
199
+
200
+ const normalizeMessageResult = (value: string): EMessageResult => {
201
+ const v = (value ?? "").trim().toLowerCase();
202
+ if (v === "ok") return EMessageResult.OK;
203
+ if (v === "yes") return EMessageResult.Yes;
204
+ if (v === "no") return EMessageResult.No;
205
+ if (v === "cancel") return EMessageResult.Cancel;
206
+ return EMessageResult.Undefined;
207
+ };
208
+
209
+ const getMessageBoxButtons = (messageButtons: EMessageBoxButtons, themeBackgroundClass: EMessageTypes, overrideYesButtonText?: string | null): MessageBoxButton[] => {
210
+ const parts = (messageButtons ?? EMessageBoxButtons.OK).split("_").filter(Boolean);
211
+ const results = parts.map(normalizeMessageResult).filter((r) => r !== EMessageResult.Undefined);
212
+ const resolved = results.length ? results : [EMessageResult.OK];
213
+
214
+ return resolved.map((result) => {
215
+ if (result === EMessageResult.Yes) return { result, label: overrideYesButtonText ?? "Yes", themeBackgroundClass };
216
+ if (result === EMessageResult.OK) return { result, label: "OK", themeBackgroundClass: themeBackgroundClass };
217
+ if (result === EMessageResult.No) return { result, label: "No", themeBackgroundClass: EBackgrounds.AlwaysDarkBackgroundColor };
218
+ if (result === EMessageResult.Cancel) return { result, label: "Cancel", themeBackgroundClass: EBackgrounds.AlwaysDarkBackgroundColor };
219
+ return { result, label: String(result) };
220
+ });
221
+ };
222
+
223
+ const getCloseResult = (buttons: MessageBoxButton[]): EMessageResult => {
224
+ if (buttons.some((b) => b.result === EMessageResult.Cancel)) return EMessageResult.Cancel;
225
+ if (buttons.some((b) => b.result === EMessageResult.No)) return EMessageResult.No;
226
+ if (buttons.some((b) => b.result === EMessageResult.OK)) return EMessageResult.OK;
227
+ return EMessageResult.Undefined;
228
+ };
229
+
230
+ /**
231
+ * Windows-like modal message box (DOM-based).
232
+ * - Keyboard: Tab / Shift+Tab / ArrowLeft / ArrowRight to move focus
233
+ * - Enter: activates focused button
234
+ * - Escape: closes with Cancel/No/OK (in that order if present)
235
+ */
236
+ export const ShowDialogMessageBox = async (messageButtons: EMessageBoxButtons, title: string, message: string, iconName: EMessageIcons, themeBackgroundClass?: EMessageTypes, overrideYesButtonText?: string | null, confirmMessage?: string | null, allowOverlayClick?: boolean): Promise<EMessageResult | null> => {
237
+ if (!IsBrowser()) return null;
238
+
239
+ return new Promise((resolve) => {
240
+ themeBackgroundClass = themeBackgroundClass ?? EMessageTypes.Information;
241
+ const bodyElement = document.body as HTMLBodyElement;
242
+ const overlayElement = document.createElement("div") as HTMLDivElement;
243
+ const dialogElement = document.createElement("div") as HTMLDivElement;
244
+
245
+ const iconBoxElement = document.createElement("div") as HTMLDivElement;
246
+ const headerElement = document.createElement("div") as HTMLDivElement;
247
+ const headerTextElement = document.createElement("div") as HTMLDivElement;
248
+ const titleElement = document.createElement("h4") as HTMLHeadingElement;
249
+ const messageElement = document.createElement("p") as HTMLParagraphElement;
250
+ const confirmElement = document.createElement("p") as HTMLParagraphElement;
251
+ const buttonRowElement = document.createElement("div") as HTMLDivElement;
252
+
253
+ const buttons = getMessageBoxButtons(messageButtons, themeBackgroundClass, overrideYesButtonText);
254
+ const actionButtons: HTMLButtonElement[] = [];
255
+ let currentFocusIndex = 0;
256
+ let isClosed = false;
257
+
258
+ overlayElement.className = `pf df ac jc ${EBackgrounds.PopupBackgroundColor} ${EAnimationClasses.FADE_IN}`;
259
+ overlayElement.style.zIndex = "100000";
260
+ overlayElement.style.inset = "0";
261
+
262
+ dialogElement.className = `bg1 r bs3 anim df ddc`;
263
+ dialogElement.style.maxWidth = "36rem";
264
+ dialogElement.style.width = "calc(100vw - 2rem)";
265
+ dialogElement.style.boxSizing = "border-box";
266
+
267
+ headerElement.className = "df w100";
268
+ headerElement.style.gap = "1rem";
269
+ headerElement.style.padding = "1.25rem";
270
+
271
+ iconBoxElement.className = `df ac jc rr bb ${themeBackgroundClass}`;
272
+ iconBoxElement.style.minWidth = "4rem";
273
+ iconBoxElement.style.maxWidth = "4rem";
274
+ iconBoxElement.style.minHeight = "4rem";
275
+ iconBoxElement.style.maxHeight = "4rem";
276
+
277
+ const iconElement = document.createElement(DEFAULT_ICON_ELEMENT_NAME) as HTMLElement & { name?: string };
278
+ iconElement.name = `${iconName}${EIconTypes.OUTLINED}`;
279
+ iconElement.className = `icon ${ESizes.Large} o08`;
280
+ iconElement.style.color = "white";
281
+ iconBoxElement.appendChild(iconElement);
282
+
283
+ headerTextElement.className = "fg";
284
+
285
+ titleElement.className = "fw600";
286
+ titleElement.style.margin = "0";
287
+ titleElement.innerText = title ?? "";
288
+
289
+ messageElement.className = "o08";
290
+ messageElement.style.margin = "0.5rem 0 0 0";
291
+ messageElement.innerHTML = message ?? "";
292
+
293
+ confirmElement.className = "o08";
294
+ confirmElement.style.margin = "0.75rem 0 0 0";
295
+ confirmElement.innerHTML = confirmMessage ?? "";
296
+ if (!confirmMessage) confirmElement.style.display = "none";
297
+
298
+ buttonRowElement.className = "df w100";
299
+ buttonRowElement.style.gap = "0.75rem";
300
+ buttonRowElement.style.justifyContent = "flex-end";
301
+ buttonRowElement.style.padding = "0 1.25rem 1.25rem 1.25rem";
302
+
303
+ const setFocus = (index: number) => {
304
+ currentFocusIndex = Math.min(Math.max(index, 0), actionButtons.length - 1);
305
+ actionButtons[currentFocusIndex]?.focus();
306
+ };
307
+
308
+ const closeDialog = (result: EMessageResult) => {
309
+ if (isClosed) return;
310
+ isClosed = true;
311
+
312
+ document.removeEventListener("keydown", onKeyDown);
313
+ overlayElement.removeEventListener("click", onOverlayClick);
314
+
315
+ overlayElement.classList.remove(EAnimationClasses.FADE_IN);
316
+ overlayElement.classList.add(EAnimationClasses.FADE_OUT);
317
+ dialogElement.classList.add(EAnimationClasses.PULSE_OUT);
318
+
319
+ RemoveElement(overlayElement, DEFAULT_ANIMATION_DURATION, EAnimationClasses.FADE_OUT).finally(() => resolve(result));
320
+ };
321
+
322
+ const onOverlayClick = (event: MouseEvent) => {
323
+ if (event.target === overlayElement) closeDialog(getCloseResult(buttons));
324
+ };
325
+
326
+ const onKeyDown = (event: KeyboardEvent) => {
327
+ const key = event.key;
328
+ const isShiftTab = key === "Tab" && event.shiftKey;
329
+ const isTab = key === "Tab" && !event.shiftKey;
330
+
331
+ if (key === "ArrowLeft" || isShiftTab) {
332
+ event.preventDefault();
333
+ setFocus((currentFocusIndex - 1 + actionButtons.length) % actionButtons.length);
334
+ } else if (key === "ArrowRight" || isTab) {
335
+ event.preventDefault();
336
+ setFocus((currentFocusIndex + 1) % actionButtons.length);
337
+ } else if (key === "Enter") {
338
+ event.preventDefault();
339
+ const result = buttons[currentFocusIndex]?.result ?? EMessageResult.Undefined;
340
+ closeDialog(result);
341
+ } else if (key === "Escape") {
342
+ event.preventDefault();
343
+ closeDialog(getCloseResult(buttons));
344
+ }
345
+ };
346
+
347
+ const createButton = (button: MessageBoxButton) => {
348
+ const buttonElement = document.createElement("button") as HTMLButtonElement;
349
+ buttonElement.type = "button";
350
+ buttonElement.className = `sn r h p2 pr4 pl4 ${button.themeBackgroundClass}`;
351
+ buttonElement.style.minWidth = "5.5rem";
352
+ buttonElement.innerText = button.label;
353
+
354
+ const onClick = () => closeDialog(button.result);
355
+ buttonElement.addEventListener("click", onClick, { once: true });
356
+ return buttonElement;
357
+ };
358
+
359
+ for (const button of buttons) {
360
+ const buttonElement = createButton(button);
361
+ buttonRowElement.appendChild(buttonElement);
362
+ actionButtons.push(buttonElement);
363
+ }
364
+
365
+ headerTextElement.appendChild(titleElement);
366
+ headerTextElement.appendChild(messageElement);
367
+ headerTextElement.appendChild(confirmElement);
368
+
369
+ headerElement.appendChild(iconBoxElement);
370
+ headerElement.appendChild(headerTextElement);
371
+
372
+ dialogElement.appendChild(headerElement);
373
+ dialogElement.appendChild(buttonRowElement);
374
+
375
+ overlayElement.appendChild(dialogElement);
376
+ bodyElement.insertBefore(overlayElement, bodyElement.firstChild);
377
+
378
+ if (allowOverlayClick === false) overlayElement.addEventListener("click", onOverlayClick);
379
+ document.addEventListener("keydown", onKeyDown);
380
+
381
+ setTimeout(() => setFocus(0), 0);
382
+ });
383
+ };
@@ -0,0 +1,20 @@
1
+ import { IUserValidationResponse } from "../interfaces/user-interfaces";
2
+ import { HttpGet } from "../services/http-services";
3
+ import { SSO_CONFIG } from "./sso-config";
4
+
5
+ /**
6
+ * Verifies the access token by calling the backend Authentication/Verify endpoint.
7
+ * Returns true when the backend responds with HTTP 200 (token accepted), false for any
8
+ * other status or network failure. No JWT secrets or JWKS keys are needed in Next.js.
9
+ */
10
+ export async function VerifyAccessToken(token: string): Promise<boolean> {
11
+ if (!token?.trim()) return false;
12
+ try {
13
+ const response = await HttpGet(SSO_CONFIG.BACKEND_ENDPOINTS.VERIFY, token);
14
+ if (!response.isSuccess) return false;
15
+ const validationResponse = response.result as IUserValidationResponse;
16
+ return validationResponse.isValidUser;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
@@ -0,0 +1,34 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { URLS } from "../nexus.environments";
3
+ import { SetAuthCookies } from "./cookie-helpers";
4
+ import { DecodeOAuthCallbackState } from "./oauth-callback-state";
5
+ import { SanitizeReturnPath } from "./redirect-context-helpers";
6
+ import { BuildAuthenticationSignInUrl, SSO_CONFIG } from "./sso-config";
7
+ import { ExchangeAuthorizationCode } from "./sso-backend-flow";
8
+
9
+ export async function CallbackRoute(req: NextRequest) {
10
+ const code = req.nextUrl.searchParams.get("code");
11
+ const state = req.nextUrl.searchParams.get("state");
12
+ const callbackState = DecodeOAuthCallbackState(state);
13
+ const failUrl = new URL(BuildAuthenticationSignInUrl(callbackState?.returnPath));
14
+
15
+ if (!code) return NextResponse.redirect(failUrl);
16
+
17
+ if (!callbackState?.codeVerifier) return NextResponse.redirect(failUrl);
18
+
19
+ try {
20
+ const tokenData = await ExchangeAuthorizationCode({
21
+ code,
22
+ redirectUri: SSO_CONFIG.FRONTEND_ENDPOINTS.CALLBACK,
23
+ codeVerifier: callbackState.codeVerifier,
24
+ });
25
+ if (!tokenData) return NextResponse.redirect(failUrl);
26
+
27
+ const redirectTarget = new URL(SanitizeReturnPath(tokenData.returnPath ?? callbackState.returnPath), URLS.CURRENT!);
28
+ const response = NextResponse.redirect(redirectTarget);
29
+ SetAuthCookies(response, tokenData);
30
+ return response;
31
+ } catch {
32
+ return NextResponse.redirect(new URL(BuildAuthenticationSignInUrl(callbackState?.returnPath)));
33
+ }
34
+ }
@@ -0,0 +1,51 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
2
+
3
+ const ALGORITHM = "aes-256-gcm";
4
+ const IV_BYTES = 12; // 96-bit IV recommended for GCM
5
+
6
+ /**
7
+ * Derives a 32-byte AES key from SSO_COOKIE_SECRET using SHA-256.
8
+ * The secret itself is never sent to the browser.
9
+ */
10
+ function deriveKey(): Buffer {
11
+ const raw = process.env.SSO_COOKIE_SECRET?.trim();
12
+ const secret = raw && !raw.startsWith("$") ? raw : process.env.NODE_ENV === "development" ? "NexusDevAuthSecretKey2026MustBe32CharsLong!" : null;
13
+ if (!secret) throw new Error("SSO_COOKIE_SECRET must be configured for cookie encryption.");
14
+ return createHash("sha256").update(secret).digest();
15
+ }
16
+
17
+ /**
18
+ * Encrypts a cookie value with AES-256-GCM.
19
+ * Output format: `<iv>.<authTag>.<ciphertext>` (all base64url).
20
+ * The browser stores only this opaque string — the raw token is never exposed.
21
+ */
22
+ export function EncryptCookieValue(value: string): string {
23
+ const key = deriveKey();
24
+ const iv = randomBytes(IV_BYTES);
25
+ const cipher = createCipheriv(ALGORITHM, key, iv);
26
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
27
+ const authTag = cipher.getAuthTag();
28
+ return `${iv.toString("base64url")}.${authTag.toString("base64url")}.${encrypted.toString("base64url")}`;
29
+ }
30
+
31
+ /**
32
+ * Decrypts a cookie value produced by EncryptCookieValue.
33
+ * Returns null if the value is missing, malformed, or the auth tag fails (tampered).
34
+ */
35
+ export function DecryptCookieValue(value: string): string | null {
36
+ if (!value) return null;
37
+ try {
38
+ const parts = value.split(".");
39
+ if (parts.length !== 3) return null;
40
+ const key = deriveKey();
41
+ const iv = Buffer.from(parts[0], "base64url");
42
+ const authTag = Buffer.from(parts[1], "base64url");
43
+ const encrypted = Buffer.from(parts[2], "base64url");
44
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
45
+ decipher.setAuthTag(authTag);
46
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
47
+ return decrypted.toString("utf8");
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -0,0 +1,77 @@
1
+ import { cookies } from "next/headers";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { VerifyAccessToken } from "./auth-token-validation";
4
+ import { DecryptCookieValue, EncryptCookieValue } from "./cookie-encryption";
5
+ import { GetAuthCookieOptions } from "./redirect-context-helpers";
6
+ import { CookieInfo, SSOAuthResponse } from "./sso-interfaces";
7
+
8
+ export const AUTH_COOKIES = {
9
+ ssoSession: "sso_session",
10
+ accessToken: "access_token",
11
+ refreshToken: "refresh_token",
12
+ redirectContext: "sso_redirect_ctx",
13
+ } as const;
14
+
15
+ export function SetAuthCookies(response: NextResponse, tokens: SSOAuthResponse) {
16
+ response.cookies.set(AUTH_COOKIES.accessToken, EncryptCookieValue(tokens.accessToken), GetAuthCookieOptions(tokens.expiresTokenIn));
17
+ response.cookies.set(AUTH_COOKIES.refreshToken, EncryptCookieValue(tokens.refreshToken), GetAuthCookieOptions(tokens.expiresRefreshTokenIn));
18
+ }
19
+
20
+ export function SetSsoSessionCookie(response: NextResponse, ssoSession: string, maxAge: number | string) {
21
+ response.cookies.set(AUTH_COOKIES.ssoSession, EncryptCookieValue(ssoSession), GetAuthCookieOptions(maxAge ?? 28800));
22
+ }
23
+
24
+ export function ClearAuthCookies(response: NextResponse) {
25
+ response.cookies.set(AUTH_COOKIES.accessToken, "", GetAuthCookieOptions(0));
26
+ response.cookies.set(AUTH_COOKIES.refreshToken, "", GetAuthCookieOptions(0));
27
+ }
28
+
29
+ export function ClearAllAuthCookies(response: NextResponse) {
30
+ response.cookies.set(AUTH_COOKIES.ssoSession, "", GetAuthCookieOptions(0));
31
+ ClearAuthCookies(response);
32
+ }
33
+
34
+ /**
35
+ * Reads and decrypts a single auth cookie from an incoming NextRequest.
36
+ * Returns null when the cookie is absent or the encrypted value is tampered / unreadable.
37
+ */
38
+ export function GetDecryptedRequestCookie(req: NextRequest, name: string): string | null {
39
+ const raw = req.cookies.get(name)?.value;
40
+ if (!raw) return null;
41
+ return DecryptCookieValue(raw);
42
+ }
43
+
44
+ export async function GetCookieInfo(): Promise<CookieInfo | null> {
45
+ const cookieStore = await cookies();
46
+
47
+ const rawSsoSession = cookieStore.get(AUTH_COOKIES.ssoSession)?.value;
48
+ if (!rawSsoSession) return null;
49
+ const ssoSession = DecryptCookieValue(rawSsoSession);
50
+ if (!ssoSession) return null;
51
+
52
+ const rawAccessToken = cookieStore.get(AUTH_COOKIES.accessToken)?.value;
53
+ const rawRefreshToken = cookieStore.get(AUTH_COOKIES.refreshToken)?.value;
54
+
55
+ return {
56
+ ssoSession,
57
+ accessToken: rawAccessToken ? (DecryptCookieValue(rawAccessToken) ?? undefined) : undefined,
58
+ refreshToken: rawRefreshToken ? (DecryptCookieValue(rawRefreshToken) ?? undefined) : undefined,
59
+ };
60
+ }
61
+
62
+ /** True when this client app has a valid access token (use on all non-auth client sites). */
63
+ export async function HasAccessToken(): Promise<boolean> {
64
+ const cookieStore = await cookies();
65
+ const raw = cookieStore.get(AUTH_COOKIES.accessToken)?.value;
66
+ if (!raw) return false;
67
+ const token = DecryptCookieValue(raw);
68
+ if (!token) return false;
69
+ return VerifyAccessToken(token);
70
+ }
71
+
72
+ /** True on the central auth app when user has SSO session or a valid local access token. */
73
+ export async function HasAuthSession(): Promise<boolean> {
74
+ const cookieStore = await cookies();
75
+ const rawSsoSession = cookieStore.get(AUTH_COOKIES.ssoSession)?.value;
76
+ return !!(rawSsoSession && DecryptCookieValue(rawSsoSession));
77
+ }
@@ -0,0 +1,36 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { HttpPost } from "../services/http-services";
3
+ import { SSO_CONFIG } from "./sso-config";
4
+
5
+ export async function ForgotPasswordPostRoute(req: NextRequest) {
6
+ const body = (await req.json()) as { usernameOrEmail?: string };
7
+ if (!body.usernameOrEmail?.trim()) {
8
+ return NextResponse.json({ error: "Username or email is required." }, { status: 400 });
9
+ }
10
+
11
+ const response = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.FORGOT_PASSWORD, {
12
+ usernameOrEmail: body.usernameOrEmail.trim(),
13
+ });
14
+
15
+ if (!response.isSuccess) {
16
+ return NextResponse.json(response, { status: response.code ?? 400 });
17
+ }
18
+
19
+ return NextResponse.json(response);
20
+ }
21
+
22
+ export async function ResetPasswordPostRoute(req: NextRequest) {
23
+ const body = (await req.json()) as {
24
+ resetSessionToken?: string;
25
+ verificationCode?: string;
26
+ newPassword?: string;
27
+ confirmPassword?: string;
28
+ };
29
+
30
+ const response = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.RESET_PASSWORD, body);
31
+ if (!response.isSuccess) {
32
+ return NextResponse.json(response, { status: response.code ?? 400 });
33
+ }
34
+
35
+ return NextResponse.json(response);
36
+ }
@@ -0,0 +1,58 @@
1
+ import { createHmac, timingSafeEqual } from "crypto";
2
+ import { SanitizeReturnPath } from "./redirect-context-helpers";
3
+
4
+ type EncodedCallbackState = {
5
+ n: string;
6
+ p: string;
7
+ v: string;
8
+ t: number;
9
+ };
10
+
11
+ function getCookieSecret(): string {
12
+ const ssoSecret = process.env.SSO_COOKIE_SECRET?.trim();
13
+ const secret = ssoSecret && !ssoSecret.startsWith("$") ? ssoSecret : undefined;
14
+ if (secret) return secret;
15
+ if (process.env.NODE_ENV === "development") return "NexusDevAuthSecretKey2026MustBe32CharsLong!";
16
+ throw new Error("SSO_COOKIE_SECRET must be configured.");
17
+ }
18
+
19
+ function signPayload(data: string): string {
20
+ return createHmac("sha256", getCookieSecret()).update(data).digest("base64url");
21
+ }
22
+
23
+ /** Signed OAuth callback state (nonce + return path + PKCE verifier). */
24
+ export function EncodeOAuthCallbackState(nonce: string, returnPath: string, codeVerifier: string): string {
25
+ const payload: EncodedCallbackState = {
26
+ n: nonce,
27
+ p: SanitizeReturnPath(returnPath),
28
+ v: codeVerifier,
29
+ t: Date.now(),
30
+ };
31
+ const data = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
32
+ return `${data}.${signPayload(data)}`;
33
+ }
34
+
35
+ export function DecodeOAuthCallbackState(value?: string | null): { nonce: string; returnPath: string; codeVerifier: string } | null {
36
+ if (!value) return null;
37
+
38
+ try {
39
+ const [data, signature] = value.split(".");
40
+ if (!data || !signature) return null;
41
+
42
+ const expected = signPayload(data);
43
+ if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
44
+ return null;
45
+ }
46
+
47
+ const payload = JSON.parse(Buffer.from(data, "base64url").toString("utf8")) as EncodedCallbackState;
48
+ if (!payload?.n || !payload?.v) return null;
49
+
50
+ return {
51
+ nonce: payload.n,
52
+ returnPath: SanitizeReturnPath(payload.p),
53
+ codeVerifier: payload.v,
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,16 @@
1
+ import { randomBytes, createHash } from "crypto";
2
+
3
+ export const PKCE_METHOD_S256 = "S256";
4
+
5
+ export type PkcePair = {
6
+ verifier: string;
7
+ challenge: string;
8
+ method: typeof PKCE_METHOD_S256;
9
+ };
10
+
11
+ /** Creates an OAuth PKCE pair (RFC 7636) using S256. */
12
+ export function CreatePkcePair(): PkcePair {
13
+ const verifier = randomBytes(32).toString("base64url");
14
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
15
+ return { verifier, challenge, method: PKCE_METHOD_S256 };
16
+ }