@solsdk/jito-ts
Version:
## What is it and why do you need it?
222 lines (196 loc) • 6.54 kB
text/typescript
import * as ed from '@noble/ed25519';
import {
InterceptingCall,
Interceptor,
InterceptorOptions,
Listener,
Metadata,
ServiceError,
} from '@grpc/grpc-js';
import {Keypair} from '@solana/web3.js';
import {NextCall} from '@grpc/grpc-js/build/src/client-interceptors';
import {
AuthServiceClient,
GenerateAuthChallengeRequest,
GenerateAuthChallengeResponse,
GenerateAuthTokensRequest,
GenerateAuthTokensResponse,
RefreshAccessTokenRequest,
RefreshAccessTokenResponse,
Role,
Token,
} from '../../gen/block-engine/auth';
import {unixTimestampFromDate} from './utils';
import {Keypair as KeypairUtils} from '@nealireverse_dev/utils';
// Intercepts requests and sets the auth header.
export const authInterceptor = (authProvider: AuthProvider): Interceptor => {
return (opts: InterceptorOptions, nextCall: NextCall) => {
return new InterceptingCall(nextCall(opts), {
start: async function (metadata: Metadata, listener: Listener, next) {
const callback = (accessToken: Jwt) => {
metadata.set('authorization', `Bearer ${accessToken.token}`);
next(metadata, listener);
};
authProvider.injectAccessToken(callback);
},
});
};
};
// Represents server issued JWT tokens.
export class Jwt {
readonly token: string;
private readonly expiration: number;
constructor(token: string, expiration: number) {
this.token = token;
this.expiration = expiration;
}
isExpired(): boolean {
const now: number = unixTimestampFromDate(new Date());
return this.expiration <= now;
}
}
// Handles requesting and refreshing tokens, providing them via callbacks.
export class AuthProvider {
private client: AuthServiceClient;
private readonly authKeypair: Keypair;
private accessToken: Jwt | undefined;
private refreshToken: Jwt | undefined;
private refreshing: Promise<void> | null = null;
constructor(client: AuthServiceClient, authKeypair: Keypair) {
this.client = client;
this.authKeypair = KeypairUtils.from({ keypair: authKeypair });
}
// Injects the current access token into the provided callback.
// If it's expired then refreshes, if the refresh token is expired then runs the full auth flow.
public injectAccessToken(callback: (accessToken: Jwt) => void) {
if (
!this.accessToken ||
!this.refreshToken ||
this.refreshToken.isExpired()
) {
this.fullAuth((accessToken: Jwt, refreshToken: Jwt) => {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
callback(accessToken);
});
return;
}
if (!this.accessToken?.isExpired()) {
callback(this.accessToken);
return;
}
if (!this.refreshing) {
this.refreshing = this.refreshAccessToken().finally(() => {
this.refreshing = null;
});
}
this.refreshing.then(() => {
if (this.accessToken) {
callback(this.accessToken);
}
});
}
// Refresh access token.
private async refreshAccessToken() {
return new Promise<void>((resolve, reject) => {
this.client.refreshAccessToken(
{
refreshToken: this.refreshToken?.token,
} as RefreshAccessTokenRequest,
async (e: ServiceError | null, resp: RefreshAccessTokenResponse) => {
if (e) {
return reject(e);
}
if (!AuthProvider.isValidToken(resp.accessToken)) {
return reject(`received invalid access token ${resp.accessToken}`);
}
this.accessToken = new Jwt(
resp.accessToken?.value || '',
unixTimestampFromDate(resp.accessToken?.expiresAtUtc || new Date())
);
resolve();
}
);
});
}
// Creates an AuthProvider object, and asynchronously performs full authentication flow.
public static async create(
client: AuthServiceClient,
authKeypair: Keypair
): Promise<AuthProvider> {
const provider = new AuthProvider(client, authKeypair);
await provider.fullAuth((accessToken: Jwt, refreshToken: Jwt) => {
provider.accessToken = accessToken;
provider.refreshToken = refreshToken;
});
return provider;
}
// Run entire auth flow:
// - fetch a server generated challenge
// - sign the challenge and submit in exchange for an access and refresh token
// - inject the tokens into the provided callback
private fullAuth(
callback: (accessToken: Jwt, refreshToken: Jwt) => void
): void {
this.client.generateAuthChallenge(
{
role: Role.SEARCHER,
pubkey: this.authKeypair.publicKey.toBytes(),
} as GenerateAuthChallengeRequest,
async (e: ServiceError | null, resp: GenerateAuthChallengeResponse) => {
if (e) {
throw e;
}
// Append pubkey to ensure what we're signing is garbage.
const challenge = `${this.authKeypair.publicKey.toString()}-${
resp.challenge
}`;
const signedChallenge = await ed.sign(
Buffer.from(challenge),
// First 32 bytes is the private key, last 32 public key.
this.authKeypair.secretKey.slice(0, 32)
);
this.client.generateAuthTokens(
{
challenge,
clientPubkey: this.authKeypair.publicKey.toBytes(),
signedChallenge,
} as GenerateAuthTokensRequest,
(e: ServiceError | null, resp: GenerateAuthTokensResponse) => {
if (e) {
throw e;
}
if (!AuthProvider.isValidToken(resp.accessToken)) {
throw `received invalid access token ${resp.accessToken}`;
}
const accessToken = new Jwt(
resp.accessToken?.value || '',
unixTimestampFromDate(
resp.accessToken?.expiresAtUtc || new Date()
)
);
if (!AuthProvider.isValidToken(resp.refreshToken)) {
throw `received invalid refresh token ${resp.refreshToken}`;
}
const refreshToken = new Jwt(
resp.refreshToken?.value || '',
unixTimestampFromDate(
resp.refreshToken?.expiresAtUtc || new Date()
)
);
callback(accessToken, refreshToken);
}
);
}
);
}
private static isValidToken(token: Token | undefined) {
if (!token) {
return false;
}
if (!token.expiresAtUtc) {
return false;
}
return true;
}
}