@unchainedshop/api 4.6.1 → 4.6.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/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,186 @@
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.JWSSignatureVerificationFailed ||
77
+ error instanceof jose.errors.JWTClaimValidationFailed) {
78
+ logger.debug('Invalid token signature or claims');
79
+ }
80
+ else {
81
+ logger.error('Token verification error:', {
82
+ message: error.message,
83
+ name: error.name,
84
+ });
85
+ }
86
+ return null;
87
+ }
88
+ }
89
+ function getJWKS(jwksUri) {
90
+ let jwks = jwksCache.get(jwksUri);
91
+ if (!jwks) {
92
+ jwks = jose.createRemoteJWKSet(new URL(jwksUri), {
93
+ cooldownDuration: 30000,
94
+ cacheMaxAge: 600000,
95
+ });
96
+ jwksCache.set(jwksUri, jwks);
97
+ }
98
+ return jwks;
99
+ }
100
+ export async function verifyOIDCToken(token, providers) {
101
+ let decodedPayload;
102
+ try {
103
+ const parts = token.split('.');
104
+ if (parts.length !== 3) {
105
+ logger.debug('Invalid JWT format');
106
+ return null;
107
+ }
108
+ decodedPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
109
+ }
110
+ catch {
111
+ logger.debug('Failed to decode JWT');
112
+ return null;
113
+ }
114
+ const { iss, sub } = decodedPayload;
115
+ if (!iss || !sub || typeof iss !== 'string' || typeof sub !== 'string') {
116
+ logger.debug('OIDC token missing issuer or subject');
117
+ return null;
118
+ }
119
+ const provider = providers.find((p) => p.issuer === iss);
120
+ if (!provider) {
121
+ logger.debug('No matching OIDC provider for issuer:', { iss });
122
+ return null;
123
+ }
124
+ const jwksUri = provider.jwksUri || `${provider.issuer}/.well-known/jwks.json`;
125
+ try {
126
+ const JWKS = getJWKS(jwksUri);
127
+ const verifyOptions = {
128
+ issuer: provider.issuer,
129
+ };
130
+ if (provider.audience) {
131
+ verifyOptions.audience = provider.audience;
132
+ }
133
+ const { payload } = await jose.jwtVerify(token, JWKS, verifyOptions);
134
+ logger.debug('OIDC token verified successfully', { issuer: iss, subject: sub });
135
+ return {
136
+ userId: payload.sub,
137
+ roles: payload.roles,
138
+ };
139
+ }
140
+ catch (error) {
141
+ if (error instanceof jose.errors.JWTExpired) {
142
+ logger.debug('OIDC token expired');
143
+ }
144
+ else if (error instanceof jose.errors.JWTClaimValidationFailed) {
145
+ logger.debug('OIDC token claim validation failed:', { message: error.message });
146
+ }
147
+ else if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
148
+ logger.debug('OIDC token signature verification failed');
149
+ }
150
+ else {
151
+ logger.error('OIDC token verification failed:', {
152
+ message: error.message,
153
+ name: error.name,
154
+ });
155
+ }
156
+ return null;
157
+ }
158
+ }
159
+ export function createAuthHandler(config) {
160
+ return async function verifyToken(token) {
161
+ if (!token) {
162
+ return {};
163
+ }
164
+ const localPayload = await verifyLocalToken(token);
165
+ if (localPayload) {
166
+ return {
167
+ userId: localPayload.sub,
168
+ tokenVersion: localPayload.ver,
169
+ impersonatorId: localPayload.imp,
170
+ fingerprintHash: localPayload.fgp,
171
+ };
172
+ }
173
+ if (config?.oidcProviders?.length) {
174
+ const oidcResult = await verifyOIDCToken(token, config.oidcProviders);
175
+ if (oidcResult) {
176
+ return {
177
+ userId: oidcResult.userId,
178
+ };
179
+ }
180
+ }
181
+ return {
182
+ accessToken: token,
183
+ isApiKey: true,
184
+ };
185
+ };
186
+ }
@@ -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;
@@ -0,0 +1,57 @@
1
+ import { createServerAdapter } from '@whatwg-node/server';
2
+ import { createLogger } from '@unchainedshop/logger';
3
+ const logger = createLogger('express');
4
+ export function mountRoutes(app, unchainedAPI, routes) {
5
+ if (routes.length === 0)
6
+ return;
7
+ const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
8
+ logger.info(`Mounting ${routes.length} route(s): ${endpoints}`);
9
+ for (const route of routes) {
10
+ const adapter = createServerAdapter(async (request, serverContext) => {
11
+ const context = {
12
+ ...unchainedAPI,
13
+ ...serverContext.unchainedContext,
14
+ params: serverContext.params || {},
15
+ rawRequest: serverContext.rawRequest,
16
+ };
17
+ try {
18
+ return await route.handler(request, context);
19
+ }
20
+ catch (error) {
21
+ logger.error(`Error in route handler ${route.method} ${route.path}`, {
22
+ error: error instanceof Error ? error.message : String(error),
23
+ });
24
+ return new Response(JSON.stringify({
25
+ error: error instanceof Error ? error.message : 'Internal Server Error',
26
+ }), {
27
+ status: 500,
28
+ headers: { 'Content-Type': 'application/json' },
29
+ });
30
+ }
31
+ });
32
+ const method = route.method.toLowerCase();
33
+ const expressHandler = async (req, res) => {
34
+ try {
35
+ await adapter.handleNodeRequestAndResponse(req, res, {
36
+ unchainedContext: req.unchainedContext,
37
+ params: req.params,
38
+ rawRequest: req,
39
+ });
40
+ }
41
+ catch (error) {
42
+ logger.error(`Error handling request for ${route.method} ${route.path}`, {
43
+ error: error instanceof Error ? error.message : String(error),
44
+ });
45
+ if (!res.headersSent) {
46
+ res.status(500).json({ error: 'Internal Server Error' });
47
+ }
48
+ }
49
+ };
50
+ if (method === 'all') {
51
+ app.use(route.path, expressHandler);
52
+ }
53
+ else {
54
+ app[method](route.path, expressHandler);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { UnchainedCore } from '@unchainedshop/core';
3
+ export declare function mountPluginRoutes(fastify: FastifyInstance, unchainedAPI: UnchainedCore): void;
@@ -0,0 +1,62 @@
1
+ import { pluginRegistry } from '@unchainedshop/core';
2
+ import { createServerAdapter } from '@whatwg-node/server';
3
+ export function mountPluginRoutes(fastify, unchainedAPI) {
4
+ const routes = pluginRegistry.getRoutes();
5
+ if (routes.length === 0)
6
+ return;
7
+ const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
8
+ fastify.log.info(`Mounting ${routes.length} plugin route(s): ${endpoints}`);
9
+ fastify.register((scope, opts, registered) => {
10
+ scope.removeAllContentTypeParsers();
11
+ scope.addContentTypeParser('*', function (request, payload, done) {
12
+ done(null);
13
+ });
14
+ for (const route of routes) {
15
+ const adapter = createServerAdapter(async (request, serverContext) => {
16
+ const context = {
17
+ ...unchainedAPI,
18
+ ...serverContext.unchainedContext,
19
+ params: serverContext.params || {},
20
+ rawRequest: serverContext.rawRequest,
21
+ };
22
+ try {
23
+ return await route.handler(request, context);
24
+ }
25
+ catch (error) {
26
+ fastify.log.error(`Error in plugin route handler ${route.method} ${route.path}: ${error instanceof Error ? error.message : String(error)}`);
27
+ return new Response(JSON.stringify({
28
+ error: error instanceof Error ? error.message : 'Internal Server Error',
29
+ }), {
30
+ status: 500,
31
+ headers: { 'Content-Type': 'application/json' },
32
+ });
33
+ }
34
+ });
35
+ let methods;
36
+ if (route.method === 'ALL') {
37
+ methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
38
+ }
39
+ else {
40
+ methods = [route.method];
41
+ }
42
+ scope.route({
43
+ url: route.path,
44
+ method: methods,
45
+ handler: async (req, reply) => {
46
+ const response = await adapter.handleNodeRequestAndResponse(req.raw, reply.raw, {
47
+ unchainedContext: req.unchainedContext,
48
+ params: req.params,
49
+ rawRequest: req.raw,
50
+ });
51
+ response.headers.forEach((value, key) => {
52
+ reply.header(key, value);
53
+ });
54
+ reply.status(response.status);
55
+ reply.send(response.body || undefined);
56
+ return reply;
57
+ },
58
+ });
59
+ }
60
+ registered();
61
+ });
62
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { UnchainedCore, PluginHttpRoute } from '@unchainedshop/core';
3
+ export declare function mountRoutes(fastify: FastifyInstance, unchainedAPI: UnchainedCore, routes: PluginHttpRoute[]): void;
@@ -0,0 +1,60 @@
1
+ import { createServerAdapter } from '@whatwg-node/server';
2
+ export function mountRoutes(fastify, unchainedAPI, routes) {
3
+ if (routes.length === 0)
4
+ return;
5
+ const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
6
+ fastify.log.info(`Mounting ${routes.length} route(s): ${endpoints}`);
7
+ fastify.register((scope, opts, registered) => {
8
+ scope.removeAllContentTypeParsers();
9
+ scope.addContentTypeParser('*', function (request, payload, done) {
10
+ done(null);
11
+ });
12
+ for (const route of routes) {
13
+ const adapter = createServerAdapter(async (request, serverContext) => {
14
+ const context = {
15
+ ...unchainedAPI,
16
+ ...serverContext.unchainedContext,
17
+ params: serverContext.params || {},
18
+ rawRequest: serverContext.rawRequest,
19
+ };
20
+ try {
21
+ return await route.handler(request, context);
22
+ }
23
+ catch (error) {
24
+ fastify.log.error(`Error in route handler ${route.method} ${route.path}: ${error instanceof Error ? error.message : String(error)}`);
25
+ return new Response(JSON.stringify({
26
+ error: error instanceof Error ? error.message : 'Internal Server Error',
27
+ }), {
28
+ status: 500,
29
+ headers: { 'Content-Type': 'application/json' },
30
+ });
31
+ }
32
+ });
33
+ let methods;
34
+ if (route.method === 'ALL') {
35
+ methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
36
+ }
37
+ else {
38
+ methods = [route.method];
39
+ }
40
+ scope.route({
41
+ url: route.path,
42
+ method: methods,
43
+ handler: async (req, reply) => {
44
+ const response = await adapter.handleNodeRequestAndResponse(req.raw, reply.raw, {
45
+ unchainedContext: req.unchainedContext,
46
+ params: req.params,
47
+ rawRequest: req.raw,
48
+ });
49
+ response.headers.forEach((value, key) => {
50
+ reply.header(key, value);
51
+ });
52
+ reply.status(response.status);
53
+ reply.send(response.body || undefined);
54
+ return reply;
55
+ },
56
+ });
57
+ }
58
+ registered();
59
+ });
60
+ }
@@ -0,0 +1,4 @@
1
+ import type { PluginHttpRoute } from '@unchainedshop/core';
2
+ import type { OIDCProviderConfig } from '../auth.ts';
3
+ export declare function createBackchannelLogoutRoute(providers: OIDCProviderConfig[]): PluginHttpRoute;
4
+ export default createBackchannelLogoutRoute;
@@ -0,0 +1,170 @@
1
+ import * as jose from 'jose';
2
+ import { createLogger } from '@unchainedshop/logger';
3
+ const logger = createLogger('unchained:api:backchannel-logout');
4
+ function normalizeIssuer(url) {
5
+ try {
6
+ const parsed = new URL(url);
7
+ return parsed.origin + parsed.pathname.replace(/\/$/, '');
8
+ }
9
+ catch {
10
+ return url;
11
+ }
12
+ }
13
+ const jwksCache = new Map();
14
+ function getJWKS(jwksUri) {
15
+ let jwks = jwksCache.get(jwksUri);
16
+ if (!jwks) {
17
+ jwks = jose.createRemoteJWKSet(new URL(jwksUri), {
18
+ cooldownDuration: 30000,
19
+ cacheMaxAge: 600000,
20
+ });
21
+ jwksCache.set(jwksUri, jwks);
22
+ }
23
+ return jwks;
24
+ }
25
+ export function createBackchannelLogoutRoute(providers) {
26
+ return {
27
+ path: '/backchannel-logout',
28
+ method: 'ALL',
29
+ handler: async (request, context) => {
30
+ if (request.method !== 'POST') {
31
+ return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
32
+ status: 405,
33
+ headers: { 'Content-Type': 'application/json' },
34
+ });
35
+ }
36
+ try {
37
+ let logoutToken = null;
38
+ const contentType = request.headers.get('content-type') || '';
39
+ if (contentType.includes('application/x-www-form-urlencoded')) {
40
+ const formData = await request.formData();
41
+ logoutToken = formData.get('logout_token');
42
+ }
43
+ else if (contentType.includes('application/json')) {
44
+ const body = (await request.json());
45
+ logoutToken = body.logout_token || null;
46
+ }
47
+ if (!logoutToken) {
48
+ logger.warn('Back-channel logout request missing logout_token');
49
+ return new Response(JSON.stringify({ error: 'missing_logout_token' }), {
50
+ status: 400,
51
+ headers: { 'Content-Type': 'application/json' },
52
+ });
53
+ }
54
+ let decodedPayload;
55
+ try {
56
+ const parts = logoutToken.split('.');
57
+ if (parts.length !== 3) {
58
+ logger.warn('Invalid logout token format: not a valid JWT');
59
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
60
+ status: 400,
61
+ headers: { 'Content-Type': 'application/json' },
62
+ });
63
+ }
64
+ decodedPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
65
+ }
66
+ catch {
67
+ logger.warn('Failed to decode logout token');
68
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
69
+ status: 400,
70
+ headers: { 'Content-Type': 'application/json' },
71
+ });
72
+ }
73
+ const { iss } = decodedPayload;
74
+ if (!iss || typeof iss !== 'string') {
75
+ logger.warn('Logout token missing issuer (iss)');
76
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
77
+ status: 400,
78
+ headers: { 'Content-Type': 'application/json' },
79
+ });
80
+ }
81
+ const normalizedIss = normalizeIssuer(iss);
82
+ const provider = providers.find((p) => normalizeIssuer(p.issuer) === normalizedIss);
83
+ if (!provider) {
84
+ logger.warn('Unknown issuer in logout token:', { iss });
85
+ return new Response(JSON.stringify({ error: 'unknown_issuer' }), {
86
+ status: 400,
87
+ headers: { 'Content-Type': 'application/json' },
88
+ });
89
+ }
90
+ const jwksUri = provider.jwksUri || `${provider.issuer}/.well-known/jwks.json`;
91
+ let verifiedPayload;
92
+ try {
93
+ const JWKS = getJWKS(jwksUri);
94
+ const verifyOptions = {
95
+ issuer: provider.issuer,
96
+ };
97
+ if (provider.audience) {
98
+ verifyOptions.audience = provider.audience;
99
+ }
100
+ const { payload } = await jose.jwtVerify(logoutToken, JWKS, verifyOptions);
101
+ verifiedPayload = payload;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof jose.errors.JWTExpired) {
105
+ logger.warn('Logout token expired');
106
+ return new Response(JSON.stringify({ error: 'token_expired' }), {
107
+ status: 400,
108
+ headers: { 'Content-Type': 'application/json' },
109
+ });
110
+ }
111
+ else if (error instanceof jose.errors.JWTClaimValidationFailed) {
112
+ logger.warn('Logout token claim validation failed:', { message: error.message });
113
+ return new Response(JSON.stringify({ error: 'invalid_claims' }), {
114
+ status: 400,
115
+ headers: { 'Content-Type': 'application/json' },
116
+ });
117
+ }
118
+ else if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
119
+ logger.warn('Logout token signature verification failed - possible forgery attempt');
120
+ return new Response(JSON.stringify({ error: 'invalid_signature' }), {
121
+ status: 400,
122
+ headers: { 'Content-Type': 'application/json' },
123
+ });
124
+ }
125
+ else {
126
+ logger.error('Logout token verification failed:', { error });
127
+ return new Response(JSON.stringify({ error: 'verification_failed' }), {
128
+ status: 400,
129
+ headers: { 'Content-Type': 'application/json' },
130
+ });
131
+ }
132
+ }
133
+ const { sub, events } = verifiedPayload;
134
+ if (!events?.['http://schemas.openid.net/event/backchannel-logout']) {
135
+ logger.warn('Token is not a back-channel logout token (missing events claim)');
136
+ return new Response(JSON.stringify({ error: 'invalid_token_type' }), {
137
+ status: 400,
138
+ headers: { 'Content-Type': 'application/json' },
139
+ });
140
+ }
141
+ if (!sub) {
142
+ logger.warn('Logout token missing subject (sub)');
143
+ return new Response(JSON.stringify({ error: 'missing_subject' }), {
144
+ status: 400,
145
+ headers: { 'Content-Type': 'application/json' },
146
+ });
147
+ }
148
+ const user = await context.modules.users.findUserById(sub);
149
+ if (!user) {
150
+ logger.info('User not found for back-channel logout:', { sub });
151
+ return new Response('', { status: 200 });
152
+ }
153
+ await context.modules.users.updateOidcLogoutAt(user._id, new Date());
154
+ logger.info('Back-channel logout processed successfully:', {
155
+ userId: user._id,
156
+ issuer: iss,
157
+ });
158
+ return new Response('', { status: 200 });
159
+ }
160
+ catch (error) {
161
+ logger.error('Back-channel logout error:', { error });
162
+ return new Response(JSON.stringify({ error: 'internal_error' }), {
163
+ status: 500,
164
+ headers: { 'Content-Type': 'application/json' },
165
+ });
166
+ }
167
+ },
168
+ };
169
+ }
170
+ export default createBackchannelLogoutRoute;
@@ -0,0 +1,29 @@
1
+ import { type AuthConfig } from '../auth.ts';
2
+ import type { LoginFn, LogoutFn } from '../context.ts';
3
+ export interface AuthContextParams {
4
+ getHeader: (key: string) => string | undefined;
5
+ setHeader: (key: string, value: string) => void;
6
+ getCookie: (name: string) => string | undefined;
7
+ setCookie: (name: string, value: string, options: CookieOptions) => void;
8
+ clearCookie: (name: string, options: CookieOptions) => void;
9
+ remoteAddress?: string;
10
+ remotePort?: number;
11
+ }
12
+ export interface CookieOptions {
13
+ domain?: string;
14
+ path?: string;
15
+ secure?: boolean;
16
+ httpOnly?: boolean;
17
+ sameSite?: 'strict' | 'lax' | 'none' | boolean;
18
+ maxAge?: number;
19
+ expires?: Date;
20
+ }
21
+ export interface AuthContext {
22
+ userId?: string;
23
+ tokenVersion?: number;
24
+ impersonatorId?: string;
25
+ accessToken?: string;
26
+ login: LoginFn;
27
+ logout: LogoutFn;
28
+ }
29
+ export declare function createAuthContext(params: AuthContextParams, authConfig?: AuthConfig): Promise<AuthContext>;
@@ -0,0 +1,128 @@
1
+ import { emit } from '@unchainedshop/events';
2
+ import { signAccessToken, createAuthHandler, generateFingerprint, verifyFingerprint, } from "../auth.js";
3
+ import { API_EVENTS } from "../events.js";
4
+ import { createLogger } from '@unchainedshop/logger';
5
+ const logger = createLogger('unchained:api:auth-middleware');
6
+ const { UNCHAINED_COOKIE_NAME = 'unchained_token', UNCHAINED_COOKIE_PATH = '/', UNCHAINED_COOKIE_DOMAIN, UNCHAINED_COOKIE_SAMESITE = 'lax', UNCHAINED_COOKIE_INSECURE, UNCHAINED_FINGERPRINT_COOKIE_NAME = '__Secure-fgp', UNCHAINED_TOKEN_EXPIRY_SECONDS = '3600', } = process.env;
7
+ function getTokenCookieOptions(expires) {
8
+ const secure = !UNCHAINED_COOKIE_INSECURE;
9
+ const sameSite = {
10
+ none: 'none',
11
+ lax: 'lax',
12
+ strict: 'strict',
13
+ '1': true,
14
+ '0': false,
15
+ }[UNCHAINED_COOKIE_SAMESITE?.trim()?.toLowerCase()] || 'lax';
16
+ if (!secure && process.env.NODE_ENV === 'production') {
17
+ logger.warn('SECURITY WARNING: Running with UNCHAINED_COOKIE_INSECURE in production is not recommended');
18
+ }
19
+ if (sameSite === 'none' && !secure) {
20
+ logger.warn('SECURITY WARNING: SameSite=None requires Secure flag to be effective');
21
+ }
22
+ const expirySeconds = parseInt(UNCHAINED_TOKEN_EXPIRY_SECONDS, 10);
23
+ return {
24
+ domain: UNCHAINED_COOKIE_DOMAIN,
25
+ path: UNCHAINED_COOKIE_PATH,
26
+ secure,
27
+ httpOnly: true,
28
+ sameSite: sameSite,
29
+ maxAge: expires ? undefined : expirySeconds * 1000,
30
+ expires,
31
+ };
32
+ }
33
+ function getFingerprintCookieOptions(expires) {
34
+ const secure = !UNCHAINED_COOKIE_INSECURE;
35
+ const expirySeconds = parseInt(UNCHAINED_TOKEN_EXPIRY_SECONDS, 10);
36
+ return {
37
+ domain: UNCHAINED_COOKIE_DOMAIN,
38
+ path: UNCHAINED_COOKIE_PATH,
39
+ secure,
40
+ httpOnly: true,
41
+ sameSite: 'strict',
42
+ maxAge: expires ? undefined : expirySeconds * 1000,
43
+ expires,
44
+ };
45
+ }
46
+ function extractBearerToken(authHeader) {
47
+ if (!authHeader)
48
+ return undefined;
49
+ const parts = authHeader.split(' ');
50
+ if (parts.length !== 2)
51
+ return undefined;
52
+ const [scheme, token] = parts;
53
+ if (scheme.toLowerCase() !== 'bearer') {
54
+ logger.debug('Authorization header present but not Bearer scheme');
55
+ return undefined;
56
+ }
57
+ return token;
58
+ }
59
+ export async function createAuthContext(params, authConfig) {
60
+ const { getHeader, getCookie, setCookie, clearCookie } = params;
61
+ const authHeader = getHeader('authorization');
62
+ const headerToken = extractBearerToken(authHeader);
63
+ const cookieToken = getCookie(UNCHAINED_COOKIE_NAME);
64
+ const token = headerToken || cookieToken;
65
+ const fingerprintCookie = getCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME);
66
+ const verifyToken = createAuthHandler(authConfig);
67
+ const authResult = token ? await verifyToken(token) : {};
68
+ let fingerprintValid = true;
69
+ if (authResult.fingerprintHash && fingerprintCookie) {
70
+ fingerprintValid = verifyFingerprint(fingerprintCookie, authResult.fingerprintHash);
71
+ if (!fingerprintValid) {
72
+ logger.warn('Token sidejacking detected: fingerprint mismatch', {
73
+ userId: authResult.userId,
74
+ });
75
+ authResult.userId = undefined;
76
+ authResult.tokenVersion = undefined;
77
+ authResult.impersonatorId = undefined;
78
+ }
79
+ }
80
+ else if (authResult.fingerprintHash && !fingerprintCookie) {
81
+ logger.warn('Token sidejacking detected: fingerprint cookie missing', {
82
+ userId: authResult.userId,
83
+ });
84
+ fingerprintValid = false;
85
+ authResult.userId = undefined;
86
+ authResult.tokenVersion = undefined;
87
+ authResult.impersonatorId = undefined;
88
+ }
89
+ const login = async (user, options = {}) => {
90
+ const { impersonator } = options;
91
+ const tokenVersion = user.tokenVersion ?? 1;
92
+ const { raw: fingerprintRaw, hash: fingerprintHash } = generateFingerprint();
93
+ const { token: newToken, expires } = await signAccessToken(user._id, tokenVersion, {
94
+ impersonatorId: impersonator?._id,
95
+ fingerprintHash,
96
+ });
97
+ setCookie(UNCHAINED_COOKIE_NAME, newToken, getTokenCookieOptions(expires));
98
+ setCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME, fingerprintRaw, getFingerprintCookieOptions(expires));
99
+ const tokenObject = {
100
+ _id: crypto.randomUUID(),
101
+ userId: user._id,
102
+ tokenExpires: expires,
103
+ };
104
+ await emit(API_EVENTS.API_LOGIN_TOKEN_CREATED, tokenObject);
105
+ user._inLoginMethodResponse = true;
106
+ return { user, ...tokenObject };
107
+ };
108
+ const logout = async () => {
109
+ clearCookie(UNCHAINED_COOKIE_NAME, getTokenCookieOptions());
110
+ clearCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME, getFingerprintCookieOptions());
111
+ if (authResult.userId) {
112
+ const tokenObject = {
113
+ _id: crypto.randomUUID(),
114
+ userId: authResult.userId,
115
+ };
116
+ await emit(API_EVENTS.API_LOGOUT, tokenObject);
117
+ }
118
+ return true;
119
+ };
120
+ return {
121
+ userId: authResult.userId,
122
+ tokenVersion: authResult.tokenVersion,
123
+ impersonatorId: authResult.impersonatorId,
124
+ accessToken: authResult.isApiKey ? authResult.accessToken : undefined,
125
+ login,
126
+ logout,
127
+ };
128
+ }
@@ -0,0 +1,4 @@
1
+ import type { Context } from '../../../context.ts';
2
+ export default function logoutAllSessions(root: never, _: never, context: Context): Promise<{
3
+ success: boolean;
4
+ }>;
@@ -0,0 +1,11 @@
1
+ import { log } from '@unchainedshop/logger';
2
+ export default async function logoutAllSessions(root, _, context) {
3
+ const { userId, modules } = context;
4
+ log('mutation logoutAllSessions', { userId });
5
+ const result = await modules.users.incrementTokenVersion(userId);
6
+ if (!result) {
7
+ throw new Error('Failed to logout all sessions', { cause: 'LOGOUT_FAILED' });
8
+ }
9
+ await context.logout();
10
+ return { success: true };
11
+ }
@@ -2,8 +2,6 @@ import { log } from '@unchainedshop/logger';
2
2
  export default async function orders(root, params, { modules, userId }) {
3
3
  const { limit, offset, paymentProviderIds, deliveryProviderIds, ...restParams } = params;
4
4
  log(`query orders: ${limit} ${offset} ${restParams?.queryString || ''}`, { userId });
5
- const promises = [];
6
- await Promise.all(promises);
7
5
  const [orderPayments, orderDeliveries] = await Promise.all([
8
6
  paymentProviderIds?.length
9
7
  ? modules.orders.payments.findOrderPaymentsByProviderIds({ paymentProviderIds })
@@ -19,6 +17,8 @@ export default async function orders(root, params, { modules, userId }) {
19
17
  const deliveryIds = orderDeliveries.map((d) => d._id);
20
18
  return modules.orders.findOrders({
21
19
  ...restParams,
20
+ limit,
21
+ offset,
22
22
  paymentIds,
23
23
  deliveryIds,
24
24
  });
@@ -0,0 +1,7 @@
1
+ import type { OrderPayment } from '@unchainedshop/core-orders';
2
+ import type { Context } from '../../../context.ts';
3
+ export declare const OrderPaymentBase: {
4
+ status(obj: OrderPayment, _: never, { modules }: Context): import("@unchainedshop/core-orders").OrderPaymentStatus;
5
+ provider(obj: OrderPayment, _: never, { loaders }: Context): Promise<import("@unchainedshop/core-payment").PaymentProvider>;
6
+ discounts(obj: OrderPayment, _: never, { loaders, services }: Context): Promise<import("@unchainedshop/core/lib/services/getPaymentDiscounts.js").PaymentDiscountPrice[]>;
7
+ };
@@ -0,0 +1,14 @@
1
+ export const OrderPaymentBase = {
2
+ status(obj, _, { modules }) {
3
+ return modules.orders.payments.normalizedStatus(obj);
4
+ },
5
+ async provider(obj, _, { loaders }) {
6
+ return loaders.paymentProviderLoader.load({
7
+ paymentProviderId: obj.paymentProviderId,
8
+ });
9
+ },
10
+ async discounts(obj, _, { loaders, services }) {
11
+ const order = await loaders.orderLoader.load({ orderId: obj.orderId });
12
+ return services.payment.getPaymentDiscounts(obj, order.currencyCode);
13
+ },
14
+ };
@@ -0,0 +1,2 @@
1
+ declare const _default: string[];
2
+ export default _default;
@@ -0,0 +1,12 @@
1
+ export default [
2
+ `
3
+ type SuccessResponse @cacheControl(maxAge: 0, scope: PRIVATE) {
4
+ success: Boolean
5
+ }
6
+
7
+ enum SortDirection {
8
+ ASC
9
+ DESC
10
+ }
11
+ `,
12
+ ];
@@ -0,0 +1 @@
1
+ export declare function mapServiceError(error: unknown): never;
@@ -0,0 +1,59 @@
1
+ import { GraphQLError } from 'graphql';
2
+ import * as errors from "../errors.js";
3
+ const errorMap = {
4
+ ProductNotFoundError: errors.ProductNotFoundError,
5
+ ProductWrongStatusError: errors.ProductWrongStatusError,
6
+ ProductWrongTypeError: errors.ProductWrongTypeError,
7
+ CyclicProductBundlingNotSupportedError: errors.CyclicProductBundlingNotSupportedError,
8
+ ProductVariationInfinityLoop: errors.ProductVariationInfinityLoop,
9
+ ProductVariationVectorInvalid: errors.ProductVariationVectorInvalid,
10
+ ProductVariationVectorAlreadySet: errors.ProductVariationVectorAlreadySet,
11
+ ProductLinkedToActiveBundleError: errors.ProductLinkedToActiveBundleError,
12
+ ProductLinkedToActiveVariationError: errors.ProductLinkedToActiveVariationError,
13
+ ProductLinkedToQuotationError: errors.ProductLinkedToQuotationError,
14
+ ProductLinkedToEnrollmentError: errors.ProductLinkedToEnrollmentError,
15
+ EnrollmentNotFoundError: errors.EnrollmentNotFoundError,
16
+ EnrollmentWrongStatusError: errors.EnrollmentWrongStatusError,
17
+ EnrollmentPlanUpdateNotAllowedError: errors.EnrollmentWrongStatusError,
18
+ OrderNotFoundError: errors.OrderNotFoundError,
19
+ OrderWrongStatusError: errors.OrderWrongStatusError,
20
+ OrderItemNotFoundError: errors.OrderItemNotFoundError,
21
+ OrderQuantityTooLowError: errors.OrderQuantityTooLowError,
22
+ OrderPaymentNotFoundError: errors.OrderPaymentNotFoundError,
23
+ OrderWrongPaymentStatusError: errors.OrderWrongPaymentStatusError,
24
+ OrderDeliveryNotFoundError: errors.OrderDeliveryNotFoundError,
25
+ OrderWrongDeliveryStatusError: errors.OrderWrongDeliveryStatusError,
26
+ QuotationNotFoundError: errors.QuotationNotFoundError,
27
+ QuotationWrongStatusError: errors.QuotationWrongStatusError,
28
+ QuotationItemConfigurationError: errors.QuotationItemConfigurationError,
29
+ BookmarkNotFoundError: errors.BookmarkNotFoundError,
30
+ MultipleBookmarksFound: errors.MultipleBookmarksFound,
31
+ BookmarkAlreadyExistsError: errors.BookmarkAlreadyExistsError,
32
+ OrderDiscountCodeAlreadyPresentError: errors.OrderDiscountCodeAlreadyPresentError,
33
+ OrderDiscountCodeNotValidError: errors.OrderDiscountCodeNotValidError,
34
+ UserNotFoundError: errors.UserNotFoundError,
35
+ ImpersonatingAdminUserError: errors.ImpersonatingAdminUserError,
36
+ InvalidEmailVerificationTokenError: errors.InvalidEmailVerificationTokenError,
37
+ UsernameOrEmailRequiredError: errors.UsernameOrEmailRequiredError,
38
+ PasswordOrWebAuthnPublicKeyRequiredError: errors.PasswordOrWebAuthnPublicKeyRequiredError,
39
+ EmailAlreadyExistsError: errors.EmailAlreadyExistsError,
40
+ UsernameAlreadyExistsError: errors.UsernameAlreadyExistsError,
41
+ PasswordInvalidError: errors.PasswordInvalidError,
42
+ WebAuthnVerificationFailedError: errors.WebAuthnVerificationFailedError,
43
+ AuthOperationFailedError: errors.AuthOperationFailedError,
44
+ InvalidCredentialsError: errors.InvalidCredentialsError,
45
+ InvalidResetTokenError: errors.InvalidResetTokenError,
46
+ InvalidIdError: errors.InvalidIdError,
47
+ ProviderConfigurationInvalid: errors.ProviderConfigurationInvalid,
48
+ };
49
+ export function mapServiceError(error) {
50
+ if (error instanceof GraphQLError) {
51
+ throw error;
52
+ }
53
+ const serviceError = error;
54
+ const ErrorClass = serviceError.code ? errorMap[serviceError.code] : undefined;
55
+ if (ErrorClass) {
56
+ throw new ErrorClass(serviceError.data);
57
+ }
58
+ throw error;
59
+ }
@@ -0,0 +1 @@
1
+ export declare function createMaskError(isDev: boolean): (error: unknown, message: string) => Error;
@@ -0,0 +1,22 @@
1
+ import { GraphQLError } from 'graphql';
2
+ import { maskError as yogaMaskError } from 'graphql-yoga';
3
+ function isKnownError(error) {
4
+ return error instanceof Error && 'code' in error && typeof error.code === 'string';
5
+ }
6
+ export function createMaskError(isDev) {
7
+ return function maskError(error, message) {
8
+ const originalError = error instanceof GraphQLError ? error.originalError || error : error;
9
+ if (error instanceof GraphQLError && error.extensions?.code) {
10
+ return error;
11
+ }
12
+ if (isKnownError(originalError)) {
13
+ return new GraphQLError(originalError.message, {
14
+ extensions: {
15
+ code: originalError.code,
16
+ ...(originalError.data || {}),
17
+ },
18
+ });
19
+ }
20
+ return yogaMaskError(error, message, isDev);
21
+ };
22
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unchainedshop/api",
3
3
  "description": "GraphQL API layer for the Unchained Engine with Express/Fastify adapters and MCP server",
4
- "version": "4.6.1",
4
+ "version": "4.6.2",
5
5
  "main": "lib/api-index.js",
6
6
  "types": "lib/api-index.d.ts",
7
7
  "type": "module",