@warlock.js/auth 5.12.0 → 5.14.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/CHANGELOG.md +30 -0
- package/README.md +88 -86
- package/esm/commands/auth-cleanup-command.mjs +1 -1
- package/esm/commands/auth-cleanup-command.mjs.map +1 -1
- package/esm/contracts/index.d.mts +2 -1
- package/esm/contracts/providers.d.mts +101 -0
- package/esm/contracts/providers.d.mts.map +1 -0
- package/esm/contracts/types.d.mts +94 -1
- package/esm/contracts/types.d.mts.map +1 -1
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/errors/auth-provider-sdk-missing.error.d.mts +17 -0
- package/esm/errors/auth-provider-sdk-missing.error.d.mts.map +1 -0
- package/esm/errors/auth-provider-sdk-missing.error.mjs +22 -0
- package/esm/errors/auth-provider-sdk-missing.error.mjs.map +1 -0
- package/esm/errors/email-not-verified.error.d.mts +16 -0
- package/esm/errors/email-not-verified.error.d.mts.map +1 -0
- package/esm/errors/email-not-verified.error.mjs +20 -0
- package/esm/errors/email-not-verified.error.mjs.map +1 -0
- package/esm/errors/index.d.mts +7 -0
- package/esm/errors/index.mjs +9 -0
- package/esm/errors/invalid-one-time-token.error.d.mts +17 -0
- package/esm/errors/invalid-one-time-token.error.d.mts.map +1 -0
- package/esm/errors/invalid-one-time-token.error.mjs +21 -0
- package/esm/errors/invalid-one-time-token.error.mjs.map +1 -0
- package/esm/errors/invalid-passkey.error.d.mts +18 -0
- package/esm/errors/invalid-passkey.error.d.mts.map +1 -0
- package/esm/errors/invalid-passkey.error.mjs +23 -0
- package/esm/errors/invalid-passkey.error.mjs.map +1 -0
- package/esm/errors/invalid-provider-callback.error.d.mts +18 -0
- package/esm/errors/invalid-provider-callback.error.d.mts.map +1 -0
- package/esm/errors/invalid-provider-callback.error.mjs +23 -0
- package/esm/errors/invalid-provider-callback.error.mjs.map +1 -0
- package/esm/errors/notifications-unavailable.error.d.mts +15 -0
- package/esm/errors/notifications-unavailable.error.d.mts.map +1 -0
- package/esm/errors/notifications-unavailable.error.mjs +20 -0
- package/esm/errors/notifications-unavailable.error.mjs.map +1 -0
- package/esm/errors/provider-email-not-verified.error.d.mts +16 -0
- package/esm/errors/provider-email-not-verified.error.d.mts.map +1 -0
- package/esm/errors/provider-email-not-verified.error.mjs +20 -0
- package/esm/errors/provider-email-not-verified.error.mjs.map +1 -0
- package/esm/index.d.mts +27 -3
- package/esm/index.mjs +27 -1
- package/esm/middleware/index.d.mts +4 -1
- package/esm/middleware/index.mjs +3 -0
- package/esm/middleware/one-time-token-throttle.middleware.d.mts +36 -0
- package/esm/middleware/one-time-token-throttle.middleware.d.mts.map +1 -0
- package/esm/middleware/one-time-token-throttle.middleware.mjs +53 -0
- package/esm/middleware/one-time-token-throttle.middleware.mjs.map +1 -0
- package/esm/middleware/otp-throttle.middleware.d.mts +30 -0
- package/esm/middleware/otp-throttle.middleware.d.mts.map +1 -0
- package/esm/middleware/otp-throttle.middleware.mjs +45 -0
- package/esm/middleware/otp-throttle.middleware.mjs.map +1 -0
- package/esm/middleware/require-verified-email.middleware.d.mts +18 -0
- package/esm/middleware/require-verified-email.middleware.d.mts.map +1 -0
- package/esm/middleware/require-verified-email.middleware.mjs +24 -0
- package/esm/middleware/require-verified-email.middleware.mjs.map +1 -0
- package/esm/models/access-token/access-token.model.d.mts +8 -0
- package/esm/models/access-token/access-token.model.d.mts.map +1 -1
- package/esm/models/access-token/access-token.model.mjs +3 -0
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/index.d.mts +3 -1
- package/esm/models/index.d.mts.map +1 -1
- package/esm/models/index.mjs +16 -1
- package/esm/models/index.mjs.map +1 -1
- package/esm/models/one-time-token/index.d.mts +1 -0
- package/esm/models/one-time-token/index.mjs +3 -0
- package/esm/models/one-time-token/migration.mjs +31 -0
- package/esm/models/one-time-token/migration.mjs.map +1 -0
- package/esm/models/one-time-token/one-time-token.model.d.mts +173 -0
- package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -0
- package/esm/models/one-time-token/one-time-token.model.mjs +151 -0
- package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -0
- package/esm/models/passkey-credential/index.d.mts +1 -0
- package/esm/models/passkey-credential/index.mjs +3 -0
- package/esm/models/passkey-credential/migration.mjs +25 -0
- package/esm/models/passkey-credential/migration.mjs.map +1 -0
- package/esm/models/passkey-credential/passkey-credential.model.d.mts +108 -0
- package/esm/models/passkey-credential/passkey-credential.model.d.mts.map +1 -0
- package/esm/models/passkey-credential/passkey-credential.model.mjs +70 -0
- package/esm/models/passkey-credential/passkey-credential.model.mjs.map +1 -0
- package/esm/models/provider-account/index.d.mts +1 -0
- package/esm/models/provider-account/index.mjs +3 -0
- package/esm/models/provider-account/migration.mjs +25 -0
- package/esm/models/provider-account/migration.mjs.map +1 -0
- package/esm/models/provider-account/provider-account.model.d.mts +75 -0
- package/esm/models/provider-account/provider-account.model.d.mts.map +1 -0
- package/esm/models/provider-account/provider-account.model.mjs +55 -0
- package/esm/models/provider-account/provider-account.model.mjs.map +1 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts +7 -0
- package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs +3 -0
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/otp/index.d.mts +1 -0
- package/esm/otp/index.mjs +3 -0
- package/esm/otp/otp.d.mts +38 -0
- package/esm/otp/otp.d.mts.map +1 -0
- package/esm/otp/otp.mjs +105 -0
- package/esm/otp/otp.mjs.map +1 -0
- package/esm/passkeys/index.d.mts +3 -0
- package/esm/passkeys/index.mjs +4 -0
- package/esm/passkeys/passkey-authentication.d.mts +33 -0
- package/esm/passkeys/passkey-authentication.d.mts.map +1 -0
- package/esm/passkeys/passkey-authentication.mjs +93 -0
- package/esm/passkeys/passkey-authentication.mjs.map +1 -0
- package/esm/passkeys/passkey-challenges.mjs +53 -0
- package/esm/passkeys/passkey-challenges.mjs.map +1 -0
- package/esm/passkeys/passkey-registration.d.mts +29 -0
- package/esm/passkeys/passkey-registration.d.mts.map +1 -0
- package/esm/passkeys/passkey-registration.mjs +88 -0
- package/esm/passkeys/passkey-registration.mjs.map +1 -0
- package/esm/passkeys/simplewebauthn.d.mts +21 -0
- package/esm/passkeys/simplewebauthn.d.mts.map +1 -0
- package/esm/passkeys/simplewebauthn.mjs +13 -0
- package/esm/passkeys/simplewebauthn.mjs.map +1 -0
- package/esm/providers/google-provider.d.mts +40 -0
- package/esm/providers/google-provider.d.mts.map +1 -0
- package/esm/providers/google-provider.mjs +105 -0
- package/esm/providers/google-provider.mjs.map +1 -0
- package/esm/providers/index.d.mts +2 -0
- package/esm/providers/index.mjs +4 -0
- package/esm/providers/provider-login.d.mts +43 -0
- package/esm/providers/provider-login.d.mts.map +1 -0
- package/esm/providers/provider-login.mjs +86 -0
- package/esm/providers/provider-login.mjs.map +1 -0
- package/esm/providers/provider-state-cookie.mjs +63 -0
- package/esm/providers/provider-state-cookie.mjs.map +1 -0
- package/esm/providers/resolve-provider-user.mjs +45 -0
- package/esm/providers/resolve-provider-user.mjs.map +1 -0
- package/esm/services/auth-config.mjs +72 -0
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth.service.d.mts +18 -1
- package/esm/services/auth.service.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +28 -1
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/services/email-verification.d.mts +35 -0
- package/esm/services/email-verification.d.mts.map +1 -0
- package/esm/services/email-verification.mjs +55 -0
- package/esm/services/email-verification.mjs.map +1 -0
- package/esm/services/index.d.mts +4 -1
- package/esm/services/index.mjs +3 -0
- package/esm/services/one-time-tokens.d.mts +32 -0
- package/esm/services/one-time-tokens.d.mts.map +1 -0
- package/esm/services/one-time-tokens.mjs +56 -0
- package/esm/services/one-time-tokens.mjs.map +1 -0
- package/esm/services/optional-peer.mjs +37 -0
- package/esm/services/optional-peer.mjs.map +1 -0
- package/esm/services/password-reset.d.mts +34 -0
- package/esm/services/password-reset.d.mts.map +1 -0
- package/esm/services/password-reset.mjs +81 -0
- package/esm/services/password-reset.mjs.map +1 -0
- package/esm/services/token-notifications.mjs +87 -0
- package/esm/services/token-notifications.mjs.map +1 -0
- package/esm/utils/auth-error-codes.d.mts +39 -1
- package/esm/utils/auth-error-codes.d.mts.map +1 -1
- package/esm/utils/auth-error-codes.mjs +33 -0
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/esm/utils/index.mjs +1 -0
- package/esm/utils/safe-equal.d.mts +6 -0
- package/esm/utils/safe-equal.d.mts.map +1 -0
- package/esm/utils/safe-equal.mjs +13 -0
- package/esm/utils/safe-equal.mjs.map +1 -0
- package/llms-full.txt +313 -10
- package/llms.txt +2 -0
- package/package.json +21 -7
- package/skills/login-with-providers/SKILL.md +154 -0
- package/skills/manage-tokens/SKILL.md +7 -1
- package/skills/register-user/SKILL.md +6 -8
- package/skills/run-auth-commands/SKILL.md +1 -1
- package/skills/verify-email-and-reset-password/SKILL.md +137 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AccessToken } from "../models/access-token/access-token.model.mjs";
|
|
2
2
|
import "../models/access-token/index.mjs";
|
|
3
|
+
import { OneTimeToken } from "../models/one-time-token/one-time-token.model.mjs";
|
|
4
|
+
import "../models/one-time-token/index.mjs";
|
|
3
5
|
import { RefreshToken } from "../models/refresh-token/refresh-token.model.mjs";
|
|
4
6
|
import "../models/refresh-token/index.mjs";
|
|
5
7
|
import { authConfig } from "./auth-config.mjs";
|
|
@@ -26,6 +28,12 @@ var AuthService = class {
|
|
|
26
28
|
return config.key("auth.refreshToken.model", RefreshToken);
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
31
|
+
* Resolve the active one-time-token model (default or registered override).
|
|
32
|
+
*/
|
|
33
|
+
get oneTimeTokenModel() {
|
|
34
|
+
return config.key("auth.oneTimeToken.model", OneTimeToken);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
29
37
|
* Build the default access-token JWT payload from a user.
|
|
30
38
|
*/
|
|
31
39
|
buildAccessTokenPayload(user) {
|
|
@@ -188,6 +196,23 @@ var AuthService = class {
|
|
|
188
196
|
async login(Model, credentials, deviceInfo) {
|
|
189
197
|
const user = await this.attemptLogin(Model, credentials);
|
|
190
198
|
if (!user) return null;
|
|
199
|
+
return this.finalizeLogin(user, deviceInfo);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Log in a user some OTHER method already authenticated — a provider
|
|
203
|
+
* callback, a passkey assertion, a one-time code. Applies the
|
|
204
|
+
* `auth.canAuthenticate` policy (403 on refusal), then produces exactly
|
|
205
|
+
* what {@link login} produces: the same tokens, rows and events. Pair with
|
|
206
|
+
* {@link setAuthCookie} for a cookie session, as with password login.
|
|
207
|
+
*
|
|
208
|
+
* Never call this for a user you have not authenticated.
|
|
209
|
+
*/
|
|
210
|
+
async completeLogin(user, deviceInfo) {
|
|
211
|
+
await this.assertCanAuthenticate(user);
|
|
212
|
+
return this.finalizeLogin(user, deviceInfo);
|
|
213
|
+
}
|
|
214
|
+
/** The one token-issuing tail every login method shares. */
|
|
215
|
+
async finalizeLogin(user, deviceInfo) {
|
|
191
216
|
if (!authConfig.refreshToken.enabled()) return {
|
|
192
217
|
user,
|
|
193
218
|
tokens: { accessToken: await this.issueAccessToken(user, deviceInfo?.payload) }
|
|
@@ -261,7 +286,8 @@ var AuthService = class {
|
|
|
261
286
|
authEvents.emit("token.familyRevoked", familyId, revokedTokens);
|
|
262
287
|
}
|
|
263
288
|
/**
|
|
264
|
-
* Delete expired tokens (refresh + access)
|
|
289
|
+
* Delete expired tokens (refresh + access) and spent one-time tokens
|
|
290
|
+
* (expired or consumed verification/reset/OTP/passkey rows). Emits `token.expired` per refresh
|
|
265
291
|
* token and `cleanup.completed` with the refresh count. Drives the
|
|
266
292
|
* `auth.cleanup` CLI command.
|
|
267
293
|
*/
|
|
@@ -269,6 +295,7 @@ var AuthService = class {
|
|
|
269
295
|
const expiredTokens = await this.refreshTokenModel.purgeExpired();
|
|
270
296
|
for (const token of expiredTokens) authEvents.emit("token.expired", token);
|
|
271
297
|
await this.accessTokenModel.purgeExpired();
|
|
298
|
+
await this.oneTimeTokenModel.purgeSpent();
|
|
272
299
|
authEvents.emit("cleanup.completed", expiredTokens.length);
|
|
273
300
|
return expiredTokens.length;
|
|
274
301
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.service.mjs","names":[],"sources":["../../../../../../../auth/src/services/auth.service.ts"],"sourcesContent":["import { Random } from \"@mongez/reinforcements\";\nimport type { ChildModel } from \"@warlock.js/cascade\";\nimport {\n config,\n ForbiddenError,\n hashPassword,\n type Response,\n verifyPassword,\n} from \"@warlock.js/core\";\nimport type {\n AccessTokenOutput,\n AuthCredentials,\n ClearAuthCookieOptions,\n DeviceInfo,\n LoginResult,\n SetAuthCookieOptions,\n TokenPair,\n} from \"../contracts/types\";\nimport { AccessToken } from \"../models/access-token\";\nimport type { Auth } from \"../models/auth.model\";\nimport { RefreshToken } from \"../models/refresh-token\";\nimport { authConfig } from \"./auth-config\";\nimport { authEvents } from \"./auth-events\";\nimport { isInvalidCredentialError, jwt } from \"./jwt\";\n\nclass AuthService {\n /**\n * Resolve the active access-token model — the package default, or a subclass\n * an app registered under `config.auth.accessToken.model` (e.g. to add a\n * tenant column). The service never references the concrete class directly so\n * an override is a pure config change.\n */\n private get accessTokenModel(): typeof AccessToken {\n return config.key(\"auth.accessToken.model\", AccessToken);\n }\n\n /**\n * Resolve the active refresh-token model (default or registered override).\n */\n private get refreshTokenModel(): typeof RefreshToken {\n return config.key(\"auth.refreshToken.model\", RefreshToken);\n }\n\n /**\n * Build the default access-token JWT payload from a user.\n */\n public buildAccessTokenPayload(user: Auth): Record<string, unknown> {\n return {\n id: user.id,\n userType: user.userType,\n created_at: Date.now(),\n };\n }\n\n /** One policy seam keeps account-state checks identical across all auth paths. */\n public async canAuthenticate(user: Auth): Promise<boolean> {\n return authConfig.canAuthenticate(user);\n }\n\n private async assertCanAuthenticate(user: Auth): Promise<void> {\n if (!(await this.canAuthenticate(user))) {\n throw new ForbiddenError(\"This user cannot authenticate.\");\n }\n }\n\n private async issueAccessToken(\n user: Auth,\n payload?: Record<string, unknown>,\n ): Promise<AccessTokenOutput> {\n const data = payload || this.buildAccessTokenPayload(user);\n // Validate before signing so an unusable lifetime cannot mint an immortal token.\n const expiresIn = authConfig.accessToken.expiresInMs();\n\n const token = await jwt.generate(data, { expiresIn });\n const expiresAt = new Date(Date.now() + expiresIn);\n\n await this.accessTokenModel.issue(user, token, expiresAt);\n\n return { token, expiresAt: expiresAt.toISOString() };\n }\n\n /**\n * Sign + persist an access token for the user and return the token with its\n * expiry. The expiry is computed locally from `expiresIn` rather than by\n * re-verifying the token we just signed.\n */\n public async generateAccessToken(\n user: Auth,\n payload?: Record<string, unknown>,\n ): Promise<AccessTokenOutput> {\n await this.assertCanAuthenticate(user);\n\n return this.issueAccessToken(user, payload);\n }\n\n private async issueRefreshToken(\n user: Auth,\n deviceInfo?: DeviceInfo,\n ): Promise<RefreshToken | undefined> {\n if (!authConfig.refreshToken.enabled()) return;\n\n // Validate the lifetime first — before the per-user cap is enforced, before\n // anything is signed — so a bad value can never revoke a user's oldest\n // session on its way to failing.\n const expiresIn = authConfig.refreshToken.expiresInMs();\n\n const familyId = deviceInfo?.familyId || Random.string(32);\n\n const payload = {\n userId: user.id,\n userType: user.userType,\n familyId,\n };\n\n const expiresAt = new Date(Date.now() + expiresIn).toISOString();\n\n await this.refreshTokenModel.enforceMax(user, authConfig.refreshToken.maxPerUser());\n\n const token = await jwt.generateRefreshToken(payload, { expiresIn });\n\n return this.refreshTokenModel.issue(user, token, { familyId, expiresAt, deviceInfo });\n }\n\n /**\n * Sign + persist a refresh token for the user (enforcing the per-user cap\n * first). Resolves to `undefined` when refresh tokens are disabled in config.\n */\n public async createRefreshToken(\n user: Auth,\n deviceInfo?: DeviceInfo,\n ): Promise<RefreshToken | undefined> {\n if (!authConfig.refreshToken.enabled()) return;\n\n await this.assertCanAuthenticate(user);\n\n return this.issueRefreshToken(user, deviceInfo);\n }\n\n private async issueTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\n const refreshToken = await this.issueRefreshToken(user, deviceInfo);\n\n const tokenPair: TokenPair = {\n accessToken,\n refreshToken: refreshToken\n ? {\n token: refreshToken.get(\"token\"),\n expiresAt: refreshToken.get(\"expires_at\"),\n }\n : undefined,\n };\n\n authEvents.emit(\"token.created\", user, tokenPair);\n\n if (refreshToken) {\n authEvents.emit(\"session.created\", user, refreshToken, deviceInfo);\n }\n\n return tokenPair;\n }\n\n /**\n * Issue both an access and a refresh token, emitting the creation events.\n */\n public async createTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n await this.assertCanAuthenticate(user);\n\n return this.issueTokenPair(user, deviceInfo);\n }\n\n /**\n * Exchange a refresh token for a new token pair, with rotation + replay\n * detection. A concurrent reuse of the same token loses the atomic revoke and\n * is treated as a breach — the whole family is revoked and the request fails.\n */\n public async refreshTokens(\n refreshTokenString: string,\n deviceInfo?: DeviceInfo,\n ): Promise<TokenPair | null> {\n let decoded:\n | {\n userId: number;\n userType: string;\n familyId: string;\n }\n | null;\n\n try {\n decoded = await jwt.verifyRefreshToken<{\n userId: number;\n userType: string;\n familyId: string;\n }>(refreshTokenString);\n } catch (error) {\n if (isInvalidCredentialError(error)) return null;\n\n throw error;\n }\n\n if (!decoded) return null;\n\n const refreshToken = await this.refreshTokenModel.findByToken(refreshTokenString);\n\n if (!refreshToken?.isValid) {\n // An already-invalid token may be a replay of a rotated credential.\n if (refreshToken) {\n await this.revokeTokenFamily(refreshToken.familyId);\n }\n\n return null;\n }\n\n const UserModel = config.key(`auth.userType.${decoded.userType}`);\n\n if (!UserModel) {\n throw new Error(`User type ${decoded.userType} is unknown type.`);\n }\n\n const user = (await UserModel.find(decoded.userId)) as Auth | null;\n\n if (!user) return null;\n\n if (!(await this.canAuthenticate(user))) return null;\n\n const rotationEnabled = authConfig.refreshToken.rotation();\n\n if (rotationEnabled) {\n const won = await refreshToken.revokeIfActive();\n\n if (!won) {\n // A concurrent request already rotated this token.\n await this.revokeTokenFamily(refreshToken.familyId);\n\n return null;\n }\n } else {\n await refreshToken.markAsUsed();\n }\n\n const newTokenPair = await this.issueTokenPair(user, {\n ...deviceInfo,\n familyId: refreshToken.familyId,\n });\n\n authEvents.emit(\"token.refreshed\", user, newTokenPair, refreshToken);\n\n return newTokenPair;\n }\n\n /**\n * Verify a plaintext password against a stored hash.\n */\n public async verifyPassword(plainPassword: string, hashedPassword: string): Promise<boolean> {\n return verifyPassword(plainPassword, hashedPassword);\n }\n\n /**\n * Hash a plaintext password.\n */\n public async hashPassword(password: string): Promise<string> {\n return hashPassword(password);\n }\n\n /**\n * Resolve a user by credentials, verifying the password. Returns `null` on a\n * missing user or a wrong password, emitting `login.failed` either way.\n */\n public async attemptLogin<T extends Auth>(\n Model: ChildModel<T>,\n data: AuthCredentials,\n ): Promise<T | null> {\n const { password, ...otherData } = data;\n\n authEvents.emit(\"login.attempt\", otherData);\n\n const user = (await Model.first(otherData)) as T | null;\n\n if (!user) {\n authEvents.emit(\"login.failed\", otherData, \"User not found\");\n\n return null;\n }\n\n if (!(await this.verifyPassword(password, user.string(\"password\")!))) {\n authEvents.emit(\"login.failed\", otherData, \"Invalid password\");\n\n return null;\n }\n\n if (!(await this.canAuthenticate(user))) {\n authEvents.emit(\"login.failed\", otherData, \"Authentication not allowed\");\n\n return null;\n }\n\n return user;\n }\n\n /**\n * Full login flow: validate credentials, issue tokens, emit events. Returns\n * the user + token pair on success, `null` on failure.\n */\n public async login<T extends Auth>(\n Model: ChildModel<T>,\n credentials: AuthCredentials,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T> | null> {\n const user = await this.attemptLogin(Model, credentials);\n\n if (!user) {\n return null;\n }\n\n if (!authConfig.refreshToken.enabled()) {\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\n\n return { user, tokens: { accessToken } };\n }\n\n const tokens = await this.issueTokenPair(user, deviceInfo);\n\n authEvents.emit(\"login.success\", user, tokens, deviceInfo);\n\n return { user, tokens };\n }\n\n /**\n * Log a user out.\n *\n * @param accessToken - access token string to revoke (optional)\n * @param refreshToken - refresh token string to revoke (optional)\n *\n * When no refresh token is supplied, `config.auth.refreshToken.logoutWithoutToken`\n * decides the behavior: `\"revoke-all\"` (default, fail-safe) revokes every\n * refresh token; `\"error\"` requires the caller to pass one.\n */\n public async logout(user: Auth, accessToken?: string, refreshToken?: string): Promise<void> {\n if (accessToken) {\n await this.removeAccessToken(user, accessToken);\n }\n\n if (refreshToken) {\n const token = await this.refreshTokenModel.findForUser(user, refreshToken);\n\n if (token) {\n await token.revoke();\n authEvents.emit(\"session.destroyed\", user, token);\n }\n } else {\n const behavior = authConfig.refreshToken.logoutWithoutToken();\n\n if (behavior === \"error\") {\n throw new Error(\"Refresh token required for logout\");\n }\n\n await this.revokeAllTokens(user);\n authEvents.emit(\"logout.failsafe\", user);\n }\n\n authEvents.emit(\"logout\", user);\n }\n\n /**\n * Remove a specific access token belonging to the user.\n */\n public async removeAccessToken(user: Auth, token: string): Promise<void> {\n await this.accessTokenModel.deleteForUser(user, token);\n }\n\n /**\n * Remove every access token belonging to the user.\n */\n public async removeAllAccessTokens(user: Auth): Promise<void> {\n await this.accessTokenModel.deleteAllForUser(user);\n }\n\n /**\n * Remove a specific refresh token belonging to the user.\n */\n public async removeRefreshToken(user: Auth, token: string): Promise<void> {\n await this.refreshTokenModel.deleteForUser(user, token);\n }\n\n /**\n * Revoke every active refresh token for the user and delete their access\n * tokens — \"log out of all devices\". Revocation is a single bulk update; an\n * event fires per revoked token.\n */\n public async revokeAllTokens(user: Auth): Promise<void> {\n const revokedTokens = await this.refreshTokenModel.revokeAllFor(user);\n\n for (const token of revokedTokens) {\n authEvents.emit(\"token.revoked\", user, token);\n }\n\n await this.removeAllAccessTokens(user);\n\n authEvents.emit(\"logout.all\", user);\n }\n\n /**\n * Revoke an entire token family — rotation breach containment.\n */\n public async revokeTokenFamily(familyId: string): Promise<void> {\n const revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);\n\n authEvents.emit(\"token.familyRevoked\", familyId, revokedTokens);\n }\n\n /**\n * Delete expired tokens (refresh + access). Emits `token.expired` per refresh\n * token and `cleanup.completed` with the refresh count. Drives the\n * `auth.cleanup` CLI command.\n */\n public async cleanupExpiredTokens(): Promise<number> {\n const expiredTokens = await this.refreshTokenModel.purgeExpired();\n\n for (const token of expiredTokens) {\n authEvents.emit(\"token.expired\", token);\n }\n\n await this.accessTokenModel.purgeExpired();\n\n authEvents.emit(\"cleanup.completed\", expiredTokens.length);\n\n return expiredTokens.length;\n }\n\n /**\n * Find every persisted token — access and refresh — that can never retire\n * itself: an unusable `expires_at`, or a token carrying no `exp` claim.\n *\n * This is the remediation read for the pre-4.12.0 `expiresIn` defect (#25).\n * Nothing else surfaces these rows: `auth.cleanup` selects `expires_at < now`,\n * which an `Invalid Date` never satisfies, and a row whose date column is\n * perfectly fine can still hold a token with no deadline in it. Read-only —\n * pair with {@link purgeNeverExpiringTokens} to act on the answer.\n */\n public async findNeverExpiringTokens(): Promise<{\n accessTokens: AccessToken[];\n refreshTokens: RefreshToken[];\n }> {\n return {\n accessTokens: await this.accessTokenModel.findNeverExpiring(),\n refreshTokens: await this.refreshTokenModel.findNeverExpiring(),\n };\n }\n\n /**\n * Revoke every never-expiring token by deleting its row, emitting\n * `token.revoked` per refresh token. Drives `warlock auth.purge-never-expiring`.\n *\n * Access tokens first: deleting one is immediate revocation, while a refresh\n * token left in place for a moment can only mint an access token through a\n * verifier that now rejects it for the missing `exp`.\n */\n public async purgeNeverExpiringTokens(): Promise<{\n accessTokens: number;\n refreshTokens: number;\n }> {\n const accessTokens = await this.accessTokenModel.purgeNeverExpiring();\n const refreshTokens = await this.refreshTokenModel.purgeNeverExpiring();\n\n // `token.expired` (the event `auth.cleanup` already emits for a removed\n // refresh token) rather than `token.revoked`, which carries a loaded `Auth`\n // this batch path has no reason to fetch a user row for.\n for (const token of refreshTokens) {\n authEvents.emit(\"token.expired\", token);\n }\n\n return { accessTokens: accessTokens.length, refreshTokens: refreshTokens.length };\n }\n\n /**\n * Active, unexpired sessions for the user, newest first.\n */\n public async getActiveSessions(user: Auth): Promise<RefreshToken[]> {\n return this.refreshTokenModel.activeFor(user);\n }\n\n /**\n * `Max-Age` (seconds) `setAuthCookie` should apply, derived from an\n * `AccessTokenOutput`'s `expiresAt` — or `undefined` for a bare token\n * string (a session cookie) or an unparseable `expiresAt`.\n */\n private cookieMaxAgeFromToken(token: string | AccessTokenOutput): number | undefined {\n if (typeof token === \"string\") return undefined;\n\n const expiresAt = new Date(token.expiresAt).getTime();\n\n if (Number.isNaN(expiresAt)) return undefined;\n\n return Math.max(0, Math.round((expiresAt - Date.now()) / 1000));\n }\n\n /**\n * Write the auth cookie on `response` — the write side of the `cookie:<name>`\n * token source `authMiddleware([], \"cookie:<name>\")` already reads. An explicit\n * app-controller call, never a side effect of {@link login}, so an existing\n * bearer-only app never starts emitting `Set-Cookie` just by upgrading.\n *\n * Attribute flags (`HttpOnly`, `SameSite=Lax`, `Secure` outside dev) come\n * from `response.cookie()`'s own `secureCookieDefaults()` floor — this\n * method never overrides them, only `name`/`path`/`maxAge`. `raw: true`\n * writes the token unquoted, so the same string a `cookie:<name>` source\n * reads back is exactly what was issued, not a JSON-wrapped copy.\n *\n * @param token - the token string, or an `AccessTokenOutput` (e.g. from\n * {@link login}) whose `expiresAt` sets `Max-Age` automatically when\n * `options.maxAge` is not given. A bare string with no `options.maxAge`\n * produces a session cookie.\n *\n * @example\n * const { user, tokens } = await authService.login(User, credentials);\n * authService.setAuthCookie(response, tokens.accessToken);\n */\n public setAuthCookie(\n response: Response,\n token: string | AccessTokenOutput,\n options: SetAuthCookieOptions = {},\n ): void {\n const name = options.name ?? authConfig.cookie.name();\n const path = options.path ?? authConfig.cookie.path();\n const tokenValue = typeof token === \"string\" ? token : token.token;\n const maxAge = options.maxAge ?? this.cookieMaxAgeFromToken(token);\n\n response.cookie(name, tokenValue, {\n raw: true,\n path,\n ...(maxAge !== undefined ? { maxAge } : {}),\n });\n }\n\n /**\n * Clear the auth cookie {@link setAuthCookie} wrote. An explicit\n * app-controller call — pair it with {@link logout} after the token row is\n * revoked. `path` must match what the cookie was set with, or the browser\n * silently ignores the clear (see `response.clearCookie`'s own doc comment).\n *\n * @example\n * await authService.logout(user, accessToken, refreshToken);\n * authService.clearAuthCookie(response);\n */\n public clearAuthCookie(response: Response, options: ClearAuthCookieOptions = {}): void {\n const name = options.name ?? authConfig.cookie.name();\n const path = options.path ?? authConfig.cookie.path();\n\n response.clearCookie(name, { path });\n }\n}\n\nexport const authService = new AuthService();\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"auth.service.mjs","names":[],"sources":["../../../../../../../auth/src/services/auth.service.ts"],"sourcesContent":["import { Random } from \"@mongez/reinforcements\";\nimport type { ChildModel } from \"@warlock.js/cascade\";\nimport {\n config,\n ForbiddenError,\n hashPassword,\n type Response,\n verifyPassword,\n} from \"@warlock.js/core\";\nimport type {\n AccessTokenOutput,\n AuthCredentials,\n ClearAuthCookieOptions,\n DeviceInfo,\n LoginResult,\n SetAuthCookieOptions,\n TokenPair,\n} from \"../contracts/types\";\nimport { AccessToken } from \"../models/access-token\";\nimport type { Auth } from \"../models/auth.model\";\nimport { OneTimeToken } from \"../models/one-time-token\";\nimport { RefreshToken } from \"../models/refresh-token\";\nimport { authConfig } from \"./auth-config\";\nimport { authEvents } from \"./auth-events\";\nimport { isInvalidCredentialError, jwt } from \"./jwt\";\n\nclass AuthService {\n /**\n * Resolve the active access-token model — the package default, or a subclass\n * an app registered under `config.auth.accessToken.model` (e.g. to add a\n * tenant column). The service never references the concrete class directly so\n * an override is a pure config change.\n */\n private get accessTokenModel(): typeof AccessToken {\n return config.key(\"auth.accessToken.model\", AccessToken);\n }\n\n /**\n * Resolve the active refresh-token model (default or registered override).\n */\n private get refreshTokenModel(): typeof RefreshToken {\n return config.key(\"auth.refreshToken.model\", RefreshToken);\n }\n\n /**\n * Resolve the active one-time-token model (default or registered override).\n */\n private get oneTimeTokenModel(): typeof OneTimeToken {\n return config.key(\"auth.oneTimeToken.model\", OneTimeToken);\n }\n\n /**\n * Build the default access-token JWT payload from a user.\n */\n public buildAccessTokenPayload(user: Auth): Record<string, unknown> {\n return {\n id: user.id,\n userType: user.userType,\n created_at: Date.now(),\n };\n }\n\n /** One policy seam keeps account-state checks identical across all auth paths. */\n public async canAuthenticate(user: Auth): Promise<boolean> {\n return authConfig.canAuthenticate(user);\n }\n\n private async assertCanAuthenticate(user: Auth): Promise<void> {\n if (!(await this.canAuthenticate(user))) {\n throw new ForbiddenError(\"This user cannot authenticate.\");\n }\n }\n\n private async issueAccessToken(\n user: Auth,\n payload?: Record<string, unknown>,\n ): Promise<AccessTokenOutput> {\n const data = payload || this.buildAccessTokenPayload(user);\n // Validate before signing so an unusable lifetime cannot mint an immortal token.\n const expiresIn = authConfig.accessToken.expiresInMs();\n\n const token = await jwt.generate(data, { expiresIn });\n const expiresAt = new Date(Date.now() + expiresIn);\n\n await this.accessTokenModel.issue(user, token, expiresAt);\n\n return { token, expiresAt: expiresAt.toISOString() };\n }\n\n /**\n * Sign + persist an access token for the user and return the token with its\n * expiry. The expiry is computed locally from `expiresIn` rather than by\n * re-verifying the token we just signed.\n */\n public async generateAccessToken(\n user: Auth,\n payload?: Record<string, unknown>,\n ): Promise<AccessTokenOutput> {\n await this.assertCanAuthenticate(user);\n\n return this.issueAccessToken(user, payload);\n }\n\n private async issueRefreshToken(\n user: Auth,\n deviceInfo?: DeviceInfo,\n ): Promise<RefreshToken | undefined> {\n if (!authConfig.refreshToken.enabled()) return;\n\n // Validate the lifetime first — before the per-user cap is enforced, before\n // anything is signed — so a bad value can never revoke a user's oldest\n // session on its way to failing.\n const expiresIn = authConfig.refreshToken.expiresInMs();\n\n const familyId = deviceInfo?.familyId || Random.string(32);\n\n const payload = {\n userId: user.id,\n userType: user.userType,\n familyId,\n };\n\n const expiresAt = new Date(Date.now() + expiresIn).toISOString();\n\n await this.refreshTokenModel.enforceMax(user, authConfig.refreshToken.maxPerUser());\n\n const token = await jwt.generateRefreshToken(payload, { expiresIn });\n\n return this.refreshTokenModel.issue(user, token, { familyId, expiresAt, deviceInfo });\n }\n\n /**\n * Sign + persist a refresh token for the user (enforcing the per-user cap\n * first). Resolves to `undefined` when refresh tokens are disabled in config.\n */\n public async createRefreshToken(\n user: Auth,\n deviceInfo?: DeviceInfo,\n ): Promise<RefreshToken | undefined> {\n if (!authConfig.refreshToken.enabled()) return;\n\n await this.assertCanAuthenticate(user);\n\n return this.issueRefreshToken(user, deviceInfo);\n }\n\n private async issueTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\n const refreshToken = await this.issueRefreshToken(user, deviceInfo);\n\n const tokenPair: TokenPair = {\n accessToken,\n refreshToken: refreshToken\n ? {\n token: refreshToken.get(\"token\"),\n expiresAt: refreshToken.get(\"expires_at\"),\n }\n : undefined,\n };\n\n authEvents.emit(\"token.created\", user, tokenPair);\n\n if (refreshToken) {\n authEvents.emit(\"session.created\", user, refreshToken, deviceInfo);\n }\n\n return tokenPair;\n }\n\n /**\n * Issue both an access and a refresh token, emitting the creation events.\n */\n public async createTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n await this.assertCanAuthenticate(user);\n\n return this.issueTokenPair(user, deviceInfo);\n }\n\n /**\n * Exchange a refresh token for a new token pair, with rotation + replay\n * detection. A concurrent reuse of the same token loses the atomic revoke and\n * is treated as a breach — the whole family is revoked and the request fails.\n */\n public async refreshTokens(\n refreshTokenString: string,\n deviceInfo?: DeviceInfo,\n ): Promise<TokenPair | null> {\n let decoded: {\n userId: number;\n userType: string;\n familyId: string;\n } | null;\n\n try {\n decoded = await jwt.verifyRefreshToken<{\n userId: number;\n userType: string;\n familyId: string;\n }>(refreshTokenString);\n } catch (error) {\n if (isInvalidCredentialError(error)) return null;\n\n throw error;\n }\n\n if (!decoded) return null;\n\n const refreshToken = await this.refreshTokenModel.findByToken(refreshTokenString);\n\n if (!refreshToken?.isValid) {\n // An already-invalid token may be a replay of a rotated credential.\n if (refreshToken) {\n await this.revokeTokenFamily(refreshToken.familyId);\n }\n\n return null;\n }\n\n const UserModel = config.key(`auth.userType.${decoded.userType}`);\n\n if (!UserModel) {\n throw new Error(`User type ${decoded.userType} is unknown type.`);\n }\n\n const user = (await UserModel.find(decoded.userId)) as Auth | null;\n\n if (!user) return null;\n\n if (!(await this.canAuthenticate(user))) return null;\n\n const rotationEnabled = authConfig.refreshToken.rotation();\n\n if (rotationEnabled) {\n const won = await refreshToken.revokeIfActive();\n\n if (!won) {\n // A concurrent request already rotated this token.\n await this.revokeTokenFamily(refreshToken.familyId);\n\n return null;\n }\n } else {\n await refreshToken.markAsUsed();\n }\n\n const newTokenPair = await this.issueTokenPair(user, {\n ...deviceInfo,\n familyId: refreshToken.familyId,\n });\n\n authEvents.emit(\"token.refreshed\", user, newTokenPair, refreshToken);\n\n return newTokenPair;\n }\n\n /**\n * Verify a plaintext password against a stored hash.\n */\n public async verifyPassword(plainPassword: string, hashedPassword: string): Promise<boolean> {\n return verifyPassword(plainPassword, hashedPassword);\n }\n\n /**\n * Hash a plaintext password.\n */\n public async hashPassword(password: string): Promise<string> {\n return hashPassword(password);\n }\n\n /**\n * Resolve a user by credentials, verifying the password. Returns `null` on a\n * missing user or a wrong password, emitting `login.failed` either way.\n */\n public async attemptLogin<T extends Auth>(\n Model: ChildModel<T>,\n data: AuthCredentials,\n ): Promise<T | null> {\n const { password, ...otherData } = data;\n\n authEvents.emit(\"login.attempt\", otherData);\n\n const user = (await Model.first(otherData)) as T | null;\n\n if (!user) {\n authEvents.emit(\"login.failed\", otherData, \"User not found\");\n\n return null;\n }\n\n if (!(await this.verifyPassword(password, user.string(\"password\")!))) {\n authEvents.emit(\"login.failed\", otherData, \"Invalid password\");\n\n return null;\n }\n\n if (!(await this.canAuthenticate(user))) {\n authEvents.emit(\"login.failed\", otherData, \"Authentication not allowed\");\n\n return null;\n }\n\n return user;\n }\n\n /**\n * Full login flow: validate credentials, issue tokens, emit events. Returns\n * the user + token pair on success, `null` on failure.\n */\n public async login<T extends Auth>(\n Model: ChildModel<T>,\n credentials: AuthCredentials,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T> | null> {\n const user = await this.attemptLogin(Model, credentials);\n\n if (!user) {\n return null;\n }\n\n return this.finalizeLogin(user, deviceInfo);\n }\n\n /**\n * Log in a user some OTHER method already authenticated — a provider\n * callback, a passkey assertion, a one-time code. Applies the\n * `auth.canAuthenticate` policy (403 on refusal), then produces exactly\n * what {@link login} produces: the same tokens, rows and events. Pair with\n * {@link setAuthCookie} for a cookie session, as with password login.\n *\n * Never call this for a user you have not authenticated.\n */\n public async completeLogin<T extends Auth>(\n user: T,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T>> {\n await this.assertCanAuthenticate(user);\n\n return this.finalizeLogin(user, deviceInfo);\n }\n\n /** The one token-issuing tail every login method shares. */\n private async finalizeLogin<T extends Auth>(\n user: T,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T>> {\n if (!authConfig.refreshToken.enabled()) {\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\n\n return { user, tokens: { accessToken } };\n }\n\n const tokens = await this.issueTokenPair(user, deviceInfo);\n\n authEvents.emit(\"login.success\", user, tokens, deviceInfo);\n\n return { user, tokens };\n }\n\n /**\n * Log a user out.\n *\n * @param accessToken - access token string to revoke (optional)\n * @param refreshToken - refresh token string to revoke (optional)\n *\n * When no refresh token is supplied, `config.auth.refreshToken.logoutWithoutToken`\n * decides the behavior: `\"revoke-all\"` (default, fail-safe) revokes every\n * refresh token; `\"error\"` requires the caller to pass one.\n */\n public async logout(user: Auth, accessToken?: string, refreshToken?: string): Promise<void> {\n if (accessToken) {\n await this.removeAccessToken(user, accessToken);\n }\n\n if (refreshToken) {\n const token = await this.refreshTokenModel.findForUser(user, refreshToken);\n\n if (token) {\n await token.revoke();\n authEvents.emit(\"session.destroyed\", user, token);\n }\n } else {\n const behavior = authConfig.refreshToken.logoutWithoutToken();\n\n if (behavior === \"error\") {\n throw new Error(\"Refresh token required for logout\");\n }\n\n await this.revokeAllTokens(user);\n authEvents.emit(\"logout.failsafe\", user);\n }\n\n authEvents.emit(\"logout\", user);\n }\n\n /**\n * Remove a specific access token belonging to the user.\n */\n public async removeAccessToken(user: Auth, token: string): Promise<void> {\n await this.accessTokenModel.deleteForUser(user, token);\n }\n\n /**\n * Remove every access token belonging to the user.\n */\n public async removeAllAccessTokens(user: Auth): Promise<void> {\n await this.accessTokenModel.deleteAllForUser(user);\n }\n\n /**\n * Remove a specific refresh token belonging to the user.\n */\n public async removeRefreshToken(user: Auth, token: string): Promise<void> {\n await this.refreshTokenModel.deleteForUser(user, token);\n }\n\n /**\n * Revoke every active refresh token for the user and delete their access\n * tokens — \"log out of all devices\". Revocation is a single bulk update; an\n * event fires per revoked token.\n */\n public async revokeAllTokens(user: Auth): Promise<void> {\n const revokedTokens = await this.refreshTokenModel.revokeAllFor(user);\n\n for (const token of revokedTokens) {\n authEvents.emit(\"token.revoked\", user, token);\n }\n\n await this.removeAllAccessTokens(user);\n\n authEvents.emit(\"logout.all\", user);\n }\n\n /**\n * Revoke an entire token family — rotation breach containment.\n */\n public async revokeTokenFamily(familyId: string): Promise<void> {\n const revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);\n\n authEvents.emit(\"token.familyRevoked\", familyId, revokedTokens);\n }\n\n /**\n * Delete expired tokens (refresh + access) and spent one-time tokens\n * (expired or consumed verification/reset/OTP/passkey rows). Emits `token.expired` per refresh\n * token and `cleanup.completed` with the refresh count. Drives the\n * `auth.cleanup` CLI command.\n */\n public async cleanupExpiredTokens(): Promise<number> {\n const expiredTokens = await this.refreshTokenModel.purgeExpired();\n\n for (const token of expiredTokens) {\n authEvents.emit(\"token.expired\", token);\n }\n\n await this.accessTokenModel.purgeExpired();\n await this.oneTimeTokenModel.purgeSpent();\n\n authEvents.emit(\"cleanup.completed\", expiredTokens.length);\n\n return expiredTokens.length;\n }\n\n /**\n * Find every persisted token — access and refresh — that can never retire\n * itself: an unusable `expires_at`, or a token carrying no `exp` claim.\n *\n * This is the remediation read for the pre-4.12.0 `expiresIn` defect (#25).\n * Nothing else surfaces these rows: `auth.cleanup` selects `expires_at < now`,\n * which an `Invalid Date` never satisfies, and a row whose date column is\n * perfectly fine can still hold a token with no deadline in it. Read-only —\n * pair with {@link purgeNeverExpiringTokens} to act on the answer.\n */\n public async findNeverExpiringTokens(): Promise<{\n accessTokens: AccessToken[];\n refreshTokens: RefreshToken[];\n }> {\n return {\n accessTokens: await this.accessTokenModel.findNeverExpiring(),\n refreshTokens: await this.refreshTokenModel.findNeverExpiring(),\n };\n }\n\n /**\n * Revoke every never-expiring token by deleting its row, emitting\n * `token.revoked` per refresh token. Drives `warlock auth.purge-never-expiring`.\n *\n * Access tokens first: deleting one is immediate revocation, while a refresh\n * token left in place for a moment can only mint an access token through a\n * verifier that now rejects it for the missing `exp`.\n */\n public async purgeNeverExpiringTokens(): Promise<{\n accessTokens: number;\n refreshTokens: number;\n }> {\n const accessTokens = await this.accessTokenModel.purgeNeverExpiring();\n const refreshTokens = await this.refreshTokenModel.purgeNeverExpiring();\n\n // `token.expired` (the event `auth.cleanup` already emits for a removed\n // refresh token) rather than `token.revoked`, which carries a loaded `Auth`\n // this batch path has no reason to fetch a user row for.\n for (const token of refreshTokens) {\n authEvents.emit(\"token.expired\", token);\n }\n\n return { accessTokens: accessTokens.length, refreshTokens: refreshTokens.length };\n }\n\n /**\n * Active, unexpired sessions for the user, newest first.\n */\n public async getActiveSessions(user: Auth): Promise<RefreshToken[]> {\n return this.refreshTokenModel.activeFor(user);\n }\n\n /**\n * `Max-Age` (seconds) `setAuthCookie` should apply, derived from an\n * `AccessTokenOutput`'s `expiresAt` — or `undefined` for a bare token\n * string (a session cookie) or an unparseable `expiresAt`.\n */\n private cookieMaxAgeFromToken(token: string | AccessTokenOutput): number | undefined {\n if (typeof token === \"string\") return undefined;\n\n const expiresAt = new Date(token.expiresAt).getTime();\n\n if (Number.isNaN(expiresAt)) return undefined;\n\n return Math.max(0, Math.round((expiresAt - Date.now()) / 1000));\n }\n\n /**\n * Write the auth cookie on `response` — the write side of the `cookie:<name>`\n * token source `authMiddleware([], \"cookie:<name>\")` already reads. An explicit\n * app-controller call, never a side effect of {@link login}, so an existing\n * bearer-only app never starts emitting `Set-Cookie` just by upgrading.\n *\n * Attribute flags (`HttpOnly`, `SameSite=Lax`, `Secure` outside dev) come\n * from `response.cookie()`'s own `secureCookieDefaults()` floor — this\n * method never overrides them, only `name`/`path`/`maxAge`. `raw: true`\n * writes the token unquoted, so the same string a `cookie:<name>` source\n * reads back is exactly what was issued, not a JSON-wrapped copy.\n *\n * @param token - the token string, or an `AccessTokenOutput` (e.g. from\n * {@link login}) whose `expiresAt` sets `Max-Age` automatically when\n * `options.maxAge` is not given. A bare string with no `options.maxAge`\n * produces a session cookie.\n *\n * @example\n * const { user, tokens } = await authService.login(User, credentials);\n * authService.setAuthCookie(response, tokens.accessToken);\n */\n public setAuthCookie(\n response: Response,\n token: string | AccessTokenOutput,\n options: SetAuthCookieOptions = {},\n ): void {\n const name = options.name ?? authConfig.cookie.name();\n const path = options.path ?? authConfig.cookie.path();\n const tokenValue = typeof token === \"string\" ? token : token.token;\n const maxAge = options.maxAge ?? this.cookieMaxAgeFromToken(token);\n\n response.cookie(name, tokenValue, {\n raw: true,\n path,\n ...(maxAge !== undefined ? { maxAge } : {}),\n });\n }\n\n /**\n * Clear the auth cookie {@link setAuthCookie} wrote. An explicit\n * app-controller call — pair it with {@link logout} after the token row is\n * revoked. `path` must match what the cookie was set with, or the browser\n * silently ignores the clear (see `response.clearCookie`'s own doc comment).\n *\n * @example\n * await authService.logout(user, accessToken, refreshToken);\n * authService.clearAuthCookie(response);\n */\n public clearAuthCookie(response: Response, options: ClearAuthCookieOptions = {}): void {\n const name = options.name ?? authConfig.cookie.name();\n const path = options.path ?? authConfig.cookie.path();\n\n response.clearCookie(name, { path });\n }\n}\n\nexport const authService = new AuthService();\n"],"mappings":";;;;;;;;;;;;;AA0BA,IAAM,cAAN,MAAkB;;;;;;;CAOhB,IAAY,mBAAuC;EACjD,OAAO,OAAO,IAAI,0BAA0B,WAAW;CACzD;;;;CAKA,IAAY,oBAAyC;EACnD,OAAO,OAAO,IAAI,2BAA2B,YAAY;CAC3D;;;;CAKA,IAAY,oBAAyC;EACnD,OAAO,OAAO,IAAI,2BAA2B,YAAY;CAC3D;;;;CAKA,AAAO,wBAAwB,MAAqC;EAClE,OAAO;GACL,IAAI,KAAK;GACT,UAAU,KAAK;GACf,YAAY,KAAK,IAAI;EACvB;CACF;;CAGA,MAAa,gBAAgB,MAA8B;EACzD,OAAO,WAAW,gBAAgB,IAAI;CACxC;CAEA,MAAc,sBAAsB,MAA2B;EAC7D,IAAI,CAAE,MAAM,KAAK,gBAAgB,IAAI,GACnC,MAAM,IAAI,eAAe,gCAAgC;CAE7D;CAEA,MAAc,iBACZ,MACA,SAC4B;EAC5B,MAAM,OAAO,WAAW,KAAK,wBAAwB,IAAI;EAEzD,MAAM,YAAY,WAAW,YAAY,YAAY;EAErD,MAAM,QAAQ,MAAM,IAAI,SAAS,MAAM,EAAE,UAAU,CAAC;EACpD,MAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS;EAEjD,MAAM,KAAK,iBAAiB,MAAM,MAAM,OAAO,SAAS;EAExD,OAAO;GAAE;GAAO,WAAW,UAAU,YAAY;EAAE;CACrD;;;;;;CAOA,MAAa,oBACX,MACA,SAC4B;EAC5B,MAAM,KAAK,sBAAsB,IAAI;EAErC,OAAO,KAAK,iBAAiB,MAAM,OAAO;CAC5C;CAEA,MAAc,kBACZ,MACA,YACmC;EACnC,IAAI,CAAC,WAAW,aAAa,QAAQ,GAAG;EAKxC,MAAM,YAAY,WAAW,aAAa,YAAY;EAEtD,MAAM,WAAW,YAAY,YAAY,OAAO,OAAO,EAAE;EAEzD,MAAM,UAAU;GACd,QAAQ,KAAK;GACb,UAAU,KAAK;GACf;EACF;EAEA,MAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,CAAC,CAAC,YAAY;EAE/D,MAAM,KAAK,kBAAkB,WAAW,MAAM,WAAW,aAAa,WAAW,CAAC;EAElF,MAAM,QAAQ,MAAM,IAAI,qBAAqB,SAAS,EAAE,UAAU,CAAC;EAEnE,OAAO,KAAK,kBAAkB,MAAM,MAAM,OAAO;GAAE;GAAU;GAAW;EAAW,CAAC;CACtF;;;;;CAMA,MAAa,mBACX,MACA,YACmC;EACnC,IAAI,CAAC,WAAW,aAAa,QAAQ,GAAG;EAExC,MAAM,KAAK,sBAAsB,IAAI;EAErC,OAAO,KAAK,kBAAkB,MAAM,UAAU;CAChD;CAEA,MAAc,eAAe,MAAY,YAA6C;EACpF,MAAM,cAAc,MAAM,KAAK,iBAAiB,MAAM,YAAY,OAAO;EACzE,MAAM,eAAe,MAAM,KAAK,kBAAkB,MAAM,UAAU;EAElE,MAAM,YAAuB;GAC3B;GACA,cAAc,eACV;IACE,OAAO,aAAa,IAAI,OAAO;IAC/B,WAAW,aAAa,IAAI,YAAY;GAC1C,IACA;EACN;EAEA,WAAW,KAAK,iBAAiB,MAAM,SAAS;EAEhD,IAAI,cACF,WAAW,KAAK,mBAAmB,MAAM,cAAc,UAAU;EAGnE,OAAO;CACT;;;;CAKA,MAAa,gBAAgB,MAAY,YAA6C;EACpF,MAAM,KAAK,sBAAsB,IAAI;EAErC,OAAO,KAAK,eAAe,MAAM,UAAU;CAC7C;;;;;;CAOA,MAAa,cACX,oBACA,YAC2B;EAC3B,IAAI;EAMJ,IAAI;GACF,UAAU,MAAM,IAAI,mBAIjB,kBAAkB;EACvB,SAAS,OAAO;GACd,IAAI,yBAAyB,KAAK,GAAG,OAAO;GAE5C,MAAM;EACR;EAEA,IAAI,CAAC,SAAS,OAAO;EAErB,MAAM,eAAe,MAAM,KAAK,kBAAkB,YAAY,kBAAkB;EAEhF,IAAI,CAAC,cAAc,SAAS;GAE1B,IAAI,cACF,MAAM,KAAK,kBAAkB,aAAa,QAAQ;GAGpD,OAAO;EACT;EAEA,MAAM,YAAY,OAAO,IAAI,iBAAiB,QAAQ,UAAU;EAEhE,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,aAAa,QAAQ,SAAS,kBAAkB;EAGlE,MAAM,OAAQ,MAAM,UAAU,KAAK,QAAQ,MAAM;EAEjD,IAAI,CAAC,MAAM,OAAO;EAElB,IAAI,CAAE,MAAM,KAAK,gBAAgB,IAAI,GAAI,OAAO;EAIhD,IAFwB,WAAW,aAAa,SAE9B,GAGhB;OAAI,CAAC,MAFa,aAAa,eAAe,GAEpC;IAER,MAAM,KAAK,kBAAkB,aAAa,QAAQ;IAElD,OAAO;GACT;SAEA,MAAM,aAAa,WAAW;EAGhC,MAAM,eAAe,MAAM,KAAK,eAAe,MAAM;GACnD,GAAG;GACH,UAAU,aAAa;EACzB,CAAC;EAED,WAAW,KAAK,mBAAmB,MAAM,cAAc,YAAY;EAEnE,OAAO;CACT;;;;CAKA,MAAa,eAAe,eAAuB,gBAA0C;EAC3F,OAAO,eAAe,eAAe,cAAc;CACrD;;;;CAKA,MAAa,aAAa,UAAmC;EAC3D,OAAO,aAAa,QAAQ;CAC9B;;;;;CAMA,MAAa,aACX,OACA,MACmB;EACnB,MAAM,EAAE,UAAU,GAAG,cAAc;EAEnC,WAAW,KAAK,iBAAiB,SAAS;EAE1C,MAAM,OAAQ,MAAM,MAAM,MAAM,SAAS;EAEzC,IAAI,CAAC,MAAM;GACT,WAAW,KAAK,gBAAgB,WAAW,gBAAgB;GAE3D,OAAO;EACT;EAEA,IAAI,CAAE,MAAM,KAAK,eAAe,UAAU,KAAK,OAAO,UAAU,CAAE,GAAI;GACpE,WAAW,KAAK,gBAAgB,WAAW,kBAAkB;GAE7D,OAAO;EACT;EAEA,IAAI,CAAE,MAAM,KAAK,gBAAgB,IAAI,GAAI;GACvC,WAAW,KAAK,gBAAgB,WAAW,4BAA4B;GAEvE,OAAO;EACT;EAEA,OAAO;CACT;;;;;CAMA,MAAa,MACX,OACA,aACA,YACgC;EAChC,MAAM,OAAO,MAAM,KAAK,aAAa,OAAO,WAAW;EAEvD,IAAI,CAAC,MACH,OAAO;EAGT,OAAO,KAAK,cAAc,MAAM,UAAU;CAC5C;;;;;;;;;;CAWA,MAAa,cACX,MACA,YACyB;EACzB,MAAM,KAAK,sBAAsB,IAAI;EAErC,OAAO,KAAK,cAAc,MAAM,UAAU;CAC5C;;CAGA,MAAc,cACZ,MACA,YACyB;EACzB,IAAI,CAAC,WAAW,aAAa,QAAQ,GAGnC,OAAO;GAAE;GAAM,QAAQ,EAAE,mBAFC,KAAK,iBAAiB,MAAM,YAAY,OAAO,EAEpC;EAAE;EAGzC,MAAM,SAAS,MAAM,KAAK,eAAe,MAAM,UAAU;EAEzD,WAAW,KAAK,iBAAiB,MAAM,QAAQ,UAAU;EAEzD,OAAO;GAAE;GAAM;EAAO;CACxB;;;;;;;;;;;CAYA,MAAa,OAAO,MAAY,aAAsB,cAAsC;EAC1F,IAAI,aACF,MAAM,KAAK,kBAAkB,MAAM,WAAW;EAGhD,IAAI,cAAc;GAChB,MAAM,QAAQ,MAAM,KAAK,kBAAkB,YAAY,MAAM,YAAY;GAEzE,IAAI,OAAO;IACT,MAAM,MAAM,OAAO;IACnB,WAAW,KAAK,qBAAqB,MAAM,KAAK;GAClD;EACF,OAAO;GAGL,IAFiB,WAAW,aAAa,mBAE9B,MAAM,SACf,MAAM,IAAI,MAAM,mCAAmC;GAGrD,MAAM,KAAK,gBAAgB,IAAI;GAC/B,WAAW,KAAK,mBAAmB,IAAI;EACzC;EAEA,WAAW,KAAK,UAAU,IAAI;CAChC;;;;CAKA,MAAa,kBAAkB,MAAY,OAA8B;EACvE,MAAM,KAAK,iBAAiB,cAAc,MAAM,KAAK;CACvD;;;;CAKA,MAAa,sBAAsB,MAA2B;EAC5D,MAAM,KAAK,iBAAiB,iBAAiB,IAAI;CACnD;;;;CAKA,MAAa,mBAAmB,MAAY,OAA8B;EACxE,MAAM,KAAK,kBAAkB,cAAc,MAAM,KAAK;CACxD;;;;;;CAOA,MAAa,gBAAgB,MAA2B;EACtD,MAAM,gBAAgB,MAAM,KAAK,kBAAkB,aAAa,IAAI;EAEpE,KAAK,MAAM,SAAS,eAClB,WAAW,KAAK,iBAAiB,MAAM,KAAK;EAG9C,MAAM,KAAK,sBAAsB,IAAI;EAErC,WAAW,KAAK,cAAc,IAAI;CACpC;;;;CAKA,MAAa,kBAAkB,UAAiC;EAC9D,MAAM,gBAAgB,MAAM,KAAK,kBAAkB,aAAa,QAAQ;EAExE,WAAW,KAAK,uBAAuB,UAAU,aAAa;CAChE;;;;;;;CAQA,MAAa,uBAAwC;EACnD,MAAM,gBAAgB,MAAM,KAAK,kBAAkB,aAAa;EAEhE,KAAK,MAAM,SAAS,eAClB,WAAW,KAAK,iBAAiB,KAAK;EAGxC,MAAM,KAAK,iBAAiB,aAAa;EACzC,MAAM,KAAK,kBAAkB,WAAW;EAExC,WAAW,KAAK,qBAAqB,cAAc,MAAM;EAEzD,OAAO,cAAc;CACvB;;;;;;;;;;;CAYA,MAAa,0BAGV;EACD,OAAO;GACL,cAAc,MAAM,KAAK,iBAAiB,kBAAkB;GAC5D,eAAe,MAAM,KAAK,kBAAkB,kBAAkB;EAChE;CACF;;;;;;;;;CAUA,MAAa,2BAGV;EACD,MAAM,eAAe,MAAM,KAAK,iBAAiB,mBAAmB;EACpE,MAAM,gBAAgB,MAAM,KAAK,kBAAkB,mBAAmB;EAKtE,KAAK,MAAM,SAAS,eAClB,WAAW,KAAK,iBAAiB,KAAK;EAGxC,OAAO;GAAE,cAAc,aAAa;GAAQ,eAAe,cAAc;EAAO;CAClF;;;;CAKA,MAAa,kBAAkB,MAAqC;EAClE,OAAO,KAAK,kBAAkB,UAAU,IAAI;CAC9C;;;;;;CAOA,AAAQ,sBAAsB,OAAuD;EACnF,IAAI,OAAO,UAAU,UAAU,OAAO;EAEtC,MAAM,YAAY,IAAI,KAAK,MAAM,SAAS,CAAC,CAAC,QAAQ;EAEpD,IAAI,OAAO,MAAM,SAAS,GAAG,OAAO;EAEpC,OAAO,KAAK,IAAI,GAAG,KAAK,OAAO,YAAY,KAAK,IAAI,KAAK,GAAI,CAAC;CAChE;;;;;;;;;;;;;;;;;;;;;;CAuBA,AAAO,cACL,UACA,OACA,UAAgC,CAAC,GAC3B;EACN,MAAM,OAAO,QAAQ,QAAQ,WAAW,OAAO,KAAK;EACpD,MAAM,OAAO,QAAQ,QAAQ,WAAW,OAAO,KAAK;EACpD,MAAM,aAAa,OAAO,UAAU,WAAW,QAAQ,MAAM;EAC7D,MAAM,SAAS,QAAQ,UAAU,KAAK,sBAAsB,KAAK;EAEjE,SAAS,OAAO,MAAM,YAAY;GAChC,KAAK;GACL;GACA,GAAI,WAAW,SAAY,EAAE,OAAO,IAAI,CAAC;EAC3C,CAAC;CACH;;;;;;;;;;;CAYA,AAAO,gBAAgB,UAAoB,UAAkC,CAAC,GAAS;EACrF,MAAM,OAAO,QAAQ,QAAQ,WAAW,OAAO,KAAK;EACpD,MAAM,OAAO,QAAQ,QAAQ,WAAW,OAAO,KAAK;EAEpD,SAAS,YAAY,MAAM,EAAE,KAAK,CAAC;CACrC;AACF;AAEA,MAAa,cAAc,IAAI,YAAY"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Auth } from "../models/auth.model.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../auth/src/services/email-verification.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether the user has verified their email — the configured
|
|
6
|
+
* `auth.verification.field` (default `emailVerifiedAt`) holds a value.
|
|
7
|
+
*/
|
|
8
|
+
declare function isEmailVerified(user: Pick<Auth, "get">): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Issue an email-verification token for the user and send it through the
|
|
11
|
+
* verification notification (`auth.verification.notification`, else auth's
|
|
12
|
+
* default mail). Earlier verification links keep working until they expire or
|
|
13
|
+
* one of them is used.
|
|
14
|
+
*
|
|
15
|
+
* @throws NotificationsUnavailableError before any token is issued when
|
|
16
|
+
* `@warlock.js/notifications` is not installed or not configured.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const user = await User.create(data);
|
|
20
|
+
* await sendEmailVerification(user);
|
|
21
|
+
*/
|
|
22
|
+
declare function sendEmailVerification(user: Auth): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Consume a verification token and stamp the user's verified field with the
|
|
25
|
+
* current date. Resolves to the verified user.
|
|
26
|
+
*
|
|
27
|
+
* @throws InvalidOneTimeTokenError for an unknown, reset-purpose, expired or
|
|
28
|
+
* already-used token.
|
|
29
|
+
* @throws Error when the save did not keep the field — the user schema must
|
|
30
|
+
* declare it (e.g. `emailVerifiedAt: v.date().optional()`).
|
|
31
|
+
*/
|
|
32
|
+
declare function verifyEmail(token: string): Promise<Auth>;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { isEmailVerified, sendEmailVerification, verifyEmail };
|
|
35
|
+
//# sourceMappingURL=email-verification.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-verification.d.mts","names":[],"sources":["../../../../../../../auth/src/services/email-verification.ts"],"mappings":";;;;;AASA;;iBAAgB,eAAA,CAAgB,IAAA,EAAM,IAAI,CAAC,IAAA;;;;;;AAAY;AAiBvD;;;;;;;iBAAsB,qBAAA,CAAsB,IAAA,EAAM,IAAA,GAAO,OAAO;AAAA;AAuBhE;;;;;;;;AAvBgE,iBAuB1C,WAAA,CAAY,KAAA,WAAgB,OAAO,CAAC,IAAA"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { authConfig } from "./auth-config.mjs";
|
|
2
|
+
import { consumeOneTimeToken, issueOneTimeToken } from "./one-time-tokens.mjs";
|
|
3
|
+
import { resolveTokenNotification } from "./token-notifications.mjs";
|
|
4
|
+
|
|
5
|
+
//#region ../auth/src/services/email-verification.ts
|
|
6
|
+
/**
|
|
7
|
+
* Whether the user has verified their email — the configured
|
|
8
|
+
* `auth.verification.field` (default `emailVerifiedAt`) holds a value.
|
|
9
|
+
*/
|
|
10
|
+
function isEmailVerified(user) {
|
|
11
|
+
return !!user.get(authConfig.verification.field());
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Issue an email-verification token for the user and send it through the
|
|
15
|
+
* verification notification (`auth.verification.notification`, else auth's
|
|
16
|
+
* default mail). Earlier verification links keep working until they expire or
|
|
17
|
+
* one of them is used.
|
|
18
|
+
*
|
|
19
|
+
* @throws NotificationsUnavailableError before any token is issued when
|
|
20
|
+
* `@warlock.js/notifications` is not installed or not configured.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const user = await User.create(data);
|
|
24
|
+
* await sendEmailVerification(user);
|
|
25
|
+
*/
|
|
26
|
+
async function sendEmailVerification(user) {
|
|
27
|
+
const notification = await resolveTokenNotification("email-verification");
|
|
28
|
+
const { token, expiresAt } = await issueOneTimeToken(user, "email-verification", authConfig.verification.expiresInMs());
|
|
29
|
+
const url = authConfig.verification.url()?.(token, user);
|
|
30
|
+
await notification.send(user, {
|
|
31
|
+
token,
|
|
32
|
+
expiresAt,
|
|
33
|
+
...url ? { url } : {}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Consume a verification token and stamp the user's verified field with the
|
|
38
|
+
* current date. Resolves to the verified user.
|
|
39
|
+
*
|
|
40
|
+
* @throws InvalidOneTimeTokenError for an unknown, reset-purpose, expired or
|
|
41
|
+
* already-used token.
|
|
42
|
+
* @throws Error when the save did not keep the field — the user schema must
|
|
43
|
+
* declare it (e.g. `emailVerifiedAt: v.date().optional()`).
|
|
44
|
+
*/
|
|
45
|
+
async function verifyEmail(token) {
|
|
46
|
+
const user = await consumeOneTimeToken(token, "email-verification");
|
|
47
|
+
const field = authConfig.verification.field();
|
|
48
|
+
await user.merge({ [field]: /* @__PURE__ */ new Date() }).save();
|
|
49
|
+
if (!isEmailVerified(user)) throw new Error(`@warlock.js/auth: verifyEmail could not persist "${field}" on the user — declare it in the user schema (e.g. \`${field}: v.date().optional()\`) or set \`auth.verification.field\` to an existing attribute.`);
|
|
50
|
+
return user;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
export { isEmailVerified, sendEmailVerification, verifyEmail };
|
|
55
|
+
//# sourceMappingURL=email-verification.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-verification.mjs","names":[],"sources":["../../../../../../../auth/src/services/email-verification.ts"],"sourcesContent":["import type { Auth } from \"../models/auth.model\";\nimport { authConfig } from \"./auth-config\";\nimport { consumeOneTimeToken, issueOneTimeToken } from \"./one-time-tokens\";\nimport { resolveTokenNotification } from \"./token-notifications\";\n\n/**\n * Whether the user has verified their email — the configured\n * `auth.verification.field` (default `emailVerifiedAt`) holds a value.\n */\nexport function isEmailVerified(user: Pick<Auth, \"get\">): boolean {\n return !!user.get(authConfig.verification.field());\n}\n\n/**\n * Issue an email-verification token for the user and send it through the\n * verification notification (`auth.verification.notification`, else auth's\n * default mail). Earlier verification links keep working until they expire or\n * one of them is used.\n *\n * @throws NotificationsUnavailableError before any token is issued when\n * `@warlock.js/notifications` is not installed or not configured.\n *\n * @example\n * const user = await User.create(data);\n * await sendEmailVerification(user);\n */\nexport async function sendEmailVerification(user: Auth): Promise<void> {\n const notification = await resolveTokenNotification(\"email-verification\");\n\n const { token, expiresAt } = await issueOneTimeToken(\n user,\n \"email-verification\",\n authConfig.verification.expiresInMs(),\n );\n\n const url = authConfig.verification.url()?.(token, user);\n\n await notification.send(user, { token, expiresAt, ...(url ? { url } : {}) });\n}\n\n/**\n * Consume a verification token and stamp the user's verified field with the\n * current date. Resolves to the verified user.\n *\n * @throws InvalidOneTimeTokenError for an unknown, reset-purpose, expired or\n * already-used token.\n * @throws Error when the save did not keep the field — the user schema must\n * declare it (e.g. `emailVerifiedAt: v.date().optional()`).\n */\nexport async function verifyEmail(token: string): Promise<Auth> {\n const user = await consumeOneTimeToken(token, \"email-verification\");\n const field = authConfig.verification.field();\n\n await user.merge({ [field]: new Date() } as never).save();\n\n if (!isEmailVerified(user)) {\n throw new Error(\n `@warlock.js/auth: verifyEmail could not persist \"${field}\" on the user — ` +\n `declare it in the user schema (e.g. \\`${field}: v.date().optional()\\`) ` +\n \"or set `auth.verification.field` to an existing attribute.\",\n );\n }\n\n return user;\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,gBAAgB,MAAkC;CAChE,OAAO,CAAC,CAAC,KAAK,IAAI,WAAW,aAAa,MAAM,CAAC;AACnD;;;;;;;;;;;;;;AAeA,eAAsB,sBAAsB,MAA2B;CACrE,MAAM,eAAe,MAAM,yBAAyB,oBAAoB;CAExE,MAAM,EAAE,OAAO,cAAc,MAAM,kBACjC,MACA,sBACA,WAAW,aAAa,YAAY,CACtC;CAEA,MAAM,MAAM,WAAW,aAAa,IAAI,CAAC,GAAG,OAAO,IAAI;CAEvD,MAAM,aAAa,KAAK,MAAM;EAAE;EAAO;EAAW,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;CAAG,CAAC;AAC7E;;;;;;;;;;AAWA,eAAsB,YAAY,OAA8B;CAC9D,MAAM,OAAO,MAAM,oBAAoB,OAAO,oBAAoB;CAClE,MAAM,QAAQ,WAAW,aAAa,MAAM;CAE5C,MAAM,KAAK,MAAM,GAAG,wBAAQ,IAAI,KAAK,EAAE,CAAU,CAAC,CAAC,KAAK;CAExD,IAAI,CAAC,gBAAgB,IAAI,GACvB,MAAM,IAAI,MACR,oDAAoD,MAAM,wDACf,MAAM,sFAEnD;CAGF,OAAO;AACT"}
|
package/esm/services/index.d.mts
CHANGED
|
@@ -2,4 +2,7 @@ import { AuthEventCallback, AuthEventName, AuthEventPayloads, authEvents } from
|
|
|
2
2
|
import { authService } from "./auth.service.mjs";
|
|
3
3
|
import { currentUser } from "./current-user.mjs";
|
|
4
4
|
import { generateJWTSecret } from "./generate-jwt-secret.mjs";
|
|
5
|
-
import { TokenType, TokenTypeError, isInvalidCredentialError, jwt } from "./jwt.mjs";
|
|
5
|
+
import { TokenType, TokenTypeError, isInvalidCredentialError, jwt } from "./jwt.mjs";
|
|
6
|
+
import { isEmailVerified, sendEmailVerification, verifyEmail } from "./email-verification.mjs";
|
|
7
|
+
import { IssuedOneTimeToken, consumeOneTimeToken, hashOneTimeToken, issueOneTimeToken, oneTimeTokenModel } from "./one-time-tokens.mjs";
|
|
8
|
+
import { requestPasswordReset, resetPassword } from "./password-reset.mjs";
|
package/esm/services/index.mjs
CHANGED
|
@@ -3,5 +3,8 @@ import { TokenTypeError, isInvalidCredentialError, jwt } from "./jwt.mjs";
|
|
|
3
3
|
import { authService } from "./auth.service.mjs";
|
|
4
4
|
import { generateJWTSecret } from "./generate-jwt-secret.mjs";
|
|
5
5
|
import { currentUser } from "./current-user.mjs";
|
|
6
|
+
import { consumeOneTimeToken, hashOneTimeToken, issueOneTimeToken, oneTimeTokenModel } from "./one-time-tokens.mjs";
|
|
7
|
+
import { isEmailVerified, sendEmailVerification, verifyEmail } from "./email-verification.mjs";
|
|
8
|
+
import { requestPasswordReset, resetPassword } from "./password-reset.mjs";
|
|
6
9
|
|
|
7
10
|
export { };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Auth } from "../models/auth.model.mjs";
|
|
2
|
+
import { OneTimeToken, OneTimeTokenPurpose } from "../models/one-time-token/one-time-token.model.mjs";
|
|
3
|
+
//#region ../auth/src/services/one-time-tokens.d.ts
|
|
4
|
+
/** The raw token handed to the notification, with its expiry. */
|
|
5
|
+
type IssuedOneTimeToken = {
|
|
6
|
+
token: string;
|
|
7
|
+
expiresAt: Date;
|
|
8
|
+
};
|
|
9
|
+
/** Resolve the active one-time-token model — the default or `auth.oneTimeToken.model`. */
|
|
10
|
+
declare function oneTimeTokenModel(): typeof OneTimeToken;
|
|
11
|
+
/** SHA-256 hex digest of a raw one-time token — the only form ever persisted. */
|
|
12
|
+
declare function hashOneTimeToken(token: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Create and persist a one-time token for the user: 32 random bytes,
|
|
15
|
+
* base64url-encoded. Only the hash reaches storage.
|
|
16
|
+
*
|
|
17
|
+
* @param invalidatePrevious - consume the user's still-unused tokens of the
|
|
18
|
+
* same purpose first (password reset: only the newest link works).
|
|
19
|
+
*/
|
|
20
|
+
declare function issueOneTimeToken(user: Auth, purpose: OneTimeTokenPurpose, expiresInMs: number, invalidatePrevious?: boolean): Promise<IssuedOneTimeToken>;
|
|
21
|
+
/**
|
|
22
|
+
* Consume a raw one-time token of the given purpose and resolve its user.
|
|
23
|
+
*
|
|
24
|
+
* Unknown, other-purpose, expired, already-consumed, lost-the-race and
|
|
25
|
+
* orphaned (user gone) tokens all throw the same {@link InvalidOneTimeTokenError}.
|
|
26
|
+
* The consume is a conditional update, so of N concurrent callers exactly one
|
|
27
|
+
* proceeds.
|
|
28
|
+
*/
|
|
29
|
+
declare function consumeOneTimeToken(rawToken: string, purpose: OneTimeTokenPurpose): Promise<Auth>;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { IssuedOneTimeToken, consumeOneTimeToken, hashOneTimeToken, issueOneTimeToken, oneTimeTokenModel };
|
|
32
|
+
//# sourceMappingURL=one-time-tokens.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"one-time-tokens.d.mts","names":[],"sources":["../../../../../../../auth/src/services/one-time-tokens.ts"],"mappings":";;;;KAOY,kBAAA;EACV,KAAA;EACA,SAAA,EAAW,IAAI;AAAA;;iBAID,iBAAA,WAA4B,YAAY;;iBAKxC,gBAAA,CAAiB,KAAa;;;AAT7B;AAIjB;;;;iBAgBsB,iBAAA,CACpB,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,EACT,WAAA,UACA,kBAAA,aACC,OAAA,CAAQ,kBAAA;AAhBX;;;;AAA8C;AAW9C;;;AAXA,iBAuCsB,mBAAA,CACpB,QAAA,UACA,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,IAAA"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { OneTimeToken } from "../models/one-time-token/one-time-token.model.mjs";
|
|
2
|
+
import "../models/one-time-token/index.mjs";
|
|
3
|
+
import { InvalidOneTimeTokenError } from "../errors/invalid-one-time-token.error.mjs";
|
|
4
|
+
import { config } from "@warlock.js/core";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
//#region ../auth/src/services/one-time-tokens.ts
|
|
8
|
+
/** Resolve the active one-time-token model — the default or `auth.oneTimeToken.model`. */
|
|
9
|
+
function oneTimeTokenModel() {
|
|
10
|
+
return config.key("auth.oneTimeToken.model", OneTimeToken);
|
|
11
|
+
}
|
|
12
|
+
/** SHA-256 hex digest of a raw one-time token — the only form ever persisted. */
|
|
13
|
+
function hashOneTimeToken(token) {
|
|
14
|
+
return createHash("sha256").update(token).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Create and persist a one-time token for the user: 32 random bytes,
|
|
18
|
+
* base64url-encoded. Only the hash reaches storage.
|
|
19
|
+
*
|
|
20
|
+
* @param invalidatePrevious - consume the user's still-unused tokens of the
|
|
21
|
+
* same purpose first (password reset: only the newest link works).
|
|
22
|
+
*/
|
|
23
|
+
async function issueOneTimeToken(user, purpose, expiresInMs, invalidatePrevious = false) {
|
|
24
|
+
const Model = oneTimeTokenModel();
|
|
25
|
+
if (invalidatePrevious) await Model.invalidateActiveFor(user, purpose);
|
|
26
|
+
const token = randomBytes(32).toString("base64url");
|
|
27
|
+
const expiresAt = new Date(Date.now() + expiresInMs);
|
|
28
|
+
await Model.issue(user, purpose, hashOneTimeToken(token), expiresAt);
|
|
29
|
+
return {
|
|
30
|
+
token,
|
|
31
|
+
expiresAt
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Consume a raw one-time token of the given purpose and resolve its user.
|
|
36
|
+
*
|
|
37
|
+
* Unknown, other-purpose, expired, already-consumed, lost-the-race and
|
|
38
|
+
* orphaned (user gone) tokens all throw the same {@link InvalidOneTimeTokenError}.
|
|
39
|
+
* The consume is a conditional update, so of N concurrent callers exactly one
|
|
40
|
+
* proceeds.
|
|
41
|
+
*/
|
|
42
|
+
async function consumeOneTimeToken(rawToken, purpose) {
|
|
43
|
+
if (typeof rawToken !== "string" || rawToken.length === 0) throw new InvalidOneTimeTokenError();
|
|
44
|
+
const row = await oneTimeTokenModel().findByHash(hashOneTimeToken(rawToken), purpose);
|
|
45
|
+
if (!row || row.isConsumed || row.isExpired) throw new InvalidOneTimeTokenError();
|
|
46
|
+
const UserModel = config.key(`auth.userType.${row.userType}`);
|
|
47
|
+
if (!UserModel) throw new Error(`User type ${row.userType} is unknown type.`);
|
|
48
|
+
const user = await UserModel.find(row.userId);
|
|
49
|
+
if (!user) throw new InvalidOneTimeTokenError();
|
|
50
|
+
if (!await row.consumeIfActive()) throw new InvalidOneTimeTokenError();
|
|
51
|
+
return user;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
export { consumeOneTimeToken, hashOneTimeToken, issueOneTimeToken, oneTimeTokenModel };
|
|
56
|
+
//# sourceMappingURL=one-time-tokens.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"one-time-tokens.mjs","names":[],"sources":["../../../../../../../auth/src/services/one-time-tokens.ts"],"sourcesContent":["import { config } from \"@warlock.js/core\";\nimport { createHash, randomBytes } from \"node:crypto\";\nimport { InvalidOneTimeTokenError } from \"../errors/invalid-one-time-token.error\";\nimport type { Auth } from \"../models/auth.model\";\nimport { OneTimeToken, type OneTimeTokenPurpose } from \"../models/one-time-token\";\n\n/** The raw token handed to the notification, with its expiry. */\nexport type IssuedOneTimeToken = {\n token: string;\n expiresAt: Date;\n};\n\n/** Resolve the active one-time-token model — the default or `auth.oneTimeToken.model`. */\nexport function oneTimeTokenModel(): typeof OneTimeToken {\n return config.key(\"auth.oneTimeToken.model\", OneTimeToken);\n}\n\n/** SHA-256 hex digest of a raw one-time token — the only form ever persisted. */\nexport function hashOneTimeToken(token: string): string {\n return createHash(\"sha256\").update(token).digest(\"hex\");\n}\n\n/**\n * Create and persist a one-time token for the user: 32 random bytes,\n * base64url-encoded. Only the hash reaches storage.\n *\n * @param invalidatePrevious - consume the user's still-unused tokens of the\n * same purpose first (password reset: only the newest link works).\n */\nexport async function issueOneTimeToken(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n expiresInMs: number,\n invalidatePrevious = false,\n): Promise<IssuedOneTimeToken> {\n const Model = oneTimeTokenModel();\n\n if (invalidatePrevious) {\n await Model.invalidateActiveFor(user, purpose);\n }\n\n const token = randomBytes(32).toString(\"base64url\");\n const expiresAt = new Date(Date.now() + expiresInMs);\n\n await Model.issue(user, purpose, hashOneTimeToken(token), expiresAt);\n\n return { token, expiresAt };\n}\n\n/**\n * Consume a raw one-time token of the given purpose and resolve its user.\n *\n * Unknown, other-purpose, expired, already-consumed, lost-the-race and\n * orphaned (user gone) tokens all throw the same {@link InvalidOneTimeTokenError}.\n * The consume is a conditional update, so of N concurrent callers exactly one\n * proceeds.\n */\nexport async function consumeOneTimeToken(\n rawToken: string,\n purpose: OneTimeTokenPurpose,\n): Promise<Auth> {\n if (typeof rawToken !== \"string\" || rawToken.length === 0) {\n throw new InvalidOneTimeTokenError();\n }\n\n const row = await oneTimeTokenModel().findByHash(hashOneTimeToken(rawToken), purpose);\n\n if (!row || row.isConsumed || row.isExpired) {\n throw new InvalidOneTimeTokenError();\n }\n\n const UserModel = config.key(`auth.userType.${row.userType}`);\n\n if (!UserModel) {\n throw new Error(`User type ${row.userType} is unknown type.`);\n }\n\n const user = (await UserModel.find(row.userId)) as Auth | null;\n\n if (!user) {\n throw new InvalidOneTimeTokenError();\n }\n\n if (!(await row.consumeIfActive())) {\n throw new InvalidOneTimeTokenError();\n }\n\n return user;\n}\n"],"mappings":";;;;;;;;AAaA,SAAgB,oBAAyC;CACvD,OAAO,OAAO,IAAI,2BAA2B,YAAY;AAC3D;;AAGA,SAAgB,iBAAiB,OAAuB;CACtD,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;AACxD;;;;;;;;AASA,eAAsB,kBACpB,MACA,SACA,aACA,qBAAqB,OACQ;CAC7B,MAAM,QAAQ,kBAAkB;CAEhC,IAAI,oBACF,MAAM,MAAM,oBAAoB,MAAM,OAAO;CAG/C,MAAM,QAAQ,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;CAClD,MAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,WAAW;CAEnD,MAAM,MAAM,MAAM,MAAM,SAAS,iBAAiB,KAAK,GAAG,SAAS;CAEnE,OAAO;EAAE;EAAO;CAAU;AAC5B;;;;;;;;;AAUA,eAAsB,oBACpB,UACA,SACe;CACf,IAAI,OAAO,aAAa,YAAY,SAAS,WAAW,GACtD,MAAM,IAAI,yBAAyB;CAGrC,MAAM,MAAM,MAAM,kBAAkB,CAAC,CAAC,WAAW,iBAAiB,QAAQ,GAAG,OAAO;CAEpF,IAAI,CAAC,OAAO,IAAI,cAAc,IAAI,WAChC,MAAM,IAAI,yBAAyB;CAGrC,MAAM,YAAY,OAAO,IAAI,iBAAiB,IAAI,UAAU;CAE5D,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,aAAa,IAAI,SAAS,kBAAkB;CAG9D,MAAM,OAAQ,MAAM,UAAU,KAAK,IAAI,MAAM;CAE7C,IAAI,CAAC,MACH,MAAM,IAAI,yBAAyB;CAGrC,IAAI,CAAE,MAAM,IAAI,gBAAgB,GAC9B,MAAM,IAAI,yBAAyB;CAGrC,OAAO;AACT"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AuthProviderSdkMissingError } from "../errors/auth-provider-sdk-missing.error.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../auth/src/services/optional-peer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether `error` (or an error it wraps as `cause`) is the resolver reporting
|
|
6
|
+
* that `packageName` ITSELF is absent. A missing transitive dependency names
|
|
7
|
+
* a different package and is not treated as "not installed".
|
|
8
|
+
*/
|
|
9
|
+
function isPackageMissing(error, packageName) {
|
|
10
|
+
for (let current = error, depth = 0; current && depth < 5; depth++) {
|
|
11
|
+
const { code, message } = current;
|
|
12
|
+
if ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && typeof message === "string" && message.includes(`'${packageName}'`)) return true;
|
|
13
|
+
current = current.cause;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Load an optional vendor SDK lazily. The specifier reaches `import()` as a
|
|
19
|
+
* variable, so neither the type-checker nor a bundler ever requires the peer;
|
|
20
|
+
* nothing is loaded until a login method actually runs.
|
|
21
|
+
*
|
|
22
|
+
* @param packageName - the optional peer, e.g. `"jose"`.
|
|
23
|
+
* @param feature - the `warlock add` feature that installs it, e.g. `"auth-google"`.
|
|
24
|
+
* @throws AuthProviderSdkMissingError when the package is not installed.
|
|
25
|
+
*/
|
|
26
|
+
async function loadOptionalPeer(packageName, feature) {
|
|
27
|
+
try {
|
|
28
|
+
return await import(packageName);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (isPackageMissing(error, packageName)) throw new AuthProviderSdkMissingError(packageName, feature, error);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
export { loadOptionalPeer };
|
|
37
|
+
//# sourceMappingURL=optional-peer.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optional-peer.mjs","names":[],"sources":["../../../../../../../auth/src/services/optional-peer.ts"],"sourcesContent":["import { AuthProviderSdkMissingError } from \"../errors/auth-provider-sdk-missing.error\";\n\n/**\n * Whether `error` (or an error it wraps as `cause`) is the resolver reporting\n * that `packageName` ITSELF is absent. A missing transitive dependency names\n * a different package and is not treated as \"not installed\".\n */\nfunction isPackageMissing(error: unknown, packageName: string): boolean {\n for (let current = error, depth = 0; current && depth < 5; depth++) {\n const { code, message } = current as { code?: unknown; message?: unknown };\n\n if (\n (code === \"ERR_MODULE_NOT_FOUND\" || code === \"MODULE_NOT_FOUND\") &&\n typeof message === \"string\" &&\n message.includes(`'${packageName}'`)\n ) {\n return true;\n }\n\n current = (current as { cause?: unknown }).cause;\n }\n\n return false;\n}\n\n/**\n * Load an optional vendor SDK lazily. The specifier reaches `import()` as a\n * variable, so neither the type-checker nor a bundler ever requires the peer;\n * nothing is loaded until a login method actually runs.\n *\n * @param packageName - the optional peer, e.g. `\"jose\"`.\n * @param feature - the `warlock add` feature that installs it, e.g. `\"auth-google\"`.\n * @throws AuthProviderSdkMissingError when the package is not installed.\n */\nexport async function loadOptionalPeer<TModule extends object>(\n packageName: string,\n feature: string,\n): Promise<TModule> {\n try {\n return (await import(packageName)) as TModule;\n } catch (error) {\n if (isPackageMissing(error, packageName)) {\n throw new AuthProviderSdkMissingError(packageName, feature, error);\n }\n\n throw error;\n }\n}\n"],"mappings":";;;;;;;;AAOA,SAAS,iBAAiB,OAAgB,aAA8B;CACtE,KAAK,IAAI,UAAU,OAAO,QAAQ,GAAG,WAAW,QAAQ,GAAG,SAAS;EAClE,MAAM,EAAE,MAAM,YAAY;EAE1B,KACG,SAAS,0BAA0B,SAAS,uBAC7C,OAAO,YAAY,YACnB,QAAQ,SAAS,IAAI,YAAY,EAAE,GAEnC,OAAO;EAGT,UAAW,QAAgC;CAC7C;CAEA,OAAO;AACT;;;;;;;;;;AAWA,eAAsB,iBACpB,aACA,SACkB;CAClB,IAAI;EACF,OAAQ,MAAM,OAAO;CACvB,SAAS,OAAO;EACd,IAAI,iBAAiB,OAAO,WAAW,GACrC,MAAM,IAAI,4BAA4B,aAAa,SAAS,KAAK;EAGnE,MAAM;CACR;AACF"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Auth } from "../models/auth.model.mjs";
|
|
2
|
+
import { ChildModel } from "@warlock.js/cascade";
|
|
3
|
+
|
|
4
|
+
//#region ../auth/src/services/password-reset.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Start a password reset for the account whose `auth.passwordReset.identifierField`
|
|
7
|
+
* (default `email`) equals `identifier`: invalidate the account's unused reset
|
|
8
|
+
* tokens, issue a new one, and send it through the reset notification.
|
|
9
|
+
*
|
|
10
|
+
* **Anti-enumeration:** resolves `undefined` whether or not the account exists
|
|
11
|
+
* — an unknown identifier simply sends nothing. Notification availability is
|
|
12
|
+
* checked before the lookup, so a misconfigured app fails identically for both.
|
|
13
|
+
*
|
|
14
|
+
* @throws NotificationsUnavailableError when `@warlock.js/notifications` is not installed or not configured.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* await requestPasswordReset(User, request.input("email"));
|
|
18
|
+
* return response.success({ message: "If the account exists, we sent a reset link." });
|
|
19
|
+
*/
|
|
20
|
+
declare function requestPasswordReset<T extends Auth>(Model: ChildModel<T>, identifier: string): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Complete a password reset: consume the token, store the new password, then
|
|
23
|
+
* revoke every access token, refresh token and cookie session the user holds
|
|
24
|
+
* (`authService.revokeAllTokens`). Resolves to the user.
|
|
25
|
+
*
|
|
26
|
+
* Validate password strength BEFORE calling this — the token is consumed first.
|
|
27
|
+
*
|
|
28
|
+
* @throws InvalidOneTimeTokenError for an unknown, verification-purpose,
|
|
29
|
+
* expired, superseded or already-used token.
|
|
30
|
+
*/
|
|
31
|
+
declare function resetPassword(token: string, newPassword: string): Promise<Auth>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { requestPasswordReset, resetPassword };
|
|
34
|
+
//# sourceMappingURL=password-reset.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"password-reset.d.mts","names":[],"sources":["../../../../../../../auth/src/services/password-reset.ts"],"mappings":";;;;;;AAwDA;;;;;;;;;;;;;iBAAsB,oBAAA,WAA+B,IAAA,EACnD,KAAA,EAAO,UAAA,CAAW,CAAA,GAClB,UAAA,WACC,OAAA;;;;AAAO;AAmCV;;;;;;iBAAsB,aAAA,CAAc,KAAA,UAAe,WAAA,WAAsB,OAAO,CAAC,IAAA"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { authConfig } from "./auth-config.mjs";
|
|
2
|
+
import { authEvents } from "./auth-events.mjs";
|
|
3
|
+
import { authService } from "./auth.service.mjs";
|
|
4
|
+
import { consumeOneTimeToken, issueOneTimeToken } from "./one-time-tokens.mjs";
|
|
5
|
+
import { resolveTokenNotification } from "./token-notifications.mjs";
|
|
6
|
+
|
|
7
|
+
//#region ../auth/src/services/password-reset.ts
|
|
8
|
+
/**
|
|
9
|
+
* Default password writer. Login verifies with `authService.verifyPassword`,
|
|
10
|
+
* so that is the authority on what "stored correctly" means:
|
|
11
|
+
*
|
|
12
|
+
* 1. Save `hashPassword(plain)` — right for a model that stores what it's given.
|
|
13
|
+
* 2. If that no longer verifies, the model hashed on save (core's
|
|
14
|
+
* `useHashedPassword()`), double-hashing it: save the plaintext so the
|
|
15
|
+
* model's own transformer hashes it once.
|
|
16
|
+
* 3. Still not verifiable ⇒ throw. Never leave a reset that "succeeded" with a
|
|
17
|
+
* password nobody can log in with.
|
|
18
|
+
*
|
|
19
|
+
* The first write is always a hash, so a model without a transformer never
|
|
20
|
+
* has plaintext written.
|
|
21
|
+
*/
|
|
22
|
+
async function writePassword(user, plainPassword) {
|
|
23
|
+
await user.merge({ password: await authService.hashPassword(plainPassword) }).save();
|
|
24
|
+
if (await authService.verifyPassword(plainPassword, user.string("password") ?? "")) return;
|
|
25
|
+
await user.merge({ password: plainPassword }).save();
|
|
26
|
+
const stored = user.string("password") ?? "";
|
|
27
|
+
if (stored !== plainPassword && await authService.verifyPassword(plainPassword, stored)) return;
|
|
28
|
+
throw new Error("@warlock.js/auth: resetPassword could not store a password that verifies — set `auth.passwordReset.setPassword` to write it the way your user model expects.");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Start a password reset for the account whose `auth.passwordReset.identifierField`
|
|
32
|
+
* (default `email`) equals `identifier`: invalidate the account's unused reset
|
|
33
|
+
* tokens, issue a new one, and send it through the reset notification.
|
|
34
|
+
*
|
|
35
|
+
* **Anti-enumeration:** resolves `undefined` whether or not the account exists
|
|
36
|
+
* — an unknown identifier simply sends nothing. Notification availability is
|
|
37
|
+
* checked before the lookup, so a misconfigured app fails identically for both.
|
|
38
|
+
*
|
|
39
|
+
* @throws NotificationsUnavailableError when `@warlock.js/notifications` is not installed or not configured.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* await requestPasswordReset(User, request.input("email"));
|
|
43
|
+
* return response.success({ message: "If the account exists, we sent a reset link." });
|
|
44
|
+
*/
|
|
45
|
+
async function requestPasswordReset(Model, identifier) {
|
|
46
|
+
const notification = await resolveTokenNotification("password-reset");
|
|
47
|
+
if (typeof identifier !== "string" || identifier.length === 0) return;
|
|
48
|
+
const user = await Model.first({ [authConfig.passwordReset.identifierField()]: identifier });
|
|
49
|
+
if (!user) return;
|
|
50
|
+
const { token, expiresAt } = await issueOneTimeToken(user, "password-reset", authConfig.passwordReset.expiresInMs(), true);
|
|
51
|
+
authEvents.emit("password.resetRequested", user, token);
|
|
52
|
+
const url = authConfig.passwordReset.url()?.(token, user);
|
|
53
|
+
await notification.send(user, {
|
|
54
|
+
token,
|
|
55
|
+
expiresAt,
|
|
56
|
+
...url ? { url } : {}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Complete a password reset: consume the token, store the new password, then
|
|
61
|
+
* revoke every access token, refresh token and cookie session the user holds
|
|
62
|
+
* (`authService.revokeAllTokens`). Resolves to the user.
|
|
63
|
+
*
|
|
64
|
+
* Validate password strength BEFORE calling this — the token is consumed first.
|
|
65
|
+
*
|
|
66
|
+
* @throws InvalidOneTimeTokenError for an unknown, verification-purpose,
|
|
67
|
+
* expired, superseded or already-used token.
|
|
68
|
+
*/
|
|
69
|
+
async function resetPassword(token, newPassword) {
|
|
70
|
+
const user = await consumeOneTimeToken(token, "password-reset");
|
|
71
|
+
const setPassword = authConfig.passwordReset.setPassword();
|
|
72
|
+
if (setPassword) await setPassword(user, newPassword);
|
|
73
|
+
else await writePassword(user, newPassword);
|
|
74
|
+
await authService.revokeAllTokens(user);
|
|
75
|
+
authEvents.emit("password.reset", user);
|
|
76
|
+
return user;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
export { requestPasswordReset, resetPassword };
|
|
81
|
+
//# sourceMappingURL=password-reset.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"password-reset.mjs","names":[],"sources":["../../../../../../../auth/src/services/password-reset.ts"],"sourcesContent":["import type { ChildModel } from \"@warlock.js/cascade\";\nimport type { Auth } from \"../models/auth.model\";\nimport { authConfig } from \"./auth-config\";\nimport { authEvents } from \"./auth-events\";\nimport { authService } from \"./auth.service\";\nimport { consumeOneTimeToken, issueOneTimeToken } from \"./one-time-tokens\";\nimport { resolveTokenNotification } from \"./token-notifications\";\n\n/**\n * Default password writer. Login verifies with `authService.verifyPassword`,\n * so that is the authority on what \"stored correctly\" means:\n *\n * 1. Save `hashPassword(plain)` — right for a model that stores what it's given.\n * 2. If that no longer verifies, the model hashed on save (core's\n * `useHashedPassword()`), double-hashing it: save the plaintext so the\n * model's own transformer hashes it once.\n * 3. Still not verifiable ⇒ throw. Never leave a reset that \"succeeded\" with a\n * password nobody can log in with.\n *\n * The first write is always a hash, so a model without a transformer never\n * has plaintext written.\n */\nasync function writePassword(user: Auth, plainPassword: string): Promise<void> {\n await user.merge({ password: await authService.hashPassword(plainPassword) } as never).save();\n\n if (await authService.verifyPassword(plainPassword, user.string(\"password\") ?? \"\")) return;\n\n await user.merge({ password: plainPassword } as never).save();\n\n const stored = user.string(\"password\") ?? \"\";\n\n if (stored !== plainPassword && (await authService.verifyPassword(plainPassword, stored))) {\n return;\n }\n\n throw new Error(\n \"@warlock.js/auth: resetPassword could not store a password that verifies — \" +\n \"set `auth.passwordReset.setPassword` to write it the way your user model expects.\",\n );\n}\n\n/**\n * Start a password reset for the account whose `auth.passwordReset.identifierField`\n * (default `email`) equals `identifier`: invalidate the account's unused reset\n * tokens, issue a new one, and send it through the reset notification.\n *\n * **Anti-enumeration:** resolves `undefined` whether or not the account exists\n * — an unknown identifier simply sends nothing. Notification availability is\n * checked before the lookup, so a misconfigured app fails identically for both.\n *\n * @throws NotificationsUnavailableError when `@warlock.js/notifications` is not installed or not configured.\n *\n * @example\n * await requestPasswordReset(User, request.input(\"email\"));\n * return response.success({ message: \"If the account exists, we sent a reset link.\" });\n */\nexport async function requestPasswordReset<T extends Auth>(\n Model: ChildModel<T>,\n identifier: string,\n): Promise<void> {\n const notification = await resolveTokenNotification(\"password-reset\");\n\n if (typeof identifier !== \"string\" || identifier.length === 0) return;\n\n const user = (await Model.first({\n [authConfig.passwordReset.identifierField()]: identifier,\n })) as T | null;\n\n if (!user) return;\n\n const { token, expiresAt } = await issueOneTimeToken(\n user,\n \"password-reset\",\n authConfig.passwordReset.expiresInMs(),\n true,\n );\n\n authEvents.emit(\"password.resetRequested\", user, token);\n\n const url = authConfig.passwordReset.url()?.(token, user);\n\n await notification.send(user, { token, expiresAt, ...(url ? { url } : {}) });\n}\n\n/**\n * Complete a password reset: consume the token, store the new password, then\n * revoke every access token, refresh token and cookie session the user holds\n * (`authService.revokeAllTokens`). Resolves to the user.\n *\n * Validate password strength BEFORE calling this — the token is consumed first.\n *\n * @throws InvalidOneTimeTokenError for an unknown, verification-purpose,\n * expired, superseded or already-used token.\n */\nexport async function resetPassword(token: string, newPassword: string): Promise<Auth> {\n const user = await consumeOneTimeToken(token, \"password-reset\");\n\n const setPassword = authConfig.passwordReset.setPassword();\n\n if (setPassword) {\n await setPassword(user, newPassword);\n } else {\n await writePassword(user, newPassword);\n }\n\n await authService.revokeAllTokens(user);\n\n authEvents.emit(\"password.reset\", user);\n\n return user;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsBA,eAAe,cAAc,MAAY,eAAsC;CAC7E,MAAM,KAAK,MAAM,EAAE,UAAU,MAAM,YAAY,aAAa,aAAa,EAAE,CAAU,CAAC,CAAC,KAAK;CAE5F,IAAI,MAAM,YAAY,eAAe,eAAe,KAAK,OAAO,UAAU,KAAK,EAAE,GAAG;CAEpF,MAAM,KAAK,MAAM,EAAE,UAAU,cAAc,CAAU,CAAC,CAAC,KAAK;CAE5D,MAAM,SAAS,KAAK,OAAO,UAAU,KAAK;CAE1C,IAAI,WAAW,iBAAkB,MAAM,YAAY,eAAe,eAAe,MAAM,GACrF;CAGF,MAAM,IAAI,MACR,8JAEF;AACF;;;;;;;;;;;;;;;;AAiBA,eAAsB,qBACpB,OACA,YACe;CACf,MAAM,eAAe,MAAM,yBAAyB,gBAAgB;CAEpE,IAAI,OAAO,eAAe,YAAY,WAAW,WAAW,GAAG;CAE/D,MAAM,OAAQ,MAAM,MAAM,MAAM,GAC7B,WAAW,cAAc,gBAAgB,IAAI,WAChD,CAAC;CAED,IAAI,CAAC,MAAM;CAEX,MAAM,EAAE,OAAO,cAAc,MAAM,kBACjC,MACA,kBACA,WAAW,cAAc,YAAY,GACrC,IACF;CAEA,WAAW,KAAK,2BAA2B,MAAM,KAAK;CAEtD,MAAM,MAAM,WAAW,cAAc,IAAI,CAAC,GAAG,OAAO,IAAI;CAExD,MAAM,aAAa,KAAK,MAAM;EAAE;EAAO;EAAW,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;CAAG,CAAC;AAC7E;;;;;;;;;;;AAYA,eAAsB,cAAc,OAAe,aAAoC;CACrF,MAAM,OAAO,MAAM,oBAAoB,OAAO,gBAAgB;CAE9D,MAAM,cAAc,WAAW,cAAc,YAAY;CAEzD,IAAI,aACF,MAAM,YAAY,MAAM,WAAW;MAEnC,MAAM,cAAc,MAAM,WAAW;CAGvC,MAAM,YAAY,gBAAgB,IAAI;CAEtC,WAAW,KAAK,kBAAkB,IAAI;CAEtC,OAAO;AACT"}
|