@unchainedshop/api 4.6.1 → 4.7.2
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/lib/acl.js +29 -1
- package/lib/api-index.d.ts +1 -0
- package/lib/auth.d.ts +44 -0
- package/lib/auth.js +187 -0
- package/lib/context.d.ts +1 -0
- package/lib/context.js +8 -3
- package/lib/events.d.ts +2 -0
- package/lib/events.js +2 -0
- package/lib/express/index.d.ts +6 -9
- package/lib/express/index.js +52 -125
- package/lib/express/mountPluginRoutes.d.ts +3 -0
- package/lib/express/mountPluginRoutes.js +59 -0
- package/lib/express/mountRoutes.d.ts +3 -0
- package/lib/express/mountRoutes.js +57 -0
- package/lib/fastify/index.d.ts +6 -9
- package/lib/fastify/index.js +51 -113
- package/lib/fastify/mountPluginRoutes.d.ts +3 -0
- package/lib/fastify/mountPluginRoutes.js +62 -0
- package/lib/fastify/mountRoutes.d.ts +3 -0
- package/lib/fastify/mountRoutes.js +60 -0
- package/lib/handlers/createBackchannelLogoutHandler.d.ts +4 -0
- package/lib/handlers/createBackchannelLogoutHandler.js +170 -0
- package/lib/mcp/tools/localization/getLocalizationsConfig.d.ts +1 -1
- package/lib/mcp/tools/localization/schemas.d.ts +34 -0
- package/lib/mcp/tools/order/handlers/getTopCustomers.d.ts +2 -0
- package/lib/mcp/tools/quotation/handlers/getQuotation.d.ts +2 -0
- package/lib/mcp/tools/quotation/handlers/listQuotations.d.ts +2 -0
- package/lib/mcp/tools/quotation/handlers/makeQuotationProposal.d.ts +2 -0
- package/lib/mcp/tools/quotation/handlers/rejectQuotation.d.ts +2 -0
- package/lib/mcp/tools/quotation/handlers/verifyQuotation.d.ts +2 -0
- package/lib/mcp/tools/system/handlers/countEvents.d.ts +8 -1
- package/lib/mcp/tools/system/handlers/countWork.d.ts +10 -1
- package/lib/mcp/tools/system/handlers/countWork.js +1 -0
- package/lib/mcp/tools/system/handlers/index.d.ts +4 -4
- package/lib/mcp/tools/system/handlers/listEvents.d.ts +12 -1
- package/lib/mcp/tools/system/handlers/listWork.d.ts +14 -1
- package/lib/mcp/tools/system/handlers/listWork.js +1 -0
- package/lib/mcp/tools/users/handlers/addUserEmail.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/createUser.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/enrollUser.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/getCurrentUser.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/getUser.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/listUsers.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/setUserTags.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/setUserUsername.d.ts +2 -0
- package/lib/mcp/tools/users/handlers/updateUser.d.ts +2 -0
- package/lib/mcp/utils/getNormalizedQuotationDetails.d.ts +2 -0
- package/lib/mcp/utils/getNormalizedUserDetails.d.ts +2 -0
- package/lib/mcp/utils/normalizeMediaUrl.js +1 -1
- package/lib/mcp/utils/sanitizeLocalizationEntityData.d.ts +1 -1
- package/lib/mcp/utils/validateIsoCode.d.ts +1 -1
- package/lib/middleware/createAuthMiddleware.d.ts +29 -0
- package/lib/middleware/createAuthMiddleware.js +128 -0
- package/lib/resolvers/mutations/accounts/logoutAllSessions.d.ts +4 -0
- package/lib/resolvers/mutations/accounts/logoutAllSessions.js +11 -0
- package/lib/resolvers/mutations/index.d.ts +1 -6
- package/lib/resolvers/mutations/index.js +2 -12
- package/lib/resolvers/mutations/orders/updateCart.js +7 -1
- package/lib/resolvers/type/index.d.ts +0 -1
- package/lib/resolvers/type/order/order-delivery-pickup-types.d.ts +0 -1
- package/lib/resolvers/type/order/order-delivery-pickup-types.js +0 -7
- package/lib/resolvers/type/order/order-payment-base.d.ts +7 -0
- package/lib/resolvers/type/order/order-payment-base.js +14 -0
- package/lib/roles/index.js +1 -0
- package/lib/roles/loggedIn.js +1 -0
- package/lib/schema/mutation.js +6 -44
- package/lib/schema/types/common.d.ts +2 -0
- package/lib/schema/types/common.js +12 -0
- package/lib/schema/types/index.js +2 -2
- package/lib/schema/types/order/delivery.js +0 -2
- package/lib/utils/mapServiceError.d.ts +1 -0
- package/lib/utils/mapServiceError.js +59 -0
- package/lib/utils/maskError.d.ts +1 -0
- package/lib/utils/maskError.js +22 -0
- package/package.json +8 -17
package/lib/acl.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { Roles } from '@unchainedshop/roles';
|
|
2
|
+
import { emit } from '@unchainedshop/events';
|
|
2
3
|
import { NoPermissionError, PermissionSystemError } from "./errors.js";
|
|
4
|
+
import { API_EVENTS } from "./events.js";
|
|
5
|
+
const SENSITIVE_ACTION_PREFIXES = [
|
|
6
|
+
'manageUser',
|
|
7
|
+
'login',
|
|
8
|
+
'logout',
|
|
9
|
+
'reset',
|
|
10
|
+
'forgot',
|
|
11
|
+
'impersonate',
|
|
12
|
+
'updateUser',
|
|
13
|
+
'createUser',
|
|
14
|
+
];
|
|
3
15
|
const defaultOptions = {
|
|
4
16
|
showKey: true,
|
|
5
17
|
mapArgs: (...args) => args,
|
|
@@ -19,11 +31,27 @@ export const ensureIsFunction = (fn, action, options, key) => {
|
|
|
19
31
|
});
|
|
20
32
|
}
|
|
21
33
|
};
|
|
34
|
+
const isSensitiveAction = (action) => {
|
|
35
|
+
return SENSITIVE_ACTION_PREFIXES.some((prefix) => action.startsWith(prefix));
|
|
36
|
+
};
|
|
22
37
|
const checkAction = async (context, action, args = emptyArray, options = emptyObject) => {
|
|
23
38
|
const { key } = options || emptyObject;
|
|
24
39
|
const hasPermission = await Roles.userHasPermission(context, action, args);
|
|
25
|
-
if (hasPermission)
|
|
40
|
+
if (hasPermission) {
|
|
41
|
+
if (isSensitiveAction(action)) {
|
|
42
|
+
await emit(API_EVENTS.ACL_GRANTED_SENSITIVE, {
|
|
43
|
+
userId: context.userId,
|
|
44
|
+
action,
|
|
45
|
+
key,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
26
48
|
return;
|
|
49
|
+
}
|
|
50
|
+
await emit(API_EVENTS.ACL_DENIED, {
|
|
51
|
+
userId: context.userId,
|
|
52
|
+
action,
|
|
53
|
+
key,
|
|
54
|
+
});
|
|
27
55
|
const keyText = key && key !== '' ? ` in "${key}"` : '';
|
|
28
56
|
throw new NoPermissionError({
|
|
29
57
|
userId: context.userId,
|
package/lib/api-index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './loaders/index.ts';
|
|
|
8
8
|
export * from './errors.ts';
|
|
9
9
|
export * as acl from './acl.ts';
|
|
10
10
|
export * as roles from './roles/index.ts';
|
|
11
|
+
export type { OIDCProviderConfig, AuthConfig } from './auth.ts';
|
|
11
12
|
export { createContextResolver, getCurrentContextResolver, setCurrentContextResolver };
|
|
12
13
|
export type UnchainedServerOptions = {
|
|
13
14
|
roles?: any;
|
package/lib/auth.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface AccessTokenPayload {
|
|
2
|
+
iss: string;
|
|
3
|
+
sub: string;
|
|
4
|
+
ver: number;
|
|
5
|
+
fgp?: string;
|
|
6
|
+
imp?: string;
|
|
7
|
+
jti?: string;
|
|
8
|
+
iat?: number;
|
|
9
|
+
exp?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface OIDCProviderConfig {
|
|
12
|
+
issuer: string;
|
|
13
|
+
jwksUri?: string;
|
|
14
|
+
audience?: string | string[];
|
|
15
|
+
}
|
|
16
|
+
export interface AuthConfig {
|
|
17
|
+
oidcProviders?: OIDCProviderConfig[];
|
|
18
|
+
}
|
|
19
|
+
export declare function generateFingerprint(): {
|
|
20
|
+
raw: string;
|
|
21
|
+
hash: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function verifyFingerprint(raw: string, hash: string): boolean;
|
|
24
|
+
export declare function signAccessToken(userId: string, tokenVersion: number, options?: {
|
|
25
|
+
impersonatorId?: string;
|
|
26
|
+
fingerprintHash?: string;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
token: string;
|
|
29
|
+
expires: Date;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function verifyLocalToken(token: string): Promise<AccessTokenPayload | null>;
|
|
32
|
+
export declare function verifyOIDCToken(token: string, providers: OIDCProviderConfig[]): Promise<{
|
|
33
|
+
userId: string;
|
|
34
|
+
roles?: string[];
|
|
35
|
+
} | null>;
|
|
36
|
+
export interface AuthHandlerResult {
|
|
37
|
+
userId?: string;
|
|
38
|
+
tokenVersion?: number;
|
|
39
|
+
impersonatorId?: string;
|
|
40
|
+
fingerprintHash?: string;
|
|
41
|
+
accessToken?: string;
|
|
42
|
+
isApiKey?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function createAuthHandler(config?: AuthConfig): (token: string) => Promise<AuthHandlerResult>;
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createLogger } from '@unchainedshop/logger';
|
|
2
|
+
import * as jose from 'jose';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
const logger = createLogger('unchained:api:auth');
|
|
5
|
+
const { UNCHAINED_TOKEN_SECRET, UNCHAINED_TOKEN_EXPIRY_SECONDS = '3600', UNCHAINED_TOKEN_ISSUER = 'unchained-engine', } = process.env;
|
|
6
|
+
const MIN_SECRET_LENGTH = 32;
|
|
7
|
+
const jwksCache = new Map();
|
|
8
|
+
function validateSecretStrength(secret) {
|
|
9
|
+
if (secret.length < MIN_SECRET_LENGTH) {
|
|
10
|
+
throw new Error(`UNCHAINED_TOKEN_SECRET must be at least ${MIN_SECRET_LENGTH} characters (256 bits) for security. ` +
|
|
11
|
+
`Current length: ${secret.length}. Generate a secure secret with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function generateFingerprint() {
|
|
15
|
+
const raw = crypto.randomUUID() + crypto.randomUUID();
|
|
16
|
+
const hash = createHash('sha256').update(raw).digest('hex');
|
|
17
|
+
return { raw, hash };
|
|
18
|
+
}
|
|
19
|
+
export function verifyFingerprint(raw, hash) {
|
|
20
|
+
const computedHash = createHash('sha256').update(raw).digest('hex');
|
|
21
|
+
if (computedHash.length !== hash.length)
|
|
22
|
+
return false;
|
|
23
|
+
let result = 0;
|
|
24
|
+
for (let i = 0; i < computedHash.length; i++) {
|
|
25
|
+
result |= computedHash.charCodeAt(i) ^ hash.charCodeAt(i);
|
|
26
|
+
}
|
|
27
|
+
return result === 0;
|
|
28
|
+
}
|
|
29
|
+
export async function signAccessToken(userId, tokenVersion, options) {
|
|
30
|
+
if (!UNCHAINED_TOKEN_SECRET) {
|
|
31
|
+
throw new Error('UNCHAINED_TOKEN_SECRET environment variable is required');
|
|
32
|
+
}
|
|
33
|
+
validateSecretStrength(UNCHAINED_TOKEN_SECRET);
|
|
34
|
+
const expirySeconds = parseInt(UNCHAINED_TOKEN_EXPIRY_SECONDS, 10);
|
|
35
|
+
const now = Math.floor(Date.now() / 1000);
|
|
36
|
+
const payload = {
|
|
37
|
+
sub: userId,
|
|
38
|
+
ver: tokenVersion,
|
|
39
|
+
jti: crypto.randomUUID(),
|
|
40
|
+
};
|
|
41
|
+
if (options?.fingerprintHash) {
|
|
42
|
+
payload.fgp = options.fingerprintHash;
|
|
43
|
+
}
|
|
44
|
+
if (options?.impersonatorId) {
|
|
45
|
+
payload.imp = options.impersonatorId;
|
|
46
|
+
}
|
|
47
|
+
const secret = new TextEncoder().encode(UNCHAINED_TOKEN_SECRET);
|
|
48
|
+
const token = await new jose.SignJWT(payload)
|
|
49
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
50
|
+
.setIssuedAt(now)
|
|
51
|
+
.setIssuer(UNCHAINED_TOKEN_ISSUER)
|
|
52
|
+
.setExpirationTime(now + expirySeconds)
|
|
53
|
+
.sign(secret);
|
|
54
|
+
const expires = new Date((now + expirySeconds) * 1000);
|
|
55
|
+
return { token, expires };
|
|
56
|
+
}
|
|
57
|
+
export async function verifyLocalToken(token) {
|
|
58
|
+
if (!UNCHAINED_TOKEN_SECRET) {
|
|
59
|
+
logger.warn('UNCHAINED_TOKEN_SECRET not set, cannot verify local tokens');
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
validateSecretStrength(UNCHAINED_TOKEN_SECRET);
|
|
64
|
+
const secret = new TextEncoder().encode(UNCHAINED_TOKEN_SECRET);
|
|
65
|
+
const { payload } = await jose.jwtVerify(token, secret, {
|
|
66
|
+
algorithms: ['HS256'],
|
|
67
|
+
issuer: UNCHAINED_TOKEN_ISSUER,
|
|
68
|
+
});
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof jose.errors.JWTExpired) {
|
|
73
|
+
logger.debug('Token expired');
|
|
74
|
+
}
|
|
75
|
+
else if (error instanceof jose.errors.JWTInvalid ||
|
|
76
|
+
error instanceof jose.errors.JWSInvalid ||
|
|
77
|
+
error instanceof jose.errors.JWSSignatureVerificationFailed ||
|
|
78
|
+
error instanceof jose.errors.JWTClaimValidationFailed) {
|
|
79
|
+
logger.debug('Invalid token signature or claims');
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
logger.error('Token verification error:', {
|
|
83
|
+
message: error.message,
|
|
84
|
+
name: error.name,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function getJWKS(jwksUri) {
|
|
91
|
+
let jwks = jwksCache.get(jwksUri);
|
|
92
|
+
if (!jwks) {
|
|
93
|
+
jwks = jose.createRemoteJWKSet(new URL(jwksUri), {
|
|
94
|
+
cooldownDuration: 30000,
|
|
95
|
+
cacheMaxAge: 600000,
|
|
96
|
+
});
|
|
97
|
+
jwksCache.set(jwksUri, jwks);
|
|
98
|
+
}
|
|
99
|
+
return jwks;
|
|
100
|
+
}
|
|
101
|
+
export async function verifyOIDCToken(token, providers) {
|
|
102
|
+
let decodedPayload;
|
|
103
|
+
try {
|
|
104
|
+
const parts = token.split('.');
|
|
105
|
+
if (parts.length !== 3) {
|
|
106
|
+
logger.debug('Invalid JWT format');
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
decodedPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
logger.debug('Failed to decode JWT');
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const { iss, sub } = decodedPayload;
|
|
116
|
+
if (!iss || !sub || typeof iss !== 'string' || typeof sub !== 'string') {
|
|
117
|
+
logger.debug('OIDC token missing issuer or subject');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const provider = providers.find((p) => p.issuer === iss);
|
|
121
|
+
if (!provider) {
|
|
122
|
+
logger.debug('No matching OIDC provider for issuer:', { iss });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const jwksUri = provider.jwksUri || `${provider.issuer}/.well-known/jwks.json`;
|
|
126
|
+
try {
|
|
127
|
+
const JWKS = getJWKS(jwksUri);
|
|
128
|
+
const verifyOptions = {
|
|
129
|
+
issuer: provider.issuer,
|
|
130
|
+
};
|
|
131
|
+
if (provider.audience) {
|
|
132
|
+
verifyOptions.audience = provider.audience;
|
|
133
|
+
}
|
|
134
|
+
const { payload } = await jose.jwtVerify(token, JWKS, verifyOptions);
|
|
135
|
+
logger.debug('OIDC token verified successfully', { issuer: iss, subject: sub });
|
|
136
|
+
return {
|
|
137
|
+
userId: payload.sub,
|
|
138
|
+
roles: payload.roles,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof jose.errors.JWTExpired) {
|
|
143
|
+
logger.debug('OIDC token expired');
|
|
144
|
+
}
|
|
145
|
+
else if (error instanceof jose.errors.JWTClaimValidationFailed) {
|
|
146
|
+
logger.debug('OIDC token claim validation failed:', { message: error.message });
|
|
147
|
+
}
|
|
148
|
+
else if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
|
|
149
|
+
logger.debug('OIDC token signature verification failed');
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
logger.error('OIDC token verification failed:', {
|
|
153
|
+
message: error.message,
|
|
154
|
+
name: error.name,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function createAuthHandler(config) {
|
|
161
|
+
return async function verifyToken(token) {
|
|
162
|
+
if (!token) {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
const localPayload = await verifyLocalToken(token);
|
|
166
|
+
if (localPayload) {
|
|
167
|
+
return {
|
|
168
|
+
userId: localPayload.sub,
|
|
169
|
+
tokenVersion: localPayload.ver,
|
|
170
|
+
impersonatorId: localPayload.imp,
|
|
171
|
+
fingerprintHash: localPayload.fgp,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (config?.oidcProviders?.length) {
|
|
175
|
+
const oidcResult = await verifyOIDCToken(token, config.oidcProviders);
|
|
176
|
+
if (oidcResult) {
|
|
177
|
+
return {
|
|
178
|
+
userId: oidcResult.userId,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
accessToken: token,
|
|
184
|
+
isApiKey: true,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
}
|
package/lib/context.d.ts
CHANGED
package/lib/context.js
CHANGED
|
@@ -6,7 +6,7 @@ export const getCurrentContextResolver = () => context;
|
|
|
6
6
|
export const setCurrentContextResolver = (newContext) => {
|
|
7
7
|
context = newContext;
|
|
8
8
|
};
|
|
9
|
-
export const createContextResolver = (unchainedAPI, unchainedConfig) => async ({ getHeader, setHeader, remoteAddress, remotePort, userId, impersonatorId, accessToken, login, logout, }) => {
|
|
9
|
+
export const createContextResolver = (unchainedAPI, unchainedConfig) => async ({ getHeader, setHeader, remoteAddress, remotePort, userId, impersonatorId, accessToken, tokenVersion, login, logout, }) => {
|
|
10
10
|
const abstractHttpServerContext = { remoteAddress, remotePort, getHeader, setHeader };
|
|
11
11
|
const loaders = instantiateLoaders(unchainedAPI);
|
|
12
12
|
const localeContext = await getLocaleContext(abstractHttpServerContext, unchainedAPI);
|
|
@@ -21,8 +21,13 @@ export const createContextResolver = (unchainedAPI, unchainedConfig) => async ({
|
|
|
21
21
|
if (userId && !userContext.userId) {
|
|
22
22
|
const user = await unchainedAPI.modules.users.findUserById(userId);
|
|
23
23
|
if (user) {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const userTokenVersion = user.tokenVersion ?? 1;
|
|
25
|
+
if (tokenVersion !== undefined && tokenVersion !== userTokenVersion) {
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
userContext.user = user;
|
|
29
|
+
userContext.userId = user._id;
|
|
30
|
+
}
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
return {
|
package/lib/events.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const API_EVENTS: {
|
|
2
2
|
readonly API_LOGIN_TOKEN_CREATED: "API_LOGIN_TOKEN_CREATED";
|
|
3
3
|
readonly API_LOGOUT: "API_LOGOUT";
|
|
4
|
+
readonly ACL_DENIED: "ACL_DENIED";
|
|
5
|
+
readonly ACL_GRANTED_SENSITIVE: "ACL_GRANTED_SENSITIVE";
|
|
4
6
|
};
|
|
5
7
|
export type API_EVENTS = (typeof API_EVENTS)[keyof typeof API_EVENTS];
|
package/lib/events.js
CHANGED
package/lib/express/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import e from 'express';
|
|
2
2
|
import type { YogaServerInstance } from 'graphql-yoga';
|
|
3
|
-
import { mongodb } from '@unchainedshop/mongodb';
|
|
4
3
|
import type { UnchainedCore } from '@unchainedshop/core';
|
|
4
|
+
import type { AuthConfig } from '../auth.ts';
|
|
5
5
|
import type { ChatConfiguration } from '../chat/utils.ts';
|
|
6
6
|
import { connectChat } from './chatHandler.ts';
|
|
7
7
|
export interface AdminUIRouterOptions {
|
|
@@ -9,17 +9,14 @@ export interface AdminUIRouterOptions {
|
|
|
9
9
|
enabled?: boolean;
|
|
10
10
|
}
|
|
11
11
|
export declare const adminUIRouter: (enabled?: boolean) => import("express-serve-static-core").Router;
|
|
12
|
-
export declare const connect: (expressApp: e.Express, { graphqlHandler,
|
|
12
|
+
export declare const connect: (expressApp: e.Express, { graphqlHandler, unchainedAPI, }: {
|
|
13
13
|
graphqlHandler: YogaServerInstance<any, any>;
|
|
14
|
-
db: mongodb.Db;
|
|
15
14
|
unchainedAPI: UnchainedCore;
|
|
16
|
-
}, { allowRemoteToLocalhostSecureCookies, adminUI, chat,
|
|
15
|
+
}, { allowRemoteToLocalhostSecureCookies, adminUI, chat, authConfig, trustProxy, }?: {
|
|
17
16
|
allowRemoteToLocalhostSecureCookies?: boolean;
|
|
18
17
|
adminUI?: boolean | Omit<AdminUIRouterOptions, "enabled">;
|
|
19
18
|
chat?: ChatConfiguration;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}) => void;
|
|
24
|
-
export declare const expressRouter: (enabled?: boolean) => import("express-serve-static-core").Router;
|
|
19
|
+
authConfig?: AuthConfig;
|
|
20
|
+
trustProxy?: boolean;
|
|
21
|
+
}) => Promise<void>;
|
|
25
22
|
export { connectChat };
|
package/lib/express/index.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import e from 'express';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import MongoStore from "../mongo-store.js";
|
|
5
|
-
import { Passport } from 'passport';
|
|
6
|
-
import { mongodb } from '@unchainedshop/mongodb';
|
|
7
|
-
import { emit } from '@unchainedshop/events';
|
|
2
|
+
import cookieParser from 'cookie-parser';
|
|
3
|
+
import { pluginRegistry } from '@unchainedshop/core';
|
|
8
4
|
import { getCurrentContextResolver } from "../context.js";
|
|
9
|
-
import
|
|
10
|
-
import createERCMetadataMiddleware from "./createERCMetadataMiddleware.js";
|
|
11
|
-
import createTempUploadMiddleware from "./createTempUploadMiddleware.js";
|
|
5
|
+
import { createAuthContext } from "../middleware/createAuthMiddleware.js";
|
|
12
6
|
import createMCPMiddleware from "./createMCPMiddleware.js";
|
|
13
|
-
import { API_EVENTS } from "../events.js";
|
|
14
7
|
import { connectChat } from "./chatHandler.js";
|
|
8
|
+
import { mountRoutes } from "./mountRoutes.js";
|
|
9
|
+
import { createBackchannelLogoutRoute } from "../handlers/createBackchannelLogoutHandler.js";
|
|
15
10
|
export const adminUIRouter = (enabled = true) => {
|
|
16
11
|
const router = e.Router();
|
|
17
12
|
const staticURL = import.meta.resolve('@unchainedshop/admin-ui');
|
|
@@ -24,87 +19,53 @@ export const adminUIRouter = (enabled = true) => {
|
|
|
24
19
|
}
|
|
25
20
|
return router;
|
|
26
21
|
};
|
|
27
|
-
const resolveUserRemoteAddress = (req) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
req.
|
|
22
|
+
const resolveUserRemoteAddress = (req, trustProxy = false) => {
|
|
23
|
+
let remoteAddress;
|
|
24
|
+
if (trustProxy) {
|
|
25
|
+
const forwardedFor = req.headers['x-forwarded-for'];
|
|
26
|
+
const forwardedIps = forwardedFor?.split(',').map((ip) => ip.trim());
|
|
27
|
+
remoteAddress =
|
|
28
|
+
req.headers['x-real-ip'] ||
|
|
29
|
+
forwardedIps?.[forwardedIps.length - 1] ||
|
|
30
|
+
req.socket?.remoteAddress;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
remoteAddress = req.socket?.remoteAddress;
|
|
34
|
+
}
|
|
31
35
|
const remotePort = req.socket?.remotePort;
|
|
32
36
|
return { remoteAddress, remotePort };
|
|
33
37
|
};
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const { BULK_IMPORT_API_PATH = '/bulk-import', ERC_METADATA_API_PATH = '/erc-metadata', TEMP_UPLOAD_API_PATH = '/temp-upload', MCP_API_PATH = '/mcp', GRAPHQL_API_PATH = '/graphql', UNCHAINED_COOKIE_NAME = 'unchained_token', UNCHAINED_COOKIE_PATH = '/', UNCHAINED_COOKIE_DOMAIN, UNCHAINED_COOKIE_SAMESITE = 'none', UNCHAINED_COOKIE_INSECURE, } = process.env;
|
|
37
|
-
const addContext = async function middlewareWithContext(req, res, next) {
|
|
38
|
+
const { MCP_API_PATH = '/mcp' } = process.env;
|
|
39
|
+
const createAddContextMiddleware = (authConfig, trustProxy = false) => async function middlewareWithContext(req, res, next) {
|
|
38
40
|
try {
|
|
39
41
|
const setHeader = (key, value) => res.setHeader(key, value);
|
|
40
42
|
const getHeader = (key) => req.headers[key];
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
});
|
|
54
|
-
const tokenObject = {
|
|
55
|
-
_id: req.sessionID,
|
|
56
|
-
userId: user._id,
|
|
57
|
-
tokenExpires: new Date(req.session?.cookie._expires),
|
|
58
|
-
};
|
|
59
|
-
await emit(API_EVENTS.API_LOGIN_TOKEN_CREATED, tokenObject);
|
|
60
|
-
user._inLoginMethodResponse = true;
|
|
61
|
-
return { user, ...tokenObject };
|
|
62
|
-
};
|
|
63
|
-
const logout = async (sessionId) => {
|
|
64
|
-
const { user } = req;
|
|
65
|
-
if (!user)
|
|
66
|
-
return false;
|
|
67
|
-
const currentSessionId = req.sessionID;
|
|
68
|
-
const targetSessionId = sessionId || currentSessionId;
|
|
69
|
-
const tokenObject = {
|
|
70
|
-
_id: targetSessionId,
|
|
71
|
-
userId: user._id,
|
|
72
|
-
};
|
|
73
|
-
if (sessionId && sessionId !== currentSessionId) {
|
|
74
|
-
await new Promise((resolve, reject) => {
|
|
75
|
-
req.sessionStore.destroy(sessionId, (error) => {
|
|
76
|
-
if (error) {
|
|
77
|
-
return reject(error);
|
|
78
|
-
}
|
|
79
|
-
return resolve();
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
await new Promise((resolve, reject) => {
|
|
85
|
-
req.logout((error, result) => {
|
|
86
|
-
if (error) {
|
|
87
|
-
return reject(error);
|
|
88
|
-
}
|
|
89
|
-
req.session.impersonatorId = null;
|
|
90
|
-
return resolve(result);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
await emit(API_EVENTS.API_LOGOUT, tokenObject);
|
|
95
|
-
return true;
|
|
43
|
+
const getCookie = (name) => req.cookies?.[name];
|
|
44
|
+
const setCookie = (name, value, options) => res.cookie(name, value, options);
|
|
45
|
+
const clearCookie = (name, options) => res.clearCookie(name, { ...options, maxAge: 0 });
|
|
46
|
+
const { remoteAddress, remotePort } = resolveUserRemoteAddress(req, trustProxy);
|
|
47
|
+
const authContextParams = {
|
|
48
|
+
setHeader,
|
|
49
|
+
getHeader,
|
|
50
|
+
getCookie,
|
|
51
|
+
setCookie,
|
|
52
|
+
clearCookie,
|
|
53
|
+
remoteAddress,
|
|
54
|
+
remotePort,
|
|
96
55
|
};
|
|
97
|
-
const
|
|
56
|
+
const authContext = await createAuthContext(authContextParams, authConfig);
|
|
57
|
+
const context = getCurrentContextResolver();
|
|
98
58
|
req.unchainedContext = await context({
|
|
99
59
|
setHeader,
|
|
100
60
|
getHeader,
|
|
101
61
|
remoteAddress,
|
|
102
62
|
remotePort,
|
|
103
|
-
login,
|
|
104
|
-
logout,
|
|
105
|
-
accessToken,
|
|
106
|
-
userId:
|
|
107
|
-
impersonatorId:
|
|
63
|
+
login: authContext.login,
|
|
64
|
+
logout: authContext.logout,
|
|
65
|
+
accessToken: authContext.accessToken,
|
|
66
|
+
userId: authContext.userId,
|
|
67
|
+
impersonatorId: authContext.impersonatorId,
|
|
68
|
+
tokenVersion: authContext.tokenVersion,
|
|
108
69
|
}, req, res);
|
|
109
70
|
next();
|
|
110
71
|
}
|
|
@@ -112,8 +73,12 @@ const addContext = async function middlewareWithContext(req, res, next) {
|
|
|
112
73
|
next(error);
|
|
113
74
|
}
|
|
114
75
|
};
|
|
115
|
-
export const connect = (expressApp, { graphqlHandler,
|
|
76
|
+
export const connect = async (expressApp, { graphqlHandler, unchainedAPI, }, { allowRemoteToLocalhostSecureCookies = false, adminUI = false, chat, authConfig, trustProxy = false, } = {}) => {
|
|
116
77
|
if (allowRemoteToLocalhostSecureCookies) {
|
|
78
|
+
if (process.env.NODE_ENV === 'production') {
|
|
79
|
+
throw new Error('allowRemoteToLocalhostSecureCookies is not allowed in production. ' +
|
|
80
|
+
'Configure a proper CORS policy with specific allowed origins instead.');
|
|
81
|
+
}
|
|
117
82
|
expressApp.set('trust proxy', 1);
|
|
118
83
|
expressApp.use((req, res, next) => {
|
|
119
84
|
req.headers['x-forwarded-proto'] = 'https';
|
|
@@ -125,59 +90,21 @@ export const connect = (expressApp, { graphqlHandler, db, unchainedAPI, }, { all
|
|
|
125
90
|
next();
|
|
126
91
|
});
|
|
127
92
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
passport.deserializeUser(function deserialize(_id, done) {
|
|
133
|
-
done(null, { _id });
|
|
134
|
-
});
|
|
135
|
-
const name = UNCHAINED_COOKIE_NAME;
|
|
136
|
-
const domain = UNCHAINED_COOKIE_DOMAIN;
|
|
137
|
-
const path = UNCHAINED_COOKIE_PATH;
|
|
138
|
-
const secure = UNCHAINED_COOKIE_INSECURE ? false : true;
|
|
139
|
-
const sameSite = ({
|
|
140
|
-
none: 'none',
|
|
141
|
-
lax: 'lax',
|
|
142
|
-
strict: 'strict',
|
|
143
|
-
'1': true,
|
|
144
|
-
'0': false,
|
|
145
|
-
}[UNCHAINED_COOKIE_SAMESITE?.trim()?.toLowerCase()] || false);
|
|
146
|
-
expressApp.use(session({
|
|
147
|
-
secret: process.env.UNCHAINED_TOKEN_SECRET,
|
|
148
|
-
store: MongoStore.create({
|
|
149
|
-
client: db.client,
|
|
150
|
-
dbName: db.databaseName,
|
|
151
|
-
collectionName: 'sessions',
|
|
152
|
-
touchAfter: 24 * 3600,
|
|
153
|
-
}),
|
|
154
|
-
name,
|
|
155
|
-
saveUninitialized: false,
|
|
156
|
-
resave: false,
|
|
157
|
-
cookie: {
|
|
158
|
-
domain,
|
|
159
|
-
path,
|
|
160
|
-
sameSite,
|
|
161
|
-
secure,
|
|
162
|
-
httpOnly: true,
|
|
163
|
-
maxAge: 1000 * 60 * 60 * 24 * 7,
|
|
164
|
-
},
|
|
165
|
-
}), passport.initialize(), passport.session(), addContext);
|
|
166
|
-
expressApp.use(GRAPHQL_API_PATH, graphqlHandler.handle);
|
|
167
|
-
expressApp.use(ERC_METADATA_API_PATH, createERCMetadataMiddleware);
|
|
168
|
-
expressApp.use(BULK_IMPORT_API_PATH, createBulkImportMiddleware);
|
|
169
|
-
expressApp.use(TEMP_UPLOAD_API_PATH, upload.any(), createTempUploadMiddleware);
|
|
93
|
+
expressApp.use(cookieParser());
|
|
94
|
+
expressApp.use(createAddContextMiddleware(authConfig, trustProxy || allowRemoteToLocalhostSecureCookies));
|
|
95
|
+
expressApp.use(graphqlHandler.graphqlEndpoint, graphqlHandler.handle);
|
|
170
96
|
expressApp.use(MCP_API_PATH, e.json({ limit: '10mb' }));
|
|
171
97
|
expressApp.use(MCP_API_PATH, createMCPMiddleware);
|
|
172
98
|
if (chat) {
|
|
173
99
|
connectChat(expressApp, chat);
|
|
174
100
|
}
|
|
175
|
-
|
|
176
|
-
|
|
101
|
+
const routes = pluginRegistry.getRoutes();
|
|
102
|
+
if (authConfig?.oidcProviders?.length) {
|
|
103
|
+
routes.push(createBackchannelLogoutRoute(authConfig.oidcProviders));
|
|
177
104
|
}
|
|
105
|
+
mountRoutes(expressApp, unchainedAPI, routes);
|
|
178
106
|
if (adminUI) {
|
|
179
107
|
expressApp.use(typeof adminUI === 'object' ? adminUI.prefix : '/', adminUIRouter(true));
|
|
180
108
|
}
|
|
181
109
|
};
|
|
182
|
-
export const expressRouter = adminUIRouter;
|
|
183
110
|
export { connectChat };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { pluginRegistry } from '@unchainedshop/core';
|
|
2
|
+
import { createServerAdapter } from '@whatwg-node/server';
|
|
3
|
+
import { createLogger } from '@unchainedshop/logger';
|
|
4
|
+
const logger = createLogger('express');
|
|
5
|
+
export function mountPluginRoutes(app, unchainedAPI) {
|
|
6
|
+
const routes = pluginRegistry.getRoutes();
|
|
7
|
+
if (routes.length > 0) {
|
|
8
|
+
const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
|
|
9
|
+
logger.info(`Mounting ${routes.length} plugin route(s): ${endpoints}`);
|
|
10
|
+
}
|
|
11
|
+
for (const route of routes) {
|
|
12
|
+
const adapter = createServerAdapter(async (request, serverContext) => {
|
|
13
|
+
const context = {
|
|
14
|
+
...unchainedAPI,
|
|
15
|
+
...serverContext.unchainedContext,
|
|
16
|
+
params: serverContext.params || {},
|
|
17
|
+
rawRequest: serverContext.rawRequest,
|
|
18
|
+
};
|
|
19
|
+
try {
|
|
20
|
+
return await route.handler(request, context);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
logger.error(`Error in plugin route handler ${route.method} ${route.path}`, {
|
|
24
|
+
error: error instanceof Error ? error.message : String(error),
|
|
25
|
+
});
|
|
26
|
+
return new Response(JSON.stringify({
|
|
27
|
+
error: error instanceof Error ? error.message : 'Internal Server Error',
|
|
28
|
+
}), {
|
|
29
|
+
status: 500,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const method = route.method.toLowerCase();
|
|
35
|
+
const expressHandler = async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
await adapter.handleNodeRequestAndResponse(req, res, {
|
|
38
|
+
unchainedContext: req.unchainedContext,
|
|
39
|
+
params: req.params,
|
|
40
|
+
rawRequest: req,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
logger.error(`Error handling request for ${route.method} ${route.path}`, {
|
|
45
|
+
error: error instanceof Error ? error.message : String(error),
|
|
46
|
+
});
|
|
47
|
+
if (!res.headersSent) {
|
|
48
|
+
res.status(500).json({ error: 'Internal Server Error' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if (method === 'all') {
|
|
53
|
+
app.use(route.path, expressHandler);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
app[method](route.path, expressHandler);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|