Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/docs/Extras/access_tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import { JwtService } from "@jmondi/oauth2-server";

export class MyCustomJwtService extends JwtService {
extraTokenFields(params: ExtraAccessTokenFieldArgs) {
const { user = undefined, client } = params;
const { user = undefined, client, originatingAuthCodeId } = params;
return {
email: user?.email,
originatingAuthCodeId,
myCustomProps: "this will be in the decoded token!",
};
}
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/authorization_server/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The authorization server has a few optional settings with the following default
| `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**. |
| `logger` | LoggerService \| undefined | undefined | Optional logger service to capture debugging information, particularly useful for tracking token operations like revocations. |
| `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. |
| `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. |

```ts
type AuthorizationServerOptions = {
Expand All @@ -31,6 +32,7 @@ type AuthorizationServerOptions = {
authenticateRevoke: boolean;
logger?: LoggerService;
useOpaqueAuthorizationCodes?: boolean;
useOpaqueRefreshTokens?: boolean;
};
```

Expand Down
4 changes: 4 additions & 0 deletions src/authorization_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export interface AuthorizationServerOptions {
* If enabled opaque codes are used instead of JWT-based authorization codes.
*/
useOpaqueAuthorizationCodes?: boolean;
/**
* If enabled opaque tokens are used instead of JWT-based refresh tokens.
*/
useOpaqueRefreshTokens?: boolean;
}

export type EnableableGrants =
Expand Down
10 changes: 7 additions & 3 deletions src/grants/abstract/abstract.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,14 @@ export abstract class AbstractGrant implements GrantInterface {

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

let encryptedRefreshToken: string | undefined = undefined;
let refreshToken: string | undefined = undefined;

if (accessToken.refreshToken) {
encryptedRefreshToken = await this.encryptRefreshToken(client, accessToken, scopes);
if (this.options.useOpaqueRefreshTokens) {
refreshToken = accessToken.refreshToken;
} else {
refreshToken = await this.encryptRefreshToken(client, accessToken, scopes);
}
}

const bearerTokenResponse = new BearerTokenResponse(accessToken);
Expand All @@ -95,7 +99,7 @@ export abstract class AbstractGrant implements GrantInterface {
token_type: "Bearer",
expires_in: getSecondsUntil(accessToken.accessTokenExpiresAt),
access_token: encryptedAccessToken,
refresh_token: encryptedRefreshToken,
refresh_token: refreshToken,
scope,
};

Expand Down
30 changes: 20 additions & 10 deletions src/grants/refresh_token.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,31 @@ export class RefreshTokenGrant extends AbstractGrant {
}

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

if (!encryptedRefreshToken) {
if (!providedRefreshToken) {
throw OAuthException.invalidParameter("refresh_token");
}

let refreshTokenData: any;

try {
refreshTokenData = await this.decrypt(encryptedRefreshToken);
} catch (e) {
if (e instanceof Error && e.message === "invalid signature") {
throw OAuthException.invalidParameter("refresh_token", "Cannot verify the refresh token");
let refreshToken: OAuthToken | null = null;

if (this.options.useOpaqueRefreshTokens) {
refreshToken = await this.tokenRepository.getByRefreshToken(providedRefreshToken);
refreshTokenData = {
refresh_token_id: refreshToken.refreshToken,
client_id: refreshToken.client.id,
expire_time: refreshToken.refreshTokenExpiresAt,
};
} else {
try {
refreshTokenData = await this.decrypt(providedRefreshToken);
} catch (e) {
if (e instanceof Error && e.message === "invalid signature") {
throw OAuthException.invalidParameter("refresh_token", "Cannot verify the refresh token");
}
throw OAuthException.invalidParameter("refresh_token", "Cannot decrypt the refresh token");
}
throw OAuthException.invalidParameter("refresh_token", "Cannot decrypt the refresh token");
}

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

const refreshToken = await this.tokenRepository.getByRefreshToken(refreshTokenData.refresh_token_id);
refreshToken ??= await this.tokenRepository.getByRefreshToken(refreshTokenData.refresh_token_id);

if (await this.tokenRepository.isRefreshTokenRevoked(refreshToken)) {
throw OAuthException.invalidParameter("refresh_token", "Token has been revoked");
Expand Down
14 changes: 11 additions & 3 deletions test/e2e/grants/auth_code.grant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,12 +454,20 @@ describe("authorization_code grant", () => {
});
});

describe.each([true, false])("respond to access token request with code (opaqueAuthCodes: %s)", opaqueAuthCodes => {
describe.each([
{ useOpaqueAuthorizationCodes: true, useOpaqueRefreshTokens: true },
{ useOpaqueAuthorizationCodes: true, useOpaqueRefreshTokens: false },
{ useOpaqueAuthorizationCodes: false, useOpaqueRefreshTokens: true },
{ useOpaqueAuthorizationCodes: false, useOpaqueRefreshTokens: false },
])("respond to access token request with code (%s)", options => {
let authorizationRequest: AuthorizationRequest;
let authorizationCode: string;

beforeEach(async () => {
grant = createGrant({ issuer: "TestIssuer", useOpaqueAuthorizationCodes: opaqueAuthCodes });
grant = createGrant({
issuer: "TestIssuer",
...options,
});

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

it("is successful without pkce", async () => {
grant = createGrant({ requiresPKCE: false, issuer: "TestIssuer", useOpaqueAuthorizationCodes: opaqueAuthCodes });
grant = createGrant({ requiresPKCE: false, issuer: "TestIssuer", ...options });
authorizationRequest = new AuthorizationRequest("authorization_code", client, "http://example.com");
authorizationRequest.isAuthorizationApproved = true;
authorizationRequest.user = user;
Expand Down
17 changes: 11 additions & 6 deletions test/e2e/grants/refresh_token.grant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
inMemoryScopeRepository,
} from "../_helpers/in_memory/repository.js";
import {
AuthorizationServerOptions,
DateInterval,
JwtService,
OAuthClient,
Expand All @@ -19,17 +20,17 @@ import {
import { expectTokenResponse } from "./client_credentials.grant.spec.js";
import { DEFAULT_AUTHORIZATION_SERVER_OPTIONS } from "../../../src/options.js";

function createGrant() {
function createGrant(options?: Partial<AuthorizationServerOptions>) {
return new RefreshTokenGrant(
inMemoryClientRepository,
inMemoryAccessTokenRepository,
inMemoryScopeRepository,
new JwtService("secret-key"),
DEFAULT_AUTHORIZATION_SERVER_OPTIONS,
{ ...DEFAULT_AUTHORIZATION_SERVER_OPTIONS, ...options },
);
}

describe("refresh_token grant", () => {
describe.each([true, false])("refresh_token grant (opaqueRefreshTokens: %s)", opaqueRefreshTokens => {
let user: OAuthUser;
let client: OAuthClient;
let accessToken: OAuthToken;
Expand Down Expand Up @@ -71,7 +72,7 @@ describe("refresh_token grant", () => {
inMemoryDatabase.clients[client.id] = client;
inMemoryDatabase.tokens[accessToken.accessToken] = accessToken;

grant = createGrant();
grant = createGrant({ useOpaqueRefreshTokens: opaqueRefreshTokens });
});

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

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

// assert
await expect(tokenResponse).rejects.toThrowError(/Cannot decrypt the refresh token/);
if (opaqueRefreshTokens) {
await expect(tokenResponse).rejects.toThrowError("token not found");
} else {
await expect(tokenResponse).rejects.toThrowError(/Cannot decrypt the refresh token/);
}
});
});