@if (!pullRequest)
@else
app/Actions/Auth/LoginAction.tsadded+95-0
app/Actions/Auth/LogoutAction.tsadded+39-0
app/Actions/Auth/RegisterAction.tsadded+78-0
app/Actions/Auth/SocialCallbackAction.tsmodified+16-14
app/Actions/Auth/VerifyTwoFactorLoginAction.tsadded+72-0
app/Actions/Auth/authCookie.tsadded+57-0
app/Actions/MeAction.tsmodified+5-4
app/Middleware/Auth.tsmodified+20-1
app/Support/request-auth.tsmodified+6-3
config/auth.tsmodified+27-10
config/server.tsmodified+11-5
resources/partials/AuthGuard.stxdeleted+0-37
resources/stores/session.tsmodified+35-100
resources/views/account.stxmodified+0-5
resources/views/analytics.stxmodified+8-2
resources/views/dashboard.stxmodified+15-17
resources/views/fix.stxmodified+2-2
resources/views/index.stxmodified+1-1
resources/views/login.stxmodified+123-107
No such pull request
loghqorg/loghq has no pull request numbered 7.
also looking at this
Deploy: uniform auth session model (main → production)
#7
282 files
+3028
-64856
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,95 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/auth' | |
| 4 | import { User } from '@stacksjs/orm' | |
| 5 | import { response } from '@stacksjs/router' | |
| 6 | import { schema } from '@stacksjs/validation' | |
| 7 | import { buildAuthCookie, sessionExpiryMinutes } from './authCookie' | |
| 8 | ||
| 9 | /** | |
| 10 | * Email/password sign-in, registered at POST /api/auth/login (routes/auth.ts). | |
| 11 | * | |
| 12 | * Replaces the old login.stx page action. loghq is server-rendered stx with no | |
| 13 | * client hydration, so on success the issued bearer is mirrored into the | |
| 14 | * HttpOnly `auth-token` cookie (see authCookie.ts) — that cookie IS the session | |
| 15 | * every server-rendered page and every same-origin dashboard fetch | |
| 16 | * authenticates from. There is no localStorage token and no Authorization | |
| 17 | * header anywhere in the client any more. | |
| 18 | * | |
| 19 | * `Auth.attempt` carries the rate limiting (RateLimiter, keyed by email) and | |
| 20 | * verifies credentials WITHOUT minting a token, so a 2FA-enabled account never | |
| 21 | * gets a token pack before its TOTP code is also checked. | |
| 22 | */ | |
| 23 | export default new Action({ | |
| 24 | name: 'LoginAction', | |
| 25 | description: 'Login to the application', | |
| 26 | method: 'POST', | |
| 27 | ||
| 28 | validations: { | |
| 29 | email: { | |
| 30 | rule: schema.string().email(), | |
| 31 | message: 'Email must be a valid email address.', | |
| 32 | }, | |
| 33 | password: { | |
| 34 | rule: schema.string().min(6).max(255), | |
| 35 | message: 'Password must be between 6 and 255 characters.', | |
| 36 | }, | |
| 37 | }, | |
| 38 | ||
| 39 | async handle(request: RequestInstance) { | |
| 40 | const email = String(request.get('email') ?? '').trim() | |
| 41 | const password = String(request.get('password') ?? '') | |
| 42 | ||
| 43 | const isValid = await Auth.attempt({ email, password }) | |
| 44 | if (!isValid) | |
| 45 | return response.unauthorized('Invalid email or password.') | |
| 46 | ||
| 47 | const authedUser = await User.where('email', '=', email).first() | |
| 48 | if (!authedUser) | |
| 49 | return response.unauthorized('Invalid email or password.') | |
| 50 | ||
| 51 | // 2FA is dormant in loghq (no enrolment UI), but wired so a 2FA-enabled | |
| 52 | // account can never lock itself out. getTwoFactorState reads two_factor_* | |
| 53 | // columns that may not exist on this database yet — treat any failure as | |
| 54 | // "not enabled" so a normal login is never broken by their absence. | |
| 55 | let twoFactorEnabled = false | |
| 56 | try { | |
| 57 | twoFactorEnabled = (await getTwoFactorState(authedUser.id as number)).enabled | |
| 58 | } | |
| 59 | catch { | |
| 60 | twoFactorEnabled = false | |
| 61 | } | |
| 62 | ||
| 63 | if (twoFactorEnabled) { | |
| 64 | const challengeToken = await createTwoFactorChallenge(authedUser.id as number) | |
| 65 | return response.json({ | |
| 66 | requires_two_factor: true, | |
| 67 | challenge_token: challengeToken, | |
| 68 | }) | |
| 69 | } | |
| 70 | ||
| 71 | // Session length is set once, here, from the "remember me" checkbox: a week | |
| 72 | // by default, 30 days when checked. See sessionExpiryMinutes. | |
| 73 | const expiresInMinutes = sessionExpiryMinutes(request.get('remember')) | |
| 74 | const result = await Auth.loginUsingId(authedUser.id as number, { expiresInMinutes }) | |
| 75 | if (!result) | |
| 76 | return response.unauthorized('Invalid email or password.') | |
| 77 | ||
| 78 | const user = result.user | |
| 79 | ||
| 80 | return response.json( | |
| 81 | { | |
| 82 | access_token: result.token, | |
| 83 | token_type: 'Bearer', | |
| 84 | expires_in: result.expiresIn, | |
| 85 | token: result.token, | |
| 86 | user: { | |
| 87 | id: user?.id, | |
| 88 | email: user?.email, | |
| 89 | name: user?.name, | |
| 90 | }, | |
| 91 | }, | |
| 92 | { status: 200, headers: { 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn) } }, | |
| 93 | ) | |
| 94 | }, | |
| 95 | }) | |
| @@ -0,0 +1,39 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { Auth } from '@stacksjs/auth' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { clearAuthCookie } from './authCookie' | |
| 6 | ||
| 7 | /** | |
| 8 | * Project override of the framework's default LogoutAction — same token | |
| 9 | * revocation, plus clearing the HttpOnly `auth-token` cookie the auth actions | |
| 10 | * set (see authCookie.ts). The cookie is HttpOnly, so only the server can clear | |
| 11 | * it: the client signOut() just calls this and reloads. | |
| 12 | */ | |
| 13 | export default new Action({ | |
| 14 | name: 'LogoutAction', | |
| 15 | description: 'Logout from the application', | |
| 16 | method: 'POST', | |
| 17 | async handle(request: RequestInstance) { | |
| 18 | await Auth.logout() | |
| 19 | ||
| 20 | const clearCookie = clearAuthCookie() | |
| 21 | ||
| 22 | // A plain full-page <form method="POST"> logout is a navigation, not an | |
| 23 | // XHR, so returning JSON would render the raw payload in the browser. | |
| 24 | // Redirect browser navigations to /login; XHR/API callers (Accept: | |
| 25 | // application/json) still get JSON. | |
| 26 | const accept = String(request.headers?.get?.('accept') ?? '') | |
| 27 | if (accept.includes('text/html')) { | |
| 28 | return new Response(null, { | |
| 29 | status: 302, | |
| 30 | headers: { 'Location': '/login', 'Set-Cookie': clearCookie }, | |
| 31 | }) | |
| 32 | } | |
| 33 | ||
| 34 | return response.json( | |
| 35 | { message: 'Successfully logged out' }, | |
| 36 | { status: 200, headers: { 'Set-Cookie': clearCookie } }, | |
| 37 | ) | |
| 38 | }, | |
| 39 | }) | |
| @@ -0,0 +1,78 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { Auth, register } from '@stacksjs/auth' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { schema } from '@stacksjs/validation' | |
| 6 | import { buildAuthCookie } from './authCookie' | |
| 7 | ||
| 8 | /** | |
| 9 | * Account creation, registered at POST /api/auth/register (routes/auth.ts). | |
| 10 | * | |
| 11 | * Replaces the old register.stx page action. Like LoginAction, on success the | |
| 12 | * issued bearer is mirrored into the HttpOnly `auth-token` cookie so the very | |
| 13 | * first server-rendered page after signup is already authenticated. loghq has | |
| 14 | * no teams model, so there is no personal-team creation here — just the account | |
| 15 | * and its session. Where the user lands afterwards (an invited user goes to the | |
| 16 | * dashboard, everyone else to the create-your-first-app page) is decided by the | |
| 17 | * client, which knows the invite context from the URL. | |
| 18 | */ | |
| 19 | export default new Action({ | |
| 20 | name: 'RegisterAction', | |
| 21 | description: 'Register a new user', | |
| 22 | method: 'POST', | |
| 23 | ||
| 24 | validations: { | |
| 25 | name: { | |
| 26 | rule: schema.string().min(2).max(255), | |
| 27 | message: 'Name must be between 2 and 255 characters.', | |
| 28 | }, | |
| 29 | email: { | |
| 30 | rule: schema.string().email(), | |
| 31 | message: 'Email must be a valid email address.', | |
| 32 | }, | |
| 33 | password: { | |
| 34 | rule: schema.string().min(8).max(255), | |
| 35 | message: 'Password must be at least 8 characters.', | |
| 36 | }, | |
| 37 | }, | |
| 38 | ||
| 39 | async handle(request: RequestInstance) { | |
| 40 | const name = String(request.get('name') ?? '').trim() | |
| 41 | const email = String(request.get('email') ?? '').trim() | |
| 42 | const password = String(request.get('password') ?? '') | |
| 43 | ||
| 44 | // `register` throws an HttpError for an invalid address, a short password or | |
| 45 | // a duplicate. Its duplicate message is deliberately vague — the framework | |
| 46 | // treats "this email exists" as an enumeration oracle — so surface it as-is. | |
| 47 | let result | |
| 48 | try { | |
| 49 | result = await register({ name, email, password }) | |
| 50 | } | |
| 51 | catch (err) { | |
| 52 | return response.json( | |
| 53 | { message: (err as Error)?.message || 'Could not create the account.' }, | |
| 54 | { status: 422 }, | |
| 55 | ) | |
| 56 | } | |
| 57 | ||
| 58 | if (!result?.token) | |
| 59 | return response.json({ message: 'Could not create the account.' }, { status: 422 }) | |
| 60 | ||
| 61 | // register() returns the token pack but not the user record; resolve it for | |
| 62 | // the response body. The cookie's Max-Age matches the token's own expiry | |
| 63 | // (config baseline — a week), so signup lands on the baseline tier. | |
| 64 | const user = await Auth.getUserFromToken(result.token) | |
| 65 | ||
| 66 | return response.json( | |
| 67 | { | |
| 68 | token: result.token, | |
| 69 | user: { | |
| 70 | id: user?.id, | |
| 71 | email: user?.email, | |
| 72 | name: user?.name, | |
| 73 | }, | |
| 74 | }, | |
| 75 | { status: 200, headers: { 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn) } }, | |
| 76 | ) | |
| 77 | }, | |
| 78 | }) | |
| @@ -6,6 +6,7 @@ import { config } from '@stacksjs/config' | ||
| 6 | 6 | import { db } from '@stacksjs/database' |
| 7 | 7 | import { response } from '@stacksjs/router' |
| 8 | 8 | import { GitHubProvider, GoogleProvider } from '@stacksjs/socials' |
| 9 | import { buildAuthCookie, sessionExpiryMinutes } from './authCookie' | |
| 9 | 10 | |
| 10 | 11 | function makeDriver(provider: string): GitHubProvider | GoogleProvider | null { |
| 11 | 12 | const svc = config.services as any |
| @@ -97,22 +98,23 @@ export default new Action({ | ||
| 97 | 98 | .execute() |
| 98 | 99 | } |
| 99 | 100 | |
| 100 | const result = await Auth.loginUsingId(userId) | |
| 101 | // SSO has no "remember me" checkbox, so it lands on the baseline session | |
| 102 | // (a week). loginUsingId stamps expires_at from expiresInMinutes and | |
| 103 | // returns the matching expiresIn, which buildAuthCookie uses for Max-Age — | |
| 104 | // one number drives both. | |
| 105 | const result = await Auth.loginUsingId(userId, { expiresInMinutes: sessionExpiryMinutes(false) }) | |
| 101 | 106 | if (!result?.token) |
| 102 | 107 | return fail('We could not sign you in.') |
| 103 | 108 | |
| 104 | // Hand the token to the client, mirroring the email/password flow exactly: | |
| 105 | // localStorage for bearer API calls + the loghq_token cookie so the very | |
| 106 | // first server-rendered page is already authenticated. Token is embedded in | |
| 107 | // the response body, never in the URL. Then land in the app. | |
| 108 | const token = JSON.stringify(result.token) | |
| 109 | const user = JSON.stringify({ id: userId, email, name: social.name }) | |
| 110 | return response.html( | |
| 111 | `<!doctype html><meta charset="utf-8"><title>Signing you in</title>` | |
| 112 | + `<script>try{localStorage.setItem('token', ${token});localStorage.setItem('user', ${user});` | |
| 113 | + `document.cookie='loghq_token='+${token}+'; path=/; max-age=2592000; samesite=lax'+(location.protocol==='https:'?'; secure':'')}catch(e){}` | |
| 114 | + `location.replace('/dashboard')</script>Signing you in...`, | |
| 115 | 200, | |
| 116 | ) | |
| 109 | // Establish the session server-side, exactly like the email/password flow: | |
| 110 | // a 302 to /dashboard carrying the single HttpOnly `auth-token` cookie. No | |
| 111 | // localStorage, no client-readable cookie, no token in the URL. | |
| 112 | return new Response(null, { | |
| 113 | status: 302, | |
| 114 | headers: { | |
| 115 | 'Location': '/dashboard', | |
| 116 | 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn), | |
| 117 | }, | |
| 118 | }) | |
| 117 | 119 | }, |
| 118 | 120 | }) |
| @@ -0,0 +1,72 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { Auth, consumeTwoFactorChallenge, verifyTwoFactorLoginCode } from '@stacksjs/auth' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { schema } from '@stacksjs/validation' | |
| 6 | import { buildAuthCookie, sessionExpiryMinutes } from './authCookie' | |
| 7 | ||
| 8 | /** | |
| 9 | * Exchange a LoginAction 2FA challenge + TOTP code for a real session, | |
| 10 | * registered at POST /api/auth/verify-two-factor-login (routes/auth.ts). | |
| 11 | * | |
| 12 | * Dormant in loghq (there is no enrolment UI), but wired end to end so a | |
| 13 | * 2FA-enabled account completing step two is signed in exactly like the | |
| 14 | * single-step path: the same HttpOnly `auth-token` cookie, the same | |
| 15 | * remember-me tier. See LoginAction / authCookie.ts. | |
| 16 | */ | |
| 17 | export default new Action({ | |
| 18 | name: 'VerifyTwoFactorLoginAction', | |
| 19 | description: 'Exchange a LoginAction 2FA challenge + TOTP code for a real token pack', | |
| 20 | method: 'POST', | |
| 21 | ||
| 22 | validations: { | |
| 23 | challenge_token: { | |
| 24 | rule: schema.string().min(1), | |
| 25 | message: 'A challenge token is required.', | |
| 26 | }, | |
| 27 | code: { | |
| 28 | rule: schema.string().min(6).max(6), | |
| 29 | message: 'Code must be a 6-digit TOTP code.', | |
| 30 | }, | |
| 31 | }, | |
| 32 | ||
| 33 | async handle(request: RequestInstance) { | |
| 34 | const challengeToken = request.get('challenge_token') | |
| 35 | const code = request.get('code') | |
| 36 | ||
| 37 | // Single-use: a second attempt with the same challenge token (right code or | |
| 38 | // wrong) must start over from LoginAction, not retry. | |
| 39 | const userId = await consumeTwoFactorChallenge(challengeToken) | |
| 40 | if (!userId) | |
| 41 | return response.unauthorized('This login attempt has expired — please sign in again.') | |
| 42 | ||
| 43 | const valid = await verifyTwoFactorLoginCode(userId, code) | |
| 44 | if (!valid) | |
| 45 | return response.unauthorized('Invalid code — please sign in again.') | |
| 46 | ||
| 47 | // Carry the "remember me" tier chosen on step one through to the session | |
| 48 | // issued here, so a 2FA account is not silently downgraded to the baseline | |
| 49 | // week. The login page re-sends the checkbox with the TOTP code. | |
| 50 | const expiresInMinutes = sessionExpiryMinutes(request.get('remember')) | |
| 51 | const result = await Auth.loginUsingId(userId, { expiresInMinutes }) | |
| 52 | if (!result) | |
| 53 | return response.unauthorized('Invalid code — please sign in again.') | |
| 54 | ||
| 55 | const user = result.user | |
| 56 | ||
| 57 | return response.json( | |
| 58 | { | |
| 59 | access_token: result.token, | |
| 60 | token_type: 'Bearer', | |
| 61 | expires_in: result.expiresIn, | |
| 62 | token: result.token, | |
| 63 | user: { | |
| 64 | id: user?.id, | |
| 65 | email: user?.email, | |
| 66 | name: user?.name, | |
| 67 | }, | |
| 68 | }, | |
| 69 | { status: 200, headers: { 'Set-Cookie': buildAuthCookie(result.token, result.expiresIn) } }, | |
| 70 | ) | |
| 71 | }, | |
| 72 | }) | |
| @@ -0,0 +1,57 @@ | ||
| 1 | import process from 'node:process' | |
| 2 | import { config } from '@stacksjs/config' | |
| 3 | ||
| 4 | /** | |
| 5 | * Build the `Set-Cookie` header value that mirrors an issued bearer token | |
| 6 | * into an HttpOnly cookie. The SPA's `useAuth` composable only stores the | |
| 7 | * bearer in localStorage, which is invisible during server-side rendering — | |
| 8 | * the dashboard's `.stx` pages (rendered directly from SQLite, no client | |
| 9 | * hydration) resolve "who's logged in" from this cookie instead. | |
| 10 | * | |
| 11 | * Shared by every auth action that mints a session (LoginAction, | |
| 12 | * RegisterAction, VerifyTwoFactorLoginAction) so the cookie contract stays | |
| 13 | * identical across all three entry points into an authenticated session. | |
| 14 | */ | |
| 15 | export function buildAuthCookie(token: string, expiresInSeconds?: number): string { | |
| 16 | const name = config.auth?.defaultTokenName || 'auth-token' | |
| 17 | const maxAge = Math.max(1, Math.floor(expiresInSeconds ?? (config.auth?.tokenExpiry ?? 60 * 60 * 1000) / 1000)) | |
| 18 | const env = (process.env.APP_ENV ?? process.env.NODE_ENV ?? '').toLowerCase() | |
| 19 | const isLocal = env === '' || env === 'local' || env === 'development' || env === 'dev' || env === 'test' || env === 'testing' | |
| 20 | ||
| 21 | const parts = [`${name}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAge}`] | |
| 22 | if (!isLocal) | |
| 23 | parts.push('Secure') | |
| 24 | ||
| 25 | return parts.join('; ') | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Clear the auth cookie {@link buildAuthCookie} sets — used by LogoutAction. | |
| 30 | */ | |
| 31 | export function clearAuthCookie(): string { | |
| 32 | const name = config.auth?.defaultTokenName || 'auth-token' | |
| 33 | return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` | |
| 34 | } | |
| 35 | ||
| 36 | /** Baseline session: a week. */ | |
| 37 | const WEEK_MINUTES = 7 * 24 * 60 | |
| 38 | /** "Keep me signed in": a month. */ | |
| 39 | const MONTH_MINUTES = 30 * 24 * 60 | |
| 40 | ||
| 41 | /** | |
| 42 | * Session length for a fresh login, in minutes, from the login form's | |
| 43 | * "remember me" checkbox. Passed to `Auth.loginUsingId(id, { expiresInMinutes })`, | |
| 44 | * which stamps BOTH the `oauth_access_tokens.expires_at` row and (via the | |
| 45 | * returned `expiresIn`) the cookie's Max-Age from the same number — so the | |
| 46 | * whole session, not just the cookie, honours the tier. Nothing extends either | |
| 47 | * value afterwards (getUserFromToken leaves expires_at alone), so this is the | |
| 48 | * real session cap. | |
| 49 | * | |
| 50 | * Unchecked is the baseline week; checked is 30 days. Accepts whatever the JSON | |
| 51 | * body carries for `remember` — boolean true, or '1'/'true'/'on'/'yes'. | |
| 52 | */ | |
| 53 | export function sessionExpiryMinutes(remember: unknown): number { | |
| 54 | const on = remember === true | |
| 55 | || ['1', 'true', 'on', 'yes'].includes(String(remember ?? '').toLowerCase()) | |
| 56 | return on ? MONTH_MINUTES : WEEK_MINUTES | |
| 57 | } | |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import type { RequestInstance } from '@stacksjs/types' |
| 2 | 2 | import { Action } from '@stacksjs/actions' |
| 3 | import { Auth } from '@stacksjs/auth' | |
| 4 | 3 | import { db } from '@stacksjs/database' |
| 5 | 4 | import { Payment } from '@stacksjs/payments' |
| 6 | 5 | import { response } from '@stacksjs/router' |
| 6 | import { userFromRequest } from '../Support/request-auth' | |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * Return the authenticated user plus their Pro status. The dashboard calls this |
| @@ -16,9 +16,10 @@ export default new Action({ | ||
| 16 | 16 | description: 'Return the current user and their Pro status', |
| 17 | 17 | method: 'GET', |
| 18 | 18 | async handle(request: RequestInstance) { |
| 19 | const authHeader = ((request as any).headers?.get?.('authorization') ?? '') | |
| 20 | const bearer = (request as any).bearerToken?.() ?? authHeader.replace(/^Bearer\s+/i, '') | |
| 21 | const user = bearer ? await Auth.getUserFromToken(bearer) : await request.user() | |
| 19 | // Resolve from either carrier: an external bearer header, or the HttpOnly | |
| 20 | // `auth-token` cookie the dashboard sends on same-origin fetches. /api/me | |
| 21 | // has no `.middleware('auth')`, so the resolution happens here. | |
| 22 | const user = await userFromRequest(request) | |
| 22 | 23 | if (!user) |
| 23 | 24 | return response.unauthorized('Authentication required') |
| 24 | 25 | |
| @@ -17,6 +17,18 @@ export default new Middleware({ | ||
| 17 | 17 | if (!isValid) |
| 18 | 18 | throw new HttpError(401, 'Unauthorized. Invalid token.') |
| 19 | 19 | |
| 20 | // Stamp the resolved user like the cookie branch below and the | |
| 21 | // framework-default middleware do — `request.user()` returns | |
| 22 | // `_authenticatedUser` with no fallback, so without this a route behind | |
| 23 | // `.middleware('auth')` that reads `await request.user()` (e.g. | |
| 24 | // CreateCheckoutAction) saw undefined even after a valid bearer passed | |
| 25 | // here. Do NOT overwrite `request.user` itself — it is the accessor. | |
| 26 | const user = await Auth.getUserFromToken(bearerToken) | |
| 27 | if (user) { | |
| 28 | Auth.setUser(user) | |
| 29 | ;(request as { _authenticatedUser?: unknown })._authenticatedUser = user | |
| 30 | } | |
| 31 | ||
| 20 | 32 | log.debug(`[middleware:auth] Bearer token valid`) |
| 21 | 33 | return |
| 22 | 34 | } |
| @@ -51,11 +63,18 @@ export default new Middleware({ | ||
| 51 | 63 | |
| 52 | 64 | if (sessionId) { |
| 53 | 65 | log.debug(`[middleware:auth] Validating session`) |
| 54 | const { sessionCheck } = await import('@stacksjs/auth') | |
| 66 | const { sessionCheck, sessionUser } = await import('@stacksjs/auth') | |
| 55 | 67 | const isValid = await sessionCheck(sessionId) |
| 56 | 68 | if (!isValid) |
| 57 | 69 | throw new HttpError(401, 'Unauthorized. Session expired.') |
| 58 | 70 | |
| 71 | // Same stamping for the session branch. | |
| 72 | const user = await sessionUser(sessionId) | |
| 73 | if (user) { | |
| 74 | Auth.setUser(user) | |
| 75 | ;(request as { _authenticatedUser?: unknown })._authenticatedUser = user | |
| 76 | } | |
| 77 | ||
| 59 | 78 | log.debug(`[middleware:auth] Session valid`) |
| 60 | 79 | return |
| 61 | 80 | } |
| @@ -8,10 +8,12 @@ | ||
| 8 | 8 | * This exists instead of the `auth` middleware alias because that alias does not |
| 9 | 9 | * reliably populate `request.user()` on these route handlers (see the note in |
| 10 | 10 | * routes/projects.ts), so the token is read and resolved directly. Two carriers |
| 11 | * are accepted: a bearer header, which is what the API clients send, and the | |
| 12 | * `loghq_token` cookie, which is what the dashboard has after signing in. | |
| 11 | * are accepted: a bearer header, which is what the external API clients send, | |
| 12 | * and the HttpOnly `auth-token` cookie (config.auth.defaultTokenName), which is | |
| 13 | * what the dashboard has after signing in. | |
| 13 | 14 | */ |
| 14 | 15 | import { Auth } from '@stacksjs/auth' |
| 16 | import { config } from '@stacksjs/config' | |
| 15 | 17 | |
| 16 | 18 | export async function userFromRequest(request: any): Promise<any | null> { |
| 17 | 19 | const authHeader = request.headers?.get?.('authorization') ?? '' |
| @@ -19,7 +21,8 @@ export async function userFromRequest(request: any): Promise<any | null> { | ||
| 19 | 21 | |
| 20 | 22 | if (!token) { |
| 21 | 23 | const cookie = request.headers?.get?.('cookie') ?? '' |
| 22 | const m = cookie.match(/(?:^|;)\s*loghq_token=([^;]+)/) | |
| 24 | const name = (config.auth?.defaultTokenName || 'auth-token').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 25 | const m = cookie.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`)) | |
| 23 | 26 | if (m) |
| 24 | 27 | token = decodeURIComponent(m[1]) |
| 25 | 28 | } |
| @@ -47,20 +47,37 @@ export default { | ||
| 47 | 47 | password: env.AUTH_PASSWORD_FIELD || 'password', |
| 48 | 48 | |
| 49 | 49 | /** |
| 50 | * Access-token expiry in milliseconds (default: 1 hour). | |
| 50 | * Access-token expiry in milliseconds (default: 7 days). | |
| 51 | 51 | * |
| 52 | * Access tokens are deliberately short-lived: a leaked bearer (logs, | |
| 53 | * proxy, browser storage) is then usable for an hour, not a month. The | |
| 54 | * paired refresh token (`refreshTokenExpiry`) carries the long-lived | |
| 55 | * session and is rotated on use, so UX is unaffected. | |
| 52 | * This value IS the browser session length, not just an API-bearer TTL. | |
| 53 | * The custom auth actions (LoginAction, RegisterAction, | |
| 54 | * VerifyTwoFactorLoginAction, SocialCallbackAction) mirror the issued | |
| 55 | * access token into the HttpOnly `auth-token` cookie (see | |
| 56 | * Actions/Auth/authCookie.ts) because the dashboard is server-rendered stx | |
| 57 | * with no client hydration and has no other way to know who is asking. Both | |
| 58 | * the cookie's Max-Age and the `oauth_access_tokens.expires_at` row are | |
| 59 | * stamped from here, and nothing extends either one — `getUserFromToken` | |
| 60 | * bumps `updated_at` on every request but leaves `expires_at` alone, then | |
| 61 | * deletes the row once it passes. So a signed-in operator is logged out | |
| 62 | * exactly this long after login regardless of activity. | |
| 63 | * | |
| 64 | * This is the BASELINE only. LoginAction and VerifyTwoFactorLoginAction | |
| 65 | * pass a per-login `expiresInMinutes` from the sign-in form's "remember me" | |
| 66 | * checkbox (see sessionExpiryMinutes in Actions/Auth/authCookie.ts): a week | |
| 67 | * unchecked, 30 days checked. This default covers the entry points that have | |
| 68 | * no such checkbox — register, SSO — so they land on the baseline week. | |
| 56 | 69 | */ |
| 57 | // 30 days: the dashboard is a long-lived session (token kept in localStorage + | |
| 58 | // a cookie). A 1h expiry logged users out mid-session. | |
| 59 | tokenExpiry: env.AUTH_TOKEN_EXPIRY || 30 * 24 * 60 * 60 * 1000, | |
| 70 | tokenExpiry: env.AUTH_TOKEN_EXPIRY || 7 * 24 * 60 * 60 * 1000, | |
| 60 | 71 | |
| 61 | 72 | /** |
| 62 | * Refresh-token expiry in milliseconds (default: 30 days). This is the | |
| 63 | * long-lived credential exchanged for fresh access tokens. | |
| 73 | * Refresh-token expiry in milliseconds (default: 30 days). | |
| 74 | * | |
| 75 | * NOT WIRED UP. A refresh token is minted and returned in the login | |
| 76 | * response body by the auth actions and consumed by nothing: there is no | |
| 77 | * refresh route and no cookie stores it, so the value below only bounds a | |
| 78 | * row in `oauth_refresh_tokens` that never gets read. Session length is | |
| 79 | * `tokenExpiry` above, alone — the long-lived `auth-token` cookie IS the | |
| 80 | * session. | |
| 64 | 81 | */ |
| 65 | 82 | refreshTokenExpiry: env.AUTH_REFRESH_TOKEN_EXPIRY || 30 * 24 * 60 * 60 * 1000, |
| 66 | 83 | |
| @@ -29,16 +29,22 @@ import type { ServerConfig } from '@stacksjs/types' | ||
| 29 | 29 | * |
| 30 | 30 | * routes/api.ts auto-prefixed /api -> covered by the prefix |
| 31 | 31 | * routes/projects.ts all under /api/ -> covered by the prefix |
| 32 | * routes/auth.ts NOT prefixed -> named individually | |
| 32 | * routes/auth.ts sign-in/up/2FA under /api/auth/ -> covered by the prefix; | |
| 33 | * /logout + the /password/* + /payments/* + /webhooks/* | |
| 34 | * paths are NOT prefixed -> named individually below | |
| 33 | 35 | * routes/logs.ts POST /logs (SDK ingest) -> named |
| 34 | 36 | * routes/buddy.ts POST /jobs/{id}/cancel|retry, parameterised -> prefix |
| 35 | 37 | * |
| 36 | 38 | * `paths` is matched exactly and IGNORES THE METHOD, so a path listed here |
| 37 | 39 | * takes its GET with it. That is why `/login` and `/register` are absent: they |
| 38 | * are the only two paths in the app that have both a POST route and a GET page, | |
| 39 | * and listing them would proxy the sign-in page itself away. They are page | |
| 40 | * actions now — the page handles its own POST — which is what made emptying | |
| 41 | * `methods` possible without breaking them. | |
| 40 | * each still render a GET page (the sign-in / sign-up forms), and listing them | |
| 41 | * would proxy that page away. Their POST no longer lives at those paths — the | |
| 42 | * forms are client-fetch to `/api/auth/login` and `/api/auth/register` (under | |
| 43 | * the proxied `/api/` prefix) — so nothing here needs to name them. The four | |
| 44 | * remaining page-action forms (fix, forgot-password, reset-password, | |
| 45 | * projects/new) DO handle their own POST at their own path, which is what makes | |
| 46 | * emptying `methods` load-bearing: naming any of them here would proxy the page | |
| 47 | * away. | |
| 42 | 48 | * |
| 43 | 49 | * Do not add a path here without checking `resources/views/` for a page of the |
| 44 | 50 | * same name first. The failure is silent: the page stops rendering and the |
| @@ -1,37 +0,0 @@ | ||
| 1 | {{-- Pre-paint auth bounce for the app pages. | |
| 2 | ||
| 3 | This is the one place in the app that is allowed to touch document.cookie | |
| 4 | directly, and it stays a plain script rather than a client block on purpose: | |
| 5 | it must run before the runtime exists and before anything paints, so no | |
| 6 | composable is available to it and hydrating would be far too late — a | |
| 7 | signed-out visitor would see the app shell first. | |
| 8 | ||
| 9 | Keeping it in ONE partial rather than copied into five pages is what makes | |
| 10 | the strict-mode report legible: the DOM guard names this file, and any | |
| 11 | second file appearing in that report is a real regression rather than one of | |
| 12 | five known copies. | |
| 13 | ||
| 14 | It used to read the token from localStorage and, if the cookie was missing, | |
| 15 | write the cookie and reload the whole page so the server could see it. All | |
| 16 | of that is gone. The cookie IS the session now — the session store writes it | |
| 17 | at sign-in and every server-rendered page authenticates from it — so there | |
| 18 | is nothing to mirror and nothing to reload for. That removes a full document | |
| 19 | load from first sign-in. | |
| 20 | ||
| 21 | `#auth-pending` is rendered by the server when the cookie did not | |
| 22 | authenticate, which means the token is stale rather than absent. Reading it | |
| 23 | here, from the DOM, is deliberate: the server cannot distinguish "no cookie | |
| 24 | yet" from "bad cookie" at render time, and moving this into the server's own | |
| 25 | signed-out branch would bounce a first-visit user in a loop. --}} | |
| 26 | <script> | |
| 27 | (function () { | |
| 28 | if (!/(^|;)\s*loghq_token=/.test(document.cookie)) { | |
| 29 | window.location.replace('/login') | |
| 30 | return | |
| 31 | } | |
| 32 | if (document.getElementById('auth-pending')) { | |
| 33 | document.cookie = 'loghq_token=; path=/; max-age=0' | |
| 34 | window.location.replace('/login') | |
| 35 | } | |
| 36 | })() | |
| 37 | </script> | |
| @@ -1,113 +1,60 @@ | ||
| 1 | 1 | /** |
| 2 | * Session store — the auth token and the cached viewer. | |
| 2 | * Session store — the cached viewer and the current-project browsing preference. | |
| 3 | 3 | * |
| 4 | 4 | * Same file contract as theme.ts: no value imports (they are deleted before |
| 5 | 5 | * transpile), no reserved filename, and every top-level name here must be unique |
| 6 | 6 | * across resources/stores/*.ts, because the loader concatenates them into one |
| 7 | 7 | * shared IIFE. |
| 8 | 8 | * |
| 9 | * WHY THE TOKEN IS A COOKIE AND THE USER IS LOCALSTORAGE | |
| 9 | * THERE IS NO CLIENT TOKEN | |
| 10 | 10 | * |
| 11 | * useLocalStorage JSON.parses on read and JSON.stringifies on write | |
| 12 | * (signals.js:3526-3539), unguarded. loghq stores the token as a RAW string, so | |
| 13 | * useLocalStorage('token') would throw SyntaxError on every existing session — | |
| 14 | * verified, not assumed — and would then write it back quoted, changing the | |
| 15 | * format for the server too. `user` is already JSON, so it is safe. | |
| 11 | * The session is a single HttpOnly `auth-token` cookie the server sets at | |
| 12 | * sign-in (see app/Actions/Auth/authCookie.ts). It is invisible to JavaScript | |
| 13 | * by design, so the client cannot read it, cannot attach a bearer and cannot | |
| 14 | * clear it — the browser sends it automatically on every same-origin request, | |
| 15 | * and only the server LogoutAction can remove it. Every authenticated data call | |
| 16 | * therefore rides the cookie: `credentials: 'same-origin'`, no Authorization | |
| 17 | * header anywhere. This replaced the old readable `loghq_token` cookie + bearer | |
| 18 | * plumbing wholesale. | |
| 16 | 19 | * |
| 17 | * useCookie has no JSON layer at all (signals.js:3568+): it encodes with | |
| 18 | * encodeURIComponent and decodes symmetrically. It is the right home for a | |
| 19 | * bearer token, and it is where the token already had to live — every | |
| 20 | * server-rendered page authenticates from the loghq_token cookie. Making the | |
| 21 | * cookie the single source of truth removes the localStorage copy, the | |
| 22 | * mirror-into-a-cookie step, and the full page reload that followed it. | |
| 23 | * | |
| 24 | * The pre-paint auth guards still read document.cookie directly. That is | |
| 25 | * deliberate and is the one sanctioned exception: they run before the runtime | |
| 26 | * exists, so no composable is available to them, and their whole job is to leave | |
| 27 | * the page before it is worth rendering. | |
| 20 | * The only cookie this store still owns is `loghq_project`, a browsing | |
| 21 | * preference (which project the dashboard is looking at), not a credential. | |
| 28 | 22 | */ |
| 29 | 23 | |
| 30 | 24 | function registerSessionStore() { |
| 31 | 25 | defineStore('session', () => { |
| 32 | // Called bare, as any other composable would be. It could not be until | |
| 33 | // stx#1838: useCookie is one of ~34 composables that live only on | |
| 34 | // window.stx rather than as a bare window global, and store bundles used to | |
| 35 | // be injected with no runtime-globals preamble — so the straightforward | |
| 36 | // call was a ReferenceError, and this file reached through window.stx to | |
| 37 | // work around it. store-loader now emits the same preamble a <script client> | |
| 38 | // block gets, so the workaround is gone. | |
| 39 | // | |
| 40 | // The failure it caused was worth the detour: the throw happens inside the | |
| 41 | // shared store IIFE, so defineStore never completes and what a developer | |
| 42 | // actually sees is "Store not found" from useStore('session') — raised in a | |
| 43 | // different file, which is fine. | |
| 44 | // | |
| 45 | // 30 days, matching what the hand-serialised writes used. useCookie adds | |
| 46 | // path=/, SameSite=Lax and — only on https — Secure, which is the exact | |
| 47 | // string that was being retyped at every call site. | |
| 48 | const token = useCookie('loghq_token', { maxAge: 2592000, sameSite: 'Lax' }) | |
| 49 | ||
| 50 | 26 | // Which project the dashboard and settings pages are looking at. A browsing |
| 51 | // preference, not a credential, so it gets a year. | |
| 27 | // preference, not a credential, so it gets a year and stays readable. | |
| 52 | 28 | const project = useCookie('loghq_project', { maxAge: 31536000, sameSite: 'Lax' }) |
| 53 | 29 | |
| 54 | // Safe under useLocalStorage: this value is already written as JSON. The | |
| 55 | // default is widened so the signal is MeUser | null rather than null. | |
| 56 | const user: StxSignal<MeUser | null> = useLocalStorage('user', null as MeUser | null) | |
| 57 | ||
| 58 | ||
| 59 | 30 | // ---- the viewer ------------------------------------------------------- |
| 60 | 31 | // GET /api/me was fetched independently by account.stx and dashboard.stx, |
| 61 | 32 | // each with its own `pro` signal, so navigating between the two refetched |
| 62 | 33 | // every time and the two copies could disagree. It lives here now: one |
| 63 | 34 | // request per page load, shared across SPA navigations because the store |
| 64 | 35 | // registry survives a fragment swap. |
| 65 | // | |
| 66 | // Still a plain fetch, but no longer for the reason this comment used to | |
| 67 | // give. The old note said useQuery throws `HTTP <status>: <statusText>` and | |
| 68 | // never reads the body, so a 401 could only be recovered by matching an | |
| 69 | // error string. That was fixed upstream (stx#1848) and is measured false | |
| 70 | // now: the thrown error carries `.status`, `.statusText`, `.data` and | |
| 71 | // `.response`, so `err.status === 401` and `err.data.message` both work. | |
| 72 | // | |
| 73 | // What keeps it a plain fetch is the in-flight de-duplication below. This is | |
| 74 | // the one caller that must collapse concurrent callers onto a single | |
| 75 | // promise, and it owns that promise directly. The caching useQuery offers is | |
| 76 | // what this store already is. | |
| 77 | 36 | const viewer: StxSignal<MeUser | null> = state(null as MeUser | null) |
| 78 | 37 | const pro = state(false) |
| 79 | 38 | const viewerLoaded = state(false) |
| 80 | 39 | |
| 81 | // One place the bearer token is attached, and one place a stale one is | |
| 82 | // noticed. Applies to every stx data primitive — useFetch, useQuery, | |
| 40 | // One place authentication is carried, and one place a signed-out response | |
| 41 | // is noticed. Applies to every stx data primitive — useFetch, useQuery, | |
| 83 | 42 | // useMutation all route through the same __stxFetch — so a call site says |
| 84 | 43 | // what it wants and never how to authenticate. |
| 85 | 44 | // |
| 86 | // Hooks REPLACE rather than stack, by design upstream: a module re-evaluated | |
| 87 | // by hot reload or re-run after an SPA swap must not end up sending two | |
| 88 | // Authorization headers or counting one 401 twice. | |
| 89 | // | |
| 90 | // The token check in onRequest is not defensive noise. Sign-in and sign-up | |
| 91 | // post to /login and /register with no session, and sending `Bearer ` with | |
| 92 | // an empty value is not the same as sending nothing. | |
| 93 | // | |
| 94 | // The same check in onResponseError is what keeps a *credential* 401 apart | |
| 95 | // from a *session* 401. Bad password at sign-in answers 401 too, and | |
| 96 | // clearing the session there would be harmless but bouncing to /login from | |
| 97 | // /login is not. A 401 while we are holding a token is the stale-session | |
| 98 | // case, and only that one signs out. | |
| 45 | // onRequest sends the HttpOnly cookie (credentials: 'same-origin') and NO | |
| 46 | // Authorization header — there is no client token to attach. onResponseError | |
| 47 | // treats a 401 as a stale/absent session: the cookie the browser sent did | |
| 48 | // not resolve a user, so clear the cached viewer and leave for /login. The | |
| 49 | // cookie itself cannot be cleared here (HttpOnly); the server owns that. | |
| 99 | 50 | configureFetch({ |
| 100 | 51 | onRequest(ctx: StxFetchRequestContext) { |
| 101 | const bearer = token() | |
| 102 | if (bearer) | |
| 103 | ctx.options.headers.Authorization = `Bearer ${bearer}` | |
| 52 | ctx.options.credentials = 'same-origin' | |
| 104 | 53 | }, |
| 105 | 54 | onResponseError(ctx: StxFetchErrorContext) { |
| 106 | if (ctx.response?.status !== 401 || !token()) | |
| 55 | if (ctx.response?.status !== 401) | |
| 107 | 56 | return |
| 108 | 57 | batch(() => { |
| 109 | token.set('') | |
| 110 | user.set(null) | |
| 111 | 58 | viewer.set(null) |
| 112 | 59 | pro.set(false) |
| 113 | 60 | viewerLoaded.set(false) |
| @@ -123,22 +70,20 @@ function registerSessionStore() { | ||
| 123 | 70 | let viewerRequest: Promise<void> | null = null |
| 124 | 71 | |
| 125 | 72 | async function loadViewer(): Promise<void> { |
| 126 | const bearer = token() | |
| 127 | if (!bearer || viewerLoaded()) | |
| 73 | if (viewerLoaded()) | |
| 128 | 74 | return |
| 129 | 75 | if (viewerRequest) |
| 130 | 76 | return viewerRequest |
| 131 | viewerRequest = fetchViewer(bearer).finally(() => { viewerRequest = null }) | |
| 77 | viewerRequest = fetchViewer().finally(() => { viewerRequest = null }) | |
| 132 | 78 | return viewerRequest |
| 133 | 79 | } |
| 134 | 80 | |
| 135 | async function fetchViewer(bearer: string): Promise<void> { | |
| 81 | async function fetchViewer(): Promise<void> { | |
| 136 | 82 | try { |
| 137 | const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${bearer}` } }) | |
| 83 | const res = await fetch('/api/me', { credentials: 'same-origin', headers: { Accept: 'application/json' } }) | |
| 138 | 84 | if (res.status === 401) { |
| 139 | // Stale token. Clearing it through the signal also clears the cookie | |
| 140 | // the server authenticates from. | |
| 141 | token.set('') | |
| 85 | // Signed out. The cookie is HttpOnly, so there is nothing to clear | |
| 86 | // client-side — just leave. | |
| 142 | 87 | navigate('/login', { reload: true }) |
| 143 | 88 | return |
| 144 | 89 | } |
| @@ -157,38 +102,28 @@ function registerSessionStore() { | ||
| 157 | 102 | } |
| 158 | 103 | |
| 159 | 104 | return { |
| 160 | token, | |
| 161 | 105 | project, |
| 162 | user, | |
| 163 | 106 | viewer, |
| 164 | 107 | pro, |
| 165 | 108 | viewerLoaded, |
| 166 | 109 | loadViewer, |
| 167 | isAuthed: derived(() => !!token()), | |
| 168 | 110 | |
| 169 | 111 | /** |
| 170 | * Clear the session locally and leave. The revoke request is | |
| 171 | * fire-and-forget on purpose: the credentials are being discarded either | |
| 172 | * way, so a failed or slow revoke must not strand someone on a page they | |
| 173 | * have already logged out of. | |
| 112 | * Clear the cached viewer and leave. The revoke request is | |
| 113 | * fire-and-forget on purpose: the session is being discarded either way, | |
| 114 | * so a failed or slow revoke must not strand someone on a page they have | |
| 115 | * already logged out of. The server LogoutAction clears the HttpOnly | |
| 116 | * cookie; the client cannot. | |
| 174 | 117 | */ |
| 175 | 118 | signOut() { |
| 176 | const bearer = token() | |
| 177 | if (bearer) { | |
| 178 | fetch('/logout', { method: 'POST', headers: { Authorization: `Bearer ${bearer}` } }) | |
| 179 | .catch(() => {}) | |
| 180 | } | |
| 119 | fetch('/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}) | |
| 181 | 120 | batch(() => { |
| 182 | token.set('') | |
| 183 | user.set(null) | |
| 184 | 121 | viewer.set(null) |
| 185 | 122 | pro.set(false) |
| 186 | 123 | viewerLoaded.set(false) |
| 187 | 124 | }) |
| 188 | 125 | // reload, not a fragment swap: signing out should drop every signal and |
| 189 | // cached query in memory, not carry them to the next page. `replace` | |
| 190 | // would also be defensible here — it would keep the authed page out of | |
| 191 | // history — but it is a fragment swap, so the in-memory state survives. | |
| 126 | // cached query in memory, not carry them to the next page. | |
| 192 | 127 | navigate('/login', { reload: true }) |
| 193 | 128 | }, |
| 194 | 129 | } |
| @@ -189,9 +189,4 @@ onMount(() => { | ||
| 189 | 189 | </div> |
| 190 | 190 | </div> |
| 191 | 191 | |
| 192 | {{-- Auth guard. Navigation only, renders nothing, and must run before the page is | |
| 193 | usable — a directive would hydrate too late and flash the account shell to a | |
| 194 | signed-out visitor. --}} | |
| 195 | ||
| 196 | @include('AuthGuard') | |
| 197 | 192 | @endsection |
| @@ -51,7 +51,7 @@ const rangeLabel = isCustom | ||
| 51 | 51 | const requestedProject = query.get('project') |
| 52 | 52 | const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : '' |
| 53 | 53 | |
| 54 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 54 | const __token = typeof cookies !== 'undefined' ? (cookies['auth-token'] ?? '') : '' | |
| 55 | 55 | let user: AuthUser | null = null |
| 56 | 56 | if (__token) { |
| 57 | 57 | try { |
| @@ -378,8 +378,14 @@ summary:focus-visible { | ||
| 378 | 378 | |
| 379 | 379 | <div class="mx-auto max-w-5xl"> |
| 380 | 380 | |
| 381 | {{-- Signed-out gate. An stx GET server block cannot 302, so instead of a | |
| 382 | client pre-paint bounce (deleted with AuthGuard) the server renders a | |
| 383 | sign-in panel when the auth-token cookie resolved no user. --}} | |
| 381 | 384 | @if (!user) |
| 382 | <div id="auth-pending" class="py-24 text-center text-sm text-subtle">Loading…</div> | |
| 385 | <div class="py-24 text-center"> | |
| 386 | <p class="mb-4 text-sm text-subtle">You're signed out.</p> | |
| 387 | <StxLink to="/login" class="inline-block px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">Sign in</StxLink> | |
| 388 | </div> | |
| 383 | 389 | @endif |
| 384 | 390 | |
| 385 | 391 | @if (user && projectsUnavailable) |
| @@ -132,10 +132,11 @@ const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : | ||
| 132 | 132 | // skips `__`-prefixed bindings, and everything else in this block is a bridge |
| 133 | 133 | // candidate that gets published into the HTML as soon as its NAME appears |
| 134 | 134 | // anywhere in the client script — comments included. Named `token`, this line |
| 135 | // put the raw session credential in the page source of every /dashboard render, | |
| 136 | // because `useStore('session').token()` appears in the client block below. See | |
| 137 | // the fuller note in settings.stx. | |
| 138 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 135 | // would put the raw session credential in the page source of every /dashboard | |
| 136 | // render. The auth-token cookie is HttpOnly now, but this SSR read still holds | |
| 137 | // its raw value, so it stays `__`-prefixed and out of the bridge. See the | |
| 138 | // fuller note in settings.stx. | |
| 139 | const __token = typeof cookies !== 'undefined' ? (cookies['auth-token'] ?? '') : '' | |
| 139 | 140 | let user: AuthUser | null = null |
| 140 | 141 | if (__token) { |
| 141 | 142 | try { |
| @@ -554,8 +555,9 @@ function dismissInvite(token: string): void { | ||
| 554 | 555 | } |
| 555 | 556 | |
| 556 | 557 | // One URL, one method — the shape useMutation fits. No Authorization header: the |
| 557 | // session store's configureFetch attaches the bearer, and its onResponseError | |
| 558 | // owns the 401, which this hand-written version had no branch for at all. | |
| 558 | // session store's configureFetch sends the HttpOnly auth-token cookie | |
| 559 | // (credentials: 'same-origin') and its onResponseError owns the 401, which this | |
| 560 | // hand-written version had no branch for at all. | |
| 559 | 561 | // |
| 560 | 562 | // `joining` stays a page signal rather than using acceptInvite.loading(). It |
| 561 | 563 | // holds WHICH invite is in flight, not merely whether one is, because the banner |
| @@ -651,8 +653,14 @@ onMount(() => { | ||
| 651 | 653 | |
| 652 | 654 | <div class="mx-auto max-w-5xl"> |
| 653 | 655 | |
| 656 | {{-- Signed-out gate. An stx GET server block cannot 302, so instead of a | |
| 657 | client pre-paint bounce (deleted with AuthGuard) the server renders a | |
| 658 | sign-in panel when the auth-token cookie resolved no user. --}} | |
| 654 | 659 | @if (!user) |
| 655 | <div id="auth-pending" class="py-24 text-center text-sm text-subtle">Loading…</div> | |
| 660 | <div class="py-24 text-center"> | |
| 661 | <p class="mb-4 text-sm text-subtle">You're signed out.</p> | |
| 662 | <StxLink to="/login" class="inline-block px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">Sign in</StxLink> | |
| 663 | </div> | |
| 656 | 664 | @endif |
| 657 | 665 | |
| 658 | 666 | @if (user && needsOnboarding) |
| @@ -906,14 +914,4 @@ onMount(() => { | ||
| 906 | 914 | |
| 907 | 915 | </div> |
| 908 | 916 | </div> |
| 909 | ||
| 910 | {{-- Client guard: enforce the bearer token and mirror it into the cookie the | |
| 911 | server authenticates from. Navigation only — it renders nothing, and it has | |
| 912 | to run before the page is usable, so it stays a plain script rather than | |
| 913 | hydrating with the rest. | |
| 914 | The chrome and invite banner it used to build now live in the client block | |
| 915 | at the top of this file. --}} | |
| 916 | ||
| 917 | ||
| 918 | @include('AuthGuard') | |
| 919 | 917 | @endsection |
| @@ -26,7 +26,7 @@ useSeoMeta({ | ||
| 26 | 26 | const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '') |
| 27 | 27 | const logId = (query.get('log') || '').slice(0, 255) |
| 28 | 28 | |
| 29 | const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : '' | |
| 29 | const __token = typeof cookies !== 'undefined' ? (cookies['auth-token'] ?? '') : '' | |
| 30 | 30 | let __user: any = null |
| 31 | 31 | if (__token) { |
| 32 | 32 | try { |
| @@ -132,7 +132,7 @@ const analysisVerification = Array.isArray(analysis?.verification) ? analysis.ve | ||
| 132 | 132 | // it completes inside this request rather than needing a queue and a poller - |
| 133 | 133 | // see the note in app/Fix/analyze.ts. |
| 134 | 134 | const action = async ({ form, cookies: reqCookies }: { form: Record<string, string | string[]>, cookies: Record<string, string> }) => { |
| 135 | const token = reqCookies.loghq_token || '' | |
| 135 | const token = reqCookies['auth-token'] || '' | |
| 136 | 136 | // Re-authenticated from the request the action was handed. A POST is its own |
| 137 | 137 | // request and the session can expire between drawing the button and pressing |
| 138 | 138 | // it. |
| @@ -48,7 +48,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/' }] }) | ||
| 48 | 48 | // This also settles ch.9.1. Both CTAs were plain `<a href>` carrying a `:href` |
| 49 | 49 | // binding, which is a same-origin navigation done as a full document load. They |
| 50 | 50 | // are `<StxLink>` now — one branch, one destination, no binding to override it. |
| 51 | const pageAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token | |
| 51 | const pageAuthed = typeof cookies !== 'undefined' && !!cookies['auth-token'] | |
| 52 | 52 | </script> |
| 53 | 53 | |
| 54 | 54 | <script client> |
| @@ -1,17 +1,4 @@ | ||
| 1 | 1 | <script server> |
| 2 | // STATIC import, and that is load-bearing — do not turn it into an | |
| 3 | // `await import('@stacksjs/auth')` inside the action below. | |
| 4 | // | |
| 5 | // A static import here is resolved against this project. A dynamic import | |
| 6 | // evaluated inside an action's body is resolved in the context of the module | |
| 7 | // running it — stx's variable-extractor — and the stx fork's | |
| 8 | // node_modules/@stacksjs/auth is a symlink into a SIBLING PROJECT (bughq). So | |
| 9 | // the dynamic form loaded another app's auth package, whose `@stacksjs/orm` | |
| 10 | // has no User model generated for this database, and `Auth.login` died on | |
| 11 | // `User.where is not a function`. Measured, not theorised: the stack trace | |
| 12 | // named bughq's path. | |
| 13 | import { Auth } from '@stacksjs/auth' | |
| 14 | ||
| 15 | 2 | // Show a provider's sign-in button only when it's actually configured, so the |
| 16 | 3 | // buttons stay hidden until credentials are set (then reappear automatically — |
| 17 | 4 | // no code change). See config/services.ts. |
| @@ -32,77 +19,105 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/login' }] }) | ||
| 32 | 19 | // server-known at render time, so reading it on the client meant the message |
| 33 | 20 | // could not exist until hydration. |
| 34 | 21 | const oauthError = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '').get('error') || '' |
| 22 | </script> | |
| 35 | 23 | |
| 36 | // What the action merges back on a rejected submit, declared with its | |
| 37 | // resting values. | |
| 38 | // | |
| 39 | // Not decoration. On a GET neither name exists at all, so the template's | |
| 40 | // ` | |