Skip to content

Commit b69516e

Browse files
authored
Merge pull request #8 from MaximeMRF/feat/refresh-token
feat(refresh tokens)
2 parents afa556f + 5200eae commit b69516e

File tree

8 files changed

+684
-8
lines changed

8 files changed

+684
-8
lines changed

README.md

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
1414
## Prerequisites
1515

16-
You have to install the auth package from AdonisJS with the session guard because the jwt package use some components from the session guard.
16+
You have to install the auth package from AdonisJS
1717

1818
```bash
19-
node ace add @adonisjs/auth --guard=session
19+
node ace add @adonisjs/auth
2020
```
2121

2222
## Setup
@@ -35,6 +35,7 @@ Go to `config/auth.ts` and add the following configuration:
3535
import { defineConfig } from '@adonisjs/auth'
3636
import { InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
3737
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
38+
import { tokensUserProvider } from '@adonisjs/auth/access_tokens'
3839
import { jwtGuard } from '@maximemrf/adonisjs-jwt/jwt_config'
3940
import { JwtGuardUser, BaseJwtContent } from '@maximemrf/adonisjs-jwt/types'
4041
import User from '#models/user'
@@ -69,6 +70,11 @@ const authConfig = defineConfig({
6970
provider: sessionUserProvider({
7071
model: () => import('#models/user'),
7172
}),
73+
// if you want to use refresh tokens, you have to set the refreshTokenUserProvider
74+
refreshTokenUserProvider: tokensUserProvider({
75+
tokens: 'refreshTokens',
76+
model: () => import('#models/user'),
77+
}),
7278
// content is a function that takes the user and returns the content of the token, it can be optional, by default it returns only the user id
7379
content: <T>(user: JwtGuardUser<T>): JwtContent => {
7480
return {
@@ -81,13 +87,13 @@ const authConfig = defineConfig({
8187
})
8288
```
8389

84-
`tokenName` is the name of the token passed as a cookie, it can be optional, by default it is `token`.
90+
`tokenName` is the name of the jwt token passed as a cookie, it can be optional, by default it is `token`.
8591

8692
```typescript
8793
tokenName: 'custom-name'
8894
```
8995

90-
`tokenExpiresIn` is the time before the token expires it can be a string or a number and it can be optional.
96+
`tokenExpiresIn` is the time before the jwt token expires it can be a string or a number and it can be optional.
9197

9298
```typescript
9399
// string
@@ -104,6 +110,67 @@ useCookies: true
104110

105111
If you just want to use jwt with the bearer token no need to set `useCookies` to `false` you can just remove it.
106112

113+
## Refresh Tokens
114+
115+
To use refresh tokens, you have to set the `refreshTokenUserProvider` in the guard configuration, see the example above.
116+
117+
Create a new AdonisJS migration file and run it to create the `jwt_refresh_tokens` table:
118+
119+
```typescript
120+
import { BaseSchema } from '@adonisjs/lucid/schema'
121+
122+
export default class extends BaseSchema {
123+
protected tableName = 'jwt_refresh_tokens'
124+
125+
async up() {
126+
this.schema.createTable(this.tableName, (table) => {
127+
table.increments()
128+
table.integer('tokenable_id').notNullable().unsigned()
129+
table.string('type').notNullable()
130+
table.string('name').nullable()
131+
table.string('hash', 80).notNullable()
132+
table.text('abilities').notNullable()
133+
table.timestamp('created_at', { precision: 6, useTz: true }).notNullable()
134+
table.timestamp('updated_at', { precision: 6, useTz: true }).notNullable()
135+
table.timestamp('expires_at', { precision: 6, useTz: true }).nullable()
136+
table.timestamp('last_used_at', { precision: 6, useTz: true }).nullable()
137+
})
138+
}
139+
140+
async down() {
141+
this.schema.dropTable(this.tableName)
142+
}
143+
}
144+
```
145+
146+
And add the `refreshTokens` property to your User model:
147+
148+
```typescript
149+
import { column, BaseModel } from '@adonisjs/lucid/orm'
150+
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
151+
152+
export default class User extends BaseModel {
153+
@column({ isPrimary: true })
154+
declare id: number
155+
156+
@column()
157+
declare username: string
158+
159+
@column()
160+
declare email: string
161+
162+
@column()
163+
declare password: string
164+
165+
static refreshTokens = DbAccessTokensProvider.forModel(User, {
166+
prefix: 'rt_',
167+
table: 'jwt_refresh_tokens',
168+
type: 'jwt_refresh_token',
169+
tokenSecretLength: 40,
170+
})
171+
}
172+
```
173+
107174
## Authentication
108175

109176
To make a protected route, you have to use the `auth` middleware with the `jwt` guard.
@@ -128,6 +195,26 @@ router.get('/', async ({ auth }) => {
128195
return auth.use('jwt').getUserOrFail()
129196
})
130197
.use(middleware.auth({ guards: ['jwt'] }))
198+
199+
// to create a refresh token to a given user
200+
import User from '#models/user'
201+
const user = auth.getUserOrFail()
202+
const refreshToken = await User.refreshTokens.create(user)
203+
204+
// if you use the refresh token
205+
router.post('jwt/refresh', async ({ auth }) => {
206+
// this will authenticate the user using the refresh token
207+
// it will delete the old refresh token and generate a new one
208+
const user = await auth.use('jwt').authenticateWithRefreshToken()
209+
const newRefreshToken = user.currentToken
210+
const newToken = await auth.use('jwt').generate(user)
211+
212+
return response.ok({
213+
token: newToken,
214+
refreshToken: newRefreshToken,
215+
...user.serialize(),
216+
})
217+
})
131218
```
132219

133220
## Security

bin/test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { assert } from '@japa/assert'
2+
import { fileSystem } from '@japa/file-system'
23
import { configure, processCLIArgs, run } from '@japa/runner'
34

45
processCLIArgs(process.argv.splice(2))
56

67
configure({
78
files: ['tests/**/*.spec.ts'],
8-
plugins: [assert()],
9+
plugins: [assert(), fileSystem()],
910
})
1011

1112
run()

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@adonisjs/prettier-config": "^1.4.4",
4343
"@adonisjs/tsconfig": "^1.4.0",
4444
"@japa/assert": "^4.0.1",
45+
"@japa/file-system": "^2.3.2",
4546
"@japa/runner": "^4.2.0",
4647
"@swc/core": "1.11.24",
4748
"@types/node": "^22.15.18",
@@ -52,6 +53,7 @@
5253
"luxon": "^3.4.4",
5354
"np": "^10.0.0",
5455
"prettier": "^3.5.3",
56+
"sqlite3": "^5.1.7",
5557
"timekeeper": "^2.3.1",
5658
"ts-node-maintained": "^10.9.5",
5759
"typescript": "~5.8"

src/define_config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { JwtGuardUser, JwtUserProviderContract } from './types.js'
44
import { JwtGuard } from './jwt.js'
55
import { Secret } from '@adonisjs/core/helpers'
66
import type { StringValue } from 'ms'
7+
import { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens'
78

89
export function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>>(config: {
910
provider: UserProvider
11+
refreshTokenUserProvider?: AccessTokensUserProviderContract<unknown>
1012
tokenName?: string
1113
tokenExpiresIn?: number | StringValue
1214
useCookies?: boolean
@@ -18,6 +20,7 @@ export function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>>(
1820
const appKey = (app.config.get('app.appKey') as Secret<string>).release()
1921
const options = {
2022
secret: config.secret ?? appKey,
23+
refreshTokenUserProvider: config.refreshTokenUserProvider,
2124
tokenName: config.tokenName,
2225
expiresIn: config.tokenExpiresIn,
2326
useCookies: config.useCookies,

src/jwt.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import { AuthClientResponse, GuardContract } from '@adonisjs/auth/types'
33
import type { HttpContext } from '@adonisjs/core/http'
44
import jwt from 'jsonwebtoken'
55
import { JwtUserProviderContract, JwtGuardOptions } from './types.js'
6+
import { Secret } from '@adonisjs/core/helpers'
7+
import { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens'
68

79
export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
810
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
911
{
1012
#ctx: HttpContext
1113
#userProvider: UserProvider
14+
#refreshTokenUserProvider?: AccessTokensUserProviderContract<
15+
UserProvider[typeof symbols.PROVIDER_REAL_USER]
16+
>
1217
#options: JwtGuardOptions<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
1318
#tokenName: string
1419

@@ -20,6 +25,7 @@ export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
2025
this.#ctx = ctx
2126
this.#userProvider = userProvider
2227
this.#options = option
28+
this.#refreshTokenUserProvider = this.#options.refreshTokenUserProvider
2329
if (!this.#options.content) this.#options.content = (user) => ({ userId: user.getId() })
2430
this.#tokenName = this.#options.tokenName ?? 'token'
2531
}
@@ -171,6 +177,92 @@ export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
171177
return this.getUserOrFail()
172178
}
173179

180+
async authenticateWithRefreshToken(): Promise<
181+
UserProvider[typeof symbols.PROVIDER_REAL_USER] & { currentToken: string }
182+
> {
183+
/**
184+
* Avoid re-authentication when it has been done already
185+
* for the given request
186+
*/
187+
if (this.authenticationAttempted) {
188+
return this.getUserOrFail()
189+
}
190+
this.authenticationAttempted = true
191+
192+
/**
193+
* Ensure the refresh token user provider is defined
194+
*/
195+
if (!this.#refreshTokenUserProvider) {
196+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
197+
guardDriverName: this.driverName,
198+
})
199+
}
200+
/**
201+
* Ensure the auth header exists
202+
*/
203+
const authHeader = this.#ctx.request.header('authorization')
204+
if (!authHeader) {
205+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
206+
guardDriverName: this.driverName,
207+
})
208+
}
209+
210+
/**
211+
* Split the header value and read the token from it
212+
*/
213+
const [, refreshToken] = authHeader!.split('Bearer ')
214+
if (!refreshToken) {
215+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
216+
guardDriverName: this.driverName,
217+
})
218+
}
219+
220+
const accessToken = await this.#refreshTokenUserProvider.verifyToken(new Secret(refreshToken))
221+
222+
if (!accessToken) {
223+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
224+
guardDriverName: this.driverName,
225+
})
226+
}
227+
228+
/**
229+
* Fetch the user by user ID
230+
*/
231+
const providerUser = await this.#refreshTokenUserProvider.findById(accessToken.tokenableId)
232+
if (!providerUser) {
233+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
234+
guardDriverName: this.driverName,
235+
})
236+
}
237+
238+
this.isAuthenticated = true
239+
this.user = providerUser.getOriginal() as UserProvider[typeof symbols.PROVIDER_REAL_USER] & {
240+
currentToken: string
241+
}
242+
243+
/**
244+
* Delete the refresh token from the database
245+
*/
246+
const isDeleted = await this.#refreshTokenUserProvider.invalidateToken(new Secret(refreshToken))
247+
if (!isDeleted) {
248+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
249+
guardDriverName: this.driverName,
250+
})
251+
}
252+
253+
const newRefreshToken = await this.#refreshTokenUserProvider.createToken(this.user)
254+
255+
if (!newRefreshToken.value) {
256+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
257+
guardDriverName: this.driverName,
258+
})
259+
}
260+
261+
this.user.currentToken = newRefreshToken.value?.release()
262+
263+
return this.getUserOrFail()
264+
}
265+
174266
/**
175267
* Same as authenticate, but does not throw an exception
176268
*/

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { symbols } from '@adonisjs/auth'
22
import type { StringValue } from 'ms'
3+
import { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens'
34

45
/**
56
* The bridge between the User provider and the
@@ -46,6 +47,7 @@ export type BaseJwtContent = {
4647

4748
export type JwtGuardOptions<RealUser extends any = unknown> = {
4849
secret: string
50+
refreshTokenUserProvider?: AccessTokensUserProviderContract<RealUser>
4951
tokenName?: string
5052
expiresIn?: number | StringValue
5153
useCookies?: boolean

0 commit comments

Comments
 (0)