feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
@@ -0,0 +1,12 @@
/**
* HPM_ERR_INVALID_MULTIPART prefixed error code will be used in
* [status-code.ts]({@link ../../status-code.ts}) to return status code 400.
*/
export declare const HPM_ERR_INVALID_MULTIPART = "HPM_ERR_INVALID_MULTIPART";
/**
* stringify FormData data
* @param contentType
* @param data
* @returns
*/
export declare function stringifyFormData(contentType: string, data: object): string;
@@ -0,0 +1,46 @@
import { HttpProxyMiddlewareError } from '../../errors.js';
const CR_OR_LF = /[\r\n]/;
/**
* HPM_ERR_INVALID_MULTIPART prefixed error code will be used in
* [status-code.ts]({@link ../../status-code.ts}) to return status code 400.
*/
export const HPM_ERR_INVALID_MULTIPART = 'HPM_ERR_INVALID_MULTIPART';
/**
* stringify FormData data
* @param contentType
* @param data
* @returns
*/
export function stringifyFormData(contentType, data) {
const boundary = getMultipartBoundary(contentType);
let str = '';
for (const [key, value] of Object.entries(data)) {
const normalizedKey = String(key);
const normalizedValue = String(value);
// Reject potentially dangerous sequences to prevent multipart header/body injection.
validateMultipartField(normalizedKey, normalizedValue, boundary);
str += `--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartFieldName(normalizedKey)}"\r\n\r\n${normalizedValue}\r\n`;
}
return str;
}
function getMultipartBoundary(contentType) {
const boundaryMatch = /(?:^|;)\s*boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
// Keep backward-compatible behavior when boundary is omitted: fall back to legacy extraction.
const boundary = (boundaryMatch?.[1] ?? boundaryMatch?.[2] ?? contentType).trim();
if (!boundary || CR_OR_LF.test(boundary)) {
throw new HttpProxyMiddlewareError('[HPM] invalid multipart boundary detected.', `${HPM_ERR_INVALID_MULTIPART}_BOUNDARY`);
}
return boundary;
}
function validateMultipartField(fieldName, fieldValue, boundary) {
const boundaryDelimiter = `--${boundary}`;
if (CR_OR_LF.test(fieldName)) {
throw new HttpProxyMiddlewareError(`[HPM] invalid multipart field name "${fieldName}" detected.`, `${HPM_ERR_INVALID_MULTIPART}_FIELD_NAME`);
}
if (CR_OR_LF.test(fieldValue) || fieldValue.includes(boundaryDelimiter)) {
throw new HttpProxyMiddlewareError(`[HPM] invalid multipart field value for "${fieldName}" detected.`, `${HPM_ERR_INVALID_MULTIPART}_FIELD_VALUE`);
}
}
function escapeMultipartFieldName(fieldName) {
return fieldName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
+22
View File
@@ -0,0 +1,22 @@
import type * as http from 'node:http';
export type BodyParserLikeRequest = http.IncomingMessage & {
body?: any;
};
/**
* Fix proxied body if bodyParser is involved.
*
* @example
* ```ts
* createProxyMiddleware({
* target: 'http://example.com',
* on: {
* proxyReq: fixRequestBody,
* }
* });
* ```
*
* Alternative solution without using `fixRequestBody()`: put `http-proxy-middleware` before `bodyParser` in the middleware stack.
*
* @see {@link https://github.com/chimurai/http-proxy-middleware/issues/40 Github issue #40 - POST request body is not proxied}
*/
export declare function fixRequestBody<TReq extends BodyParserLikeRequest = BodyParserLikeRequest>(proxyReq: http.ClientRequest, req: TReq): void;
+78
View File
@@ -0,0 +1,78 @@
import * as querystring from 'node:querystring';
import * as zlib from 'node:zlib';
import { stringifyFormData } from './fix-request-body-utils/stringify-form-data.js';
/**
* Fix proxied body if bodyParser is involved.
*
* @example
* ```ts
* createProxyMiddleware({
* target: 'http://example.com',
* on: {
* proxyReq: fixRequestBody,
* }
* });
* ```
*
* Alternative solution without using `fixRequestBody()`: put `http-proxy-middleware` before `bodyParser` in the middleware stack.
*
* @see {@link https://github.com/chimurai/http-proxy-middleware/issues/40 Github issue #40 - POST request body is not proxied}
*/
export function fixRequestBody(proxyReq, req) {
// skip fixRequestBody() when req.readableLength not 0 (bodyParser failure)
if (req.readableLength !== 0) {
return;
}
const requestBody = req.body;
if (!requestBody) {
return;
}
const contentType = proxyReq.getHeader('Content-Type');
if (!contentType) {
return;
}
const writeBody = (bodyData) => {
let proxyData = bodyData;
const contentEncoding = String(proxyReq.getHeader('Content-Encoding')).toLowerCase();
switch (contentEncoding) {
case 'br':
proxyData = zlib.brotliCompressSync(proxyData);
break;
case 'deflate':
proxyData = zlib.deflateSync(proxyData);
break;
case 'gzip':
proxyData = zlib.gzipSync(proxyData);
break;
case 'zstd':
proxyData = zlib.zstdCompressSync(proxyData);
break;
}
proxyReq.setHeader('Content-Length', Buffer.byteLength(proxyData));
proxyReq.write(proxyData);
};
try {
// Use if-elseif to prevent multiple writeBody/setHeader calls:
// Error: "Cannot set headers after they are sent to the client"
if (contentType.includes('application/json') || contentType.includes('+json')) {
writeBody(JSON.stringify(requestBody));
}
else if (contentType.includes('application/x-www-form-urlencoded')) {
writeBody(querystring.stringify(requestBody));
}
else if (contentType.includes('multipart/form-data')) {
writeBody(stringifyFormData(contentType, requestBody));
}
else if (contentType.includes('text/plain')) {
writeBody(requestBody);
}
}
catch (error) {
// proxyReq listeners run outside the middleware try/catch path; re-throwing here can bubble as
// an unhandled exception in consumers, so destroy() is used to fail closed through proxy error handling.
proxyReq.destroy(toError(error));
}
}
function toError(error) {
return error instanceof Error ? error : new Error(String(error));
}
+1
View File
@@ -0,0 +1 @@
export * from './public.js';
+1
View File
@@ -0,0 +1 @@
export * from './public.js';
+2
View File
@@ -0,0 +1,2 @@
export { responseInterceptor } from './response-interceptor.js';
export { fixRequestBody } from './fix-request-body.js';
+2
View File
@@ -0,0 +1,2 @@
export { responseInterceptor } from './response-interceptor.js';
export { fixRequestBody } from './fix-request-body.js';
@@ -0,0 +1,27 @@
import type * as http from 'node:http';
type Interceptor<TReq = http.IncomingMessage, TRes = http.ServerResponse> = (buffer: Buffer, proxyRes: http.IncomingMessage, req: TReq, res: TRes) => Promise<Buffer | string>;
/**
* Intercept responses from upstream.
* Automatically decompress (deflate, gzip, brotli, zstd).
* Give developer the opportunity to modify intercepted Buffer and http.ServerResponse
*
* NOTE: must set options.selfHandleResponse=true (prevent automatic call of res.end())
*
* @example
*
* ```ts
* createProxyMiddleware({
* target: 'http://example.com',
* selfHandleResponse: true, // MUST set selfHandleResponse=true
* on: {
* proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
* // modify intercepted buffer and return modified buffer
* const modifiedBuffer = Buffer.from(buffer.toString().replace(/Example/g, 'Demo'), 'utf8');
* return modifiedBuffer;
* }),
* }
* });
* ```
*/
export declare function responseInterceptor<TReq extends http.IncomingMessage = http.IncomingMessage, TRes extends http.ServerResponse = http.ServerResponse>(interceptor: Interceptor<TReq, TRes>): (proxyRes: http.IncomingMessage, req: TReq, res: TRes) => Promise<void>;
export {};
@@ -0,0 +1,145 @@
import * as zlib from 'node:zlib';
import { Debug } from '../debug.js';
import { getFunctionName } from '../utils/function.js';
const debug = Debug.extend('response-interceptor');
/**
* Intercept responses from upstream.
* Automatically decompress (deflate, gzip, brotli, zstd).
* Give developer the opportunity to modify intercepted Buffer and http.ServerResponse
*
* NOTE: must set options.selfHandleResponse=true (prevent automatic call of res.end())
*
* @example
*
* ```ts
* createProxyMiddleware({
* target: 'http://example.com',
* selfHandleResponse: true, // MUST set selfHandleResponse=true
* on: {
* proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
* // modify intercepted buffer and return modified buffer
* const modifiedBuffer = Buffer.from(buffer.toString().replace(/Example/g, 'Demo'), 'utf8');
* return modifiedBuffer;
* }),
* }
* });
* ```
*/
export function responseInterceptor(interceptor) {
return async function proxyResResponseInterceptor(proxyRes, req, res) {
debug('intercept proxy response');
const originalProxyRes = proxyRes;
const chunks = [];
let bufferLength = 0;
// Bodyless responses (HEAD, 1xx, 204, 304) must not be decompressed.
const contentEncoding = isBodylessResponse(proxyRes.statusCode, req.method)
? undefined
: proxyRes.headers['content-encoding'];
// decompress proxy response
const _proxyRes = decompress(proxyRes, contentEncoding);
// collect data chunks and concatenate once on end to avoid repeated full-buffer copies
_proxyRes.on('data', (chunk) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(chunkBuffer);
bufferLength += chunkBuffer.length; // precalculate Buffer length for slightly better performance on Buffer.concat()
});
_proxyRes.on('end', async () => {
const buffer = Buffer.concat(chunks, bufferLength);
chunks.length = 0; // clear chunks array
bufferLength = 0;
// copy original headers
copyHeaders(proxyRes, res);
// RFC 9110: HEAD and 1xx/204/304 responses do not include content.
// End the response after headers to avoid writing an invalid body.
if (isBodylessResponse(proxyRes.statusCode, req.method)) {
res.end();
return;
}
// call interceptor with intercepted response (buffer)
debug('call interceptor function: %s', getFunctionName(interceptor));
const interceptedBuffer = Buffer.from(await interceptor(buffer, originalProxyRes, req, res));
// set correct content-length (with double byte character support)
debug('set content-length: %s', Buffer.byteLength(interceptedBuffer));
// Buffered responses cannot preserve trailer framing.
// Remove trailer declaration (and transfer-encoding just in case) before setting content-length.
res.removeHeader('trailer');
res.removeHeader('transfer-encoding');
res.setHeader('content-length', Buffer.byteLength(interceptedBuffer));
debug('write intercepted response');
res.write(interceptedBuffer);
res.end();
});
_proxyRes.on('error', (error) => {
chunks.length = 0; // clear chunks array
bufferLength = 0;
res.end(`Error fetching proxied request: ${error.message}`);
});
};
}
function isBodylessResponse(statusCode, method) {
return (method?.toUpperCase() === 'HEAD' ||
(statusCode !== undefined &&
((statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304)));
}
/**
* Streaming decompression of proxy response
* source: https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L116
*/
function decompress(proxyRes, contentEncoding) {
let _proxyRes = proxyRes;
let decompress;
switch (contentEncoding) {
case 'gzip':
decompress = zlib.createGunzip();
break;
case 'br':
decompress = zlib.createBrotliDecompress();
break;
case 'deflate':
decompress = zlib.createInflate();
break;
case 'zstd':
decompress = zlib.createZstdDecompress();
break;
default:
break;
}
if (decompress) {
debug(`decompress proxy response with 'content-encoding': %s`, contentEncoding);
_proxyRes.pipe(decompress);
_proxyRes = decompress;
}
return _proxyRes;
}
/**
* Copy original headers
* https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L78
*/
function copyHeaders(originalResponse, response) {
debug('copy original response headers');
if (originalResponse.statusCode) {
response.statusCode = originalResponse.statusCode;
}
if (originalResponse.statusMessage) {
response.statusMessage = originalResponse.statusMessage;
}
if (response.setHeader) {
let keys = Object.keys(originalResponse.headers);
// ignore encoding/framing headers that are incompatible with buffered interception
keys = keys.filter((key) => !['content-encoding', 'transfer-encoding', 'trailer'].includes(key));
keys.forEach((key) => {
let value = originalResponse.headers[key];
if (key === 'set-cookie' && value) {
// remove cookie domain
value = Array.isArray(value) ? value : [value];
value = value.map((x) => x.replace(/Domain=[^;]+?/i, ''));
}
response.setHeader(key, value);
});
}
else {
if ('headers' in response) {
response.headers = originalResponse.headers;
}
}
}