Skip to content

Commit

Permalink
fix: revocation inconsistencies and match oauth spec rfc7009
Browse files Browse the repository at this point in the history
- requires client credentials authentication over the revoke endpoint
- properly support token_type_hint access_token and revoke_token
  - previously only token_type_hint refresh_token was supported
- extends the token_type_hint parameter to support 'auth_code' revocation
  • Loading branch information
jasonraimondi committed Aug 9, 2024
1 parent 3b4e9b7 commit 76ca09c
Show file tree
Hide file tree
Showing 11 changed files with 246 additions and 201 deletions.
16 changes: 3 additions & 13 deletions src/authorization_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,23 +201,13 @@ export class AuthorizationServer {
}

async revoke(req: RequestInterface): Promise<ResponseInterface> {
const tokenTypeHint = req.body?.["token_type_hint"];
let response;

for (const grantType of Object.values(this.enabledGrantTypes)) {
// As per https://www.rfc-editor.org/rfc/rfc7009#section-2.1, the `token_type_hint` field is optional, and in
// case we MUST extend our search across all supported token types.
if (!tokenTypeHint || grantType.canRespondToRevokeRequest(req)) {
response = grantType.respondToRevokeRequest(req);
if (grantType.canRespondToRevokeRequest(req)) {
return grantType.respondToRevokeRequest(req);
}
}

if (!response) {
// token_type_hint must have been specified, but none of our grant types handled it
throw OAuthException.unsupportedGrantType();
}

return response;
throw OAuthException.unsupportedGrantType();
}

async introspect(req: RequestInterface): Promise<ResponseInterface> {
Expand Down
30 changes: 9 additions & 21 deletions src/grants/abstract/abstract.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { OAuthUserRepository } from "../../repositories/user.repository.js";
import { AuthorizationRequest } from "../../requests/authorization.request.js";
import { RequestInterface } from "../../requests/request.js";
import { BearerTokenResponse } from "../../responses/bearer_token.response.js";
import { OAuthResponse, ResponseInterface } from "../../responses/response.js";
import { ResponseInterface } from "../../responses/response.js";
import { arrayDiff } from "../../utils/array.js";
import { base64decode } from "../../utils/base64.js";
import { DateInterval } from "../../utils/date_interval.js";
Expand Down Expand Up @@ -290,8 +290,12 @@ export abstract class AbstractGrant implements GrantInterface {
return false;
}

canRespondToRevokeRequest(request: RequestInterface): boolean {
return this.getRequestParameter("token_type_hint", request) === this.identifier;
canRespondToRevokeRequest(_request: RequestInterface): boolean {
return false;
}

canRespondToIntrospectRequest(_request: RequestInterface): boolean {
return false;
}

async completeAuthorizationRequest(_authorizationRequest: AuthorizationRequest): Promise<ResponseInterface> {
Expand All @@ -302,30 +306,14 @@ export abstract class AbstractGrant implements GrantInterface {
throw new Error("Grant does not support the request");
}

async respondToRevokeRequest(request: RequestInterface): Promise<ResponseInterface> {
const encryptedToken = this.getRequestParameter("token", request);

if (!encryptedToken) {
throw OAuthException.invalidParameter("token");
}

await this.doRevoke(encryptedToken);
return new OAuthResponse();
}

canRespondToIntrospectRequest(_request: RequestInterface): boolean {
return false;
async respondToRevokeRequest(_request: RequestInterface): Promise<ResponseInterface> {
throw new Error("Grant does not support the request");
}

async respondToIntrospectRequest(_req: RequestInterface): Promise<ResponseInterface> {
throw new Error("Grant does not support the request");
}

protected async doRevoke(_encryptedToken: string): Promise<void> {
// default: nothing to do, be quiet about it
return;
}

protected async extraJwtFields(
req: RequestInterface,
client: OAuthClient,
Expand Down
58 changes: 38 additions & 20 deletions src/grants/auth_code.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import { OAuthUserRepository } from "../repositories/user.repository.js";
import { AuthorizationRequest } from "../requests/authorization.request.js";
import { RequestInterface } from "../requests/request.js";
import { RedirectResponse } from "../responses/redirect.response.js";
import { ResponseInterface } from "../responses/response.js";
import { OAuthResponse, ResponseInterface } from "../responses/response.js";
import { DateInterval } from "../utils/date_interval.js";
import { JwtInterface } from "../utils/jwt.js";
import { AbstractAuthorizedGrant } from "./abstract/abstract_authorized.grant.js";
import { GrantIdentifier } from "./abstract/grant.interface.js";
import { AuthorizationServerOptions } from "../authorization_server.js";

export interface IAuthCodePayload {
export interface PayloadAuthCode {
client_id: string;
auth_code_id: string;
expire_time: number;
Expand All @@ -32,6 +32,8 @@ export interface IAuthCodePayload {
code_challenge_method?: CodeChallengeMethod | null;
audience?: string[] | string | null;
}
/** @deprecated use `PayloadAuthCode` instead */
export interface IAuthCodePayload extends PayloadAuthCode {}

export const REGEXP_CODE_VERIFIER = /^[A-Za-z0-9-._~]{43,128}$/;

Expand Down Expand Up @@ -250,24 +252,6 @@ export class AuthCodeGrant extends AbstractAuthorizedGrant {
return new RedirectResponse(finalRedirectUri);
}

async doRevoke(encryptedToken: string): Promise<void> {
let decryptedCode: any;

try {
decryptedCode = await this.decrypt(encryptedToken);
} catch (e) {
return;
}

if (!decryptedCode?.auth_code_id) {
return;
}

await this.authCodeRepository.revoke(decryptedCode.auth_code_id);

return;
}

private async validateAuthorizationCode(payload: any, client: OAuthClient, request: RequestInterface) {
if (!payload.auth_code_id) {
throw OAuthException.invalidParameter("code", "Authorization code malformed");
Expand Down Expand Up @@ -322,4 +306,38 @@ export class AuthCodeGrant extends AbstractAuthorizedGrant {
await this.authCodeRepository.persist(authCode);
return authCode;
}

canRespondToRevokeRequest(request: RequestInterface): boolean {
return this.getRequestParameter("token_type_hint", request) === "auth_code";
}

async respondToRevokeRequest(req: RequestInterface): Promise<ResponseInterface> {
req.body["grant_type"] = this.identifier;

await this.validateClient(req);

const token = this.getRequestParameter("token", req);

if (!token) {
throw OAuthException.invalidParameter("token", "Missing `token` parameter in request body");
}

const parsedCode: unknown = this.jwt.decode(token);

if (!this.isAuthCodePayload(parsedCode)) {
throw OAuthException.invalidParameter("token", "Token does not contain valid auth code payload");
}

try {
await this.authCodeRepository.revoke(parsedCode.auth_code_id);
return new OAuthResponse();
} catch (e) {
const message = e instanceof Error ? e.message : "";
throw OAuthException.invalidParameter("token", `Error during token revocation${message}`);
}
}

private isAuthCodePayload(code: unknown): code is PayloadAuthCode {
return typeof code === "object" && code !== null && "auth_code_id" in code;
}
}
61 changes: 39 additions & 22 deletions src/grants/client_credentials.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,44 @@ export class ClientCredentialsGrant extends AbstractGrant {
}

async respondToIntrospectRequest(req: RequestInterface): Promise<ResponseInterface> {
// introspection is authenticated via client_credentials, but we don't want to require this param in the request
req.body["grant_type"] = "client_credentials";
const { parsedToken, oauthToken, expiresAt, tokenType } = await this.tokenFromRequest(req);

const active = expiresAt > new Date();

const responseBody: OAuthTokenIntrospectionResponse = active
? {
active: true,
scope: oauthToken.scopes.map(s => s.name).join(this.options.scopeDelimiter),
client_id: oauthToken.client.id,
token_type: tokenType,
...parsedToken,
}
: { active: false };

const response = new OAuthResponse();
response.body = responseBody;
return response;
}

canRespondToRevokeRequest(request: RequestInterface): boolean {
return this.getRequestParameter("token_type_hint", request) !== "auth_code";
}

async respondToRevokeRequest(req: RequestInterface): Promise<ResponseInterface> {
let { oauthToken } = await this.tokenFromRequest(req);

await this.tokenRepository.revoke(oauthToken);

return new OAuthResponse();
}

private async tokenFromRequest(req: RequestInterface) {
req.body["grant_type"] = this.identifier;

await this.validateClient(req);

const token = req.body?.["token"];
const tokenTypeHint = req.body?.["token_type_hint"];
const token = this.getRequestParameter("token", req);
const tokenTypeHint = this.getRequestParameter("token_type_hint", req);

if (!token) {
throw OAuthException.invalidParameter("token", "Missing `token` parameter in request body");
Expand All @@ -45,15 +77,15 @@ export class ClientCredentialsGrant extends AbstractGrant {

let oauthToken: undefined | OAuthToken = undefined;
let expiresAt = new Date(0);
let tokenType: string = "access_token";
let tokenType: "access_token" | "refresh_token" = "access_token";

if (tokenTypeHint === "refresh_token" && this.isRefreshTokenPayload(parsedToken)) {
oauthToken = await this.tokenRepository.getByRefreshToken(parsedToken.refresh_token_id);
expiresAt = oauthToken.refreshTokenExpiresAt ?? expiresAt;
tokenType = "refresh_token";
} else if (this.isAccessTokenPayload(parsedToken)) {
if (typeof this.tokenRepository.getByAccessToken !== "function") {
throw OAuthException.internalServerError("Token introspection for access tokens is not supported");
throw OAuthException.internalServerError("TokenRepository#getByAccessToken is not implemented");
}
oauthToken = await this.tokenRepository.getByAccessToken(parsedToken.jti!);
if (!oauthToken) {
Expand All @@ -63,22 +95,7 @@ export class ClientCredentialsGrant extends AbstractGrant {
} else {
throw OAuthException.invalidParameter("token", "Invalid token provided");
}

const active = expiresAt > new Date();

const responseBody: OAuthTokenIntrospectionResponse = active
? {
active: true,
scope: oauthToken.scopes.map(s => s.name).join(this.options.scopeDelimiter),
client_id: oauthToken.client.id,
token_type: tokenType,
...parsedToken,
}
: { active: false };

const response = new OAuthResponse();
response.body = responseBody;
return response;
return { parsedToken, oauthToken, expiresAt, tokenType };
}

private isAccessTokenPayload(token: unknown): token is ParsedAccessToken {
Expand Down
28 changes: 0 additions & 28 deletions src/grants/refresh_token.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,32 +79,4 @@ export class RefreshTokenGrant extends AbstractGrant {

return refreshToken;
}

async doRevoke(encryptedToken: string): Promise<void> {
let refreshTokenData: any;

try {
refreshTokenData = await this.decrypt(encryptedToken);
} catch (e) {
return;
}

if (!refreshTokenData?.refresh_token_id) {
return;
}

if (Date.now() / 1000 > refreshTokenData?.expire_time) {
return;
}

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

if (await this.tokenRepository.isRefreshTokenRevoked(refreshToken)) {
return;
}

await this.tokenRepository.revoke(refreshToken);

return;
}
}
1 change: 0 additions & 1 deletion src/repositories/auth_code.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,5 @@ export interface OAuthAuthCodeRepository {
persist(authCode: OAuthAuthCode): Promise<void>;

isRevoked(authCodeCode: string): Promise<boolean>;

revoke(authCodeCode: string): Promise<void>;
}
9 changes: 7 additions & 2 deletions test/e2e/_helpers/in_memory/oauth_authorization_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ const tokenRepository = inMemoryAccessTokenRepository;
const scopeRepository = inMemoryScopeRepository;
const userRepository = inMemoryUserRepository;

const jwtService = new JwtService("secret secret secret");
export const testingJwtService = new JwtService("secret secret secret");

const authorizationServer = new AuthorizationServer(clientRepository, tokenRepository, scopeRepository, jwtService);
const authorizationServer = new AuthorizationServer(
clientRepository,
tokenRepository,
scopeRepository,
testingJwtService,
);

authorizationServer.enableGrantType(
{ grant: "authorization_code", authCodeRepository, userRepository },
Expand Down
3 changes: 3 additions & 0 deletions test/e2e/_helpers/in_memory/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export const inMemoryAccessTokenRepository: OAuthTokenRepository = {
if (!token) throw new Error("token not found");
return token;
},
async getByAccessToken(accessToken: string): Promise<OAuthToken> {
return inMemoryDatabase.tokens[accessToken];
},
async isRefreshTokenRevoked(token: OAuthToken): Promise<boolean> {
return Date.now() > (token.refreshTokenExpiresAt?.getTime() ?? 0);
},
Expand Down
Loading

0 comments on commit 76ca09c

Please sign in to comment.