Skip to content

Commit

Permalink
feat: support token introspection with client credentials auth
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonraimondi committed Aug 5, 2024
1 parent a3a88f7 commit 82c5956
Show file tree
Hide file tree
Showing 7 changed files with 329 additions and 6 deletions.
24 changes: 23 additions & 1 deletion docs/docs/getting_started/endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ app.get("/authorize", async (req: Express.Request, res: Express.Response) => {

:::info Note

Implementing this endpoint is optional, but recommended. RFC7009 OAuth 2.0 Token Revocation
Implementing this endpoint is optional, but recommended. RFC7009 "OAuth 2.0 Token Revocation"

:::

Expand All @@ -94,3 +94,25 @@ app.post("/token/revoke", async (req: Express.Request, res: Express.Response) =>
}
});
```

## The Introspect Endpoint

:::info Note

Implementing this endpoint is optional. RFC7662 "OAuth 2.0 Token Introspection"

:::

The `/token/introspect` endpoint is a back channel endpoint that revokes an existing token.

```ts
app.post("/token/introspect", async (req: Express.Request, res: Express.Response) => {
try {
const oauthResponse = await authorizationServer.introspect(req);
return handleExpressResponse(res, oauthResponse);
} catch (e) {
handleExpressError(e, res);
return;
}
});
```
25 changes: 25 additions & 0 deletions src/authorization_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ export type EnableableGrants =
};
export type EnableGrant = EnableableGrants | [EnableableGrants, DateInterval];

export type OAuthTokenIntrospectionResponse = {
active: boolean;
scope?: string;
client_id?: string;
username?: string;
token_type?: string;
exp?: number;
iat?: number;
nbf?: number;
sub?: string;
aud?: string | string[];
iss?: string;
jti?: string;
};

export class AuthorizationServer {
public readonly enabledGrantTypes: Record<string, GrantInterface> = {};
public readonly grantTypeAccessTokenTTL: Record<string, DateInterval> = {};
Expand Down Expand Up @@ -205,6 +220,16 @@ export class AuthorizationServer {
return response;
}

async introspect(req: RequestInterface): Promise<ResponseInterface> {
for (const grantType of Object.values(this.enabledGrantTypes)) {
if (grantType.canRespondToIntrospectRequest(req)) {
return grantType.respondToIntrospectRequest(req);
}
}

throw OAuthException.unsupportedGrantType();
}

// I am only using this in testing... should it be here?
getGrant<T extends GrantInterface>(grantType: GrantIdentifier): T {
return this.enabledGrantTypes[grantType] as T;
Expand Down
82 changes: 77 additions & 5 deletions src/grants/abstract/abstract.grant.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthorizationServerOptions } from "../../authorization_server.js";
import { AuthorizationServerOptions, OAuthTokenIntrospectionResponse } from "../../authorization_server.js";
import { isClientConfidential, OAuthClient } from "../../entities/client.entity.js";
import { OAuthScope } from "../../entities/scope.entity.js";
import { OAuthToken } from "../../entities/token.entity.js";
Expand All @@ -20,18 +20,31 @@ import { ExtraAccessTokenFields, JwtInterface } from "../../utils/jwt.js";
import { getSecondsUntil, roundToSeconds } from "../../utils/time.js";
import { GrantIdentifier, GrantInterface } from "./grant.interface.js";

export interface ITokenData {
iss: undefined;
sub: string | undefined;
aud: undefined;
export interface JwtPayload {
iss?: string;
aud?: string | string[];

// Standard claims
sub?: string;
exp: number;
nbf: number;
iat: number;
jti: string;
}

export interface ParsedAccessToken extends JwtPayload {
// Non-standard claims
cid: string;
scope: string;

// Extra JWT fields (assuming they can be of any type)
[key: string]: unknown;
}
export interface ITokenData extends ParsedAccessToken {}
export interface ParsedRefreshToken extends JwtPayload {
access_token_id: string;
refresh_token_id: string;
}

export abstract class AbstractGrant implements GrantInterface {
protected authCodeRepository?: OAuthAuthCodeRepository;
Expand Down Expand Up @@ -300,6 +313,65 @@ export abstract class AbstractGrant implements GrantInterface {
return new OAuthResponse();
}

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

async respondToIntrospectRequest(req: RequestInterface): Promise<ResponseInterface> {
await this.validateClient(req);

const token = req.body?.["token"];
const tokenTypeHint = req.body?.["token_type_hint"];

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

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

let oauthToken: undefined | OAuthToken = undefined;
let expiresAt = new Date(0);
let tokenType: string = "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 (!this.tokenRepository.getByAccessToken) {
throw OAuthException.internalServerError("Token introspection for access tokens is not supported");
}
oauthToken = await this.tokenRepository.getByAccessToken(parsedToken.jti!);
expiresAt = oauthToken.accessTokenExpiresAt ?? expiresAt;
} 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;
}

private isAccessTokenPayload(token: unknown): token is ParsedAccessToken {
return typeof token === "object" && token !== null && "jti" in token;
}

private isRefreshTokenPayload(token: unknown): token is ParsedRefreshToken {
return typeof token === "object" && token !== null && "refresh_token_id" in token;
}

protected async doRevoke(_encryptedToken: string): Promise<void> {
// default: nothing to do, be quiet about it
return;
Expand Down
4 changes: 4 additions & 0 deletions src/grants/abstract/grant.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ export interface GrantInterface {
canRespondToRevokeRequest(request: RequestInterface): boolean;

respondToRevokeRequest(request: RequestInterface): Promise<ResponseInterface>;

canRespondToIntrospectRequest(request: RequestInterface): boolean;

respondToIntrospectRequest(request: RequestInterface): Promise<ResponseInterface>;
}
4 changes: 4 additions & 0 deletions src/grants/client_credentials.grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ export class ClientCredentialsGrant extends AbstractGrant {

return await this.makeBearerTokenResponse(client, accessToken, validScopes, jwtExtras);
}

canRespondToIntrospectRequest(request: RequestInterface): boolean {
return this.getRequestParameter("grant_type", request) === this.identifier;
}
}
2 changes: 2 additions & 0 deletions src/repositories/access_token.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export interface OAuthTokenRepository {
isRefreshTokenRevoked(refreshToken: OAuthToken): Promise<boolean>;

getByRefreshToken(refreshTokenToken: string): Promise<OAuthToken>;

getByAccessToken?(accessTokenToken: string): Promise<OAuthToken>;
}
Loading

0 comments on commit 82c5956

Please sign in to comment.