@fonoster/identity 0.26.0 → 0.30.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 (119) hide show
  1. package/README.md +25 -0
  2. package/dist/allowList.d.ts +4 -0
  3. package/dist/allowList.js +7 -1
  4. package/dist/apikeys/createCreateApiKey.js +16 -0
  5. package/dist/apikeys/createDeleteApiKey.js +22 -2
  6. package/dist/apikeys/createListApiKeys.js +14 -0
  7. package/dist/apikeys/createRegenerateApiKey.js +23 -2
  8. package/dist/config/index.d.ts +20 -0
  9. package/dist/config/index.js +36 -0
  10. package/dist/config/publishedKeys.d.ts +9 -0
  11. package/dist/config/publishedKeys.js +56 -0
  12. package/dist/config/schemas.d.ts +392 -0
  13. package/dist/config/schemas.js +163 -0
  14. package/dist/config/toIdentityConfig.d.ts +19 -0
  15. package/dist/config/toIdentityConfig.js +93 -0
  16. package/dist/constants.d.ts +28 -0
  17. package/dist/constants.js +29 -1
  18. package/dist/exchanges/createExchangeApiKey.js +4 -1
  19. package/dist/exchanges/createExchangeCredentials.d.ts +6 -1
  20. package/dist/exchanges/createExchangeCredentials.js +20 -2
  21. package/dist/exchanges/createExchangeOauth2Code.js +25 -2
  22. package/dist/exchanges/createExchangeRefreshToken.d.ts +9 -1
  23. package/dist/exchanges/createExchangeRefreshToken.js +57 -13
  24. package/dist/exchanges/createExchangeVerificationCode.d.ts +35 -0
  25. package/dist/exchanges/createExchangeVerificationCode.js +95 -0
  26. package/dist/exchanges/createRevokeToken.d.ts +33 -0
  27. package/dist/exchanges/createRevokeToken.js +64 -0
  28. package/dist/exchanges/exchangeTokens.d.ts +3 -1
  29. package/dist/exchanges/exchangeTokens.js +23 -5
  30. package/dist/exchanges/index.d.ts +2 -0
  31. package/dist/exchanges/index.js +2 -0
  32. package/dist/exchanges/payloads/users/createGetIdTokenPayload.js +3 -2
  33. package/dist/exchanges/refreshTokens.d.ts +30 -0
  34. package/dist/exchanges/refreshTokens.js +101 -0
  35. package/dist/exchanges/types.d.ts +53 -2
  36. package/dist/generated/@prisma/client/edge.js +32 -9
  37. package/dist/generated/@prisma/client/index-browser.js +24 -1
  38. package/dist/generated/@prisma/client/index.d.ts +3233 -417
  39. package/dist/generated/@prisma/client/index.js +34 -11
  40. package/dist/generated/@prisma/client/{libquery_engine-darwin-arm64.dylib.node → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
  41. package/dist/generated/@prisma/client/package.json +1 -1
  42. package/dist/generated/@prisma/client/schema.prisma +47 -7
  43. package/dist/generated/@prisma/client/wasm.js +32 -9
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/server/config.d.ts +286 -55
  47. package/dist/server/config.js +74 -108
  48. package/dist/server/index.js +5 -3
  49. package/dist/service.d.ts +6 -0
  50. package/dist/service.js +11 -4
  51. package/dist/users/createCreateUser.d.ts +6 -1
  52. package/dist/users/createCreateUser.js +17 -9
  53. package/dist/users/createCreateUserWithOauth2Code.js +19 -15
  54. package/dist/users/createResetPassword.d.ts +5 -0
  55. package/dist/users/createResetPassword.js +33 -7
  56. package/dist/users/createSendResetPasswordCode.d.ts +6 -0
  57. package/dist/users/createSendResetPasswordCode.js +33 -1
  58. package/dist/users/createUpdateUser.d.ts +7 -0
  59. package/dist/users/createUpdateUser.js +41 -8
  60. package/dist/users/createUserFromGitHub.d.ts +40 -0
  61. package/dist/users/createUserFromGitHub.js +27 -0
  62. package/dist/users/index.d.ts +1 -0
  63. package/dist/users/index.js +1 -0
  64. package/dist/users/migratePasswordStorage.d.ts +7 -0
  65. package/dist/users/migratePasswordStorage.js +47 -0
  66. package/dist/users/types.d.ts +3 -1
  67. package/dist/users/upsertDefaultUser.js +6 -14
  68. package/dist/utils/authMethods.d.ts +31 -0
  69. package/dist/utils/authMethods.js +44 -0
  70. package/dist/utils/claimUnverifiedAccount.d.ts +35 -0
  71. package/dist/utils/claimUnverifiedAccount.js +43 -0
  72. package/dist/utils/clientAddress.d.ts +26 -0
  73. package/dist/utils/clientAddress.js +9 -0
  74. package/dist/utils/createGenerateVerificationCode.d.ts +4 -0
  75. package/dist/utils/createGenerateVerificationCode.js +12 -2
  76. package/dist/utils/createGenerateWorkspaceInviteToken.js +3 -0
  77. package/dist/utils/createGetApiKeyByAccessKeyId.d.ts +5 -1
  78. package/dist/utils/createGetApiKeyByAccessKeyId.js +5 -0
  79. package/dist/utils/createGetUserByAccessKeyId.d.ts +3 -1
  80. package/dist/utils/createGetUserByEmail.d.ts +3 -1
  81. package/dist/utils/createGetUserByGitHubEmails.d.ts +41 -0
  82. package/dist/utils/createGetUserByGitHubEmails.js +31 -0
  83. package/dist/utils/createGetUserByWorkspaceAccessKeyId.d.ts +3 -1
  84. package/dist/utils/createIsValidVerificationCode.d.ts +5 -0
  85. package/dist/utils/createIsValidVerificationCode.js +34 -7
  86. package/dist/utils/createSendEmail.d.ts +2 -1
  87. package/dist/utils/createSendEmail.js +17 -0
  88. package/dist/utils/createUpdateMembershipStatus.js +10 -3
  89. package/dist/utils/generateOneTimePassword.d.ts +3 -0
  90. package/dist/utils/generateOneTimePassword.js +25 -0
  91. package/dist/utils/getGitHubUserWithOauth2Code.d.ts +20 -2
  92. package/dist/utils/getGitHubUserWithOauth2Code.js +41 -9
  93. package/dist/utils/isWorkspaceAdminCall.d.ts +8 -0
  94. package/dist/utils/isWorkspaceAdminCall.js +47 -0
  95. package/dist/utils/normalizeContact.d.ts +22 -0
  96. package/dist/utils/normalizeContact.js +28 -0
  97. package/dist/utils/passwords.d.ts +11 -0
  98. package/dist/utils/passwords.js +84 -0
  99. package/dist/utils/rateLimiter.d.ts +20 -0
  100. package/dist/utils/rateLimiter.js +108 -0
  101. package/dist/utils/signInThrottle.d.ts +47 -0
  102. package/dist/utils/signInThrottle.js +90 -0
  103. package/dist/utils/userPasswords.d.ts +25 -0
  104. package/dist/utils/userPasswords.js +98 -0
  105. package/dist/verification/createSendVerificationCode.js +27 -1
  106. package/dist/verification/createVerifyCode.js +10 -0
  107. package/dist/workspaces/createCreateWorkspace.d.ts +7 -1
  108. package/dist/workspaces/createCreateWorkspace.js +41 -5
  109. package/dist/workspaces/createDeleteWorkspace.d.ts +2 -1
  110. package/dist/workspaces/createDeleteWorkspace.js +16 -2
  111. package/dist/workspaces/createInviteUserToWorkspace.js +19 -17
  112. package/dist/workspaces/createResendWorkspaceMembershipInvitation.js +17 -2
  113. package/dist/workspaces/createUpdateWorkspace.js +18 -4
  114. package/dist/workspaces/createWorkspaceDirectory.d.ts +29 -0
  115. package/dist/workspaces/createWorkspaceDirectory.js +42 -0
  116. package/dist/workspaces/index.d.ts +1 -0
  117. package/dist/workspaces/index.js +1 -0
  118. package/package.json +7 -6
  119. package/dist/generated/@prisma/client/runtime/wasm.js +0 -35
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.VERIFICATION_CODE_EXPIRATION = exports.SIGN_ALGORITHM = void 0;
3
+ exports.REFRESH_TOKEN_REUSE_GRACE_MS = exports.SIGN_IN_FAILURE_LIMITS = exports.RESET_PASSWORD_ATTEMPT_LIMITS = exports.VERIFICATION_CODE_SEND_LIMITS = exports.VERIFICATION_CODE_MAX_ATTEMPTS = exports.VERIFICATION_CODE_EXPIRATION = exports.SIGN_ALGORITHM = void 0;
4
4
  /**
5
5
  * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
6
6
  * http://github.com/fonoster/fonoster
@@ -21,3 +21,31 @@ exports.VERIFICATION_CODE_EXPIRATION = exports.SIGN_ALGORITHM = void 0;
21
21
  */
22
22
  exports.SIGN_ALGORITHM = "RS256";
23
23
  exports.VERIFICATION_CODE_EXPIRATION = 5 * 60 * 1000;
24
+ /** Wrong guesses allowed before a code stops working. */
25
+ exports.VERIFICATION_CODE_MAX_ATTEMPTS = 5;
26
+ /** Codes sent per email, and per client IP, within the window. */
27
+ exports.VERIFICATION_CODE_SEND_LIMITS = {
28
+ perEmail: 3,
29
+ perIp: 20,
30
+ windowMs: 10 * 60 * 1000
31
+ };
32
+ /** ResetPassword attempts per email, and per client IP, within the window. */
33
+ exports.RESET_PASSWORD_ATTEMPT_LIMITS = {
34
+ perEmail: 10,
35
+ perIp: 30,
36
+ windowMs: 10 * 60 * 1000
37
+ };
38
+ /**
39
+ * Failed sign-ins (ExchangeCredentials, ExchangeVerificationCode) per email,
40
+ * and per client IP, before sign-in is refused for the rest of the window.
41
+ */
42
+ exports.SIGN_IN_FAILURE_LIMITS = {
43
+ perEmail: 10,
44
+ perIp: 50,
45
+ windowMs: 15 * 60 * 1000
46
+ };
47
+ /**
48
+ * How long a just-used refresh token may be used again (concurrent renewals,
49
+ * e.g. two browser tabs). Later reuse revokes the token's family.
50
+ */
51
+ exports.REFRESH_TOKEN_REUSE_GRACE_MS = 30 * 1000;
@@ -73,7 +73,10 @@ function createExchangeApiKey(prisma, identityConfig) {
73
73
  const { accessKeyId, accessKeySecret } = request;
74
74
  logger.verbose("call to exchangeApiKey", { accessKeyId });
75
75
  const key = yield (0, createGetApiKeyByAccessKeyId_1.createGetApiKeyByAccessKeyId)(prisma)(accessKeyId);
76
- if ((key === null || key === void 0 ? void 0 : key.accessKeySecret) !== (accessKeySecret === null || accessKeySecret === void 0 ? void 0 : accessKeySecret.trim())) {
76
+ // An expired key gets the same answer as a wrong secret.
77
+ if (!key ||
78
+ key.accessKeySecret !== (accessKeySecret === null || accessKeySecret === void 0 ? void 0 : accessKeySecret.trim()) ||
79
+ (0, createGetApiKeyByAccessKeyId_1.isApiKeyExpired)(key)) {
77
80
  return callback({
78
81
  code: grpc.status.PERMISSION_DENIED,
79
82
  message: "Invalid credentials"
@@ -18,8 +18,13 @@
18
18
  */
19
19
  import { GrpcErrorMessage } from "@fonoster/common";
20
20
  import { Prisma } from "../db";
21
+ import { SignInThrottle } from "../utils/signInThrottle";
21
22
  import { IdentityConfig } from "./types";
22
- declare function createExchangeCredentials(prisma: Prisma, identityConfig: IdentityConfig): (call: {
23
+ /**
24
+ * Password sign-in. Failed attempts are throttled per email and per client IP
25
+ * (see createSignInThrottle).
26
+ */
27
+ declare function createExchangeCredentials(prisma: Prisma, identityConfig: IdentityConfig, throttle?: SignInThrottle): (call: {
23
28
  request: unknown;
24
29
  }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
25
30
  export { createExchangeCredentials };
@@ -64,19 +64,36 @@ exports.createExchangeCredentials = createExchangeCredentials;
64
64
  const common_1 = require("@fonoster/common");
65
65
  const logger_1 = require("@fonoster/logger");
66
66
  const grpc = __importStar(require("@grpc/grpc-js"));
67
+ const authMethods_1 = require("../utils/authMethods");
68
+ const clientAddress_1 = require("../utils/clientAddress");
67
69
  const createGetUserByEmail_1 = require("../utils/createGetUserByEmail");
68
70
  const createIsValidVerificationCode_1 = require("../utils/createIsValidVerificationCode");
71
+ const signInThrottle_1 = require("../utils/signInThrottle");
72
+ const userPasswords_1 = require("../utils/userPasswords");
69
73
  const verification_1 = require("../verification");
70
74
  const exchangeTokens_1 = require("./exchangeTokens");
71
75
  const logger = (0, logger_1.getLogger)({ service: "identity", filePath: __filename });
72
- function createExchangeCredentials(prisma, identityConfig) {
76
+ /**
77
+ * Password sign-in. Failed attempts are throttled per email and per client IP
78
+ * (see createSignInThrottle).
79
+ */
80
+ function createExchangeCredentials(prisma, identityConfig, throttle = (0, signInThrottle_1.createSignInThrottle)()) {
73
81
  const isValidVerificationCode = (0, createIsValidVerificationCode_1.createIsValidVerificationCode)(prisma);
82
+ const verifyUserPassword = (0, userPasswords_1.createVerifyUserPassword)(prisma);
74
83
  const exchangeCredentials = (call, callback) => __awaiter(this, void 0, void 0, function* () {
75
84
  const { request } = call;
76
85
  const { username: email, password, twoFactorCode } = request;
77
86
  logger.verbose("call to exchangeCredentials", { username: email });
87
+ if (!(0, authMethods_1.isMethodEnabled)(identityConfig, "password")) {
88
+ return callback((0, authMethods_1.methodDisabledError)("password"));
89
+ }
90
+ const ip = (0, clientAddress_1.clientAddress)(call);
91
+ if (throttle.isBlocked(email, ip)) {
92
+ return callback(signInThrottle_1.tooManySignInAttempts);
93
+ }
78
94
  const user = yield (0, createGetUserByEmail_1.createGetUserByEmail)(prisma)(email);
79
- if (!user || user.password !== (password === null || password === void 0 ? void 0 : password.trim())) {
95
+ if (!user || !(yield verifyUserPassword(user, password === null || password === void 0 ? void 0 : password.trim()))) {
96
+ throttle.recordFailure(email, ip);
80
97
  return callback({
81
98
  code: grpc.status.PERMISSION_DENIED,
82
99
  message: "Invalid credentials"
@@ -91,6 +108,7 @@ function createExchangeCredentials(prisma, identityConfig) {
91
108
  code: twoFactorCode
92
109
  });
93
110
  if (!isValid) {
111
+ throttle.recordFailure(email, ip);
94
112
  return callback({
95
113
  code: grpc.status.PERMISSION_DENIED,
96
114
  message: "Invalid 2FA code"
@@ -64,21 +64,44 @@ exports.createExchangeOauth2Code = createExchangeOauth2Code;
64
64
  const common_1 = require("@fonoster/common");
65
65
  const logger_1 = require("@fonoster/logger");
66
66
  const grpc = __importStar(require("@grpc/grpc-js"));
67
- const createGetUserByEmail_1 = require("../utils/createGetUserByEmail");
67
+ const createUserFromGitHub_1 = require("../users/createUserFromGitHub");
68
+ const authMethods_1 = require("../utils/authMethods");
69
+ const claimUnverifiedAccount_1 = require("../utils/claimUnverifiedAccount");
70
+ const createGetUserByGitHubEmails_1 = require("../utils/createGetUserByGitHubEmails");
68
71
  const getGitHubUserWithOauth2Code_1 = require("../utils/getGitHubUserWithOauth2Code");
69
72
  const exchangeTokens_1 = require("./exchangeTokens");
70
73
  const logger = (0, logger_1.getLogger)({ service: "identity", filePath: __filename });
71
74
  function createExchangeOauth2Code(prisma, identityConfig) {
72
75
  const exchangeOauth2Code = (call, callback) => __awaiter(this, void 0, void 0, function* () {
76
+ var _a, _b;
73
77
  const { request } = call;
74
78
  const { provider, code } = request;
75
79
  logger.verbose("call to exchangeOauth2Code", { provider });
80
+ if (!(0, authMethods_1.isMethodEnabled)(identityConfig, "github") ||
81
+ !identityConfig.githubOauth2Config) {
82
+ return callback((0, authMethods_1.methodDisabledError)("github"));
83
+ }
76
84
  const userData = yield (0, getGitHubUserWithOauth2Code_1.getGitHubUserWithOauth2Code)({
77
85
  clientId: identityConfig.githubOauth2Config.clientId,
78
86
  clientSecret: identityConfig.githubOauth2Config.clientSecret,
87
+ url: identityConfig.githubOauth2Config.url,
88
+ apiUrl: identityConfig.githubOauth2Config.apiUrl,
79
89
  code
80
90
  });
81
- const user = yield (0, createGetUserByEmail_1.createGetUserByEmail)(prisma)(userData.email);
91
+ if (!userData.email) {
92
+ return callback({
93
+ code: grpc.status.PERMISSION_DENIED,
94
+ message: "Your GitHub account has no verified primary email"
95
+ });
96
+ }
97
+ // First GitHub sign-in creates the account when sign-up is open; invited
98
+ // users already exist (the invite created them). GitHub verified the
99
+ // email, so an existing account whose email was never verified loses its
100
+ // password (M2).
101
+ const existing = yield (0, createGetUserByGitHubEmails_1.createGetUserByGitHubEmails)(prisma)((_a = userData.verifiedEmails) !== null && _a !== void 0 ? _a : [userData.email]);
102
+ const user = (_b = (existing && (yield (0, claimUnverifiedAccount_1.createClaimUnverifiedAccount)(prisma)(existing)))) !== null && _b !== void 0 ? _b : ((0, authMethods_1.isOpenSignup)(identityConfig)
103
+ ? yield (0, createUserFromGitHub_1.createUserFromGitHub)(prisma)(Object.assign(Object.assign({}, userData), { email: userData.email }))
104
+ : null);
82
105
  if (!user) {
83
106
  return callback({
84
107
  code: grpc.status.PERMISSION_DENIED,
@@ -19,7 +19,15 @@
19
19
  import { GrpcErrorMessage } from "@fonoster/common";
20
20
  import { Prisma } from "../db";
21
21
  import { IdentityConfig } from "./types";
22
+ /**
23
+ * The claims of a refresh token this Identity issued (RS256, its issuer and
24
+ * audience, `tokenUse: "refresh"`, with a `jti`), or undefined.
25
+ */
26
+ declare function verifyRefreshToken(token: string, identityConfig: IdentityConfig): {
27
+ jti: string;
28
+ accessKeyId: string;
29
+ };
22
30
  declare function createExchangeRefreshToken(prisma: Prisma, identityConfig: IdentityConfig): (call: {
23
31
  request: unknown;
24
32
  }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
25
- export { createExchangeRefreshToken };
33
+ export { createExchangeRefreshToken, verifyRefreshToken };
@@ -8,11 +8,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  step((generator = generator.apply(thisArg, _arguments || [])).next());
9
9
  });
10
10
  };
11
- var __importDefault = (this && this.__importDefault) || function (mod) {
12
- return (mod && mod.__esModule) ? mod : { "default": mod };
13
- };
14
11
  Object.defineProperty(exports, "__esModule", { value: true });
15
12
  exports.createExchangeRefreshToken = createExchangeRefreshToken;
13
+ exports.verifyRefreshToken = verifyRefreshToken;
16
14
  /**
17
15
  * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
18
16
  * http://github.com/fonoster/fonoster
@@ -33,21 +31,67 @@ exports.createExchangeRefreshToken = createExchangeRefreshToken;
33
31
  */
34
32
  const common_1 = require("@fonoster/common");
35
33
  const logger_1 = require("@fonoster/logger");
36
- const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
37
- const constants_1 = require("../constants");
34
+ const grpc_js_1 = require("@grpc/grpc-js");
35
+ const createGetApiKeyByAccessKeyId_1 = require("../utils/createGetApiKeyByAccessKeyId");
38
36
  const exchangeTokens_1 = require("./exchangeTokens");
37
+ const refreshTokens_1 = require("./refreshTokens");
39
38
  const logger = (0, logger_1.getLogger)({ service: "identity", filePath: __filename });
39
+ const invalidRefreshToken = {
40
+ code: grpc_js_1.status.UNAUTHENTICATED,
41
+ message: "Invalid refresh token"
42
+ };
43
+ /**
44
+ * The claims of a refresh token this Identity issued (RS256, its issuer and
45
+ * audience, `tokenUse: "refresh"`, with a `jti`), or undefined.
46
+ */
47
+ function verifyRefreshToken(token, identityConfig) {
48
+ const claims = (0, common_1.verifyToken)(token, identityConfig.publicKey, {
49
+ issuer: identityConfig.issuer,
50
+ audience: identityConfig.audience
51
+ });
52
+ if (!claims ||
53
+ claims.tokenUse !== common_1.TokenUseEnum.REFRESH ||
54
+ typeof claims.jti !== "string" ||
55
+ typeof claims.accessKeyId !== "string") {
56
+ return undefined;
57
+ }
58
+ return claims;
59
+ }
40
60
  function createExchangeRefreshToken(prisma, identityConfig) {
61
+ const refreshTokens = (0, refreshTokens_1.createRefreshTokenStore)(prisma);
62
+ const getApiKey = (0, createGetApiKeyByAccessKeyId_1.createGetApiKeyByAccessKeyId)(prisma);
63
+ /** The user or API key still exists (and the key hasn't expired). */
64
+ const principalIsActive = (accessKeyId) => __awaiter(this, void 0, void 0, function* () {
65
+ if (accessKeyId.startsWith("US")) {
66
+ return (yield prisma.user.findFirst({ where: { accessKeyId } })) !== null;
67
+ }
68
+ const key = yield getApiKey(accessKeyId);
69
+ return key !== null && !(0, createGetApiKeyByAccessKeyId_1.isApiKeyExpired)(key);
70
+ });
41
71
  const exchangeRefreshToken = (call, callback) => __awaiter(this, void 0, void 0, function* () {
42
- const { privateKey } = identityConfig;
43
- const { request } = call;
44
- const { refreshToken: oldRefreshToken } = request;
45
- const oldRefreshTokenDecoded = jsonwebtoken_1.default.verify(oldRefreshToken, privateKey, {
46
- algorithms: [constants_1.SIGN_ALGORITHM]
47
- });
48
- const { accessKeyId } = oldRefreshTokenDecoded;
72
+ const claims = verifyRefreshToken(call.request.refreshToken, identityConfig);
73
+ if (!claims) {
74
+ return callback(invalidRefreshToken);
75
+ }
76
+ const { jti, accessKeyId } = claims;
49
77
  logger.verbose("call to exchangeRefreshToken", { accessKeyId });
50
- callback(null, yield (0, exchangeTokens_1.exchangeTokens)(prisma, identityConfig)(accessKeyId));
78
+ // Single use: a token seen before (or revoked) revokes its whole family.
79
+ const consumed = yield refreshTokens.consume(jti);
80
+ if (consumed.status !== "ok" || consumed.accessKeyId !== accessKeyId) {
81
+ if (consumed.status === "reused") {
82
+ logger.warn("refresh token reused; its family is revoked", {
83
+ accessKeyId
84
+ });
85
+ }
86
+ return callback(invalidRefreshToken);
87
+ }
88
+ if (!(yield principalIsActive(accessKeyId))) {
89
+ yield refreshTokens.revokeFamily(consumed.familyId);
90
+ return callback(invalidRefreshToken);
91
+ }
92
+ callback(null, yield (0, exchangeTokens_1.exchangeTokens)(prisma, identityConfig)(accessKeyId, {
93
+ familyId: consumed.familyId
94
+ }));
51
95
  });
52
96
  return (0, common_1.withErrorHandlingAndValidation)(exchangeRefreshToken, common_1.exchangeRefreshTokenRequestSchema);
53
97
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
3
+ * http://github.com/fonoster/fonoster
4
+ *
5
+ * This file is part of Fonoster
6
+ *
7
+ * Licensed under the MIT License (the "License");
8
+ * you may not use this file except in compliance with
9
+ * the License. You may obtain a copy of the License at
10
+ *
11
+ * https://opensource.org/licenses/MIT
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */
19
+ import { GrpcErrorMessage } from "@fonoster/common";
20
+ import { Prisma } from "../db";
21
+ import { SignInThrottle } from "../utils/signInThrottle";
22
+ import { IdentityConfig } from "./types";
23
+ type ExchangeVerificationCodeRequest = {
24
+ email: string;
25
+ code: string;
26
+ };
27
+ /**
28
+ * Passwordless sign-in: trades the code from SendVerificationCode for tokens.
29
+ * The first sign-in creates the account when sign-up is open; with invite-only
30
+ * sign-up, unknown emails get the same error as a wrong code.
31
+ */
32
+ declare function createExchangeVerificationCode(prisma: Prisma, identityConfig: IdentityConfig, throttle?: SignInThrottle): (call: {
33
+ request: unknown;
34
+ }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
35
+ export { createExchangeVerificationCode, ExchangeVerificationCodeRequest };
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createExchangeVerificationCode = createExchangeVerificationCode;
13
+ /**
14
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
15
+ * http://github.com/fonoster/fonoster
16
+ *
17
+ * This file is part of Fonoster
18
+ *
19
+ * Licensed under the MIT License (the "License");
20
+ * you may not use this file except in compliance with
21
+ * the License. You may obtain a copy of the License at
22
+ *
23
+ * https://opensource.org/licenses/MIT
24
+ *
25
+ * Unless required by applicable law or agreed to in writing, software
26
+ * distributed under the License is distributed on an "AS IS" BASIS,
27
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28
+ * See the License for the specific language governing permissions and
29
+ * limitations under the License.
30
+ */
31
+ const common_1 = require("@fonoster/common");
32
+ const logger_1 = require("@fonoster/logger");
33
+ const grpc_js_1 = require("@grpc/grpc-js");
34
+ const authMethods_1 = require("../utils/authMethods");
35
+ const claimUnverifiedAccount_1 = require("../utils/claimUnverifiedAccount");
36
+ const clientAddress_1 = require("../utils/clientAddress");
37
+ const createGetUserByEmail_1 = require("../utils/createGetUserByEmail");
38
+ const createIsValidVerificationCode_1 = require("../utils/createIsValidVerificationCode");
39
+ const generateAccessKeyId_1 = require("../utils/generateAccessKeyId");
40
+ const signInThrottle_1 = require("../utils/signInThrottle");
41
+ const types_1 = require("../verification/types");
42
+ const exchangeTokens_1 = require("./exchangeTokens");
43
+ const logger = (0, logger_1.getLogger)({ service: "identity", filePath: __filename });
44
+ const invalidCode = {
45
+ code: grpc_js_1.status.PERMISSION_DENIED,
46
+ message: "Invalid verification code"
47
+ };
48
+ /**
49
+ * Passwordless sign-in: trades the code from SendVerificationCode for tokens.
50
+ * The first sign-in creates the account when sign-up is open; with invite-only
51
+ * sign-up, unknown emails get the same error as a wrong code.
52
+ */
53
+ function createExchangeVerificationCode(prisma, identityConfig, throttle = (0, signInThrottle_1.createSignInThrottle)()) {
54
+ const isValidVerificationCode = (0, createIsValidVerificationCode_1.createIsValidVerificationCode)(prisma);
55
+ const getUserByEmail = (0, createGetUserByEmail_1.createGetUserByEmail)(prisma);
56
+ const claimUnverifiedAccount = (0, claimUnverifiedAccount_1.createClaimUnverifiedAccount)(prisma);
57
+ const exchangeVerificationCode = (call, callback) => __awaiter(this, void 0, void 0, function* () {
58
+ const email = call.request.email.trim().toLowerCase();
59
+ logger.verbose("call to exchangeVerificationCode", { email });
60
+ if (!(0, authMethods_1.isMethodEnabled)(identityConfig, "email_code")) {
61
+ return callback((0, authMethods_1.methodDisabledError)("email_code"));
62
+ }
63
+ const ip = (0, clientAddress_1.clientAddress)(call);
64
+ if (throttle.isBlocked(email, ip)) {
65
+ return callback(signInThrottle_1.tooManySignInAttempts);
66
+ }
67
+ const isValid = yield isValidVerificationCode({
68
+ type: types_1.ContactType.EMAIL,
69
+ value: email,
70
+ code: call.request.code
71
+ });
72
+ if (!isValid) {
73
+ throttle.recordFailure(email, ip);
74
+ return callback(invalidCode);
75
+ }
76
+ const existing = yield getUserByEmail(email);
77
+ if (!existing && !(0, authMethods_1.isOpenSignup)(identityConfig)) {
78
+ return callback(invalidCode);
79
+ }
80
+ // The code proves the address, so the email is verified either way. An
81
+ // account whose email was never verified loses its password (M2).
82
+ const user = existing
83
+ ? yield claimUnverifiedAccount(existing)
84
+ : yield prisma.user.create({
85
+ data: {
86
+ name: email.split("@")[0].slice(0, 60),
87
+ email,
88
+ emailVerified: true,
89
+ accessKeyId: (0, generateAccessKeyId_1.generateAccessKeyId)(generateAccessKeyId_1.AccessKeyIdType.USER)
90
+ }
91
+ });
92
+ callback(null, yield (0, exchangeTokens_1.exchangeTokens)(prisma, identityConfig)(user.accessKeyId));
93
+ });
94
+ return (0, common_1.withErrorHandlingAndValidation)(exchangeVerificationCode, common_1.Validators.exchangeVerificationCodeRequestSchema);
95
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
3
+ * http://github.com/fonoster/fonoster
4
+ *
5
+ * This file is part of Fonoster
6
+ *
7
+ * Licensed under the MIT License (the "License");
8
+ * you may not use this file except in compliance with
9
+ * the License. You may obtain a copy of the License at
10
+ *
11
+ * https://opensource.org/licenses/MIT
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */
19
+ import { GrpcErrorMessage } from "@fonoster/common";
20
+ import { Prisma } from "../db";
21
+ import { IdentityConfig } from "./types";
22
+ type RevokeTokenRequest = {
23
+ token: string;
24
+ };
25
+ /**
26
+ * Sign-out: revokes the refresh token and every token rotated from the same
27
+ * sign-in. Holding the refresh token is the authorization, so the method is
28
+ * public. Access tokens already issued stay valid until they expire.
29
+ */
30
+ declare function createRevokeToken(prisma: Prisma, identityConfig: IdentityConfig): (call: {
31
+ request: unknown;
32
+ }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
33
+ export { createRevokeToken, RevokeTokenRequest };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createRevokeToken = createRevokeToken;
13
+ /**
14
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
15
+ * http://github.com/fonoster/fonoster
16
+ *
17
+ * This file is part of Fonoster
18
+ *
19
+ * Licensed under the MIT License (the "License");
20
+ * you may not use this file except in compliance with
21
+ * the License. You may obtain a copy of the License at
22
+ *
23
+ * https://opensource.org/licenses/MIT
24
+ *
25
+ * Unless required by applicable law or agreed to in writing, software
26
+ * distributed under the License is distributed on an "AS IS" BASIS,
27
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28
+ * See the License for the specific language governing permissions and
29
+ * limitations under the License.
30
+ */
31
+ const common_1 = require("@fonoster/common");
32
+ const logger_1 = require("@fonoster/logger");
33
+ const grpc_js_1 = require("@grpc/grpc-js");
34
+ const createExchangeRefreshToken_1 = require("./createExchangeRefreshToken");
35
+ const refreshTokens_1 = require("./refreshTokens");
36
+ const logger = (0, logger_1.getLogger)({ service: "identity", filePath: __filename });
37
+ /**
38
+ * Sign-out: revokes the refresh token and every token rotated from the same
39
+ * sign-in. Holding the refresh token is the authorization, so the method is
40
+ * public. Access tokens already issued stay valid until they expire.
41
+ */
42
+ function createRevokeToken(prisma, identityConfig) {
43
+ const refreshTokens = (0, refreshTokens_1.createRefreshTokenStore)(prisma);
44
+ const revokeToken = (call, callback) => __awaiter(this, void 0, void 0, function* () {
45
+ const claims = (0, createExchangeRefreshToken_1.verifyRefreshToken)(call.request.token, identityConfig);
46
+ if (!claims) {
47
+ return callback({
48
+ code: grpc_js_1.status.UNAUTHENTICATED,
49
+ message: "Invalid refresh token"
50
+ });
51
+ }
52
+ const row = yield prisma.refreshToken.findUnique({
53
+ where: { jti: claims.jti }
54
+ });
55
+ if (row) {
56
+ yield refreshTokens.revokeFamily(row.familyId);
57
+ }
58
+ logger.verbose("refresh token revoked", {
59
+ accessKeyId: claims.accessKeyId
60
+ });
61
+ callback(null, { token: claims.jti });
62
+ });
63
+ return (0, common_1.withErrorHandlingAndValidation)(revokeToken, common_1.Validators.revokeTokenRequestSchema);
64
+ }
@@ -1,6 +1,8 @@
1
1
  import { Prisma } from "../db";
2
2
  import { IdentityConfig } from "./types";
3
- declare function exchangeTokens(prisma: Prisma, identityConfig: IdentityConfig): (accessKeyId: string) => Promise<{
3
+ declare function exchangeTokens(prisma: Prisma, identityConfig: IdentityConfig): (accessKeyId: string, options?: {
4
+ familyId?: string;
5
+ }) => Promise<{
4
6
  idToken: any;
5
7
  accessToken: any;
6
8
  refreshToken: any;
@@ -64,17 +64,28 @@ exports.exchangeTokens = exchangeTokens;
64
64
  * See the License for the specific language governing permissions and
65
65
  * limitations under the License.
66
66
  */
67
+ const crypto_1 = require("crypto");
67
68
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
69
+ const constants_1 = require("../constants");
68
70
  const AK = __importStar(require("./payloads/apikeys"));
69
71
  const US = __importStar(require("./payloads/users"));
70
- const SIGN_ALGORITHM = "RS256";
72
+ const refreshTokens_1 = require("./refreshTokens");
71
73
  // prettier-ignore
72
74
  function exchangeTokens(prisma, identityConfig) {
73
- return (accessKeyId) => __awaiter(this, void 0, void 0, function* () {
75
+ const refreshTokens = (0, refreshTokens_1.createRefreshTokenStore)(prisma);
76
+ /**
77
+ * Issues tokens for `accessKeyId`. The refresh token gets a fresh `jti`,
78
+ * recorded so it can be used once; `familyId` continues a rotation chain
79
+ * (absent for a new sign-in).
80
+ */
81
+ return (accessKeyId_1, ...args_1) => __awaiter(this, [accessKeyId_1, ...args_1], void 0, function* (accessKeyId, options = {}) {
82
+ var _a;
74
83
  const { privateKey, idTokenExpiresIn, accessTokenExpiresIn, refreshTokenExpiresIn } = identityConfig;
75
- const idTokenSignOptions = { algorithm: SIGN_ALGORITHM, expiresIn: idTokenExpiresIn };
76
- const accessTokenSignOptions = { algorithm: SIGN_ALGORITHM, expiresIn: accessTokenExpiresIn };
77
- const refreshTokenSignOptions = { algorithm: SIGN_ALGORITHM, expiresIn: refreshTokenExpiresIn };
84
+ const jti = (0, crypto_1.randomUUID)();
85
+ const familyId = (_a = options.familyId) !== null && _a !== void 0 ? _a : (0, crypto_1.randomUUID)();
86
+ const idTokenSignOptions = { algorithm: constants_1.SIGN_ALGORITHM, expiresIn: idTokenExpiresIn };
87
+ const accessTokenSignOptions = { algorithm: constants_1.SIGN_ALGORITHM, expiresIn: accessTokenExpiresIn };
88
+ const refreshTokenSignOptions = { algorithm: constants_1.SIGN_ALGORITHM, expiresIn: refreshTokenExpiresIn, jwtid: jti };
78
89
  let idToken = null;
79
90
  let accessToken = null;
80
91
  let refreshToken = null;
@@ -92,6 +103,13 @@ function exchangeTokens(prisma, identityConfig) {
92
103
  accessToken = jsonwebtoken_1.default.sign(accessTokenPayload, privateKey, accessTokenSignOptions);
93
104
  refreshToken = jsonwebtoken_1.default.sign(refreshTokenPayload, privateKey, refreshTokenSignOptions);
94
105
  }
106
+ const { exp } = jsonwebtoken_1.default.decode(refreshToken);
107
+ yield refreshTokens.record({
108
+ jti,
109
+ familyId,
110
+ accessKeyId,
111
+ expiresAt: new Date(exp * 1000)
112
+ });
95
113
  return {
96
114
  idToken,
97
115
  accessToken,
@@ -20,4 +20,6 @@ export * from "./createExchangeApiKey";
20
20
  export * from "./createExchangeCredentials";
21
21
  export * from "./createExchangeOauth2Code";
22
22
  export * from "./createExchangeRefreshToken";
23
+ export * from "./createExchangeVerificationCode";
24
+ export * from "./createRevokeToken";
23
25
  export * from "./types";
@@ -36,4 +36,6 @@ __exportStar(require("./createExchangeApiKey"), exports);
36
36
  __exportStar(require("./createExchangeCredentials"), exports);
37
37
  __exportStar(require("./createExchangeOauth2Code"), exports);
38
38
  __exportStar(require("./createExchangeRefreshToken"), exports);
39
+ __exportStar(require("./createExchangeVerificationCode"), exports);
40
+ __exportStar(require("./createRevokeToken"), exports);
39
41
  __exportStar(require("./types"), exports);
@@ -41,7 +41,7 @@ function createGetIdTokenPayload(prisma, identityConfig) {
41
41
  return null;
42
42
  }
43
43
  const { issuer, audience } = identityConfig;
44
- const { ref, email, phoneNumber, emailVerified, phoneNumberVerified } = user;
44
+ const { ref, email, phoneNumber, emailVerified, phoneNumberVerified, mustChangePassword } = user;
45
45
  return {
46
46
  iss: issuer,
47
47
  sub: ref,
@@ -51,7 +51,8 @@ function createGetIdTokenPayload(prisma, identityConfig) {
51
51
  email,
52
52
  emailVerified,
53
53
  phoneNumber,
54
- phoneNumberVerified
54
+ phoneNumberVerified,
55
+ mustChangePassword
55
56
  };
56
57
  });
57
58
  };
@@ -0,0 +1,30 @@
1
+ import { Prisma } from "../db";
2
+ type ConsumeResult = {
3
+ status: "ok";
4
+ familyId: string;
5
+ accessKeyId: string;
6
+ } | {
7
+ status: "reused" | "unknown";
8
+ };
9
+ /**
10
+ * Issued refresh tokens, by `jti`. A token is good for one exchange: using it
11
+ * marks it used and the exchange issues the next token in the same family. A
12
+ * token presented again (or after revocation) revokes the whole family, so a
13
+ * stolen token stops working for the thief and the owner alike. A token used
14
+ * moments ago (REFRESH_TOKEN_REUSE_GRACE_MS) may be used again, so two tabs
15
+ * renewing the same session at once don't sign each other out.
16
+ */
17
+ declare function createRefreshTokenStore(prisma: Prisma): {
18
+ record(token: {
19
+ jti: string;
20
+ familyId: string;
21
+ accessKeyId: string;
22
+ expiresAt: Date;
23
+ }): Promise<void>;
24
+ /** Marks `jti` used, once. A second use revokes its family. */
25
+ consume(jti: string): Promise<ConsumeResult>;
26
+ revokeFamily(familyId: string): Promise<void>;
27
+ /** Signs a user or API key out everywhere (password reset, key rotation). */
28
+ revokeAll(accessKeyId: string): Promise<void>;
29
+ };
30
+ export { createRefreshTokenStore };