@appweaver/core 1.3.0 → 1.4.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/export/export-service.d.ts +4 -3
- package/export/export-service.js +33 -22
- package/factory/create-model.js +36 -21
- package/factory/create-service.js +5 -3
- package/package.json +2 -2
- package/prisma/client/commonInputTypes.d.ts +0 -50
- package/prisma/client/internal/class.js +3 -3
- package/prisma/client/models/File.d.ts +15 -28
- package/resource/resource-loader.js +6 -0
- package/resource/resource-routes.js +2 -2
- package/resource/resource-schema.d.ts +38 -12
- package/resource/resource-schema.js +63 -15
- package/resource/resource-service.d.ts +25 -16
- package/resource/resource-service.js +61 -26
- package/resource/schemas/resource-sort-schema.js +2 -2
- package/resource/utils/cursor-util.d.ts +60 -0
- package/resource/utils/cursor-util.js +117 -0
- package/resource/utils/index.d.ts +1 -0
- package/resource/utils/index.js +1 -0
- package/resource/utils/relation-util.d.ts +3 -3
- package/resource/utils/relation-util.js +1 -1
- package/resource/utils/sort-util.d.ts +11 -0
- package/resource/utils/sort-util.js +18 -0
- package/security/api-key/api-key-auth.js +4 -1
- package/security/auth-service.d.ts +7 -7
- package/security/auth-service.js +43 -5
- package/security/create-auth-resources.d.ts +2 -1
- package/security/oauth2/create-oauth2-plugin.js +3 -34
- package/security/oauth2/oauth2-microsoft.js +1 -1
- package/security/oauth2/oauth2-util.d.ts +9 -7
- package/security/oauth2/oauth2-util.js +17 -10
- package/security/resources/api-key/model.js +2 -0
- package/security/resources/connected-account/model.js +2 -0
- package/security/store/database-security-store.js +2 -1
- package/storage/file-service.d.ts +19 -4
- package/storage/file-service.js +199 -146
- package/storage/resources/file/model.js +4 -2
- package/types/auth.d.ts +8 -8
- package/types/generated.d.ts +8 -2
- package/types/index.d.ts +1 -0
- package/types/index.js +1 -0
- package/types/storage.d.ts +16 -0
- package/types/storage.js +2 -0
- package/utils/index.d.ts +1 -0
- package/utils/index.js +1 -0
- package/utils/model-util.d.ts +12 -0
- package/utils/model-util.js +113 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.queryFingerprint = queryFingerprint;
|
|
4
|
+
exports.encodeCursor = encodeCursor;
|
|
5
|
+
exports.pageCursors = pageCursors;
|
|
6
|
+
exports.decodeCursor = decodeCursor;
|
|
7
|
+
const common_1 = require("@appweaver/common");
|
|
8
|
+
const errors_1 = require("../../errors");
|
|
9
|
+
/**
|
|
10
|
+
* Builds the fingerprint identifying the query a cursor belongs to. A cursor
|
|
11
|
+
* only yields the intended records while the resource, the query, and the order
|
|
12
|
+
* stay the same, so it carries the fingerprint and is rejected on mismatch.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} resourceName - The name of the queried model.
|
|
15
|
+
* @param {Object} query - The mapped database query the cursor was issued for.
|
|
16
|
+
* @param {Object[]} orderBy - The mapped order entries the cursor was issued for.
|
|
17
|
+
* @return {string} The fingerprint of the query.
|
|
18
|
+
*/
|
|
19
|
+
function queryFingerprint(resourceName, query, orderBy) {
|
|
20
|
+
const serialized = stableStringify([resourceName, query ?? {}, orderBy]);
|
|
21
|
+
return (0, common_1.makeHash)(serialized, 'sha256', 'base64url').slice(0, 16);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encodes the cursor of a page adjacent to a query result. The direction belongs
|
|
25
|
+
* to the cursor rather than to the request, so a caller cannot pair one with a
|
|
26
|
+
* direction it was not issued for.
|
|
27
|
+
*
|
|
28
|
+
* @param {ResourceId} id - The primary key of the record the page continues from.
|
|
29
|
+
* @param {string} fingerprint - The fingerprint of the query, as built by
|
|
30
|
+
* {@link queryFingerprint}.
|
|
31
|
+
* @param {boolean} [backward] - Whether the cursor pages towards the preceding
|
|
32
|
+
* records.
|
|
33
|
+
* @return {string} The encoded cursor.
|
|
34
|
+
*/
|
|
35
|
+
function encodeCursor(id, fingerprint, backward = false) {
|
|
36
|
+
const payload = { i: id, f: fingerprint };
|
|
37
|
+
if (backward) {
|
|
38
|
+
payload.b = true;
|
|
39
|
+
}
|
|
40
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the cursors of the pages adjacent to a returned page, each addressing
|
|
44
|
+
* the record of this page it continues from. A page that has no neighbour in a
|
|
45
|
+
* direction, and an empty page, yield null rather than an absent cursor.
|
|
46
|
+
*
|
|
47
|
+
* @param {Object[]} resources - The records of the returned page, in the order
|
|
48
|
+
* they were queried in.
|
|
49
|
+
* @param {string} fingerprint - The fingerprint of the query the page belongs to.
|
|
50
|
+
* @param {boolean} hasNext - Whether a page follows the returned one.
|
|
51
|
+
* @param {boolean} hasPrev - Whether a page precedes the returned one.
|
|
52
|
+
* @return {PageCursors} The cursors of the existing adjacent pages.
|
|
53
|
+
*/
|
|
54
|
+
function pageCursors(resources, fingerprint, hasNext, hasPrev) {
|
|
55
|
+
// A queried record always carries its primary key, which a model type need
|
|
56
|
+
// not declare
|
|
57
|
+
const first = resources[0];
|
|
58
|
+
const last = resources[resources.length - 1];
|
|
59
|
+
if (!first || !last) {
|
|
60
|
+
return { nextCursor: null, prevCursor: null };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
nextCursor: hasNext ? encodeCursor(last.id, fingerprint) : null,
|
|
64
|
+
prevCursor: hasPrev ? encodeCursor(first.id, fingerprint, true) : null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Decodes the cursor a query request carries, resolving the record the page
|
|
69
|
+
* continues from and the direction it runs in.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} [cursor] - The cursor of the request, which may be the null
|
|
72
|
+
* one of a page that does not exist.
|
|
73
|
+
* @param {string} fingerprint - The fingerprint of the query the cursor is used
|
|
74
|
+
* on, as built by {@link queryFingerprint}.
|
|
75
|
+
* @return {DecodedCursor | undefined} The decoded cursor, or `undefined` when the
|
|
76
|
+
* request carries none.
|
|
77
|
+
* @throws {HttpError} 400 if the cursor is malformed, or was issued for another
|
|
78
|
+
* resource, filter, or sort order.
|
|
79
|
+
*/
|
|
80
|
+
function decodeCursor(cursor, fingerprint) {
|
|
81
|
+
if (!cursor) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
let payload;
|
|
85
|
+
try {
|
|
86
|
+
payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
throw new errors_1.HttpError('Invalid pagination cursor', 400, e);
|
|
90
|
+
}
|
|
91
|
+
if (!(0, common_1.isPlainObject)(payload) || payload.i === undefined) {
|
|
92
|
+
throw new errors_1.HttpError('Invalid pagination cursor', 400);
|
|
93
|
+
}
|
|
94
|
+
if (payload.f !== fingerprint) {
|
|
95
|
+
throw new errors_1.HttpError('Pagination cursor does not match the filter and sort of this query', 400);
|
|
96
|
+
}
|
|
97
|
+
return { id: payload.i, backward: payload.b === true };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Serializes a value with its object keys in a stable order, so two equal
|
|
101
|
+
* queries fingerprint identically whatever order their properties arrived in.
|
|
102
|
+
*
|
|
103
|
+
* @param {any} value - The value to serialize.
|
|
104
|
+
* @return {string} The serialized value.
|
|
105
|
+
*/
|
|
106
|
+
function stableStringify(value) {
|
|
107
|
+
if ((0, common_1.isArray)(value)) {
|
|
108
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
109
|
+
}
|
|
110
|
+
if ((0, common_1.isPlainObject)(value)) {
|
|
111
|
+
const entries = Object.keys(value)
|
|
112
|
+
.sort()
|
|
113
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
|
|
114
|
+
return `{${entries.join(',')}}`;
|
|
115
|
+
}
|
|
116
|
+
return JSON.stringify(value) ?? 'null';
|
|
117
|
+
}
|
package/resource/utils/index.js
CHANGED
|
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./aggregate-util"), exports);
|
|
18
|
+
__exportStar(require("./cursor-util"), exports);
|
|
18
19
|
__exportStar(require("./filter-util"), exports);
|
|
19
20
|
__exportStar(require("./relation-util"), exports);
|
|
20
21
|
__exportStar(require("./sort-util"), exports);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ActionType } from '@appweaver/common';
|
|
1
|
+
import { ActionType, ResourceId } from '@appweaver/common';
|
|
2
2
|
/** The nested write actions a single relation field can be mapped to. */
|
|
3
3
|
export type RelationActions = Record<string, Partial<{
|
|
4
4
|
connect: any;
|
|
@@ -74,11 +74,11 @@ export declare function missingRelationFields(resourceName: string | undefined,
|
|
|
74
74
|
* user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
|
|
75
75
|
*
|
|
76
76
|
* @param {string} resourceName - The name of the model the audit relation is built for.
|
|
77
|
-
* @return {{connect: {id:
|
|
77
|
+
* @return {{connect: {id: ResourceId}}|undefined} The connect action pointing at the id of the currently authenticated
|
|
78
78
|
* user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
|
|
79
79
|
*/
|
|
80
80
|
export declare function createdByConnect(resourceName: string): {
|
|
81
81
|
connect: {
|
|
82
|
-
id:
|
|
82
|
+
id: ResourceId;
|
|
83
83
|
};
|
|
84
84
|
} | undefined;
|
|
@@ -276,7 +276,7 @@ function missingRelationFields(resourceName, data) {
|
|
|
276
276
|
* user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
|
|
277
277
|
*
|
|
278
278
|
* @param {string} resourceName - The name of the model the audit relation is built for.
|
|
279
|
-
* @return {{connect: {id:
|
|
279
|
+
* @return {{connect: {id: ResourceId}}|undefined} The connect action pointing at the id of the currently authenticated
|
|
280
280
|
* user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
|
|
281
281
|
*/
|
|
282
282
|
function createdByConnect(resourceName) {
|
|
@@ -18,3 +18,14 @@ import { ActionType, QuerySort } from '@appweaver/common';
|
|
|
18
18
|
* direction, or targets a relation that the action does not include in its response.
|
|
19
19
|
*/
|
|
20
20
|
export declare function mapSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
|
|
21
|
+
/**
|
|
22
|
+
* Maps a sort input the same way {@link mapSortValues} does, and terminates it with the primary key when no entry
|
|
23
|
+
* orders by it. Without a total order, records sharing every sort value can be skipped or repeated across pages.
|
|
24
|
+
*
|
|
25
|
+
* @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
|
|
26
|
+
* @param {string} resourceName - The name of the model the sort is applied on.
|
|
27
|
+
* @param {ActionType} [action] - The action the sort is applied on. Defaults to the query action.
|
|
28
|
+
* @return {Object[]} The `orderBy` entries, ending in a unique one.
|
|
29
|
+
* @throws {HttpError} 400 under the same conditions as {@link mapSortValues}.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mapStableSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.mapSortValues = mapSortValues;
|
|
4
|
+
exports.mapStableSortValues = mapStableSortValues;
|
|
4
5
|
const common_1 = require("@appweaver/common");
|
|
5
6
|
const context_1 = require("../../context");
|
|
6
7
|
const errors_1 = require("../../errors");
|
|
@@ -46,6 +47,23 @@ function mapSortValues(sort, resourceName, action = 'query') {
|
|
|
46
47
|
}
|
|
47
48
|
return orderBy;
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Maps a sort input the same way {@link mapSortValues} does, and terminates it with the primary key when no entry
|
|
52
|
+
* orders by it. Without a total order, records sharing every sort value can be skipped or repeated across pages.
|
|
53
|
+
*
|
|
54
|
+
* @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
|
|
55
|
+
* @param {string} resourceName - The name of the model the sort is applied on.
|
|
56
|
+
* @param {ActionType} [action] - The action the sort is applied on. Defaults to the query action.
|
|
57
|
+
* @return {Object[]} The `orderBy` entries, ending in a unique one.
|
|
58
|
+
* @throws {HttpError} 400 under the same conditions as {@link mapSortValues}.
|
|
59
|
+
*/
|
|
60
|
+
function mapStableSortValues(sort, resourceName, action = 'query') {
|
|
61
|
+
const orderBy = mapSortValues(sort, resourceName, action);
|
|
62
|
+
if (!orderBy.some((entry) => 'id' in entry)) {
|
|
63
|
+
orderBy.push({ id: 'asc' });
|
|
64
|
+
}
|
|
65
|
+
return orderBy;
|
|
66
|
+
}
|
|
49
67
|
/**
|
|
50
68
|
* Flattens a sort input into the list of its field paths and directions, keeping the order the fields were declared
|
|
51
69
|
* in. String inputs are split on commas, with a `-` or `+` prefix selecting the direction, and object inputs are
|
|
@@ -26,7 +26,9 @@ exports.apiKeyAuth = (0, fastify_plugin_1.default)(async (server) => {
|
|
|
26
26
|
// Use configured delimiter to split an API key and separate ID from the
|
|
27
27
|
// rest of the key value
|
|
28
28
|
const apiKeyParts = sanitizedApiKey.split(common_1.config.SECURITY_API_KEY_DELIMITER);
|
|
29
|
-
|
|
29
|
+
// The id prefix is read back in the primary key type of the ApiKey model,
|
|
30
|
+
// which can be configured as a string like on any other model
|
|
31
|
+
const apiKeyId = (0, common_1.toResourceId)(apiKeyParts.shift() ?? '', (0, context_1.injectModel)('ApiKey', false)?.config?.id);
|
|
30
32
|
const apiKeyValue = apiKeyParts.join(common_1.config.SECURITY_API_KEY_DELIMITER);
|
|
31
33
|
const cacheKey = cacheService.buildCacheKey({
|
|
32
34
|
baseKey: `apikey:${apiKeyId}`,
|
|
@@ -35,6 +37,7 @@ exports.apiKeyAuth = (0, fastify_plugin_1.default)(async (server) => {
|
|
|
35
37
|
let apiKey = await cacheService.getCachedValue(cacheKey);
|
|
36
38
|
if (!apiKey) {
|
|
37
39
|
try {
|
|
40
|
+
// The generated client types the id after the configured primary key
|
|
38
41
|
apiKey = await db
|
|
39
42
|
.client()
|
|
40
43
|
.apiKey.findFirst({ where: { id: apiKeyId } });
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { AuthScope, AuthSource, AuthUser, RouteConfig } from '@appweaver/common';
|
|
1
|
+
import { AuthScope, AuthSource, AuthUser, ResourceId, RouteConfig } from '@appweaver/common';
|
|
2
2
|
import { AuthTokens, JwtPayload, UserAdditionalData } from '../types';
|
|
3
3
|
export declare class AuthService {
|
|
4
4
|
/**
|
|
5
5
|
* Finds an authenticated user by their unique identifier.
|
|
6
6
|
*
|
|
7
|
-
* @param {
|
|
7
|
+
* @param {ResourceId} id - The unique identifier of the authenticated user to find.
|
|
8
8
|
* @return {Promise<AuthUser | null>} A promise that resolves to the authenticated user object if found, otherwise
|
|
9
9
|
* null.
|
|
10
10
|
*/
|
|
11
|
-
findById(id:
|
|
11
|
+
findById(id: ResourceId): Promise<AuthUser | null>;
|
|
12
12
|
/**
|
|
13
13
|
* Retrieves a user by their username.
|
|
14
14
|
*
|
|
@@ -19,12 +19,12 @@ export declare class AuthService {
|
|
|
19
19
|
/**
|
|
20
20
|
* Updates an authenticated user's information in the system.
|
|
21
21
|
*
|
|
22
|
-
* @param {
|
|
22
|
+
* @param {ResourceId} id - The unique identifier of the authenticated user to be updated.
|
|
23
23
|
* @param {Partial<AuthUser> & { password?: string }} data - The partial user data to update, optionally including a
|
|
24
24
|
* password.
|
|
25
25
|
* @return {Promise<AuthUser>} A promise that resolves to the updated authenticated user object.
|
|
26
26
|
*/
|
|
27
|
-
updateAuthUser(id:
|
|
27
|
+
updateAuthUser(id: ResourceId, data: Partial<AuthUser> & {
|
|
28
28
|
password?: string;
|
|
29
29
|
}): Promise<AuthUser>;
|
|
30
30
|
/**
|
|
@@ -112,9 +112,9 @@ export declare class AuthService {
|
|
|
112
112
|
/**
|
|
113
113
|
* Logs out a user by updating their authentication information with a logout timestamp.
|
|
114
114
|
*
|
|
115
|
-
* @param {
|
|
115
|
+
* @param {ResourceId} id - The unique identifier of the user to be logged out.
|
|
116
116
|
* @return {Promise<boolean>} A promise that resolves to a boolean indicating whether the logout operation was
|
|
117
117
|
* successful.
|
|
118
118
|
*/
|
|
119
|
-
logout(id:
|
|
119
|
+
logout(id: ResourceId): Promise<boolean>;
|
|
120
120
|
}
|
package/security/auth-service.js
CHANGED
|
@@ -6,6 +6,7 @@ const helper_1 = require("./helper");
|
|
|
6
6
|
const oauth2_service_1 = require("./oauth2/oauth2-service");
|
|
7
7
|
const context_1 = require("../context");
|
|
8
8
|
const cache_1 = require("../cache");
|
|
9
|
+
const storage_1 = require("../storage");
|
|
9
10
|
const errors_1 = require("../errors");
|
|
10
11
|
const AUTH_KEY = 'auth';
|
|
11
12
|
class AuthService {
|
|
@@ -21,7 +22,7 @@ class AuthService {
|
|
|
21
22
|
/**
|
|
22
23
|
* Finds an authenticated user by their unique identifier.
|
|
23
24
|
*
|
|
24
|
-
* @param {
|
|
25
|
+
* @param {ResourceId} id - The unique identifier of the authenticated user to find.
|
|
25
26
|
* @return {Promise<AuthUser | null>} A promise that resolves to the authenticated user object if found, otherwise
|
|
26
27
|
* null.
|
|
27
28
|
*/
|
|
@@ -80,7 +81,7 @@ class AuthService {
|
|
|
80
81
|
/**
|
|
81
82
|
* Updates an authenticated user's information in the system.
|
|
82
83
|
*
|
|
83
|
-
* @param {
|
|
84
|
+
* @param {ResourceId} id - The unique identifier of the authenticated user to be updated.
|
|
84
85
|
* @param {Partial<AuthUser> & { password?: string }} data - The partial user data to update, optionally including a
|
|
85
86
|
* password.
|
|
86
87
|
* @return {Promise<AuthUser>} A promise that resolves to the updated authenticated user object.
|
|
@@ -106,10 +107,11 @@ class AuthService {
|
|
|
106
107
|
* @throws {HttpError} Throws an error if the registration process fails.
|
|
107
108
|
*/
|
|
108
109
|
async registerAuthUser(source, email, password, data) {
|
|
110
|
+
let authUser;
|
|
109
111
|
try {
|
|
110
112
|
const serviceConfig = this._authUserService[common_1.CONFIG];
|
|
111
|
-
const registrationData = serviceConfig.registrationData(source, email, password, data);
|
|
112
|
-
|
|
113
|
+
const registrationData = await serviceConfig.registrationData(source, email, password, data);
|
|
114
|
+
authUser = await this._authUserService.create({
|
|
113
115
|
...registrationData,
|
|
114
116
|
verifiedEmail: source !== common_1.AuthSource.Password
|
|
115
117
|
});
|
|
@@ -117,6 +119,42 @@ class AuthService {
|
|
|
117
119
|
catch (e) {
|
|
118
120
|
throw new errors_1.HttpError('Auth user registration error', 500, e);
|
|
119
121
|
}
|
|
122
|
+
await this.saveRegistrationFiles(authUser, source, data);
|
|
123
|
+
return authUser;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Saves the files the `registrationFiles` service configuration selects for a newly registered user, each one to the
|
|
127
|
+
* file field of the auth model it is keyed by. The files are stored only after the user exists, since a file record
|
|
128
|
+
* is linked to the resource that owns it. Storing a file is best-effort, so a rejected file, such as an avatar the
|
|
129
|
+
* provider served in an unsupported format, is logged and never fails the registration.
|
|
130
|
+
*
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
async saveRegistrationFiles(authUser, source, data) {
|
|
134
|
+
const serviceConfig = this._authUserService[common_1.CONFIG];
|
|
135
|
+
if (!serviceConfig.registrationFiles) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let files;
|
|
139
|
+
try {
|
|
140
|
+
files = (await serviceConfig.registrationFiles(source, data)) ?? {};
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
common_1.logger.error({ id: authUser.id, err: e }, 'Registration files selection error');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const fileService = (0, context_1.inject)(storage_1.FileService);
|
|
147
|
+
for (const [field, file] of Object.entries(files)) {
|
|
148
|
+
if (!file) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
await fileService.saveBuffer(field, file, authUser, this._authUserService.client);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
common_1.logger.error({ id: authUser.id, field, err: e }, 'Registration file save error');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
120
158
|
}
|
|
121
159
|
/**
|
|
122
160
|
* Changes the password for the authenticated user.
|
|
@@ -292,7 +330,7 @@ class AuthService {
|
|
|
292
330
|
/**
|
|
293
331
|
* Logs out a user by updating their authentication information with a logout timestamp.
|
|
294
332
|
*
|
|
295
|
-
* @param {
|
|
333
|
+
* @param {ResourceId} id - The unique identifier of the user to be logged out.
|
|
296
334
|
* @return {Promise<boolean>} A promise that resolves to a boolean indicating whether the logout operation was
|
|
297
335
|
* successful.
|
|
298
336
|
*/
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Ctor, IResourceService, ResourceModel, ResourceModelConfig, ResourceServiceConfig } from '@appweaver/common';
|
|
2
|
-
import { CheckOAuth2UserFn, RegistrationDataFn } from '../types';
|
|
2
|
+
import { CheckOAuth2UserFn, RegistrationDataFn, RegistrationFilesFn } from '../types';
|
|
3
3
|
export declare function createAuthModel(config: ResourceModelConfig): ResourceModel;
|
|
4
4
|
export declare function createAuthService<T = any, C = any, U = any>(config: ResourceServiceConfig<T, C, U> & {
|
|
5
5
|
registrationData?: RegistrationDataFn<T>;
|
|
6
|
+
registrationFiles?: RegistrationFilesFn;
|
|
6
7
|
checkOAuth2User?: CheckOAuth2UserFn;
|
|
7
8
|
}): Ctor<IResourceService<T, T, C, U>>;
|
|
@@ -13,6 +13,7 @@ const auth_service_1 = require("../auth-service");
|
|
|
13
13
|
const oauth2_service_1 = require("./oauth2-service");
|
|
14
14
|
const helper_1 = require("../helper");
|
|
15
15
|
const oauth2_schema_1 = require("./oauth2-schema");
|
|
16
|
+
const oauth2_util_1 = require("./oauth2-util");
|
|
16
17
|
function createOAuth2Plugin(authSource, oAuth2Config) {
|
|
17
18
|
const name = authSource.replace('oauth2', '');
|
|
18
19
|
const upperName = name.toUpperCase();
|
|
@@ -100,7 +101,8 @@ function createOAuth2Plugin(authSource, oAuth2Config) {
|
|
|
100
101
|
}
|
|
101
102
|
authUser = await authService.registerAuthUser(authSource, userInfo.email, undefined, {
|
|
102
103
|
...(0, common_1.pickProperties)(userInfo, ['firstName', 'lastName', 'avatarUrl']),
|
|
103
|
-
avatarFile: userInfo.avatarFile ??
|
|
104
|
+
avatarFile: userInfo.avatarFile ??
|
|
105
|
+
(await (0, oauth2_util_1.fetchAvatarFile)(userInfo.avatarUrl, userInfo.id))
|
|
104
106
|
});
|
|
105
107
|
}
|
|
106
108
|
const stateData = request.oauth2State;
|
|
@@ -137,36 +139,3 @@ function createOAuth2Plugin(authSource, oAuth2Config) {
|
|
|
137
139
|
});
|
|
138
140
|
});
|
|
139
141
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Downloads the user's avatar image from the OAuth2 provider so it can be passed to the `registrationData` callback.
|
|
142
|
-
* Fetching is best-effort: any failure is logged and `undefined` is returned so the registration flow is not blocked.
|
|
143
|
-
*
|
|
144
|
-
* @param {UserInfo} userInfo - The user info extracted from the OAuth2 provider, including the optional avatar URL.
|
|
145
|
-
* @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar file, or `undefined` when
|
|
146
|
-
* avatar fetching is disabled, no avatar URL is available, or the download fails.
|
|
147
|
-
*/
|
|
148
|
-
async function fetchAvatarFile(userInfo) {
|
|
149
|
-
if (!common_1.config.SECURITY_OAUTH2_FETCH_AVATAR_ENABLED || !userInfo.avatarUrl) {
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
152
|
-
try {
|
|
153
|
-
const resp = await fetch(userInfo.avatarUrl, { method: 'GET' });
|
|
154
|
-
if (!resp.ok) {
|
|
155
|
-
common_1.logger.debug({ url: userInfo.avatarUrl, status: resp.status }, 'OAuth2 avatar fetch failed');
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
const mimeType = resp.headers.get('content-type') ?? 'image/jpeg';
|
|
159
|
-
const data = Buffer.from(await resp.arrayBuffer());
|
|
160
|
-
const extension = mimeType.split('/')[1]?.split(';')[0] ?? 'jpg';
|
|
161
|
-
return {
|
|
162
|
-
name: `avatar-${userInfo.id}.${extension}`,
|
|
163
|
-
mimeType,
|
|
164
|
-
size: data.length,
|
|
165
|
-
data
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
catch (e) {
|
|
169
|
-
common_1.logger.debug({ url: userInfo.avatarUrl, err: e }, 'OAuth2 avatar fetch error');
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
@@ -31,6 +31,6 @@ async function fetchMicrosoftUser(accessToken) {
|
|
|
31
31
|
firstName: data.givenName ?? firstName,
|
|
32
32
|
lastName: data.surname ?? lastName,
|
|
33
33
|
// Microsoft Graph serves the photo as authenticated binary content, so it has to be downloaded here.
|
|
34
|
-
avatarFile: await (0, oauth2_util_1.
|
|
34
|
+
avatarFile: await (0, oauth2_util_1.fetchAvatarFile)(`${userInfoUrl}/photo/$value`, data.id, accessToken)
|
|
35
35
|
};
|
|
36
36
|
}
|
|
@@ -6,20 +6,22 @@ import { AvatarFile } from '../../types';
|
|
|
6
6
|
* @param {string} url - The user info endpoint to call.
|
|
7
7
|
* @param {string} accessToken - The access token obtained from the authorization code flow.
|
|
8
8
|
* @param {Record<string, string>} [headers] - Extra request headers required by the provider.
|
|
9
|
-
* @return {Promise<
|
|
9
|
+
* @return {Promise<Object>} A promise resolving to the parsed response body.
|
|
10
10
|
* @throws {HttpError} If the provider responds with a non-2xx status.
|
|
11
11
|
*/
|
|
12
12
|
export declare function fetchUserInfo<T>(providerName: string, url: string, accessToken: string, headers?: Record<string, string>): Promise<T>;
|
|
13
13
|
/**
|
|
14
|
-
* Downloads
|
|
15
|
-
*
|
|
14
|
+
* Downloads the user's avatar image so it can be passed to the `registrationData` and `registrationFiles` callbacks as
|
|
15
|
+
* `avatarFile`. Fetching is opt-in through `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` and best-effort: every reason the
|
|
16
|
+
* image does not arrive is logged and `undefined` is returned, so the registration flow is never blocked by it.
|
|
16
17
|
*
|
|
17
|
-
* @param {string} url - The avatar endpoint to call.
|
|
18
|
-
* @param {string} accessToken - The access token obtained from the authorization code flow.
|
|
18
|
+
* @param {string | undefined} url - The avatar endpoint to call.
|
|
19
19
|
* @param {string} id - The provider's user identifier, used to name the file.
|
|
20
|
-
* @
|
|
20
|
+
* @param {string} [accessToken] - Access token, for providers whose avatar endpoint requires authentication.
|
|
21
|
+
* @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar, or `undefined` when
|
|
22
|
+
* fetching is disabled, no avatar URL is available, or the download fails.
|
|
21
23
|
*/
|
|
22
|
-
export declare function
|
|
24
|
+
export declare function fetchAvatarFile(url: string | undefined, id: string, accessToken?: string): Promise<AvatarFile | undefined>;
|
|
23
25
|
/**
|
|
24
26
|
* Splits a provider's single display name field into a first and last name. Everything after the first whitespace
|
|
25
27
|
* separated word becomes the last name, so multipart surnames are kept intact.
|
|
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.fetchUserInfo = fetchUserInfo;
|
|
37
|
-
exports.
|
|
37
|
+
exports.fetchAvatarFile = fetchAvatarFile;
|
|
38
38
|
exports.splitFullName = splitFullName;
|
|
39
39
|
exports.requireEmail = requireEmail;
|
|
40
40
|
exports.decodeJwtPayload = decodeJwtPayload;
|
|
@@ -51,7 +51,7 @@ const APPLE_TOKEN_AUDIENCE = 'https://appleid.apple.com';
|
|
|
51
51
|
* @param {string} url - The user info endpoint to call.
|
|
52
52
|
* @param {string} accessToken - The access token obtained from the authorization code flow.
|
|
53
53
|
* @param {Record<string, string>} [headers] - Extra request headers required by the provider.
|
|
54
|
-
* @return {Promise<
|
|
54
|
+
* @return {Promise<Object>} A promise resolving to the parsed response body.
|
|
55
55
|
* @throws {HttpError} If the provider responds with a non-2xx status.
|
|
56
56
|
*/
|
|
57
57
|
async function fetchUserInfo(providerName, url, accessToken, headers = {}) {
|
|
@@ -65,24 +65,30 @@ async function fetchUserInfo(providerName, url, accessToken, headers = {}) {
|
|
|
65
65
|
return resp.json();
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* Downloads
|
|
69
|
-
*
|
|
68
|
+
* Downloads the user's avatar image so it can be passed to the `registrationData` and `registrationFiles` callbacks as
|
|
69
|
+
* `avatarFile`. Fetching is opt-in through `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` and best-effort: every reason the
|
|
70
|
+
* image does not arrive is logged and `undefined` is returned, so the registration flow is never blocked by it.
|
|
70
71
|
*
|
|
71
|
-
* @param {string} url - The avatar endpoint to call.
|
|
72
|
-
* @param {string} accessToken - The access token obtained from the authorization code flow.
|
|
72
|
+
* @param {string | undefined} url - The avatar endpoint to call.
|
|
73
73
|
* @param {string} id - The provider's user identifier, used to name the file.
|
|
74
|
-
* @
|
|
74
|
+
* @param {string} [accessToken] - Access token, for providers whose avatar endpoint requires authentication.
|
|
75
|
+
* @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar, or `undefined` when
|
|
76
|
+
* fetching is disabled, no avatar URL is available, or the download fails.
|
|
75
77
|
*/
|
|
76
|
-
async function
|
|
78
|
+
async function fetchAvatarFile(url, id, accessToken) {
|
|
77
79
|
if (!common_1.config.SECURITY_OAUTH2_FETCH_AVATAR_ENABLED) {
|
|
78
80
|
return undefined;
|
|
79
81
|
}
|
|
82
|
+
if (!url) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
80
85
|
try {
|
|
81
86
|
const resp = await fetch(url, {
|
|
82
87
|
method: 'GET',
|
|
83
|
-
headers: { authorization: `Bearer ${accessToken}` }
|
|
88
|
+
headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {}
|
|
84
89
|
});
|
|
85
90
|
if (!resp.ok) {
|
|
91
|
+
common_1.logger.error({ url, status: resp.status }, 'OAuth2 avatar fetch failed');
|
|
86
92
|
return undefined;
|
|
87
93
|
}
|
|
88
94
|
const mimeType = resp.headers.get('content-type') ?? 'image/jpeg';
|
|
@@ -95,7 +101,8 @@ async function fetchAuthenticatedAvatar(url, accessToken, id) {
|
|
|
95
101
|
data
|
|
96
102
|
};
|
|
97
103
|
}
|
|
98
|
-
catch {
|
|
104
|
+
catch (e) {
|
|
105
|
+
common_1.logger.error({ url, err: e }, 'OAuth2 avatar fetch error');
|
|
99
106
|
return undefined;
|
|
100
107
|
}
|
|
101
108
|
}
|
|
@@ -45,7 +45,8 @@ class DatabaseSecurityStore extends common_1.SecurityStore {
|
|
|
45
45
|
}
|
|
46
46
|
/** @internal */
|
|
47
47
|
async removeOneTimeToken(id) {
|
|
48
|
-
|
|
48
|
+
// The generated client types the id after the configured primary key
|
|
49
|
+
await this._db.client().oneTimeToken.delete({ where: { id: id } });
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
exports.DatabaseSecurityStore = DatabaseSecurityStore;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Multipart, MultipartFile } from '@fastify/multipart';
|
|
2
|
-
import { ContentStream, Resource, ResourceClient } from '@appweaver/common';
|
|
3
|
-
import { File } from '../types';
|
|
2
|
+
import { ContentStream, Resource, ResourceClient, ResourceId } from '@appweaver/common';
|
|
3
|
+
import { File, FileBuffer } from '../types';
|
|
4
4
|
export type FileStream = {
|
|
5
5
|
content: ContentStream;
|
|
6
6
|
fileName: string;
|
|
@@ -41,6 +41,21 @@ export declare class FileService {
|
|
|
41
41
|
* @throws {HttpError} Throws an error if file validation, storage, or resource association fails.
|
|
42
42
|
*/
|
|
43
43
|
saveFile(data: MultipartFile, resource: Resource, client: ResourceClient): Promise<File>;
|
|
44
|
+
/**
|
|
45
|
+
* Saves an in-memory file to storage and associates it with a file field of a specific resource. Behaves exactly like
|
|
46
|
+
* {@link saveFile}, except the content is taken from a buffer instead of a multipart upload, which makes it usable
|
|
47
|
+
* outside a file upload request, for an avatar downloaded from an OAuth2 provider or an image the application
|
|
48
|
+
* generates itself.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} fieldName The file field of the resource the file is saved to.
|
|
51
|
+
* @param {FileBuffer} file The file content, along with its original name and media type.
|
|
52
|
+
* @param {Resource} resource The resource object with which the file is being associated.
|
|
53
|
+
* @param {ResourceClient} client The database client responsible for handling the resource.
|
|
54
|
+
* @return {Promise<File>} A promise that resolves to the saved file object or rejects with an error if the operation
|
|
55
|
+
* fails.
|
|
56
|
+
* @throws {HttpError} Throws an error if file validation, storage, or resource association fails.
|
|
57
|
+
*/
|
|
58
|
+
saveBuffer(fieldName: string, file: FileBuffer, resource: Resource, client: ResourceClient): Promise<File>;
|
|
44
59
|
/**
|
|
45
60
|
* Saves files received as an asynchronous iterable of multipart data.
|
|
46
61
|
*
|
|
@@ -85,8 +100,8 @@ export declare class FileService {
|
|
|
85
100
|
* setting (or set to `'keep'`) are left untouched.
|
|
86
101
|
*
|
|
87
102
|
* @param {string} resourceName - The resource model name.
|
|
88
|
-
* @param {
|
|
103
|
+
* @param {ResourceId} id - The ID of the deleted resource.
|
|
89
104
|
* @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
|
|
90
105
|
*/
|
|
91
|
-
deleteResourceFiles(resourceName: string,
|
|
106
|
+
deleteResourceFiles(resourceName: string, id: ResourceId): Promise<File[]>;
|
|
92
107
|
}
|