@warlock.js/auth 5.16.0 → 5.17.1
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 +10 -0
- package/esm/commands/auth-cleanup-command.mjs +1 -1
- package/esm/commands/auth-purge-never-expiring-command.mjs +1 -1
- package/esm/index.d.mts +4 -1
- package/esm/index.mjs +5 -1
- package/esm/localization/ar.translations.mjs +16 -0
- package/esm/localization/ar.translations.mjs.map +1 -0
- package/esm/localization/auth-default-translations.d.mts +10 -0
- package/esm/localization/auth-default-translations.d.mts.map +1 -0
- package/esm/localization/auth-default-translations.mjs +15 -0
- package/esm/localization/auth-default-translations.mjs.map +1 -0
- package/esm/localization/auth-translations.type.d.mts +27 -0
- package/esm/localization/auth-translations.type.d.mts.map +1 -0
- package/esm/localization/en.translations.mjs +16 -0
- package/esm/localization/en.translations.mjs.map +1 -0
- package/esm/localization/index.mjs +4 -0
- package/esm/localization/init.mjs +8 -0
- package/esm/localization/init.mjs.map +1 -0
- package/esm/localization/register-auth-translations.d.mts +17 -0
- package/esm/localization/register-auth-translations.d.mts.map +1 -0
- package/esm/localization/register-auth-translations.mjs +29 -0
- package/esm/localization/register-auth-translations.mjs.map +1 -0
- package/esm/middleware/auth.middleware.d.mts.map +1 -1
- package/esm/middleware/auth.middleware.mjs +4 -2
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/csrf-origin-check.d.mts +9 -0
- package/esm/middleware/csrf-origin-check.d.mts.map +1 -1
- package/esm/middleware/csrf-origin-check.mjs +12 -56
- package/esm/middleware/csrf-origin-check.mjs.map +1 -1
- package/esm/middleware/localized-login-path.mjs +48 -0
- package/esm/middleware/localized-login-path.mjs.map +1 -0
- package/esm/models/access-token/access-token.model.d.mts +7 -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/one-time-token/one-time-token.model.d.mts +6 -0
- package/esm/models/one-time-token/one-time-token.model.d.mts.map +1 -1
- package/esm/models/one-time-token/one-time-token.model.mjs +3 -0
- package/esm/models/one-time-token/one-time-token.model.mjs.map +1 -1
- 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/services/auth.service.mjs +1 -1
- package/esm/services/generate-jwt-secret.mjs +1 -1
- package/package.json +8 -8
- package/skills/throttle-login-attempts/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to `@warlock.js/auth` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 5.17.0 - 2026-09-21
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- The CSRF Origin/Referer check `authMiddleware("cookie:*")` runs (`assertCsrfOriginAllowed`) now delegates its same-origin/`auth.csrf.allowedOrigins` comparison to `@warlock.js/core`'s `resolveCsrfOriginVerdict`, shared with core's new default CSRF-Origin guard (card 8a752ab2, see `@warlock.js/core`'s 5.17.0 changelog) so the two checks can never drift apart. Behaviour is unchanged for routes already using `authMiddleware("cookie:*")`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The `pageAuth` login redirect now carries the request's locale prefix when `@warlock.js/web`'s `web.localeRouting.strategy` is active. Before, an anonymous request to a locale-prefixed page (e.g. `/ar/admin`) always redirected to the bare `auth.pageAuth.loginPath` (e.g. `/login?returnUrl=%2Far%2Fadmin`), dropping the locale even though `returnUrl` kept it. It now redirects to the locale-prefixed login path (`/ar/login?returnUrl=%2Far%2Fadmin`) — reading `web.localeRouting.strategy` / `app.localeCodes` / `app.localeCode` from config directly (`auth` has no dependency on `web`, in either direction). Left unchanged when `loginPath` is absolute (`http…`) or already locale-prefixed.
|
|
16
|
+
|
|
7
17
|
## 5.16.0 - 2026-09-18
|
|
8
18
|
|
|
9
19
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { authService } from "../services/auth.service.mjs";
|
|
2
|
-
import { colors } from "@mongez/copper";
|
|
3
2
|
import { command } from "@warlock.js/core";
|
|
3
|
+
import { colors } from "@mongez/copper";
|
|
4
4
|
|
|
5
5
|
//#region ../auth/src/commands/auth-purge-never-expiring-command.ts
|
|
6
6
|
/**
|
package/esm/index.d.mts
CHANGED
|
@@ -17,6 +17,9 @@ import { InvalidPasskeyError } from "./errors/invalid-passkey.error.mjs";
|
|
|
17
17
|
import { InvalidProviderCallbackError } from "./errors/invalid-provider-callback.error.mjs";
|
|
18
18
|
import { NotificationsUnavailableError } from "./errors/notifications-unavailable.error.mjs";
|
|
19
19
|
import { ProviderEmailNotVerifiedError } from "./errors/provider-email-not-verified.error.mjs";
|
|
20
|
+
import { AuthErrorTranslations, AuthTranslations } from "./localization/auth-translations.type.mjs";
|
|
21
|
+
import { authDefaultTranslations } from "./localization/auth-default-translations.mjs";
|
|
22
|
+
import { registerAuthTranslations } from "./localization/register-auth-translations.mjs";
|
|
20
23
|
import { authMiddleware } from "./middleware/auth.middleware.mjs";
|
|
21
24
|
import { CsrfOriginMismatchError, CsrfOriginMismatchReason, assertCsrfOriginAllowed, requiresCsrfOriginCheck } from "./middleware/csrf-origin-check.mjs";
|
|
22
25
|
import { LoginThrottleIdentifier, LoginThrottleOptions, loginThrottleMiddleware } from "./middleware/login-throttle.middleware.mjs";
|
|
@@ -48,5 +51,5 @@ import { IssuedOneTimeToken, consumeOneTimeToken, hashOneTimeToken, issueOneTime
|
|
|
48
51
|
import { requestPasswordReset, resetPassword } from "./services/password-reset.mjs";
|
|
49
52
|
import { safeEqual } from "./utils/safe-equal.mjs";
|
|
50
53
|
import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./utils/token-expiry.mjs";
|
|
51
|
-
export { APPLE_AUTH_FEATURE, AccessToken, AccessTokenConfig, AccessTokenOutput, AppleJoseModule, AppleProvider, AppleProviderConfig, Auth, AuthConfigurations, AuthCredentials, AuthErrorCodes, AuthEventCallback, AuthEventName, AuthEventPayloads, AuthNotification, AuthProvider, AuthProviderSdkMissingError, Authenticable, CanAuthenticate, ClearAuthCookieOptions, CookieAuthConfig, CsrfConfig, CsrfOriginMismatchError, CsrfOriginMismatchReason, DeviceInfo, DiscordProvider, DiscordProviderConfig, EmailNotVerifiedError, EmailVerificationConfig, FacebookProvider, FacebookProviderConfig, GOOGLE_AUTH_FEATURE, GitHubProvider, GitHubProviderConfig, GoogleProvider, GoogleProviderConfig, InvalidOneTimeTokenError, InvalidPasskeyError, InvalidProviderCallbackError, IssuedOneTimeToken, JoseModule, LINKEDIN_AUTH_FEATURE, LegacyJwtConfig, LinkedInProvider, LinkedInProviderConfig, LoginResult, LoginThrottleIdentifier, LoginThrottleOptions, LogoutWithoutTokenBehavior, NO_EXPIRATION, NotificationsUnavailableError, OneTimeToken, OneTimeTokenConfig, OneTimeTokenNotificationData, OneTimeTokenPurpose, OneTimeTokenUrlBuilder, OtpConfig, OtpMessage, OtpSender, PageAuthConfig, PasskeyCredential, type PasskeyOptionsJSON, type PasskeyResponseJSON, PasskeysConfig, PasswordResetConfig, PasswordSetter, ProviderAccount, ProviderAuthorizationState, ProviderCallbackParams, ProviderEmailNotVerifiedError, ProviderProfile, ProviderUserCreator, ProvidersConfig, RefreshToken, RefreshTokenConfig, RefreshTokenIssueOptions, RequestUser, SetAuthCookieOptions, TokenFrom, TokenPair, TokenType, TokenTypeError, XProvider, XProviderConfig, accessTokenSchema, assertCsrfOriginAllowed, authEvents, authMiddleware, authMigrations, authService, completeProviderLogin, consumeOneTimeToken, currentUser, generateJWTSecret, generatePasskeyAuthenticationOptions, generatePasskeyRegistrationOptions, hashOneTimeToken, isEmailVerified, isInvalidCredentialError, isNeverExpiring, isUsableExpiry, issueOneTimeToken, jwt, loginThrottleMiddleware, oneTimeTokenModel, oneTimeTokenSchema, otpRequestThrottleMiddleware, otpVerifyThrottleMiddleware, passkeyCredentialSchema, pkceChallenge, providerAccountSchema, refreshTokenSchema, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand, registerJWTSecretGeneratorCommand, requestOtp, requestPasswordReset, requireVerifiedEmail, requiresCsrfOriginCheck, resetPassword, resolveAuthProvider, safeEqual, sendEmailVerification, startProviderLogin, tokenConsumeThrottleMiddleware, tokenHasExpClaim, tokenIssueThrottleMiddleware, verifyEmail, verifyOtp, verifyPasskeyAuthentication, verifyPasskeyRegistration };
|
|
54
|
+
export { APPLE_AUTH_FEATURE, AccessToken, AccessTokenConfig, AccessTokenOutput, AppleJoseModule, AppleProvider, AppleProviderConfig, Auth, AuthConfigurations, AuthCredentials, AuthErrorCodes, AuthErrorTranslations, AuthEventCallback, AuthEventName, AuthEventPayloads, AuthNotification, AuthProvider, AuthProviderSdkMissingError, AuthTranslations, Authenticable, CanAuthenticate, ClearAuthCookieOptions, CookieAuthConfig, CsrfConfig, CsrfOriginMismatchError, CsrfOriginMismatchReason, DeviceInfo, DiscordProvider, DiscordProviderConfig, EmailNotVerifiedError, EmailVerificationConfig, FacebookProvider, FacebookProviderConfig, GOOGLE_AUTH_FEATURE, GitHubProvider, GitHubProviderConfig, GoogleProvider, GoogleProviderConfig, InvalidOneTimeTokenError, InvalidPasskeyError, InvalidProviderCallbackError, IssuedOneTimeToken, JoseModule, LINKEDIN_AUTH_FEATURE, LegacyJwtConfig, LinkedInProvider, LinkedInProviderConfig, LoginResult, LoginThrottleIdentifier, LoginThrottleOptions, LogoutWithoutTokenBehavior, NO_EXPIRATION, NotificationsUnavailableError, OneTimeToken, OneTimeTokenConfig, OneTimeTokenNotificationData, OneTimeTokenPurpose, OneTimeTokenUrlBuilder, OtpConfig, OtpMessage, OtpSender, PageAuthConfig, PasskeyCredential, type PasskeyOptionsJSON, type PasskeyResponseJSON, PasskeysConfig, PasswordResetConfig, PasswordSetter, ProviderAccount, ProviderAuthorizationState, ProviderCallbackParams, ProviderEmailNotVerifiedError, ProviderProfile, ProviderUserCreator, ProvidersConfig, RefreshToken, RefreshTokenConfig, RefreshTokenIssueOptions, RequestUser, SetAuthCookieOptions, TokenFrom, TokenPair, TokenType, TokenTypeError, XProvider, XProviderConfig, accessTokenSchema, assertCsrfOriginAllowed, authDefaultTranslations, authEvents, authMiddleware, authMigrations, authService, completeProviderLogin, consumeOneTimeToken, currentUser, generateJWTSecret, generatePasskeyAuthenticationOptions, generatePasskeyRegistrationOptions, hashOneTimeToken, isEmailVerified, isInvalidCredentialError, isNeverExpiring, isUsableExpiry, issueOneTimeToken, jwt, loginThrottleMiddleware, oneTimeTokenModel, oneTimeTokenSchema, otpRequestThrottleMiddleware, otpVerifyThrottleMiddleware, passkeyCredentialSchema, pkceChallenge, providerAccountSchema, refreshTokenSchema, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand, registerAuthTranslations, registerJWTSecretGeneratorCommand, requestOtp, requestPasswordReset, requireVerifiedEmail, requiresCsrfOriginCheck, resetPassword, resolveAuthProvider, safeEqual, sendEmailVerification, startProviderLogin, tokenConsumeThrottleMiddleware, tokenHasExpClaim, tokenIssueThrottleMiddleware, verifyEmail, verifyOtp, verifyPasskeyAuthentication, verifyPasskeyRegistration };
|
|
52
55
|
import "./contracts/request-locals.mjs";
|
package/esm/index.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { authDefaultTranslations } from "./localization/auth-default-translations.mjs";
|
|
2
|
+
import { registerAuthTranslations } from "./localization/register-auth-translations.mjs";
|
|
3
|
+
import "./localization/init.mjs";
|
|
1
4
|
import { isNeverExpiring, isUsableExpiry, tokenHasExpClaim } from "./utils/token-expiry.mjs";
|
|
2
5
|
import { AccessToken, accessTokenSchema } from "./models/access-token/access-token.model.mjs";
|
|
3
6
|
import { OneTimeToken, oneTimeTokenSchema } from "./models/one-time-token/one-time-token.model.mjs";
|
|
@@ -19,6 +22,7 @@ import { InvalidProviderCallbackError } from "./errors/invalid-provider-callback
|
|
|
19
22
|
import { NotificationsUnavailableError } from "./errors/notifications-unavailable.error.mjs";
|
|
20
23
|
import { ProviderEmailNotVerifiedError } from "./errors/provider-email-not-verified.error.mjs";
|
|
21
24
|
import "./errors/index.mjs";
|
|
25
|
+
import "./localization/index.mjs";
|
|
22
26
|
import { CsrfOriginMismatchError, assertCsrfOriginAllowed, requiresCsrfOriginCheck } from "./middleware/csrf-origin-check.mjs";
|
|
23
27
|
import { authMiddleware } from "./middleware/auth.middleware.mjs";
|
|
24
28
|
import { loginThrottleMiddleware } from "./middleware/login-throttle.middleware.mjs";
|
|
@@ -52,4 +56,4 @@ import { completeProviderLogin, resolveAuthProvider, startProviderLogin } from "
|
|
|
52
56
|
import "./providers/index.mjs";
|
|
53
57
|
import "./utils/index.mjs";
|
|
54
58
|
|
|
55
|
-
export { APPLE_AUTH_FEATURE, AccessToken, AppleProvider, Auth, AuthErrorCodes, AuthProviderSdkMissingError, CsrfOriginMismatchError, DiscordProvider, EmailNotVerifiedError, FacebookProvider, GOOGLE_AUTH_FEATURE, GitHubProvider, GoogleProvider, InvalidOneTimeTokenError, InvalidPasskeyError, InvalidProviderCallbackError, LINKEDIN_AUTH_FEATURE, LinkedInProvider, NO_EXPIRATION, NotificationsUnavailableError, OneTimeToken, PasskeyCredential, ProviderAccount, ProviderEmailNotVerifiedError, RefreshToken, TokenTypeError, XProvider, accessTokenSchema, assertCsrfOriginAllowed, authEvents, authMiddleware, authMigrations, authService, completeProviderLogin, consumeOneTimeToken, currentUser, generateJWTSecret, generatePasskeyAuthenticationOptions, generatePasskeyRegistrationOptions, hashOneTimeToken, isEmailVerified, isInvalidCredentialError, isNeverExpiring, isUsableExpiry, issueOneTimeToken, jwt, loginThrottleMiddleware, oneTimeTokenModel, oneTimeTokenSchema, otpRequestThrottleMiddleware, otpVerifyThrottleMiddleware, passkeyCredentialSchema, pkceChallenge, providerAccountSchema, refreshTokenSchema, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand, registerJWTSecretGeneratorCommand, requestOtp, requestPasswordReset, requireVerifiedEmail, requiresCsrfOriginCheck, resetPassword, resolveAuthProvider, safeEqual, sendEmailVerification, startProviderLogin, tokenConsumeThrottleMiddleware, tokenHasExpClaim, tokenIssueThrottleMiddleware, verifyEmail, verifyOtp, verifyPasskeyAuthentication, verifyPasskeyRegistration };
|
|
59
|
+
export { APPLE_AUTH_FEATURE, AccessToken, AppleProvider, Auth, AuthErrorCodes, AuthProviderSdkMissingError, CsrfOriginMismatchError, DiscordProvider, EmailNotVerifiedError, FacebookProvider, GOOGLE_AUTH_FEATURE, GitHubProvider, GoogleProvider, InvalidOneTimeTokenError, InvalidPasskeyError, InvalidProviderCallbackError, LINKEDIN_AUTH_FEATURE, LinkedInProvider, NO_EXPIRATION, NotificationsUnavailableError, OneTimeToken, PasskeyCredential, ProviderAccount, ProviderEmailNotVerifiedError, RefreshToken, TokenTypeError, XProvider, accessTokenSchema, assertCsrfOriginAllowed, authDefaultTranslations, authEvents, authMiddleware, authMigrations, authService, completeProviderLogin, consumeOneTimeToken, currentUser, generateJWTSecret, generatePasskeyAuthenticationOptions, generatePasskeyRegistrationOptions, hashOneTimeToken, isEmailVerified, isInvalidCredentialError, isNeverExpiring, isUsableExpiry, issueOneTimeToken, jwt, loginThrottleMiddleware, oneTimeTokenModel, oneTimeTokenSchema, otpRequestThrottleMiddleware, otpVerifyThrottleMiddleware, passkeyCredentialSchema, pkceChallenge, providerAccountSchema, refreshTokenSchema, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand, registerAuthTranslations, registerJWTSecretGeneratorCommand, requestOtp, requestPasswordReset, requireVerifiedEmail, requiresCsrfOriginCheck, resetPassword, resolveAuthProvider, safeEqual, sendEmailVerification, startProviderLogin, tokenConsumeThrottleMiddleware, tokenHasExpClaim, tokenIssueThrottleMiddleware, verifyEmail, verifyOtp, verifyPasskeyAuthentication, verifyPasskeyRegistration };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region ../auth/src/localization/ar.translations.ts
|
|
2
|
+
/**
|
|
3
|
+
* Built-in Arabic text for auth's translation keys. Applications override
|
|
4
|
+
* any of these by registering the same key in their own locale files.
|
|
5
|
+
*/
|
|
6
|
+
const authArabicTranslations = { auth: { errors: {
|
|
7
|
+
missingAccessToken: "رمز الوصول مفقود.",
|
|
8
|
+
invalidAccessToken: "رمز الوصول غير صالح أو منتهي الصلاحية.",
|
|
9
|
+
unauthorized: "غير مصرح لك بالوصول إلى هذا المورد.",
|
|
10
|
+
csrfOriginMismatch: "مصدر الطلب غير مسموح به.",
|
|
11
|
+
tooManyAttempts: "محاولات كثيرة جدًا. يرجى المحاولة مرة أخرى لاحقًا."
|
|
12
|
+
} } };
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { authArabicTranslations };
|
|
16
|
+
//# sourceMappingURL=ar.translations.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ar.translations.mjs","names":[],"sources":["../../../../../../../auth/src/localization/ar.translations.ts"],"sourcesContent":["import type { AuthTranslations } from \"./auth-translations.type\";\n\n/**\n * Built-in Arabic text for auth's translation keys. Applications override\n * any of these by registering the same key in their own locale files.\n */\nexport const authArabicTranslations: AuthTranslations = {\n auth: {\n errors: {\n missingAccessToken: \"رمز الوصول مفقود.\",\n invalidAccessToken: \"رمز الوصول غير صالح أو منتهي الصلاحية.\",\n unauthorized: \"غير مصرح لك بالوصول إلى هذا المورد.\",\n csrfOriginMismatch: \"مصدر الطلب غير مسموح به.\",\n tooManyAttempts: \"محاولات كثيرة جدًا. يرجى المحاولة مرة أخرى لاحقًا.\",\n },\n },\n};\n"],"mappings":";;;;;AAMA,MAAa,yBAA2C,EACtD,MAAM,EACJ,QAAQ;CACN,oBAAoB;CACpB,oBAAoB;CACpB,cAAc;CACd,oBAAoB;CACpB,iBAAiB;AACnB,EACF,EACF"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AuthTranslations } from "./auth-translations.type.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../auth/src/localization/auth-default-translations.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Every locale auth ships default text for, keyed by locale code.
|
|
6
|
+
*/
|
|
7
|
+
declare const authDefaultTranslations: Record<string, AuthTranslations>;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { authDefaultTranslations };
|
|
10
|
+
//# sourceMappingURL=auth-default-translations.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-default-translations.d.mts","names":[],"sources":["../../../../../../../auth/src/localization/auth-default-translations.ts"],"mappings":";;;;;AAOA;cAAa,uBAAA,EAAyB,MAAM,SAAS,gBAAA"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { authArabicTranslations } from "./ar.translations.mjs";
|
|
2
|
+
import { authEnglishTranslations } from "./en.translations.mjs";
|
|
3
|
+
|
|
4
|
+
//#region ../auth/src/localization/auth-default-translations.ts
|
|
5
|
+
/**
|
|
6
|
+
* Every locale auth ships default text for, keyed by locale code.
|
|
7
|
+
*/
|
|
8
|
+
const authDefaultTranslations = {
|
|
9
|
+
en: authEnglishTranslations,
|
|
10
|
+
ar: authArabicTranslations
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { authDefaultTranslations };
|
|
15
|
+
//# sourceMappingURL=auth-default-translations.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-default-translations.mjs","names":[],"sources":["../../../../../../../auth/src/localization/auth-default-translations.ts"],"sourcesContent":["import { authArabicTranslations } from \"./ar.translations\";\nimport type { AuthTranslations } from \"./auth-translations.type\";\nimport { authEnglishTranslations } from \"./en.translations\";\n\n/**\n * Every locale auth ships default text for, keyed by locale code.\n */\nexport const authDefaultTranslations: Record<string, AuthTranslations> = {\n en: authEnglishTranslations,\n ar: authArabicTranslations,\n};\n"],"mappings":";;;;;;;AAOA,MAAa,0BAA4D;CACvE,IAAI;CACJ,IAAI;AACN"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region ../auth/src/localization/auth-translations.type.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Text for every error key auth passes to `t()`.
|
|
4
|
+
*
|
|
5
|
+
* Every key is required so a locale shipped by auth can never silently miss
|
|
6
|
+
* one — translating a new `auth.errors.*` key means adding it here, and the
|
|
7
|
+
* compiler then demands a string in each built-in locale.
|
|
8
|
+
*/
|
|
9
|
+
type AuthErrorTranslations = {
|
|
10
|
+
/** 401 — no credential was sent. */missingAccessToken: string; /** 401 — the credential is malformed, expired, or revoked. */
|
|
11
|
+
invalidAccessToken: string; /** 401 — the credential is valid but its user type is not allowed here. */
|
|
12
|
+
unauthorized: string; /** 403 — a cookie-authenticated unsafe request came from a foreign origin. */
|
|
13
|
+
csrfOriginMismatch: string; /** 429 — login throttle lockout. */
|
|
14
|
+
tooManyAttempts: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* One locale's worth of auth translations, shaped as the keyword tree
|
|
18
|
+
* `@mongez/localization`'s `extend(localeCode, keywords)` accepts.
|
|
19
|
+
*/
|
|
20
|
+
type AuthTranslations = {
|
|
21
|
+
auth: {
|
|
22
|
+
errors: AuthErrorTranslations;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { AuthErrorTranslations, AuthTranslations };
|
|
27
|
+
//# sourceMappingURL=auth-translations.type.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-translations.type.d.mts","names":[],"sources":["../../../../../../../auth/src/localization/auth-translations.type.ts"],"mappings":";;AAOA;;;;;;KAAY,qBAAA;EAQV,oCANA,kBAAA,UAQe;EANf,kBAAA,UAaU;EAXV,YAAA;EAEA,kBAAA,UAUA;EARA,eAAA;AAAA;;AAS+B;;;KAFrB,gBAAA;EACV,IAAA;IACE,MAAA,EAAQ,qBAAqB;EAAA;AAAA"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region ../auth/src/localization/en.translations.ts
|
|
2
|
+
/**
|
|
3
|
+
* Built-in English text for auth's translation keys. Applications override
|
|
4
|
+
* any of these by registering the same key in their own locale files.
|
|
5
|
+
*/
|
|
6
|
+
const authEnglishTranslations = { auth: { errors: {
|
|
7
|
+
missingAccessToken: "Missing access token.",
|
|
8
|
+
invalidAccessToken: "Invalid or expired access token.",
|
|
9
|
+
unauthorized: "You are not authorized to access this resource.",
|
|
10
|
+
csrfOriginMismatch: "Request origin is not allowed.",
|
|
11
|
+
tooManyAttempts: "Too many attempts. Please try again later."
|
|
12
|
+
} } };
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { authEnglishTranslations };
|
|
16
|
+
//# sourceMappingURL=en.translations.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"en.translations.mjs","names":[],"sources":["../../../../../../../auth/src/localization/en.translations.ts"],"sourcesContent":["import type { AuthTranslations } from \"./auth-translations.type\";\n\n/**\n * Built-in English text for auth's translation keys. Applications override\n * any of these by registering the same key in their own locale files.\n */\nexport const authEnglishTranslations: AuthTranslations = {\n auth: {\n errors: {\n missingAccessToken: \"Missing access token.\",\n invalidAccessToken: \"Invalid or expired access token.\",\n unauthorized: \"You are not authorized to access this resource.\",\n csrfOriginMismatch: \"Request origin is not allowed.\",\n tooManyAttempts: \"Too many attempts. Please try again later.\",\n },\n },\n};\n"],"mappings":";;;;;AAMA,MAAa,0BAA4C,EACvD,MAAM,EACJ,QAAQ;CACN,oBAAoB;CACpB,oBAAoB;CACpB,cAAc;CACd,oBAAoB;CACpB,iBAAiB;AACnB,EACF,EACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"init.mjs","names":[],"sources":["../../../../../../../auth/src/localization/init.ts"],"sourcesContent":["import { registerAuthTranslations } from \"./register-auth-translations\";\n\n// Side-effect module: importing `@warlock.js/auth` registers its default\n// translations, so `t(\"auth.errors.*\")` never returns the raw key.\nregisterAuthTranslations();\n"],"mappings":";;;AAIA,yBAAyB"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region ../auth/src/localization/register-auth-translations.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Register auth's built-in translations with `@mongez/localization`, the
|
|
4
|
+
* same store `t()` from `@warlock.js/core` reads.
|
|
5
|
+
*
|
|
6
|
+
* Only keys the application has NOT already translated are added, so an app
|
|
7
|
+
* translation wins regardless of load order: registered before this runs, it
|
|
8
|
+
* is left untouched; registered after, `extend()` merges it over the default.
|
|
9
|
+
* An app locale file that predates a newer auth key therefore gets the
|
|
10
|
+
* default text for that key instead of the raw key string.
|
|
11
|
+
*
|
|
12
|
+
* Runs once at import of `@warlock.js/auth`; safe to call again.
|
|
13
|
+
*/
|
|
14
|
+
declare function registerAuthTranslations(): void;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { registerAuthTranslations };
|
|
17
|
+
//# sourceMappingURL=register-auth-translations.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"register-auth-translations.d.mts","names":[],"sources":["../../../../../../../auth/src/localization/register-auth-translations.ts"],"mappings":";;AAgBA;;;;AAAwC;;;;;;;iBAAxB,wBAAA"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { authDefaultTranslations } from "./auth-default-translations.mjs";
|
|
2
|
+
import { flatten, get, set } from "@mongez/reinforcements";
|
|
3
|
+
import { extend, getKeywordsListOf } from "@warlock.js/core";
|
|
4
|
+
|
|
5
|
+
//#region ../auth/src/localization/register-auth-translations.ts
|
|
6
|
+
/**
|
|
7
|
+
* Register auth's built-in translations with `@mongez/localization`, the
|
|
8
|
+
* same store `t()` from `@warlock.js/core` reads.
|
|
9
|
+
*
|
|
10
|
+
* Only keys the application has NOT already translated are added, so an app
|
|
11
|
+
* translation wins regardless of load order: registered before this runs, it
|
|
12
|
+
* is left untouched; registered after, `extend()` merges it over the default.
|
|
13
|
+
* An app locale file that predates a newer auth key therefore gets the
|
|
14
|
+
* default text for that key instead of the raw key string.
|
|
15
|
+
*
|
|
16
|
+
* Runs once at import of `@warlock.js/auth`; safe to call again.
|
|
17
|
+
*/
|
|
18
|
+
function registerAuthTranslations() {
|
|
19
|
+
for (const [localeCode, translations] of Object.entries(authDefaultTranslations)) {
|
|
20
|
+
const existing = getKeywordsListOf(localeCode) ?? {};
|
|
21
|
+
const missing = {};
|
|
22
|
+
for (const [key, text] of Object.entries(flatten(translations))) if (get(existing, key) === void 0) set(missing, key, text);
|
|
23
|
+
if (Object.keys(missing).length > 0) extend(localeCode, missing);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { registerAuthTranslations };
|
|
29
|
+
//# sourceMappingURL=register-auth-translations.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"register-auth-translations.mjs","names":[],"sources":["../../../../../../../auth/src/localization/register-auth-translations.ts"],"sourcesContent":["import { flatten, get, set } from \"@mongez/reinforcements\";\nimport { extend, getKeywordsListOf, type Keywords } from \"@warlock.js/core\";\nimport { authDefaultTranslations } from \"./auth-default-translations\";\n\n/**\n * Register auth's built-in translations with `@mongez/localization`, the\n * same store `t()` from `@warlock.js/core` reads.\n *\n * Only keys the application has NOT already translated are added, so an app\n * translation wins regardless of load order: registered before this runs, it\n * is left untouched; registered after, `extend()` merges it over the default.\n * An app locale file that predates a newer auth key therefore gets the\n * default text for that key instead of the raw key string.\n *\n * Runs once at import of `@warlock.js/auth`; safe to call again.\n */\nexport function registerAuthTranslations(): void {\n for (const [localeCode, translations] of Object.entries(authDefaultTranslations)) {\n const existing = getKeywordsListOf(localeCode) ?? {};\n const missing: Keywords = {};\n\n for (const [key, text] of Object.entries(flatten(translations))) {\n if (get(existing, key) === undefined) {\n set(missing, key, text);\n }\n }\n\n if (Object.keys(missing).length > 0) {\n extend(localeCode, missing);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,SAAgB,2BAAiC;CAC/C,KAAK,MAAM,CAAC,YAAY,iBAAiB,OAAO,QAAQ,uBAAuB,GAAG;EAChF,MAAM,WAAW,kBAAkB,UAAU,KAAK,CAAC;EACnD,MAAM,UAAoB,CAAC;EAE3B,KAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,QAAQ,YAAY,CAAC,GAC5D,IAAI,IAAI,UAAU,GAAG,MAAM,QACzB,IAAI,SAAS,KAAK,IAAI;EAI1B,IAAI,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,GAChC,OAAO,YAAY,OAAO;CAE9B;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"auth.middleware.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"mappings":";;;;;;AAoHA;;;;;;;;;AAGa;;;;;;;;;;iBAHG,cAAA,CACd,eAAA,qBACA,SAAA,GAAW,SAAA,GACV,UAAU"}
|
|
@@ -5,6 +5,7 @@ import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
|
|
|
5
5
|
import { isInvalidCredentialError, jwt } from "../services/jwt.mjs";
|
|
6
6
|
import { authService } from "../services/auth.service.mjs";
|
|
7
7
|
import { CsrfOriginMismatchError, assertCsrfOriginAllowed, requiresCsrfOriginCheck } from "./csrf-origin-check.mjs";
|
|
8
|
+
import { localizedLoginPath } from "./localized-login-path.mjs";
|
|
8
9
|
import { config, t } from "@warlock.js/core";
|
|
9
10
|
import { log } from "@warlock.js/logger";
|
|
10
11
|
|
|
@@ -30,9 +31,10 @@ import { log } from "@warlock.js/logger";
|
|
|
30
31
|
function rejectUnauthorized({ request, response }, payload) {
|
|
31
32
|
const loginPath = authConfig.pageAuth.loginPath();
|
|
32
33
|
if (loginPath && request.route?.isPage) {
|
|
33
|
-
const
|
|
34
|
+
const destination = localizedLoginPath(loginPath, request.locale);
|
|
35
|
+
const separator = destination.includes("?") ? "&" : "?";
|
|
34
36
|
const returnUrl = encodeURIComponent(request.url);
|
|
35
|
-
return response.redirect(`${
|
|
37
|
+
return response.redirect(`${destination}${separator}${authConfig.pageAuth.returnUrlParam()}=${returnUrl}`);
|
|
36
38
|
}
|
|
37
39
|
return response.unauthorized(payload);
|
|
38
40
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, type HttpContext, t, type Middleware, type Request } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport type { TokenFrom } from \"../contracts/types\";\r\nimport { AccessToken } from \"../models/access-token\";\r\nimport { authConfig } from \"../services/auth-config\";\r\nimport { authService } from \"../services/auth.service\";\r\nimport { isInvalidCredentialError, jwt } from \"../services/jwt\";\r\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\r\nimport {\r\n assertCsrfOriginAllowed,\r\n CsrfOriginMismatchError,\r\n requiresCsrfOriginCheck,\r\n} from \"./csrf-origin-check\";\r\n\r\n/** The 401 body shape every rejection in this middleware carries. */\r\ntype UnauthorizedPayload = { error: string; errorCode: string };\r\n\r\n/**\r\n * Reject an unauthenticated request, choosing the representation by ROUTE KIND.\r\n *\r\n * An API route always gets the JSON 401 it has always gotten. A PAGE route\r\n * (`request.route.isPage` — the React-SSR pages) instead redirects a logged-out\r\n * browser to the configured login path with a `returnUrl`, so a human\r\n * navigating to a guarded page lands on the login screen rather than reading a\r\n * raw JSON error blob — the defect in finding b9ab9804.\r\n *\r\n * The redirect is OPT-IN and backward-compatible: with no `auth.pageAuth.loginPath`\r\n * configured, even a page route falls back to the JSON 401, so an app that has\r\n * not asked for page-login UX keeps exactly its current behavior. The API 401\r\n * contract is never changed for API routes.\r\n *\r\n * `returnUrl` carries `request.url` — the relative path + query the browser was\r\n * on — never the absolute URL: a relative target cannot be turned into an\r\n * open-redirect off this app's origin.\r\n */\r\nfunction rejectUnauthorized({ request, response }: HttpContext, payload: UnauthorizedPayload) {\r\n const loginPath = authConfig.pageAuth.loginPath();\r\n\r\n if (loginPath && request.route?.isPage) {\r\n const separator = loginPath.includes(\"?\") ? \"&\" : \"?\";\r\n const returnUrl = encodeURIComponent(request.url);\r\n\r\n return response.redirect(\r\n `${loginPath}${separator}${authConfig.pageAuth.returnUrlParam()}=${returnUrl}`,\r\n );\r\n }\r\n\r\n return response.unauthorized(payload);\r\n}\r\n\r\n/**\r\n * Decoded access-token claims the middleware reads. The full payload carries\r\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\r\n */\r\ntype DecodedAccessToken = {\r\n id: string | number;\r\n userType?: string;\r\n};\r\n\r\n/**\r\n * Whether the persisted row says the token is dead.\r\n *\r\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\r\n * override that renames or reshapes its expiry column stays authoritative and\r\n * the middleware never touches a column name. A row that cannot answer at all —\r\n * an override that dropped the getter — is treated as **expired**: the failure\r\n * mode of this whole defect class was a check that quietly answered \"fine\" when\r\n * it had nothing to check, and that is not repeated here.\r\n *\r\n * This is independent of the `exp` claim required by `jwt.verify`. That guard\r\n * catches a token whose *claims* carry no deadline; this one catches a token\r\n * whose *row* says the deadline has passed — a logged-out or expired session\r\n * whose JWT is still within its own lifetime. Neither subsumes the other.\r\n */\r\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\r\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\r\n}\r\n\r\nfunction readCredential(request: Request, tokenFrom: TokenFrom): string {\r\n if (tokenFrom === \"header\") {\r\n return request.authorizationValue;\r\n }\r\n\r\n const value = request.cookie(tokenFrom.slice(\"cookie:\".length));\r\n\r\n return value ? String(value) : \"\";\r\n}\r\n\r\n/**\r\n * Build a route gate that always requires an authenticated request.\r\n *\r\n * The argument is mandatory and selects which user types may pass:\r\n * - `[]` — any authenticated user (token required, type not checked).\r\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\r\n * `userType` must be one of the listed types.\r\n *\r\n * There is no anonymous/optional mode: a request without a valid access\r\n * token is always rejected with `401`. Routes that should be public\r\n * simply omit the middleware.\r\n *\r\n * `tokenFrom` selects one credential source and defaults to `\"header\"`.\r\n * A single source avoids making credential precedence depend on array order.\r\n *\r\n * @example\r\n * router.get(\"/account\", authMiddleware([]), accountController);\r\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\r\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\r\n * router.get(\"/browser-account\", authMiddleware([], \"cookie:token\"), accountController);\r\n */\r\nexport function authMiddleware(\r\n allowedUserType: string | string[],\r\n tokenFrom: TokenFrom = \"header\",\r\n): Middleware {\r\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\r\n\r\n const auth: Middleware = async ({ request, response }) => {\r\n const authorizationValue = readCredential(request, tokenFrom);\r\n\r\n if (!authorizationValue) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.missingAccessToken\"),\r\n errorCode: AuthErrorCodes.MissingAccessToken,\r\n });\r\n }\r\n\r\n // CSRF Origin check: only in scope for a cookie-sourced\r\n // credential on an unsafe method. Header-token auth and safe methods\r\n // (GET/HEAD/OPTIONS) never reach `assertCsrfOriginAllowed`.\r\n if (requiresCsrfOriginCheck(tokenFrom, request.method)) {\r\n try {\r\n assertCsrfOriginAllowed(request);\r\n } catch (error) {\r\n if (!(error instanceof CsrfOriginMismatchError)) {\r\n throw error;\r\n }\r\n\r\n log.error(\"http\", \"auth\", error);\r\n\r\n return response.forbidden({\r\n error: t(\"auth.errors.csrfOriginMismatch\"),\r\n errorCode: AuthErrorCodes.CsrfOriginMismatch,\r\n });\r\n }\r\n }\r\n\r\n let decoded: DecodedAccessToken;\r\n\r\n // The ONLY try in this middleware, and it wraps a single call. Everything\r\n // after it is storage and configuration, where an exception means the\r\n // server is broken rather than the caller (D5).\r\n try {\r\n decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\r\n } catch (error) {\r\n if (!isInvalidCredentialError(error)) {\r\n // A DB/cache outage, a missing `JWT_SECRET`, or any other server-side\r\n // fault — not a verdict on this credential. Propagate to the request's\r\n // normal error path (a 500 monitoring can see) instead of answering\r\n // 401 and clearing the caller's session.\r\n throw error;\r\n }\r\n\r\n // A forged, malformed, expired token — or (D6) a `tokenType` mismatch —\r\n // is not an incident: it is a request carrying a credential the server\r\n // will never accept.\r\n log.error(\"http\", \"auth\", error);\r\n\r\n // Clearing rather than leaving a stale value matters: a request that\r\n // failed authentication must not carry the identity of whoever\r\n // `request.locals.user` last held (see the two-sided spec below that\r\n // pins this — removing the clear must make the previous identity\r\n // survive).\r\n request.locals.user = undefined;\r\n\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n request.decodedAccessToken = decoded;\r\n\r\n // A valid signature is not enough — the token must still exist in storage,\r\n // so deleting the row (logout) invalidates it before its JWT expiry.\r\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\r\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\r\n\r\n if (!accessToken) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n // ... and the row must still be live. Existence alone was the whole check\r\n // before 4.12.0, so a row whose own `expires_at` had passed still opened\r\n // the gate. The stored expiry is now enforced, and the dead row is\r\n // removed on the way out rather than left for the cleanup command.\r\n if (accessTokenRowIsExpired(accessToken)) {\r\n await accessToken.destroy();\r\n\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n const userType = decoded.userType ?? accessToken.userType;\r\n\r\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${userType}`);\r\n\r\n if (!UserModel) {\r\n // Configuration, not credentials. Throwing keeps a mis-registered app\r\n // loudly broken instead of quietly rejecting every request of this type.\r\n throw new Error(`User type ${userType} is unknown type.`);\r\n }\r\n\r\n const currentUser = await UserModel.find(decoded.id);\r\n\r\n if (!currentUser) {\r\n await accessToken.destroy();\r\n\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n });\r\n }\r\n\r\n if (!(await authService.canAuthenticate(currentUser))) {\r\n return rejectUnauthorized({ request, response }, {\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n });\r\n }\r\n\r\n request.locals.user = currentUser;\r\n };\r\n\r\n return auth;\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"auth.middleware.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/auth.middleware.ts"],"sourcesContent":["import { config, type HttpContext, t, type Middleware, type Request } from \"@warlock.js/core\";\r\nimport { log } from \"@warlock.js/logger\";\r\nimport type { TokenFrom } from \"../contracts/types\";\r\nimport { AccessToken } from \"../models/access-token\";\r\nimport { authConfig } from \"../services/auth-config\";\r\nimport { authService } from \"../services/auth.service\";\r\nimport { isInvalidCredentialError, jwt } from \"../services/jwt\";\r\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\r\nimport {\r\n assertCsrfOriginAllowed,\r\n CsrfOriginMismatchError,\r\n requiresCsrfOriginCheck,\r\n} from \"./csrf-origin-check\";\r\nimport { localizedLoginPath } from \"./localized-login-path\";\r\n\r\n/** The 401 body shape every rejection in this middleware carries. */\r\ntype UnauthorizedPayload = { error: string; errorCode: string };\r\n\r\n/**\r\n * Reject an unauthenticated request, choosing the representation by ROUTE KIND.\r\n *\r\n * An API route always gets the JSON 401 it has always gotten. A PAGE route\r\n * (`request.route.isPage` — the React-SSR pages) instead redirects a logged-out\r\n * browser to the configured login path with a `returnUrl`, so a human\r\n * navigating to a guarded page lands on the login screen rather than reading a\r\n * raw JSON error blob — the defect in finding b9ab9804.\r\n *\r\n * The redirect is OPT-IN and backward-compatible: with no `auth.pageAuth.loginPath`\r\n * configured, even a page route falls back to the JSON 401, so an app that has\r\n * not asked for page-login UX keeps exactly its current behavior. The API 401\r\n * contract is never changed for API routes.\r\n *\r\n * `returnUrl` carries `request.url` — the relative path + query the browser was\r\n * on — never the absolute URL: a relative target cannot be turned into an\r\n * open-redirect off this app's origin.\r\n */\r\nfunction rejectUnauthorized({ request, response }: HttpContext, payload: UnauthorizedPayload) {\r\n const loginPath = authConfig.pageAuth.loginPath();\r\n\r\n if (loginPath && request.route?.isPage) {\r\n // Under an active `web.localeRouting` strategy, the login destination\r\n // itself must carry the request's locale prefix — otherwise an anonymous\r\n // `/ar/admin` bounces to the DEFAULT locale's `/login` rather than\r\n // `/ar/login`, even though `returnUrl` below correctly points back at\r\n // `/ar/admin`. See `./localized-login-path.ts`.\r\n const destination = localizedLoginPath(loginPath, request.locale);\r\n const separator = destination.includes(\"?\") ? \"&\" : \"?\";\r\n const returnUrl = encodeURIComponent(request.url);\r\n\r\n return response.redirect(\r\n `${destination}${separator}${authConfig.pageAuth.returnUrlParam()}=${returnUrl}`,\r\n );\r\n }\r\n\r\n return response.unauthorized(payload);\r\n}\r\n\r\n/**\r\n * Decoded access-token claims the middleware reads. The full payload carries\r\n * more (`created_at`, `tokenType`, `iat`, `exp`) but only these drive routing.\r\n */\r\ntype DecodedAccessToken = {\r\n id: string | number;\r\n userType?: string;\r\n};\r\n\r\n/**\r\n * Whether the persisted row says the token is dead.\r\n *\r\n * The answer belongs to the model (`AccessToken.isExpired`), so a registered\r\n * override that renames or reshapes its expiry column stays authoritative and\r\n * the middleware never touches a column name. A row that cannot answer at all —\r\n * an override that dropped the getter — is treated as **expired**: the failure\r\n * mode of this whole defect class was a check that quietly answered \"fine\" when\r\n * it had nothing to check, and that is not repeated here.\r\n *\r\n * This is independent of the `exp` claim required by `jwt.verify`. That guard\r\n * catches a token whose *claims* carry no deadline; this one catches a token\r\n * whose *row* says the deadline has passed — a logged-out or expired session\r\n * whose JWT is still within its own lifetime. Neither subsumes the other.\r\n */\r\nfunction accessTokenRowIsExpired(accessToken: AccessToken): boolean {\r\n return typeof accessToken.isExpired === \"boolean\" ? accessToken.isExpired : true;\r\n}\r\n\r\nfunction readCredential(request: Request, tokenFrom: TokenFrom): string {\r\n if (tokenFrom === \"header\") {\r\n return request.authorizationValue;\r\n }\r\n\r\n const value = request.cookie(tokenFrom.slice(\"cookie:\".length));\r\n\r\n return value ? String(value) : \"\";\r\n}\r\n\r\n/**\r\n * Build a route gate that always requires an authenticated request.\r\n *\r\n * The argument is mandatory and selects which user types may pass:\r\n * - `[]` — any authenticated user (token required, type not checked).\r\n * - `\"admin\"` / `[\"admin\", \"staff\"]` — token required AND the user's\r\n * `userType` must be one of the listed types.\r\n *\r\n * There is no anonymous/optional mode: a request without a valid access\r\n * token is always rejected with `401`. Routes that should be public\r\n * simply omit the middleware.\r\n *\r\n * `tokenFrom` selects one credential source and defaults to `\"header\"`.\r\n * A single source avoids making credential precedence depend on array order.\r\n *\r\n * @example\r\n * router.get(\"/account\", authMiddleware([]), accountController);\r\n * router.get(\"/admin\", authMiddleware(\"admin\"), adminController);\r\n * router.get(\"/back-office\", authMiddleware([\"admin\", \"staff\"]), backOfficeController);\r\n * router.get(\"/browser-account\", authMiddleware([], \"cookie:token\"), accountController);\r\n */\r\nexport function authMiddleware(\r\n allowedUserType: string | string[],\r\n tokenFrom: TokenFrom = \"header\",\r\n): Middleware {\r\n const allowedTypes = Array.isArray(allowedUserType) ? allowedUserType : [allowedUserType];\r\n\r\n const auth: Middleware = async ({ request, response }) => {\r\n const authorizationValue = readCredential(request, tokenFrom);\r\n\r\n if (!authorizationValue) {\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.missingAccessToken\"),\r\n errorCode: AuthErrorCodes.MissingAccessToken,\r\n },\r\n );\r\n }\r\n\r\n // CSRF Origin check: only in scope for a cookie-sourced\r\n // credential on an unsafe method. Header-token auth and safe methods\r\n // (GET/HEAD/OPTIONS) never reach `assertCsrfOriginAllowed`.\r\n if (requiresCsrfOriginCheck(tokenFrom, request.method)) {\r\n try {\r\n assertCsrfOriginAllowed(request);\r\n } catch (error) {\r\n if (!(error instanceof CsrfOriginMismatchError)) {\r\n throw error;\r\n }\r\n\r\n log.error(\"http\", \"auth\", error);\r\n\r\n return response.forbidden({\r\n error: t(\"auth.errors.csrfOriginMismatch\"),\r\n errorCode: AuthErrorCodes.CsrfOriginMismatch,\r\n });\r\n }\r\n }\r\n\r\n let decoded: DecodedAccessToken;\r\n\r\n // The ONLY try in this middleware, and it wraps a single call. Everything\r\n // after it is storage and configuration, where an exception means the\r\n // server is broken rather than the caller (D5).\r\n try {\r\n decoded = await jwt.verify<DecodedAccessToken>(authorizationValue);\r\n } catch (error) {\r\n if (!isInvalidCredentialError(error)) {\r\n // A DB/cache outage, a missing `JWT_SECRET`, or any other server-side\r\n // fault — not a verdict on this credential. Propagate to the request's\r\n // normal error path (a 500 monitoring can see) instead of answering\r\n // 401 and clearing the caller's session.\r\n throw error;\r\n }\r\n\r\n // A forged, malformed, expired token — or (D6) a `tokenType` mismatch —\r\n // is not an incident: it is a request carrying a credential the server\r\n // will never accept.\r\n log.error(\"http\", \"auth\", error);\r\n\r\n // Clearing rather than leaving a stale value matters: a request that\r\n // failed authentication must not carry the identity of whoever\r\n // `request.locals.user` last held (see the two-sided spec below that\r\n // pins this — removing the clear must make the previous identity\r\n // survive).\r\n request.locals.user = undefined;\r\n\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n },\r\n );\r\n }\r\n\r\n request.decodedAccessToken = decoded;\r\n\r\n // A valid signature is not enough — the token must still exist in storage,\r\n // so deleting the row (logout) invalidates it before its JWT expiry.\r\n const AccessTokenModel = config.key(\"auth.accessToken.model\", AccessToken);\r\n const accessToken = await AccessTokenModel.findByToken(authorizationValue);\r\n\r\n if (!accessToken) {\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n },\r\n );\r\n }\r\n\r\n // ... and the row must still be live. Existence alone was the whole check\r\n // before 4.12.0, so a row whose own `expires_at` had passed still opened\r\n // the gate. The stored expiry is now enforced, and the dead row is\r\n // removed on the way out rather than left for the cleanup command.\r\n if (accessTokenRowIsExpired(accessToken)) {\r\n await accessToken.destroy();\r\n\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n },\r\n );\r\n }\r\n\r\n const userType = decoded.userType ?? accessToken.userType;\r\n\r\n if (allowedTypes.length && !allowedTypes.includes(userType)) {\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n },\r\n );\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${userType}`);\r\n\r\n if (!UserModel) {\r\n // Configuration, not credentials. Throwing keeps a mis-registered app\r\n // loudly broken instead of quietly rejecting every request of this type.\r\n throw new Error(`User type ${userType} is unknown type.`);\r\n }\r\n\r\n const currentUser = await UserModel.find(decoded.id);\r\n\r\n if (!currentUser) {\r\n await accessToken.destroy();\r\n\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.invalidAccessToken\"),\r\n errorCode: AuthErrorCodes.InvalidAccessToken,\r\n },\r\n );\r\n }\r\n\r\n if (!(await authService.canAuthenticate(currentUser))) {\r\n return rejectUnauthorized(\r\n { request, response },\r\n {\r\n error: t(\"auth.errors.unauthorized\"),\r\n errorCode: AuthErrorCodes.Unauthorized,\r\n },\r\n );\r\n }\r\n\r\n request.locals.user = currentUser;\r\n };\r\n\r\n return auth;\r\n}\r\n"],"mappings":"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"}
|
|
@@ -31,6 +31,15 @@ declare function requiresCsrfOriginCheck(tokenFrom: TokenFrom, method: string):
|
|
|
31
31
|
* Throws {@link CsrfOriginMismatchError} otherwise, including when BOTH
|
|
32
32
|
* headers are absent.
|
|
33
33
|
*
|
|
34
|
+
* Delegates the actual same-origin/allowedOrigins/trustProxy-aware
|
|
35
|
+
* comparison to `@warlock.js/core`'s `resolveCsrfOriginVerdict`
|
|
36
|
+
* (`core/src/http/csrf-origin-policy.ts`) — SECURITY card 8a752ab2 moved that
|
|
37
|
+
* logic down into `core` so this middleware's check and core's own default
|
|
38
|
+
* CSRF-Origin guard (`core/src/http/csrf-default-guard.ts`, which now runs
|
|
39
|
+
* even earlier, ahead of this middleware, for cookie-carrying unsafe-method
|
|
40
|
+
* requests generally) share one implementation instead of two copies that
|
|
41
|
+
* could drift apart.
|
|
42
|
+
*
|
|
34
43
|
* Callers gate this behind {@link requiresCsrfOriginCheck} — this function
|
|
35
44
|
* itself does not re-check the token source or the method.
|
|
36
45
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"csrf-origin-check.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/csrf-origin-check.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"csrf-origin-check.d.mts","names":[],"sources":["../../../../../../../auth/src/middleware/csrf-origin-check.ts"],"mappings":";;;;;;KAQY,wBAAA;AAAZ;;;;AAAoC;AAWpC;AAXA,cAWa,uBAAA,SAAgC,KAAA;EAAA,SAGf,MAAA,EAAQ,wBAAA;EAAA,SAF3B,IAAA,GAAI,cAAA,CAAA,kBAAA;cAEe,MAAA,EAAQ,wBAAA;AAAA;;;;;;;;iBAatB,uBAAA,CAAwB,SAAA,EAAW,SAAS,EAAE,MAAA;;;;;;AAbA;AAa9D;;;;;;;;AAA4E;AAuB5E;;;;iBAAgB,uBAAA,CAAwB,OAAgB,EAAP,OAAO"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { authConfig } from "../services/auth-config.mjs";
|
|
2
1
|
import { AuthErrorCodes } from "../utils/auth-error-codes.mjs";
|
|
2
|
+
import { resolveCsrfOriginVerdict } from "@warlock.js/core";
|
|
3
3
|
|
|
4
4
|
//#region ../auth/src/middleware/csrf-origin-check.ts
|
|
5
5
|
/** HTTP methods the CSRF Origin check exempts — reads, never state changes. */
|
|
@@ -33,71 +33,27 @@ function requiresCsrfOriginCheck(tokenFrom, method) {
|
|
|
33
33
|
return tokenFrom.startsWith("cookie:") && !SAFE_METHODS.has(method.toUpperCase());
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* The request's own origin — what an `Origin`/`Referer` header must match.
|
|
37
|
-
*
|
|
38
|
-
* `request.hostname` (core's `Request`, backed by Express/Fastify's
|
|
39
|
-
* `hostname`) never carries a port. A browser's `Origin` header on a
|
|
40
|
-
* non-default port (e.g. any `warlock dev` session) does, so the port must
|
|
41
|
-
* come from the raw `Host` header instead — core exposes no getter for that,
|
|
42
|
-
* so it is read directly here.
|
|
43
|
-
*/
|
|
44
|
-
function ownOrigin(request) {
|
|
45
|
-
const hostHeader = request.header("host");
|
|
46
|
-
const host = typeof hostHeader === "string" && hostHeader ? hostHeader : request.hostname;
|
|
47
|
-
return `${request.protocol}://${host}`;
|
|
48
|
-
}
|
|
49
|
-
/** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */
|
|
50
|
-
function originOf(rawUrl) {
|
|
51
|
-
try {
|
|
52
|
-
const url = new URL(rawUrl);
|
|
53
|
-
return `${url.protocol}//${url.host}`;
|
|
54
|
-
} catch {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Normalize an origin string (`scheme://host[:port]`) so that a default port
|
|
60
|
-
* (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin
|
|
61
|
-
* written without a port. Falls back to the raw value if it does not parse
|
|
62
|
-
* as a URL (in which case it will simply fail the exact-match comparison).
|
|
63
|
-
*/
|
|
64
|
-
function normalizeOrigin(origin) {
|
|
65
|
-
try {
|
|
66
|
-
const url = new URL(origin);
|
|
67
|
-
const isDefaultPort = url.protocol === "http:" && (url.port === "" || url.port === "80") || url.protocol === "https:" && (url.port === "" || url.port === "443");
|
|
68
|
-
return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;
|
|
69
|
-
} catch {
|
|
70
|
-
return origin;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
/** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */
|
|
74
|
-
function isAllowedOrigin(origin, request) {
|
|
75
|
-
if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;
|
|
76
|
-
return authConfig.csrf.allowedOrigins().includes(origin);
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
36
|
* CSRF Origin check for a cookie-authenticated unsafe-method request (lead
|
|
80
37
|
* decision 3). Allowed when `Origin` — or, when `Origin` is absent, `Referer`
|
|
81
38
|
* — names the request's own origin or an entry in `auth.csrf.allowedOrigins`.
|
|
82
39
|
* Throws {@link CsrfOriginMismatchError} otherwise, including when BOTH
|
|
83
40
|
* headers are absent.
|
|
84
41
|
*
|
|
42
|
+
* Delegates the actual same-origin/allowedOrigins/trustProxy-aware
|
|
43
|
+
* comparison to `@warlock.js/core`'s `resolveCsrfOriginVerdict`
|
|
44
|
+
* (`core/src/http/csrf-origin-policy.ts`) — SECURITY card 8a752ab2 moved that
|
|
45
|
+
* logic down into `core` so this middleware's check and core's own default
|
|
46
|
+
* CSRF-Origin guard (`core/src/http/csrf-default-guard.ts`, which now runs
|
|
47
|
+
* even earlier, ahead of this middleware, for cookie-carrying unsafe-method
|
|
48
|
+
* requests generally) share one implementation instead of two copies that
|
|
49
|
+
* could drift apart.
|
|
50
|
+
*
|
|
85
51
|
* Callers gate this behind {@link requiresCsrfOriginCheck} — this function
|
|
86
52
|
* itself does not re-check the token source or the method.
|
|
87
53
|
*/
|
|
88
54
|
function assertCsrfOriginAllowed(request) {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
if (isAllowedOrigin(origin, request)) return;
|
|
92
|
-
throw new CsrfOriginMismatchError("origin-mismatch");
|
|
93
|
-
}
|
|
94
|
-
const referer = request.header("referer");
|
|
95
|
-
const refererOrigin = typeof referer === "string" ? originOf(referer) : void 0;
|
|
96
|
-
if (refererOrigin) {
|
|
97
|
-
if (isAllowedOrigin(refererOrigin, request)) return;
|
|
98
|
-
throw new CsrfOriginMismatchError("referer-mismatch");
|
|
99
|
-
}
|
|
100
|
-
throw new CsrfOriginMismatchError("missing-origin-and-referer");
|
|
55
|
+
const verdict = resolveCsrfOriginVerdict(request);
|
|
56
|
+
if (!verdict.allowed) throw new CsrfOriginMismatchError(verdict.reason);
|
|
101
57
|
}
|
|
102
58
|
|
|
103
59
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"csrf-origin-check.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/csrf-origin-check.ts"],"sourcesContent":["import type
|
|
1
|
+
{"version":3,"file":"csrf-origin-check.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/csrf-origin-check.ts"],"sourcesContent":["import { resolveCsrfOriginVerdict, type Request } from \"@warlock.js/core\";\nimport type { TokenFrom } from \"../contracts/types\";\nimport { AuthErrorCodes } from \"../utils/auth-error-codes\";\n\n/** HTTP methods the CSRF Origin check exempts — reads, never state changes. */\nconst SAFE_METHODS = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/** Why {@link assertCsrfOriginAllowed} refused a request. */\nexport type CsrfOriginMismatchReason =\n | \"origin-mismatch\"\n | \"referer-mismatch\"\n | \"missing-origin-and-referer\";\n\n/**\n * A cookie-authenticated, unsafe-method request whose `Origin`/`Referer`\n * failed the check. `reason` names exactly why,\n * so an error transformer or an incident log never has to string-match the\n * message.\n */\nexport class CsrfOriginMismatchError extends Error {\n readonly code = AuthErrorCodes.CsrfOriginMismatch;\n\n constructor(public readonly reason: CsrfOriginMismatchReason) {\n super(`CSRF Origin check failed: ${reason}`);\n this.name = \"CsrfOriginMismatchError\";\n }\n}\n\n/**\n * Whether a request is in scope for the CSRF Origin check at all — only a\n * `cookie:`-sourced credential on an unsafe method (POST/PUT/PATCH/DELETE) is.\n * Header-token auth and safe methods (GET/HEAD/OPTIONS) are always exempt:\n * only a cookie-sourced credential can be silently replayed cross-site by a\n * browser, so only it needs the check.\n */\nexport function requiresCsrfOriginCheck(tokenFrom: TokenFrom, method: string): boolean {\n return tokenFrom.startsWith(\"cookie:\") && !SAFE_METHODS.has(method.toUpperCase());\n}\n\n/**\n * CSRF Origin check for a cookie-authenticated unsafe-method request (lead\n * decision 3). Allowed when `Origin` — or, when `Origin` is absent, `Referer`\n * — names the request's own origin or an entry in `auth.csrf.allowedOrigins`.\n * Throws {@link CsrfOriginMismatchError} otherwise, including when BOTH\n * headers are absent.\n *\n * Delegates the actual same-origin/allowedOrigins/trustProxy-aware\n * comparison to `@warlock.js/core`'s `resolveCsrfOriginVerdict`\n * (`core/src/http/csrf-origin-policy.ts`) — SECURITY card 8a752ab2 moved that\n * logic down into `core` so this middleware's check and core's own default\n * CSRF-Origin guard (`core/src/http/csrf-default-guard.ts`, which now runs\n * even earlier, ahead of this middleware, for cookie-carrying unsafe-method\n * requests generally) share one implementation instead of two copies that\n * could drift apart.\n *\n * Callers gate this behind {@link requiresCsrfOriginCheck} — this function\n * itself does not re-check the token source or the method.\n */\nexport function assertCsrfOriginAllowed(request: Request): void {\n const verdict = resolveCsrfOriginVerdict(request);\n\n if (!verdict.allowed) {\n throw new CsrfOriginMismatchError(verdict.reason);\n }\n}\n"],"mappings":";;;;;AAKA,MAAM,eAAe,IAAI,IAAI;CAAC;CAAO;CAAQ;AAAS,CAAC;;;;;;;AAcvD,IAAa,0BAAb,cAA6C,MAAM;CAGjD,YAAY,AAAgB,QAAkC;EAC5D,MAAM,6BAA6B,QAAQ;EADjB;;EAE1B,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,SAAgB,wBAAwB,WAAsB,QAAyB;CACrF,OAAO,UAAU,WAAW,SAAS,KAAK,CAAC,aAAa,IAAI,OAAO,YAAY,CAAC;AAClF;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,wBAAwB,SAAwB;CAC9D,MAAM,UAAU,yBAAyB,OAAO;CAEhD,IAAI,CAAC,QAAQ,SACX,MAAM,IAAI,wBAAwB,QAAQ,MAAM;AAEpD"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { config } from "@warlock.js/core";
|
|
2
|
+
|
|
3
|
+
//#region ../auth/src/middleware/localized-login-path.ts
|
|
4
|
+
/**
|
|
5
|
+
* Prefixes `loginPath` with `locale` when `web`'s locale-URL routing is
|
|
6
|
+
* active for that locale — so a guarded `/ar/admin` page redirects an
|
|
7
|
+
* anonymous request to `/ar/login`, not the bare `/login` (finding: the
|
|
8
|
+
* `returnUrl` carried the locale, the login destination itself did not).
|
|
9
|
+
*
|
|
10
|
+
* `auth` is not on `web`'s dependency graph, and `web` is not on `auth`'s
|
|
11
|
+
* (neither package declares the other, in either `dependencies` or
|
|
12
|
+
* `peerDependencies`) — so this cannot import `web`'s
|
|
13
|
+
* `resolveLocaleRouting()` / `withLocalePrefix()` / `isPrefixedLocale()`
|
|
14
|
+
* (`web/src/routing/locale-routing.ts`, `web/src/routing/locale-prefixed-paths.ts`).
|
|
15
|
+
* Instead it reads the SAME config keys those read — `web.localeRouting.strategy`,
|
|
16
|
+
* `app.localeCodes`, `app.localeCode` — by convention rather than by import,
|
|
17
|
+
* the same pattern `web/src/server/auth-cookie-name.ts` uses in the other
|
|
18
|
+
* direction for `auth.cookie.name`.
|
|
19
|
+
*
|
|
20
|
+
* Left alone (returned unchanged) when:
|
|
21
|
+
* - `loginPath` is absolute (`http://…`, `https://…`) — a redirect target
|
|
22
|
+
* outside this app has no "locale prefix" to add.
|
|
23
|
+
* - `loginPath` is already prefixed with a known locale code (an app that
|
|
24
|
+
* configured `auth.pageAuth.loginPath` as `/ar/login` itself).
|
|
25
|
+
* - `web.localeRouting.strategy` is `"none"` (the default) or unset.
|
|
26
|
+
* - `locale` is not one of `app.localeCodes`, or — under
|
|
27
|
+
* `"prefix-except-default"` — `locale` is the default locale.
|
|
28
|
+
*/
|
|
29
|
+
function localizedLoginPath(loginPath, locale) {
|
|
30
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(loginPath)) return loginPath;
|
|
31
|
+
const strategy = config.key("web.localeRouting.strategy", "none");
|
|
32
|
+
if (strategy !== "prefix" && strategy !== "prefix-except-default") return loginPath;
|
|
33
|
+
const codes = config.key("app.localeCodes", []) ?? [];
|
|
34
|
+
if (isAlreadyPrefixed(loginPath, codes)) return loginPath;
|
|
35
|
+
if (!codes.includes(locale)) return loginPath;
|
|
36
|
+
if (strategy === "prefix-except-default") {
|
|
37
|
+
if (locale === config.key("app.localeCode", "")) return loginPath;
|
|
38
|
+
}
|
|
39
|
+
return loginPath === "/" ? `/${locale}` : `/${locale}${loginPath}`;
|
|
40
|
+
}
|
|
41
|
+
/** `true` when `path` already starts with one of `codes` as its own URL segment. */
|
|
42
|
+
function isAlreadyPrefixed(path, codes) {
|
|
43
|
+
return codes.some((code) => path === `/${code}` || path.startsWith(`/${code}/`));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
export { localizedLoginPath };
|
|
48
|
+
//# sourceMappingURL=localized-login-path.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"localized-login-path.mjs","names":[],"sources":["../../../../../../../auth/src/middleware/localized-login-path.ts"],"sourcesContent":["import { config } from \"@warlock.js/core\";\n\n/**\n * Prefixes `loginPath` with `locale` when `web`'s locale-URL routing is\n * active for that locale — so a guarded `/ar/admin` page redirects an\n * anonymous request to `/ar/login`, not the bare `/login` (finding: the\n * `returnUrl` carried the locale, the login destination itself did not).\n *\n * `auth` is not on `web`'s dependency graph, and `web` is not on `auth`'s\n * (neither package declares the other, in either `dependencies` or\n * `peerDependencies`) — so this cannot import `web`'s\n * `resolveLocaleRouting()` / `withLocalePrefix()` / `isPrefixedLocale()`\n * (`web/src/routing/locale-routing.ts`, `web/src/routing/locale-prefixed-paths.ts`).\n * Instead it reads the SAME config keys those read — `web.localeRouting.strategy`,\n * `app.localeCodes`, `app.localeCode` — by convention rather than by import,\n * the same pattern `web/src/server/auth-cookie-name.ts` uses in the other\n * direction for `auth.cookie.name`.\n *\n * Left alone (returned unchanged) when:\n * - `loginPath` is absolute (`http://…`, `https://…`) — a redirect target\n * outside this app has no \"locale prefix\" to add.\n * - `loginPath` is already prefixed with a known locale code (an app that\n * configured `auth.pageAuth.loginPath` as `/ar/login` itself).\n * - `web.localeRouting.strategy` is `\"none\"` (the default) or unset.\n * - `locale` is not one of `app.localeCodes`, or — under\n * `\"prefix-except-default\"` — `locale` is the default locale.\n */\nexport function localizedLoginPath(loginPath: string, locale: string): string {\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(loginPath)) return loginPath;\n\n const strategy = config.key<string>(\"web.localeRouting.strategy\", \"none\");\n\n if (strategy !== \"prefix\" && strategy !== \"prefix-except-default\") return loginPath;\n\n const codes = config.key<string[]>(\"app.localeCodes\", []) ?? [];\n\n if (isAlreadyPrefixed(loginPath, codes)) return loginPath;\n\n if (!codes.includes(locale)) return loginPath;\n\n if (strategy === \"prefix-except-default\") {\n const defaultLocale = config.key<string>(\"app.localeCode\", \"\");\n\n if (locale === defaultLocale) return loginPath;\n }\n\n return loginPath === \"/\" ? `/${locale}` : `/${locale}${loginPath}`;\n}\n\n/** `true` when `path` already starts with one of `codes` as its own URL segment. */\nfunction isAlreadyPrefixed(path: string, codes: readonly string[]): boolean {\n return codes.some((code) => path === `/${code}` || path.startsWith(`/${code}/`));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,mBAAmB,WAAmB,QAAwB;CAC5E,IAAI,2BAA2B,KAAK,SAAS,GAAG,OAAO;CAEvD,MAAM,WAAW,OAAO,IAAY,8BAA8B,MAAM;CAExE,IAAI,aAAa,YAAY,aAAa,yBAAyB,OAAO;CAE1E,MAAM,QAAQ,OAAO,IAAc,mBAAmB,CAAC,CAAC,KAAK,CAAC;CAE9D,IAAI,kBAAkB,WAAW,KAAK,GAAG,OAAO;CAEhD,IAAI,CAAC,MAAM,SAAS,MAAM,GAAG,OAAO;CAEpC,IAAI,aAAa,yBAGf;MAAI,WAFkB,OAAO,IAAY,kBAAkB,EAEhC,GAAG,OAAO;CAAS;CAGhD,OAAO,cAAc,MAAM,IAAI,WAAW,IAAI,SAAS;AACzD;;AAGA,SAAS,kBAAkB,MAAc,OAAmC;CAC1E,OAAO,MAAM,MAAM,SAAS,SAAS,IAAI,UAAU,KAAK,WAAW,IAAI,KAAK,EAAE,CAAC;AACjF"}
|
|
@@ -54,6 +54,13 @@ declare class AccessToken extends Model {
|
|
|
54
54
|
user_type: import("@warlock.js/seal").StringValidator & import("@warlock.js/seal").StandardSchemaV1<string, string>;
|
|
55
55
|
expires_at: import("@warlock.js/seal").DateValidator & import("@warlock.js/seal").StandardSchemaV1<Date, Date>;
|
|
56
56
|
}>>;
|
|
57
|
+
/**
|
|
58
|
+
* The raw signed JWT is a bearer credential — never serialize it. `hidden`
|
|
59
|
+
* only strips it from `toJSON()` / `JSON.stringify(model)` (so
|
|
60
|
+
* `response.json(token)` or a resource cannot leak it); `get("token")`,
|
|
61
|
+
* `findByToken` and every query still read the column.
|
|
62
|
+
*/
|
|
63
|
+
static hidden: string[];
|
|
57
64
|
/**
|
|
58
65
|
* Permanent regardless of the data source default — Mongo's driver default
|
|
59
66
|
* is `"trash"`, which would copy live credential material (the JWT itself)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAaA;;;;;;cAAa,iBAAA,6BAAiB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;cAiBjB,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA
|
|
1
|
+
{"version":3,"file":"access-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"mappings":";;;;;;AAaA;;;;;;cAAa,iBAAA,6BAAiB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;cAiBjB,WAAA,SAAoB,KAAA;EAAA,OACjB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;SAQN,MAAA;;;AAXhB;;;;;SAoBgB,cAAA;;MAGH,MAAA;;MAKA,QAAA;EAwC+C;;;;;;;;;;;;;;;EAAA,IArB/C,SAAA;EA/CyB;;;;;;EAAA,IA6DzB,YAAA;;;;SAOG,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,SAAA,EAAW,IAAA,GAAI,OAAA,CAAA,WAAA;;;;SAYhD,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,WAAA;;;;SAOpC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA,WAAa,OAAA;;;;SAOvC,gBAAA,CAAiB,IAAA,EAAM,IAAA,GAAI,OAAA;;;;;SAQrB,YAAA,IAAgB,OAAA;;;;;;;;;;;;SAqBhB,iBAAA,IAAqB,OAAA,CAAQ,WAAA;;;;;;SAW7B,kBAAA,IAAsB,OAAA,CAAQ,WAAA;AAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted access-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape.\r\n *\r\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\r\n * ever read; access tokens are revoked by deleting the row.\r\n */\r\nexport const accessTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n expires_at: v.date().required(),\r\n});\r\n\r\n/**\r\n * Persisted access-token record + the data layer for access tokens.\r\n *\r\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\r\n * presented JWT against this table so deleting a row (logout) invalidates the\r\n * token immediately, before its JWT expiry. The auth service goes through the\r\n * named statics exclusively, so it never hard-codes a column name and an\r\n * override can rename/add columns by registering under\r\n * `config.auth.accessToken.model`.\r\n */\r\nexport class AccessToken extends Model {\r\n public static table = \"access_tokens\";\r\n\r\n public static schema = accessTokenSchema;\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `access_tokensTrash` on every `destroy()` (logout, expiry purge,\r\n * never-expiring purge). A revoked/expired access token must not survive\r\n * anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** The user this token was issued for. */\r\n public get userId() {\r\n return this.get(\"user_id\");\r\n }\r\n\r\n /** The user-type slug this token was issued for. */\r\n public get userType(): string {\r\n return this.get(\"user_type\");\r\n }\r\n\r\n /**\r\n * Whether the persisted expiry has passed.\r\n *\r\n * The middleware asks this on every request, which is what makes the row the\r\n * authority it always looked like it was: before 4.12.0 the gate only checked\r\n * that the row *existed*, so a row the database knew was dead still let its\r\n * token through — the database was never asked.\r\n *\r\n * **Fails closed.** A missing or unparseable `expires_at` counts as expired.\r\n * `expires_at` is `required` in the schema, so a row that cannot answer \"when\r\n * does this die\" is malformed, and the safe reading of a malformed credential\r\n * is that it is not one. This is deliberately stricter than a naive\r\n * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby\r\n * grants exactly the poisoned rows an unlimited life.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date(expiresAt).getTime() <= Date.now();\r\n }\r\n\r\n /**\r\n * Whether nothing about this row can ever retire it — an unusable\r\n * `expires_at`, or a token carrying no `exp` claim. See\r\n * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects\r\n * on.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed access token for the user.\r\n */\r\n public static issue(user: Auth, token: string, expiresAt: Date) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n expires_at: expiresAt,\r\n });\r\n }\r\n\r\n /**\r\n * Find an access-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<AccessToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Delete a specific token that belongs to the given user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete every access token belonging to the user.\r\n */\r\n public static deleteAllForUser(user: Auth) {\r\n return this.delete({ user_id: user.id });\r\n }\r\n\r\n /**\r\n * Hard-delete every expired access-token row. Returns the number removed.\r\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<number> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens.length;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}.\r\n *\r\n * **A full scan, filtered in memory, on purpose.** The defining case is a row\r\n * whose `expires_at` is an `Invalid Date`, which no date predicate can select\r\n * (`< now` and `> now` are both `false` for it), and the definitive case is a\r\n * token with no `exp` claim, which lives inside the token string rather than\r\n * in a column. Neither is expressible as a `where`, so the rows have to be\r\n * read to be judged. This runs from a one-off remediation command, not a\r\n * request path.\r\n */\r\n public static async findNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: AccessToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed so the\r\n * caller can report what it revoked. Deletion *is* revocation for access\r\n * tokens — the middleware rejects a token with no row.\r\n */\r\n public static async purgeNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;
|
|
1
|
+
{"version":3,"file":"access-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/access-token/access-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted access-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape.\r\n *\r\n * `last_access` and the `is_active` soft-revoke flag were removed — neither was\r\n * ever read; access tokens are revoked by deleting the row.\r\n */\r\nexport const accessTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n expires_at: v.date().required(),\r\n});\r\n\r\n/**\r\n * Persisted access-token record + the data layer for access tokens.\r\n *\r\n * **Role.** Owns access-token persistence and lookup. The middleware checks a\r\n * presented JWT against this table so deleting a row (logout) invalidates the\r\n * token immediately, before its JWT expiry. The auth service goes through the\r\n * named statics exclusively, so it never hard-codes a column name and an\r\n * override can rename/add columns by registering under\r\n * `config.auth.accessToken.model`.\r\n */\r\nexport class AccessToken extends Model {\r\n public static table = \"access_tokens\";\r\n\r\n public static schema = accessTokenSchema;\r\n\r\n /**\r\n * The raw signed JWT is a bearer credential — never serialize it. `hidden`\r\n * only strips it from `toJSON()` / `JSON.stringify(model)` (so\r\n * `response.json(token)` or a resource cannot leak it); `get(\"token\")`,\r\n * `findByToken` and every query still read the column.\r\n */\r\n public static hidden = [\"token\"];\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `access_tokensTrash` on every `destroy()` (logout, expiry purge,\r\n * never-expiring purge). A revoked/expired access token must not survive\r\n * anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** The user this token was issued for. */\r\n public get userId() {\r\n return this.get(\"user_id\");\r\n }\r\n\r\n /** The user-type slug this token was issued for. */\r\n public get userType(): string {\r\n return this.get(\"user_type\");\r\n }\r\n\r\n /**\r\n * Whether the persisted expiry has passed.\r\n *\r\n * The middleware asks this on every request, which is what makes the row the\r\n * authority it always looked like it was: before 4.12.0 the gate only checked\r\n * that the row *existed*, so a row the database knew was dead still let its\r\n * token through — the database was never asked.\r\n *\r\n * **Fails closed.** A missing or unparseable `expires_at` counts as expired.\r\n * `expires_at` is `required` in the schema, so a row that cannot answer \"when\r\n * does this die\" is malformed, and the safe reading of a malformed credential\r\n * is that it is not one. This is deliberately stricter than a naive\r\n * `now > expires_at`, which answers `false` for an `Invalid Date` and thereby\r\n * grants exactly the poisoned rows an unlimited life.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date(expiresAt).getTime() <= Date.now();\r\n }\r\n\r\n /**\r\n * Whether nothing about this row can ever retire it — an unusable\r\n * `expires_at`, or a token carrying no `exp` claim. See\r\n * {@link isNeverExpiring}; this is the predicate `purgeNeverExpiring` selects\r\n * on.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed access token for the user.\r\n */\r\n public static issue(user: Auth, token: string, expiresAt: Date) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n expires_at: expiresAt,\r\n });\r\n }\r\n\r\n /**\r\n * Find an access-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<AccessToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Delete a specific token that belongs to the given user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete every access token belonging to the user.\r\n */\r\n public static deleteAllForUser(user: Auth) {\r\n return this.delete({ user_id: user.id });\r\n }\r\n\r\n /**\r\n * Hard-delete every expired access-token row. Returns the number removed.\r\n * Runs from the `auth.cleanup` CLI command (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<number> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens.length;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}.\r\n *\r\n * **A full scan, filtered in memory, on purpose.** The defining case is a row\r\n * whose `expires_at` is an `Invalid Date`, which no date predicate can select\r\n * (`< now` and `> now` are both `false` for it), and the definitive case is a\r\n * token with no `exp` claim, which lives inside the token string rather than\r\n * in a column. Neither is expressible as a `where`, so the rows have to be\r\n * read to be judged. This runs from a one-off remediation command, not a\r\n * request path.\r\n */\r\n public static async findNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: AccessToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed so the\r\n * caller can report what it revoked. Deletion *is* revocation for access\r\n * tokens — the middleware rejects a token with no row.\r\n */\r\n public static async purgeNeverExpiring(): Promise<AccessToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAa,oBAAoB,EAAE,OAAO;CACxC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS;CAC3B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;AAChC,CAAC;;;;;;;;;;;AAYD,IAAa,cAAb,cAAiC,MAAM;;eACf;;;gBAEC;;;gBAQA,CAAC,OAAO;;;wBASA;;;CAG/B,IAAW,SAAS;EAClB,OAAO,KAAK,IAAI,SAAS;CAC3B;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,IAAI,WAAW;CAC7B;;;;;;;;;;;;;;;;CAiBA,IAAW,YAAqB;EAC9B,MAAM,YAAY,KAAK,IAAI,YAAY;EAEvC,IAAI,CAAC,eAAe,SAAS,GAAG,OAAO;EAEvC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI;CACnD;;;;;;;CAQA,IAAW,eAAwB;EACjC,OAAO,gBAAgB,KAAK,IAAI,OAAO,GAAG,KAAK,IAAI,YAAY,CAAC;CAClE;;;;CAKA,OAAc,MAAM,MAAY,OAAe,WAAiB;EAC9D,OAAO,KAAK,OAAO;GACjB;GACA,SAAS,KAAK;GACd,WAAW,KAAK;GAChB,YAAY;EACd,CAAC;CACH;;;;CAKA,OAAc,YAAY,OAA4C;EACpE,OAAO,KAAK,MAAM,EAAE,MAAM,CAAC;CAC7B;;;;CAKA,OAAc,cAAc,MAAY,OAAe;EACrD,OAAO,KAAK,OAAO;GAAE;GAAO,SAAS,KAAK;EAAG,CAAC;CAChD;;;;CAKA,OAAc,iBAAiB,MAAY;EACzC,OAAO,KAAK,OAAO,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC;;;;;CAMA,aAAoB,eAAgC;EAClD,MAAM,gBAAgB,MAAM,KAAK,MAAM,CAAC,CAAC,MAAM,cAAc,qBAAK,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI;EAElF,KAAK,MAAM,SAAS,eAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO,cAAc;CACvB;;;;;;;;;;;;CAaA,aAAoB,oBAA4C;EAG9D,QAAO,MAFc,KAAK,MAAM,CAAC,CAAC,IAAI,EAEzB,CAAC,QAAQ,UAAuB,MAAM,YAAY;CACjE;;;;;;CAOA,aAAoB,qBAA6C;EAC/D,MAAM,SAAS,MAAM,KAAK,kBAAkB;EAE5C,KAAK,MAAM,SAAS,QAClB,MAAM,MAAM,QAAQ;EAGtB,OAAO;CACT;AACF"}
|
|
@@ -117,6 +117,12 @@ declare class OneTimeToken extends Model {
|
|
|
117
117
|
isOptional: true;
|
|
118
118
|
};
|
|
119
119
|
}>>;
|
|
120
|
+
/**
|
|
121
|
+
* The SHA-256 of a one-time secret. For a short OTP code the hash is
|
|
122
|
+
* brute-forceable offline, so it is as sensitive as the code itself — never
|
|
123
|
+
* serialize it. Only `toJSON()` is affected; `findByHash` still queries it.
|
|
124
|
+
*/
|
|
125
|
+
static hidden: string[];
|
|
120
126
|
/**
|
|
121
127
|
* Permanent regardless of the data source default — Mongo's driver default
|
|
122
128
|
* is `"trash"`, which would copy spent token hashes into
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"one-time-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"mappings":";;;;;KAMY,mBAAA;AAAZ;;;;AAAA,cAWa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoBlB,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAHtB
|
|
1
|
+
{"version":3,"file":"one-time-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"mappings":";;;;;KAMY,mBAAA;AAAZ;;;;AAAA,cAWa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoBlB,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAHtB;;;SAUgB,MAAA;;;;;;;SAQA,cAAA;;MAGH,MAAA;;MAKA,QAAA;EAuBqB;EAAA,IAlBrB,UAAA;EAgCA;EAAA,IA3BA,SAAA;EA6BM;;;;;EAhBJ,eAAA,IAAmB,OAAA;EAqCnB;EAAA,OAzBC,KAAA,CACZ,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,EACT,SAAA,UACA,SAAA,EAAW,IAAA,GAAI,OAAA,CAAA,YAAA;EAqCN;;;;EAAA,OApBG,cAAA,CACZ,OAAA,EAAS,mBAAA,EACT,SAAA,UACA,SAAA,EAAW,IAAA,EACX,IAAA,GAAO,IAAA,GAAI,OAAA,CAAA,YAAA;EAiEF;EAAA,OAnDG,aAAA,CACZ,IAAA,EAAM,IAAA,EACN,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,YAAA;EAiDR;;;;;EArCU,aAAA,CAAc,GAAA,WAAc,OAAA;EAnHJ;;;;;;EAAA,OAsIjB,UAAA,IAAc,OAAA;;SAepB,UAAA,CACZ,SAAA,UACA,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,YAAA;;;;;SAQG,mBAAA,CAAoB,IAAA,EAAM,IAAA,EAAM,OAAA,EAAS,mBAAA,GAAsB,OAAA;AAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"one-time-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport { isUsableExpiry } from \"../../utils/token-expiry\";\nimport type { Auth } from \"../auth.model\";\n\n/** What a one-time token may be used for. A token only ever satisfies its own purpose. */\nexport type OneTimeTokenPurpose =\n | \"email-verification\"\n | \"password-reset\"\n | \"otp\"\n | \"passkey-registration\"\n | \"passkey-authentication\";\n\n/**\n * Seal schema for the persisted one-time token. Exported so an override can\n * extend it (e.g. a tenant key).\n */\nexport const oneTimeTokenSchema = v.object({\n token_hash: v.string().required(),\n purpose: v.string().required(),\n // Absent only on a passkey authentication challenge (no user identified yet).\n user_id: v.scalar().optional(),\n user_type: v.string().optional(),\n expires_at: v.date().required(),\n consumed_at: v.date().optional(),\n attempts: v.int().optional(),\n});\n\n/**\n * Persisted one-time secret: email-verification / password-reset token, OTP\n * code, or passkey ceremony challenge.\n *\n * **Only a SHA-256 hash is stored** — the raw token exists in the notification\n * and nowhere else, so a leaked table cannot be replayed. Single use is\n * enforced by {@link consumeIfActive}, a conditional update in the same shape\n * as `RefreshToken.revokeIfActive`. Override via `config.auth.oneTimeToken.model`.\n */\nexport class OneTimeToken extends Model {\n public static table = \"one_time_tokens\";\n\n public static schema = oneTimeTokenSchema;\n\n /**\n * Permanent regardless of the data source default — Mongo's driver default\n * is `\"trash\"`, which would copy spent token hashes into\n * `one_time_tokensTrash` on every `destroy()`. A purged one-time token must\n * not survive anywhere.\n */\n public static deleteStrategy = \"permanent\" as const;\n\n /** The user this token was issued for. */\n public get userId() {\n return this.get(\"user_id\");\n }\n\n /** The user-type slug this token was issued for. */\n public get userType(): string {\n return this.get(\"user_type\");\n }\n\n /** Whether the token has been used (or invalidated by a newer one). */\n public get isConsumed(): boolean {\n return !!this.get(\"consumed_at\");\n }\n\n /** Whether the expiry has passed. Fails closed: an unusable `expires_at` counts as expired. */\n public get isExpired(): boolean {\n const expiresAt = this.get(\"expires_at\");\n\n if (!isUsableExpiry(expiresAt)) return true;\n\n return new Date(expiresAt).getTime() <= Date.now();\n }\n\n /**\n * Atomically mark this token consumed ONLY if nobody has yet. Resolves `true`\n * for the single caller that won; every concurrent or later caller gets\n * `false`.\n */\n public async consumeIfActive(): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const consumed = await modelClass.atomic(\n { id: this.id, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n\n return consumed > 0;\n }\n\n /** Persist a new token row. `tokenHash` is the SHA-256 hex digest, never the raw token. */\n public static issue(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user.id,\n user_type: user.userType,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /**\n * Persist a passkey ceremony challenge. `user` is omitted for an\n * authentication challenge, issued before anyone is identified.\n */\n public static issueChallenge(\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n user?: Auth,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user?.id ?? null,\n user_type: user?.userType ?? null,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /** The user's newest unconsumed token of `purpose` (at most one for OTP). */\n public static findActiveFor(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.query()\n .where({ user_id: user.id, user_type: user.userType, purpose, consumed_at: null })\n .orderBy(\"created_at\", \"desc\")\n .first();\n }\n\n /**\n * Atomically count one verify attempt, ONLY while fewer than `max` have been\n * counted and the token is unconsumed. Resolves `false` once the cap is\n * reached — of N concurrent attempts, at most `max` ever resolve `true`.\n */\n public async recordAttempt(max: number): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const counted = await modelClass.atomic(\n { id: this.id, consumed_at: null, attempts: { $lt: max } },\n { $inc: { attempts: 1 } },\n // Code-authored filter; nothing here comes from the request.\n { trustedFilter: true },\n );\n\n return counted > 0;\n }\n\n /**\n * Hard-delete every spent row — expired, or consumed (used, invalidated by a\n * newer token, or exhausted). Neither can ever be redeemed again, so nothing\n * reads them. Hard-deleted, never trashed. Returns the number removed. Runs from the `auth.cleanup` CLI\n * command (a cold batch path).\n */\n public static async purgeSpent(): Promise<number> {\n const expired = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n const consumed = await this.query().whereNotNull(\"consumed_at\").get();\n const spent = new Map([...expired, ...consumed].map((token) => [String(token.id), token]));\n\n for (const token of spent.values()) {\n // `static deleteStrategy` above already forces \"permanent\" regardless\n // of the data source default (Mongo's is \"trash\").\n await token.destroy();\n }\n\n return spent.size;\n }\n\n /** Find a token by hash, scoped to a purpose — a token of the other purpose is not found. */\n public static findByHash(\n tokenHash: string,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.first({ token_hash: tokenHash, purpose });\n }\n\n /**\n * Consume every still-unused token of `purpose` for the user, returning how\n * many were invalidated. Used before issuing a new password-reset token.\n */\n public static invalidateActiveFor(user: Auth, purpose: OneTimeTokenPurpose): Promise<number> {\n return this.atomic(\n { user_id: user.id, user_type: user.userType, purpose, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n }\n}\n"],"mappings":";;;;;;;;;AAiBA,MAAa,qBAAqB,EAAE,OAAO;CACzC,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;CAChC,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAE7B,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS;CAC/B,YAAY,EAAE,KAAK,CAAC,CAAC,SAAS;CAC9B,aAAa,EAAE,KAAK,CAAC,CAAC,SAAS;CAC/B,UAAU,EAAE,IAAI,CAAC,CAAC,SAAS;AAC7B,CAAC;;;;;;;;;;AAWD,IAAa,eAAb,cAAkC,MAAM;;eAChB;;;gBAEC;;;
|
|
1
|
+
{"version":3,"file":"one-time-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/one-time-token/one-time-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\nimport { v } from \"@warlock.js/seal\";\nimport { isUsableExpiry } from \"../../utils/token-expiry\";\nimport type { Auth } from \"../auth.model\";\n\n/** What a one-time token may be used for. A token only ever satisfies its own purpose. */\nexport type OneTimeTokenPurpose =\n | \"email-verification\"\n | \"password-reset\"\n | \"otp\"\n | \"passkey-registration\"\n | \"passkey-authentication\";\n\n/**\n * Seal schema for the persisted one-time token. Exported so an override can\n * extend it (e.g. a tenant key).\n */\nexport const oneTimeTokenSchema = v.object({\n token_hash: v.string().required(),\n purpose: v.string().required(),\n // Absent only on a passkey authentication challenge (no user identified yet).\n user_id: v.scalar().optional(),\n user_type: v.string().optional(),\n expires_at: v.date().required(),\n consumed_at: v.date().optional(),\n attempts: v.int().optional(),\n});\n\n/**\n * Persisted one-time secret: email-verification / password-reset token, OTP\n * code, or passkey ceremony challenge.\n *\n * **Only a SHA-256 hash is stored** — the raw token exists in the notification\n * and nowhere else, so a leaked table cannot be replayed. Single use is\n * enforced by {@link consumeIfActive}, a conditional update in the same shape\n * as `RefreshToken.revokeIfActive`. Override via `config.auth.oneTimeToken.model`.\n */\nexport class OneTimeToken extends Model {\n public static table = \"one_time_tokens\";\n\n public static schema = oneTimeTokenSchema;\n\n /**\n * The SHA-256 of a one-time secret. For a short OTP code the hash is\n * brute-forceable offline, so it is as sensitive as the code itself — never\n * serialize it. Only `toJSON()` is affected; `findByHash` still queries it.\n */\n public static hidden = [\"token_hash\"];\n\n /**\n * Permanent regardless of the data source default — Mongo's driver default\n * is `\"trash\"`, which would copy spent token hashes into\n * `one_time_tokensTrash` on every `destroy()`. A purged one-time token must\n * not survive anywhere.\n */\n public static deleteStrategy = \"permanent\" as const;\n\n /** The user this token was issued for. */\n public get userId() {\n return this.get(\"user_id\");\n }\n\n /** The user-type slug this token was issued for. */\n public get userType(): string {\n return this.get(\"user_type\");\n }\n\n /** Whether the token has been used (or invalidated by a newer one). */\n public get isConsumed(): boolean {\n return !!this.get(\"consumed_at\");\n }\n\n /** Whether the expiry has passed. Fails closed: an unusable `expires_at` counts as expired. */\n public get isExpired(): boolean {\n const expiresAt = this.get(\"expires_at\");\n\n if (!isUsableExpiry(expiresAt)) return true;\n\n return new Date(expiresAt).getTime() <= Date.now();\n }\n\n /**\n * Atomically mark this token consumed ONLY if nobody has yet. Resolves `true`\n * for the single caller that won; every concurrent or later caller gets\n * `false`.\n */\n public async consumeIfActive(): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const consumed = await modelClass.atomic(\n { id: this.id, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n\n return consumed > 0;\n }\n\n /** Persist a new token row. `tokenHash` is the SHA-256 hex digest, never the raw token. */\n public static issue(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user.id,\n user_type: user.userType,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /**\n * Persist a passkey ceremony challenge. `user` is omitted for an\n * authentication challenge, issued before anyone is identified.\n */\n public static issueChallenge(\n purpose: OneTimeTokenPurpose,\n tokenHash: string,\n expiresAt: Date,\n user?: Auth,\n ) {\n return this.create({\n token_hash: tokenHash,\n purpose,\n user_id: user?.id ?? null,\n user_type: user?.userType ?? null,\n expires_at: expiresAt,\n consumed_at: null,\n attempts: 0,\n });\n }\n\n /** The user's newest unconsumed token of `purpose` (at most one for OTP). */\n public static findActiveFor(\n user: Auth,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.query()\n .where({ user_id: user.id, user_type: user.userType, purpose, consumed_at: null })\n .orderBy(\"created_at\", \"desc\")\n .first();\n }\n\n /**\n * Atomically count one verify attempt, ONLY while fewer than `max` have been\n * counted and the token is unconsumed. Resolves `false` once the cap is\n * reached — of N concurrent attempts, at most `max` ever resolve `true`.\n */\n public async recordAttempt(max: number): Promise<boolean> {\n const modelClass = this.constructor as typeof OneTimeToken;\n\n const counted = await modelClass.atomic(\n { id: this.id, consumed_at: null, attempts: { $lt: max } },\n { $inc: { attempts: 1 } },\n // Code-authored filter; nothing here comes from the request.\n { trustedFilter: true },\n );\n\n return counted > 0;\n }\n\n /**\n * Hard-delete every spent row — expired, or consumed (used, invalidated by a\n * newer token, or exhausted). Neither can ever be redeemed again, so nothing\n * reads them. Hard-deleted, never trashed. Returns the number removed. Runs from the `auth.cleanup` CLI\n * command (a cold batch path).\n */\n public static async purgeSpent(): Promise<number> {\n const expired = await this.query().where(\"expires_at\", \"<\", new Date()).get();\n const consumed = await this.query().whereNotNull(\"consumed_at\").get();\n const spent = new Map([...expired, ...consumed].map((token) => [String(token.id), token]));\n\n for (const token of spent.values()) {\n // `static deleteStrategy` above already forces \"permanent\" regardless\n // of the data source default (Mongo's is \"trash\").\n await token.destroy();\n }\n\n return spent.size;\n }\n\n /** Find a token by hash, scoped to a purpose — a token of the other purpose is not found. */\n public static findByHash(\n tokenHash: string,\n purpose: OneTimeTokenPurpose,\n ): Promise<OneTimeToken | null> {\n return this.first({ token_hash: tokenHash, purpose });\n }\n\n /**\n * Consume every still-unused token of `purpose` for the user, returning how\n * many were invalidated. Used before issuing a new password-reset token.\n */\n public static invalidateActiveFor(user: Auth, purpose: OneTimeTokenPurpose): Promise<number> {\n return this.atomic(\n { user_id: user.id, user_type: user.userType, purpose, consumed_at: null },\n { $set: { consumed_at: new Date() } },\n );\n }\n}\n"],"mappings":"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"}
|
|
@@ -139,6 +139,13 @@ declare class RefreshToken extends Model {
|
|
|
139
139
|
isOptional: true;
|
|
140
140
|
};
|
|
141
141
|
}>>;
|
|
142
|
+
/**
|
|
143
|
+
* The raw signed refresh JWT is a bearer credential — never serialize it.
|
|
144
|
+
* `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a
|
|
145
|
+
* session list returned via `response.json`); `get("token")`,
|
|
146
|
+
* `findByToken` / `findForUser` and every query still read the column.
|
|
147
|
+
*/
|
|
148
|
+
static hidden: string[];
|
|
142
149
|
/**
|
|
143
150
|
* Permanent regardless of the data source default — Mongo's driver default
|
|
144
151
|
* is `"trash"`, which would copy live credential material (the JWT itself)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"refresh-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"mappings":";;;;;;;AAkBA;;;;;;;;;;cAAa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAcnB,wBAAA;EACV,QAAA;EACA,SAAA;EACA,UAAA,GAAa,UAAU;AAAA;;;;;;;;;;;cAaZ,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAQN,cAAA;;MAGH,QAAA
|
|
1
|
+
{"version":3,"file":"refresh-token.model.d.mts","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"mappings":";;;;;;;AAkBA;;;;;;;;;;cAAa,kBAAA,6BAAkB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAcnB,wBAAA;EACV,QAAA;EACA,SAAA;EACA,UAAA,GAAa,UAAU;AAAA;;;;;;;;;;;cAaZ,YAAA,SAAqB,KAAA;EAAA,OAClB,KAAA;EAAA,OAEA,MAAA,6BAAM,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAQN,MAAA;;;;;;;SAQA,cAAA;;MAGH,QAAA;EAtCuB;;;;;;;;AAGX;AAazB;EAhBoC,IAoDvB,SAAA;;;;;MAYA,YAAA;;MAKA,SAAA;;MAKA,OAAA;;;;EAOE,MAAA,IAAU,OAAA;;;;;;;;EAWV,cAAA,IAAkB,OAAA;;;;EAclB,UAAA,IAAc,OAAA;EAzBJ;;;EAAA,OAgCT,KAAA,CAAM,IAAA,EAAM,IAAA,EAAM,KAAA,UAAe,OAAA,EAAS,wBAAA,GAAwB,OAAA,CAAA,YAAA;EAAxB;;;EAAA,OAoB1C,WAAA,CAAY,KAAA,WAAgB,OAAA,CAAQ,YAAA;EAAR;;;;EAAA,OAQ5B,WAAA,CAAY,IAAA,EAAM,IAAA,EAAM,KAAA,WAAgB,OAAA,CAAQ,YAAA;EAOT;;;EAAA,OAAvC,aAAA,CAAc,IAAA,EAAM,IAAA,EAAM,KAAA,WAAa,OAAA;EAuBd;;;EAAA,OAhBzB,SAAA,CAAU,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EAgCO;;;;;;;;EAAA,OAhBhC,YAAA,CAAa,IAAA,EAAM,IAAA,GAAO,OAAA,CAAQ,YAAA;EA3JtB;;;;EAAA,OA2KZ,YAAA,CAAa,QAAA,WAAmB,OAAA,CAAQ,YAAA;EAxK9C;;;;;EAAA,OAuLM,UAAA,CAAW,IAAA,EAAM,IAAA,EAAM,GAAA,WAAc,OAAA;;;;;;SAoBrC,YAAA,IAAgB,OAAA,CAAQ,YAAA;;;;;;;SAgBxB,iBAAA,IAAqB,OAAA,CAAQ,YAAA;;;;;;;;;;SAe7B,kBAAA,IAAsB,OAAA,CAAQ,YAAA;AAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { DeviceInfo } from \"../../contracts/types\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted refresh-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape:\r\n *\r\n * @example\r\n * export class AppRefreshToken extends RefreshToken {\r\n * public static schema = refreshTokenSchema.extend({\r\n * organization_id: v.string().required(),\r\n * });\r\n * }\r\n */\r\nexport const refreshTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n family_id: v.string().required(),\r\n expires_at: v.date().required(),\r\n last_used_at: v.date().default(() => new Date()),\r\n revoked_at: v.date().optional(),\r\n device_info: v.record(v.any()).optional(),\r\n});\r\n\r\n/**\r\n * Extra attributes captured when a refresh token is issued.\r\n */\r\nexport type RefreshTokenIssueOptions = {\r\n familyId: string;\r\n expiresAt: string;\r\n deviceInfo?: DeviceInfo;\r\n};\r\n\r\n/**\r\n * Persisted refresh-token record + the data layer for refresh tokens.\r\n *\r\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\r\n * The auth service drives refresh-token state exclusively through this model's\r\n * named statics and instance methods, so it never hard-codes a column name —\r\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\r\n * structurally impossible and lets an override rename or add columns by\r\n * extending this class and registering it under `config.auth.refreshToken.model`.\r\n */\r\nexport class RefreshToken extends Model {\r\n public static table = \"refresh_tokens\";\r\n\r\n public static schema = refreshTokenSchema;\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\r\n * never-expiring purge). A purged refresh token must not survive anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** Token family this row belongs to (rotation / replay grouping). */\r\n public get familyId(): string {\r\n return this.get(\"family_id\");\r\n }\r\n\r\n /**\r\n * Whether the token's `expires_at` is in the past.\r\n *\r\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\r\n * counts as expired. It previously answered `false` for both — \"no expiry\r\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\r\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\r\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\r\n * that cannot say when it dies is malformed, not immortal.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date().getTime() > new Date(expiresAt).getTime();\r\n }\r\n\r\n /**\r\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\r\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /** Whether the token has been revoked. */\r\n public get isRevoked(): boolean {\r\n return !!this.get(\"revoked_at\");\r\n }\r\n\r\n /** Whether the token is still usable (not expired and not revoked). */\r\n public get isValid(): boolean {\r\n return !this.isExpired && !this.isRevoked;\r\n }\r\n\r\n /**\r\n * Unconditionally stamp `revoked_at` on this token.\r\n */\r\n public async revoke(): Promise<this> {\r\n return this.merge({ revoked_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\r\n * when this call performed the revoke, `false` when a concurrent request had\r\n * already revoked it — the win/lose signal that powers rotation replay\r\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\r\n * same token can never both succeed.\r\n */\r\n public async revokeIfActive(): Promise<boolean> {\r\n const modelClass = this.constructor as typeof RefreshToken;\r\n\r\n const revokedCount = await modelClass.atomic(\r\n { id: this.id, revoked_at: null },\r\n { $set: { revoked_at: new Date() } },\r\n );\r\n\r\n return revokedCount > 0;\r\n }\r\n\r\n /**\r\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\r\n */\r\n public async markAsUsed(): Promise<void> {\r\n await this.merge({ last_used_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed refresh token for the user.\r\n */\r\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n family_id: options.familyId,\r\n expires_at: options.expiresAt,\r\n device_info: options.deviceInfo\r\n ? {\r\n userAgent: options.deviceInfo.userAgent,\r\n ip: options.deviceInfo.ip,\r\n deviceId: options.deviceInfo.deviceId,\r\n }\r\n : undefined,\r\n });\r\n }\r\n\r\n /**\r\n * Find a refresh-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<RefreshToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Find a refresh token scoped to a user — used by logout so a caller can only\r\n * revoke a token that actually belongs to them.\r\n */\r\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\r\n return this.first({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete a specific refresh token belonging to the user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\r\n return this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .where(\"expires_at\", \">\", new Date())\r\n .orderBy(\"created_at\", \"desc\")\r\n .get();\r\n }\r\n\r\n /**\r\n * Revoke every still-active refresh token for the user, returning the rows\r\n * that were revoked so the caller can emit a per-token event for each.\r\n *\r\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\r\n * `revoked_at: null` would re-query the same predicate after the update and\r\n * match nothing, returning an empty set.\r\n */\r\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\r\n const tokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke every still-active token in a family (rotation breach containment),\r\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\r\n */\r\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\r\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\r\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\r\n * small.\r\n */\r\n public static async enforceMax(user: Auth, max: number): Promise<void> {\r\n const activeTokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .orderBy(\"created_at\", \"asc\")\r\n .get();\r\n\r\n if (activeTokens.length < max) return;\r\n\r\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\r\n\r\n for (const token of tokensToRevoke) {\r\n await token.revoke();\r\n }\r\n }\r\n\r\n /**\r\n * Hard-delete every expired refresh token, returning the deleted rows so the\r\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\r\n * (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<RefreshToken[]> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}. A full\r\n * scan filtered in memory, for the reason given on\r\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\r\n * missing `exp` claim is expressible as a `where`.\r\n */\r\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: RefreshToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed.\r\n *\r\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\r\n * table has to carry, and one of the two shapes being removed is a row whose\r\n * date column cannot be compared at all — leaving it in place keeps a\r\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\r\n * these rows can no longer be presented successfully either way.\r\n */\r\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"refresh-token.model.mjs","names":[],"sources":["../../../../../../../../auth/src/models/refresh-token/refresh-token.model.ts"],"sourcesContent":["import { Model } from \"@warlock.js/cascade\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport type { DeviceInfo } from \"../../contracts/types\";\r\nimport { isNeverExpiring, isUsableExpiry } from \"../../utils/token-expiry\";\r\nimport type { Auth } from \"../auth.model\";\r\n\r\n/**\r\n * Seal schema for the persisted refresh-token record. Exported so an override\r\n * can spread it and add columns (e.g. a tenant key) without re-declaring the\r\n * base shape:\r\n *\r\n * @example\r\n * export class AppRefreshToken extends RefreshToken {\r\n * public static schema = refreshTokenSchema.extend({\r\n * organization_id: v.string().required(),\r\n * });\r\n * }\r\n */\r\nexport const refreshTokenSchema = v.object({\r\n token: v.string().required(),\r\n user_id: v.scalar().required(),\r\n user_type: v.string().required(),\r\n family_id: v.string().required(),\r\n expires_at: v.date().required(),\r\n last_used_at: v.date().default(() => new Date()),\r\n revoked_at: v.date().optional(),\r\n device_info: v.record(v.any()).optional(),\r\n});\r\n\r\n/**\r\n * Extra attributes captured when a refresh token is issued.\r\n */\r\nexport type RefreshTokenIssueOptions = {\r\n familyId: string;\r\n expiresAt: string;\r\n deviceInfo?: DeviceInfo;\r\n};\r\n\r\n/**\r\n * Persisted refresh-token record + the data layer for refresh tokens.\r\n *\r\n * **Role.** Owns every refresh-token read, write, and lifecycle transition.\r\n * The auth service drives refresh-token state exclusively through this model's\r\n * named statics and instance methods, so it never hard-codes a column name —\r\n * which is what makes a snake/camel mismatch (the historical `userId` bug)\r\n * structurally impossible and lets an override rename or add columns by\r\n * extending this class and registering it under `config.auth.refreshToken.model`.\r\n */\r\nexport class RefreshToken extends Model {\r\n public static table = \"refresh_tokens\";\r\n\r\n public static schema = refreshTokenSchema;\r\n\r\n /**\r\n * The raw signed refresh JWT is a bearer credential — never serialize it.\r\n * `hidden` only strips it from `toJSON()` / `JSON.stringify(model)` (e.g. a\r\n * session list returned via `response.json`); `get(\"token\")`,\r\n * `findByToken` / `findForUser` and every query still read the column.\r\n */\r\n public static hidden = [\"token\"];\r\n\r\n /**\r\n * Permanent regardless of the data source default — Mongo's driver default\r\n * is `\"trash\"`, which would copy live credential material (the JWT itself)\r\n * into `refresh_tokensTrash` on every `destroy()` (expiry purge,\r\n * never-expiring purge). A purged refresh token must not survive anywhere.\r\n */\r\n public static deleteStrategy = \"permanent\" as const;\r\n\r\n /** Token family this row belongs to (rotation / replay grouping). */\r\n public get familyId(): string {\r\n return this.get(\"family_id\");\r\n }\r\n\r\n /**\r\n * Whether the token's `expires_at` is in the past.\r\n *\r\n * **Fails closed as of 4.12.0**: a missing or unparseable `expires_at` now\r\n * counts as expired. It previously answered `false` for both — \"no expiry\r\n * recorded ⇒ never expires\" — which handed an unlimited life to precisely the\r\n * malformed rows, including the `Invalid Date` a pre-4.12.0 unparseable\r\n * `expiresIn` could write. `expires_at` is `required` in the schema, so a row\r\n * that cannot say when it dies is malformed, not immortal.\r\n */\r\n public get isExpired(): boolean {\r\n const expiresAt = this.get(\"expires_at\");\r\n\r\n if (!isUsableExpiry(expiresAt)) return true;\r\n\r\n return new Date().getTime() > new Date(expiresAt).getTime();\r\n }\r\n\r\n /**\r\n * Whether nothing can ever retire this row — an unusable `expires_at`, or a\r\n * token carrying no `exp` claim. See {@link isNeverExpiring}.\r\n */\r\n public get neverExpires(): boolean {\r\n return isNeverExpiring(this.get(\"token\"), this.get(\"expires_at\"));\r\n }\r\n\r\n /** Whether the token has been revoked. */\r\n public get isRevoked(): boolean {\r\n return !!this.get(\"revoked_at\");\r\n }\r\n\r\n /** Whether the token is still usable (not expired and not revoked). */\r\n public get isValid(): boolean {\r\n return !this.isExpired && !this.isRevoked;\r\n }\r\n\r\n /**\r\n * Unconditionally stamp `revoked_at` on this token.\r\n */\r\n public async revoke(): Promise<this> {\r\n return this.merge({ revoked_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Atomically revoke this token ONLY if it is still active. Resolves to `true`\r\n * when this call performed the revoke, `false` when a concurrent request had\r\n * already revoked it — the win/lose signal that powers rotation replay\r\n * detection. Uses a conditional UPDATE so two concurrent rotations of the\r\n * same token can never both succeed.\r\n */\r\n public async revokeIfActive(): Promise<boolean> {\r\n const modelClass = this.constructor as typeof RefreshToken;\r\n\r\n const revokedCount = await modelClass.atomic(\r\n { id: this.id, revoked_at: null },\r\n { $set: { revoked_at: new Date() } },\r\n );\r\n\r\n return revokedCount > 0;\r\n }\r\n\r\n /**\r\n * Touch `last_used_at` without revoking — the non-rotating refresh path.\r\n */\r\n public async markAsUsed(): Promise<void> {\r\n await this.merge({ last_used_at: new Date() }).save();\r\n }\r\n\r\n /**\r\n * Persist a freshly-signed refresh token for the user.\r\n */\r\n public static issue(user: Auth, token: string, options: RefreshTokenIssueOptions) {\r\n return this.create({\r\n token,\r\n user_id: user.id,\r\n user_type: user.userType,\r\n family_id: options.familyId,\r\n expires_at: options.expiresAt,\r\n device_info: options.deviceInfo\r\n ? {\r\n userAgent: options.deviceInfo.userAgent,\r\n ip: options.deviceInfo.ip,\r\n deviceId: options.deviceInfo.deviceId,\r\n }\r\n : undefined,\r\n });\r\n }\r\n\r\n /**\r\n * Find a refresh-token row by its raw token string.\r\n */\r\n public static findByToken(token: string): Promise<RefreshToken | null> {\r\n return this.first({ token });\r\n }\r\n\r\n /**\r\n * Find a refresh token scoped to a user — used by logout so a caller can only\r\n * revoke a token that actually belongs to them.\r\n */\r\n public static findForUser(user: Auth, token: string): Promise<RefreshToken | null> {\r\n return this.first({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Delete a specific refresh token belonging to the user.\r\n */\r\n public static deleteForUser(user: Auth, token: string) {\r\n return this.delete({ token, user_id: user.id });\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public static activeFor(user: Auth): Promise<RefreshToken[]> {\r\n return this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .where(\"expires_at\", \">\", new Date())\r\n .orderBy(\"created_at\", \"desc\")\r\n .get();\r\n }\r\n\r\n /**\r\n * Revoke every still-active refresh token for the user, returning the rows\r\n * that were revoked so the caller can emit a per-token event for each.\r\n *\r\n * Rows are fetched BEFORE they are revoked: a bulk `findAndUpdate` keyed on\r\n * `revoked_at: null` would re-query the same predicate after the update and\r\n * match nothing, returning an empty set.\r\n */\r\n public static async revokeAllFor(user: Auth): Promise<RefreshToken[]> {\r\n const tokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke every still-active token in a family (rotation breach containment),\r\n * returning the revoked rows (fetched before revocation — see `revokeAllFor`).\r\n */\r\n public static async revokeFamily(familyId: string): Promise<RefreshToken[]> {\r\n const tokens = await this.query().where({ family_id: familyId, revoked_at: null }).get();\r\n\r\n for (const token of tokens) {\r\n await token.revoke();\r\n }\r\n\r\n return tokens;\r\n }\r\n\r\n /**\r\n * Revoke the oldest active tokens so at most `max - 1` remain — making room\r\n * for the about-to-be-issued one. Bounded by `max`, so the per-row loop is\r\n * small.\r\n */\r\n public static async enforceMax(user: Auth, max: number): Promise<void> {\r\n const activeTokens = await this.query()\r\n .where({ user_id: user.id, user_type: user.userType, revoked_at: null })\r\n .orderBy(\"created_at\", \"asc\")\r\n .get();\r\n\r\n if (activeTokens.length < max) return;\r\n\r\n const tokensToRevoke = activeTokens.slice(0, activeTokens.length - max + 1);\r\n\r\n for (const token of tokensToRevoke) {\r\n await token.revoke();\r\n }\r\n }\r\n\r\n /**\r\n * Hard-delete every expired refresh token, returning the deleted rows so the\r\n * caller can emit a per-token event. Runs from the `auth.cleanup` CLI command\r\n * (a cold batch path).\r\n */\r\n public static async purgeExpired(): Promise<RefreshToken[]> {\r\n const expiredTokens = await this.query().where(\"expires_at\", \"<\", new Date()).get();\r\n\r\n for (const token of expiredTokens) {\r\n await token.destroy();\r\n }\r\n\r\n return expiredTokens;\r\n }\r\n\r\n /**\r\n * Every row that can never retire itself — see {@link neverExpires}. A full\r\n * scan filtered in memory, for the reason given on\r\n * {@link AccessToken.findNeverExpiring}: neither an `Invalid Date` nor a\r\n * missing `exp` claim is expressible as a `where`.\r\n */\r\n public static async findNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.query().get();\r\n\r\n return tokens.filter((token: RefreshToken) => token.neverExpires);\r\n }\r\n\r\n /**\r\n * Hard-delete every never-expiring row, returning the rows removed.\r\n *\r\n * Deleted rather than `revoked_at`-stamped: a revoked row is still a row this\r\n * table has to carry, and one of the two shapes being removed is a row whose\r\n * date column cannot be compared at all — leaving it in place keeps a\r\n * permanently unpurgeable record. Rotation replay-detection is unaffected;\r\n * these rows can no longer be presented successfully either way.\r\n */\r\n public static async purgeNeverExpiring(): Promise<RefreshToken[]> {\r\n const tokens = await this.findNeverExpiring();\r\n\r\n for (const token of tokens) {\r\n await token.destroy();\r\n }\r\n\r\n return tokens;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
@@ -7,8 +7,8 @@ import "../models/refresh-token/index.mjs";
|
|
|
7
7
|
import { authConfig } from "./auth-config.mjs";
|
|
8
8
|
import { authEvents } from "./auth-events.mjs";
|
|
9
9
|
import { isInvalidCredentialError, jwt } from "./jwt.mjs";
|
|
10
|
-
import { ForbiddenError, config, hashPassword, verifyPassword } from "@warlock.js/core";
|
|
11
10
|
import { Random } from "@mongez/reinforcements";
|
|
11
|
+
import { ForbiddenError, config, hashPassword, verifyPassword } from "@warlock.js/core";
|
|
12
12
|
|
|
13
13
|
//#region ../auth/src/services/auth.service.ts
|
|
14
14
|
var AuthService = class {
|
package/package.json
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"ms": "^2.1.3"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@warlock.js/fs": "5.
|
|
16
|
-
"@warlock.js/cache": "5.
|
|
17
|
-
"@warlock.js/cascade": "5.
|
|
18
|
-
"@warlock.js/core": "5.
|
|
19
|
-
"@warlock.js/logger": "5.
|
|
20
|
-
"@warlock.js/seal": "5.
|
|
21
|
-
"@warlock.js/notifications": "5.
|
|
15
|
+
"@warlock.js/fs": "5.17.1",
|
|
16
|
+
"@warlock.js/cache": "5.17.1",
|
|
17
|
+
"@warlock.js/cascade": "5.17.1",
|
|
18
|
+
"@warlock.js/core": "5.17.1",
|
|
19
|
+
"@warlock.js/logger": "5.17.1",
|
|
20
|
+
"@warlock.js/seal": "5.17.1",
|
|
21
|
+
"@warlock.js/notifications": "5.17.1",
|
|
22
22
|
"@simplewebauthn/server": "^13.1.0",
|
|
23
23
|
"jose": "^6.1.0"
|
|
24
24
|
},
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
],
|
|
48
48
|
"author": "hassanzohdy",
|
|
49
49
|
"license": "MIT",
|
|
50
|
-
"version": "5.
|
|
50
|
+
"version": "5.17.1",
|
|
51
51
|
"type": "module",
|
|
52
52
|
"main": "./esm/index.mjs",
|
|
53
53
|
"module": "./esm/index.mjs",
|
|
@@ -74,7 +74,7 @@ On lockout the middleware sends:
|
|
|
74
74
|
{ "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
`@warlock.js/auth` ships built-in `en` and `ar` text for `auth.errors.tooManyAttempts` (and every other `auth.errors.*` key it uses); define the key in your own locale files, or pass `errorMessage`, to override it. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
|
|
78
78
|
|
|
79
79
|
## Beyond login
|
|
80
80
|
|