nexus-shared 1.1.17 → 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.
- package/package.json +1 -1
- package/src/api-services/system-service.tsx +1 -2
- package/src/client.ts +3 -2
- package/src/components/documents/button.tsx +5 -1
- package/src/components/inputs/input-box.tsx +4 -2
- package/src/components/inputs/input-form.tsx +126 -14
- package/src/components/layouts/global-layout.tsx +4 -2
- package/src/components/layouts/layout-helpers.tsx +23 -23
- package/src/helpers/browser-helpers.tsx +41 -1
- package/src/helpers/input-helpers.tsx +34 -4
- package/src/helpers/utility-helpers.tsx +1 -0
- package/src/helpers/validation-helpers.tsx +302 -0
- package/src/index.ts +3 -18
- package/src/interface.ts +4 -1
- package/src/interfaces/button-interfaces.tsx +2 -2
- package/src/interfaces/exception-interfaces.tsx +99 -0
- package/src/interfaces/http-interfaces.tsx +120 -3
- package/src/interfaces/input-interfaces.tsx +61 -11
- package/src/interfaces/message-interfaces.tsx +32 -0
- package/src/interfaces/permission-interfaces.tsx +4 -4
- package/src/interfaces/storage-interfaces.tsx +1 -0
- package/src/interfaces/system-interfaces.tsx +2 -2
- package/src/interfaces/type-interfaces.tsx +8 -2
- package/src/interfaces/user-interfaces.tsx +17 -0
- package/src/nexus.environments.tsx +27 -17
- package/src/proxy-api/proxy-backend.tsx +60 -0
- package/src/proxy-api/proxy-constants.tsx +28 -0
- package/src/proxy-api/proxy-helpers.tsx +91 -0
- package/src/proxy-api-client.tsx +1 -0
- package/src/proxy-api-server.tsx +2 -0
- package/src/services/http-client-services.tsx +75 -0
- package/src/services/http-services.tsx +158 -0
- package/src/services/loader-service.tsx +5 -4
- package/src/services/localstorage-service.tsx +59 -0
- package/src/services/message-services.tsx +383 -0
- package/src/sso-config/auth-token-validation.ts +20 -0
- package/src/sso-config/callback-route.tsx +34 -0
- package/src/sso-config/cookie-encryption.ts +51 -0
- package/src/sso-config/cookie-helpers.tsx +77 -0
- package/src/sso-config/forgot-password-route.tsx +36 -0
- package/src/sso-config/oauth-callback-state.ts +58 -0
- package/src/sso-config/pkce-helpers.ts +16 -0
- package/src/sso-config/provider-complete-route.tsx +15 -0
- package/src/sso-config/redirect-context-actions.tsx +46 -0
- package/src/sso-config/redirect-context-helpers.tsx +144 -0
- package/src/sso-config/redirect-context-persist.tsx +18 -0
- package/src/sso-config/redirect-context-route.tsx +45 -0
- package/src/sso-config/refresh-route.tsx +22 -0
- package/src/sso-config/sign-in-route.tsx +69 -0
- package/src/sso-config/sign-out-route.tsx +53 -0
- package/src/sso-config/sign-up-route.tsx +46 -0
- package/src/sso-config/sso-backend-flow.ts +45 -0
- package/src/sso-config/sso-complete-route.tsx +46 -0
- package/src/sso-config/sso-config.ts +57 -0
- package/src/sso-config/sso-interfaces.tsx +68 -0
- package/src/sso-config/sso-response-helpers.tsx +20 -0
- package/src/sso-server.tsx +21 -1
- package/src/styles/nexus.dialog.css +3 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { SetSsoSessionCookie } from "./cookie-helpers";
|
|
3
|
+
import { SSO_CONFIG } from "./sso-config";
|
|
4
|
+
|
|
5
|
+
/** GET — complete provider OAuth by persisting the backend SSO session cookie. */
|
|
6
|
+
export async function ProviderCompleteRoute(req: NextRequest) {
|
|
7
|
+
const signInUrl = new URL("/auth/sign-in", req.url);
|
|
8
|
+
const ssoSession = req.nextUrl.searchParams.get("ssoSession");
|
|
9
|
+
|
|
10
|
+
if (!ssoSession) return NextResponse.redirect(signInUrl);
|
|
11
|
+
|
|
12
|
+
const response = NextResponse.redirect(new URL(SSO_CONFIG.FRONTEND_ENDPOINTS.SSO_COMPLETE, req.url));
|
|
13
|
+
SetSsoSessionCookie(response, ssoSession, SSO_CONFIG.BACKEND_CONFIG.SSO_SESSION_MAX_AGE);
|
|
14
|
+
return response;
|
|
15
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
import { AUTH_COOKIES } from "./cookie-helpers";
|
|
5
|
+
import { CreatePkcePair } from "./pkce-helpers";
|
|
6
|
+
import { CreateOAuthNonce, DecodeRedirectContext, EncodeRedirectContext, GetRedirectContextCookieOptions, IsAllowedRedirectUri, SanitizeReturnPath } from "./redirect-context-helpers";
|
|
7
|
+
import { IRedirectContext } from "./sso-interfaces";
|
|
8
|
+
|
|
9
|
+
function ReadRedirectContextFromStore(cookieStore: Awaited<ReturnType<typeof cookies>>): IRedirectContext | null {
|
|
10
|
+
return DecodeRedirectContext(cookieStore.get(AUTH_COOKIES.redirectContext)?.value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function WriteRedirectContextToStore(cookieStore: Awaited<ReturnType<typeof cookies>>, context: IRedirectContext) {
|
|
14
|
+
cookieStore.set(AUTH_COOKIES.redirectContext, EncodeRedirectContext(context), GetRedirectContextCookieOptions());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Server Action — persists redirect_uri/state into a signed httpOnly cookie. */
|
|
18
|
+
export async function PersistRedirectContext(redirectUri?: string | null, state?: string | null) {
|
|
19
|
+
const cookieStore = await cookies();
|
|
20
|
+
const trimmedUri = redirectUri?.trim();
|
|
21
|
+
|
|
22
|
+
if (trimmedUri && IsAllowedRedirectUri(trimmedUri)) {
|
|
23
|
+
const existing = ReadRedirectContextFromStore(cookieStore);
|
|
24
|
+
if (existing?.redirectUri === trimmedUri) return;
|
|
25
|
+
|
|
26
|
+
const pkce = CreatePkcePair();
|
|
27
|
+
WriteRedirectContextToStore(cookieStore, {
|
|
28
|
+
redirectUri: trimmedUri,
|
|
29
|
+
state: CreateOAuthNonce(),
|
|
30
|
+
returnPath: SanitizeReturnPath(state),
|
|
31
|
+
codeChallenge: pkce.challenge,
|
|
32
|
+
codeChallengeMethod: pkce.method,
|
|
33
|
+
pkceVerifier: pkce.verifier,
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const existing = ReadRedirectContextFromStore(cookieStore);
|
|
39
|
+
if (existing) WriteRedirectContextToStore(cookieStore, existing);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Server Action — clears the redirect context cookie after SSO completes. */
|
|
43
|
+
export async function ClearRedirectContext() {
|
|
44
|
+
const cookieStore = await cookies();
|
|
45
|
+
cookieStore.delete(AUTH_COOKIES.redirectContext);
|
|
46
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { URLS } from "../nexus.environments";
|
|
5
|
+
import { AUTH_COOKIES } from "./cookie-helpers";
|
|
6
|
+
import { CreatePkcePair, PKCE_METHOD_S256 } from "./pkce-helpers";
|
|
7
|
+
import { SSO_CONFIG } from "./sso-config";
|
|
8
|
+
import { IRedirectContext } from "./sso-interfaces";
|
|
9
|
+
|
|
10
|
+
const CALLBACK_SUFFIX = "/api/auth/callback";
|
|
11
|
+
|
|
12
|
+
type EncodedRedirectContext = {
|
|
13
|
+
r: string;
|
|
14
|
+
s: string;
|
|
15
|
+
p: string;
|
|
16
|
+
c: string;
|
|
17
|
+
m: string;
|
|
18
|
+
v: string;
|
|
19
|
+
t: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const signPayload = (data: string): string => createHmac("sha256", SSO_CONFIG.COOKIE_CONFIG.COOKIE_SECRET).update(data).digest("base64url");
|
|
23
|
+
|
|
24
|
+
export function SanitizeReturnPath(value?: string | null): string {
|
|
25
|
+
if (!value?.trim()) return "/";
|
|
26
|
+
const path = value.trim();
|
|
27
|
+
if (!path.startsWith("/") || path.startsWith("//")) return "/";
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function CreateOAuthNonce(): string {
|
|
32
|
+
return randomBytes(24).toString("base64url");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Validates client callback URLs to prevent open redirects. */
|
|
36
|
+
export function IsAllowedRedirectUri(redirectUri: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(redirectUri);
|
|
39
|
+
if (!url.pathname.endsWith(CALLBACK_SUFFIX)) return false;
|
|
40
|
+
if (url.username || url.password) return false;
|
|
41
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
42
|
+
return URLS.ALLOWED_ORIGINS.includes(url.origin);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function EncodeRedirectContext(context: IRedirectContext): string {
|
|
49
|
+
const payload: EncodedRedirectContext = {
|
|
50
|
+
r: context.redirectUri,
|
|
51
|
+
s: context.state,
|
|
52
|
+
p: context.returnPath,
|
|
53
|
+
c: context.codeChallenge,
|
|
54
|
+
m: context.codeChallengeMethod,
|
|
55
|
+
v: context.pkceVerifier,
|
|
56
|
+
t: Date.now(),
|
|
57
|
+
};
|
|
58
|
+
const data = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
59
|
+
return `${data}.${signPayload(data)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function DecodeRedirectContext(value?: string | null): IRedirectContext | null {
|
|
63
|
+
if (!value) return null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const [data, signature] = value.split(".");
|
|
67
|
+
if (!data || !signature) return null;
|
|
68
|
+
|
|
69
|
+
const expected = signPayload(data);
|
|
70
|
+
if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const payload = JSON.parse(Buffer.from(data, "base64url").toString("utf8")) as EncodedRedirectContext;
|
|
75
|
+
if (!payload?.r || !IsAllowedRedirectUri(payload.r)) return null;
|
|
76
|
+
|
|
77
|
+
const ageMs = Date.now() - (payload.t ?? 0);
|
|
78
|
+
if (ageMs > Number(SSO_CONFIG.BACKEND_CONFIG.REDIRECT_CONTEXT_MAX_AGE) * 1000) return null;
|
|
79
|
+
|
|
80
|
+
if (!payload.c || !payload.v) return null;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
redirectUri: payload.r,
|
|
84
|
+
state: payload.s ?? "",
|
|
85
|
+
returnPath: SanitizeReturnPath(payload.p),
|
|
86
|
+
codeChallenge: payload.c,
|
|
87
|
+
codeChallengeMethod: payload.m ?? PKCE_METHOD_S256,
|
|
88
|
+
pkceVerifier: payload.v,
|
|
89
|
+
};
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createRedirectContext(redirectUri: string, returnPath?: string | null): IRedirectContext {
|
|
96
|
+
const pkce = CreatePkcePair();
|
|
97
|
+
return {
|
|
98
|
+
redirectUri,
|
|
99
|
+
state: CreateOAuthNonce(),
|
|
100
|
+
returnPath: SanitizeReturnPath(returnPath),
|
|
101
|
+
codeChallenge: pkce.challenge,
|
|
102
|
+
codeChallengeMethod: pkce.method,
|
|
103
|
+
pkceVerifier: pkce.verifier,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read-only: resolve redirect context from cookie, or build an in-memory context from URL params (no cookie writes). */
|
|
108
|
+
export async function ReadRedirectContext(redirectUri?: string | null, state?: string | null): Promise<IRedirectContext | null> {
|
|
109
|
+
const cookieStore = await cookies();
|
|
110
|
+
const fromCookie = DecodeRedirectContext(cookieStore.get(AUTH_COOKIES.redirectContext)?.value);
|
|
111
|
+
|
|
112
|
+
const trimmedUri = redirectUri?.trim();
|
|
113
|
+
if (trimmedUri && IsAllowedRedirectUri(trimmedUri)) {
|
|
114
|
+
if (fromCookie?.redirectUri === trimmedUri) {
|
|
115
|
+
return fromCookie;
|
|
116
|
+
}
|
|
117
|
+
return createRedirectContext(trimmedUri, state);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return fromCookie;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function GetAuthCookieOptions(maxAge: number | string) {
|
|
124
|
+
return {
|
|
125
|
+
httpOnly: true,
|
|
126
|
+
path: "/",
|
|
127
|
+
sameSite: "lax" as const,
|
|
128
|
+
maxAge: Number(maxAge),
|
|
129
|
+
secure: process.env.NODE_ENV === "production",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function GetRedirectContextCookieOptions() {
|
|
134
|
+
return GetAuthCookieOptions(SSO_CONFIG.BACKEND_CONFIG.REDIRECT_CONTEXT_MAX_AGE);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function SetRedirectContextCookie(response: NextResponse, context: IRedirectContext) {
|
|
138
|
+
if (!IsAllowedRedirectUri(context.redirectUri)) return;
|
|
139
|
+
response.cookies.set(AUTH_COOKIES.redirectContext, EncodeRedirectContext(context), GetRedirectContextCookieOptions());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function ClearRedirectContextCookie(response: NextResponse) {
|
|
143
|
+
response.cookies.set(AUTH_COOKIES.redirectContext, "", { ...GetRedirectContextCookieOptions(), maxAge: 0 });
|
|
144
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from "next/navigation";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { PersistRedirectContext } from "./redirect-context-actions";
|
|
6
|
+
|
|
7
|
+
/** Keeps redirect_uri/state in a secure httpOnly cookie across auth page navigations. */
|
|
8
|
+
export function RedirectContextPersist() {
|
|
9
|
+
const searchParams = useSearchParams();
|
|
10
|
+
const redirectUri = searchParams.get("redirect_uri");
|
|
11
|
+
const state = searchParams.get("state");
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
void PersistRedirectContext(redirectUri, state);
|
|
15
|
+
}, [redirectUri, state]);
|
|
16
|
+
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { AUTH_COOKIES } from "./cookie-helpers";
|
|
3
|
+
import { CreatePkcePair } from "./pkce-helpers";
|
|
4
|
+
import {
|
|
5
|
+
CreateOAuthNonce,
|
|
6
|
+
DecodeRedirectContext,
|
|
7
|
+
IsAllowedRedirectUri,
|
|
8
|
+
SanitizeReturnPath,
|
|
9
|
+
SetRedirectContextCookie,
|
|
10
|
+
} from "./redirect-context-helpers";
|
|
11
|
+
|
|
12
|
+
/** Route Handler — persists redirect_uri/state in a signed cookie, then forwards to the auth sign-in page. */
|
|
13
|
+
export async function RedirectContextRoute(req: NextRequest) {
|
|
14
|
+
const redirectUri = req.nextUrl.searchParams.get("redirect_uri");
|
|
15
|
+
const state = req.nextUrl.searchParams.get("state");
|
|
16
|
+
|
|
17
|
+
const signInUrl = new URL("/auth/sign-in", req.url);
|
|
18
|
+
if (redirectUri) signInUrl.searchParams.set("redirect_uri", redirectUri);
|
|
19
|
+
if (state) signInUrl.searchParams.set("state", state);
|
|
20
|
+
|
|
21
|
+
const response = NextResponse.redirect(signInUrl);
|
|
22
|
+
const trimmedUri = redirectUri?.trim();
|
|
23
|
+
|
|
24
|
+
if (!trimmedUri || !IsAllowedRedirectUri(trimmedUri)) {
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const existing = DecodeRedirectContext(req.cookies.get(AUTH_COOKIES.redirectContext)?.value);
|
|
29
|
+
if (existing?.redirectUri === trimmedUri) {
|
|
30
|
+
SetRedirectContextCookie(response, existing);
|
|
31
|
+
return response;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pkce = CreatePkcePair();
|
|
35
|
+
SetRedirectContextCookie(response, {
|
|
36
|
+
redirectUri: trimmedUri,
|
|
37
|
+
state: CreateOAuthNonce(),
|
|
38
|
+
returnPath: SanitizeReturnPath(state),
|
|
39
|
+
codeChallenge: pkce.challenge,
|
|
40
|
+
codeChallengeMethod: pkce.method,
|
|
41
|
+
pkceVerifier: pkce.verifier,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { HttpPost } from "../services/http-services";
|
|
3
|
+
import { AUTH_COOKIES, GetDecryptedRequestCookie, SetAuthCookies } from "./cookie-helpers";
|
|
4
|
+
import { SSO_CONFIG } from "./sso-config";
|
|
5
|
+
import { SSOAuthResponse } from "./sso-interfaces";
|
|
6
|
+
|
|
7
|
+
export async function RefreshRoute(req: NextRequest) {
|
|
8
|
+
const refreshToken = GetDecryptedRequestCookie(req, AUTH_COOKIES.refreshToken);
|
|
9
|
+
if (!refreshToken) return NextResponse.json({ error: "No refresh token" }, { status: 401 });
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const refreshRes = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.REFRESH, { refreshToken, clientSystemName: SSO_CONFIG.BACKEND_CONFIG.CLIENT_SYSTEM_NAME });
|
|
13
|
+
if (!refreshRes.isSuccess) return NextResponse.json({ error: "Refresh failed" }, { status: 401 });
|
|
14
|
+
|
|
15
|
+
const newTokens = refreshRes.result as SSOAuthResponse;
|
|
16
|
+
const response = NextResponse.json({ ok: true });
|
|
17
|
+
SetAuthCookies(response, newTokens);
|
|
18
|
+
return response;
|
|
19
|
+
} catch {
|
|
20
|
+
return NextResponse.json({ error: "Auth service unavailable" }, { status: 500 });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { EErrorCodes, EExceptionCategories } from "../interfaces/exception-interfaces";
|
|
3
|
+
import { GetResponse } from "../interfaces/http-interfaces";
|
|
4
|
+
import { URLS } from "../nexus.environments";
|
|
5
|
+
import { HttpPost } from "../services/http-services";
|
|
6
|
+
import { AUTH_COOKIES, GetDecryptedRequestCookie, SetAuthCookies, SetSsoSessionCookie } from "./cookie-helpers";
|
|
7
|
+
import { CreatePkcePair } from "./pkce-helpers";
|
|
8
|
+
import { ClearRedirectContextCookie } from "./redirect-context-helpers";
|
|
9
|
+
import { ExchangeAuthorizationCode, RequestAuthorizationCode } from "./sso-backend-flow";
|
|
10
|
+
import { BuildAuthenticationSignInUrl, SSO_CONFIG } from "./sso-config";
|
|
11
|
+
import { ILoginRequest, ILoginResponse } from "./sso-interfaces";
|
|
12
|
+
|
|
13
|
+
function failRedirect(req: NextRequest) {
|
|
14
|
+
const returnPath = req.nextUrl.searchParams.get("state") ?? "/";
|
|
15
|
+
return NextResponse.redirect(new URL(BuildAuthenticationSignInUrl(returnPath)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** GET — exchange local sso_session for access tokens (identity provider app). */
|
|
19
|
+
export async function SignInGetRoute(req: NextRequest) {
|
|
20
|
+
const ssoSession = GetDecryptedRequestCookie(req, AUTH_COOKIES.ssoSession);
|
|
21
|
+
|
|
22
|
+
if (!ssoSession) return failRedirect(req);
|
|
23
|
+
|
|
24
|
+
const pkce = CreatePkcePair();
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const code = await RequestAuthorizationCode({
|
|
28
|
+
ssoSession,
|
|
29
|
+
redirectUri: SSO_CONFIG.FRONTEND_ENDPOINTS.CALLBACK,
|
|
30
|
+
pkce,
|
|
31
|
+
});
|
|
32
|
+
if (!code) return failRedirect(req);
|
|
33
|
+
|
|
34
|
+
const tokenData = await ExchangeAuthorizationCode({
|
|
35
|
+
code,
|
|
36
|
+
redirectUri: SSO_CONFIG.FRONTEND_ENDPOINTS.CALLBACK,
|
|
37
|
+
codeVerifier: pkce.verifier,
|
|
38
|
+
});
|
|
39
|
+
if (!tokenData) return failRedirect(req);
|
|
40
|
+
|
|
41
|
+
const response = NextResponse.redirect(new URL(URLS.CURRENT!));
|
|
42
|
+
SetAuthCookies(response, tokenData);
|
|
43
|
+
ClearRedirectContextCookie(response);
|
|
44
|
+
return response;
|
|
45
|
+
} catch {
|
|
46
|
+
return failRedirect(req);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** POST — username/password sign-in (identity provider app). */
|
|
51
|
+
export async function SignInPostRoute(req: NextRequest) {
|
|
52
|
+
const body = (await req.json()) as ILoginRequest;
|
|
53
|
+
body.clientSystemName = SSO_CONFIG.BACKEND_CONFIG.CLIENT_SYSTEM_NAME;
|
|
54
|
+
|
|
55
|
+
if (!body.username?.trim() || !body.password) return NextResponse.json(GetResponse.ERROR("Username and password are required.", "USERNAME_AND_PASSWORD_REQUIRED", EExceptionCategories.Validation, EErrorCodes.FormatInvalid, "Username|Password"), { status: 400 });
|
|
56
|
+
|
|
57
|
+
const signInRes = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.SIGN_IN, body);
|
|
58
|
+
|
|
59
|
+
const signInResult = signInRes?.result as ILoginResponse;
|
|
60
|
+
|
|
61
|
+
if (signInResult?.ssoSession) signInResult.redirectUrl = SSO_CONFIG.FRONTEND_ENDPOINTS.SSO_COMPLETE;
|
|
62
|
+
|
|
63
|
+
if (!signInRes.isSuccess || !signInResult?.ssoSession) return NextResponse.json(signInRes, { status: signInRes.code ?? 401 });
|
|
64
|
+
|
|
65
|
+
const response = NextResponse.json(signInRes);
|
|
66
|
+
|
|
67
|
+
SetSsoSessionCookie(response, signInResult.ssoSession, SSO_CONFIG.BACKEND_CONFIG.SSO_SESSION_MAX_AGE);
|
|
68
|
+
return response;
|
|
69
|
+
}
|