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.
- package/package.json +1 -1
- package/src/client.ts +2 -1
- 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/helpers/browser-helpers.tsx +41 -1
- package/src/helpers/input-helpers.tsx +34 -4
- package/src/helpers/validation-helpers.tsx +302 -0
- package/src/index.ts +2 -18
- package/src/interface.ts +4 -1
- package/src/interfaces/button-interfaces.tsx +2 -2
- package/src/interfaces/http-interfaces.tsx +89 -8
- package/src/interfaces/input-interfaces.tsx +61 -11
- package/src/interfaces/message-interfaces.tsx +32 -0
- package/src/interfaces/storage-interfaces.tsx +1 -0
- package/src/interfaces/type-interfaces.tsx +8 -2
- package/src/interfaces/user-interfaces.tsx +17 -0
- package/src/nexus.environments.tsx +6 -1
- 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 +26 -11
- package/src/services/http-services.tsx +37 -22
- 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/{interfaces → sso-config}/sso-interfaces.tsx +17 -1
- 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
- package/src/load-env.ts +0 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { NEXUS_INFO, URLS } from "../nexus.environments";
|
|
3
|
+
import { HttpPost } from "../services/http-services";
|
|
4
|
+
import { AUTH_COOKIES, ClearAllAuthCookies, GetDecryptedRequestCookie } from "./cookie-helpers";
|
|
5
|
+
import { SanitizeReturnPath } from "./redirect-context-helpers";
|
|
6
|
+
import { SSO_CONFIG } from "./sso-config";
|
|
7
|
+
|
|
8
|
+
function hasAuthSession(req: NextRequest): boolean {
|
|
9
|
+
return !!(req.cookies.get(AUTH_COOKIES.ssoSession)?.value || req.cookies.get(AUTH_COOKIES.accessToken)?.value || req.cookies.get(AUTH_COOKIES.refreshToken)?.value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveReturnUrl(req: NextRequest): URL {
|
|
13
|
+
if (NEXUS_INFO.IS_ADMIN_MODULE && !hasAuthSession(req)) {
|
|
14
|
+
return new URL(URLS.SIGN_IN);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const appBase = req.nextUrl.origin ?? URLS.CURRENT!;
|
|
18
|
+
const returnPath = req.nextUrl.searchParams.get("returnUrl");
|
|
19
|
+
if (returnPath) {
|
|
20
|
+
return new URL(SanitizeReturnPath(returnPath), appBase);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new URL(appBase);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function revokeServerSession(req: NextRequest) {
|
|
27
|
+
const ssoSession = GetDecryptedRequestCookie(req, AUTH_COOKIES.ssoSession);
|
|
28
|
+
if (!ssoSession) return;
|
|
29
|
+
|
|
30
|
+
const response = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.SIGN_OUT, { ssoSession });
|
|
31
|
+
|
|
32
|
+
return response.isSuccess;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET — clear cookies and redirect back to the originating app page. */
|
|
36
|
+
export async function SignOutGetRoute(req: NextRequest) {
|
|
37
|
+
await revokeServerSession(req);
|
|
38
|
+
const response = NextResponse.redirect(resolveReturnUrl(req));
|
|
39
|
+
ClearAllAuthCookies(response);
|
|
40
|
+
return response;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** POST — clear cookies and return JSON (API clients). */
|
|
44
|
+
export async function SignOutPostRoute(req: NextRequest) {
|
|
45
|
+
if (NEXUS_INFO.IS_ADMIN_MODULE && !hasAuthSession(req)) {
|
|
46
|
+
return NextResponse.json({ ok: false, redirect: URLS.SIGN_IN }, { status: 401 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await revokeServerSession(req);
|
|
50
|
+
const response = NextResponse.json({ ok: true });
|
|
51
|
+
ClearAllAuthCookies(response);
|
|
52
|
+
return response;
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { HttpPost } from "../services/http-services";
|
|
3
|
+
import { SetSsoSessionCookie } from "./cookie-helpers";
|
|
4
|
+
import { SSO_CONFIG } from "./sso-config";
|
|
5
|
+
import { ISignUpRequest } from "./sso-interfaces";
|
|
6
|
+
import { ParseWrappedResult } from "./sso-response-helpers";
|
|
7
|
+
|
|
8
|
+
const mapGender = (gender?: string | null) => {
|
|
9
|
+
const normalized = gender?.trim().toLowerCase();
|
|
10
|
+
if (normalized === "male") return 0;
|
|
11
|
+
if (normalized === "female") return 1;
|
|
12
|
+
return 2;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** POST — register a new account (identity provider app). */
|
|
16
|
+
export async function SignUpPostRoute(req: NextRequest) {
|
|
17
|
+
const body = (await req.json()) as ISignUpRequest & { confirmPassword?: string };
|
|
18
|
+
|
|
19
|
+
if (!body.fullName?.trim() || !body.username?.trim() || !body.email?.trim() || !body.password || !body.confirmPassword) {
|
|
20
|
+
return NextResponse.json({ error: "All required fields must be provided." }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (body.password !== body.confirmPassword) {
|
|
24
|
+
return NextResponse.json({ error: "Passwords do not match." }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const signUpRes = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.SIGN_UP, {
|
|
28
|
+
fullname: body.fullName.trim(),
|
|
29
|
+
username: body.username.trim(),
|
|
30
|
+
password: body.password,
|
|
31
|
+
confirmPassword: body.confirmPassword,
|
|
32
|
+
genderType: mapGender(body.gender),
|
|
33
|
+
emailAddress: body.email.trim(),
|
|
34
|
+
contactNumber: null,
|
|
35
|
+
clientSystemName: SSO_CONFIG.BACKEND_CONFIG.CLIENT_SYSTEM_NAME,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const signUpResult = ParseWrappedResult<{ ssoSession: string }>(signUpRes);
|
|
39
|
+
if (!signUpRes.isSuccess || !signUpResult?.ssoSession) {
|
|
40
|
+
return NextResponse.json(signUpRes, { status: signUpRes.code ?? 400 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const response = NextResponse.json(signUpRes);
|
|
44
|
+
SetSsoSessionCookie(response, signUpResult.ssoSession, SSO_CONFIG.BACKEND_CONFIG.SSO_SESSION_MAX_AGE);
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { HttpPost } from "../services/http-services";
|
|
2
|
+
import { PKCE_METHOD_S256, PkcePair } from "./pkce-helpers";
|
|
3
|
+
import { SSO_CONFIG } from "./sso-config";
|
|
4
|
+
import { SSOAuthResponse } from "./sso-interfaces";
|
|
5
|
+
import { ParseSsoCodeResponse, ParseTokenResponse } from "./sso-response-helpers";
|
|
6
|
+
|
|
7
|
+
export type SsoExchangeInput = {
|
|
8
|
+
ssoSession: string;
|
|
9
|
+
redirectUri: string;
|
|
10
|
+
state?: string;
|
|
11
|
+
returnPath?: string;
|
|
12
|
+
pkce: PkcePair;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TokenExchangeInput = {
|
|
16
|
+
code: string;
|
|
17
|
+
redirectUri: string;
|
|
18
|
+
codeVerifier: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Server-only: request an authorization code using client policy + PKCE. */
|
|
22
|
+
export async function RequestAuthorizationCode(input: SsoExchangeInput): Promise<string | null> {
|
|
23
|
+
const ssoRes = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.SSO, {
|
|
24
|
+
ssoSession: input.ssoSession,
|
|
25
|
+
redirectUri: input.redirectUri,
|
|
26
|
+
state: input.state,
|
|
27
|
+
returnPath: input.returnPath,
|
|
28
|
+
codeChallenge: input.pkce.challenge,
|
|
29
|
+
codeChallengeMethod: input.pkce.method ?? PKCE_METHOD_S256,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return ParseSsoCodeResponse(ssoRes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Server-only: exchange authorization code for tokens. */
|
|
36
|
+
export async function ExchangeAuthorizationCode(input: TokenExchangeInput): Promise<SSOAuthResponse | null> {
|
|
37
|
+
const tokenRes = await HttpPost(SSO_CONFIG.BACKEND_ENDPOINTS.TOKEN, {
|
|
38
|
+
code: input.code,
|
|
39
|
+
redirectUri: input.redirectUri,
|
|
40
|
+
clientSystemName: SSO_CONFIG.BACKEND_CONFIG.CLIENT_SYSTEM_NAME,
|
|
41
|
+
codeVerifier: input.codeVerifier,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return ParseTokenResponse(tokenRes);
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { AUTH_COOKIES, GetDecryptedRequestCookie } from "./cookie-helpers";
|
|
3
|
+
import { EncodeOAuthCallbackState } from "./oauth-callback-state";
|
|
4
|
+
import { BuildAuthenticationSignInUrl } from "./sso-config";
|
|
5
|
+
import { ClearRedirectContextCookie, DecodeRedirectContext } from "./redirect-context-helpers";
|
|
6
|
+
import { PKCE_METHOD_S256, PkcePair } from "./pkce-helpers";
|
|
7
|
+
import { RequestAuthorizationCode } from "./sso-backend-flow";
|
|
8
|
+
|
|
9
|
+
function buildSignInRedirect(redirectContext: ReturnType<typeof DecodeRedirectContext>) {
|
|
10
|
+
return new URL(BuildAuthenticationSignInUrl(redirectContext?.returnPath));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Completes SSO after credentials sign-in: exchange session for code and redirect to the client callback. */
|
|
14
|
+
export async function SsoCompleteRoute(req: NextRequest) {
|
|
15
|
+
const ssoSession = GetDecryptedRequestCookie(req, AUTH_COOKIES.ssoSession);
|
|
16
|
+
const redirectContext = DecodeRedirectContext(req.cookies.get(AUTH_COOKIES.redirectContext)?.value);
|
|
17
|
+
|
|
18
|
+
if (!ssoSession || !redirectContext?.redirectUri) {
|
|
19
|
+
return NextResponse.redirect(buildSignInRedirect(redirectContext));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pkce: PkcePair = {
|
|
23
|
+
verifier: redirectContext.pkceVerifier,
|
|
24
|
+
challenge: redirectContext.codeChallenge,
|
|
25
|
+
method: (redirectContext.codeChallengeMethod as PkcePair["method"]) ?? PKCE_METHOD_S256,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const code = await RequestAuthorizationCode({
|
|
30
|
+
ssoSession,
|
|
31
|
+
redirectUri: redirectContext.redirectUri,
|
|
32
|
+
state: redirectContext.state,
|
|
33
|
+
returnPath: redirectContext.returnPath,
|
|
34
|
+
pkce,
|
|
35
|
+
});
|
|
36
|
+
if (!code) return NextResponse.redirect(buildSignInRedirect(redirectContext));
|
|
37
|
+
|
|
38
|
+
const callbackState = EncodeOAuthCallbackState(redirectContext.state, redirectContext.returnPath, pkce.verifier);
|
|
39
|
+
const location = `${redirectContext.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(callbackState)}`;
|
|
40
|
+
const response = NextResponse.redirect(location);
|
|
41
|
+
ClearRedirectContextCookie(response);
|
|
42
|
+
return response;
|
|
43
|
+
} catch {
|
|
44
|
+
return NextResponse.redirect(buildSignInRedirect(redirectContext));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { APIS, URLS } from "nexus-shared";
|
|
2
|
+
|
|
3
|
+
const _callbackPath = `${URLS.CURRENT}/api/auth/callback`;
|
|
4
|
+
|
|
5
|
+
export const SSO_CONFIG = {
|
|
6
|
+
COOKIE_CONFIG: {
|
|
7
|
+
COOKIE_SECRET: process.env.SSO_COOKIE_SECRET as string,
|
|
8
|
+
},
|
|
9
|
+
BACKEND_ENDPOINTS: {
|
|
10
|
+
SIGN_IN: `${APIS.AUTHENTICATION}/Authentication/SignIn`,
|
|
11
|
+
SIGN_UP: `${APIS.AUTHENTICATION}/Authentication/SignUp`,
|
|
12
|
+
SSO: `${APIS.AUTHENTICATION}/Authentication/SSO`,
|
|
13
|
+
TOKEN: `${APIS.AUTHENTICATION}/Authentication/Token`,
|
|
14
|
+
REFRESH: `${APIS.AUTHENTICATION}/Authentication/Refresh`,
|
|
15
|
+
SIGN_OUT: `${APIS.AUTHENTICATION}/Authentication/SignOut`,
|
|
16
|
+
PROVIDERS: `${APIS.AUTHENTICATION}/Authentication/Providers`,
|
|
17
|
+
FORGOT_PASSWORD: `${APIS.AUTHENTICATION}/Authentication/ForgotPassword`,
|
|
18
|
+
RESET_PASSWORD: `${APIS.AUTHENTICATION}/Authentication/ResetPassword`,
|
|
19
|
+
/** Used by VerifyAccessToken — returns 200 for a valid Bearer token, 401 otherwise. */
|
|
20
|
+
VERIFY: `${APIS.AUTHENTICATION}/Authentication/Verify`,
|
|
21
|
+
PROVIDER_CHALLENGE: (provider: string, returnUrl: string, clientSystemName: string) => `${APIS.AUTHENTICATION}/Provider/${provider}/Challenge?returnUrl=${encodeURIComponent(returnUrl)}&clientSystemName=${encodeURIComponent(clientSystemName)}`,
|
|
22
|
+
},
|
|
23
|
+
BACKEND_CONFIG: {
|
|
24
|
+
/** Matches AuthenticationOptions.DefaultClientSystemName (NEXUS_WEB_APPLICATION_API). */
|
|
25
|
+
CLIENT_SYSTEM_NAME: process.env.AUTH_CLIENT_SYSTEM_NAME as string,
|
|
26
|
+
/** Matches ClientApplications.SsoSessionLifetime (30 minutes). */
|
|
27
|
+
SSO_SESSION_MAX_AGE: process.env.SSO_SESSION_MAX_AGE as string,
|
|
28
|
+
/** How long redirect_uri/state survive in a secure httpOnly cookie on the auth app (seconds). */
|
|
29
|
+
REDIRECT_CONTEXT_MAX_AGE: process.env.REDIRECT_CONTEXT_MAX_AGE as string,
|
|
30
|
+
},
|
|
31
|
+
FRONTEND_ENDPOINTS: {
|
|
32
|
+
CALLBACK: _callbackPath,
|
|
33
|
+
SIGN_IN: `${URLS.CURRENT}/api/auth/sign-in`,
|
|
34
|
+
SIGN_UP: `${URLS.CURRENT}/api/auth/sign-up`,
|
|
35
|
+
SIGN_OUT: `${URLS.CURRENT}/api/auth/sign-out`,
|
|
36
|
+
REFRESH: `${URLS.CURRENT}/api/auth/refresh`,
|
|
37
|
+
SSO_COMPLETE: `${URLS.CURRENT}/api/auth/sso-complete`,
|
|
38
|
+
PROVIDER_COMPLETE: `${URLS.CURRENT}/api/auth/provider-complete`,
|
|
39
|
+
FORGOT_PASSWORD: `${URLS.CURRENT}/api/auth/forgot-password`,
|
|
40
|
+
RESET_PASSWORD: `${URLS.CURRENT}/api/auth/reset-password`,
|
|
41
|
+
},
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/** Client-app entry URL for centralized sign-in (includes redirect_uri + optional return path). */
|
|
45
|
+
export function BuildAuthenticationSignInUrl(returnPath?: string) {
|
|
46
|
+
const entryUrl = new URL(`${URLS.AUTHENTICATION}/api/auth/redirect-context`);
|
|
47
|
+
entryUrl.searchParams.set("redirect_uri", SSO_CONFIG.FRONTEND_ENDPOINTS.CALLBACK);
|
|
48
|
+
if (returnPath) entryUrl.searchParams.set("state", returnPath);
|
|
49
|
+
return entryUrl.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Sign-out URL that returns the user to a page on this app after cookies are cleared. */
|
|
53
|
+
export function BuildSignOutUrl(returnPath?: string) {
|
|
54
|
+
const signOutUrl = new URL(SSO_CONFIG.FRONTEND_ENDPOINTS.SIGN_OUT);
|
|
55
|
+
if (returnPath) signOutUrl.searchParams.set("returnUrl", returnPath);
|
|
56
|
+
return signOutUrl.toString();
|
|
57
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UN } from "
|
|
1
|
+
import { UN } from "../interfaces/type-interfaces";
|
|
2
2
|
export interface SSOAuthResponse {
|
|
3
3
|
accessToken: string;
|
|
4
4
|
refreshToken: string;
|
|
@@ -19,6 +19,11 @@ export interface IRedirectContext {
|
|
|
19
19
|
state: string;
|
|
20
20
|
/** Relative path to return to after sign-in completes. */
|
|
21
21
|
returnPath: string;
|
|
22
|
+
/** PKCE code challenge (stored server-side until SSO). */
|
|
23
|
+
codeChallenge: string;
|
|
24
|
+
codeChallengeMethod: string;
|
|
25
|
+
/** PKCE verifier (server-side only; also embedded in signed callback state). */
|
|
26
|
+
pkceVerifier: string;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export interface ISignInParams {
|
|
@@ -33,6 +38,8 @@ export interface ISsoSessionResponse {
|
|
|
33
38
|
export interface ILoginRequest {
|
|
34
39
|
username: string;
|
|
35
40
|
password: string;
|
|
41
|
+
rememberMe: boolean;
|
|
42
|
+
clientSystemName: string;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface ILoginResponse {
|
|
@@ -40,6 +47,15 @@ export interface ILoginResponse {
|
|
|
40
47
|
displayName: string;
|
|
41
48
|
role: string;
|
|
42
49
|
userId: number;
|
|
50
|
+
redirectUrl?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ISignUpRequest {
|
|
54
|
+
fullName: string;
|
|
55
|
+
gender?: string | UN;
|
|
56
|
+
username: string;
|
|
57
|
+
email: string;
|
|
58
|
+
password: string;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
export interface IDemoUserInfo {
|