Skip to content

Commit a5f8422

Browse files
committed
feat(refresh tokens): authenticateWithRefreshToken() method
1 parent afa556f commit a5f8422

File tree

7 files changed

+267
-1
lines changed

7 files changed

+267
-1
lines changed

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { StringValue } from 'ms'
77

88
export function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>>(config: {
99
provider: UserProvider
10+
refreshTokenUserProvider?: any
1011
tokenName?: string
1112
tokenExpiresIn?: number | StringValue
1213
useCookies?: boolean
@@ -18,6 +19,7 @@ export function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>>(
1819
const appKey = (app.config.get('app.appKey') as Secret<string>).release()
1920
const options = {
2021
secret: config.secret ?? appKey,
22+
refreshTokenUserProvider: config.refreshTokenUserProvider,
2123
tokenName: config.tokenName,
2224
expiresIn: config.tokenExpiresIn,
2325
useCookies: config.useCookies,

src/jwt.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ 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'
67

78
export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
89
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
910
{
1011
#ctx: HttpContext
1112
#userProvider: UserProvider
13+
#refreshTokenUserProvider?: any
1214
#options: JwtGuardOptions<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
1315
#tokenName: string
1416

@@ -20,6 +22,7 @@ export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
2022
this.#ctx = ctx
2123
this.#userProvider = userProvider
2224
this.#options = option
25+
this.#refreshTokenUserProvider = this.#options.refreshTokenUserProvider
2326
if (!this.#options.content) this.#options.content = (user) => ({ userId: user.getId() })
2427
this.#tokenName = this.#options.tokenName ?? 'token'
2528
}
@@ -171,6 +174,75 @@ export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
171174
return this.getUserOrFail()
172175
}
173176

177+
async authenticateWithRefreshToken(): Promise<
178+
UserProvider[typeof symbols.PROVIDER_REAL_USER] & { currentToken: string }
179+
> {
180+
/**
181+
* Ensure the refresh token user provider is defined
182+
*/
183+
if (!this.#refreshTokenUserProvider) {
184+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
185+
guardDriverName: this.driverName,
186+
})
187+
}
188+
189+
/**
190+
* Avoid re-authentication when it has been done already
191+
* for the given request
192+
*/
193+
if (this.authenticationAttempted) {
194+
return this.getUserOrFail()
195+
}
196+
this.authenticationAttempted = true
197+
198+
/**
199+
* Ensure the auth header exists
200+
*/
201+
const authHeader = this.#ctx.request.header('authorization')
202+
if (!authHeader) {
203+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
204+
guardDriverName: this.driverName,
205+
})
206+
}
207+
208+
/**
209+
* Split the header value and read the token from it
210+
*/
211+
const [, refreshToken] = authHeader!.split('Bearer ')
212+
if (!refreshToken) {
213+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
214+
guardDriverName: this.driverName,
215+
})
216+
}
217+
218+
const accessToken = await this.#refreshTokenUserProvider.verifyToken(new Secret(refreshToken))
219+
220+
/**
221+
* Fetch the user by user ID and save a reference to it
222+
*/
223+
const providerUser = await this.#userProvider.findById(accessToken.tokenableId)
224+
if (!providerUser) {
225+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
226+
guardDriverName: this.driverName,
227+
})
228+
}
229+
230+
/**
231+
* Generate a new JWT token for the user
232+
*/
233+
const { token }: any = await this.generate(providerUser.getOriginal())
234+
this.isAuthenticated = true
235+
this.user = providerUser.getOriginal() as UserProvider[typeof symbols.PROVIDER_REAL_USER] & {
236+
currentToken: string
237+
}
238+
239+
if (!this.#options.useCookies) {
240+
this.user!.currentToken = token
241+
}
242+
243+
return this.getUserOrFail()
244+
}
245+
174246
/**
175247
* Same as authenticate, but does not throw an exception
176248
*/

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type BaseJwtContent = {
4646

4747
export type JwtGuardOptions<RealUser extends any = unknown> = {
4848
secret: string
49+
refreshTokenUserProvider?: any
4950
tokenName?: string
5051
expiresIn?: number | StringValue
5152
useCookies?: boolean

tests/guard.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,72 @@ import { errors } from '@adonisjs/auth'
66
import { JwtAuthFakeUser, JwtFakeUserProvider } from '../factories/main.js'
77
import jwt from 'jsonwebtoken'
88
import { timeTravel } from '../tests/helpers.js'
9+
import { BaseModel, column } from '@adonisjs/lucid/orm'
10+
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
11+
import { createDatabase, createTables } from '../tests/helpers.js'
12+
import { tokensUserProvider } from '@adonisjs/auth/access_tokens'
913

1014
test.group('Jwt guard | authenticate', () => {
15+
test('it should return a jwt token when user is authenticated with refresh token', async ({ assert }) => {
16+
const ctx = new HttpContextFactory().create()
17+
const userProvider = new JwtFakeUserProvider()
18+
19+
const db = await createDatabase()
20+
await createTables(db)
21+
22+
const guard = new JwtGuard(ctx, userProvider, {
23+
secret: 'thisisasecret',
24+
refreshTokenUserProvider: tokensUserProvider({
25+
tokens: 'refreshTokens',
26+
async model() {
27+
return {
28+
default: User,
29+
}
30+
},
31+
}),
32+
})
33+
34+
class User extends BaseModel {
35+
@column({ isPrimary: true })
36+
declare id: number
37+
38+
@column()
39+
declare username: string
40+
41+
@column()
42+
declare email: string
43+
44+
@column()
45+
declare password: string
46+
47+
static refreshTokens = DbAccessTokensProvider.forModel(User, {
48+
prefix: 'rt_',
49+
table: 'jwt_refresh_tokens',
50+
type: 'auth_token',
51+
tokenSecretLength: 40,
52+
})
53+
}
54+
55+
const user = await User.create({
56+
57+
username: 'max',
58+
password: 'secret',
59+
})
60+
61+
const refreshToken = await User.refreshTokens.create(user)
62+
63+
ctx.request.request.headers.authorization = `Bearer ${refreshToken.value?.release()}`
64+
65+
const userAuthenticated = await guard.authenticateWithRefreshToken()
66+
67+
assert.isTrue(guard.isAuthenticated)
68+
assert.isTrue(guard.authenticationAttempted)
69+
assert.equal(guard.user, userAuthenticated)
70+
assert.deepEqual(guard.getUserOrFail(), userAuthenticated)
71+
assert.exists(userAuthenticated.currentToken)
72+
assert.exists(refreshToken.value?.release())
73+
})
74+
1175
test('it should return a token when user is authenticated', async ({ assert }) => {
1276
const ctx = new HttpContextFactory().create()
1377
const userProvider = new JwtFakeUserProvider()

tests/helpers.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import timekeeper from 'timekeeper'
22
import { getActiveTest } from '@japa/runner'
3+
import { BaseModel } from '@adonisjs/lucid/orm'
4+
import { AppFactory } from '@adonisjs/core/factories/app'
5+
import { mkdir, rm } from 'node:fs/promises'
6+
import { join } from 'node:path'
7+
import { Emitter } from '@adonisjs/core/events'
8+
import { LoggerFactory } from '@adonisjs/core/factories/logger'
9+
import { Database } from '@adonisjs/lucid/database'
310

411
/**
512
* Travels time by seconds
@@ -39,3 +46,120 @@ export function freezeTime() {
3946
timekeeper.reset()
4047
})
4148
}
49+
50+
/**
51+
* Creates an instance of the database class for making queries
52+
*/
53+
export async function createDatabase() {
54+
const test = getActiveTest()
55+
if (!test) {
56+
throw new Error('Cannot use "createDatabase" outside of a Japa test')
57+
}
58+
59+
const basePath = test.context.fs.basePath
60+
await mkdir(basePath)
61+
62+
const app = new AppFactory().create(test.context.fs.baseUrl, () => {})
63+
const logger = new LoggerFactory().create()
64+
const emitter = new Emitter(app)
65+
const db = new Database(
66+
{
67+
connection: process.env.DB || 'sqlite',
68+
connections: {
69+
sqlite: {
70+
client: 'sqlite3',
71+
connection: {
72+
filename: join(test.context.fs.basePath, 'db.sqlite3'),
73+
},
74+
},
75+
pg: {
76+
client: 'pg',
77+
connection: {
78+
host: process.env.PG_HOST as string,
79+
port: Number(process.env.PG_PORT),
80+
database: process.env.PG_DATABASE as string,
81+
user: process.env.PG_USER as string,
82+
password: process.env.PG_PASSWORD as string,
83+
},
84+
},
85+
mssql: {
86+
client: 'mssql',
87+
connection: {
88+
server: process.env.MSSQL_HOST as string,
89+
port: Number(process.env.MSSQL_PORT! as string),
90+
user: process.env.MSSQL_USER as string,
91+
password: process.env.MSSQL_PASSWORD as string,
92+
database: 'master',
93+
options: {
94+
enableArithAbort: true,
95+
},
96+
},
97+
},
98+
mysql: {
99+
client: 'mysql2',
100+
connection: {
101+
host: process.env.MYSQL_HOST as string,
102+
port: Number(process.env.MYSQL_PORT),
103+
database: process.env.MYSQL_DATABASE as string,
104+
user: process.env.MYSQL_USER as string,
105+
password: process.env.MYSQL_PASSWORD as string,
106+
},
107+
},
108+
},
109+
},
110+
logger,
111+
emitter
112+
)
113+
114+
test.cleanup(async () => {
115+
db.manager.closeAll()
116+
await rm(basePath, { force: true, recursive: true, maxRetries: 3 })
117+
})
118+
BaseModel.useAdapter(db.modelAdapter())
119+
return db
120+
}
121+
122+
/**
123+
* Creates needed database tables
124+
*/
125+
export async function createTables(db: Database) {
126+
const test = getActiveTest()
127+
if (!test) {
128+
throw new Error('Cannot use "createTables" outside of a Japa test')
129+
}
130+
131+
test.cleanup(async () => {
132+
await db.connection().schema.dropTable('users')
133+
await db.connection().schema.dropTable('jwt_refresh_tokens')
134+
await db.connection().schema.dropTable('remember_me_tokens')
135+
})
136+
137+
await db.connection().schema.createTable('jwt_refresh_tokens', (table) => {
138+
table.increments()
139+
table.integer('tokenable_id').notNullable().unsigned()
140+
table.string('type').notNullable()
141+
table.string('name').nullable()
142+
table.string('hash', 80).notNullable()
143+
table.text('abilities').notNullable()
144+
table.timestamp('created_at', { precision: 6, useTz: true }).notNullable()
145+
table.timestamp('updated_at', { precision: 6, useTz: true }).notNullable()
146+
table.timestamp('expires_at', { precision: 6, useTz: true }).nullable()
147+
table.timestamp('last_used_at', { precision: 6, useTz: true }).nullable()
148+
})
149+
150+
await db.connection().schema.createTable('users', (table) => {
151+
table.increments()
152+
table.string('username').unique().notNullable()
153+
table.string('email').unique().notNullable()
154+
table.string('password').nullable()
155+
})
156+
157+
await db.connection().schema.createTable('remember_me_tokens', (table) => {
158+
table.increments()
159+
table.integer('tokenable_id').notNullable().unsigned()
160+
table.string('hash', 80).notNullable()
161+
table.timestamp('created_at', { precision: 6, useTz: true }).notNullable()
162+
table.timestamp('updated_at', { precision: 6, useTz: true }).notNullable()
163+
table.timestamp('expires_at', { precision: 6, useTz: true }).notNullable()
164+
})
165+
}

0 commit comments

Comments
 (0)