@stacksjs/defaults 0.75.28 → 0.75.29
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-auth/SKILL.md +22 -1
- package/app/Actions/Auth/OAuthAuthorizationAction.ts +81 -0
- package/app/Actions/Auth/OAuthClientDisableAction.ts +38 -0
- package/app/Actions/Auth/OAuthClientSecretRotateAction.ts +41 -0
- package/app/Actions/Auth/OAuthClientStoreAction.ts +73 -0
- package/app/Actions/Auth/OAuthClientUpdateAction.ts +73 -0
- package/app/Actions/Auth/OAuthClientsAction.ts +42 -0
- package/app/Actions/Auth/OAuthConnectionsAction.ts +40 -0
- package/app/Actions/Auth/OAuthConsentAction.ts +34 -0
- package/app/Actions/Auth/OAuthDisconnectAction.ts +37 -0
- package/app/Actions/Auth/OAuthMetadataAction.ts +20 -0
- package/app/Actions/Auth/OAuthRevocationAction.ts +24 -0
- package/app/Actions/Auth/OAuthTokenAction.ts +35 -0
- package/app/Actions/Auth/oauth-request.ts +10 -0
- package/app/Middleware/Csrf.ts +1 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Video.stx +4 -2
- package/resources/components/Video.test.ts +8 -0
- package/routes/auth.ts +25 -0
|
@@ -161,7 +161,28 @@ routes: minting and revoking both carry semantics a generic route does not.
|
|
|
161
161
|
The authorization server is opt-in through `config/auth.ts` under
|
|
162
162
|
`oauthProvider`. It is separate from social sign-in, where Stacks is the OAuth
|
|
163
163
|
client. The provider profile is Authorization Code with S256 PKCE and rotating
|
|
164
|
-
refresh tokens. It does not support implicit or
|
|
164
|
+
refresh tokens. It does not support implicit, password, or client-credentials
|
|
165
|
+
grants. It also does not implement token introspection or OpenID Connect.
|
|
166
|
+
|
|
167
|
+
When enabled, the default auth route bundle registers:
|
|
168
|
+
|
|
169
|
+
- `GET` and `POST /oauth/authorize`
|
|
170
|
+
- `POST /oauth/token`
|
|
171
|
+
- `POST /oauth/revoke`
|
|
172
|
+
- `GET /.well-known/oauth-authorization-server`
|
|
173
|
+
- owner-managed clients under `/auth/oauth/clients`
|
|
174
|
+
- user-managed connected applications under `/auth/oauth/connections`
|
|
175
|
+
|
|
176
|
+
Set a canonical `issuer`, then register every scope and resource explicitly.
|
|
177
|
+
Resource audiences are absolute URIs and redirect URIs use exact matching.
|
|
178
|
+
The consent view is `auth/oauth/consent` by default and can be overridden with
|
|
179
|
+
`oauthProvider.consent.view`. Use `oauthProvider.consent.resolveWorkspace` when
|
|
180
|
+
the application must bind consent to current server-owned workspace authority.
|
|
181
|
+
|
|
182
|
+
Provider actions return 404 while `oauthProvider.enabled` is false. The token
|
|
183
|
+
and revocation endpoints use protocol credentials and intentionally skip
|
|
184
|
+
browser CSRF. Authorization approval, client management, and disconnect remain
|
|
185
|
+
authenticated and CSRF protected.
|
|
165
186
|
|
|
166
187
|
- `resolveOAuthProviderConfig(options)` returns `null` unless explicitly enabled
|
|
167
188
|
- `generatePkceVerifier()` creates a 256-bit RFC 7636 verifier
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { OAuthAuthorizationConsentPageContext } from '@stacksjs/auth'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { Action } from '@stacksjs/actions'
|
|
5
|
+
import { resolveDefaultsResources } from '@stacksjs/actions/dev/defaults-resources'
|
|
6
|
+
import {
|
|
7
|
+
authenticatedUser,
|
|
8
|
+
authUser,
|
|
9
|
+
handleOAuthAuthorizationPageRequest,
|
|
10
|
+
resolveOAuthConsentWorkspace,
|
|
11
|
+
resolveOAuthProviderConfig,
|
|
12
|
+
} from '@stacksjs/auth'
|
|
13
|
+
import { config } from '@stacksjs/config'
|
|
14
|
+
import { path } from '@stacksjs/path'
|
|
15
|
+
import { getCurrentRequest, response } from '@stacksjs/router'
|
|
16
|
+
import { oauthPositiveId } from './oauth-request'
|
|
17
|
+
|
|
18
|
+
function identityLabel(user: { email: string, [key: string]: unknown }): string {
|
|
19
|
+
const name = typeof user.name === 'string' ? user.name.trim() : ''
|
|
20
|
+
return name || user.email.trim()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function renderConsentView(
|
|
24
|
+
view: string,
|
|
25
|
+
context: OAuthAuthorizationConsentPageContext,
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const relative = view.endsWith('.stx') ? view : `${view}.stx`
|
|
28
|
+
const applicationView = path.userViewsPath(relative)
|
|
29
|
+
const frameworkView = join(resolveDefaultsResources(), 'views', relative)
|
|
30
|
+
const template = existsSync(applicationView) ? applicationView : frameworkView
|
|
31
|
+
const { renderTemplate } = await import('@stacksjs/stx')
|
|
32
|
+
|
|
33
|
+
return String(await renderTemplate(template, {
|
|
34
|
+
context,
|
|
35
|
+
injectCSS: true,
|
|
36
|
+
templateOnly: true,
|
|
37
|
+
processClientScripts: false,
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default new Action({
|
|
42
|
+
name: 'OAuthAuthorizationAction',
|
|
43
|
+
description: 'Start or resume an OAuth authorization request',
|
|
44
|
+
method: 'GET',
|
|
45
|
+
|
|
46
|
+
async handle(request: RequestInstance) {
|
|
47
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
48
|
+
if (!provider)
|
|
49
|
+
return response.notFound('OAuth provider is not enabled')
|
|
50
|
+
|
|
51
|
+
// This GET route must stay public so an anonymous browser can persist the
|
|
52
|
+
// request before login. `request.user()` only exposes identity populated
|
|
53
|
+
// by auth middleware, which this route deliberately cannot use, so fall
|
|
54
|
+
// through to the lazy cookie/bearer resolver after honoring a directly
|
|
55
|
+
// injected action-test or upstream middleware identity.
|
|
56
|
+
const user = await authenticatedUser(request)
|
|
57
|
+
?? (getCurrentRequest() ? await authUser() : undefined)
|
|
58
|
+
const subjectId = user ? oauthPositiveId(user.id) : null
|
|
59
|
+
const workspace = user && subjectId
|
|
60
|
+
? await resolveOAuthConsentWorkspace(provider, { request, user })
|
|
61
|
+
: null
|
|
62
|
+
const resolveWorkspaceId = user
|
|
63
|
+
? async () => (await resolveOAuthConsentWorkspace(provider, { request, user }))?.id ?? null
|
|
64
|
+
: undefined
|
|
65
|
+
return handleOAuthAuthorizationPageRequest({
|
|
66
|
+
provider,
|
|
67
|
+
request: request as unknown as Request,
|
|
68
|
+
identity: user ? { label: identityLabel(user), workspaceLabel: workspace?.label } : null,
|
|
69
|
+
subject: subjectId
|
|
70
|
+
? {
|
|
71
|
+
type: 'users',
|
|
72
|
+
id: subjectId,
|
|
73
|
+
workspaceId: workspace?.id,
|
|
74
|
+
resolveWorkspaceId,
|
|
75
|
+
}
|
|
76
|
+
: null,
|
|
77
|
+
csrfToken: (request as unknown as { _csrfToken?: string })._csrfToken,
|
|
78
|
+
dependencies: { render: renderConsentView },
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { disableOAuthClient, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { oauthPositiveId } from './oauth-request'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'OAuthClientDisableAction',
|
|
9
|
+
description: 'Disable an OAuth client owned by the authenticated user',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
14
|
+
if (!provider)
|
|
15
|
+
return response.notFound('OAuth provider is not enabled')
|
|
16
|
+
|
|
17
|
+
const user = await request.user()
|
|
18
|
+
const ownerId = user ? oauthPositiveId(user.id) : null
|
|
19
|
+
if (!ownerId)
|
|
20
|
+
return response.unauthorized('Authentication required')
|
|
21
|
+
|
|
22
|
+
const rawClientId = request.getParam('id')
|
|
23
|
+
const clientId = /^\d+$/.test(rawClientId) ? oauthPositiveId(rawClientId) : null
|
|
24
|
+
if (!clientId)
|
|
25
|
+
return response.badRequest('Invalid OAuth client ID')
|
|
26
|
+
|
|
27
|
+
const disabled = await disableOAuthClient(ownerId, clientId)
|
|
28
|
+
if (!disabled)
|
|
29
|
+
return response.notFound('OAuth client not found')
|
|
30
|
+
|
|
31
|
+
return response.json({
|
|
32
|
+
message: 'OAuth client disabled successfully',
|
|
33
|
+
client_id: clientId,
|
|
34
|
+
}, {
|
|
35
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { resolveOAuthProviderConfig, rotateOAuthClientSecret } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { oauthPositiveId } from './oauth-request'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'OAuthClientSecretRotateAction',
|
|
9
|
+
description: 'Rotate an OAuth client secret owned by the authenticated user',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
14
|
+
if (!provider)
|
|
15
|
+
return response.notFound('OAuth provider is not enabled')
|
|
16
|
+
|
|
17
|
+
const user = await request.user()
|
|
18
|
+
const ownerId = user ? oauthPositiveId(user.id) : null
|
|
19
|
+
if (!ownerId)
|
|
20
|
+
return response.unauthorized('Authentication required')
|
|
21
|
+
|
|
22
|
+
const rawClientId = request.getParam('id')
|
|
23
|
+
const clientId = /^\d+$/.test(rawClientId) ? oauthPositiveId(rawClientId) : null
|
|
24
|
+
if (!clientId)
|
|
25
|
+
return response.badRequest('Invalid OAuth client ID')
|
|
26
|
+
|
|
27
|
+
const clientSecret = await rotateOAuthClientSecret(ownerId, clientId)
|
|
28
|
+
if (!clientSecret)
|
|
29
|
+
return response.notFound('OAuth client not found')
|
|
30
|
+
|
|
31
|
+
return response.json({
|
|
32
|
+
client_id: clientId,
|
|
33
|
+
client_secret: clientSecret,
|
|
34
|
+
}, {
|
|
35
|
+
headers: {
|
|
36
|
+
'Cache-Control': 'no-store',
|
|
37
|
+
'Pragma': 'no-cache',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import {
|
|
3
|
+
OAuthClientRegistrationError,
|
|
4
|
+
registerOAuthClient,
|
|
5
|
+
resolveOAuthProviderConfig,
|
|
6
|
+
} from '@stacksjs/auth'
|
|
7
|
+
import { config } from '@stacksjs/config'
|
|
8
|
+
import { response } from '@stacksjs/router'
|
|
9
|
+
import { oauthPositiveId, oauthStringList } from './oauth-request'
|
|
10
|
+
|
|
11
|
+
export default new Action({
|
|
12
|
+
name: 'OAuthClientStoreAction',
|
|
13
|
+
description: 'Register an OAuth client for the authenticated user',
|
|
14
|
+
method: 'POST',
|
|
15
|
+
|
|
16
|
+
async handle(request: RequestInstance) {
|
|
17
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
18
|
+
if (!provider)
|
|
19
|
+
return response.notFound('OAuth provider is not enabled')
|
|
20
|
+
|
|
21
|
+
const user = await request.user()
|
|
22
|
+
const ownerId = user ? oauthPositiveId(user.id) : null
|
|
23
|
+
if (!ownerId)
|
|
24
|
+
return response.unauthorized('Authentication required')
|
|
25
|
+
|
|
26
|
+
const body = await request.all() as Record<string, unknown>
|
|
27
|
+
const name = typeof body.name === 'string' ? body.name : null
|
|
28
|
+
const type = body.type === 'public' || body.type === 'confidential' ? body.type : null
|
|
29
|
+
const redirectUris = oauthStringList(body.redirect_uris)
|
|
30
|
+
const scopes = oauthStringList(body.scopes)
|
|
31
|
+
const resources = oauthStringList(body.resources)
|
|
32
|
+
if (!name || !type || !redirectUris || !scopes || !resources)
|
|
33
|
+
return response.badRequest('OAuth client registration metadata is invalid')
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await registerOAuthClient(provider, ownerId, {
|
|
37
|
+
name,
|
|
38
|
+
type,
|
|
39
|
+
tokenEndpointAuthMethod: type === 'public' ? 'none' : 'client_secret_basic',
|
|
40
|
+
redirectUris,
|
|
41
|
+
grantTypes: ['authorization_code', 'refresh_token'],
|
|
42
|
+
scopes,
|
|
43
|
+
resources,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return response.json({
|
|
47
|
+
client: {
|
|
48
|
+
id: result.client.id,
|
|
49
|
+
name: result.client.name,
|
|
50
|
+
type: result.client.type,
|
|
51
|
+
token_endpoint_auth_method: result.client.tokenEndpointAuthMethod,
|
|
52
|
+
redirect_uris: result.client.redirectUris,
|
|
53
|
+
grant_types: result.client.grantTypes,
|
|
54
|
+
scopes: result.client.scopes,
|
|
55
|
+
resources: result.client.resources,
|
|
56
|
+
created_at: result.client.createdAt.toISOString(),
|
|
57
|
+
},
|
|
58
|
+
...(result.plainTextSecret ? { client_secret: result.plainTextSecret } : {}),
|
|
59
|
+
}, {
|
|
60
|
+
status: 201,
|
|
61
|
+
headers: {
|
|
62
|
+
'Cache-Control': 'no-store',
|
|
63
|
+
'Pragma': 'no-cache',
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof OAuthClientRegistrationError)
|
|
69
|
+
return response.json({ message: error.message }, { status: 422 })
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import {
|
|
3
|
+
OAuthClientRegistrationError,
|
|
4
|
+
resolveOAuthProviderConfig,
|
|
5
|
+
updateOAuthClient,
|
|
6
|
+
} from '@stacksjs/auth'
|
|
7
|
+
import { config } from '@stacksjs/config'
|
|
8
|
+
import { response } from '@stacksjs/router'
|
|
9
|
+
import { oauthPositiveId, oauthStringList } from './oauth-request'
|
|
10
|
+
|
|
11
|
+
export default new Action({
|
|
12
|
+
name: 'OAuthClientUpdateAction',
|
|
13
|
+
description: 'Update an OAuth client owned by the authenticated user',
|
|
14
|
+
method: 'PATCH',
|
|
15
|
+
|
|
16
|
+
async handle(request: RequestInstance) {
|
|
17
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
18
|
+
if (!provider)
|
|
19
|
+
return response.notFound('OAuth provider is not enabled')
|
|
20
|
+
|
|
21
|
+
const user = await request.user()
|
|
22
|
+
const ownerId = user ? oauthPositiveId(user.id) : null
|
|
23
|
+
if (!ownerId)
|
|
24
|
+
return response.unauthorized('Authentication required')
|
|
25
|
+
|
|
26
|
+
const rawClientId = request.getParam('id')
|
|
27
|
+
const clientId = /^\d+$/.test(rawClientId) ? oauthPositiveId(rawClientId) : null
|
|
28
|
+
if (!clientId)
|
|
29
|
+
return response.badRequest('Invalid OAuth client ID')
|
|
30
|
+
|
|
31
|
+
const body = await request.all() as Record<string, unknown>
|
|
32
|
+
const name = typeof body.name === 'string' ? body.name : null
|
|
33
|
+
const redirectUris = oauthStringList(body.redirect_uris)
|
|
34
|
+
const scopes = oauthStringList(body.scopes)
|
|
35
|
+
const resources = oauthStringList(body.resources)
|
|
36
|
+
if (!name || !redirectUris || !scopes || !resources)
|
|
37
|
+
return response.badRequest('OAuth client update metadata is invalid')
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const client = await updateOAuthClient(provider, ownerId, clientId, {
|
|
41
|
+
name,
|
|
42
|
+
redirectUris,
|
|
43
|
+
grantTypes: ['authorization_code', 'refresh_token'],
|
|
44
|
+
scopes,
|
|
45
|
+
resources,
|
|
46
|
+
})
|
|
47
|
+
if (!client)
|
|
48
|
+
return response.notFound('OAuth client not found')
|
|
49
|
+
|
|
50
|
+
return response.json({
|
|
51
|
+
client: {
|
|
52
|
+
id: client.id,
|
|
53
|
+
name: client.name,
|
|
54
|
+
type: client.type,
|
|
55
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
56
|
+
redirect_uris: client.redirectUris,
|
|
57
|
+
grant_types: client.grantTypes,
|
|
58
|
+
scopes: client.scopes,
|
|
59
|
+
resources: client.resources,
|
|
60
|
+
revoked: client.revoked,
|
|
61
|
+
created_at: client.createdAt.toISOString(),
|
|
62
|
+
},
|
|
63
|
+
}, {
|
|
64
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof OAuthClientRegistrationError)
|
|
69
|
+
return response.json({ message: error.message }, { status: 422 })
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { listOAuthClients, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { oauthPositiveId } from './oauth-request'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'OAuthClientsAction',
|
|
9
|
+
description: 'List OAuth clients owned by the authenticated user',
|
|
10
|
+
method: 'GET',
|
|
11
|
+
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
14
|
+
if (!provider)
|
|
15
|
+
return response.notFound('OAuth provider is not enabled')
|
|
16
|
+
|
|
17
|
+
const user = await request.user()
|
|
18
|
+
const id = user ? oauthPositiveId(user.id) : null
|
|
19
|
+
if (!id)
|
|
20
|
+
return response.unauthorized('Authentication required')
|
|
21
|
+
|
|
22
|
+
const clients = await listOAuthClients(id)
|
|
23
|
+
|
|
24
|
+
return response.json({
|
|
25
|
+
clients: clients.map(client => ({
|
|
26
|
+
id: client.id,
|
|
27
|
+
name: client.name,
|
|
28
|
+
type: client.type,
|
|
29
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
30
|
+
redirect_uris: client.redirectUris,
|
|
31
|
+
grant_types: client.grantTypes,
|
|
32
|
+
scopes: client.scopes,
|
|
33
|
+
resources: client.resources,
|
|
34
|
+
revoked: client.revoked,
|
|
35
|
+
created_at: client.createdAt.toISOString(),
|
|
36
|
+
})),
|
|
37
|
+
count: clients.length,
|
|
38
|
+
}, {
|
|
39
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { listOAuthConnections, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { oauthPositiveId } from './oauth-request'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'OAuthConnectionsAction',
|
|
9
|
+
description: 'List OAuth applications connected to the authenticated user',
|
|
10
|
+
method: 'GET',
|
|
11
|
+
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
14
|
+
if (!provider)
|
|
15
|
+
return response.notFound('OAuth provider is not enabled')
|
|
16
|
+
|
|
17
|
+
const user = await request.user()
|
|
18
|
+
const id = user ? oauthPositiveId(user.id) : null
|
|
19
|
+
if (!id)
|
|
20
|
+
return response.unauthorized('Authentication required')
|
|
21
|
+
|
|
22
|
+
const connections = await listOAuthConnections('users', id)
|
|
23
|
+
|
|
24
|
+
return response.json({
|
|
25
|
+
connections: connections.map(connection => ({
|
|
26
|
+
grant_id: connection.grantId,
|
|
27
|
+
client_id: connection.clientId,
|
|
28
|
+
client_name: connection.clientName,
|
|
29
|
+
scopes: connection.scopes,
|
|
30
|
+
resources: connection.resources,
|
|
31
|
+
audiences: connection.audiences,
|
|
32
|
+
workspace_id: connection.workspaceId,
|
|
33
|
+
created_at: connection.createdAt.toISOString(),
|
|
34
|
+
})),
|
|
35
|
+
count: connections.length,
|
|
36
|
+
}, {
|
|
37
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import {
|
|
3
|
+
handleOAuthAuthorizationConsentRequest,
|
|
4
|
+
resolveOAuthConsentWorkspace,
|
|
5
|
+
resolveOAuthProviderConfig,
|
|
6
|
+
} from '@stacksjs/auth'
|
|
7
|
+
import { config } from '@stacksjs/config'
|
|
8
|
+
import { response } from '@stacksjs/router'
|
|
9
|
+
import { oauthPositiveId } from './oauth-request'
|
|
10
|
+
|
|
11
|
+
export default new Action({
|
|
12
|
+
name: 'OAuthConsentAction',
|
|
13
|
+
description: 'Approve or deny an OAuth authorization request',
|
|
14
|
+
method: 'POST',
|
|
15
|
+
|
|
16
|
+
async handle(request: RequestInstance) {
|
|
17
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
18
|
+
if (!provider)
|
|
19
|
+
return response.notFound('OAuth provider is not enabled')
|
|
20
|
+
|
|
21
|
+
const user = await request.user()
|
|
22
|
+
const id = user ? oauthPositiveId(user.id) : null
|
|
23
|
+
if (!id)
|
|
24
|
+
return response.unauthorized('Authentication required')
|
|
25
|
+
|
|
26
|
+
return handleOAuthAuthorizationConsentRequest({
|
|
27
|
+
provider,
|
|
28
|
+
request: request as unknown as Request,
|
|
29
|
+
subjectType: 'users',
|
|
30
|
+
subjectId: id,
|
|
31
|
+
resolveWorkspaceId: async () => (await resolveOAuthConsentWorkspace(provider, { request, user }))?.id ?? null,
|
|
32
|
+
})
|
|
33
|
+
},
|
|
34
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { disconnectOAuthGrant, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { oauthPositiveId } from './oauth-request'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'OAuthDisconnectAction',
|
|
9
|
+
description: 'Disconnect an OAuth application from the authenticated user',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
14
|
+
if (!provider)
|
|
15
|
+
return response.notFound('OAuth provider is not enabled')
|
|
16
|
+
|
|
17
|
+
const user = await request.user()
|
|
18
|
+
const id = user ? oauthPositiveId(user.id) : null
|
|
19
|
+
if (!id)
|
|
20
|
+
return response.unauthorized('Authentication required')
|
|
21
|
+
|
|
22
|
+
const grantId = request.getParam('id')
|
|
23
|
+
if (!/^[a-f0-9]{32}$/.test(grantId))
|
|
24
|
+
return response.badRequest('Invalid OAuth grant ID')
|
|
25
|
+
|
|
26
|
+
const disconnected = await disconnectOAuthGrant('users', id, grantId)
|
|
27
|
+
if (!disconnected)
|
|
28
|
+
return response.notFound('OAuth connection not found')
|
|
29
|
+
|
|
30
|
+
return response.json({
|
|
31
|
+
message: 'OAuth application disconnected successfully',
|
|
32
|
+
grant_id: grantId,
|
|
33
|
+
}, {
|
|
34
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { oauthAuthorizationServerMetadata, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'OAuthMetadataAction',
|
|
8
|
+
description: 'Publish OAuth authorization-server metadata',
|
|
9
|
+
method: 'GET',
|
|
10
|
+
|
|
11
|
+
async handle() {
|
|
12
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
13
|
+
if (!provider)
|
|
14
|
+
return response.notFound('OAuth provider is not enabled')
|
|
15
|
+
|
|
16
|
+
return response.json(oauthAuthorizationServerMetadata(provider), {
|
|
17
|
+
headers: { 'Cache-Control': 'public, max-age=300' },
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { handleOAuthRevocationRequest, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'OAuthRevocationAction',
|
|
8
|
+
description: 'Revoke a delegated OAuth token',
|
|
9
|
+
method: 'POST',
|
|
10
|
+
// OAuth clients authenticate with their registered protocol method instead
|
|
11
|
+
// of a browser session, so they cannot and must not need a CSRF cookie.
|
|
12
|
+
skipCsrf: true,
|
|
13
|
+
|
|
14
|
+
async handle(request: RequestInstance) {
|
|
15
|
+
if (!resolveOAuthProviderConfig(config.auth.oauthProvider))
|
|
16
|
+
return response.notFound('OAuth provider is not enabled')
|
|
17
|
+
|
|
18
|
+
return handleOAuthRevocationRequest({
|
|
19
|
+
body: (await request.rawBody?.()) ?? '',
|
|
20
|
+
contentType: request.headers.get('content-type'),
|
|
21
|
+
authorization: request.headers.get('authorization'),
|
|
22
|
+
})
|
|
23
|
+
},
|
|
24
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { OAuthSubjectEligibility } from '@stacksjs/auth'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { handleOAuthTokenRequest, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
4
|
+
import { config } from '@stacksjs/config'
|
|
5
|
+
import { db } from '@stacksjs/database/runtime'
|
|
6
|
+
import { response } from '@stacksjs/router'
|
|
7
|
+
|
|
8
|
+
const isSubjectEligible: OAuthSubjectEligibility = async (subject) => {
|
|
9
|
+
if (subject.type !== 'users') return false
|
|
10
|
+
return Boolean(await db.primary.selectFrom('users')
|
|
11
|
+
.where('id', '=', subject.id)
|
|
12
|
+
.select('id')
|
|
13
|
+
.executeTakeFirst())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default new Action({
|
|
17
|
+
name: 'OAuthTokenAction',
|
|
18
|
+
description: 'Exchange OAuth authorization codes and refresh tokens',
|
|
19
|
+
method: 'POST',
|
|
20
|
+
// OAuth clients authenticate with PKCE or HTTP Basic and cannot obtain the
|
|
21
|
+
// browser CSRF cookie. The protocol boundary validates its own credentials.
|
|
22
|
+
skipCsrf: true,
|
|
23
|
+
|
|
24
|
+
async handle(request: RequestInstance) {
|
|
25
|
+
const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
26
|
+
if (!provider)
|
|
27
|
+
return response.notFound('OAuth provider is not enabled')
|
|
28
|
+
|
|
29
|
+
return handleOAuthTokenRequest(provider, {
|
|
30
|
+
body: (await request.rawBody?.()) ?? '',
|
|
31
|
+
contentType: request.headers.get('content-type'),
|
|
32
|
+
authorization: request.headers.get('authorization'),
|
|
33
|
+
}, { isSubjectEligible })
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function oauthPositiveId(value: number | string): number | null {
|
|
2
|
+
const id = Number(value)
|
|
3
|
+
return Number.isSafeInteger(id) && id > 0 ? id : null
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function oauthStringList(value: unknown): string[] | null {
|
|
7
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string')
|
|
8
|
+
? value
|
|
9
|
+
: null
|
|
10
|
+
}
|
package/app/Middleware/Csrf.ts
CHANGED
|
@@ -215,7 +215,7 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
|
|
|
215
215
|
* Last duplicate wins; the canonical name takes precedence over the legacy
|
|
216
216
|
* name unless its final value is empty. Malformed pairs are skipped.
|
|
217
217
|
*/
|
|
218
|
-
function csrfCookieToken(req: Request): string {
|
|
218
|
+
export function csrfCookieToken(req: Request): string {
|
|
219
219
|
const header = req.headers.get('cookie')
|
|
220
220
|
if (!header) return ''
|
|
221
221
|
// The cookie this framework emits has one exact, whitespace-free shape.
|
package/ide/vscode/package.json
CHANGED
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.29",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.75.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.29",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
|
@@ -67,10 +67,12 @@ effect(() => {
|
|
|
67
67
|
})
|
|
68
68
|
</script>
|
|
69
69
|
|
|
70
|
+
{{-- No src attribute until there is a video: an empty one resolves to the
|
|
71
|
+
page's own URL, which the browser then downloads as the video. --}}
|
|
70
72
|
<figure class="m-0 {{ className }}">
|
|
71
73
|
<video-player
|
|
72
74
|
ref="player"
|
|
73
|
-
src="{{ src }}"
|
|
75
|
+
@if(src) src="{{ src }}" @endif
|
|
74
76
|
:src="liveSrc() || null"
|
|
75
77
|
:poster="livePoster() || null"
|
|
76
78
|
data-media-config="{{ playerConfig }}"
|
|
@@ -93,7 +95,7 @@ effect(() => {
|
|
|
93
95
|
@if(isEmbed)
|
|
94
96
|
<a data-native-fallback href="{{ src }}" target="_blank" rel="noopener" class="flex items-center justify-center h-full w-full text-sm text-white underline">Watch {{ title }}</a>
|
|
95
97
|
@else
|
|
96
|
-
<video data-native-fallback src="{{ src }}" @if(poster) poster="{{ poster }}" @endif @if(width) width="{{ width }}" @endif @if(height) height="{{ height }}" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="{{ preload }}" @if(crossorigin) crossorigin="{{ crossorigin }}" @endif controlslist="{{ controlslist }}" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="{{ title }}" class="object-contain h-full w-full">
|
|
98
|
+
<video data-native-fallback @if(src) src="{{ src }}" @endif @if(poster) poster="{{ poster }}" @endif @if(width) width="{{ width }}" @endif @if(height) height="{{ height }}" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="{{ preload }}" @if(crossorigin) crossorigin="{{ crossorigin }}" @endif controlslist="{{ controlslist }}" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="{{ title }}" class="object-contain h-full w-full">
|
|
97
99
|
@foreach (sources as source)
|
|
98
100
|
<source src="{{ source.src }}" @if(source.type) type="{{ source.type }}" @endif>
|
|
99
101
|
@endforeach
|
|
@@ -11,6 +11,14 @@ describe('Video', () => {
|
|
|
11
11
|
expect(video).toContain(`:poster="livePoster() || null"`)
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
+
it('renders no src attribute without a video, which would fetch the page itself', () => {
|
|
15
|
+
// An empty src resolves to the document's URL: the browser downloads the
|
|
16
|
+
// whole page as the video and fails, every time a player with nothing
|
|
17
|
+
// chosen yet renders.
|
|
18
|
+
expect(video).not.toMatch(/(?<!@if\(src\) )src="\{\{ src \}\}"/)
|
|
19
|
+
expect(video.match(/@if\(src\) src="\{\{ src \}\}" @endif/g)?.length).toBe(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
14
22
|
it('pauses when its src is cleared, as closing a sheet does', () => {
|
|
15
23
|
expect(video).toContain(`useRef('player')`)
|
|
16
24
|
expect(video).toContain('ref="player"')
|
package/routes/auth.ts
CHANGED
|
@@ -23,8 +23,18 @@
|
|
|
23
23
|
* and user routes load first, so your handler always takes priority.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { oauthAuthorizationServerMetadataPath, resolveOAuthProviderConfig } from '@stacksjs/auth'
|
|
27
|
+
import { config } from '@stacksjs/config'
|
|
26
28
|
import { route } from '@stacksjs/router'
|
|
27
29
|
|
|
30
|
+
const oauthProvider = resolveOAuthProviderConfig(config.auth.oauthProvider)
|
|
31
|
+
const oauthAuthorizationPath = oauthProvider ? new URL(oauthProvider.endpoints.authorization).pathname : '/oauth/authorize'
|
|
32
|
+
const oauthTokenPath = oauthProvider ? new URL(oauthProvider.endpoints.token).pathname : '/oauth/token'
|
|
33
|
+
const oauthRevocationPath = oauthProvider ? new URL(oauthProvider.endpoints.revocation).pathname : '/oauth/revoke'
|
|
34
|
+
const oauthMetadataPath = oauthProvider
|
|
35
|
+
? oauthAuthorizationServerMetadataPath(oauthProvider.issuer)
|
|
36
|
+
: '/.well-known/oauth-authorization-server'
|
|
37
|
+
|
|
28
38
|
// Rate limits on token-issuance + password-reset endpoints
|
|
29
39
|
// (stacksjs/stacks#1921). `Auth.attempt()` already has a per-email
|
|
30
40
|
// lockout but it doesn't stop credential-stuffing across many emails
|
|
@@ -34,6 +44,14 @@ import { route } from '@stacksjs/router'
|
|
|
34
44
|
// `routes/api.ts` (user routes win) gets to pick its own limits.
|
|
35
45
|
route.post('/login', 'Actions/Auth/LoginAction').rateLimit(5, 'minute')
|
|
36
46
|
route.post('/register', 'Actions/Auth/RegisterAction').rateLimit(3, 'minute')
|
|
47
|
+
// OAuth authorization-server token exchange. The action remains a 404 unless
|
|
48
|
+
// config.auth.oauthProvider.enabled is explicitly true. PKCE or HTTP Basic is
|
|
49
|
+
// the endpoint's credential boundary, so the action opts out of browser CSRF.
|
|
50
|
+
route.post(oauthTokenPath, 'Actions/Auth/OAuthTokenAction').rateLimit(30, 'minute')
|
|
51
|
+
route.post(oauthRevocationPath, 'Actions/Auth/OAuthRevocationAction').rateLimit(30, 'minute')
|
|
52
|
+
route.get(oauthAuthorizationPath, 'Actions/Auth/OAuthAuthorizationAction').rateLimit(60, 'minute')
|
|
53
|
+
route.post(oauthAuthorizationPath, 'Actions/Auth/OAuthConsentAction').middleware('auth').rateLimit(30, 'minute')
|
|
54
|
+
route.get(oauthMetadataPath, 'Actions/Auth/OAuthMetadataAction')
|
|
37
55
|
// Magic links (config.auth.magicLink.enabled gates both, 404 when off).
|
|
38
56
|
// The send endpoint answers a uniform 202 either way (anti-enumeration
|
|
39
57
|
// lives in sendMagicLink); the consume endpoint is a POST because email
|
|
@@ -67,6 +85,13 @@ route.post('/verify-two-factor-login', 'Actions/Auth/VerifyTwoFactorLoginAction'
|
|
|
67
85
|
|
|
68
86
|
route.group({ prefix: '/auth' }, () => {
|
|
69
87
|
route.post('/refresh', 'Actions/Auth/RefreshTokenAction').rateLimit(10, 'minute')
|
|
88
|
+
route.get('/oauth/clients', 'Actions/Auth/OAuthClientsAction').middleware('auth')
|
|
89
|
+
route.post('/oauth/clients', 'Actions/Auth/OAuthClientStoreAction').middleware('auth').rateLimit(10, 'minute')
|
|
90
|
+
route.patch('/oauth/clients/{id}', 'Actions/Auth/OAuthClientUpdateAction').middleware('auth').rateLimit(20, 'minute')
|
|
91
|
+
route.post('/oauth/clients/{id}/disable', 'Actions/Auth/OAuthClientDisableAction').middleware('auth').rateLimit(10, 'minute')
|
|
92
|
+
route.post('/oauth/clients/{id}/rotate-secret', 'Actions/Auth/OAuthClientSecretRotateAction').middleware('auth').rateLimit(5, 'minute')
|
|
93
|
+
route.get('/oauth/connections', 'Actions/Auth/OAuthConnectionsAction').middleware('auth')
|
|
94
|
+
route.post('/oauth/connections/{id}/disconnect', 'Actions/Auth/OAuthDisconnectAction').middleware('auth').rateLimit(10, 'minute')
|
|
70
95
|
route.get('/tokens', 'Actions/Auth/ListTokensAction').middleware('auth')
|
|
71
96
|
route.post('/token', 'Actions/Auth/CreateTokenAction').middleware('auth').rateLimit(10, 'minute')
|
|
72
97
|
route.delete('/tokens/{id}', 'Actions/Auth/RevokeTokenAction').middleware('auth')
|