diff --git a/packages/auth/src/Auth.ts b/packages/auth/src/Auth.ts index 968ab529..a0b4c945 100644 --- a/packages/auth/src/Auth.ts +++ b/packages/auth/src/Auth.ts @@ -1,5 +1,6 @@ import { Concrete, Hash, appKey, env, getModel } from '@arkstack/common' import { JWTPayload, SignJWT, jwtVerify } from 'jose' +import { randomUUID } from 'node:crypto' import { AuthContract } from './Contracts/AuthContract' import { AuthenticationException } from './Exceptions/AuthenticationException' @@ -371,7 +372,10 @@ export class Auth extends AuthContract { } /** - * Create a JWT token + * Create a JWT token. Each carries a random `jti`: without it, two tokens for + * the same user issued within the same second (`iat` has one-second precision) + * are identical, and the second collides with the first on the unique + * `personal_access_tokens.token` column. * * @param payload * @returns @@ -379,6 +383,7 @@ export class Auth extends AuthContract { private async createJWT(payload: JWTPayload, expiresIn: string = env('JWT_EXPIRES_IN', '1h')): Promise { const jwt = await new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) + .setJti(randomUUID()) .setIssuedAt() .setExpirationTime(expiresIn) .sign(new TextEncoder().encode(this.getSecret())) diff --git a/packages/auth/tests/jwt.test.ts b/packages/auth/tests/jwt.test.ts new file mode 100644 index 00000000..1a9405a8 --- /dev/null +++ b/packages/auth/tests/jwt.test.ts @@ -0,0 +1,22 @@ +import { decodeJwt } from 'jose' +import { describe, expect, it } from 'vitest' + +import { Auth } from '../src/Auth' +import { authSecret } from './fixtures/auth' + +type WithCreateJWT = { createJWT(payload: Record): Promise } + +describe('Auth JWT', () => { + it('issues a distinct token each time, even for the same user within one second', async () => { + const auth = new Auth(authSecret) as unknown as WithCreateJWT + const payload = { sub: '1', email: 'a@example.com' } + + const first = await auth.createJWT(payload) + const second = await auth.createJWT(payload) + + // Same `sub` and the same one-second `iat`: only the `jti` tells them apart. + expect(second).not.toBe(first) + expect(decodeJwt(first).jti).toBeTruthy() + expect(decodeJwt(second).jti).not.toBe(decodeJwt(first).jti) + }) +})