@stonecrop/casl-middleware 0.7.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/README.md +149 -0
- package/dist/casl-middleware.d.ts +158 -0
- package/dist/casl-middleware.js +40571 -0
- package/dist/casl-middleware.js.map +1 -0
- package/dist/casl_middleware.tsbuildinfo +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +3 -0
- package/dist/src/middleware/ability.d.ts +55 -0
- package/dist/src/middleware/ability.d.ts.map +1 -0
- package/dist/src/middleware/ability.js +139 -0
- package/dist/src/middleware/graphql.d.ts +11 -0
- package/dist/src/middleware/graphql.d.ts.map +1 -0
- package/dist/src/middleware/graphql.js +120 -0
- package/dist/src/middleware/introspection.d.ts +71 -0
- package/dist/src/middleware/introspection.d.ts.map +1 -0
- package/dist/src/middleware/introspection.js +169 -0
- package/dist/src/middleware/jwt.d.ts +114 -0
- package/dist/src/middleware/jwt.d.ts.map +1 -0
- package/dist/src/middleware/jwt.js +291 -0
- package/dist/src/middleware/postgraphile.d.ts +7 -0
- package/dist/src/middleware/postgraphile.d.ts.map +1 -0
- package/dist/src/middleware/postgraphile.js +80 -0
- package/dist/src/middleware/yoga.d.ts +15 -0
- package/dist/src/middleware/yoga.d.ts.map +1 -0
- package/dist/src/middleware/yoga.js +32 -0
- package/dist/src/tsdoc-metadata.json +11 -0
- package/dist/src/types/index.d.ts +114 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +0 -0
- package/dist/tests/ability.test.d.ts +2 -0
- package/dist/tests/ability.test.d.ts.map +1 -0
- package/dist/tests/ability.test.js +125 -0
- package/dist/tests/helpers/test-utils.d.ts +46 -0
- package/dist/tests/helpers/test-utils.d.ts.map +1 -0
- package/dist/tests/helpers/test-utils.js +92 -0
- package/dist/tests/introspection.test.d.ts +2 -0
- package/dist/tests/introspection.test.d.ts.map +1 -0
- package/dist/tests/introspection.test.js +368 -0
- package/dist/tests/jwt.test.d.ts +2 -0
- package/dist/tests/jwt.test.d.ts.map +1 -0
- package/dist/tests/jwt.test.js +371 -0
- package/dist/tests/middleware.test.d.ts +2 -0
- package/dist/tests/middleware.test.d.ts.map +1 -0
- package/dist/tests/middleware.test.js +184 -0
- package/dist/tests/postgraphile-plugin.test.d.ts +2 -0
- package/dist/tests/postgraphile-plugin.test.d.ts.map +1 -0
- package/dist/tests/postgraphile-plugin.test.js +56 -0
- package/dist/tests/setup.d.ts +2 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +11 -0
- package/dist/tests/user-roles.test.d.ts +2 -0
- package/dist/tests/user-roles.test.d.ts.map +1 -0
- package/dist/tests/user-roles.test.js +157 -0
- package/dist/tests/yoga-plugin.test.d.ts +2 -0
- package/dist/tests/yoga-plugin.test.d.ts.map +1 -0
- package/dist/tests/yoga-plugin.test.js +47 -0
- package/package.json +91 -0
- package/src/index.ts +15 -0
- package/src/middleware/ability.ts +191 -0
- package/src/middleware/graphql.ts +157 -0
- package/src/middleware/introspection.ts +258 -0
- package/src/middleware/jwt.ts +394 -0
- package/src/middleware/postgraphile.ts +93 -0
- package/src/middleware/yoga.ts +39 -0
- package/src/types/index.ts +133 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { GraphQLError } from 'graphql'
|
|
2
|
+
import type { Context, MiddlewareOptions } from '../types'
|
|
3
|
+
|
|
4
|
+
export interface IntrospectionConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Whether to allow introspection queries at all
|
|
7
|
+
* @default true in development, false in production
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Roles that are allowed to introspect the schema
|
|
13
|
+
* If undefined, all authenticated users can introspect
|
|
14
|
+
*/
|
|
15
|
+
allowedRoles?: string[]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Allow unauthenticated introspection
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
allowAnonymous?: boolean
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Custom function to determine if introspection is allowed
|
|
25
|
+
*/
|
|
26
|
+
customCheck?: (context: Context) => boolean | Promise<boolean>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Types to hide from introspection based on user permissions
|
|
30
|
+
* Maps type names to required permissions
|
|
31
|
+
*/
|
|
32
|
+
typePermissions?: Record<string, { action: string; subject: string }>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fields to hide from introspection based on user permissions
|
|
36
|
+
* Maps "Type.field" to required permissions
|
|
37
|
+
*/
|
|
38
|
+
fieldPermissions?: Record<string, { action: string; subject: string }>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Middleware to restrict GraphQL introspection based on user permissions
|
|
43
|
+
*/
|
|
44
|
+
export const createIntrospectionMiddleware = (config: IntrospectionConfig = {}) => {
|
|
45
|
+
const {
|
|
46
|
+
enabled = process.env.NODE_ENV !== 'production',
|
|
47
|
+
allowedRoles,
|
|
48
|
+
allowAnonymous = false,
|
|
49
|
+
customCheck,
|
|
50
|
+
typePermissions = {},
|
|
51
|
+
fieldPermissions = {},
|
|
52
|
+
} = config
|
|
53
|
+
|
|
54
|
+
return async (resolve: any, root: any, args: any, context: Context, info: any) => {
|
|
55
|
+
// Check if this is an introspection query
|
|
56
|
+
const isIntrospection =
|
|
57
|
+
info.fieldName === '__schema' ||
|
|
58
|
+
info.fieldName === '__type' ||
|
|
59
|
+
info.parentType?.name === '__Schema' ||
|
|
60
|
+
info.parentType?.name === '__Type'
|
|
61
|
+
|
|
62
|
+
if (!isIntrospection) {
|
|
63
|
+
// Not an introspection query, continue normally
|
|
64
|
+
return resolve(root, args, context, info)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if introspection is enabled
|
|
68
|
+
if (!enabled) {
|
|
69
|
+
throw new GraphQLError('Introspection is disabled')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Custom check function
|
|
73
|
+
if (customCheck) {
|
|
74
|
+
const allowed = await customCheck(context)
|
|
75
|
+
if (!allowed) {
|
|
76
|
+
throw new GraphQLError('Introspection not allowed')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check authentication
|
|
81
|
+
if (!context.user && !allowAnonymous) {
|
|
82
|
+
throw new GraphQLError('Authentication required for introspection')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check role-based access
|
|
86
|
+
if (allowedRoles && allowedRoles.length > 0) {
|
|
87
|
+
const userRoles = context.user?.roles || []
|
|
88
|
+
const hasAllowedRole = allowedRoles.some(role => userRoles.includes(role))
|
|
89
|
+
|
|
90
|
+
if (!hasAllowedRole) {
|
|
91
|
+
throw new GraphQLError('Insufficient permissions for introspection')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check CASL-based permissions
|
|
96
|
+
if (context.ability) {
|
|
97
|
+
// Check if user can read schema
|
|
98
|
+
if (!context.ability.can('read', '__Schema')) {
|
|
99
|
+
throw new GraphQLError('Permission denied for schema introspection')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Get the result
|
|
104
|
+
let result = await resolve(root, args, context, info)
|
|
105
|
+
|
|
106
|
+
// Filter the result based on permissions
|
|
107
|
+
if (result && (typePermissions || fieldPermissions)) {
|
|
108
|
+
result = filterIntrospectionResult(result, context, {
|
|
109
|
+
typePermissions,
|
|
110
|
+
fieldPermissions,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Filter introspection results based on user permissions
|
|
120
|
+
*/
|
|
121
|
+
function filterIntrospectionResult(
|
|
122
|
+
result: any,
|
|
123
|
+
context: Context,
|
|
124
|
+
config: {
|
|
125
|
+
typePermissions?: Record<string, { action: string; subject: string }>
|
|
126
|
+
fieldPermissions?: Record<string, { action: string; subject: string }>
|
|
127
|
+
}
|
|
128
|
+
): any {
|
|
129
|
+
if (!context.ability) return result
|
|
130
|
+
|
|
131
|
+
// Filter __schema result
|
|
132
|
+
if (result && result.types) {
|
|
133
|
+
result.types = result.types.filter((type: any) => {
|
|
134
|
+
// Check if user has permission to see this type
|
|
135
|
+
const permission = config.typePermissions?.[type.name]
|
|
136
|
+
if (permission) {
|
|
137
|
+
return context.ability!.can(permission.action, permission.subject)
|
|
138
|
+
}
|
|
139
|
+
return true // Show types without specific permissions
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Filter fields within types
|
|
143
|
+
result.types.forEach((type: any) => {
|
|
144
|
+
if (type.fields) {
|
|
145
|
+
type.fields = type.fields.filter((field: any) => {
|
|
146
|
+
const fieldKey = `${type.name}.${field.name}`
|
|
147
|
+
const permission = config.fieldPermissions?.[fieldKey]
|
|
148
|
+
if (permission) {
|
|
149
|
+
return context.ability!.can(permission.action, permission.subject)
|
|
150
|
+
}
|
|
151
|
+
return true
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Filter __type result
|
|
158
|
+
if (result && result.fields) {
|
|
159
|
+
const typeName = result.name
|
|
160
|
+
result.fields = result.fields.filter((field: any) => {
|
|
161
|
+
const fieldKey = `${typeName}.${field.name}`
|
|
162
|
+
const permission = config.fieldPermissions?.[fieldKey]
|
|
163
|
+
if (permission) {
|
|
164
|
+
return context.ability!.can(permission.action, permission.subject)
|
|
165
|
+
}
|
|
166
|
+
return true
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Postgraphile plugin for introspection control
|
|
175
|
+
*/
|
|
176
|
+
export const createPostgraphileIntrospectionPlugin = (config: IntrospectionConfig) => {
|
|
177
|
+
return {
|
|
178
|
+
name: 'IntrospectionControlPlugin',
|
|
179
|
+
version: '1.0.0',
|
|
180
|
+
|
|
181
|
+
// Disable introspection in GraphiQL based on config
|
|
182
|
+
grafast: {
|
|
183
|
+
hooks: {
|
|
184
|
+
GraphQLSchema(schema: any) {
|
|
185
|
+
if (!config.enabled) {
|
|
186
|
+
// Remove introspection from schema
|
|
187
|
+
// This is a simplified approach - real implementation would be more complex
|
|
188
|
+
console.warn('Introspection control in Postgraphile requires custom implementation')
|
|
189
|
+
}
|
|
190
|
+
return schema
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Utility to create ability rules for introspection
|
|
199
|
+
*/
|
|
200
|
+
export const createIntrospectionAbilityRules = (user?: {
|
|
201
|
+
roles?: string[]
|
|
202
|
+
}): Array<{ action: string; subject: string }> => {
|
|
203
|
+
const rules: Array<{ action: string; subject: string }> = []
|
|
204
|
+
|
|
205
|
+
if (!user) {
|
|
206
|
+
// Anonymous users cannot introspect
|
|
207
|
+
return rules
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const roles = user.roles || []
|
|
211
|
+
|
|
212
|
+
// Admins can introspect everything
|
|
213
|
+
if (roles.includes('admin')) {
|
|
214
|
+
rules.push({ action: 'read', subject: '__Schema' })
|
|
215
|
+
rules.push({ action: 'read', subject: '__Type' })
|
|
216
|
+
return rules
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Developers can introspect
|
|
220
|
+
if (roles.includes('developer')) {
|
|
221
|
+
rules.push({ action: 'read', subject: '__Schema' })
|
|
222
|
+
rules.push({ action: 'read', subject: '__Type' })
|
|
223
|
+
return rules
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Regular users get limited introspection
|
|
227
|
+
if (roles.includes('user')) {
|
|
228
|
+
// They can see the schema but not all types
|
|
229
|
+
rules.push({ action: 'read', subject: '__Schema' })
|
|
230
|
+
// Specific types they can see would be added here
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return rules
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Example: Combine introspection with CASL middleware
|
|
238
|
+
*/
|
|
239
|
+
export const createSecureGraphQLMiddleware = (options: {
|
|
240
|
+
casl?: MiddlewareOptions
|
|
241
|
+
introspection?: IntrospectionConfig
|
|
242
|
+
}) => {
|
|
243
|
+
const middlewares: any[] = []
|
|
244
|
+
|
|
245
|
+
// Add introspection control
|
|
246
|
+
if (options.introspection) {
|
|
247
|
+
middlewares.push(createIntrospectionMiddleware(options.introspection))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Combine all middlewares
|
|
251
|
+
return (resolve: any, root: any, args: any, context: any, info: any) => {
|
|
252
|
+
const chain = middlewares.reduceRight(
|
|
253
|
+
(next, middleware) => () => middleware(next, root, args, context, info),
|
|
254
|
+
() => resolve(root, args, context, info)
|
|
255
|
+
)
|
|
256
|
+
return chain()
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken'
|
|
2
|
+
import { AbilityBuilder, PureAbility } from '@casl/ability'
|
|
3
|
+
import type { Context, User } from '../types'
|
|
4
|
+
import { defaultAbilityBuilder } from './ability'
|
|
5
|
+
|
|
6
|
+
export interface JWTConfig {
|
|
7
|
+
enabled?: boolean
|
|
8
|
+
secret?: string
|
|
9
|
+
publicKey?: string
|
|
10
|
+
algorithms?: jwt.Algorithm[]
|
|
11
|
+
issuer?: string
|
|
12
|
+
audience?: string
|
|
13
|
+
extractUser?: (payload: any) => User | undefined
|
|
14
|
+
headerName?: string // Default: 'authorization'
|
|
15
|
+
tokenPrefix?: string // Default: 'Bearer '
|
|
16
|
+
optional?: boolean // If true, continues without error if no token
|
|
17
|
+
maxAge?: string // Maximum age of token (e.g., '1h', '7d')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JWTPayload extends jwt.JwtPayload {
|
|
21
|
+
sub?: string // Subject (user id)
|
|
22
|
+
roles?: string[]
|
|
23
|
+
permissions?: Array<{
|
|
24
|
+
action: string
|
|
25
|
+
subject: string
|
|
26
|
+
conditions?: any
|
|
27
|
+
}>
|
|
28
|
+
[key: string]: any
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default user extractor from JWT payload
|
|
33
|
+
*/
|
|
34
|
+
const defaultUserExtractor = (payload: JWTPayload): User | undefined => {
|
|
35
|
+
if (!payload.sub) return undefined
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id: payload.sub,
|
|
39
|
+
roles: payload.roles || [],
|
|
40
|
+
...payload, // Include any additional claims
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* JWT middleware factory for GraphQL servers
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* // In Nuxt Yoga
|
|
50
|
+
* export default defineNuxtConfig({
|
|
51
|
+
* yoga: {
|
|
52
|
+
* middleware: [
|
|
53
|
+
* createJWTMiddleware({
|
|
54
|
+
* enabled: true,
|
|
55
|
+
* secret: process.env.JWT_SECRET,
|
|
56
|
+
* optional: true // Don't fail if no token
|
|
57
|
+
* })
|
|
58
|
+
* ]
|
|
59
|
+
* }
|
|
60
|
+
* })
|
|
61
|
+
*
|
|
62
|
+
* // In Postgraphile
|
|
63
|
+
* const jwtPlugin = createPostgraphileJWTPlugin({
|
|
64
|
+
* secret: process.env.JWT_SECRET,
|
|
65
|
+
* extractUser: (payload) => ({
|
|
66
|
+
* id: payload.user_id,
|
|
67
|
+
* roles: payload.user_roles
|
|
68
|
+
* })
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export const createJWTMiddleware = (config: JWTConfig = {}) => {
|
|
73
|
+
const {
|
|
74
|
+
enabled = true,
|
|
75
|
+
secret,
|
|
76
|
+
publicKey,
|
|
77
|
+
algorithms = ['HS256'] as jwt.Algorithm[],
|
|
78
|
+
issuer,
|
|
79
|
+
audience,
|
|
80
|
+
headerName = 'authorization',
|
|
81
|
+
tokenPrefix = 'Bearer ',
|
|
82
|
+
optional = false,
|
|
83
|
+
extractUser = defaultUserExtractor,
|
|
84
|
+
maxAge,
|
|
85
|
+
} = config
|
|
86
|
+
|
|
87
|
+
// Validate configuration
|
|
88
|
+
if (enabled && !secret && !publicKey) {
|
|
89
|
+
throw new Error('JWT middleware requires either secret or publicKey')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return async (context: Context, next: () => Promise<any>) => {
|
|
93
|
+
// Skip if JWT is disabled
|
|
94
|
+
if (!enabled) {
|
|
95
|
+
return next()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// Extract token from request headers
|
|
100
|
+
const authHeader =
|
|
101
|
+
context.req?.headers?.get?.(headerName) ||
|
|
102
|
+
context.request?.headers?.get?.(headerName) ||
|
|
103
|
+
context.headers?.[headerName]
|
|
104
|
+
|
|
105
|
+
if (!authHeader) {
|
|
106
|
+
if (optional) {
|
|
107
|
+
return next()
|
|
108
|
+
}
|
|
109
|
+
throw new Error('No authorization header found')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Remove token prefix
|
|
113
|
+
const token = authHeader.startsWith(tokenPrefix) ? authHeader.slice(tokenPrefix.length) : authHeader
|
|
114
|
+
|
|
115
|
+
// Prepare verification options
|
|
116
|
+
const verifyOptions: jwt.VerifyOptions = {
|
|
117
|
+
algorithms,
|
|
118
|
+
...(issuer && { issuer }),
|
|
119
|
+
...(audience && { audience }),
|
|
120
|
+
...(maxAge && { maxAge }),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Verify and decode token
|
|
124
|
+
const secretOrPublicKey = publicKey || secret!
|
|
125
|
+
const payload = jwt.verify(token, secretOrPublicKey, verifyOptions) as JWTPayload
|
|
126
|
+
|
|
127
|
+
// Extract user from payload
|
|
128
|
+
const user = extractUser(payload)
|
|
129
|
+
|
|
130
|
+
if (user) {
|
|
131
|
+
context.user = user
|
|
132
|
+
// Store the raw payload for potential use
|
|
133
|
+
context.jwtPayload = payload
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Continue to next middleware
|
|
137
|
+
return next()
|
|
138
|
+
} catch (error: any) {
|
|
139
|
+
if (optional) {
|
|
140
|
+
// Log error in development
|
|
141
|
+
if (process.env.NODE_ENV === 'development') {
|
|
142
|
+
console.warn('JWT verification failed (optional):', error.message)
|
|
143
|
+
}
|
|
144
|
+
// Continue without user if optional
|
|
145
|
+
return next()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Re-throw with more specific error messages
|
|
149
|
+
if (error.name === 'TokenExpiredError') {
|
|
150
|
+
throw new Error('Token has expired')
|
|
151
|
+
} else if (error.name === 'JsonWebTokenError') {
|
|
152
|
+
throw new Error('Invalid token')
|
|
153
|
+
} else if (error.name === 'NotBeforeError') {
|
|
154
|
+
throw new Error('Token not active yet')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a JWT token with user data
|
|
164
|
+
*/
|
|
165
|
+
export const createJWT = (
|
|
166
|
+
user: User,
|
|
167
|
+
config: {
|
|
168
|
+
secret: string
|
|
169
|
+
expiresIn?: string | number
|
|
170
|
+
issuer?: string
|
|
171
|
+
audience?: string
|
|
172
|
+
additionalClaims?: Record<string, any>
|
|
173
|
+
}
|
|
174
|
+
): string => {
|
|
175
|
+
const { secret, expiresIn = '1h', issuer, audience, additionalClaims = {} } = config
|
|
176
|
+
|
|
177
|
+
const payload: JWTPayload = {
|
|
178
|
+
sub: user.id,
|
|
179
|
+
roles: user.roles || [],
|
|
180
|
+
...additionalClaims,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const signOptions: jwt.SignOptions = {}
|
|
184
|
+
|
|
185
|
+
// Add optional fields only if they exist
|
|
186
|
+
if (issuer !== undefined) signOptions.issuer = issuer
|
|
187
|
+
if (audience !== undefined) signOptions.audience = audience
|
|
188
|
+
if (expiresIn !== undefined) signOptions.expiresIn = expiresIn as any // Type cast to avoid TS issues
|
|
189
|
+
|
|
190
|
+
return jwt.sign(payload, secret, signOptions)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Integration with CASL ability builder
|
|
195
|
+
*/
|
|
196
|
+
export const createJWTAbilityBuilder = (config: JWTConfig = {}) => {
|
|
197
|
+
return async (user?: User) => {
|
|
198
|
+
// If user has direct permissions in JWT, use those
|
|
199
|
+
const jwtPermissions = (user as any)?.permissions
|
|
200
|
+
|
|
201
|
+
if (jwtPermissions && Array.isArray(jwtPermissions)) {
|
|
202
|
+
// Build ability from JWT permissions
|
|
203
|
+
const { can, cannot, build } = new AbilityBuilder<PureAbility>(PureAbility as any)
|
|
204
|
+
|
|
205
|
+
jwtPermissions.forEach((permission: any) => {
|
|
206
|
+
if (permission.inverted) {
|
|
207
|
+
cannot(permission.action, permission.subject, permission.conditions)
|
|
208
|
+
} else {
|
|
209
|
+
can(permission.action, permission.subject, permission.conditions)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return build()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fall back to role-based abilities
|
|
217
|
+
return defaultAbilityBuilder(user)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Postgraphile-specific JWT plugin
|
|
223
|
+
*/
|
|
224
|
+
export const createPostgraphileJWTPlugin = (config: JWTConfig) => {
|
|
225
|
+
return {
|
|
226
|
+
name: 'JWTAuthPlugin',
|
|
227
|
+
version: '1.0.0',
|
|
228
|
+
|
|
229
|
+
// Hook into Postgraphile's context building
|
|
230
|
+
grafast: {
|
|
231
|
+
hooks: {
|
|
232
|
+
async context(ctx: any, build: any) {
|
|
233
|
+
const middleware = createJWTMiddleware(config)
|
|
234
|
+
|
|
235
|
+
// Create a simple context object that the middleware can work with
|
|
236
|
+
const context = {
|
|
237
|
+
req: ctx.req,
|
|
238
|
+
headers: ctx.req?.headers,
|
|
239
|
+
user: undefined,
|
|
240
|
+
jwtPayload: undefined,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Run the JWT middleware
|
|
244
|
+
await middleware(context, async () => {})
|
|
245
|
+
|
|
246
|
+
// Add user to Postgraphile context
|
|
247
|
+
if (context.user) {
|
|
248
|
+
return { ...ctx, user: context.user, jwtPayload: context.jwtPayload }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return ctx
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Express/Koa middleware for REST endpoints
|
|
260
|
+
*/
|
|
261
|
+
export const createHTTPJWTMiddleware = (config: JWTConfig) => {
|
|
262
|
+
const jwtMiddleware = createJWTMiddleware(config)
|
|
263
|
+
|
|
264
|
+
// Express middleware
|
|
265
|
+
return async (req: any, res: any, next: any) => {
|
|
266
|
+
const context = {
|
|
267
|
+
req: {
|
|
268
|
+
headers: {
|
|
269
|
+
get: (name: string) => req.headers[name],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
headers: req.headers,
|
|
273
|
+
user: undefined,
|
|
274
|
+
jwtPayload: undefined,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
await jwtMiddleware(context, async () => {})
|
|
279
|
+
req.user = context.user
|
|
280
|
+
req.jwtPayload = context.jwtPayload
|
|
281
|
+
next()
|
|
282
|
+
} catch (error: any) {
|
|
283
|
+
if (config.optional) {
|
|
284
|
+
next()
|
|
285
|
+
} else {
|
|
286
|
+
res.status(401).json({
|
|
287
|
+
error: error.message,
|
|
288
|
+
code: 'UNAUTHORIZED',
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Refresh token utilities
|
|
297
|
+
*/
|
|
298
|
+
export const refreshTokenUtils = {
|
|
299
|
+
/**
|
|
300
|
+
* Create access and refresh tokens
|
|
301
|
+
*/
|
|
302
|
+
createTokenPair: (
|
|
303
|
+
user: User,
|
|
304
|
+
config: {
|
|
305
|
+
accessSecret: string
|
|
306
|
+
refreshSecret: string
|
|
307
|
+
accessExpiresIn?: string
|
|
308
|
+
refreshExpiresIn?: string
|
|
309
|
+
}
|
|
310
|
+
) => {
|
|
311
|
+
const { accessSecret, refreshSecret, accessExpiresIn = '15m', refreshExpiresIn = '7d' } = config
|
|
312
|
+
|
|
313
|
+
const accessPayload: any = {
|
|
314
|
+
sub: user.id,
|
|
315
|
+
roles: user.roles,
|
|
316
|
+
type: 'access',
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const refreshPayload: any = {
|
|
320
|
+
sub: user.id,
|
|
321
|
+
type: 'refresh',
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Create access token with proper options
|
|
325
|
+
const accessOptions: jwt.SignOptions = {}
|
|
326
|
+
if (accessExpiresIn) {
|
|
327
|
+
accessOptions.expiresIn = accessExpiresIn as any // Type cast to avoid TS issues
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const accessToken = jwt.sign(accessPayload, accessSecret, accessOptions)
|
|
331
|
+
|
|
332
|
+
// Create refresh token with proper options
|
|
333
|
+
const refreshOptions: jwt.SignOptions = {}
|
|
334
|
+
if (refreshExpiresIn) {
|
|
335
|
+
refreshOptions.expiresIn = refreshExpiresIn as any // Type cast to avoid TS issues
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const refreshToken = jwt.sign(refreshPayload, refreshSecret, refreshOptions)
|
|
339
|
+
|
|
340
|
+
return { accessToken, refreshToken }
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Verify refresh token and create new access token
|
|
345
|
+
*/
|
|
346
|
+
refreshAccessToken: async (
|
|
347
|
+
refreshToken: string,
|
|
348
|
+
config: {
|
|
349
|
+
accessSecret: string
|
|
350
|
+
refreshSecret: string
|
|
351
|
+
getUserById: (id: string) => Promise<User | null>
|
|
352
|
+
accessExpiresIn?: string
|
|
353
|
+
}
|
|
354
|
+
) => {
|
|
355
|
+
const { accessSecret, refreshSecret, getUserById, accessExpiresIn = '15m' } = config
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
// Verify refresh token
|
|
359
|
+
const payload = jwt.verify(refreshToken, refreshSecret) as any
|
|
360
|
+
|
|
361
|
+
if (payload.type !== 'refresh') {
|
|
362
|
+
throw new Error('Invalid token type')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Get fresh user data
|
|
366
|
+
const user = await getUserById(payload.sub)
|
|
367
|
+
if (!user) {
|
|
368
|
+
throw new Error('User not found')
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Create new access token
|
|
372
|
+
const accessPayload: any = {
|
|
373
|
+
sub: user.id,
|
|
374
|
+
roles: user.roles,
|
|
375
|
+
type: 'access',
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Create access token with proper options
|
|
379
|
+
const accessOptions: jwt.SignOptions = {}
|
|
380
|
+
if (accessExpiresIn) {
|
|
381
|
+
accessOptions.expiresIn = accessExpiresIn as any // Type cast to avoid TS issues
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const accessToken = jwt.sign(accessPayload, accessSecret, accessOptions)
|
|
385
|
+
|
|
386
|
+
return { accessToken, user }
|
|
387
|
+
} catch (error: any) {
|
|
388
|
+
if (error.name === 'TokenExpiredError') {
|
|
389
|
+
throw new Error('Refresh token expired')
|
|
390
|
+
}
|
|
391
|
+
throw error
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
}
|