@stacksjs/defaults 0.75.228 → 0.75.229

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.
@@ -1,20 +1,41 @@
1
1
  import { Action } from '@stacksjs/actions/runtime'
2
- import { Auth, authCookieForBrowserSession, resolveBrowserSessionPolicy, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
2
+ import { createSessionHandoff, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
3
3
  import { log } from '@stacksjs/logging'
4
4
  import { response } from '@stacksjs/router'
5
- import { isSocialProviderConfigured, socialHandoffFailureRedirect, socialHandoffRedirect, socialProvider } from '@stacksjs/socials'
5
+ import {
6
+ clearOAuthFlowCookie,
7
+ consumeOAuthFlow,
8
+ isSocialProviderConfigured,
9
+ readOAuthFlowCookie,
10
+ SOCIAL_PROVIDERS,
11
+ socialHandoffCodeRedirect,
12
+ socialHandoffFailureRedirect,
13
+ socialProvider,
14
+ } from '@stacksjs/socials'
15
+ import { safeSocialReturnPath, socialFlowCookieOptions, socialFlowPurpose } from './social-flow'
6
16
 
7
17
  /**
8
18
  * `GET|POST /auth/{provider}/callback` — complete a social sign-in
9
19
  * (stacksjs/stacks#2276).
10
20
  *
11
- * The provider redirects back here with a `code`. The driver exchanges it for
12
- * the provider's user, `resolveSocialSignIn` decides which local user that is
13
- * (find-or-create, with the unverified-email takeover guard), and the session
14
- * is handed to the browser twice over: the auth cookie for server-rendered
15
- * pages, and the fragment handoff `useAuth().completeSocialLogin()` reads for
16
- * the SPA side. POST as well as GET because Apple mandates
17
- * `response_mode=form_post` whenever scopes are requested.
21
+ * 1. The returned `state` must redeem the flow SocialRedirectAction started,
22
+ * in this browser (its HttpOnly cookie), once, within ten minutes. A code
23
+ * from anywhere else is refused before it is exchanged: that is the login
24
+ * CSRF a callback which "exchanges any code" is open to.
25
+ * 2. The driver exchanges the code; Apple's id_token must carry the flow's
26
+ * nonce, X's exchange gets the flow's PKCE verifier back.
27
+ * 3. `resolveSocialSignIn` decides which local user that is (find-or-create,
28
+ * refusing to link an address the provider or the local account has not
29
+ * verified).
30
+ * 4. No session is minted here. The browser gets a 60-second, single-use
31
+ * handoff code in the fragment, bound to a new HttpOnly cookie, and
32
+ * `useAuth().completeSocialLogin()` redeems it at `POST /auth/handoff`
33
+ * (SessionHandoffAction), which answers with the session. Bearer and
34
+ * refresh tokens therefore never appear in a URL or in browser history.
35
+ *
36
+ * POST as well as GET because Apple mandates `response_mode=form_post`
37
+ * whenever scopes are requested. On a POST only the form body is read, so a
38
+ * query string cannot override what Apple sent.
18
39
  *
19
40
  * A refused sign-in redirects to /login with a `social_error` the page can
20
41
  * render; it is a policy decision, not an exception. Provider/exchange
@@ -22,7 +43,7 @@ import { isSocialProviderConfigured, socialHandoffFailureRedirect, socialHandoff
22
43
  */
23
44
  export default new Action({
24
45
  name: 'SocialCallbackAction',
25
- description: 'Completes a social provider OAuth flow and signs the browser in.',
46
+ description: 'Completes a social provider OAuth flow and hands the browser a one-use sign-in code.',
26
47
  method: 'GET',
27
48
 
28
49
  async handle(request: RequestInstance) {
@@ -35,45 +56,91 @@ export default new Action({
35
56
  if (!driver)
36
57
  return response.notFound(`Social provider "${provider}" is not configured.`)
37
58
 
38
- const code = String(request.get('code') ?? '')
59
+ const descriptor = SOCIAL_PROVIDERS[provider as keyof typeof SOCIAL_PROVIDERS]
60
+ const isPost = String(request.method ?? '').toUpperCase() === 'POST'
61
+ const form = (request as { formBody?: Record<string, unknown> }).formBody
62
+ const field = (name: string): string => {
63
+ const value = isPost ? form?.[name] : request.get(name)
64
+ return typeof value === 'string' ? value : ''
65
+ }
66
+
67
+ const state = field('state')
68
+ const cookieOptions = socialFlowCookieOptions(descriptor.postCallback)
69
+ // Every outcome clears the flow cookie: it is single use either way.
70
+ const withClearedFlow = async (redirect: Response): Promise<Response> => {
71
+ if (state)
72
+ redirect.headers.append('Set-Cookie', await clearOAuthFlowCookie(state, cookieOptions))
73
+ return redirect
74
+ }
75
+ const failure = (reason: string) => withClearedFlow(socialHandoffFailureRedirect(reason))
76
+
77
+ const flow = await consumeOAuthFlow(state, {
78
+ purpose: socialFlowPurpose(provider),
79
+ browserSecret: readOAuthFlowCookie(request.headers.get('cookie'), state),
80
+ })
81
+ if (!flow)
82
+ return failure('This sign-in expired or was started in another browser. Please try again.')
83
+
84
+ if (field('error'))
85
+ return failure('Sign-in was cancelled.')
86
+
87
+ const code = field('code')
39
88
  if (!code)
40
- return socialHandoffFailureRedirect(`${provider} did not return an authorization code.`)
89
+ return failure(`${provider} did not return an authorization code.`)
41
90
 
42
91
  try {
92
+ const verifier = typeof flow.data?.verifier === 'string' ? flow.data.verifier : ''
93
+ if (verifier)
94
+ driver.withCodeVerifier(verifier)
95
+
43
96
  const accessToken = await driver.getAccessToken(code)
44
- const identity = await driver.getUserByToken(accessToken)
97
+ const identity = await driver.getUserByToken(accessToken, flow.nonce ? { nonce: flow.nonce } : undefined)
98
+
99
+ if (descriptor.nonce) {
100
+ // The driver already refuses a mismatched nonce when given one; this
101
+ // also refuses a flow that somehow carried none.
102
+ if (!flow.nonce || identity.raw?.nonce !== flow.nonce)
103
+ return failure('Sign-in could not be completed. Please try again.')
104
+ // Apple sends the name once, on the first authorization, in the form body.
105
+ if (!identity.name)
106
+ identity.name = appleDisplayName(field('user'))
107
+ }
45
108
 
46
109
  const { userId } = await resolveSocialSignIn(provider, identity)
47
110
 
48
- const policy = resolveBrowserSessionPolicy(false)
49
- const session = await Auth.loginUsingId(userId, {
50
- expiresInMinutes: policy.expiresInMinutes,
51
- withRefreshToken: policy.withRefreshToken,
52
- })
53
- if (!session?.token)
54
- return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
55
-
56
- const redirect = socialHandoffRedirect({
57
- token: session.token,
58
- ...(session.refreshToken && { refreshToken: session.refreshToken }),
59
- ...(session.expiresIn !== undefined && { expiresIn: session.expiresIn }),
60
- user: {
61
- id: userId,
62
- email: identity.email ?? null,
63
- name: identity.name ?? identity.nickname ?? null,
64
- },
65
- })
66
-
67
- // The cookie signs server-rendered pages in; the fragment pack the SPA.
68
- redirect.headers.append('Set-Cookie', authCookieForBrowserSession(session.token, session.expiresIn))
69
- return redirect
111
+ const handoff = await createSessionHandoff(userId, { bindTo: 'cookie' })
112
+ const redirectTo = safeSocialReturnPath(flow.data?.redirectTo) ?? '/'
113
+ const redirect = socialHandoffCodeRedirect(handoff.code, { redirectTo })
114
+ if (handoff.cookie)
115
+ redirect.headers.append('Set-Cookie', handoff.cookie)
116
+ return await withClearedFlow(redirect)
70
117
  }
71
118
  catch (error) {
72
119
  if (error instanceof SocialSignInRefusedError)
73
- return socialHandoffFailureRedirect(error.message)
120
+ return failure(error.message)
74
121
 
122
+ // Provider errors can echo codes or addresses; the message stays in the log.
75
123
  log.error(`[socials] ${provider} callback failed: ${(error as Error).message}`)
76
- return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
124
+ return failure('Sign-in could not be completed. Please try again.')
77
125
  }
78
126
  },
79
127
  })
128
+
129
+ /** Apple's first-authorization `user` field: `{"name":{"firstName","lastName"}}`. */
130
+ function appleDisplayName(value: string): string {
131
+ if (!value || value.length > 4096)
132
+ return ''
133
+ try {
134
+ const name = (JSON.parse(value) as { name?: { firstName?: unknown, lastName?: unknown } })?.name
135
+ return [name?.firstName, name?.lastName]
136
+ .filter((part): part is string => typeof part === 'string')
137
+ .join(' ')
138
+ // eslint-disable-next-line no-control-regex
139
+ .replace(/[\u0000-\u001F\u007F]/g, '')
140
+ .trim()
141
+ .slice(0, 255)
142
+ }
143
+ catch {
144
+ return ''
145
+ }
146
+ }
@@ -1,6 +1,9 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { randomBytes } from 'node:crypto'
1
3
  import { Action } from '@stacksjs/actions/runtime'
2
4
  import { response } from '@stacksjs/router'
3
- import { isSocialProviderConfigured, socialProvider } from '@stacksjs/socials'
5
+ import { isSocialProviderConfigured, issueOAuthFlow, SOCIAL_PROVIDERS, socialProvider } from '@stacksjs/socials'
6
+ import { safeSocialReturnPath, SOCIAL_FLOW_TTL_MS, socialFlowCookieOptions, socialFlowPurpose } from './social-flow'
4
7
 
5
8
  /**
6
9
  * `GET /auth/{provider}` — send the browser to the provider's consent page
@@ -10,6 +13,15 @@ import { isSocialProviderConfigured, socialProvider } from '@stacksjs/socials'
10
13
  * half-built OAuth flow: the sign-in page renders its buttons from
11
14
  * `configuredSocialProviders()`, so a visitor only reaches this with a name
12
15
  * that works — anything else is URL guessing.
16
+ *
17
+ * The `state` in the provider URL comes from `issueOAuthFlow()`: single use,
18
+ * ten minutes, and bound to this browser by an HttpOnly cookie, so the
19
+ * callback can refuse a code that this browser did not ask for (login CSRF).
20
+ * Apple's flow also carries a nonce the callback finds in the id_token, and
21
+ * X's carries its PKCE verifier.
22
+ *
23
+ * `?redirect_to=/path` picks where the browser lands afterwards. Only a
24
+ * same-origin relative path is kept; anything else lands on `/`.
13
25
  */
14
26
  export default new Action({
15
27
  name: 'SocialRedirectAction',
@@ -26,12 +38,35 @@ export default new Action({
26
38
  if (!driver)
27
39
  return response.notFound(`Social provider "${provider}" is not configured.`)
28
40
 
29
- return new Response(null, {
30
- status: 302,
31
- headers: {
32
- 'Location': await driver.getAuthUrl(),
33
- 'Cache-Control': 'no-store',
41
+ const descriptor = SOCIAL_PROVIDERS[provider as keyof typeof SOCIAL_PROVIDERS]
42
+ const verifier = descriptor.pkce ? Buffer.from(randomBytes(32)).toString('base64url') : undefined
43
+ const redirectTo = safeSocialReturnPath(request.get('redirect_to'))
44
+
45
+ const flow = await issueOAuthFlow({
46
+ purpose: socialFlowPurpose(provider),
47
+ ttlMs: SOCIAL_FLOW_TTL_MS,
48
+ nonce: descriptor.nonce,
49
+ data: {
50
+ ...(redirectTo && { redirectTo }),
51
+ ...(verifier && { verifier }),
34
52
  },
53
+ cookie: socialFlowCookieOptions(descriptor.postCallback),
35
54
  })
55
+
56
+ driver.withState(flow.state)
57
+ if (verifier)
58
+ driver.withCodeVerifier(verifier)
59
+ if (flow.nonce)
60
+ driver.with({ nonce: flow.nonce })
61
+
62
+ const headers = new Headers({
63
+ 'Location': await driver.getAuthUrl(),
64
+ 'Cache-Control': 'no-store',
65
+ 'Referrer-Policy': 'no-referrer',
66
+ })
67
+ if (flow.cookie)
68
+ headers.append('Set-Cookie', flow.cookie)
69
+
70
+ return new Response(null, { status: 302, headers })
36
71
  },
37
72
  })
@@ -0,0 +1,49 @@
1
+ import type { OAuthFlowCookieOptions } from '@stacksjs/socials'
2
+
3
+ /**
4
+ * Shared by the default social sign-in actions (SocialRedirectAction,
5
+ * SocialCallbackAction, SessionHandoffAction). Not an action itself.
6
+ */
7
+
8
+ /** A provider round trip: consent screens, 2FA at the provider, a slow phone. */
9
+ export const SOCIAL_FLOW_TTL_MS = 10 * 60_000
10
+
11
+ /** One purpose per provider, so a Google state can never complete an Apple callback. */
12
+ export function socialFlowPurpose(provider: string): string {
13
+ return `social:${provider}`
14
+ }
15
+
16
+ /**
17
+ * A provider that returns with a cross-site POST (Apple's `form_post`) only
18
+ * gets the flow cookie back when it is `SameSite=None; Secure`. Everything
19
+ * else returns with a top-level GET, which `Lax` covers.
20
+ */
21
+ export function socialFlowCookieOptions(postCallback: boolean): OAuthFlowCookieOptions {
22
+ return { path: '/', sameSite: postCallback ? 'None' : 'Lax' }
23
+ }
24
+
25
+ /**
26
+ * Where a finished sign-in may land: a same-origin path, or nothing.
27
+ *
28
+ * Backslashes and control characters are refused because browsers normalize
29
+ * them while parsing, so `/\evil.example` is an absolute URL to them. `//`
30
+ * is protocol-relative for the same reason.
31
+ */
32
+ export function safeSocialReturnPath(value: unknown): string | null {
33
+ if (typeof value !== 'string' || value.length === 0 || value.length > 2048)
34
+ return null
35
+ if (!value.startsWith('/') || value.startsWith('//'))
36
+ return null
37
+ // eslint-disable-next-line no-control-regex
38
+ if (/[\\\u0000-\u001F\u007F]/.test(value))
39
+ return null
40
+ try {
41
+ const parsed = new URL(value, 'https://stacks.invalid')
42
+ if (parsed.origin !== 'https://stacks.invalid')
43
+ return null
44
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`
45
+ }
46
+ catch {
47
+ return null
48
+ }
49
+ }
@@ -1,4 +1,4 @@
1
- import { clientAddress, createRateLimitMiddleware, Middleware, parseThrottleString } from '@stacksjs/router'
1
+ import { clientAddress, createRateLimitMiddleware, Middleware, parseThrottleString, scaledLimit } from '@stacksjs/router'
2
2
 
3
3
  // Cache for rate limiters to avoid creating new instances for each request
4
4
  const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware>>()
@@ -77,14 +77,15 @@ export default new Middleware({
77
77
  if (!limiter) {
78
78
  try {
79
79
  const config = parseThrottleString(params as Parameters<typeof parseThrottleString>[0])
80
- limiter = createRateLimitMiddleware({ ...config, keyGenerator: throttleKey }, `throttle:${params}`)
80
+ // `config.security.rateLimit.scale` multiplies every application limit.
81
+ limiter = createRateLimitMiddleware({ ...config, maxAttempts: scaledLimit(config.maxAttempts), keyGenerator: throttleKey }, `throttle:${params}`)
81
82
  limiterCache.set(params, limiter)
82
83
  }
83
84
  catch (error) {
84
85
  console.error(`[Throttle] Invalid throttle pattern: ${params}`, error)
85
86
  // Use default if pattern is invalid
86
87
  const defaultConfig = parseThrottleString('60,1')
87
- limiter = createRateLimitMiddleware({ ...defaultConfig, keyGenerator: throttleKey }, 'throttle:default')
88
+ limiter = createRateLimitMiddleware({ ...defaultConfig, maxAttempts: scaledLimit(defaultConfig.maxAttempts), keyGenerator: throttleKey }, 'throttle:default')
88
89
  limiterCache.set(params, limiter)
89
90
  }
90
91
  }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.228",
5
+ "version": "0.75.229",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
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.75.228",
5
+ "version": "0.75.229",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@iconify-json/f7": "^1.2.2",
46
46
  "@iconify-json/hugeicons": "^1.2.35",
47
- "@stacksjs/mobile": "0.75.228",
47
+ "@stacksjs/mobile": "0.75.229",
48
48
  "@stacksjs/sanitizer": "^0.2.534",
49
49
  "@xterm/addon-fit": "^0.11.0",
50
50
  "@xterm/xterm": "^6.0.0",
@@ -5,7 +5,17 @@ const appName = props.appName || 'Stacks'
5
5
  const resetUrl = props.resetUrl || '#'
6
6
  const expireMinutes = props.expireMinutes || 60
7
7
  const primaryColor = props.primaryColor || '#3451b2'
8
- const subject = props.subject || `Reset your ${appName} password`
8
+ // `purpose: 'setup'` is a first password for an account someone else created.
9
+ const setup = props.purpose === 'setup'
10
+ const subject = props.subject || (setup ? `Set your ${appName} password` : `Reset your ${appName} password`)
11
+ const heading = setup ? 'Choose your password' : 'Reset your password'
12
+ const intro = setup
13
+ ? `An account has been created for you on ${appName}. Click the button below to choose your password.`
14
+ : `You recently requested to reset your password for your ${appName} account. Click the button below to reset it.`
15
+ const action = setup ? 'Set Password' : 'Reset Password'
16
+ const ignore = setup
17
+ ? 'If you were not expecting this, you can ignore this email.'
18
+ : 'If you did not request a password reset, please ignore this email or contact support if you have concerns about your account security.'
9
19
  const year = props.year || new Date().getFullYear()
10
20
  </script>
11
21
 
@@ -18,13 +28,13 @@ const year = props.year || new Date().getFullYear()
18
28
  </tr>
19
29
  </template>
20
30
 
21
- <EmailText size="heading">Reset your password</EmailText>
22
- <EmailText>You recently requested to reset your password for your <strong>{{ appName }}</strong> account. Click the button below to reset it.</EmailText>
31
+ <EmailText size="heading">{{ heading }}</EmailText>
32
+ <EmailText>{{ intro }}</EmailText>
23
33
 
24
- <EmailButton href="{{ resetUrl }}" bg="{{ primaryColor }}">Reset Password</EmailButton>
34
+ <EmailButton href="{{ resetUrl }}" bg="{{ primaryColor }}">{{ action }}</EmailButton>
25
35
 
26
- <EmailText size="sm" color="#6b7280">This password reset link will expire in <strong>{{ expireMinutes }} minutes</strong>.</EmailText>
27
- <EmailText size="sm" color="#6b7280">If you did not request a password reset, please ignore this email or contact support if you have concerns about your account security.</EmailText>
36
+ <EmailText size="sm" color="#6b7280">This link will expire in <strong>{{ expireMinutes }} minutes</strong>.</EmailText>
37
+ <EmailText size="sm" color="#6b7280">{{ ignore }}</EmailText>
28
38
 
29
39
  <EmailDivider />
30
40
  <EmailText size="sm" color="#9ca3af" spacing="8px">If the button above doesn't work, copy and paste the following URL into your browser:</EmailText>
package/routes/socials.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Framework Default Routes - Social sign-in (stacksjs/stacks#2276)
3
3
  *
4
- * `GET /auth/{provider}` sends the browser to the provider's consent page;
5
- * `/auth/{provider}/callback` completes the exchange, resolves the local user
4
+ * `GET /auth/{provider}` sends the browser to the provider's consent page
5
+ * with a single-use, browser-bound `state`; `/auth/{provider}/callback`
6
+ * redeems that state, completes the exchange, resolves the local user
6
7
  * through the find-or-create policy in `@stacksjs/auth` (with its
7
- * unverified-email takeover guard), and hands the session to the browser.
8
+ * unverified-email takeover guards), and redirects with a one-use handoff
9
+ * code that `POST /auth/handoff` trades for the session.
8
10
  *
9
11
  * Mounted by `defaults/bootstrap.ts` as the `social` bundle. Unlike the other
10
12
  * bundles it is NOT part of the implicit default set: it mounts only when the
@@ -29,4 +31,12 @@ import { route } from '@stacksjs/router'
29
31
  // provider's authorize page with this app's client id.
30
32
  route.get('/auth/{provider}', 'Actions/Auth/SocialRedirectAction').rateLimit(10, 'minute')
31
33
  route.get('/auth/{provider}/callback', 'Actions/Auth/SocialCallbackAction').rateLimit(10, 'minute')
32
- route.post('/auth/{provider}/callback', 'Actions/Auth/SocialCallbackAction').rateLimit(10, 'minute')
34
+ // Apple's form_post is a cross-site POST that cannot carry a CSRF token. The
35
+ // single-use, cookie-bound OAuth `state` the action checks is the CSRF
36
+ // defence here, and a stronger one: it binds the code to the browser that
37
+ // started the flow.
38
+ route.post('/auth/{provider}/callback', 'Actions/Auth/SocialCallbackAction').skipCsrf().rateLimit(10, 'minute')
39
+ // Trades the callback's one-use code for the session (useAuth's
40
+ // completeSocialLogin). Bound to an HttpOnly cookie set beside the code,
41
+ // which is the proof a CSRF token would add.
42
+ route.post('/auth/handoff', 'Actions/Auth/SessionHandoffAction').skipCsrf().rateLimit(10, 'minute')