@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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auth/ACCOUNT-WORKFLOWS.md +24 -7
- package/ai/skills/stacks-auth/SKILL.md +42 -8
- package/ai/skills/stacks-browser/SKILL.md +31 -8
- package/ai/skills/stacks-cli/SKILL.md +8 -0
- package/ai/skills/stacks-database/SKILL.md +4 -0
- package/ai/skills/stacks-datetime/SKILL.md +67 -3
- package/ai/skills/stacks-native/CATALOG.md +32 -32
- package/ai/skills/stacks-notifications/SKILL.md +16 -1
- package/ai/skills/stacks-payments/SKILL.md +120 -5
- package/ai/skills/stacks-router/SKILL.md +23 -1
- package/ai/skills/stacks-security/SKILL.md +44 -1
- package/ai/skills/stacks-socials/SKILL.md +55 -19
- package/ai/skills/stacks-storage/SKILL.md +14 -0
- package/ai/skills/stacks-testing/SKILL.md +95 -4
- package/app/Actions/Auth/SessionHandoffAction.ts +65 -0
- package/app/Actions/Auth/SocialCallbackAction.ts +104 -37
- package/app/Actions/Auth/SocialRedirectAction.ts +41 -6
- package/app/Actions/Auth/social-flow.ts +49 -0
- package/app/Middleware/Throttle.ts +4 -3
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/emails/password-reset.stx +16 -6
- package/routes/socials.ts +14 -4
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions/runtime'
|
|
2
|
-
import {
|
|
2
|
+
import { createSessionHandoff, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
|
|
3
3
|
import { log } from '@stacksjs/logging'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
|
-
import {
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
}
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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">
|
|
22
|
-
<EmailText>
|
|
31
|
+
<EmailText size="heading">{{ heading }}</EmailText>
|
|
32
|
+
<EmailText>{{ intro }}</EmailText>
|
|
23
33
|
|
|
24
|
-
<EmailButton href="{{ resetUrl }}" bg="{{ primaryColor }}">
|
|
34
|
+
<EmailButton href="{{ resetUrl }}" bg="{{ primaryColor }}">{{ action }}</EmailButton>
|
|
25
35
|
|
|
26
|
-
<EmailText size="sm" color="#6b7280">This
|
|
27
|
-
<EmailText size="sm" color="#6b7280">
|
|
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`
|
|
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
|
|
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
|
-
|
|
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')
|