From 9194586484204c08c6fcad6eba96ffea09b27577 Mon Sep 17 00:00:00 2001 From: khilkovetsmykhailo Date: Tue, 12 Nov 2024 19:58:56 -0500 Subject: [PATCH] Update strategy Co-authored-by: adaptive-beaver --- src/strategy.ts | 196 +++++++++++++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 77 deletions(-) diff --git a/src/strategy.ts b/src/strategy.ts index c79485c..8d67d36 100644 --- a/src/strategy.ts +++ b/src/strategy.ts @@ -1,148 +1,190 @@ -import axios from 'axios'; -import { Request } from 'express'; -import { ParamsDictionary } from 'express-serve-static-core'; -import { importPKCS8, SignJWT } from 'jose'; -import NodeRSA from 'node-rsa'; -import { IntrospectionResponse, Issuer } from 'openid-client'; -import { Strategy } from 'passport'; -import { ParsedQs } from 'qs'; - -type ZitadelJwtProfile = { - type: 'application'; +import axios from "axios"; +import { Request } from "express"; +import { ParamsDictionary } from "express-serve-static-core"; +import { importPKCS8, SignJWT } from "jose"; +import NodeRSA from "node-rsa"; +import { IntrospectionResponse, Issuer } from "openid-client"; +import { Strategy } from "passport"; +import { ParsedQs } from "qs"; + +interface ZitadelJwtProfile { + type: "application"; keyId: string; key: string; appId: string; clientId: string; -}; +} -type EndpointAuthorization = +type AuthorizationConfig = | { - type: 'basic'; + type: "basic"; clientId: string; clientSecret: string; } | { - type: 'jwt-profile'; + type: "jwt-profile"; profile: ZitadelJwtProfile; }; -export type ZitadelIntrospectionOptions = { +export interface ZitadelIntrospectionOptions { authority: string; - authorization: EndpointAuthorization; + authorization: AuthorizationConfig; discoveryEndpoint?: string; introspectionEndpoint?: string; issuer?: Issuer; -}; +} export class ZitadelIntrospectionStrategy extends Strategy { - name = 'zitadel-introspection'; + public name = "zitadel-introspection"; private issuer: Issuer | undefined; private introspectionEndpoint: string; - private introspect?: (token: string) => Promise; + private tokenIntrospector?: (token: string) => Promise; constructor(private readonly options: ZitadelIntrospectionOptions) { super(); this.issuer = options.issuer; - this.introspectionEndpoint = options.introspectionEndpoint || ''; + this.introspectionEndpoint = options.introspectionEndpoint || ""; } - public static async create(options: ZitadelIntrospectionOptions): Promise { - const issuer = await Issuer.discover(options.discoveryEndpoint ?? options.authority); + public static async create( + options: ZitadelIntrospectionOptions + ): Promise { + const issuer = await Issuer.discover( + options.discoveryEndpoint ?? options.authority + ); options.issuer = issuer; - const strategy = new ZitadelIntrospectionStrategy(options); - - return strategy; + return new ZitadelIntrospectionStrategy(options); } - private get clientId() { - if (this.options.authorization.type === 'basic') { - return this.options.authorization.clientId; - } - - return this.options.authorization.profile.clientId; + private get clientId(): string { + return this.options.authorization.type === "basic" + ? this.options.authorization.clientId + : this.options.authorization.profile.clientId; } - async authenticate(req: Request>) { - if (!req.headers?.authorization || req.headers?.authorization?.toLowerCase().startsWith('bearer ') === false) { - this.fail({ message: 'No bearer token found in authorization header' }); + async authenticate( + req: Request< + ParamsDictionary, + unknown, + unknown, + ParsedQs, + Record + > + ): Promise { + const authHeader = req.headers?.authorization; + if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) { + this.fail({ message: "No bearer token found in authorization header" }); return; } - this.introspect ??= await this.getIntrospecter(); + this.tokenIntrospector ??= await this.createTokenIntrospector(); - const token = req.headers.authorization.substring(7); + const token = authHeader.substring(7); try { - const result = await this.introspect(token); + const result = await this.tokenIntrospector(token); if (!result.active) { - this.fail({ message: 'Token is not active' }); + this.fail({ message: "Token is not active" }); return; } this.success(result); - } catch (e) { - (this.error ?? console.error)(e); + } catch (error) { + (this.error ?? console.error)(error); } } - private async getIntrospecter() { + private async createTokenIntrospector(): Promise< + (token: string) => Promise + > { if (!this.introspectionEndpoint) { if (!this.issuer) { - this.issuer = await Issuer.discover(this.options.discoveryEndpoint ?? this.options.authority); + this.issuer = await Issuer.discover( + this.options.discoveryEndpoint ?? this.options.authority + ); } - this.introspectionEndpoint = this.issuer.metadata['introspection_endpoint'] as string; + this.introspectionEndpoint = this.issuer.metadata[ + "introspection_endpoint" + ] as string; } - let jwt = ''; + let jwt = ""; let lastCreated = 0; - const getPayload = async (token: string): Promise> => { - if (this.options.authorization.type === 'basic') { + const getPayload = async ( + token: string + ): Promise> => { + if (this.options.authorization.type === "basic") { return { token }; } - // check if the last created time is older than 60 minutes, if so, create a new jwt. - if (lastCreated < Date.now() - 60 * 60 * 1000) { - const rsa = new NodeRSA(this.options.authorization.profile.key); - const key = await importPKCS8(rsa.exportKey('pkcs8-private-pem'), 'RSA256'); - - jwt = await new SignJWT({ - iss: this.clientId, - sub: this.clientId, - aud: this.options.authority, - }) - .setIssuedAt() - .setExpirationTime('1h') - .setProtectedHeader({ - alg: 'RS256', - kid: this.options.authorization.profile.keyId, - }) - .sign(key); + if (this.isJwtExpired(lastCreated)) { + jwt = await this.createNewJwt(); lastCreated = Date.now(); } return { - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion_type: + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", client_assertion: jwt, token, }; }; - return async (token: string) => { + return async (token: string): Promise => { const payload = await getPayload(token); - - const response = await axios.post(this.introspectionEndpoint, new URLSearchParams(payload), { - auth: - this.options.authorization.type === 'basic' - ? { - username: this.options.authorization.clientId, - password: this.options.authorization.clientSecret, - } - : undefined, - }); - + const response = await this.makeIntrospectionRequest(payload); return response.data as IntrospectionResponse; }; } + + private isJwtExpired(lastCreated: number): boolean { + const oneHourInMs = 60 * 60 * 1000; + return lastCreated < Date.now() - oneHourInMs; + } + + private async createNewJwt(): Promise { + if (this.options.authorization.type !== "jwt-profile") { + throw new Error("JWT profile is not configured"); + } + + const { key, keyId } = this.options.authorization.profile; + const rsa = new NodeRSA(key); + const privateKey = await importPKCS8( + rsa.exportKey("pkcs8-private-pem"), + "RSA256" + ); + + return new SignJWT({ + iss: this.clientId, + sub: this.clientId, + aud: this.options.authority, + }) + .setIssuedAt() + .setExpirationTime("1h") + .setProtectedHeader({ + alg: "RS256", + kid: keyId, + }) + .sign(privateKey); + } + + private async makeIntrospectionRequest(payload: Record) { + const config = + this.options.authorization.type === "basic" + ? { + auth: { + username: this.options.authorization.clientId, + password: this.options.authorization.clientSecret, + }, + } + : {}; + + return axios.post( + this.introspectionEndpoint, + new URLSearchParams(payload), + config + ); + } }