@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.
Files changed (75) hide show
  1. package/lib/acl.js +29 -1
  2. package/lib/api-index.d.ts +1 -0
  3. package/lib/auth.d.ts +44 -0
  4. package/lib/auth.js +187 -0
  5. package/lib/context.d.ts +1 -0
  6. package/lib/context.js +8 -3
  7. package/lib/events.d.ts +2 -0
  8. package/lib/events.js +2 -0
  9. package/lib/express/index.d.ts +6 -9
  10. package/lib/express/index.js +52 -125
  11. package/lib/express/mountPluginRoutes.d.ts +3 -0
  12. package/lib/express/mountPluginRoutes.js +59 -0
  13. package/lib/express/mountRoutes.d.ts +3 -0
  14. package/lib/express/mountRoutes.js +57 -0
  15. package/lib/fastify/index.d.ts +6 -9
  16. package/lib/fastify/index.js +51 -113
  17. package/lib/fastify/mountPluginRoutes.d.ts +3 -0
  18. package/lib/fastify/mountPluginRoutes.js +62 -0
  19. package/lib/fastify/mountRoutes.d.ts +3 -0
  20. package/lib/fastify/mountRoutes.js +60 -0
  21. package/lib/handlers/createBackchannelLogoutHandler.d.ts +4 -0
  22. package/lib/handlers/createBackchannelLogoutHandler.js +170 -0
  23. package/lib/mcp/tools/localization/getLocalizationsConfig.d.ts +1 -1
  24. package/lib/mcp/tools/localization/schemas.d.ts +34 -0
  25. package/lib/mcp/tools/order/handlers/getTopCustomers.d.ts +2 -0
  26. package/lib/mcp/tools/quotation/handlers/getQuotation.d.ts +2 -0
  27. package/lib/mcp/tools/quotation/handlers/listQuotations.d.ts +2 -0
  28. package/lib/mcp/tools/quotation/handlers/makeQuotationProposal.d.ts +2 -0
  29. package/lib/mcp/tools/quotation/handlers/rejectQuotation.d.ts +2 -0
  30. package/lib/mcp/tools/quotation/handlers/verifyQuotation.d.ts +2 -0
  31. package/lib/mcp/tools/system/handlers/countEvents.d.ts +8 -1
  32. package/lib/mcp/tools/system/handlers/countWork.d.ts +10 -1
  33. package/lib/mcp/tools/system/handlers/countWork.js +1 -0
  34. package/lib/mcp/tools/system/handlers/index.d.ts +4 -4
  35. package/lib/mcp/tools/system/handlers/listEvents.d.ts +12 -1
  36. package/lib/mcp/tools/system/handlers/listWork.d.ts +14 -1
  37. package/lib/mcp/tools/system/handlers/listWork.js +1 -0
  38. package/lib/mcp/tools/users/handlers/addUserEmail.d.ts +2 -0
  39. package/lib/mcp/tools/users/handlers/createUser.d.ts +2 -0
  40. package/lib/mcp/tools/users/handlers/enrollUser.d.ts +2 -0
  41. package/lib/mcp/tools/users/handlers/getCurrentUser.d.ts +2 -0
  42. package/lib/mcp/tools/users/handlers/getUser.d.ts +2 -0
  43. package/lib/mcp/tools/users/handlers/listUsers.d.ts +2 -0
  44. package/lib/mcp/tools/users/handlers/setUserTags.d.ts +2 -0
  45. package/lib/mcp/tools/users/handlers/setUserUsername.d.ts +2 -0
  46. package/lib/mcp/tools/users/handlers/updateUser.d.ts +2 -0
  47. package/lib/mcp/utils/getNormalizedQuotationDetails.d.ts +2 -0
  48. package/lib/mcp/utils/getNormalizedUserDetails.d.ts +2 -0
  49. package/lib/mcp/utils/normalizeMediaUrl.js +1 -1
  50. package/lib/mcp/utils/sanitizeLocalizationEntityData.d.ts +1 -1
  51. package/lib/mcp/utils/validateIsoCode.d.ts +1 -1
  52. package/lib/middleware/createAuthMiddleware.d.ts +29 -0
  53. package/lib/middleware/createAuthMiddleware.js +128 -0
  54. package/lib/resolvers/mutations/accounts/logoutAllSessions.d.ts +4 -0
  55. package/lib/resolvers/mutations/accounts/logoutAllSessions.js +11 -0
  56. package/lib/resolvers/mutations/index.d.ts +1 -6
  57. package/lib/resolvers/mutations/index.js +2 -12
  58. package/lib/resolvers/mutations/orders/updateCart.js +7 -1
  59. package/lib/resolvers/type/index.d.ts +0 -1
  60. package/lib/resolvers/type/order/order-delivery-pickup-types.d.ts +0 -1
  61. package/lib/resolvers/type/order/order-delivery-pickup-types.js +0 -7
  62. package/lib/resolvers/type/order/order-payment-base.d.ts +7 -0
  63. package/lib/resolvers/type/order/order-payment-base.js +14 -0
  64. package/lib/roles/index.js +1 -0
  65. package/lib/roles/loggedIn.js +1 -0
  66. package/lib/schema/mutation.js +6 -44
  67. package/lib/schema/types/common.d.ts +2 -0
  68. package/lib/schema/types/common.js +12 -0
  69. package/lib/schema/types/index.js +2 -2
  70. package/lib/schema/types/order/delivery.js +0 -2
  71. package/lib/utils/mapServiceError.d.ts +1 -0
  72. package/lib/utils/mapServiceError.js +59 -0
  73. package/lib/utils/maskError.d.ts +1 -0
  74. package/lib/utils/maskError.js +22 -0
  75. 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,
@@ -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
@@ -16,6 +16,7 @@ export interface UnchainedUserContext {
16
16
  userId?: string;
17
17
  impersonatorId?: string;
18
18
  accessToken?: string;
19
+ tokenVersion?: number;
19
20
  user?: User;
20
21
  }
21
22
  export interface CustomAdminUiProperties {
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
- userContext.user = user;
25
- userContext.userId = user._id;
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
@@ -1,4 +1,6 @@
1
1
  export const API_EVENTS = {
2
2
  API_LOGIN_TOKEN_CREATED: 'API_LOGIN_TOKEN_CREATED',
3
3
  API_LOGOUT: 'API_LOGOUT',
4
+ ACL_DENIED: 'ACL_DENIED',
5
+ ACL_GRANTED_SENSITIVE: 'ACL_GRANTED_SENSITIVE',
4
6
  };
@@ -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, db, unchainedAPI, }: {
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, initPluginMiddlewares, }?: {
15
+ }, { allowRemoteToLocalhostSecureCookies, adminUI, chat, authConfig, trustProxy, }?: {
17
16
  allowRemoteToLocalhostSecureCookies?: boolean;
18
17
  adminUI?: boolean | Omit<AdminUIRouterOptions, "enabled">;
19
18
  chat?: ChatConfiguration;
20
- initPluginMiddlewares?: (app: e.Express, { unchainedAPI }: {
21
- unchainedAPI: UnchainedCore;
22
- }) => void;
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 };
@@ -1,17 +1,12 @@
1
1
  import e from 'express';
2
- import session from 'express-session';
3
- import multer from 'multer';
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 createBulkImportMiddleware from "./createBulkImportMiddleware.js";
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
- const remoteAddress = req.headers['x-real-ip'] ||
29
- req.headers['x-forwarded-for'] ||
30
- req.socket?.remoteAddress;
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 storage = multer.memoryStorage();
35
- const upload = multer({ storage: storage });
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 { remoteAddress, remotePort } = resolveUserRemoteAddress(req);
42
- const context = getCurrentContextResolver();
43
- const login = async (user, options = {}) => {
44
- const { impersonator } = options;
45
- await new Promise((resolve, reject) => {
46
- req.login(user, (error, result) => {
47
- if (error) {
48
- return reject(error);
49
- }
50
- req.session.impersonatorId = impersonator?._id;
51
- return resolve(result);
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 [, accessToken] = req.headers.authorization?.split(' ') || [];
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: req.user?._id,
107
- impersonatorId: req.session.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, db, unchainedAPI, }, { allowRemoteToLocalhostSecureCookies = false, adminUI = false, chat, initPluginMiddlewares, } = {}) => {
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
- const passport = new Passport();
129
- passport.serializeUser(function serialize(user, done) {
130
- done(null, user._id);
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
- if (initPluginMiddlewares) {
176
- initPluginMiddlewares(expressApp, { unchainedAPI });
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,3 @@
1
+ import type { Express } from 'express';
2
+ import type { UnchainedCore } from '@unchainedshop/core';
3
+ export declare function mountPluginRoutes(app: Express, unchainedAPI: UnchainedCore): void;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Express } from 'express';
2
+ import type { UnchainedCore, PluginHttpRoute } from '@unchainedshop/core';
3
+ export declare function mountRoutes(app: Express, unchainedAPI: UnchainedCore, routes: PluginHttpRoute[]): void;