@nixxie-cms/auth 1.0.1
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/LICENSE +21 -0
- package/README.md +6 -0
- package/components/Navigation/dist/nixxie-cms-auth-components-Navigation.cjs.d.ts +3 -0
- package/components/Navigation/dist/nixxie-cms-auth-components-Navigation.cjs.js +147 -0
- package/components/Navigation/dist/nixxie-cms-auth-components-Navigation.esm.js +143 -0
- package/components/Navigation/package.json +4 -0
- package/dist/declarations/src/components/Navigation.d.ts +6 -0
- package/dist/declarations/src/components/Navigation.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +15 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/pages/InitPage.d.ts +9 -0
- package/dist/declarations/src/pages/InitPage.d.ts.map +1 -0
- package/dist/declarations/src/pages/SigninPage.d.ts +9 -0
- package/dist/declarations/src/pages/SigninPage.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +49 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-auth.cjs.d.ts +2 -0
- package/dist/nixxie-cms-auth.cjs.js +552 -0
- package/dist/nixxie-cms-auth.esm.js +548 -0
- package/dist/useFromRedirect-2de239a9.cjs.js +26 -0
- package/dist/useFromRedirect-b3deee00.esm.js +24 -0
- package/package.json +56 -0
- package/pages/InitPage/dist/nixxie-cms-auth-pages-InitPage.cjs.d.ts +3 -0
- package/pages/InitPage/dist/nixxie-cms-auth-pages-InitPage.cjs.js +274 -0
- package/pages/InitPage/dist/nixxie-cms-auth-pages-InitPage.esm.js +266 -0
- package/pages/InitPage/package.json +4 -0
- package/pages/SigninPage/dist/nixxie-cms-auth-pages-SigninPage.cjs.d.ts +3 -0
- package/pages/SigninPage/dist/nixxie-cms-auth-pages-SigninPage.cjs.js +319 -0
- package/pages/SigninPage/dist/nixxie-cms-auth-pages-SigninPage.esm.js +311 -0
- package/pages/SigninPage/package.json +4 -0
- package/src/components/Navigation.tsx +182 -0
- package/src/gql/getBaseAuthSchema.ts +129 -0
- package/src/gql/getInitFirstItemSchema.ts +87 -0
- package/src/index.ts +291 -0
- package/src/lib/useFromRedirect.ts +23 -0
- package/src/pages/InitPage.tsx +292 -0
- package/src/pages/SigninPage.tsx +331 -0
- package/src/schema.ts +84 -0
- package/src/templates/config.ts +9 -0
- package/src/templates/init.ts +22 -0
- package/src/templates/signin.ts +20 -0
- package/src/types.ts +57 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Divider } from '@keystar/ui/layout'
|
|
4
|
+
import { css } from '@keystar/ui/style'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
useQuery,
|
|
8
|
+
useMutation,
|
|
9
|
+
gql,
|
|
10
|
+
type TypedDocumentNode,
|
|
11
|
+
} from '@nixxie-cms/core/admin-ui/apollo'
|
|
12
|
+
import {
|
|
13
|
+
DeveloperResourcesMenu,
|
|
14
|
+
NavList,
|
|
15
|
+
NavContainer,
|
|
16
|
+
NavFooter,
|
|
17
|
+
NavItem,
|
|
18
|
+
getHrefFromList,
|
|
19
|
+
} from '@nixxie-cms/core/admin-ui/components'
|
|
20
|
+
import type { NavigationProps } from '@nixxie-cms/core/admin-ui/components'
|
|
21
|
+
import { useRouter } from '@nixxie-cms/core/admin-ui/router'
|
|
22
|
+
|
|
23
|
+
export default ({ labelField }: { labelField: string }) =>
|
|
24
|
+
(props: NavigationProps) => <Navigation labelField={labelField} {...props} />
|
|
25
|
+
|
|
26
|
+
function Navigation({
|
|
27
|
+
labelField,
|
|
28
|
+
lists,
|
|
29
|
+
}: {
|
|
30
|
+
labelField: string
|
|
31
|
+
} & NavigationProps) {
|
|
32
|
+
const { data } = useQuery<{
|
|
33
|
+
authenticatedItem: null | {
|
|
34
|
+
label: string
|
|
35
|
+
}
|
|
36
|
+
}>(
|
|
37
|
+
useMemo(
|
|
38
|
+
() => gql`
|
|
39
|
+
query NxAuthFetchSession {
|
|
40
|
+
authenticatedItem {
|
|
41
|
+
label: ${labelField}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
`,
|
|
45
|
+
[labelField]
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<NavContainer>
|
|
51
|
+
<NavList>
|
|
52
|
+
<NavItem href="/">Dashboard</NavItem>
|
|
53
|
+
<Divider />
|
|
54
|
+
{lists.map(list => (
|
|
55
|
+
<NavItem key={list.key} href={getHrefFromList(list)}>
|
|
56
|
+
{list.label}
|
|
57
|
+
</NavItem>
|
|
58
|
+
))}
|
|
59
|
+
</NavList>
|
|
60
|
+
|
|
61
|
+
<NavFooter>
|
|
62
|
+
{data?.authenticatedItem && (
|
|
63
|
+
<UserFooter authItemLabel={data.authenticatedItem.label} />
|
|
64
|
+
)}
|
|
65
|
+
<DeveloperResourcesMenu />
|
|
66
|
+
</NavFooter>
|
|
67
|
+
</NavContainer>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const END_SESSION = gql`
|
|
72
|
+
mutation NxAuthEndSession {
|
|
73
|
+
endSession
|
|
74
|
+
}
|
|
75
|
+
` as TypedDocumentNode<{ endSession: boolean }>
|
|
76
|
+
|
|
77
|
+
function UserFooter({ authItemLabel }: { authItemLabel: string }) {
|
|
78
|
+
const router = useRouter()
|
|
79
|
+
const [endSession, { data }] = useMutation(END_SESSION)
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (data?.endSession) {
|
|
83
|
+
router.push('/signin')
|
|
84
|
+
}
|
|
85
|
+
}, [data])
|
|
86
|
+
|
|
87
|
+
const initials = authItemLabel
|
|
88
|
+
.split(' ')
|
|
89
|
+
.slice(0, 2)
|
|
90
|
+
.map(s => s.charAt(0).toUpperCase())
|
|
91
|
+
.join('')
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className={css({
|
|
96
|
+
display: 'flex',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
gap: 8,
|
|
99
|
+
paddingInline: '4px',
|
|
100
|
+
paddingBlock: '2px',
|
|
101
|
+
})}
|
|
102
|
+
>
|
|
103
|
+
{/* Avatar */}
|
|
104
|
+
<span
|
|
105
|
+
className={css({
|
|
106
|
+
display: 'inline-flex',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
justifyContent: 'center',
|
|
109
|
+
width: 28,
|
|
110
|
+
height: 28,
|
|
111
|
+
borderRadius: '50%',
|
|
112
|
+
backgroundColor: '#0a0a0a',
|
|
113
|
+
color: '#ffffff',
|
|
114
|
+
fontSize: 10.5,
|
|
115
|
+
fontWeight: 600,
|
|
116
|
+
letterSpacing: '0.02em',
|
|
117
|
+
flexShrink: 0,
|
|
118
|
+
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
|
119
|
+
})}
|
|
120
|
+
>
|
|
121
|
+
{initials}
|
|
122
|
+
</span>
|
|
123
|
+
|
|
124
|
+
{/* Name */}
|
|
125
|
+
<span
|
|
126
|
+
className={css({
|
|
127
|
+
flex: 1,
|
|
128
|
+
fontSize: 12.5,
|
|
129
|
+
fontWeight: 500,
|
|
130
|
+
color: '#2a2a2a',
|
|
131
|
+
overflow: 'hidden',
|
|
132
|
+
textOverflow: 'ellipsis',
|
|
133
|
+
whiteSpace: 'nowrap',
|
|
134
|
+
lineHeight: 1.2,
|
|
135
|
+
})}
|
|
136
|
+
>
|
|
137
|
+
{authItemLabel}
|
|
138
|
+
</span>
|
|
139
|
+
|
|
140
|
+
{/* Sign out icon button */}
|
|
141
|
+
<button
|
|
142
|
+
onClick={() => endSession()}
|
|
143
|
+
title="Sign out"
|
|
144
|
+
aria-label="Sign out"
|
|
145
|
+
className={css({
|
|
146
|
+
display: 'inline-flex',
|
|
147
|
+
alignItems: 'center',
|
|
148
|
+
justifyContent: 'center',
|
|
149
|
+
width: 26,
|
|
150
|
+
height: 26,
|
|
151
|
+
border: '1px solid #ebebeb',
|
|
152
|
+
borderRadius: 6,
|
|
153
|
+
background: 'transparent',
|
|
154
|
+
cursor: 'pointer',
|
|
155
|
+
color: '#a3a3a3',
|
|
156
|
+
flexShrink: 0,
|
|
157
|
+
transition: 'color 130ms, border-color 130ms, background 130ms',
|
|
158
|
+
'&:hover': {
|
|
159
|
+
color: '#0a0a0a',
|
|
160
|
+
borderColor: '#c8c8c8',
|
|
161
|
+
background: '#f5f5f5',
|
|
162
|
+
},
|
|
163
|
+
'&:focus-visible': {
|
|
164
|
+
outline: '2px solid #000',
|
|
165
|
+
outlineOffset: 2,
|
|
166
|
+
},
|
|
167
|
+
})}
|
|
168
|
+
>
|
|
169
|
+
{/* Arrow-right-from-bracket (sign out) */}
|
|
170
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
171
|
+
<path
|
|
172
|
+
d="M4.5 2H2.5C2.22386 2 2 2.22386 2 2.5V9.5C2 9.77614 2.22386 10 2.5 10H4.5M8 4L10 6L8 8M10 6H4.5"
|
|
173
|
+
stroke="currentColor"
|
|
174
|
+
strokeWidth="1.25"
|
|
175
|
+
strokeLinecap="round"
|
|
176
|
+
strokeLinejoin="round"
|
|
177
|
+
/>
|
|
178
|
+
</svg>
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { BaseItem, NixxieContext } from '@nixxie-cms/core/types'
|
|
2
|
+
import { g } from '@nixxie-cms/core'
|
|
3
|
+
import { getPasswordFieldKDF } from '@nixxie-cms/core/fields/types/password'
|
|
4
|
+
import type { AuthGqlNames } from '../types'
|
|
5
|
+
import type { BaseSchemaMeta } from '@nixxie-cms/core/graphql-ts'
|
|
6
|
+
|
|
7
|
+
const AUTHENTICATION_FAILURE = {
|
|
8
|
+
code: 'FAILURE',
|
|
9
|
+
message: 'Authentication failed.',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
export function getBaseAuthSchema<I extends string, S extends string>({
|
|
13
|
+
authGqlNames,
|
|
14
|
+
listKey,
|
|
15
|
+
identityField,
|
|
16
|
+
secretField,
|
|
17
|
+
base,
|
|
18
|
+
}: {
|
|
19
|
+
authGqlNames: AuthGqlNames
|
|
20
|
+
listKey: string
|
|
21
|
+
identityField: I
|
|
22
|
+
secretField: S
|
|
23
|
+
base: BaseSchemaMeta
|
|
24
|
+
}) {
|
|
25
|
+
const kdf = getPasswordFieldKDF(base.schema, listKey, secretField)
|
|
26
|
+
if (!kdf) {
|
|
27
|
+
throw new Error(`${listKey}.${secretField} is not a valid password field.`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ItemAuthenticationWithPasswordSuccess = g.object<{
|
|
31
|
+
sessionToken: string
|
|
32
|
+
item: BaseItem
|
|
33
|
+
}>()({
|
|
34
|
+
name: authGqlNames.ItemAuthenticationWithPasswordSuccess,
|
|
35
|
+
fields: {
|
|
36
|
+
sessionToken: g.field({ type: g.nonNull(g.String) }),
|
|
37
|
+
item: g.field({ type: g.nonNull(base.object(listKey)) }),
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const ItemAuthenticationWithPasswordFailure = g.object<{ message: string }>()({
|
|
41
|
+
name: authGqlNames.ItemAuthenticationWithPasswordFailure,
|
|
42
|
+
fields: {
|
|
43
|
+
message: g.field({ type: g.nonNull(g.String) }),
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
const AuthenticationResult = g.union({
|
|
47
|
+
name: authGqlNames.ItemAuthenticationWithPasswordResult,
|
|
48
|
+
types: [ItemAuthenticationWithPasswordSuccess, ItemAuthenticationWithPasswordFailure],
|
|
49
|
+
resolveType(val) {
|
|
50
|
+
if ('sessionToken' in val) return authGqlNames.ItemAuthenticationWithPasswordSuccess
|
|
51
|
+
return authGqlNames.ItemAuthenticationWithPasswordFailure
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const extension = {
|
|
56
|
+
query: {
|
|
57
|
+
authenticatedItem: g.field({
|
|
58
|
+
type: base.object(listKey),
|
|
59
|
+
resolve(rootVal, args, context: NixxieContext) {
|
|
60
|
+
const { session } = context
|
|
61
|
+
if (!session?.itemId) return null
|
|
62
|
+
|
|
63
|
+
return context.db[listKey].findOne({
|
|
64
|
+
where: {
|
|
65
|
+
id: session.itemId,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
mutation: {
|
|
72
|
+
endSession: g.field({
|
|
73
|
+
type: g.nonNull(g.Boolean),
|
|
74
|
+
async resolve(rootVal, args, context) {
|
|
75
|
+
await context.sessionStrategy?.end({ context })
|
|
76
|
+
return true
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
[authGqlNames.authenticateItemWithPassword]: g.field({
|
|
80
|
+
type: AuthenticationResult,
|
|
81
|
+
args: {
|
|
82
|
+
[identityField]: g.arg({ type: g.nonNull(g.String) }),
|
|
83
|
+
[secretField]: g.arg({ type: g.nonNull(g.String) }),
|
|
84
|
+
},
|
|
85
|
+
async resolve(
|
|
86
|
+
rootVal,
|
|
87
|
+
{ [identityField]: identity, [secretField]: secret },
|
|
88
|
+
context: NixxieContext
|
|
89
|
+
) {
|
|
90
|
+
if (!context.sessionStrategy) throw new Error('No session strategy on context')
|
|
91
|
+
|
|
92
|
+
const item = await context.sudo().db[listKey].findOne({
|
|
93
|
+
where: { [identityField]: identity },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (typeof item?.[secretField] !== 'string') {
|
|
97
|
+
await kdf.hash('simulated-password-to-counter-timing-attack')
|
|
98
|
+
return AUTHENTICATION_FAILURE
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const equal = await kdf.compare(secret, item[secretField])
|
|
102
|
+
if (!equal) return AUTHENTICATION_FAILURE
|
|
103
|
+
|
|
104
|
+
const sessionToken = await context.sessionStrategy.start({
|
|
105
|
+
data: {
|
|
106
|
+
listKey,
|
|
107
|
+
itemId: item.id,
|
|
108
|
+
},
|
|
109
|
+
context,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
|
|
113
|
+
return AUTHENTICATION_FAILURE
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
sessionToken,
|
|
118
|
+
item,
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
extension,
|
|
127
|
+
ItemAuthenticationWithPasswordSuccess,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { BaseItem, NixxieContext } from '@nixxie-cms/core/types'
|
|
2
|
+
import { g } from '@nixxie-cms/core'
|
|
3
|
+
import { assertInputObjectType, GraphQLInputObjectType, type GraphQLSchema } from 'graphql'
|
|
4
|
+
import { type AuthGqlNames, type InitFirstItemConfig } from '../types'
|
|
5
|
+
import type { Extension } from '@nixxie-cms/core/graphql-ts'
|
|
6
|
+
|
|
7
|
+
const AUTHENTICATION_FAILURE = 'Authentication failed.' as const
|
|
8
|
+
|
|
9
|
+
export function getInitFirstItemSchema({
|
|
10
|
+
authGqlNames,
|
|
11
|
+
listKey,
|
|
12
|
+
fields,
|
|
13
|
+
defaultItemData,
|
|
14
|
+
graphQLSchema,
|
|
15
|
+
ItemAuthenticationWithPasswordSuccess,
|
|
16
|
+
}: {
|
|
17
|
+
authGqlNames: AuthGqlNames
|
|
18
|
+
listKey: string
|
|
19
|
+
fields: InitFirstItemConfig<any>['fields']
|
|
20
|
+
defaultItemData: InitFirstItemConfig<any>['itemData']
|
|
21
|
+
graphQLSchema: GraphQLSchema
|
|
22
|
+
ItemAuthenticationWithPasswordSuccess: g<
|
|
23
|
+
typeof g.object<{
|
|
24
|
+
item: BaseItem
|
|
25
|
+
sessionToken: string
|
|
26
|
+
}>
|
|
27
|
+
>
|
|
28
|
+
// TODO: return type required by pnpm :(
|
|
29
|
+
}): Extension {
|
|
30
|
+
const createInputConfig = assertInputObjectType(
|
|
31
|
+
graphQLSchema.getType(`${listKey}CreateInput`)
|
|
32
|
+
).toConfig()
|
|
33
|
+
const fieldsSet = new Set(fields)
|
|
34
|
+
const initialCreateInput = new GraphQLInputObjectType({
|
|
35
|
+
...createInputConfig,
|
|
36
|
+
fields: Object.fromEntries(
|
|
37
|
+
Object.entries(createInputConfig.fields).filter(([fieldKey]) => fieldsSet.has(fieldKey))
|
|
38
|
+
),
|
|
39
|
+
name: authGqlNames.CreateInitialInput,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
mutation: {
|
|
44
|
+
[authGqlNames.createInitialItem]: g.field({
|
|
45
|
+
type: g.nonNull(ItemAuthenticationWithPasswordSuccess),
|
|
46
|
+
args: { data: g.arg({ type: g.nonNull(initialCreateInput) }) },
|
|
47
|
+
async resolve(rootVal, { data }, context: NixxieContext) {
|
|
48
|
+
if (!context.sessionStrategy) throw new Error('No session strategy on context')
|
|
49
|
+
|
|
50
|
+
const sudoContext = context.sudo()
|
|
51
|
+
|
|
52
|
+
// should approximate hasInitFirstItemConditions
|
|
53
|
+
const count = await sudoContext.db[listKey].count()
|
|
54
|
+
if (count !== 0) throw AUTHENTICATION_FAILURE
|
|
55
|
+
|
|
56
|
+
// Update system state
|
|
57
|
+
// this is strictly speaking incorrect. the db API will do GraphQL coercion on a value which has already been coerced
|
|
58
|
+
// (this is also mostly fine, the chance that people are using things where
|
|
59
|
+
// the input value can't round-trip like the Upload scalar here is quite low)
|
|
60
|
+
const item = await sudoContext.db[listKey].createOne({
|
|
61
|
+
data: {
|
|
62
|
+
...defaultItemData,
|
|
63
|
+
...data,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const sessionToken = await context.sessionStrategy.start({
|
|
68
|
+
data: {
|
|
69
|
+
listKey,
|
|
70
|
+
itemId: item.id,
|
|
71
|
+
},
|
|
72
|
+
context,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
|
|
76
|
+
throw AUTHENTICATION_FAILURE
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sessionToken,
|
|
81
|
+
item,
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useRouter } from '@nixxie-cms/core/admin-ui/router'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns a safe, site-relative path to redirect to after authentication.
|
|
6
|
+
*
|
|
7
|
+
* Reads the `from` query parameter and only honours it when it is a
|
|
8
|
+
* site-relative path (starts with a single `/`). Absolute URLs and
|
|
9
|
+
* protocol-relative URLs (`//evil.com`) are rejected to prevent open
|
|
10
|
+
* redirects; in those cases it falls back to `/`.
|
|
11
|
+
*/
|
|
12
|
+
export function useRedirect() {
|
|
13
|
+
const router = useRouter()
|
|
14
|
+
const raw = router.query.from
|
|
15
|
+
const from = Array.isArray(raw) ? raw[0] : raw
|
|
16
|
+
|
|
17
|
+
return useMemo(() => {
|
|
18
|
+
if (typeof from === 'string' && from.startsWith('/') && !from.startsWith('//')) {
|
|
19
|
+
return from
|
|
20
|
+
}
|
|
21
|
+
return '/'
|
|
22
|
+
}, [from])
|
|
23
|
+
}
|