ReviewOS

also looking at this

stacks/bench-review

Close unauthenticated /api/requests CRUD, and make the session cookie HttpOnly

#77
Merged glennmichael123 wants to merge fix/request-autocrud-and-httponly into main
11 files +552 -57
app/Actions/Auth/LoginAction.tsadded+91-0
Changes to app/Actions/Auth/LoginAction.ts
@@ -0,0 +1,91 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/auth'
4import { User } from '@stacksjs/orm'
5import { response } from '@stacksjs/router'
6import { schema } from '@stacksjs/validation'
7import { withSessionCookie } from '../../Support/sessionCookie'
8
9/**
10 * Login — app-local override of `storage/framework/defaults/app/Actions/Auth/LoginAction.ts`.
11 *
12 * Actions resolve `app/` first and fall back to the framework defaults (the
13 * same mechanism noted in `routes/api.ts`), so this file wins without touching
14 * the vendored tree — which matters because `storage/framework/` is replaced
15 * wholesale on a framework sync.
16 *
17 * The ONLY behavioural difference from the default is that the session token is
18 * also returned as an `HttpOnly` cookie instead of being left for the browser
19 * to write via `document.cookie`. See `app/Support/sessionCookie.ts` for why.
20 *
21 * The JSON body is kept byte-compatible with the framework's shape
22 * (`access_token` / `refresh_token` / `token_type` / `expires_in` / the legacy
23 * `token` / `user`) so any non-browser client keeps working, and so a future
24 * framework sync does not silently change the contract underneath the SPA.
25 *
26 * NOTE on 2FA: the `requires_two_factor` branch mints no token, so it sets no
27 * cookie — correct, since no session exists until the code is verified. The
28 * token for that path comes from `VerifyTwoFactorLoginAction`, which this app
29 * does not route (`routes/api.ts` declares no 2FA verify endpoint), so a 2FA
30 * account cannot complete login here. That is pre-existing, not introduced
31 * here; wire that route and give it the same cookie treatment if 2FA is turned on.
32 */
33export default new Action({
34 name: 'LoginAction',
35 description: 'Login to the application',
36 method: 'POST',
37
38 validations: {
39 email: {
40 rule: schema.string().email(),
41 message: 'Email must be a valid email address.',
42 },
43 password: {
44 rule: schema.string().min(6).max(255),
45 message: 'Password must be between 6 and 255 characters.',
46 },
47 },
48
49 async handle(request: RequestInstance) {
50 const email = request.get('email')
51 const password = request.get('password')
52
53 // Verify credentials WITHOUT minting tokens yet — if the account has TOTP
54 // 2FA enabled, no token pack should exist until the code is also verified.
55 const isValid = await Auth.attempt({ email, password })
56 if (!isValid)
57 return response.unauthorized('Incorrect email or password')
58
59 const authedUser = await User.where('email', '=', email).first()
60 if (!authedUser)
61 return response.unauthorized('Incorrect email or password')
62
63 const { enabled: twoFactorEnabled } = await getTwoFactorState(authedUser.id as number)
64 if (twoFactorEnabled) {
65 const challengeToken = await createTwoFactorChallenge(authedUser.id as number)
66 return response.json({
67 requires_two_factor: true,
68 challenge_token: challengeToken,
69 })
70 }
71
72 const result = await Auth.loginUsingId(authedUser.id as number)
73 if (!result)
74 return response.unauthorized('Incorrect email or password')
75
76 const user = result.user
77
78 return withSessionCookie({
79 access_token: result.token,
80 refresh_token: result.refreshToken,
81 token_type: 'Bearer',
82 expires_in: result.expiresIn,
83 token: result.token,
84 user: {
85 id: user?.id,
86 email: user?.email,
87 name: user?.name,
88 },
89 }, result.token)
90 },
91})