@appweaver/core 1.2.1 → 1.3.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.
Files changed (61) hide show
  1. package/app/load-providers.js +1 -0
  2. package/factory/create-service.js +5 -5
  3. package/package.json +2 -2
  4. package/prisma/client/browser.d.ts +5 -0
  5. package/prisma/client/client.d.ts +5 -0
  6. package/prisma/client/internal/class.d.ts +11 -0
  7. package/prisma/client/internal/class.js +4 -4
  8. package/prisma/client/internal/prismaNamespace.d.ts +87 -1
  9. package/prisma/client/internal/prismaNamespace.js +11 -1
  10. package/prisma/client/internal/prismaNamespaceBrowser.d.ts +11 -0
  11. package/prisma/client/internal/prismaNamespaceBrowser.js +11 -1
  12. package/prisma/client/models/ConnectedAccount.d.ts +1089 -0
  13. package/prisma/client/models/ConnectedAccount.js +2 -0
  14. package/prisma/client/models.d.ts +1 -0
  15. package/resources.d.ts +2 -0
  16. package/resources.js +15 -11
  17. package/security/auth-routes.js +2 -2
  18. package/security/auth-schema.d.ts +2 -0
  19. package/security/auth-schema.js +5 -1
  20. package/security/auth-service.d.ts +7 -19
  21. package/security/auth-service.js +37 -32
  22. package/security/auth.js +15 -2
  23. package/security/create-auth-resources.js +15 -0
  24. package/security/helper.d.ts +8 -0
  25. package/security/helper.js +13 -0
  26. package/security/index.d.ts +2 -0
  27. package/security/index.js +2 -0
  28. package/security/oauth2/create-oauth2-plugin.d.ts +19 -4
  29. package/security/oauth2/create-oauth2-plugin.js +66 -23
  30. package/security/oauth2/index.d.ts +6 -0
  31. package/security/oauth2/index.js +6 -0
  32. package/security/oauth2/oauth2-apple.d.ts +10 -0
  33. package/security/oauth2/oauth2-apple.js +56 -0
  34. package/security/oauth2/oauth2-custom.d.ts +6 -4
  35. package/security/oauth2/oauth2-custom.js +6 -8
  36. package/security/oauth2/oauth2-facebook.d.ts +3 -1
  37. package/security/oauth2/oauth2-facebook.js +5 -11
  38. package/security/oauth2/oauth2-github.d.ts +3 -0
  39. package/security/oauth2/oauth2-github.js +47 -0
  40. package/security/oauth2/oauth2-gitlab.d.ts +3 -0
  41. package/security/oauth2/oauth2-gitlab.js +33 -0
  42. package/security/oauth2/oauth2-google.d.ts +3 -1
  43. package/security/oauth2/oauth2-google.js +6 -11
  44. package/security/oauth2/oauth2-linkedin.d.ts +3 -0
  45. package/security/oauth2/oauth2-linkedin.js +28 -0
  46. package/security/oauth2/oauth2-microsoft.d.ts +3 -0
  47. package/security/oauth2/oauth2-microsoft.js +36 -0
  48. package/security/oauth2/oauth2-schema.d.ts +9 -2
  49. package/security/oauth2/oauth2-schema.js +20 -6
  50. package/security/oauth2/oauth2-service.d.ts +58 -0
  51. package/security/oauth2/oauth2-service.js +142 -0
  52. package/security/oauth2/oauth2-util.d.ts +60 -0
  53. package/security/oauth2/oauth2-util.js +206 -0
  54. package/security/oauth2/oauth2-x.d.ts +3 -0
  55. package/security/oauth2/oauth2-x.js +33 -0
  56. package/security/resources/connected-account/model.d.ts +2 -0
  57. package/security/resources/connected-account/model.js +53 -0
  58. package/security/resources/connected-account/service.d.ts +2 -0
  59. package/security/resources/connected-account/service.js +7 -0
  60. package/types/auth.d.ts +23 -5
  61. package/types/generated.d.ts +62 -0
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,4 +1,5 @@
1
1
  export type * from './models/ApiKey';
2
+ export type * from './models/ConnectedAccount';
2
3
  export type * from './models/OneTimeToken';
3
4
  export type * from './models/Permission';
4
5
  export type * from './models/Role';
package/resources.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { default as apiKey } from './security/resources/api-key/model';
2
+ export { default as connectedAccount } from './security/resources/connected-account/model';
2
3
  export { default as oneTimeToken } from './security/resources/one-time-token/model';
3
4
  export { default as role } from './security/resources/role/model';
4
5
  export { default as permission } from './security/resources/permission/model';
5
6
  export { default as seeder } from './seeder/resources/seeder/model';
6
7
  export { default as file } from './storage/resources/file/model';
7
8
  export { default as apiKeyService } from './security/resources/api-key/service';
9
+ export { default as connectedAccountService } from './security/resources/connected-account/service';
8
10
  export { default as apiKeyRoutes } from './security/resources/api-key/routes';
9
11
  export { default as apiKeyPolicy } from './security/resources/api-key/policy';
package/resources.js CHANGED
@@ -3,21 +3,25 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.apiKeyPolicy = exports.apiKeyRoutes = exports.apiKeyService = exports.file = exports.seeder = exports.permission = exports.role = exports.oneTimeToken = exports.apiKey = void 0;
6
+ exports.apiKeyPolicy = exports.apiKeyRoutes = exports.connectedAccountService = exports.apiKeyService = exports.file = exports.seeder = exports.permission = exports.role = exports.oneTimeToken = exports.connectedAccount = exports.apiKey = void 0;
7
7
  var model_1 = require("./security/resources/api-key/model");
8
8
  Object.defineProperty(exports, "apiKey", { enumerable: true, get: function () { return __importDefault(model_1).default; } });
9
- var model_2 = require("./security/resources/one-time-token/model");
10
- Object.defineProperty(exports, "oneTimeToken", { enumerable: true, get: function () { return __importDefault(model_2).default; } });
11
- var model_3 = require("./security/resources/role/model");
12
- Object.defineProperty(exports, "role", { enumerable: true, get: function () { return __importDefault(model_3).default; } });
13
- var model_4 = require("./security/resources/permission/model");
14
- Object.defineProperty(exports, "permission", { enumerable: true, get: function () { return __importDefault(model_4).default; } });
15
- var model_5 = require("./seeder/resources/seeder/model");
16
- Object.defineProperty(exports, "seeder", { enumerable: true, get: function () { return __importDefault(model_5).default; } });
17
- var model_6 = require("./storage/resources/file/model");
18
- Object.defineProperty(exports, "file", { enumerable: true, get: function () { return __importDefault(model_6).default; } });
9
+ var model_2 = require("./security/resources/connected-account/model");
10
+ Object.defineProperty(exports, "connectedAccount", { enumerable: true, get: function () { return __importDefault(model_2).default; } });
11
+ var model_3 = require("./security/resources/one-time-token/model");
12
+ Object.defineProperty(exports, "oneTimeToken", { enumerable: true, get: function () { return __importDefault(model_3).default; } });
13
+ var model_4 = require("./security/resources/role/model");
14
+ Object.defineProperty(exports, "role", { enumerable: true, get: function () { return __importDefault(model_4).default; } });
15
+ var model_5 = require("./security/resources/permission/model");
16
+ Object.defineProperty(exports, "permission", { enumerable: true, get: function () { return __importDefault(model_5).default; } });
17
+ var model_6 = require("./seeder/resources/seeder/model");
18
+ Object.defineProperty(exports, "seeder", { enumerable: true, get: function () { return __importDefault(model_6).default; } });
19
+ var model_7 = require("./storage/resources/file/model");
20
+ Object.defineProperty(exports, "file", { enumerable: true, get: function () { return __importDefault(model_7).default; } });
19
21
  var service_1 = require("./security/resources/api-key/service");
20
22
  Object.defineProperty(exports, "apiKeyService", { enumerable: true, get: function () { return __importDefault(service_1).default; } });
23
+ var service_2 = require("./security/resources/connected-account/service");
24
+ Object.defineProperty(exports, "connectedAccountService", { enumerable: true, get: function () { return __importDefault(service_2).default; } });
21
25
  var routes_1 = require("./security/resources/api-key/routes");
22
26
  Object.defineProperty(exports, "apiKeyRoutes", { enumerable: true, get: function () { return __importDefault(routes_1).default; } });
23
27
  var policy_1 = require("./security/resources/api-key/policy");
@@ -63,8 +63,8 @@ function authRoutes(server) {
63
63
  }
64
64
  }
65
65
  }, async (request, reply) => {
66
- const { token } = request.body;
67
- const authResponse = await authService.exchangeToken(token);
66
+ const { token, password } = request.body;
67
+ const authResponse = await authService.exchangeToken(token, password);
68
68
  return reply.send(authResponse);
69
69
  });
70
70
  const authUserModel = (0, helper_1.resourceAuthModel)();
@@ -19,6 +19,7 @@ export declare const LogoutResponse: TObject<{
19
19
  }>;
20
20
  export declare const ExchangeTokenRequest: TObject<{
21
21
  token: import("@sinclair/typebox").TString;
22
+ password: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
22
23
  }>;
23
24
  export declare const loginSchema: {
24
25
  tags: string[];
@@ -208,6 +209,7 @@ export declare const exchangeTokenSchema: {
208
209
  };
209
210
  body: TObject<{
210
211
  token: import("@sinclair/typebox").TString;
212
+ password: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
211
213
  }>;
212
214
  };
213
215
  export declare function authSchema(authTypes?: AuthType[]): any[];
@@ -30,7 +30,11 @@ exports.LogoutResponse = typebox_1.Type.Object({
30
30
  success: typebox_1.Type.Boolean({ example: true })
31
31
  }, { $id: 'LogoutResponse' });
32
32
  exports.ExchangeTokenRequest = typebox_1.Type.Object({
33
- token: typebox_1.Type.String({ example: 'aBcDeFgHijkLMnO123456789' })
33
+ token: typebox_1.Type.String({ example: 'aBcDeFgHijkLMnO123456789' }),
34
+ password: typebox_1.Type.Optional(typebox_1.Type.String({
35
+ description: 'Account password, required when the OAuth2 redirect returned `passwordRequired=true`',
36
+ example: 'yourPassword123!'
37
+ }))
34
38
  }, { $id: 'ExchangeTokenRequest' });
35
39
  exports.loginSchema = {
36
40
  tags: ['Auth'],
@@ -1,5 +1,5 @@
1
1
  import { AuthScope, AuthSource, AuthUser, RouteConfig } from '@appweaver/common';
2
- import { AuthTokens, JwtPayload, UserAdditionalData, UserInfo } from '../types';
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.
@@ -40,21 +40,6 @@ export declare class AuthService {
40
40
  * @throws {HttpError} Throws an error if the registration process fails.
41
41
  */
42
42
  registerAuthUser(source: AuthSource, email: string, password?: string, data?: Partial<UserAdditionalData>): Promise<AuthUser>;
43
- /**
44
- * Checks whether a user is allowed to be registered and/or authenticated via OAuth2 by invoking the optional
45
- * `checkOAuth2User` callback configured on the auth service. When the callback returns nothing, the OAuth2 flow
46
- * proceeds normally (registration of a new user or login of an existing one). When it returns a string or an error,
47
- * the flow is aborted by throwing an `HttpError`.
48
- *
49
- * @param {AuthSource} source - The OAuth2 authentication source, e.g., oauth2Google, oauth2Facebook, oauth2Custom.
50
- * @param {UserInfo} userInfo - The user info extracted from the OAuth2 provider.
51
- * @param {AuthUser | null} authUser - The existing authenticated user matched by email, or null when the user does
52
- * not exist yet (i.e., a new user would be registered).
53
- * @return {Promise<void>} A promise that resolves when the user is allowed to proceed.
54
- * @throws {HttpError} If the configured callback returns a string or an error (status 403 unless an `HttpError` is
55
- * returned, in which case it is thrown as-is).
56
- */
57
- checkOAuth2User(source: AuthSource, userInfo: UserInfo, authUser: AuthUser | null): Promise<void>;
58
43
  /**
59
44
  * Changes the password for the authenticated user.
60
45
  *
@@ -99,13 +84,16 @@ export declare class AuthService {
99
84
  */
100
85
  login(username: string, password: string): Promise<AuthTokens>;
101
86
  /**
102
- * Exchanges an existing token for new authentication tokens.
87
+ * Exchanges an existing token for new authentication tokens. When the one-time token was issued for an OAuth2
88
+ * sign-in that needs confirming, the account password must be supplied alongside it.
103
89
  *
104
90
  * @param {string} token - The token to be exchanged for new authentication tokens.
91
+ * @param {string} [password] - The account password, required when the token was flagged for confirmation.
105
92
  * @return {Promise<AuthTokens>} A promise that resolves to a set of new authentication tokens.
106
- * @throws {HttpError} If the token is invalid, expired, or associated with a non-existent or disabled user.
93
+ * @throws {HttpError} If the token is invalid, expired, associated with a non-existent or disabled user, or the
94
+ * required password confirmation is missing or wrong.
107
95
  */
108
- exchangeToken(token: string): Promise<AuthTokens>;
96
+ exchangeToken(token: string, password?: string): Promise<AuthTokens>;
109
97
  /**
110
98
  * Generates authentication tokens (access and refresh tokens) for a given authenticated user.
111
99
  *
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AuthService = void 0;
4
4
  const common_1 = require("@appweaver/common");
5
5
  const helper_1 = require("./helper");
6
+ const oauth2_service_1 = require("./oauth2/oauth2-service");
6
7
  const context_1 = require("../context");
7
8
  const cache_1 = require("../cache");
8
9
  const errors_1 = require("../errors");
@@ -14,6 +15,9 @@ class AuthService {
14
15
  _cacheService = (0, context_1.inject)(cache_1.CacheService);
15
16
  /** @internal */
16
17
  _authUserService = (0, helper_1.resourceAuthService)();
18
+ /** Optional, since an application with no OAuth2 provider never registers it.
19
+ * @internal */
20
+ _oauth2Service = (0, context_1.inject)(oauth2_service_1.OAuth2Service, false);
17
21
  /**
18
22
  * Finds an authenticated user by their unique identifier.
19
23
  *
@@ -114,34 +118,6 @@ class AuthService {
114
118
  throw new errors_1.HttpError('Auth user registration error', 500, e);
115
119
  }
116
120
  }
117
- /**
118
- * Checks whether a user is allowed to be registered and/or authenticated via OAuth2 by invoking the optional
119
- * `checkOAuth2User` callback configured on the auth service. When the callback returns nothing, the OAuth2 flow
120
- * proceeds normally (registration of a new user or login of an existing one). When it returns a string or an error,
121
- * the flow is aborted by throwing an `HttpError`.
122
- *
123
- * @param {AuthSource} source - The OAuth2 authentication source, e.g., oauth2Google, oauth2Facebook, oauth2Custom.
124
- * @param {UserInfo} userInfo - The user info extracted from the OAuth2 provider.
125
- * @param {AuthUser | null} authUser - The existing authenticated user matched by email, or null when the user does
126
- * not exist yet (i.e., a new user would be registered).
127
- * @return {Promise<void>} A promise that resolves when the user is allowed to proceed.
128
- * @throws {HttpError} If the configured callback returns a string or an error (status 403 unless an `HttpError` is
129
- * returned, in which case it is thrown as-is).
130
- */
131
- async checkOAuth2User(source, userInfo, authUser) {
132
- const serviceConfig = this._authUserService[common_1.CONFIG];
133
- if (!serviceConfig.checkOAuth2User) {
134
- return;
135
- }
136
- const result = await serviceConfig.checkOAuth2User(source, userInfo, authUser);
137
- if (!result) {
138
- return;
139
- }
140
- if (result instanceof errors_1.HttpError) {
141
- throw result;
142
- }
143
- throw new errors_1.HttpError(result instanceof Error ? result.message : result, 403, result instanceof Error ? result : undefined);
144
- }
145
121
  /**
146
122
  * Changes the password for the authenticated user.
147
123
  *
@@ -236,21 +212,50 @@ class AuthService {
236
212
  return this.generateAuthTokens(authUser);
237
213
  }
238
214
  /**
239
- * Exchanges an existing token for new authentication tokens.
215
+ * Exchanges an existing token for new authentication tokens. When the one-time token was issued for an OAuth2
216
+ * sign-in that needs confirming, the account password must be supplied alongside it.
240
217
  *
241
218
  * @param {string} token - The token to be exchanged for new authentication tokens.
219
+ * @param {string} [password] - The account password, required when the token was flagged for confirmation.
242
220
  * @return {Promise<AuthTokens>} A promise that resolves to a set of new authentication tokens.
243
- * @throws {HttpError} If the token is invalid, expired, or associated with a non-existent or disabled user.
221
+ * @throws {HttpError} If the token is invalid, expired, associated with a non-existent or disabled user, or the
222
+ * required password confirmation is missing or wrong.
244
223
  */
245
- async exchangeToken(token) {
246
- const { authUserId, authSource } = await this._securityStore.useOneTimeToken(token, common_1.AuthOTTPurpose.Authentication);
224
+ async exchangeToken(token, password) {
225
+ const { authUserId, authSource, providerAccountId, scope, passwordRequired } = await this._securityStore.useOneTimeToken(token, common_1.AuthOTTPurpose.Authentication);
247
226
  const authUser = await this.findById(authUserId);
248
227
  if (!authUser || !authUser.enabled) {
249
228
  throw new errors_1.HttpError('Auth user does not exist or is disabled', 400);
250
229
  }
230
+ if (passwordRequired) {
231
+ await this.confirmPassword(authUser, password);
232
+ }
233
+ if (providerAccountId) {
234
+ await this._oauth2Service?.linkConnectedAccount(authUser, authSource, providerAccountId, scope);
235
+ // The provider confirmed the address, and ownership of the local account was proven by the password whenever it
236
+ // had one, so an address that was never verified locally is settled by this sign-in
237
+ if (!authUser.verifiedEmail) {
238
+ await this.updateAuthUser(authUser.id, { verifiedEmail: true });
239
+ }
240
+ }
251
241
  common_1.logger.debug({ id: authUser.id, token }, 'User token exchanged');
252
242
  return this.generateAuthTokens(authUser, common_1.AuthScope.Auth, authSource);
253
243
  }
244
+ /**
245
+ * Verifies the account password supplied to confirm linking an OAuth2 provider.
246
+ *
247
+ * @internal
248
+ */
249
+ async confirmPassword(authUser, password) {
250
+ if (!password) {
251
+ throw new errors_1.HttpError('Password confirmation is required to link this provider account', 401);
252
+ }
253
+ if (!authUser.passwordHash ||
254
+ !(await (0, helper_1.checkPassword)(password, authUser.passwordHash))) {
255
+ common_1.logger.debug({ id: authUser.id }, 'OAuth2 password confirmation rejected');
256
+ throw new errors_1.HttpError('Invalid user credentials', 401);
257
+ }
258
+ }
254
259
  /**
255
260
  * Generates authentication tokens (access and refresh tokens) for a given authenticated user.
256
261
  *
package/security/auth.js CHANGED
@@ -48,6 +48,17 @@ const basic_1 = require("./basic");
48
48
  const api_key_1 = require("./api-key");
49
49
  const jwt_1 = require("./jwt");
50
50
  const oauth2Plugins = __importStar(require("./oauth2"));
51
+ const PLUGIN_META = Symbol.for('plugin-meta');
52
+ /**
53
+ * Tells the fastify plugins exported from the oauth2 directory apart from the user info extractors the same modules
54
+ * export, using the metadata `fastify-plugin` attaches to every plugin it wraps.
55
+ *
56
+ * @param {unknown} value - The exported value to check.
57
+ * @return {boolean} Whether the value is a fastify plugin.
58
+ */
59
+ function isFastifyPlugin(value) {
60
+ return typeof value === 'function' && PLUGIN_META in value;
61
+ }
51
62
  exports.default = (0, fastify_plugin_1.default)((server) => {
52
63
  server.register(auth_1.default);
53
64
  if (common_1.config.SECURITY_RECAPTCHA_ENABLED) {
@@ -62,8 +73,10 @@ exports.default = (0, fastify_plugin_1.default)((server) => {
62
73
  server.register(jwt_1.jwtAuth);
63
74
  // Load and register all plugins exported from oauth2 dir, plugin-enabled
64
75
  // status and required config values are checked at plugin initialization
65
- for (const oauth2Plugin of Object.values(oauth2Plugins)) {
66
- server.register(oauth2Plugin);
76
+ for (const exported of Object.values(oauth2Plugins)) {
77
+ if (isFastifyPlugin(exported)) {
78
+ server.register(exported);
79
+ }
67
80
  }
68
81
  server.register(auth_routes_1.authRoutes, { prefix: common_1.config.SECURITY_ROUTE_PREFIX });
69
82
  server.register(account_1.accountRoutes, {
@@ -85,6 +85,21 @@ function createAuthModel(config) {
85
85
  }
86
86
  }
87
87
  }
88
+ : {}),
89
+ ...((0, helper_1.isOAuth2Enabled)()
90
+ ? {
91
+ connectedAccounts: {
92
+ model: 'ConnectedAccount',
93
+ type: 'oneToMany',
94
+ mappedBy: (0, common_1.uncapitalize)(config.name),
95
+ input: {
96
+ type: 'none'
97
+ },
98
+ output: {
99
+ type: 'none'
100
+ }
101
+ }
102
+ }
88
103
  : {})
89
104
  };
90
105
  const authModelInputOmit = ['verifiedEmail', 'logoutAt'];
@@ -71,6 +71,14 @@ export declare function updatePasswordHash(authUser: Partial<AuthUser>, password
71
71
  * @return {ValidationResult} An object with the validation result and message.
72
72
  */
73
73
  export declare function validatePasswordComplexity(password: string): ValidationResult;
74
+ /**
75
+ * Checks whether at least one OAuth2 provider is enabled, by resolving each OAuth2 auth source to its
76
+ * `SECURITY_OAUTH2_<PROVIDER>_ENABLED` config flag. New providers are picked up automatically as long as they follow
77
+ * that naming convention.
78
+ *
79
+ * @return {boolean} True when any OAuth2 provider is enabled.
80
+ */
81
+ export declare function isOAuth2Enabled(): boolean;
74
82
  /**
75
83
  * Validates a URL and checks if it's allowed based on the configured list of allowed hosts.
76
84
  *
@@ -42,6 +42,7 @@ exports.hashPassword = hashPassword;
42
42
  exports.checkPassword = checkPassword;
43
43
  exports.updatePasswordHash = updatePasswordHash;
44
44
  exports.validatePasswordComplexity = validatePasswordComplexity;
45
+ exports.isOAuth2Enabled = isOAuth2Enabled;
45
46
  exports.validateRedirectUrl = validateRedirectUrl;
46
47
  exports.checkScopeAccess = checkScopeAccess;
47
48
  exports.hasRole = hasRole;
@@ -203,6 +204,18 @@ function validatePasswordComplexity(password) {
203
204
  }
204
205
  return { valid: true, message: 'OK' };
205
206
  }
207
+ /**
208
+ * Checks whether at least one OAuth2 provider is enabled, by resolving each OAuth2 auth source to its
209
+ * `SECURITY_OAUTH2_<PROVIDER>_ENABLED` config flag. New providers are picked up automatically as long as they follow
210
+ * that naming convention.
211
+ *
212
+ * @return {boolean} True when any OAuth2 provider is enabled.
213
+ */
214
+ function isOAuth2Enabled() {
215
+ return Object.values(common_1.AuthSource)
216
+ .filter((source) => source.startsWith('oauth2'))
217
+ .some((source) => common_1.config[`SECURITY_OAUTH2_${source.replace('oauth2', '').toUpperCase()}_ENABLED`] === true);
218
+ }
206
219
  /**
207
220
  * Validates a URL and checks if it's allowed based on the configured list of allowed hosts.
208
221
  *
@@ -1,4 +1,6 @@
1
1
  export * from './oauth2/create-oauth2-plugin';
2
+ export * from './oauth2/oauth2-service';
3
+ export * from './oauth2/oauth2-util';
2
4
  export * from './auth-service';
3
5
  export * from './auth-schema';
4
6
  export * from './helper';
package/security/index.js CHANGED
@@ -15,6 +15,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./oauth2/create-oauth2-plugin"), exports);
18
+ __exportStar(require("./oauth2/oauth2-service"), exports);
19
+ __exportStar(require("./oauth2/oauth2-util"), exports);
18
20
  __exportStar(require("./auth-service"), exports);
19
21
  __exportStar(require("./auth-schema"), exports);
20
22
  __exportStar(require("./helper"), exports);
@@ -1,12 +1,27 @@
1
+ import { ProviderConfiguration } from '@fastify/oauth2';
1
2
  import { AuthSource } from '@appweaver/common';
2
- import { Server, UserInfo } from '../../types';
3
- export type { UserInfo };
3
+ import { OAuth2TokenSet, OAuth2UserInfoContext, Server, UserInfo } from '../../types';
4
+ export type { UserInfo, OAuth2TokenSet, OAuth2UserInfoContext };
4
5
  export type OAuth2Config = {
5
6
  enabled: boolean;
6
7
  clientId?: string;
7
- clientSecret?: string;
8
+ /** A function is resolved lazily, only once the provider is known to be enabled. */
9
+ clientSecret?: string | (() => string | undefined);
8
10
  issuer?: string;
9
11
  scope: string[];
10
- extractUserInfo: (accessToken: string) => Promise<UserInfo>;
12
+ /** Provider endpoints. Defaults to the `@fastify/oauth2` preset matching the auth source name. */
13
+ auth?: ProviderConfiguration;
14
+ /** Human-readable provider name used in the generated OpenAPI schema. Defaults to the auth source name. */
15
+ displayName?: string;
16
+ /** Enables the PKCE extension, required by providers such as X. */
17
+ pkce?: 'S256' | 'plain';
18
+ /** Provider posts the authorization response as a form body instead of query parameters (`response_mode=form_post`). */
19
+ formPostCallback?: boolean;
20
+ /** `User-Agent` sent with the token request. Some providers reject the default one. */
21
+ userAgent?: string;
22
+ /** How client credentials are sent to the token endpoint. Defaults to `header` (HTTP Basic); providers such as
23
+ * Apple and LinkedIn only accept them in the request body. */
24
+ authorizationMethod?: 'header' | 'body';
25
+ extractUserInfo: (accessToken: string, context: OAuth2UserInfoContext) => Promise<UserInfo>;
11
26
  };
12
27
  export declare function createOAuth2Plugin(authSource: AuthSource, oAuth2Config: OAuth2Config): (server: Server) => Promise<void>;
@@ -10,45 +10,64 @@ const common_1 = require("@appweaver/common");
10
10
  const context_1 = require("../../context");
11
11
  const errors_1 = require("../../errors");
12
12
  const auth_service_1 = require("../auth-service");
13
+ const oauth2_service_1 = require("./oauth2-service");
13
14
  const helper_1 = require("../helper");
14
15
  const oauth2_schema_1 = require("./oauth2-schema");
15
16
  function createOAuth2Plugin(authSource, oAuth2Config) {
16
17
  const name = authSource.replace('oauth2', '');
17
18
  const upperName = name.toUpperCase();
18
19
  const lowerName = name.toLowerCase();
20
+ const displayName = oAuth2Config.displayName ?? name;
19
21
  return (0, fastify_plugin_1.default)(async (server) => {
20
22
  if (!oAuth2Config.enabled) {
21
23
  return;
22
24
  }
23
- if (!oAuth2Config.clientId || !oAuth2Config.clientSecret) {
25
+ const clientSecret = typeof oAuth2Config.clientSecret === 'function'
26
+ ? oAuth2Config.clientSecret()
27
+ : oAuth2Config.clientSecret;
28
+ if (!oAuth2Config.clientId || !clientSecret) {
24
29
  throw Error(`${name} OAuth2 configuration is missing`);
25
30
  }
26
31
  if (server[authSource] !== undefined) {
27
32
  throw Error(`${name} OAuth2 provider is already registered`);
28
33
  }
29
- const authConfig = oauth2_1.default[`${upperName}_CONFIGURATION`];
34
+ const authConfig = oAuth2Config.auth ?? oauth2_1.default[`${upperName}_CONFIGURATION`];
30
35
  if (!authConfig && !oAuth2Config.issuer) {
31
36
  throw Error(`${name} OAuth2 provider is not supported`);
32
37
  }
33
38
  const authService = (0, context_1.inject)(auth_service_1.AuthService);
39
+ const oauth2Service = (0, context_1.inject)(oauth2_service_1.OAuth2Service);
34
40
  const securityStore = (0, context_1.inject)(common_1.SecurityStore);
35
41
  const prefix = common_1.config.SECURITY_ROUTE_PREFIX.replace(/\/$/, '');
42
+ const callbackPath = `${prefix}/login/${lowerName}/callback`;
43
+ // With `response_mode=form_post` the authorization response arrives in the request body, while
44
+ // @fastify/oauth2 always reads the `code` and `state` from the query string.
45
+ const authResponse = (request) => (oAuth2Config.formPostCallback
46
+ ? request.body
47
+ : request.query) ?? {};
36
48
  server.register(oauth2_1.default, {
37
49
  name: authSource,
38
50
  credentials: {
39
51
  client: {
40
52
  id: oAuth2Config.clientId,
41
- secret: oAuth2Config.clientSecret
53
+ secret: clientSecret
42
54
  },
43
- auth: authConfig
55
+ auth: authConfig,
56
+ ...(oAuth2Config.authorizationMethod
57
+ ? {
58
+ options: { authorizationMethod: oAuth2Config.authorizationMethod }
59
+ }
60
+ : {})
44
61
  },
45
62
  ...(oAuth2Config.issuer
46
63
  ? { discovery: { issuer: oAuth2Config.issuer } }
47
64
  : {}),
48
65
  scope: oAuth2Config.scope,
49
- schema: (0, oauth2_schema_1.createOAuth2RedirectSchema)(name),
66
+ schema: (0, oauth2_schema_1.createOAuth2RedirectSchema)(displayName),
50
67
  startRedirectPath: `${prefix}/login/${lowerName}`,
51
- callbackUri: `${common_1.config.APP_HOSTNAME}${prefix}/login/${lowerName}/callback`,
68
+ callbackUri: `${common_1.config.APP_HOSTNAME}${callbackPath}`,
69
+ ...(oAuth2Config.pkce ? { pkce: oAuth2Config.pkce } : {}),
70
+ ...(oAuth2Config.userAgent ? { userAgent: oAuth2Config.userAgent } : {}),
52
71
  generateStateFunction: async (request) => {
53
72
  const redirectToUrl = request.query.redirectToUrl;
54
73
  const result = (0, helper_1.validateRedirectUrl)(redirectToUrl);
@@ -58,39 +77,63 @@ function createOAuth2Plugin(authSource, oAuth2Config) {
58
77
  return securityStore.generateOneTimeToken(common_1.AuthOTTPurpose.OAuth2State, { redirectToUrl }, common_1.config.SECURITY_OAUTH2_STATE_TTL);
59
78
  },
60
79
  checkStateFunction: async (request) => {
61
- const state = request.query.state;
80
+ const state = authResponse(request).state;
62
81
  request.oauth2State =
63
82
  await securityStore.useOneTimeToken(state, common_1.AuthOTTPurpose.OAuth2State);
64
83
  return true;
65
84
  }
66
85
  });
67
- server.get(`${prefix}/login/${lowerName}/callback`, {
68
- schema: (0, oauth2_schema_1.createOAuth2CallbackSchema)(name)
69
- }, async function (request, reply) {
86
+ const callbackHandler = async function (request, reply) {
70
87
  const { token } = await server[authSource].getAccessTokenFromAuthorizationCodeFlow(request);
71
- const userInfo = await oAuth2Config.extractUserInfo(token.access_token);
88
+ const userInfo = await oAuth2Config.extractUserInfo(token.access_token, {
89
+ token: token,
90
+ request
91
+ });
72
92
  let authUser = await authService.findByUsername(userInfo.email);
73
- await authService.checkOAuth2User(authSource, userInfo, authUser);
93
+ await oauth2Service.checkUser(authSource, userInfo, authUser);
94
+ // Decided before the user is (possibly) registered, so a freshly created account is never asked to confirm
95
+ const passwordRequired = !!authUser &&
96
+ (await oauth2Service.requiresPasswordConfirmation(authUser, authSource, userInfo.id));
74
97
  if (!authUser) {
75
98
  if (!common_1.config.SECURITY_OAUTH2_REGISTRATION_ENABLED) {
76
99
  throw new errors_1.HttpError('Auth user does not exist and OAuth2 registration is disabled', 403);
77
100
  }
78
101
  authUser = await authService.registerAuthUser(authSource, userInfo.email, undefined, {
79
- ...(0, common_1.pickProperties)(userInfo, [
80
- 'firstName',
81
- 'lastName',
82
- 'avatarUrl'
83
- ]),
84
- avatarFile: await fetchAvatarFile(userInfo)
102
+ ...(0, common_1.pickProperties)(userInfo, ['firstName', 'lastName', 'avatarUrl']),
103
+ avatarFile: userInfo.avatarFile ?? (await fetchAvatarFile(userInfo))
85
104
  });
86
105
  }
87
- else if (!authUser.verifiedEmail) {
88
- throw new errors_1.HttpError('Auth user email address is not verified', 403);
89
- }
90
106
  const stateData = request.oauth2State;
91
- const ott = await securityStore.generateOneTimeToken(common_1.AuthOTTPurpose.Authentication, { authUserId: authUser.id, authSource }, common_1.config.SECURITY_AUTH_OTT_TTL);
107
+ const ott = await securityStore.generateOneTimeToken(common_1.AuthOTTPurpose.Authentication, {
108
+ authUserId: authUser.id,
109
+ authSource,
110
+ providerAccountId: userInfo.id,
111
+ scope: token.scope ?? oAuth2Config.scope.join(' '),
112
+ passwordRequired
113
+ }, common_1.config.SECURITY_AUTH_OTT_TTL);
92
114
  const separator = stateData.redirectToUrl.includes('?') ? '&' : '?';
93
- return reply.redirect(`${stateData.redirectToUrl}${separator}token=${ott}`);
115
+ // Tell the client upfront so it can collect the password before spending the single-use token
116
+ const confirmation = passwordRequired ? '&passwordRequired=true' : '';
117
+ return reply.redirect(`${stateData.redirectToUrl}${separator}token=${ott}${confirmation}`);
118
+ };
119
+ if (!oAuth2Config.formPostCallback) {
120
+ server.get(callbackPath, { schema: (0, oauth2_schema_1.createOAuth2CallbackSchema)(displayName) }, callbackHandler);
121
+ return;
122
+ }
123
+ // Register the form-post callback in an encapsulated scope so its urlencoded body parser and
124
+ // the query rewrite below stay local to this single route.
125
+ await server.register(async (scope) => {
126
+ if (!scope.hasContentTypeParser('application/x-www-form-urlencoded')) {
127
+ scope.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body))));
128
+ }
129
+ scope.post(callbackPath, {
130
+ schema: (0, oauth2_schema_1.createOAuth2CallbackSchema)(displayName, true),
131
+ preHandler: async (request) => {
132
+ // @fastify/oauth2 reads the authorization code off the query string only.
133
+ const { code, state } = authResponse(request);
134
+ request.query = { ...request.query, code, state };
135
+ }
136
+ }, callbackHandler);
94
137
  });
95
138
  });
96
139
  }
@@ -1,3 +1,9 @@
1
1
  export * from './oauth2-custom';
2
+ export * from './oauth2-apple';
2
3
  export * from './oauth2-facebook';
4
+ export * from './oauth2-github';
5
+ export * from './oauth2-gitlab';
3
6
  export * from './oauth2-google';
7
+ export * from './oauth2-linkedin';
8
+ export * from './oauth2-microsoft';
9
+ export * from './oauth2-x';
@@ -15,5 +15,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./oauth2-custom"), exports);
18
+ __exportStar(require("./oauth2-apple"), exports);
18
19
  __exportStar(require("./oauth2-facebook"), exports);
20
+ __exportStar(require("./oauth2-github"), exports);
21
+ __exportStar(require("./oauth2-gitlab"), exports);
19
22
  __exportStar(require("./oauth2-google"), exports);
23
+ __exportStar(require("./oauth2-linkedin"), exports);
24
+ __exportStar(require("./oauth2-microsoft"), exports);
25
+ __exportStar(require("./oauth2-x"), exports);
@@ -0,0 +1,10 @@
1
+ import { OAuth2UserInfoContext, UserInfo } from '../../types';
2
+ export declare const oauth2Apple: (server: import("../../types").Server) => Promise<void>;
3
+ /**
4
+ * Builds the user info from Apple's token response. Apple has no user info endpoint: the identity is carried by the
5
+ * `id_token`, and the display name is posted alongside the authorization code on the very first authorization only.
6
+ *
7
+ * @param {OAuth2UserInfoContext} context - The token set and callback request from the authorization code flow.
8
+ * @return {Promise<UserInfo>} A promise resolving to the extracted user info.
9
+ */
10
+ export declare function extractAppleUser({ token, request }: OAuth2UserInfoContext): Promise<UserInfo>;