Skip to content

Commit

Permalink
Merge pull request #1 from khilkovetsmykhailo/hotfix/strategy
Browse files Browse the repository at this point in the history
Update strategy
  • Loading branch information
khilkovetsmykhailo authored Nov 13, 2024
2 parents 41df229 + d99f79d commit 6b4a440
Showing 1 changed file with 119 additions and 77 deletions.
196 changes: 119 additions & 77 deletions src/strategy.ts
Original file line number Diff line number Diff line change
@@ -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<IntrospectionResponse>;
private tokenIntrospector?: (token: string) => Promise<IntrospectionResponse>;

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<ZitadelIntrospectionStrategy> {
const issuer = await Issuer.discover(options.discoveryEndpoint ?? options.authority);
public static async create(
options: ZitadelIntrospectionOptions
): Promise<ZitadelIntrospectionStrategy> {
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<ParamsDictionary, unknown, unknown, ParsedQs, Record<string, any>>) {
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<string, any>
>
): Promise<void> {
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<IntrospectionResponse>
> {
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<Record<string, string>> => {
if (this.options.authorization.type === 'basic') {
const getPayload = async (
token: string
): Promise<Record<string, string>> => {
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<IntrospectionResponse> => {
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<string> {
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<string, string>) {
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
);
}
}

0 comments on commit 6b4a440

Please sign in to comment.