@qelos/integrator-nuxt 4.0.0 → 4.1.0
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/README.md +112 -59
- package/dist/cookies.d.ts +19 -0
- package/dist/cookies.js +39 -0
- package/dist/cookies.js.map +1 -0
- package/dist/middleware.d.ts +4 -8
- package/dist/middleware.js +58 -54
- package/dist/middleware.js.map +1 -1
- package/dist/module.d.ts +6 -1
- package/dist/module.js +21 -1
- package/dist/module.js.map +1 -1
- package/dist/proxy-target.d.ts +17 -0
- package/dist/proxy-target.js +26 -0
- package/dist/proxy-target.js.map +1 -0
- package/dist/runtime/api-proxy.d.ts +2 -0
- package/dist/runtime/api-proxy.js +27 -0
- package/dist/runtime/api-proxy.js.map +1 -0
- package/dist/sdk-factory.d.ts +3 -10
- package/dist/sdk-factory.js +14 -70
- package/dist/sdk-factory.js.map +1 -1
- package/dist/types.d.ts +9 -29
- package/package.json +2 -2
- package/src/cookies.test.ts +112 -0
- package/src/cookies.ts +49 -0
- package/src/middleware.ts +65 -78
- package/src/module.ts +28 -5
- package/src/proxy-target.test.ts +110 -0
- package/src/proxy-target.ts +30 -0
- package/src/runtime/api-proxy.ts +36 -0
- package/src/sdk-factory.ts +17 -89
- package/src/types.ts +9 -33
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-proxy.js","sourceRoot":"","sources":["../../src/runtime/api-proxy.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,2EAA2E;AAC3E,uCAAuC;AACvC,iCAAiC;AACjC,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,gBAAgB,EAChB,YAAY,GACb,MAAM,IAAI,CAAC;AACZ,0DAA0D;AAC1D,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AAC9B,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAG1D,eAAe,kBAAkB,CAAC,CAAC,KAAK,EAAE,EAAE;IAC1C,MAAM,MAAM,GAAG,CAAC,gBAAgB,EAAE,CAAC,KAAK,IAAI,EAAE,CAA2B,CAAC;IAC1E,MAAM,IAAI,GAAG,uBAAuB,CAAC,MAAM,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,WAAW,CAAC;YAChB,UAAU,EAAE,GAAG;YACf,aAAa,EACX,qJAAqJ;SACxJ,CAAC,CAAC;IACL,CAAC;IAED,MAAM,YAAY,GAAG,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACrD,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC;IACpD,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAEnC,OAAO,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE;QACjC,KAAK,EAAE,UAAU,CAAC,KAAK;QACvB,mBAAmB,EAAE,EAAE,GAAG,EAAE,YAAY,IAAI,EAAE,EAAE;KACjD,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
package/dist/sdk-factory.d.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import QelosSDK from '@qelos/sdk';
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
2
|
+
import { type H3Event } from 'h3';
|
|
3
|
+
import type { QelosNuxtRuntimeConfig } from './types';
|
|
4
4
|
export interface CreateSdkParams {
|
|
5
5
|
config: QelosNuxtRuntimeConfig;
|
|
6
|
-
/**
|
|
7
|
-
* Tokens for the current request. The factory mutates this object in
|
|
8
|
-
* place when a token refresh occurs, so callers can read the latest pair
|
|
9
|
-
* after the SDK has been used.
|
|
10
|
-
*/
|
|
11
|
-
tokens: QelosTokenPair;
|
|
12
6
|
event: H3Event;
|
|
13
|
-
onTokenRefresh?: TokenRefreshHook;
|
|
14
7
|
}
|
|
15
|
-
export declare function createRequestSdk({ config,
|
|
8
|
+
export declare function createRequestSdk({ config, event }: CreateSdkParams): QelosSDK;
|
package/dist/sdk-factory.js
CHANGED
|
@@ -1,84 +1,28 @@
|
|
|
1
1
|
import QelosSDK from '@qelos/sdk';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
'/api/cookie/refresh',
|
|
5
|
-
'/api/signin',
|
|
6
|
-
'/api/signup',
|
|
7
|
-
]);
|
|
8
|
-
export function createRequestSdk({ config, tokens, event, onTokenRefresh }) {
|
|
9
|
-
let sdk;
|
|
10
|
-
let refreshInFlight = null;
|
|
2
|
+
import { getRequestHeader } from 'h3';
|
|
3
|
+
export function createRequestSdk({ config, event }) {
|
|
11
4
|
const baseOptions = config.sdkOptions || {};
|
|
12
|
-
async function performRefresh() {
|
|
13
|
-
if (!tokens.refreshToken && !tokens.accessToken) {
|
|
14
|
-
throw new Error('no refresh token available');
|
|
15
|
-
}
|
|
16
|
-
const previous = {
|
|
17
|
-
accessToken: tokens.accessToken,
|
|
18
|
-
refreshToken: tokens.refreshToken,
|
|
19
|
-
};
|
|
20
|
-
let refreshed;
|
|
21
|
-
if (tokens.refreshToken) {
|
|
22
|
-
const result = await sdk.authentication.refreshToken(tokens.refreshToken);
|
|
23
|
-
refreshed = {
|
|
24
|
-
accessToken: result.payload.token,
|
|
25
|
-
refreshToken: result.payload.refreshToken,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
else {
|
|
29
|
-
const result = await sdk.authentication.refreshCookieToken(tokens.accessToken);
|
|
30
|
-
refreshed = {
|
|
31
|
-
accessToken: result.payload.cookieToken,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
tokens.accessToken = refreshed.accessToken;
|
|
35
|
-
tokens.refreshToken = refreshed.refreshToken;
|
|
36
|
-
if (onTokenRefresh) {
|
|
37
|
-
await onTokenRefresh({ event, oldTokens: previous, newTokens: refreshed, sdk });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
function ensureRefresh() {
|
|
41
|
-
if (!refreshInFlight) {
|
|
42
|
-
refreshInFlight = performRefresh().finally(() => {
|
|
43
|
-
refreshInFlight = null;
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
return refreshInFlight;
|
|
47
|
-
}
|
|
48
5
|
const options = {
|
|
49
6
|
appUrl: config.appUrl,
|
|
50
7
|
fetch: globalThis.fetch,
|
|
51
|
-
forceRefresh: !config.apiToken,
|
|
52
8
|
...baseOptions,
|
|
53
9
|
};
|
|
54
10
|
if (config.apiToken) {
|
|
55
11
|
options.apiToken = config.apiToken;
|
|
12
|
+
return new QelosSDK(options);
|
|
56
13
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
14
|
+
options.extraHeaders = async () => {
|
|
15
|
+
const headers = {};
|
|
16
|
+
const cookieHeader = getRequestHeader(event, 'cookie');
|
|
17
|
+
if (cookieHeader) {
|
|
18
|
+
headers.cookie = cookieHeader;
|
|
60
19
|
}
|
|
61
|
-
|
|
62
|
-
|
|
20
|
+
const authHeader = getRequestHeader(event, 'authorization');
|
|
21
|
+
if (authHeader) {
|
|
22
|
+
headers.authorization = authHeader;
|
|
63
23
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
if (forceRefresh && tokens.refreshToken) {
|
|
69
|
-
await ensureRefresh();
|
|
70
|
-
}
|
|
71
|
-
const token = sdk?.authentication?.accessToken || tokens.accessToken;
|
|
72
|
-
if (token) {
|
|
73
|
-
return { authorization: 'Bearer ' + token };
|
|
74
|
-
}
|
|
75
|
-
return {};
|
|
76
|
-
};
|
|
77
|
-
options.onFailedRefreshToken = async () => {
|
|
78
|
-
await ensureRefresh();
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
sdk = new QelosSDK(options);
|
|
82
|
-
return sdk;
|
|
24
|
+
return headers;
|
|
25
|
+
};
|
|
26
|
+
return new QelosSDK(options);
|
|
83
27
|
}
|
|
84
28
|
//# sourceMappingURL=sdk-factory.js.map
|
package/dist/sdk-factory.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sdk-factory.js","sourceRoot":"","sources":["../src/sdk-factory.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"sdk-factory.js","sourceRoot":"","sources":["../src/sdk-factory.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,YAAY,CAAC;AAElC,OAAO,EAAE,gBAAgB,EAAgB,MAAM,IAAI,CAAC;AAQpD,MAAM,UAAU,gBAAgB,CAAC,EAAE,MAAM,EAAE,KAAK,EAAmB;IACjE,MAAM,WAAW,GAA6B,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC;IAEtE,MAAM,OAAO,GAAoB;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAG,UAAU,CAAC,KAA8B;QACjD,GAAG,WAAW;KACf,CAAC;IAEF,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACpB,OAAO,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;QACnC,OAAO,IAAI,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAED,OAAO,CAAC,YAAY,GAAG,KAAK,IAAI,EAAE;QAChC,MAAM,OAAO,GAA2B,EAAE,CAAC;QAC3C,MAAM,YAAY,GAAG,gBAAgB,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;QACvD,IAAI,YAAY,EAAE,CAAC;YACjB,OAAO,CAAC,MAAM,GAAG,YAAY,CAAC;QAChC,CAAC;QACD,MAAM,UAAU,GAAG,gBAAgB,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC;QAC5D,IAAI,UAAU,EAAE,CAAC;YACf,OAAO,CAAC,aAAa,GAAG,UAAU,CAAC;QACrC,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IAEF,OAAO,IAAI,QAAQ,CAAC,OAAO,CAAC,CAAC;AAC/B,CAAC"}
|
package/dist/types.d.ts
CHANGED
|
@@ -2,10 +2,6 @@ import type QelosSDK from '@qelos/sdk';
|
|
|
2
2
|
import type { IUser } from '@qelos/sdk/dist/authentication';
|
|
3
3
|
import type { IWorkspace } from '@qelos/sdk/workspaces';
|
|
4
4
|
import type { QelosSDKOptions } from '@qelos/sdk/types';
|
|
5
|
-
export interface QelosTokenPair {
|
|
6
|
-
accessToken?: string;
|
|
7
|
-
refreshToken?: string;
|
|
8
|
-
}
|
|
9
5
|
export interface QelosNuxtRuntimeConfig {
|
|
10
6
|
/**
|
|
11
7
|
* Base URL of the Qelos backend (e.g. https://yourdomain.com).
|
|
@@ -13,17 +9,9 @@ export interface QelosNuxtRuntimeConfig {
|
|
|
13
9
|
appUrl: string;
|
|
14
10
|
/**
|
|
15
11
|
* Static API token used for service-to-service calls. When provided, no
|
|
16
|
-
* cookie
|
|
12
|
+
* cookie-based authentication is performed.
|
|
17
13
|
*/
|
|
18
14
|
apiToken?: string;
|
|
19
|
-
/**
|
|
20
|
-
* Cookie name carrying the Qelos access token. Defaults to `q_access_token`.
|
|
21
|
-
*/
|
|
22
|
-
accessTokenCookie?: string;
|
|
23
|
-
/**
|
|
24
|
-
* Cookie name carrying the Qelos refresh token. Defaults to `q_refresh_token`.
|
|
25
|
-
*/
|
|
26
|
-
refreshTokenCookie?: string;
|
|
27
15
|
/**
|
|
28
16
|
* Path to mount the server middleware on. Defaults to `''` (every request).
|
|
29
17
|
*/
|
|
@@ -42,18 +30,12 @@ export interface QelosNuxtRuntimeConfig {
|
|
|
42
30
|
* Optional extra options merged into the per-request SDK instance.
|
|
43
31
|
*/
|
|
44
32
|
sdkOptions?: Partial<QelosSDKOptions>;
|
|
33
|
+
/**
|
|
34
|
+
* If true, the catch-all `/api/**` proxy handler is not registered.
|
|
35
|
+
* Defaults to `false`.
|
|
36
|
+
*/
|
|
37
|
+
disableProxy?: boolean;
|
|
45
38
|
}
|
|
46
|
-
export interface ResolvedTokens {
|
|
47
|
-
accessToken: string;
|
|
48
|
-
refreshToken?: string;
|
|
49
|
-
}
|
|
50
|
-
export interface TokenRefreshContext {
|
|
51
|
-
event: import('h3').H3Event;
|
|
52
|
-
oldTokens: QelosTokenPair;
|
|
53
|
-
newTokens: ResolvedTokens;
|
|
54
|
-
sdk: QelosSDK;
|
|
55
|
-
}
|
|
56
|
-
export type TokenRefreshHook = (ctx: TokenRefreshContext) => void | Promise<void>;
|
|
57
39
|
export interface QelosRequestContext {
|
|
58
40
|
/**
|
|
59
41
|
* The authenticated user, or `null` when anonymous.
|
|
@@ -69,13 +51,11 @@ export interface QelosRequestContext {
|
|
|
69
51
|
*/
|
|
70
52
|
workspaces: IWorkspace[];
|
|
71
53
|
/**
|
|
72
|
-
* SDK instance bound to the current request's
|
|
54
|
+
* SDK instance bound to the current request's cookies. `extraHeaders`
|
|
55
|
+
* re-reads cookies live on each call, so cookie rotations applied by the
|
|
56
|
+
* middleware are picked up automatically.
|
|
73
57
|
*/
|
|
74
58
|
sdk: QelosSDK;
|
|
75
|
-
/**
|
|
76
|
-
* Tokens read from the request. Mutated when a refresh occurs.
|
|
77
|
-
*/
|
|
78
|
-
tokens: QelosTokenPair;
|
|
79
59
|
}
|
|
80
60
|
declare module 'h3' {
|
|
81
61
|
interface H3EventContext {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qelos/integrator-nuxt",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nuxt integrator for Qelos: server middleware/module that identifies the user and workspace from the Qelos SDK before your handlers run.",
|
|
6
6
|
"main": "./dist/module.js",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@qelos/sdk": "4.
|
|
62
|
+
"@qelos/sdk": "4.1.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@nuxt/kit": "^3.13.0",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Run with: `tsx --test integrators/nuxt/src/cookies.test.ts`
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { rewriteSetCookieDomain, rewriteSetCookieDomains } from './cookies';
|
|
5
|
+
|
|
6
|
+
describe('rewriteSetCookieDomain', () => {
|
|
7
|
+
it('replaces a leading-dot Domain value', () => {
|
|
8
|
+
const input = 'sid=abc; Path=/; Domain=.qelos.app; HttpOnly; Secure';
|
|
9
|
+
assert.equal(
|
|
10
|
+
rewriteSetCookieDomain(input, 'example.com'),
|
|
11
|
+
'sid=abc; Path=/; Domain=example.com; HttpOnly; Secure',
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('replaces a wildcard Domain value', () => {
|
|
16
|
+
const input = 'sid=abc; Path=/; Domain=*.qelos.app; HttpOnly';
|
|
17
|
+
assert.equal(
|
|
18
|
+
rewriteSetCookieDomain(input, 'example.com'),
|
|
19
|
+
'sid=abc; Path=/; Domain=example.com; HttpOnly',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns the cookie unchanged when no Domain attribute is present', () => {
|
|
24
|
+
const input = 'sid=abc; Path=/; HttpOnly; Secure; SameSite=Lax';
|
|
25
|
+
assert.equal(rewriteSetCookieDomain(input, 'example.com'), input);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('strips the port from a host:port newDomain before substitution', () => {
|
|
29
|
+
const input = 'sid=abc; Domain=.qelos.app; Path=/';
|
|
30
|
+
assert.equal(
|
|
31
|
+
rewriteSetCookieDomain(input, 'localhost:3000'),
|
|
32
|
+
'sid=abc; Domain=localhost; Path=/',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('preserves attribute order and other attributes across replacement', () => {
|
|
37
|
+
const input =
|
|
38
|
+
'sid=abc; Path=/; Domain=.qelos.app; HttpOnly; Secure; SameSite=Lax; Max-Age=3600';
|
|
39
|
+
assert.equal(
|
|
40
|
+
rewriteSetCookieDomain(input, 'example.com'),
|
|
41
|
+
'sid=abc; Path=/; Domain=example.com; HttpOnly; Secure; SameSite=Lax; Max-Age=3600',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('preserves an Expires attribute (which contains commas) across replacement', () => {
|
|
46
|
+
const input =
|
|
47
|
+
'sid=abc; Domain=.qelos.app; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/';
|
|
48
|
+
assert.equal(
|
|
49
|
+
rewriteSetCookieDomain(input, 'example.com'),
|
|
50
|
+
'sid=abc; Domain=example.com; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('matches the Domain attribute name case-insensitively and preserves its casing', () => {
|
|
55
|
+
const input = 'sid=abc; domain=.qelos.app; Path=/';
|
|
56
|
+
assert.equal(
|
|
57
|
+
rewriteSetCookieDomain(input, 'example.com'),
|
|
58
|
+
'sid=abc; domain=example.com; Path=/',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('strips the entire Domain segment when newDomain is undefined', () => {
|
|
63
|
+
const input =
|
|
64
|
+
'sid=abc; Path=/; Domain=.qelos.app; HttpOnly; Secure; SameSite=Lax';
|
|
65
|
+
assert.equal(
|
|
66
|
+
rewriteSetCookieDomain(input, undefined),
|
|
67
|
+
'sid=abc; Path=/; HttpOnly; Secure; SameSite=Lax',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('strips the entire Domain segment when newDomain is an empty string', () => {
|
|
72
|
+
const input = 'sid=abc; Domain=.qelos.app; Path=/';
|
|
73
|
+
assert.equal(
|
|
74
|
+
rewriteSetCookieDomain(input, ''),
|
|
75
|
+
'sid=abc; Path=/',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('removes the Domain segment when it is the last attribute', () => {
|
|
80
|
+
const input = 'sid=abc; Path=/; Domain=.qelos.app';
|
|
81
|
+
assert.equal(
|
|
82
|
+
rewriteSetCookieDomain(input, undefined),
|
|
83
|
+
'sid=abc; Path=/',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('rewriteSetCookieDomains', () => {
|
|
89
|
+
it('applies the rewrite to each entry of an array', () => {
|
|
90
|
+
const values = [
|
|
91
|
+
'a=1; Domain=.qelos.app; Path=/',
|
|
92
|
+
'b=2; Path=/; HttpOnly',
|
|
93
|
+
'c=3; Domain=*.qelos.app; Secure',
|
|
94
|
+
];
|
|
95
|
+
assert.deepEqual(rewriteSetCookieDomains(values, 'example.com'), [
|
|
96
|
+
'a=1; Domain=example.com; Path=/',
|
|
97
|
+
'b=2; Path=/; HttpOnly',
|
|
98
|
+
'c=3; Domain=example.com; Secure',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('strips the Domain segment from each entry when newDomain is undefined', () => {
|
|
103
|
+
const values = [
|
|
104
|
+
'a=1; Domain=.qelos.app; Path=/',
|
|
105
|
+
'b=2; Domain=*.qelos.app; HttpOnly',
|
|
106
|
+
];
|
|
107
|
+
assert.deepEqual(rewriteSetCookieDomains(values, undefined), [
|
|
108
|
+
'a=1; Path=/',
|
|
109
|
+
'b=2; HttpOnly',
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrite the `Domain=` attribute of a single `Set-Cookie` header value.
|
|
3
|
+
*
|
|
4
|
+
* - If the cookie contains a `Domain=<value>` attribute (case-insensitive), its
|
|
5
|
+
* value is replaced with `newDomain`, with any trailing `:port` suffix
|
|
6
|
+
* stripped first (cookie `Domain` attributes do not include a port).
|
|
7
|
+
* - If `newDomain` is `undefined` (or empty), the entire `; Domain=<value>`
|
|
8
|
+
* segment is removed so the browser scopes the cookie to the current host.
|
|
9
|
+
* - If no `Domain=` attribute is present, the input is returned unchanged.
|
|
10
|
+
*
|
|
11
|
+
* All other attributes, their casing/order and the original cookie name/value
|
|
12
|
+
* bytes are preserved.
|
|
13
|
+
*/
|
|
14
|
+
export function rewriteSetCookieDomain(
|
|
15
|
+
setCookie: string,
|
|
16
|
+
newDomain: string | undefined,
|
|
17
|
+
): string {
|
|
18
|
+
const domainSegment = /;\s*Domain\s*=\s*[^;]+/i;
|
|
19
|
+
if (!domainSegment.test(setCookie)) return setCookie;
|
|
20
|
+
|
|
21
|
+
const stripped = newDomain ? stripPort(newDomain) : '';
|
|
22
|
+
if (!stripped) {
|
|
23
|
+
return setCookie.replace(domainSegment, '');
|
|
24
|
+
}
|
|
25
|
+
return setCookie.replace(
|
|
26
|
+
/(;\s*Domain\s*=\s*)[^;]+/i,
|
|
27
|
+
`$1${stripped}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply {@link rewriteSetCookieDomain} to each entry of a `Set-Cookie` array
|
|
33
|
+
* (e.g. the result of `Headers.getSetCookie()`).
|
|
34
|
+
*/
|
|
35
|
+
export function rewriteSetCookieDomains(
|
|
36
|
+
values: string[],
|
|
37
|
+
newDomain: string | undefined,
|
|
38
|
+
): string[] {
|
|
39
|
+
return values.map((value) => rewriteSetCookieDomain(value, newDomain));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripPort(host: string): string {
|
|
43
|
+
if (host.startsWith('[')) {
|
|
44
|
+
const end = host.indexOf(']');
|
|
45
|
+
return end === -1 ? host : host.slice(1, end);
|
|
46
|
+
}
|
|
47
|
+
const colon = host.indexOf(':');
|
|
48
|
+
return colon === -1 ? host : host.slice(0, colon);
|
|
49
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,34 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
|
+
appendResponseHeader,
|
|
3
|
+
createError,
|
|
2
4
|
defineEventHandler,
|
|
3
|
-
getCookie,
|
|
4
5
|
getRequestHeader,
|
|
5
|
-
setCookie,
|
|
6
|
-
createError,
|
|
7
6
|
type H3Event,
|
|
8
7
|
} from 'h3';
|
|
9
8
|
import type { IUser } from '@qelos/sdk/dist/authentication';
|
|
10
9
|
import type { IWorkspace } from '@qelos/sdk/workspaces';
|
|
10
|
+
import { rewriteSetCookieDomain } from './cookies';
|
|
11
|
+
import { resolveQelosProxyTarget } from './proxy-target';
|
|
11
12
|
import { createRequestSdk } from './sdk-factory';
|
|
12
|
-
import type {
|
|
13
|
-
QelosNuxtRuntimeConfig,
|
|
14
|
-
QelosRequestContext,
|
|
15
|
-
QelosTokenPair,
|
|
16
|
-
TokenRefreshHook,
|
|
17
|
-
} from './types';
|
|
18
|
-
|
|
19
|
-
const DEFAULT_ACCESS_COOKIE = 'q_access_token';
|
|
20
|
-
const DEFAULT_REFRESH_COOKIE = 'q_refresh_token';
|
|
13
|
+
import type { QelosNuxtRuntimeConfig, QelosRequestContext } from './types';
|
|
21
14
|
|
|
22
15
|
export interface CreateMiddlewareOptions {
|
|
23
16
|
config: QelosNuxtRuntimeConfig;
|
|
24
17
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
onTokenRefresh?: TokenRefreshHook;
|
|
29
|
-
/**
|
|
30
|
-
* Resolve the active workspace for a request. Defaults to picking the first
|
|
31
|
-
* workspace returned from `sdk.workspaces.getList()`.
|
|
18
|
+
* Resolve the active workspace for a request. Defaults to whatever
|
|
19
|
+
* `/api/me` reports on `user.workspace` (the user's currently activated
|
|
20
|
+
* workspace, or `null` when none is active).
|
|
32
21
|
*/
|
|
33
22
|
resolveWorkspace?: (params: {
|
|
34
23
|
event: H3Event;
|
|
@@ -37,43 +26,6 @@ export interface CreateMiddlewareOptions {
|
|
|
37
26
|
}) => IWorkspace | null | Promise<IWorkspace | null>;
|
|
38
27
|
}
|
|
39
28
|
|
|
40
|
-
function readTokens(event: H3Event, config: QelosNuxtRuntimeConfig): QelosTokenPair {
|
|
41
|
-
const accessCookie = config.accessTokenCookie || DEFAULT_ACCESS_COOKIE;
|
|
42
|
-
const refreshCookie = config.refreshTokenCookie || DEFAULT_REFRESH_COOKIE;
|
|
43
|
-
|
|
44
|
-
const cookieAccess = getCookie(event, accessCookie);
|
|
45
|
-
const cookieRefresh = getCookie(event, refreshCookie);
|
|
46
|
-
|
|
47
|
-
const authHeader = getRequestHeader(event, 'authorization');
|
|
48
|
-
const headerAccess = authHeader && authHeader.toLowerCase().startsWith('bearer ')
|
|
49
|
-
? authHeader.slice(7).trim()
|
|
50
|
-
: undefined;
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
accessToken: headerAccess || cookieAccess || undefined,
|
|
54
|
-
refreshToken: cookieRefresh || undefined,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function writeTokensToCookies(
|
|
59
|
-
event: H3Event,
|
|
60
|
-
config: QelosNuxtRuntimeConfig,
|
|
61
|
-
tokens: { accessToken: string; refreshToken?: string },
|
|
62
|
-
) {
|
|
63
|
-
const accessCookie = config.accessTokenCookie || DEFAULT_ACCESS_COOKIE;
|
|
64
|
-
const refreshCookie = config.refreshTokenCookie || DEFAULT_REFRESH_COOKIE;
|
|
65
|
-
const cookieOptions = {
|
|
66
|
-
httpOnly: true,
|
|
67
|
-
secure: true,
|
|
68
|
-
sameSite: 'lax' as const,
|
|
69
|
-
path: '/',
|
|
70
|
-
};
|
|
71
|
-
setCookie(event, accessCookie, tokens.accessToken, cookieOptions);
|
|
72
|
-
if (tokens.refreshToken) {
|
|
73
|
-
setCookie(event, refreshCookie, tokens.refreshToken, cookieOptions);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
29
|
function shouldSkip(event: H3Event, config: QelosNuxtRuntimeConfig): boolean {
|
|
78
30
|
if (!config.skipPaths?.length) {
|
|
79
31
|
return false;
|
|
@@ -82,41 +34,74 @@ function shouldSkip(event: H3Event, config: QelosNuxtRuntimeConfig): boolean {
|
|
|
82
34
|
return config.skipPaths.some((prefix) => path.startsWith(prefix));
|
|
83
35
|
}
|
|
84
36
|
|
|
37
|
+
function buildMeUrl(base: string): string {
|
|
38
|
+
return `${base.replace(/\/+$/, '')}/api/me`;
|
|
39
|
+
}
|
|
40
|
+
|
|
85
41
|
export function createQelosMiddleware(options: CreateMiddlewareOptions) {
|
|
86
42
|
const { config, resolveWorkspace } = options;
|
|
87
|
-
const onTokenRefresh: TokenRefreshHook = options.onTokenRefresh
|
|
88
|
-
|| (async ({ event, newTokens }) => {
|
|
89
|
-
writeTokensToCookies(event, config, newTokens);
|
|
90
|
-
});
|
|
91
43
|
|
|
92
44
|
return defineEventHandler(async (event) => {
|
|
93
45
|
if (shouldSkip(event, config)) {
|
|
94
46
|
return;
|
|
95
47
|
}
|
|
96
48
|
|
|
97
|
-
const
|
|
98
|
-
const sdk = createRequestSdk({ config, tokens, event, onTokenRefresh });
|
|
49
|
+
const sdk = createRequestSdk({ config, event });
|
|
99
50
|
|
|
100
51
|
const ctx: QelosRequestContext = {
|
|
101
52
|
user: null,
|
|
102
53
|
workspace: null,
|
|
103
54
|
workspaces: [],
|
|
104
55
|
sdk,
|
|
105
|
-
tokens,
|
|
106
56
|
};
|
|
107
57
|
event.context.qelos = ctx;
|
|
108
58
|
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
59
|
+
const base = resolveQelosProxyTarget(config);
|
|
60
|
+
if (!base && !config.apiToken) {
|
|
111
61
|
if (config.requireAuth) {
|
|
112
62
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
|
113
63
|
}
|
|
114
64
|
return;
|
|
115
65
|
}
|
|
116
66
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
67
|
+
if (base) {
|
|
68
|
+
const headers: Record<string, string> = {};
|
|
69
|
+
const cookieHeader = getRequestHeader(event, 'cookie');
|
|
70
|
+
if (cookieHeader) headers.cookie = cookieHeader;
|
|
71
|
+
const authHeader = getRequestHeader(event, 'authorization');
|
|
72
|
+
if (authHeader) headers.authorization = authHeader;
|
|
73
|
+
|
|
74
|
+
const originalHost = getRequestHeader(event, 'host');
|
|
75
|
+
|
|
76
|
+
let response: Response | undefined;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetch(buildMeUrl(base), {
|
|
79
|
+
method: 'GET',
|
|
80
|
+
headers,
|
|
81
|
+
redirect: 'manual',
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
if (config.requireAuth) {
|
|
85
|
+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const setCookieValues = response.headers.getSetCookie?.() ?? [];
|
|
91
|
+
for (const value of setCookieValues) {
|
|
92
|
+
appendResponseHeader(event, 'set-cookie', rewriteSetCookieDomain(value, originalHost));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (response.ok) {
|
|
96
|
+
try {
|
|
97
|
+
ctx.user = (await response.json()) as IUser;
|
|
98
|
+
} catch {
|
|
99
|
+
ctx.user = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!ctx.user) {
|
|
120
105
|
if (config.requireAuth) {
|
|
121
106
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
|
122
107
|
}
|
|
@@ -129,16 +114,18 @@ export function createQelosMiddleware(options: CreateMiddlewareOptions) {
|
|
|
129
114
|
ctx.workspaces = [];
|
|
130
115
|
}
|
|
131
116
|
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
117
|
+
if (resolveWorkspace) {
|
|
118
|
+
ctx.workspace = (await resolveWorkspace({
|
|
119
|
+
event,
|
|
120
|
+
user: ctx.user,
|
|
121
|
+
workspaces: ctx.workspaces,
|
|
122
|
+
})) || null;
|
|
123
|
+
} else {
|
|
124
|
+
// `/api/me` carries `user.workspace` (`{ _id, name, roles }`) only when
|
|
125
|
+
// the user has activated a workspace. Null means the frontend must
|
|
126
|
+
// prompt to activate/create one — don't silently pick workspaces[0].
|
|
127
|
+
ctx.workspace =
|
|
128
|
+
(ctx.user as unknown as { workspace?: IWorkspace | null }).workspace || null;
|
|
142
129
|
}
|
|
143
130
|
});
|
|
144
131
|
}
|
package/src/module.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface QelosNuxtModuleOptions extends QelosNuxtRuntimeConfig {
|
|
|
13
13
|
* want to call `createQelosMiddleware` yourself from your own server route.
|
|
14
14
|
*/
|
|
15
15
|
disableMiddleware?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Disable registration of the catch-all `/api/**` proxy server handler.
|
|
18
|
+
* Defaults to `false`.
|
|
19
|
+
*/
|
|
20
|
+
disableProxy?: boolean;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
export default defineNuxtModule<QelosNuxtModuleOptions>({
|
|
@@ -33,7 +38,22 @@ export default defineNuxtModule<QelosNuxtModuleOptions>({
|
|
|
33
38
|
|
|
34
39
|
const { resolve } = createResolver(import.meta.url);
|
|
35
40
|
|
|
36
|
-
const { disableMiddleware, ...runtimeConfig } = options;
|
|
41
|
+
const { disableMiddleware, disableProxy, ...runtimeConfig } = options;
|
|
42
|
+
|
|
43
|
+
if (!disableProxy) {
|
|
44
|
+
// When the catch-all `/api/**` proxy is enabled, the user-resolution
|
|
45
|
+
// middleware MUST skip `/api/` requests. The middleware itself calls
|
|
46
|
+
// `${appUrl}/api/me` to identify the user, so without this skip
|
|
47
|
+
// every inbound `/api/me` would be both proxied AND used as the
|
|
48
|
+
// identification probe — a duplicate upstream hit and a potential
|
|
49
|
+
// cookie-rewrite loop. Users can extend `skipPaths` further but
|
|
50
|
+
// `/api/` must remain covered whenever `disableProxy` is false.
|
|
51
|
+
const existingSkipPaths = runtimeConfig.skipPaths ?? [];
|
|
52
|
+
const alreadyCovered = existingSkipPaths.some((prefix) => '/api/'.startsWith(prefix));
|
|
53
|
+
if (!alreadyCovered) {
|
|
54
|
+
runtimeConfig.skipPaths = ['/api/', ...existingSkipPaths];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
nuxt.options.runtimeConfig.qelos = {
|
|
39
59
|
...(nuxt.options.runtimeConfig.qelos as Record<string, unknown> | undefined),
|
|
@@ -47,6 +67,13 @@ export default defineNuxtModule<QelosNuxtModuleOptions>({
|
|
|
47
67
|
});
|
|
48
68
|
}
|
|
49
69
|
|
|
70
|
+
if (!disableProxy) {
|
|
71
|
+
addServerHandler({
|
|
72
|
+
route: '/api/**',
|
|
73
|
+
handler: resolve('./runtime/api-proxy'),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
addImportsDir(resolve('./runtime/composables'));
|
|
51
78
|
|
|
52
79
|
addTypeTemplate({
|
|
@@ -77,8 +104,4 @@ export type {
|
|
|
77
104
|
export type {
|
|
78
105
|
QelosNuxtRuntimeConfig,
|
|
79
106
|
QelosRequestContext,
|
|
80
|
-
QelosTokenPair,
|
|
81
|
-
ResolvedTokens,
|
|
82
|
-
TokenRefreshContext,
|
|
83
|
-
TokenRefreshHook,
|
|
84
107
|
} from './types';
|