@stacksjs/defaults 0.74.53 → 0.74.56

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.
@@ -258,7 +258,7 @@ interface RbacStore { findRoleByName, createRole, deleteRole, getAllRoles, findP
258
258
  - `SessionAuth.logout(sessionId): void`
259
259
  - `SessionAuth.user(sessionId): Promise<UserModel | undefined>`
260
260
  - `SessionAuth.check(sessionId): boolean`
261
- - `SessionAuth.refresh(sessionId, ttlMs?): boolean`
261
+ - `SessionAuth.refresh(sessionId, ttlMs?): boolean`, rejects non-positive or non-finite TTLs without changing the session
262
262
 
263
263
  Internal: in-memory Map with 10k session limit, 5-minute eviction interval, timing-safe password comparison with dummy bcrypt hash.
264
264
 
@@ -86,6 +86,12 @@ const users = await db.selectFrom('users').where('active', '=', true).get()
86
86
 
87
87
  `initializeDbConfig(config)` can be called to update the backing config at runtime.
88
88
 
89
+ For reads which cannot tolerate replication lag, use `db.primary.selectFrom(...)`.
90
+ It stays on the primary when automatic replica routing is enabled, and uses the
91
+ active transaction connection inside a transaction. It does not mark the request
92
+ as a writer or change routing for unrelated reads. Session authentication uses
93
+ this handle so a revoked session cannot authenticate from a stale replica.
94
+
89
95
  ## SQL Template Tag (types.ts)
90
96
 
91
97
  ```typescript
@@ -26,12 +26,17 @@ export default new Action({
26
26
  // rather than trusting the bearer token alone (a stolen/leaked
27
27
  // token shouldn't be enough to turn off the second factor it's
28
28
  // meant to help guard against).
29
- const confirmed = await Auth.validate({ email: user.email, password })
29
+ // Bind reconfirmation to the same account and password version while
30
+ // disabling. A concurrent password reset must invalidate this request.
31
+ const confirmed = await Auth.withVerifiedCredentials({ email: user.email, password }, async (verifiedUser) => {
32
+ if (String(verifiedUser.id) !== String(user.id))
33
+ return false
34
+ await disableTwoFactor(user.id as number)
35
+ return true
36
+ })
30
37
  if (!confirmed)
31
38
  return response.unauthorized('Incorrect password')
32
39
 
33
- await disableTwoFactor(user.id as number)
34
-
35
40
  return response.json({ enabled: false })
36
41
  },
37
42
  })
@@ -1,6 +1,5 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Auth, authCookie, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/auth'
3
- import { User } from '@stacksjs/orm'
4
3
  import { response } from '@stacksjs/router'
5
4
  import { schema } from '@stacksjs/validation'
6
5
  import { PASSWORD_MAX_LENGTH, PASSWORD_PRESENCE_MESSAGE } from '../../password-policy'
@@ -30,28 +29,27 @@ export default new Action({
30
29
  const email = request.get('email')
31
30
  const password = request.get('password')
32
31
 
33
- // Verify credentials WITHOUT minting tokens yet — if the account
34
- // has TOTP 2FA enabled, no token pack should exist until the code
35
- // is also verified (VerifyTwoFactorLoginAction mints the real
36
- // pack via Auth.loginUsingId, same as the non-2FA path below).
37
- const isValid = await Auth.attempt({ email, password })
38
- if (!isValid)
32
+ // Verify once, then keep the observed password version locked while
33
+ // choosing and issuing the challenge or token. A completed password reset
34
+ // cannot be followed by this old in-flight login minting fresh access.
35
+ const decision = await Auth.withVerifiedCredentials({ email, password }, async (authedUser) => {
36
+ const { enabled: twoFactorEnabled } = await getTwoFactorState(authedUser.id as number)
37
+ if (twoFactorEnabled) {
38
+ return { kind: 'challenge' as const, token: await createTwoFactorChallenge(authedUser.id as number) }
39
+ }
40
+ return { kind: 'login' as const, result: await Auth.loginUsingId(authedUser.id as number) }
41
+ })
42
+ if (!decision)
39
43
  return response.unauthorized('Incorrect email or password')
40
44
 
41
- const authedUser = await User.where('email', '=', email).first()
42
- if (!authedUser)
43
- return response.unauthorized('Incorrect email or password')
44
-
45
- const { enabled: twoFactorEnabled } = await getTwoFactorState(authedUser.id as number)
46
- if (twoFactorEnabled) {
47
- const challengeToken = await createTwoFactorChallenge(authedUser.id as number)
45
+ if (decision.kind === 'challenge') {
48
46
  return response.json({
49
47
  requires_two_factor: true,
50
- challenge_token: challengeToken,
48
+ challenge_token: decision.token,
51
49
  })
52
50
  }
53
51
 
54
- const result = await Auth.loginUsingId(authedUser.id as number)
52
+ const result = decision.result
55
53
  if (!result)
56
54
  return response.unauthorized('Incorrect email or password')
57
55
 
@@ -1,7 +1,7 @@
1
1
  import { timingSafeEqual } from 'node:crypto'
2
- import { HttpError } from '@stacksjs/error-handling'
2
+ import { HttpError } from '@stacksjs/error-handling/http-error'
3
3
  import type { EnhancedRequest } from '@stacksjs/router'
4
- import { Middleware } from '@stacksjs/router'
4
+ import { Middleware } from '@stacksjs/router/middleware'
5
5
 
6
6
  /**
7
7
  * CSRF Protection Middleware (default-on for unsafe methods)
@@ -144,7 +144,34 @@ export function createCsrfCookie(req: Request, minted?: string): string {
144
144
  return `${CSRF_COOKIE_PREFIX}${token}${suffix}`
145
145
  }
146
146
 
147
+ /**
148
+ * Whether a browser could go on to use a CSRF token from this response.
149
+ *
150
+ * A page carries the forms that submit it, and an API answer is what an SPA
151
+ * reads before its next request. A stylesheet, script, font or image is
152
+ * neither, and a cookie on one is worse than useless: a response that sets a
153
+ * cookie is one no shared cache will store, so seeding every static file kept
154
+ * the site's whole asset set out of the CDN (every file came back
155
+ * `cf-cache-status: BYPASS`). A cacheable file carrying a per-visitor token is
156
+ * also the shape of a leak, should any cache in the path store it anyway.
157
+ *
158
+ * No content type at all (a redirect, an empty answer) keeps the old
159
+ * behaviour: it says nothing about what the browser is looking at.
160
+ */
161
+ export function responseMayUseCsrfToken(response: Response): boolean {
162
+ const type = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
163
+ if (!type)
164
+ return true
165
+ return type === 'text/html'
166
+ || type === 'application/xhtml+xml'
167
+ || type === 'application/json'
168
+ || type.endsWith('+json')
169
+ }
170
+
147
171
  export function seedCsrfCookieIfMissing(req: Request, response: Response, minted?: string, responseHasNoCookies = false): Response {
172
+ if (!responseMayUseCsrfToken(response))
173
+ return response
174
+
148
175
  // A token the router minted before rendering wins over "the header already
149
176
  // has one", because it put that value in the header itself - and the page
150
177
  // has already embedded it in every form it drew. Generating a second token
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.53",
5
+ "version": "0.74.56",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.53",
5
+ "version": "0.74.56",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.27",
58
- "@stacksjs/mobile": "^0.74.53",
58
+ "@stacksjs/mobile": "^0.74.56",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }