-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: adaptive-beaver <adaptive.beaver@gmail.com>
- Loading branch information
1 parent
0561e47
commit 9194586
Showing
1 changed file
with
119 additions
and
77 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
} | ||
} |