1 |
|
2 | import { IncomingMessage } from 'http'
|
3 | import { NotAuthenticated } from '@feathersjs/errors'
|
4 | import { Params } from '@feathersjs/feathers'
|
5 | import { createDebug } from '@feathersjs/commons'
|
6 |
|
7 | import lt from 'long-timeout'
|
8 |
|
9 | import { AuthenticationBaseStrategy } from './strategy'
|
10 | import { AuthenticationParams, AuthenticationRequest, AuthenticationResult, ConnectionEvent } from './core'
|
11 |
|
12 | const debug = createDebug('@feathersjs/authentication/jwt')
|
13 | const SPLIT_HEADER = /(\S+)\s+(\S+)/
|
14 |
|
15 | export class JWTStrategy extends AuthenticationBaseStrategy {
|
16 | expirationTimers = new WeakMap()
|
17 |
|
18 | get configuration() {
|
19 | const authConfig = this.authentication.configuration
|
20 | const config = super.configuration
|
21 |
|
22 | return {
|
23 | service: authConfig.service,
|
24 | entity: authConfig.entity,
|
25 | entityId: authConfig.entityId,
|
26 | header: 'Authorization',
|
27 | schemes: ['Bearer', 'JWT'],
|
28 | ...config
|
29 | }
|
30 | }
|
31 |
|
32 | async handleConnection(
|
33 | event: ConnectionEvent,
|
34 | connection: any,
|
35 | authResult?: AuthenticationResult
|
36 | ): Promise<void> {
|
37 | const isValidLogout =
|
38 | event === 'logout' &&
|
39 | connection.authentication &&
|
40 | authResult &&
|
41 | connection.authentication.accessToken === authResult.accessToken
|
42 |
|
43 | const { accessToken } = authResult || {}
|
44 | const { entity } = this.configuration
|
45 |
|
46 | if (accessToken && event === 'login') {
|
47 | debug('Adding authentication information to connection')
|
48 | const { exp } =
|
49 | authResult?.authentication?.payload || (await this.authentication.verifyAccessToken(accessToken))
|
50 |
|
51 | const duration = exp * 1000 - Date.now()
|
52 | const timer = lt.setTimeout(() => this.app.emit('disconnect', connection), duration)
|
53 |
|
54 | debug(`Registering connection expiration timer for ${duration}ms`)
|
55 | lt.clearTimeout(this.expirationTimers.get(connection))
|
56 | this.expirationTimers.set(connection, timer)
|
57 |
|
58 | debug('Adding authentication information to connection')
|
59 | connection.authentication = {
|
60 | strategy: this.name,
|
61 | accessToken
|
62 | }
|
63 | connection[entity] = authResult[entity]
|
64 | } else if (event === 'disconnect' || isValidLogout) {
|
65 | debug('Removing authentication information and expiration timer from connection')
|
66 |
|
67 | await new Promise((resolve) =>
|
68 | process.nextTick(() => {
|
69 | delete connection[entity]
|
70 | delete connection.authentication
|
71 | resolve(connection)
|
72 | })
|
73 | )
|
74 |
|
75 | lt.clearTimeout(this.expirationTimers.get(connection))
|
76 | this.expirationTimers.delete(connection)
|
77 | }
|
78 | }
|
79 |
|
80 | verifyConfiguration() {
|
81 | const allowedKeys = ['entity', 'entityId', 'service', 'header', 'schemes']
|
82 |
|
83 | for (const key of Object.keys(this.configuration)) {
|
84 | if (!allowedKeys.includes(key)) {
|
85 | throw new Error(
|
86 | `Invalid JwtStrategy option 'authentication.${this.name}.${key}'. Did you mean to set it in 'authentication.jwtOptions'?`
|
87 | )
|
88 | }
|
89 | }
|
90 |
|
91 | if (typeof this.configuration.header !== 'string') {
|
92 | throw new Error(`The 'header' option for the ${this.name} strategy must be a string`)
|
93 | }
|
94 | }
|
95 |
|
96 | async getEntityQuery(_params: Params) {
|
97 | return {}
|
98 | }
|
99 |
|
100 | |
101 |
|
102 |
|
103 |
|
104 |
|
105 |
|
106 | async getEntity(id: string, params: Params) {
|
107 | const entityService = this.entityService
|
108 | const { entity } = this.configuration
|
109 |
|
110 | debug('Getting entity', id)
|
111 |
|
112 | if (entityService === null) {
|
113 | throw new NotAuthenticated('Could not find entity service')
|
114 | }
|
115 |
|
116 | const query = await this.getEntityQuery(params)
|
117 | const { provider, ...paramsWithoutProvider } = params
|
118 | const result = await entityService.get(id, {
|
119 | ...paramsWithoutProvider,
|
120 | query
|
121 | })
|
122 |
|
123 | if (!params.provider) {
|
124 | return result
|
125 | }
|
126 |
|
127 | return entityService.get(id, { ...params, [entity]: result })
|
128 | }
|
129 |
|
130 | async getEntityId(authResult: AuthenticationResult, _params: Params) {
|
131 | return authResult.authentication.payload.sub
|
132 | }
|
133 |
|
134 | async authenticate(authentication: AuthenticationRequest, params: AuthenticationParams) {
|
135 | const { accessToken } = authentication
|
136 | const { entity } = this.configuration
|
137 |
|
138 | if (!accessToken) {
|
139 | throw new NotAuthenticated('No access token')
|
140 | }
|
141 |
|
142 | const payload = await this.authentication.verifyAccessToken(accessToken, params.jwt)
|
143 | const result = {
|
144 | accessToken,
|
145 | authentication: {
|
146 | strategy: 'jwt',
|
147 | accessToken,
|
148 | payload
|
149 | }
|
150 | }
|
151 |
|
152 | if (entity === null) {
|
153 | return result
|
154 | }
|
155 |
|
156 | const entityId = await this.getEntityId(result, params)
|
157 | const value = await this.getEntity(entityId, params)
|
158 |
|
159 | return {
|
160 | ...result,
|
161 | [entity]: value
|
162 | }
|
163 | }
|
164 |
|
165 | async parse(req: IncomingMessage): Promise<{
|
166 | strategy: string
|
167 | accessToken: string
|
168 | } | null> {
|
169 | const { header, schemes }: { header: string; schemes: string[] } = this.configuration
|
170 | const headerValue = req.headers && req.headers[header.toLowerCase()]
|
171 |
|
172 | if (!headerValue || typeof headerValue !== 'string') {
|
173 | return null
|
174 | }
|
175 |
|
176 | debug('Found parsed header value')
|
177 |
|
178 | const [, scheme, schemeValue] = headerValue.match(SPLIT_HEADER) || []
|
179 | const hasScheme = scheme && schemes.some((current) => new RegExp(current, 'i').test(scheme))
|
180 |
|
181 | if (scheme && !hasScheme) {
|
182 | return null
|
183 | }
|
184 |
|
185 | return {
|
186 | strategy: this.name,
|
187 | accessToken: hasScheme ? schemeValue : headerValue
|
188 | }
|
189 | }
|
190 | }
|