Skip to content

Commit 52d5aae

Browse files
Merge pull request #196 from tobiasdcl/main
feat: add support for opaque refresh tokens
2 parents 3bc8f2a + 44d267a commit 52d5aae

File tree

7 files changed

+57
-23
lines changed

7 files changed

+57
-23
lines changed

docs/docs/Extras/access_tokens.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ import { JwtService } from "@jmondi/oauth2-server";
2626

2727
export class MyCustomJwtService extends JwtService {
2828
extraTokenFields(params: ExtraAccessTokenFieldArgs) {
29-
const { user = undefined, client } = params;
29+
const { user = undefined, client, originatingAuthCodeId } = params;
3030
return {
3131
email: user?.email,
32+
originatingAuthCodeId,
3233
myCustomProps: "this will be in the decoded token!",
3334
};
3435
}

docs/docs/authorization_server/configuration.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The authorization server has a few optional settings with the following default
1919
| `authenticateRevoke` | boolean | true | Authorize the [/revoke](../endpoints/revoke.mdx) endpoint using `client_credentials`, this requires users to pass in a valid client_id and client_secret (or Authorization header) <br /><br />In 4.x the default is **true**, in v3.x the default was **false**. |
2020
| `logger` | LoggerService \| undefined | undefined | Optional logger service to capture debugging information, particularly useful for tracking token operations like revocations. |
2121
| `useOpaqueAuthorizationCodes` | boolean | false | When enabled, authorization codes are returned as simple random strings rather than signed JWTs. This provides flexibility for different security models while maintaining full OAuth 2.0 compliance. Opaque codes are stored server-side and validated through repository lookups. |
22+
| `useOpaqueRefreshTokens` | boolean | false | When enabled, refresh tokens are returned as simple random strings rather than signed JWTs. This provides flexibility for different security models while maintaining full OAuth 2.0 compliance. Opaque codes are stored server-side and validated through repository lookups. |
2223

2324
```ts
2425
type AuthorizationServerOptions = {
@@ -31,6 +32,7 @@ type AuthorizationServerOptions = {
3132
authenticateRevoke: boolean;
3233
logger?: LoggerService;
3334
useOpaqueAuthorizationCodes?: boolean;
35+
useOpaqueRefreshTokens?: boolean;
3436
};
3537
```
3638

src/authorization_server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export interface AuthorizationServerOptions {
3838
* If enabled opaque codes are used instead of JWT-based authorization codes.
3939
*/
4040
useOpaqueAuthorizationCodes?: boolean;
41+
/**
42+
* If enabled opaque tokens are used instead of JWT-based refresh tokens.
43+
*/
44+
useOpaqueRefreshTokens?: boolean;
4145
}
4246

4347
export type EnableableGrants =

src/grants/abstract/abstract.grant.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,14 @@ export abstract class AbstractGrant implements GrantInterface {
8383

8484
const encryptedAccessToken = await this.encryptAccessToken(client, accessToken, scopes, extraJwtFields);
8585

86-
let encryptedRefreshToken: string | undefined = undefined;
86+
let refreshToken: string | undefined = undefined;
8787

8888
if (accessToken.refreshToken) {
89-
encryptedRefreshToken = await this.encryptRefreshToken(client, accessToken, scopes);
89+
if (this.options.useOpaqueRefreshTokens) {
90+
refreshToken = accessToken.refreshToken;
91+
} else {
92+
refreshToken = await this.encryptRefreshToken(client, accessToken, scopes);
93+
}
9094
}
9195

9296
const bearerTokenResponse = new BearerTokenResponse(accessToken);
@@ -95,7 +99,7 @@ export abstract class AbstractGrant implements GrantInterface {
9599
token_type: "Bearer",
96100
expires_in: getSecondsUntil(accessToken.accessTokenExpiresAt),
97101
access_token: encryptedAccessToken,
98-
refresh_token: encryptedRefreshToken,
102+
refresh_token: refreshToken,
99103
scope,
100104
};
101105

src/grants/refresh_token.grant.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,31 @@ export class RefreshTokenGrant extends AbstractGrant {
4646
}
4747

4848
private async validateOldRefreshToken(request: RequestInterface, clientId: string): Promise<OAuthToken> {
49-
const encryptedRefreshToken = this.getRequestParameter("refresh_token", request);
49+
const providedRefreshToken = this.getRequestParameter("refresh_token", request);
5050

51-
if (!encryptedRefreshToken) {
51+
if (!providedRefreshToken) {
5252
throw OAuthException.invalidParameter("refresh_token");
5353
}
5454

5555
let refreshTokenData: any;
56-
57-
try {
58-
refreshTokenData = await this.decrypt(encryptedRefreshToken);
59-
} catch (e) {
60-
if (e instanceof Error && e.message === "invalid signature") {
61-
throw OAuthException.invalidParameter("refresh_token", "Cannot verify the refresh token");
56+
let refreshToken: OAuthToken | null = null;
57+
58+
if (this.options.useOpaqueRefreshTokens) {
59+
refreshToken = await this.tokenRepository.getByRefreshToken(providedRefreshToken);
60+
refreshTokenData = {
61+
refresh_token_id: refreshToken.refreshToken,
62+
client_id: refreshToken.client.id,
63+
expire_time: refreshToken.refreshTokenExpiresAt,
64+
};
65+
} else {
66+
try {
67+
refreshTokenData = await this.decrypt(providedRefreshToken);
68+
} catch (e) {
69+
if (e instanceof Error && e.message === "invalid signature") {
70+
throw OAuthException.invalidParameter("refresh_token", "Cannot verify the refresh token");
71+
}
72+
throw OAuthException.invalidParameter("refresh_token", "Cannot decrypt the refresh token");
6273
}
63-
throw OAuthException.invalidParameter("refresh_token", "Cannot decrypt the refresh token");
6474
}
6575

6676
if (!refreshTokenData?.refresh_token_id) {
@@ -75,7 +85,7 @@ export class RefreshTokenGrant extends AbstractGrant {
7585
throw OAuthException.invalidParameter("refresh_token", "Token has expired");
7686
}
7787

78-
const refreshToken = await this.tokenRepository.getByRefreshToken(refreshTokenData.refresh_token_id);
88+
refreshToken ??= await this.tokenRepository.getByRefreshToken(refreshTokenData.refresh_token_id);
7989

8090
if (await this.tokenRepository.isRefreshTokenRevoked(refreshToken)) {
8191
throw OAuthException.invalidParameter("refresh_token", "Token has been revoked");

test/e2e/grants/auth_code.grant.spec.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,12 +454,20 @@ describe("authorization_code grant", () => {
454454
});
455455
});
456456

457-
describe.each([true, false])("respond to access token request with code (opaqueAuthCodes: %s)", opaqueAuthCodes => {
457+
describe.each([
458+
{ useOpaqueAuthorizationCodes: true, useOpaqueRefreshTokens: true },
459+
{ useOpaqueAuthorizationCodes: true, useOpaqueRefreshTokens: false },
460+
{ useOpaqueAuthorizationCodes: false, useOpaqueRefreshTokens: true },
461+
{ useOpaqueAuthorizationCodes: false, useOpaqueRefreshTokens: false },
462+
])("respond to access token request with code (%s)", options => {
458463
let authorizationRequest: AuthorizationRequest;
459464
let authorizationCode: string;
460465

461466
beforeEach(async () => {
462-
grant = createGrant({ issuer: "TestIssuer", useOpaqueAuthorizationCodes: opaqueAuthCodes });
467+
grant = createGrant({
468+
issuer: "TestIssuer",
469+
...options,
470+
});
463471

464472
authorizationRequest = new AuthorizationRequest("authorization_code", client, "http://example.com");
465473
authorizationRequest.isAuthorizationApproved = true;
@@ -586,7 +594,7 @@ describe("authorization_code grant", () => {
586594
});
587595

588596
it("is successful without pkce", async () => {
589-
grant = createGrant({ requiresPKCE: false, issuer: "TestIssuer", useOpaqueAuthorizationCodes: opaqueAuthCodes });
597+
grant = createGrant({ requiresPKCE: false, issuer: "TestIssuer", ...options });
590598
authorizationRequest = new AuthorizationRequest("authorization_code", client, "http://example.com");
591599
authorizationRequest.isAuthorizationApproved = true;
592600
authorizationRequest.user = user;

test/e2e/grants/refresh_token.grant.spec.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
inMemoryScopeRepository,
77
} from "../_helpers/in_memory/repository.js";
88
import {
9+
AuthorizationServerOptions,
910
DateInterval,
1011
JwtService,
1112
OAuthClient,
@@ -19,17 +20,17 @@ import {
1920
import { expectTokenResponse } from "./client_credentials.grant.spec.js";
2021
import { DEFAULT_AUTHORIZATION_SERVER_OPTIONS } from "../../../src/options.js";
2122

22-
function createGrant() {
23+
function createGrant(options?: Partial<AuthorizationServerOptions>) {
2324
return new RefreshTokenGrant(
2425
inMemoryClientRepository,
2526
inMemoryAccessTokenRepository,
2627
inMemoryScopeRepository,
2728
new JwtService("secret-key"),
28-
DEFAULT_AUTHORIZATION_SERVER_OPTIONS,
29+
{ ...DEFAULT_AUTHORIZATION_SERVER_OPTIONS, ...options },
2930
);
3031
}
3132

32-
describe("refresh_token grant", () => {
33+
describe.each([true, false])("refresh_token grant (opaqueRefreshTokens: %s)", opaqueRefreshTokens => {
3334
let user: OAuthUser;
3435
let client: OAuthClient;
3536
let accessToken: OAuthToken;
@@ -71,7 +72,7 @@ describe("refresh_token grant", () => {
7172
inMemoryDatabase.clients[client.id] = client;
7273
inMemoryDatabase.tokens[accessToken.accessToken] = accessToken;
7374

74-
grant = createGrant();
75+
grant = createGrant({ useOpaqueRefreshTokens: opaqueRefreshTokens });
7576
});
7677

7778
it("successful with scope", async () => {
@@ -199,7 +200,7 @@ describe("refresh_token grant", () => {
199200
expect(tokenResponse.body.refresh_token).toMatch(REGEX_ACCESS_TOKEN);
200201
});
201202

202-
it("throws for resigned token", async () => {
203+
it.skipIf(opaqueRefreshTokens)("throws for resigned token", async () => {
203204
// arrange
204205
const jwt = new JwtService("different secret");
205206
const bearerResponse = await grant.makeBearerTokenResponse(client, accessToken);
@@ -242,6 +243,10 @@ describe("refresh_token grant", () => {
242243
const tokenResponse = grant.respondToAccessTokenRequest(request, accessTokenTTL);
243244

244245
// assert
245-
await expect(tokenResponse).rejects.toThrowError(/Cannot decrypt the refresh token/);
246+
if (opaqueRefreshTokens) {
247+
await expect(tokenResponse).rejects.toThrowError("token not found");
248+
} else {
249+
await expect(tokenResponse).rejects.toThrowError(/Cannot decrypt the refresh token/);
250+
}
246251
});
247252
});

0 commit comments

Comments
 (0)