From 82c5956d6bf7487fac341193337fb9761738026a Mon Sep 17 00:00:00 2001 From: Jason Raimondi Date: Sun, 4 Aug 2024 20:55:13 -0400 Subject: [PATCH] feat: support token introspection with client credentials auth --- docs/docs/getting_started/endpoints.mdx | 24 ++- src/authorization_server.ts | 25 +++ src/grants/abstract/abstract.grant.ts | 82 +++++++- src/grants/abstract/grant.interface.ts | 4 + src/grants/client_credentials.grant.ts | 4 + src/repositories/access_token.repository.ts | 2 + .../abstract_grant/introspection.spec.ts | 194 ++++++++++++++++++ 7 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 test/e2e/grants/abstract_grant/introspection.spec.ts diff --git a/docs/docs/getting_started/endpoints.mdx b/docs/docs/getting_started/endpoints.mdx index f797f177..fe9611d5 100644 --- a/docs/docs/getting_started/endpoints.mdx +++ b/docs/docs/getting_started/endpoints.mdx @@ -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" ::: @@ -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; + } +}); +``` diff --git a/src/authorization_server.ts b/src/authorization_server.ts index f5a3aa68..07e6038c 100644 --- a/src/authorization_server.ts +++ b/src/authorization_server.ts @@ -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 = {}; public readonly grantTypeAccessTokenTTL: Record = {}; @@ -205,6 +220,16 @@ export class AuthorizationServer { return response; } + async introspect(req: RequestInterface): Promise { + 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(grantType: GrantIdentifier): T { return this.enabledGrantTypes[grantType] as T; diff --git a/src/grants/abstract/abstract.grant.ts b/src/grants/abstract/abstract.grant.ts index 632af961..702518bb 100644 --- a/src/grants/abstract/abstract.grant.ts +++ b/src/grants/abstract/abstract.grant.ts @@ -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"; @@ -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; @@ -300,6 +313,65 @@ export abstract class AbstractGrant implements GrantInterface { return new OAuthResponse(); } + canRespondToIntrospectRequest(_request: RequestInterface): boolean { + return false; + } + + async respondToIntrospectRequest(req: RequestInterface): Promise { + 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 { // default: nothing to do, be quiet about it return; diff --git a/src/grants/abstract/grant.interface.ts b/src/grants/abstract/grant.interface.ts index 0d46142e..fb549387 100644 --- a/src/grants/abstract/grant.interface.ts +++ b/src/grants/abstract/grant.interface.ts @@ -31,4 +31,8 @@ export interface GrantInterface { canRespondToRevokeRequest(request: RequestInterface): boolean; respondToRevokeRequest(request: RequestInterface): Promise; + + canRespondToIntrospectRequest(request: RequestInterface): boolean; + + respondToIntrospectRequest(request: RequestInterface): Promise; } diff --git a/src/grants/client_credentials.grant.ts b/src/grants/client_credentials.grant.ts index bdb19c52..c8afc3db 100644 --- a/src/grants/client_credentials.grant.ts +++ b/src/grants/client_credentials.grant.ts @@ -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; + } } diff --git a/src/repositories/access_token.repository.ts b/src/repositories/access_token.repository.ts index 77d6670e..892bca68 100644 --- a/src/repositories/access_token.repository.ts +++ b/src/repositories/access_token.repository.ts @@ -17,4 +17,6 @@ export interface OAuthTokenRepository { isRefreshTokenRevoked(refreshToken: OAuthToken): Promise; getByRefreshToken(refreshTokenToken: string): Promise; + + getByAccessToken?(accessTokenToken: string): Promise; } diff --git a/test/e2e/grants/abstract_grant/introspection.spec.ts b/test/e2e/grants/abstract_grant/introspection.spec.ts new file mode 100644 index 00000000..dd5b8c8e --- /dev/null +++ b/test/e2e/grants/abstract_grant/introspection.spec.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import jwt from "jsonwebtoken"; + +import { inMemoryDatabase } from "../../_helpers/in_memory/database.js"; +import { + inMemoryAccessTokenRepository, + inMemoryClientRepository, + inMemoryScopeRepository, +} from "../../_helpers/in_memory/repository.js"; +import { DEFAULT_AUTHORIZATION_SERVER_OPTIONS } from "../../../../src/options.js"; +import { + OAuthRequest, + ParsedAccessToken, + ParsedRefreshToken, + base64encode, + ClientCredentialsGrant, + JwtService, + OAuthClient, + OAuthToken, +} from "../../../../src/index.js"; +import { DateInterval } from "@jmondi/oauth2-server"; + +function createGrant() { + return new ClientCredentialsGrant( + inMemoryClientRepository, + inMemoryAccessTokenRepository, + inMemoryScopeRepository, + new JwtService("secret-key"), + DEFAULT_AUTHORIZATION_SERVER_OPTIONS, + ); +} + +describe("introspect", () => { + let client: OAuthClient = { + id: "1", + name: "test client", + secret: "super-secret-secret", + redirectUris: ["http://localhost"], + allowedGrants: ["client_credentials"], + scopes: [], + }; + const basicAuth = "Basic " + base64encode(`${client.id}:${client.secret}`); + let grant: ClientCredentialsGrant; + + let accessToken: OAuthToken; + let request: OAuthRequest; + + const accessTokenJWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imphc29uQHJhaW1vbmRpLnVzIiwiY2xpZW50IjoiU3ZlbHRlIEtpdCIsImNpZCI6IjE2YzExODEyLTg5ZGEtNGQ2OC05ZTljLTc3MTUzMjNlMzRmNSIsInNjb3BlIjoiIiwic3ViIjoiMDE5MGVmZTctNzUwMy03ZGQyLTg1MTYtNjM3NWZkNWRlODhiIiwiZXhwIjoxNzIyNTY5NDQ2LCJuYmYiOjE3MjI1NjU4NDYsImlhdCI6MTcyMjU2NTg0NiwianRpIjoiZDcxZTI3ZDdiMWNhNDczZDMxNWJiYzk1NTM0ODg4YTgwNzQ5NTdiNWNiODJkOWE3N2QzODY2ODliNTQ5NzA2MjZlYjM3N2UyYmMwZjlkZGMifQ.HsHqJOjCFt6KiT6H1y13QbMxUljqkFaFVT0WPxrO25Q"; + const parsedAccessToken = jwt.decode(accessTokenJWT) as ParsedAccessToken; + const refreshTokenJWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiIxNmMxMTgxMi04OWRhLTRkNjgtOWU5Yy03NzE1MzIzZTM0ZjUiLCJhY2Nlc3NfdG9rZW5faWQiOiJkNzFlMjdkN2IxY2E0NzNkMzE1YmJjOTU1MzQ4ODhhODA3NDk1N2I1Y2I4MmQ5YTc3ZDM4NjY4OWI1NDk3MDYyNmViMzc3ZTJiYzBmOWRkYyIsInJlZnJlc2hfdG9rZW5faWQiOiI5NzQxMDZlNjBiZDk0YTU5MzE0YzMxMzY5ZDlhZDg0ZWYwNTU3MGFiZmQ3N2JmYWI0YmUxMGYzMmY5MDQxZDBlMmRmMzE2YmY2MTM5ZjJiOCIsInNjb3BlIjoiIiwidXNlcl9pZCI6IjAxOTBlZmU3LTc1MDMtN2RkMi04NTE2LTYzNzVmZDVkZTg4YiIsImV4cGlyZV90aW1lIjoxNzIyNTczMDQ3LCJpYXQiOjE3MjI1NjU4NDZ9.vpPKS9grMO5gIUQJI2ss525bwxNez9Xo0Rv6Y10DSqY"; + const parsedRefreshToken = jwt.decode(refreshTokenJWT) as ParsedRefreshToken; + + beforeEach(() => { + grant = createGrant(); + inMemoryDatabase.clients[client.id] = client; + }); + + describe("with valid auth", () => { + beforeEach(() => { + request = new OAuthRequest({ + headers: {}, + }); + }); + + it("throws when missing grant_type", async () => { + request.headers = { authorization: basicAuth }; + request.body = {}; + + await expect(grant.introspect(request)).rejects.toThrowError(/Check the `grant_type` parameter/i); + }); + + it("throws when missing client id and secret", async () => { + request.body = { grant_type: "client_credentials" }; + + await expect(grant.introspect(request)).rejects.toThrowError(/Check the `client_id` parameter/i); + }); + }); + + describe("with valid auth", () => { + beforeEach(() => { + request = new OAuthRequest({ + headers: { + authorization: basicAuth, + }, + }); + }); + + it("throws when missing token param", async () => { + request.body = { grant_type: "client_credentials" }; + + await expect(grant.introspect(request)).rejects.toThrowError(/Missing `token` parameter in request body/i); + }); + + it("throws when access token and repository does not support it", async () => { + request.body = { + grant_type: "client_credentials", + token: accessTokenJWT, + }; + + await expect(grant.introspect(request)).rejects.toThrowError( + /Token introspection for access tokens is not supported/i, + ); + }); + + it("succeeds by access token", async () => { + accessToken = { + accessToken: parsedAccessToken.jti, + accessTokenExpiresAt: DateInterval.getDateEnd("1h"), + client, + scopes: [], + }; + inMemoryDatabase.tokens[accessToken.accessToken] = accessToken; + inMemoryAccessTokenRepository.getByAccessToken = (token: string) => + Promise.resolve(inMemoryDatabase.tokens[token]); + + request.body = { + token: accessTokenJWT, + token_type_hint: "access_token", + grant_type: "client_credentials", + }; + const response = await grant.introspect(request); + + expect(response.body.active).toBe(true); + expect(response.body.cid).toBe("16c11812-89da-4d68-9e9c-7715323e34f5"); + expect(response.body.client).toBe("Svelte Kit"); + expect(response.body.client_id).toBe("1"); + expect(response.body.exp).toBe(1722569446); + expect(response.body.iat).toBe(1722565846); + expect(response.body.jti).toBe( + "d71e27d7b1ca473d315bbc95534888a8074957b5cb82d9a77d386689b54970626eb377e2bc0f9ddc", + ); + expect(response.body.nbf).toBe(1722565846); + expect(response.body.scope).toBe(""); + expect(response.body.sub).toBe("0190efe7-7503-7dd2-8516-6375fd5de88b"); + expect(response.body.token_type).toBe("access_token"); + }); + + it("succeeds when access token is expired", async () => { + accessToken = { + accessToken: parsedAccessToken.jti, + accessTokenExpiresAt: new Date(0), + client, + scopes: [], + }; + inMemoryDatabase.tokens[accessToken.accessToken] = accessToken; + inMemoryAccessTokenRepository.getByAccessToken = (token: string) => + Promise.resolve(inMemoryDatabase.tokens[token]); + + request.body = { + token: accessTokenJWT, + token_type_hint: "access_token", + grant_type: "client_credentials", + }; + const response = await grant.introspect(request); + + expect(response.body.active).toBe(false); + }); + + it("succeeds by refresh token", async () => { + accessToken = { + accessToken: "176aa0a5-acc7-4ef7-8ff3-17cace20f83e", + accessTokenExpiresAt: DateInterval.getDateEnd("1h"), + refreshToken: parsedRefreshToken.refresh_token_id, + refreshTokenExpiresAt: DateInterval.getDateEnd("1h"), + client, + scopes: [], + }; + inMemoryDatabase.tokens[accessToken.accessToken] = accessToken; + + request.body = { + token: refreshTokenJWT, + token_type_hint: "refresh_token", + grant_type: "client_credentials", + }; + const response = await grant.introspect(request); + + expect(response.body.access_token_id).toBe( + "d71e27d7b1ca473d315bbc95534888a8074957b5cb82d9a77d386689b54970626eb377e2bc0f9ddc", + ); + expect(response.body.active).toBe(true); + expect(response.body.client_id).toBe("16c11812-89da-4d68-9e9c-7715323e34f5"); + expect(response.body.expire_time).toBe(1722573047); + expect(response.body.iat).toBe(1722565846); + expect(response.body.refresh_token_id).toBe( + "974106e60bd94a59314c31369d9ad84ef05570abfd77bfab4be10f32f9041d0e2df316bf6139f2b8", + ); + expect(response.body.scope).toBe(""); + expect(response.body.token_type).toBe("refresh_token"); + expect(response.body.user_id).toBe("0190efe7-7503-7dd2-8516-6375fd5de88b"); + }); + }); +});