@narangcia-oss/cryptic-auth-client-plain-ts
Version:
A TypeScript client for interacting with a cryptic-auth host web server, crafted by Narangcia OSS.
422 lines (384 loc) • 12.3 kB
text/typescript
import axios, {
AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import type {
AuthTokens,
UserCredentials,
SignupResponse,
LoginResponse,
TokenValidationResponse,
OAuthAuthResponse,
OAuthSignupResponse,
OAuthCallbackParams,
AuthConfig,
} from "../types/index";
import { extractTokens, isTokenExpired } from "../utils/tokens";
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_isRetry?: boolean;
headers?: any;
}
/**
* Core authentication client for handling all auth flows
* Handles login, signup, OAuth, token refresh, and validation
*/
export class AuthClient {
private api: AxiosInstance;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private isRefreshing = false;
private failedQueue: {
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
config: CustomAxiosRequestConfig;
}[] = [];
private config: AuthConfig;
constructor(config: AuthConfig) {
console.log("[AuthClient] Initializing with config:", {
...config,
baseURL: config.baseURL,
});
if (!config.baseURL) {
throw new Error("Base URL is required for AuthClient initialization.");
}
this.config = {
enableAutoRefresh: true,
tokenStorage: "memory",
...config,
};
this.api = axios.create({
baseURL: this.config.baseURL,
headers: {
"Content-Type": "application/json",
},
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor: attach access token
this.api.interceptors.request.use(
(config: CustomAxiosRequestConfig): CustomAxiosRequestConfig => {
if (this.accessToken && config.headers) {
console.log("[AuthClient] Attaching access token to request.");
config.headers.Authorization = `Bearer ${this.accessToken}`;
}
return config;
},
(error: unknown): Promise<never> => {
console.error("[AuthClient] Request error:", error);
return Promise.reject(error);
}
);
// Response interceptor: handle automatic token refresh
interface FailedQueueItem {
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
config: CustomAxiosRequestConfig;
}
this.api.interceptors.response.use(
(response: any): any => {
console.log(
"[AuthClient] Response received:",
response.status,
response.config.url
);
return response;
},
async (error: AxiosError): Promise<unknown> => {
const originalRequest: CustomAxiosRequestConfig =
error.config as CustomAxiosRequestConfig;
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._isRetry &&
this.config.enableAutoRefresh
) {
console.warn("[AuthClient] 401 detected, attempting token refresh.");
if (this.isRefreshing) {
console.log(
"[AuthClient] Token refresh already in progress, queueing request."
);
return new Promise(
(
resolve: (value?: unknown) => void,
reject: (reason?: unknown) => void
) => {
this.failedQueue.push({
resolve,
reject,
config: originalRequest,
} as FailedQueueItem);
}
);
}
this.isRefreshing = true;
originalRequest._isRetry = true;
try {
if (!this.refreshToken) {
console.error(
"[AuthClient] No refresh token available, clearing tokens."
);
this.clearTokens();
this.processQueue(null);
return Promise.reject(
new Error("Refresh token missing. Please re-authenticate.")
);
}
console.log("[AuthClient] Attempting to refresh token...");
const response: AuthTokens = await this.refreshTokenFlow(
this.refreshToken
);
const newAccessToken: string = response.access_token;
const newRefreshToken: string = response.refresh_token ?? "";
console.log(
"[AuthClient] Token refresh successful. New access token set."
);
this.setTokens(newAccessToken, newRefreshToken);
this.processQueue(newAccessToken);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
console.log(
"[AuthClient] Retrying original request after token refresh."
);
return this.api(originalRequest);
} catch (refreshError: unknown) {
console.error("[AuthClient] Token refresh failed:", refreshError);
this.clearTokens();
this.processQueue(null, refreshError);
return Promise.reject(refreshError);
} finally {
console.log("[AuthClient] Token refresh process finished.");
this.isRefreshing = false;
}
}
console.error("[AuthClient] Response error:", error);
return Promise.reject(error);
}
);
}
private processQueue(
accessToken: string | null,
error: unknown = null
): void {
console.log(
`[AuthClient] Processing failed request queue. Queue length: ${this.failedQueue.length}`
);
while (this.failedQueue.length) {
const { resolve, reject, config } = this.failedQueue.shift()!;
if (accessToken) {
if (config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
console.log(
"[AuthClient] Retrying queued request with new access token."
);
resolve(this.api(config));
} else {
console.error(
"[AuthClient] Rejecting queued request due to missing access token."
);
reject(
error || new AxiosError("Authentication required", "401", config)
);
}
}
}
public setTokens(accessToken: string, refreshToken?: string): void {
console.log("[AuthClient] Setting tokens.");
this.accessToken = accessToken;
this.refreshToken = refreshToken || null;
}
public clearTokens(): void {
console.log("[AuthClient] Clearing all tokens.");
this.accessToken = null;
this.refreshToken = null;
}
public getAccessToken(): string | null {
return this.accessToken;
}
public getRefreshToken(): string | null {
return this.refreshToken;
}
public isAuthenticated(): boolean {
return this.accessToken !== null;
}
public async login(credentials: UserCredentials): Promise<LoginResponse> {
console.log(
"[AuthClient] login called with username:",
credentials.username
);
try {
const response = await this.api.post<LoginResponse>(
"/login",
credentials
);
console.log("[AuthClient] Login successful for:", credentials.username);
this.setTokens(response.data.access_token, response.data.refresh_token);
return response.data;
} catch (error) {
console.error(
"[AuthClient] Login failed for:",
credentials.username,
error
);
throw error;
}
}
public async signup(credentials: UserCredentials): Promise<SignupResponse> {
console.log(
"[AuthClient] signup called with username:",
credentials.username
);
try {
const response = await this.api.post<SignupResponse>(
"/signup",
credentials
);
console.log("[AuthClient] Signup successful for:", credentials.username);
return response.data;
} catch (error) {
console.error(
"[AuthClient] Signup failed for:",
credentials.username,
error
);
throw error;
}
}
private async refreshTokenFlow(refreshToken: string): Promise<AuthTokens> {
console.log("[AuthClient] refreshTokenFlow called.");
try {
const response = await this.api.post<AuthTokens>("/token/refresh", {
refresh_token: refreshToken,
});
console.log("[AuthClient] Token refresh API call successful.");
return response.data;
} catch (error) {
console.error("[AuthClient] Token refresh API call failed:", error);
throw error;
}
}
public async validateToken(token: string): Promise<TokenValidationResponse> {
console.log("[AuthClient] validateToken called.");
try {
const response = await this.api.post<TokenValidationResponse>(
"/token/validate",
{ token }
);
console.log("[AuthClient] Token validation response:", response.data);
return response.data;
} catch (error) {
console.error("[AuthClient] Token validation failed:", error);
throw error;
}
}
public async healthCheck(): Promise<unknown> {
console.log("[AuthClient] healthCheck called.");
try {
const response = await this.api.get("/health");
console.log("[AuthClient] Health check response:", response.data);
return response.data;
} catch (error) {
console.error("[AuthClient] Health check failed:", error);
throw error;
}
}
public async generateOAuthAuthUrl(
provider: string,
state: string,
scopes: string[]
): Promise<string> {
console.log(
`[AuthClient] generateOAuthAuthUrl called for provider: ${provider}`
);
try {
const response = await this.api.get<OAuthAuthResponse>(
`/oauth/${provider}/auth`,
{
params: {
state,
scopes: scopes.join(" "),
},
}
);
if (response.data && response.data.auth_url) {
console.log(
`[AuthClient] OAuth auth URL generated: ${response.data.auth_url}`
);
return response.data.auth_url;
}
console.error("[AuthClient] Invalid response from OAuth auth endpoint.");
throw new Error("Invalid response from OAuth auth endpoint");
} catch (error) {
console.error(
`[AuthClient] Failed to generate OAuth URL for ${provider}:`,
error
);
throw error;
}
}
public async oauthLoginCallback(
provider: string,
params: OAuthCallbackParams
): Promise<LoginResponse> {
console.log(
`[AuthClient] oauthLoginCallback called for provider: ${provider}`
);
try {
const response = await this.api.post<LoginResponse>(`/oauth/login`, {
provider,
code: params.code,
state: params.state,
});
console.log(
`[AuthClient] OAuth login successful for provider: ${provider}`
);
this.setTokens(response.data.access_token, response.data.refresh_token);
return response.data;
} catch (error) {
console.error(
`[AuthClient] OAuth login failed for provider: ${provider}`,
error
);
throw error;
}
}
public async oauthSignupCallback(
provider: string,
params: OAuthCallbackParams
): Promise<OAuthSignupResponse> {
console.log(
`[AuthClient] oauthSignupCallback called for provider: ${provider}`
);
try {
const response = await this.api.post<OAuthSignupResponse>(
`/oauth/signup`,
{
provider,
code: params.code,
state: params.state,
}
);
console.log(
`[AuthClient] OAuth signup successful for provider: ${provider}`
);
this.setTokens(response.data.access_token, response.data.refresh_token);
return response.data;
} catch (error) {
console.error(
`[AuthClient] OAuth signup failed for provider: ${provider}`,
error
);
throw error;
}
}
public getAxiosInstance(): AxiosInstance {
console.log("[AuthClient] getAxiosInstance called.");
return this.api;
}
// Static utility methods
public static extractTokens = extractTokens;
public static isTokenExpired = isTokenExpired;
}