@nixxie-cms/auth 1.0.1 → 1.1.0

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/src/index.ts CHANGED
@@ -1,291 +1,291 @@
1
- import type {
2
- AdminFileToWrite,
3
- BaseListTypeInfo,
4
- NixxieContext,
5
- SessionStrategy,
6
- BaseNixxieTypeInfo,
7
- NixxieConfig,
8
- } from '@nixxie-cms/core/types'
9
- import type { AuthConfig, AuthGqlNames } from './types'
10
-
11
- import { getSchemaExtension } from './schema'
12
- import configTemplate from './templates/config'
13
- import signinTemplate from './templates/signin'
14
- import initTemplate from './templates/init'
15
-
16
- export type AuthSession = {
17
- itemId: string | number // TODO: use ListTypeInfo
18
- data: unknown // TODO: use ListTypeInfo
19
- }
20
-
21
- function getAuthGqlNames(singular: string): AuthGqlNames {
22
- const lowerSingularName = singular.charAt(0).toLowerCase() + singular.slice(1)
23
- return {
24
- itemQueryName: lowerSingularName,
25
- whereUniqueInputName: `${singular}WhereUniqueInput`,
26
-
27
- authenticateItemWithPassword: `authenticate${singular}WithPassword`,
28
- ItemAuthenticationWithPasswordResult: `${singular}AuthenticationWithPasswordResult`,
29
- ItemAuthenticationWithPasswordSuccess: `${singular}AuthenticationWithPasswordSuccess`,
30
- ItemAuthenticationWithPasswordFailure: `${singular}AuthenticationWithPasswordFailure`,
31
-
32
- CreateInitialInput: `CreateInitial${singular}Input`,
33
- createInitialItem: `createInitial${singular}`,
34
- } as const
35
- }
36
-
37
- // TODO: use TypeInfo and listKey for types
38
- /**
39
- * createAuth function
40
- *
41
- * Generates config for Nixxie to implement standard auth features.
42
- */
43
- export function createAuth<ListTypeInfo extends BaseListTypeInfo>({
44
- listKey,
45
- secretField,
46
- initFirstItem,
47
- identityField,
48
- sessionData = 'id',
49
- }: AuthConfig<ListTypeInfo>) {
50
- /**
51
- * getAdditionalFiles
52
- *
53
- * This function adds files to be generated into the Admin UI build. Must be added to the
54
- * ui.getAdditionalFiles config.
55
- *
56
- * The signin page is always included, and the init page is included when initFirstItem is set
57
- */
58
- const authGetAdditionalFiles = (config: NixxieConfig) => {
59
- // TODO: FIXME: this is a duplication of initialise-lists:747
60
- const listConfig = config.lists[listKey]
61
- const labelField =
62
- listConfig.ui?.labelField ??
63
- (listConfig.fields.label
64
- ? 'label'
65
- : listConfig.fields.name
66
- ? 'name'
67
- : listConfig.fields.title
68
- ? 'title'
69
- : 'id')
70
-
71
- const authGqlNames = getAuthGqlNames(listConfig.graphql?.singular ?? listKey)
72
- const filesToWrite: AdminFileToWrite[] = [
73
- {
74
- mode: 'write',
75
- src: signinTemplate({ authGqlNames, identityField, secretField }),
76
- outputPath: 'pages/signin.js',
77
- },
78
- {
79
- mode: 'write',
80
- src: configTemplate({ labelField }),
81
- outputPath: 'config.ts',
82
- },
83
- ]
84
- if (initFirstItem) {
85
- filesToWrite.push({
86
- mode: 'write',
87
- src: initTemplate({ authGqlNames, listKey, initFirstItem }),
88
- outputPath: 'pages/init.js',
89
- })
90
- }
91
- return filesToWrite
92
- }
93
-
94
- function throwIfInvalidConfig<TypeInfo extends BaseNixxieTypeInfo>(
95
- config: NixxieConfig<TypeInfo>
96
- ) {
97
- if (!(listKey in config.lists)) {
98
- throw new Error(`withAuth cannot find the list "${listKey}"`)
99
- }
100
-
101
- // TODO: verify that the identity field is unique
102
- // TODO: verify that the field is required
103
- const list = config.lists[listKey]
104
- if (!(identityField in list.fields)) {
105
- throw new Error(`withAuth cannot find the identity field "${listKey}.${identityField}"`)
106
- }
107
-
108
- if (!(secretField in list.fields)) {
109
- throw new Error(`withAuth cannot find the secret field "${listKey}.${secretField}"`)
110
- }
111
-
112
- for (const fieldKey of initFirstItem?.fields || []) {
113
- if (fieldKey in list.fields) continue
114
-
115
- throw new Error(`initFirstItem.fields has unknown field "${listKey}.${fieldKey}"`)
116
- }
117
- }
118
-
119
- // this strategy wraps the existing session strategy,
120
- // and injects the requested session.data before returning
121
- function authSessionStrategy<Session extends AuthSession>(
122
- _sessionStrategy: SessionStrategy<Session>
123
- ): SessionStrategy<Session> {
124
- const { get, ...sessionStrategy } = _sessionStrategy
125
- return {
126
- ...sessionStrategy,
127
- get: async ({ context }) => {
128
- const session = await get({ context })
129
- const sudoContext = context.sudo()
130
- if (!session?.itemId) return
131
-
132
- // TODO: replace with SessionSecret: HMAC({ listKey, identityField, secretField }, SessionSecretVar)
133
- // if (session.listKey !== listKey) return null
134
-
135
- try {
136
- const data = await sudoContext.query[listKey].findOne({
137
- where: { id: session.itemId },
138
- query: sessionData,
139
- })
140
- if (!data) return
141
-
142
- return {
143
- ...session,
144
- itemId: session.itemId,
145
- data,
146
- }
147
- } catch (e) {
148
- console.error(e)
149
- // WARNING: this is probably an invalid configuration
150
- return
151
- }
152
- },
153
- }
154
- }
155
-
156
- async function hasInitFirstItemConditions<TypeInfo extends BaseNixxieTypeInfo>(
157
- context: NixxieContext<TypeInfo>
158
- ) {
159
- // do nothing if they aren't using this feature
160
- if (!initFirstItem) return false
161
-
162
- // if they have a session, there is no initialisation necessary
163
- if (context.session) return false
164
-
165
- const count = await context.sudo().db[listKey].count({})
166
- return count === 0
167
- }
168
-
169
- async function authMiddleware<TypeInfo extends BaseNixxieTypeInfo>({
170
- context,
171
- wasAccessAllowed,
172
- basePath,
173
- }: {
174
- context: NixxieContext<TypeInfo>
175
- wasAccessAllowed: boolean
176
- basePath: string
177
- }): Promise<{ kind: 'redirect'; to: string } | void> {
178
- const { req } = context
179
- const { pathname } = new URL(req!.url!, 'http://_')
180
-
181
- // redirect to init if initFirstItem conditions are met
182
- if (pathname !== `${basePath}/init` && (await hasInitFirstItemConditions(context))) {
183
- return { kind: 'redirect', to: `${basePath}/init` }
184
- }
185
-
186
- // redirect to / if attempting to /init and initFirstItem conditions are not met
187
- if (pathname === `${basePath}/init` && !(await hasInitFirstItemConditions(context))) {
188
- return { kind: 'redirect', to: basePath }
189
- }
190
-
191
- // don't redirect if we have access
192
- if (wasAccessAllowed) return
193
-
194
- // otherwise, redirect to signin
195
- return { kind: 'redirect', to: `${basePath}/signin` }
196
- }
197
-
198
- function defaultIsAccessAllowed({ session }: NixxieContext) {
199
- return session !== undefined
200
- }
201
-
202
- function defaultExtendGraphqlSchema<T>(schema: T) {
203
- return schema
204
- }
205
-
206
- /**
207
- * withAuth
208
- *
209
- * Automatically extends your configuration with a prescriptive implementation.
210
- */
211
- function withAuth<TypeInfo extends BaseNixxieTypeInfo>(
212
- config: NixxieConfig<TypeInfo>
213
- ): NixxieConfig<TypeInfo> {
214
- throwIfInvalidConfig(config)
215
- let { ui } = config
216
- if (!ui?.isDisabled) {
217
- const {
218
- getAdditionalFiles = () => [],
219
- isAccessAllowed = defaultIsAccessAllowed,
220
- pageMiddleware,
221
- publicPages = [],
222
- } = ui || {}
223
- const authPublicPages = [`${ui?.basePath ?? ''}/signin`]
224
- ui = {
225
- ...ui,
226
- publicPages: [...publicPages, ...authPublicPages],
227
- getAdditionalFiles: async () => [
228
- ...(await getAdditionalFiles()),
229
- ...authGetAdditionalFiles(config),
230
- ],
231
-
232
- isAccessAllowed: async (context: NixxieContext) => {
233
- if (await hasInitFirstItemConditions(context)) return true
234
- return isAccessAllowed(context)
235
- },
236
-
237
- pageMiddleware: async args => {
238
- const shouldRedirect = await authMiddleware(args)
239
- if (shouldRedirect) return shouldRedirect
240
- return pageMiddleware?.(args)
241
- },
242
- }
243
- }
244
-
245
- if (!config.session) throw new TypeError('Missing .session configuration')
246
-
247
- const { graphql } = config
248
- const { extendGraphqlSchema = defaultExtendGraphqlSchema } = graphql ?? {}
249
- const listConfig = config.lists[listKey]
250
-
251
- /**
252
- * extendGraphqlSchema
253
- *
254
- * Must be added to the extendGraphqlSchema config. Can be composed.
255
- */
256
- const authGqlNames = getAuthGqlNames(listConfig.graphql?.singular ?? listKey)
257
- const authExtendGraphqlSchema = getSchemaExtension({
258
- authGqlNames,
259
- listKey,
260
- identityField,
261
- secretField,
262
- initFirstItem,
263
- sessionData,
264
- })
265
-
266
- return {
267
- ...config,
268
- graphql: {
269
- ...config.graphql,
270
- extendGraphqlSchema: schema => {
271
- return extendGraphqlSchema(authExtendGraphqlSchema(schema))
272
- },
273
- },
274
- ui,
275
- session: authSessionStrategy(config.session),
276
- lists: {
277
- ...config.lists,
278
- [listKey]: {
279
- ...listConfig,
280
- fields: {
281
- ...listConfig.fields,
282
- },
283
- },
284
- },
285
- }
286
- }
287
-
288
- return {
289
- withAuth,
290
- }
291
- }
1
+ import type {
2
+ AdminFileToWrite,
3
+ BaseCollectionTypeInfo,
4
+ NixxieContext,
5
+ SessionStrategy,
6
+ BaseNixxieTypeInfo,
7
+ NixxieConfig,
8
+ } from '@nixxie-cms/core/types'
9
+ import type { AuthConfig, AuthGqlNames } from './types'
10
+
11
+ import { getSchemaExtension } from './schema'
12
+ import configTemplate from './templates/config'
13
+ import signinTemplate from './templates/signin'
14
+ import initTemplate from './templates/init'
15
+
16
+ export type AuthSession = {
17
+ itemId: string | number // TODO: use CollectionTypeInfo
18
+ data: unknown // TODO: use CollectionTypeInfo
19
+ }
20
+
21
+ function getAuthGqlNames(singular: string): AuthGqlNames {
22
+ const lowerSingularName = singular.charAt(0).toLowerCase() + singular.slice(1)
23
+ return {
24
+ itemQueryName: lowerSingularName,
25
+ whereUniqueInputName: `${singular}WhereUniqueInput`,
26
+
27
+ authenticateItemWithPassword: `authenticate${singular}WithPassword`,
28
+ ItemAuthenticationWithPasswordResult: `${singular}AuthenticationWithPasswordResult`,
29
+ ItemAuthenticationWithPasswordSuccess: `${singular}AuthenticationWithPasswordSuccess`,
30
+ ItemAuthenticationWithPasswordFailure: `${singular}AuthenticationWithPasswordFailure`,
31
+
32
+ CreateInitialInput: `CreateInitial${singular}Input`,
33
+ createInitialItem: `createInitial${singular}`,
34
+ } as const
35
+ }
36
+
37
+ // TODO: use TypeInfo and listKey for types
38
+ /**
39
+ * createAuth function
40
+ *
41
+ * Generates config for Nixxie to implement standard auth features.
42
+ */
43
+ export function createAuth<CollectionTypeInfo extends BaseCollectionTypeInfo>({
44
+ listKey,
45
+ secretField,
46
+ initFirstItem,
47
+ identityField,
48
+ sessionData = 'id',
49
+ }: AuthConfig<CollectionTypeInfo>) {
50
+ /**
51
+ * getAdditionalFiles
52
+ *
53
+ * This function adds files to be generated into the Admin UI build. Must be added to the
54
+ * ui.getAdditionalFiles config.
55
+ *
56
+ * The signin page is always included, and the init page is included when initFirstItem is set
57
+ */
58
+ const authGetAdditionalFiles = (config: NixxieConfig) => {
59
+ // TODO: FIXME: this is a duplication of initialise-lists:747
60
+ const listConfig = config.lists[listKey]
61
+ const labelField =
62
+ listConfig.ui?.labelField ??
63
+ (listConfig.fields.label
64
+ ? 'label'
65
+ : listConfig.fields.name
66
+ ? 'name'
67
+ : listConfig.fields.title
68
+ ? 'title'
69
+ : 'id')
70
+
71
+ const authGqlNames = getAuthGqlNames(listConfig.graphql?.singular ?? listKey)
72
+ const filesToWrite: AdminFileToWrite[] = [
73
+ {
74
+ mode: 'write',
75
+ src: signinTemplate({ authGqlNames, identityField, secretField }),
76
+ outputPath: 'pages/signin.js',
77
+ },
78
+ {
79
+ mode: 'write',
80
+ src: configTemplate({ labelField }),
81
+ outputPath: 'config.ts',
82
+ },
83
+ ]
84
+ if (initFirstItem) {
85
+ filesToWrite.push({
86
+ mode: 'write',
87
+ src: initTemplate({ authGqlNames, listKey, initFirstItem }),
88
+ outputPath: 'pages/init.js',
89
+ })
90
+ }
91
+ return filesToWrite
92
+ }
93
+
94
+ function throwIfInvalidConfig<TypeInfo extends BaseNixxieTypeInfo>(
95
+ config: NixxieConfig<TypeInfo>
96
+ ) {
97
+ if (!(listKey in config.lists)) {
98
+ throw new Error(`withAuth cannot find the list "${listKey}"`)
99
+ }
100
+
101
+ // TODO: verify that the identity field is unique
102
+ // TODO: verify that the field is required
103
+ const list = config.lists[listKey]
104
+ if (!(identityField in list.fields)) {
105
+ throw new Error(`withAuth cannot find the identity field "${listKey}.${identityField}"`)
106
+ }
107
+
108
+ if (!(secretField in list.fields)) {
109
+ throw new Error(`withAuth cannot find the secret field "${listKey}.${secretField}"`)
110
+ }
111
+
112
+ for (const fieldKey of initFirstItem?.fields || []) {
113
+ if (fieldKey in list.fields) continue
114
+
115
+ throw new Error(`initFirstItem.fields has unknown field "${listKey}.${fieldKey}"`)
116
+ }
117
+ }
118
+
119
+ // this strategy wraps the existing session strategy,
120
+ // and injects the requested session.data before returning
121
+ function authSessionStrategy<Session extends AuthSession>(
122
+ _sessionStrategy: SessionStrategy<Session>
123
+ ): SessionStrategy<Session> {
124
+ const { get, ...sessionStrategy } = _sessionStrategy
125
+ return {
126
+ ...sessionStrategy,
127
+ get: async ({ context }) => {
128
+ const session = await get({ context })
129
+ const sudoContext = context.sudo()
130
+ if (!session?.itemId) return
131
+
132
+ // TODO: replace with SessionSecret: HMAC({ listKey, identityField, secretField }, SessionSecretVar)
133
+ // if (session.listKey !== listKey) return null
134
+
135
+ try {
136
+ const data = await sudoContext.query[listKey].findOne({
137
+ where: { id: session.itemId },
138
+ query: sessionData,
139
+ })
140
+ if (!data) return
141
+
142
+ return {
143
+ ...session,
144
+ itemId: session.itemId,
145
+ data,
146
+ }
147
+ } catch (e) {
148
+ console.error(e)
149
+ // WARNING: this is probably an invalid configuration
150
+ return
151
+ }
152
+ },
153
+ }
154
+ }
155
+
156
+ async function hasInitFirstItemConditions<TypeInfo extends BaseNixxieTypeInfo>(
157
+ context: NixxieContext<TypeInfo>
158
+ ) {
159
+ // do nothing if they aren't using this feature
160
+ if (!initFirstItem) return false
161
+
162
+ // if they have a session, there is no initialisation necessary
163
+ if (context.session) return false
164
+
165
+ const count = await context.sudo().db[listKey].count({})
166
+ return count === 0
167
+ }
168
+
169
+ async function authMiddleware<TypeInfo extends BaseNixxieTypeInfo>({
170
+ context,
171
+ wasAccessAllowed,
172
+ basePath,
173
+ }: {
174
+ context: NixxieContext<TypeInfo>
175
+ wasAccessAllowed: boolean
176
+ basePath: string
177
+ }): Promise<{ kind: 'redirect'; to: string } | void> {
178
+ const { req } = context
179
+ const { pathname } = new URL(req!.url!, 'http://_')
180
+
181
+ // redirect to init if initFirstItem conditions are met
182
+ if (pathname !== `${basePath}/init` && (await hasInitFirstItemConditions(context))) {
183
+ return { kind: 'redirect', to: `${basePath}/init` }
184
+ }
185
+
186
+ // redirect to / if attempting to /init and initFirstItem conditions are not met
187
+ if (pathname === `${basePath}/init` && !(await hasInitFirstItemConditions(context))) {
188
+ return { kind: 'redirect', to: basePath }
189
+ }
190
+
191
+ // don't redirect if we have access
192
+ if (wasAccessAllowed) return
193
+
194
+ // otherwise, redirect to signin
195
+ return { kind: 'redirect', to: `${basePath}/signin` }
196
+ }
197
+
198
+ function defaultIsAccessAllowed({ session }: NixxieContext) {
199
+ return session !== undefined
200
+ }
201
+
202
+ function defaultExtendGraphqlSchema<T>(schema: T) {
203
+ return schema
204
+ }
205
+
206
+ /**
207
+ * withAuth
208
+ *
209
+ * Automatically extends your configuration with a prescriptive implementation.
210
+ */
211
+ function withAuth<TypeInfo extends BaseNixxieTypeInfo>(
212
+ config: NixxieConfig<TypeInfo>
213
+ ): NixxieConfig<TypeInfo> {
214
+ throwIfInvalidConfig(config)
215
+ let { ui } = config
216
+ if (!ui?.isDisabled) {
217
+ const {
218
+ getAdditionalFiles = () => [],
219
+ isAccessAllowed = defaultIsAccessAllowed,
220
+ pageMiddleware,
221
+ publicPages = [],
222
+ } = ui || {}
223
+ const authPublicPages = [`${ui?.basePath ?? ''}/signin`]
224
+ ui = {
225
+ ...ui,
226
+ publicPages: [...publicPages, ...authPublicPages],
227
+ getAdditionalFiles: async () => [
228
+ ...(await getAdditionalFiles()),
229
+ ...authGetAdditionalFiles(config),
230
+ ],
231
+
232
+ isAccessAllowed: async (context: NixxieContext) => {
233
+ if (await hasInitFirstItemConditions(context)) return true
234
+ return isAccessAllowed(context)
235
+ },
236
+
237
+ pageMiddleware: async args => {
238
+ const shouldRedirect = await authMiddleware(args)
239
+ if (shouldRedirect) return shouldRedirect
240
+ return pageMiddleware?.(args)
241
+ },
242
+ }
243
+ }
244
+
245
+ if (!config.session) throw new TypeError('Missing .session configuration')
246
+
247
+ const { graphql } = config
248
+ const { extendGraphqlSchema = defaultExtendGraphqlSchema } = graphql ?? {}
249
+ const listConfig = config.lists[listKey]
250
+
251
+ /**
252
+ * extendGraphqlSchema
253
+ *
254
+ * Must be added to the extendGraphqlSchema config. Can be composed.
255
+ */
256
+ const authGqlNames = getAuthGqlNames(listConfig.graphql?.singular ?? listKey)
257
+ const authExtendGraphqlSchema = getSchemaExtension({
258
+ authGqlNames,
259
+ listKey,
260
+ identityField,
261
+ secretField,
262
+ initFirstItem,
263
+ sessionData,
264
+ })
265
+
266
+ return {
267
+ ...config,
268
+ graphql: {
269
+ ...config.graphql,
270
+ extendGraphqlSchema: schema => {
271
+ return extendGraphqlSchema(authExtendGraphqlSchema(schema))
272
+ },
273
+ },
274
+ ui,
275
+ session: authSessionStrategy(config.session),
276
+ lists: {
277
+ ...config.lists,
278
+ [listKey]: {
279
+ ...listConfig,
280
+ fields: {
281
+ ...listConfig.fields,
282
+ },
283
+ },
284
+ },
285
+ }
286
+ }
287
+
288
+ return {
289
+ withAuth,
290
+ }
291
+ }