ReviewOS

also looking at this

stacks/commshq

test(auth): pin the browser session contract, and gate the deploy

#14
Merged glennmichael123 wants to merge feat/auth-framework-session into main
4 files +400 -8
.github/workflows/deploy.ymlmodified+24-0
Changes to .github/workflows/deploy.yml
@@ -48,6 +48,22 @@ jobs:
4848 - name: Install dependencies
4949 run: bun install --frozen-lockfile
5050
51 # The suite runs here, not as a `needs:` on the CI workflow: `needs:`
52 # only reaches jobs in the same file, and lint/typecheck/test live in
53 # ci.yml. Without this, a push whose tests fail still deploys - as
54 # aac396460 did on 2026-09-24, where CI's test job failed and this
55 # workflow succeeded on the same sha, in the same minute. The whole
56 # suite costs a few seconds, so gating here is cheaper than a
57 # workflow_run indirection.
58 - name: Lint
59 run: ./buddy lint
60
61 - name: Typecheck
62 run: ./buddy typecheck
63
64 - name: Test
65 run: bun run test
66
5167 - name: Build the production artifact
5268 run: bun run build
5369
@@ -89,6 +105,14 @@ jobs:
89105 DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }}
90106 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91107
108 # SMOKE_EMAIL / SMOKE_PASSWORD turn on the sign-in leg: a real POST /login
109 # followed by an authenticated GET /api/v1/workspace. Without them the
110 # script still runs and says it skipped that leg, so this step does not
111 # fail before the secrets exist. The account must belong to a team and
112 # must not have two-factor enabled.
92113 - name: Smoke test production
93114 if: github.event_name == 'push' || github.event.inputs.dry_run == 'false'
94115 run: bun run deployment:smoke
116 env:
117 SMOKE_EMAIL: ${{ secrets.SMOKE_EMAIL }}
118 SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }}
scripts/smoke-deployment.tsmodified+101-8
Changes to scripts/smoke-deployment.ts
@@ -50,14 +50,107 @@ async function readWithRetry(path: string): Promise<string> {
5050 throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`)
5151}
5252
53const home = await readWithRetry('/')
54if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`)
53/**
54 * The token out of a `Set-Cookie` header, or a failure that says which half of
55 * the contract broke.
56 *
57 * Exported so the parsing can be tested without a network round trip; the live
58 * checks below only run under `import.meta.main`.
59 */
60export function assertSessionCookie(setCookie: string | null | undefined): string {
61 if (!setCookie)
62 throw new Error('POST /login returned no Set-Cookie header, so the sign-in issued no session.')
5563
56const login = await readWithRetry('/login')
57if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.')
64 const match = /(?:^|,\s*)auth-token=([^;,\s]*)/.exec(setCookie)
65 if (!match)
66 throw new Error(`POST /login set no auth-token cookie. Set-Cookie was: ${setCookie.slice(0, 200)}`)
67 if (!match[1])
68 throw new Error('POST /login set an empty auth-token cookie.')
5869
59const robots = await readWithRetry('/robots.txt')
60if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
61if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
70 return decodeURIComponent(match[1])
71}
72
73/**
74 * The attributes a deployed session cookie must carry.
75 *
76 * `Secure` is checked because the framework decides it from `config.app.url`,
77 * which falls back to `commshq.localhost` — a loopback host, which drops the
78 * flag. A misconfigured or undecryptable APP_URL would therefore ship a
79 * long-lived session token in the clear, and nothing else would notice.
80 */
81export function assertSessionCookieAttributes(setCookie: string): void {
82 const required = ['HttpOnly', 'Path=/', 'SameSite=Lax', 'Secure']
83 const missing = required.filter(attribute => !new RegExp(`(?:^|;\\s*)${attribute}`, 'i').test(setCookie))
84
85 if (missing.length)
86 throw new Error(`The deployed auth-token cookie is missing ${missing.join(', ')}. Set-Cookie was: ${setCookie.slice(0, 200)}`)
87
88 if (!/(?:^|;\s*)Max-Age=[1-9]\d*/i.test(setCookie))
89 throw new Error(`The deployed auth-token cookie carries no positive Max-Age. Set-Cookie was: ${setCookie.slice(0, 200)}`)
90}
91
92/**
93 * Sign in for real, then spend the session on an authenticated route.
94 *
95 * Static page checks pass with sign-in completely broken, which is how a
96 * schema or session regression reaches production unnoticed. `/api/v1/workspace`
97 * is the only auth-guarded GET in routes/api.ts, so it is the one route that can
98 * be exercised without a side effect.
99 *
100 * Deliberately not retried: POST /login is rate limited to 5/minute
101 * (routes/auth.ts:23), and a retry loop would spend that budget and report a
102 * 429 as a deploy failure.
103 */
104async function assertSignInWorks(): Promise<boolean> {
105 const email = process.env.SMOKE_EMAIL
106 const password = process.env.SMOKE_PASSWORD
107
108 if (!email || !password) {
109 console.log(' sign-in leg skipped: SMOKE_EMAIL and SMOKE_PASSWORD are not set.')
110 return false
111 }
112
113 const signIn = await fetch(`${baseUrl}/login`, {
114 method: 'POST',
115 headers: { 'content-type': 'application/json', 'accept': 'application/json' },
116 body: JSON.stringify({ email, password }),
117 redirect: 'manual',
118 signal: AbortSignal.timeout(15_000),
119 })
120
121 if (signIn.status !== 200)
122 throw new Error(`POST /login returned ${signIn.status}, expected 200. A 422 usually means the smoke credentials are wrong; a 429 means the rate limit was already spent.`)
123
124 const setCookie = signIn.headers.get('set-cookie')
125 if (!setCookie && /twoFactor|two_factor|challenge/i.test(await signIn.clone().text()))
126 throw new Error('POST /login answered with a two-factor challenge instead of a session. The smoke account must not have two-factor enabled.')
62127
63console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`)
128 const token = assertSessionCookie(setCookie)
129 assertSessionCookieAttributes(setCookie as string)
130
131 const workspace = await fetch(`${baseUrl}/api/v1/workspace`, {
132 headers: { accept: 'application/json', cookie: `auth-token=${encodeURIComponent(token)}` },
133 signal: AbortSignal.timeout(15_000),
134 })
135
136 if (workspace.status !== 200)
137 throw new Error(`GET /api/v1/workspace returned ${workspace.status} for a freshly issued session. A 401 means the cookie was not accepted; a 403 means the smoke account belongs to no team.`)
138
139 return true
140}
141
142if (import.meta.main) {
143 const home = await readWithRetry('/')
144 if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`)
145
146 const login = await readWithRetry('/login')
147 if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.')
148
149 const robots = await readWithRetry('/robots.txt')
150 if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
151 if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
152
153 const signedIn = await assertSignInWorks()
154
155 console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, ${signedIn ? 'a real sign-in, ' : ''}and robots metadata are live.`)
156}
tests/Unit/BrowserSession.test.tsadded+209-0
Changes to tests/Unit/BrowserSession.test.ts
@@ -0,0 +1,209 @@
1import { describe, expect, it } from 'bun:test'
2import {
3 authCookieForBrowserSession,
4 browserSessionRemembered,
5 resolveBrowserSessionPolicy,
6 shouldSecureAuthCookie,
7} from '@stacksjs/auth'
8import { buildAuthCookie, sessionExpiryMinutes } from '../../app/Actions/Auth/authCookie'
9
10/**
11 * Characterization tests for the browser session contract.
12 *
13 * These pin what a signed-in commshq user experiences TODAY, before the
14 * hand-rolled auth overrides in `app/Actions/Auth/` are deleted in favour of
15 * the framework's browser-session policy (stacksjs/stacks#2795). They are
16 * written against both sides at once: the app's current helpers and the
17 * framework functions that will replace them. Every assertion must keep
18 * passing after the swap, which is the whole point - the moment the framework
19 * would issue a different cookie or a different session length, one of these
20 * fails instead of a user being silently signed out or silently downgraded.
21 *
22 * Nothing here touches the global config: every framework call is handed an
23 * explicit config object, so the results do not depend on which .env the
24 * suite happens to load.
25 */
26
27/** What `config/auth.ts` sets today. */
28const WEEK_MS = 7 * 24 * 60 * 60 * 1000
29const MONTH_MS = 30 * 24 * 60 * 60 * 1000
30
31const WEEK_MINUTES = 7 * 24 * 60 // 10_080
32const MONTH_MINUTES = 30 * 24 * 60 // 43_200
33
34/** The config the app must carry for the framework to reproduce today's tiers. */
35const TIERED_AUTH = {
36 tokenExpiry: WEEK_MS,
37 browserSession: { baselineLifetime: WEEK_MS, rememberedLifetime: MONTH_MS },
38}
39
40describe('browser session: lifetime tiers', () => {
41 it('is a week unchecked and a month when remembered, today', () => {
42 expect(sessionExpiryMinutes(undefined)).toBe(WEEK_MINUTES)
43 expect(sessionExpiryMinutes(true)).toBe(MONTH_MINUTES)
44 })
45
46 it('reproduces both tiers through the framework policy, given the config', () => {
47 expect(resolveBrowserSessionPolicy(undefined, TIERED_AUTH).expiresInMinutes).toBe(WEEK_MINUTES)
48 expect(resolveBrowserSessionPolicy(true, TIERED_AUTH).expiresInMinutes).toBe(MONTH_MINUTES)
49 })
50
51 /**
52 * The regression this whole file exists for. `rememberedLifetime` defaults to
53 * the baseline, so deleting the overrides WITHOUT adding the browserSession
54 * block silently collapses "keep me signed in for 30 days" to 7 days. No
55 * error, no test failure anywhere else, no user-visible signal until someone
56 * is logged out 23 days early.
57 */
58 it('collapses the remembered tier to the baseline when browserSession is absent', () => {
59 const untiered = { tokenExpiry: WEEK_MS }
60
61 expect(resolveBrowserSessionPolicy(true, untiered).expiresInMinutes).toBe(WEEK_MINUTES)
62 expect(resolveBrowserSessionPolicy(true, untiered).expiresInMinutes).not.toBe(MONTH_MINUTES)
63 })
64})
65
66describe('browser session: the remember accept-list', () => {
67 // Everything `resources/views/login.stx` can put in the JSON body. It sends a
68 // real boolean from `checkbox.checked` at step one and re-sends the stored
69 // choice at step two, but the action has always accepted the string forms too.
70 const remembered = [true, 1, '1', 'true', 'on', 'yes', 'TRUE', ' true ']
71 const notRemembered = [false, 0, undefined, null, '', '0', 'false', 'off', 'no']
72
73 it('agrees with the framework on every value the login view can send', () => {
74 for (const value of [true, false, undefined, '1', 'true', 'on', 'yes', '', '0'])
75 expect(sessionExpiryMinutes(value) === MONTH_MINUTES).toBe(browserSessionRemembered(value))
76 })
77
78 it('treats the documented truthy forms as remembered', () => {
79 for (const value of remembered)
80 expect(resolveBrowserSessionPolicy(value, TIERED_AUTH).remembered).toBe(true)
81 })
82
83 it('treats everything else as the baseline', () => {
84 for (const value of notRemembered)
85 expect(resolveBrowserSessionPolicy(value, TIERED_AUTH).remembered).toBe(false)
86 })
87})
88
89describe('browser session: the cookie contract', () => {
90 it('names, scopes and flags the cookie exactly as it does today', () => {
91 const cookie = buildAuthCookie('a'.repeat(80), WEEK_MINUTES * 60)
92
93 expect(cookie).toContain(`auth-token=${'a'.repeat(80)}`)
94 expect(cookie).toContain('Path=/')
95 expect(cookie).toContain('HttpOnly')
96 expect(cookie).toContain('SameSite=Lax')
97 expect(cookie).toContain('Max-Age=604800')
98 })
99
100 /**
101 * The cookie's Max-Age and the `oauth_access_tokens.expires_at` row have to
102 * come from the SAME number, or the browser keeps sending a credential the
103 * database has already expired (or drops one that is still valid).
104 */
105 it('stamps Max-Age from the issued lifetime, not from a recomputed default', () => {
106 const issued = 1234
107 expect(buildAuthCookie('token', issued)).toContain(`Max-Age=${issued}`)
108 expect(authCookieForBrowserSession('token', issued)).toContain(`Max-Age=${issued}`)
109 })
110
111 it('refuses to invent a lifetime when issuance did not report one', () => {
112 expect(() => authCookieForBrowserSession('token', undefined)).toThrow(TypeError)
113 expect(() => authCookieForBrowserSession('token', 0)).toThrow(TypeError)
114 expect(() => authCookieForBrowserSession('token', -1)).toThrow(TypeError)
115 })
116
117 it('leaves an 80-character hex token byte-identical through cookie encoding', () => {
118 // Access tokens are randomBytes(40).toString('hex'), on which
119 // encodeURIComponent is the identity - so no live session is invalidated
120 // when issuance moves from the app helper to the framework one.
121 const token = 'deadbeef'.repeat(10)
122 expect(token).toHaveLength(80)
123 expect(encodeURIComponent(token)).toBe(token)
124 expect(authCookieForBrowserSession(token, 60)).toContain(`auth-token=${token}`)
125 })
126})
127
128describe('browser session: two-factor keeps the tier', () => {
129 /**
130 * Step two must not downgrade a remembered session. Today `login.stx` stores
131 * the checkbox at step one and re-sends it with the code, and
132 * `VerifyTwoFactorLoginAction` runs it back through `sessionExpiryMinutes`.
133 * The framework carries it on the challenge id instead. Either way the
134 * property is the same, so this assertion survives the swap.
135 */
136 it('resolves a remembered challenge to the month, not the week', () => {
137 expect(sessionExpiryMinutes(true)).toBe(MONTH_MINUTES)
138 expect(resolveBrowserSessionPolicy(true, TIERED_AUTH).lifetimeMs).toBe(MONTH_MS)
139 })
140
141 it('resolves an unremembered challenge to the week', () => {
142 expect(sessionExpiryMinutes(false)).toBe(WEEK_MINUTES)
143 expect(resolveBrowserSessionPolicy(false, TIERED_AUTH).lifetimeMs).toBe(WEEK_MS)
144 })
145})
146
147describe('browser session: refresh tokens', () => {
148 /**
149 * Browser sessions are fixed-lifetime: no refresh exchange. The framework
150 * still defaults `withRefreshToken` to true, so turning it off is a
151 * deliberate, separately reviewable change rather than a side effect of the
152 * migration. Pinning the default here makes that flip visible.
153 */
154 it('defaults to issuing a refresh token', () => {
155 expect(resolveBrowserSessionPolicy(undefined, { tokenExpiry: WEEK_MS }).withRefreshToken).toBe(true)
156 })
157
158 it('honours an explicit opt-out', () => {
159 const fixed = { ...TIERED_AUTH, browserSession: { ...TIERED_AUTH.browserSession, withRefreshToken: false } }
160 expect(resolveBrowserSessionPolicy(true, fixed).withRefreshToken).toBe(false)
161 })
162})
163
164describe('browser session: the Secure flag', () => {
165 /**
166 * The one difference that is a security regression rather than a cosmetic
167 * change. The app helper decides Secure from APP_ENV; the framework decides
168 * it from `config.app.url`. commshq's url falls back to `commshq.localhost`
169 * (config/app.ts:15), which the framework reads as a loopback host and
170 * therefore serves WITHOUT Secure - a 7-to-30 day session cookie in the
171 * clear if APP_URL is ever absent or fails to decrypt.
172 *
173 * These assertions document the trap. `config.auth.cookie.secure` must be set
174 * explicitly in the migration so the inference never runs.
175 */
176 it('secures an https app url', () => {
177 expect(shouldSecureAuthCookie({ url: 'https://commshq.com' })).toBe(true)
178 })
179
180 it('secures a non-loopback host even over plain http', () => {
181 expect(shouldSecureAuthCookie({ url: 'http://commshq.com' })).toBe(true)
182 })
183
184 it('DROPS Secure for the commshq.localhost fallback - the trap', () => {
185 expect(shouldSecureAuthCookie({ url: 'commshq.localhost' })).toBe(false)
186 expect(shouldSecureAuthCookie({ url: 'http://localhost:3000' })).toBe(false)
187 })
188
189 it('still sets Secure today, because the app helper reads APP_ENV', () => {
190 // Guards the current behavior: whatever the migration does, a production
191 // cookie must keep this attribute.
192 const production = buildAuthCookieUnder('production')
193 expect(production).toContain('Secure')
194 })
195})
196
197/** Build a cookie with APP_ENV temporarily set, then restore it. */
198function buildAuthCookieUnder(appEnv: string): string {
199 const previous = process.env.APP_ENV
200 process.env.APP_ENV = appEnv
201 try {
202 return buildAuthCookie('token', 60)
203 }
204 finally {
205 if (previous === undefined)
206 delete process.env.APP_ENV
207 else process.env.APP_ENV = previous
208 }
209}
tests/Unit/DeploymentSmoke.test.tsadded+66-0
Changes to tests/Unit/DeploymentSmoke.test.ts
@@ -0,0 +1,66 @@
1import { describe, expect, it } from 'bun:test'
2import { assertSessionCookie, assertSessionCookieAttributes } from '../../scripts/smoke-deployment'
3
4/**
5 * The deploy smoke test signs in for real and spends the session on an
6 * authenticated route. These cover the header parsing without a network round
7 * trip, so a broken assertion is caught here rather than by a red deploy.
8 */
9
10const LIVE = 'auth-token=abc123; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800; Secure'
11
12describe('deployment smoke: session cookie parsing', () => {
13 it('reads the token out of a live Set-Cookie header', () => {
14 expect(assertSessionCookie(LIVE)).toBe('abc123')
15 })
16
17 it('url-decodes the value, since the framework encodes it', () => {
18 expect(assertSessionCookie('auth-token=a%2Bb; Path=/; HttpOnly')).toBe('a+b')
19 })
20
21 it('finds the cookie when other cookies precede it', () => {
22 expect(assertSessionCookie('other=1; Path=/, auth-token=xyz; Path=/; HttpOnly')).toBe('xyz')
23 })
24
25 it('names the real failure when sign-in issued no cookie at all', () => {
26 expect(() => assertSessionCookie(null)).toThrow('issued no session')
27 expect(() => assertSessionCookie(undefined)).toThrow('issued no session')
28 })
29
30 it('distinguishes a missing cookie from an empty one', () => {
31 expect(() => assertSessionCookie('session=nope; Path=/')).toThrow('set no auth-token cookie')
32 expect(() => assertSessionCookie('auth-token=; Path=/')).toThrow('empty auth-token cookie')
33 })
34})
35
36describe('deployment smoke: session cookie attributes', () => {
37 it('accepts the attributes a deployed session must carry', () => {
38 expect(() => assertSessionCookieAttributes(LIVE)).not.toThrow()
39 })
40
41 /**
42 * The regression this guards. shouldSecureAuthCookie() reads config.app.url,
43 * which falls back to commshq.localhost - a loopback host, so the flag is
44 * dropped and a 7-to-30 day session token ships in the clear.
45 */
46 it('rejects a production cookie served without Secure', () => {
47 expect(() => assertSessionCookieAttributes('auth-token=abc; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800'))
48 .toThrow('missing Secure')
49 })
50
51 it('rejects a cookie readable by JavaScript', () => {
52 expect(() => assertSessionCookieAttributes('auth-token=abc; Path=/; SameSite=Lax; Max-Age=604800; Secure'))
53 .toThrow('missing HttpOnly')
54 })
55
56 it('reports every missing attribute at once', () => {
57 expect(() => assertSessionCookieAttributes('auth-token=abc; Max-Age=604800'))
58 .toThrow('missing HttpOnly, Path=/, SameSite=Lax, Secure')
59 })
60
61 it('rejects a session cookie with no positive Max-Age', () => {
62 const base = 'auth-token=abc; Path=/; HttpOnly; SameSite=Lax; Secure'
63 expect(() => assertSessionCookieAttributes(base)).toThrow('no positive Max-Age')
64 expect(() => assertSessionCookieAttributes(`${base}; Max-Age=0`)).toThrow('no positive Max-Age')
65 })
66})