192 lines
4.6 KiB
TypeScript
192 lines
4.6 KiB
TypeScript
const TOKEN_STORAGE_KEY = 'tenant_admin.token.v1';
|
|
const TOKEN_SESSION_KEY = 'tenant_admin.token.session.v1';
|
|
|
|
export type StoredToken = {
|
|
accessToken: string;
|
|
abilities: string[];
|
|
issuedAt: number;
|
|
};
|
|
|
|
export class AuthError extends Error {
|
|
constructor(public code: 'unauthenticated' | 'unauthorized', message?: string) {
|
|
super(message ?? code);
|
|
this.name = 'AuthError';
|
|
}
|
|
}
|
|
|
|
let cachedToken: StoredToken | null = null;
|
|
|
|
function decodeStoredToken(raw: string | null): StoredToken | null {
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as StoredToken;
|
|
if (!parsed || typeof parsed.accessToken !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
accessToken: parsed.accessToken,
|
|
abilities: Array.isArray(parsed.abilities) ? parsed.abilities : [],
|
|
issuedAt: typeof parsed.issuedAt === 'number' ? parsed.issuedAt : Date.now(),
|
|
} satisfies StoredToken;
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to decode stored token', error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readTokenFromStorage(): StoredToken | null {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const sessionValue = window.sessionStorage.getItem(TOKEN_SESSION_KEY);
|
|
const fromSession = decodeStoredToken(sessionValue);
|
|
if (fromSession) {
|
|
return fromSession;
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to read session stored token', error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const persistentValue = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
|
return decodeStoredToken(persistentValue);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to read persisted token', error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function loadToken(): StoredToken | null {
|
|
if (cachedToken) {
|
|
return cachedToken;
|
|
}
|
|
|
|
const stored = readTokenFromStorage();
|
|
cachedToken = stored;
|
|
|
|
return stored;
|
|
}
|
|
|
|
function persistToken(token: StoredToken): void {
|
|
if (typeof window === 'undefined') {
|
|
cachedToken = token;
|
|
return;
|
|
}
|
|
|
|
const serialized = JSON.stringify(token);
|
|
|
|
try {
|
|
window.localStorage.setItem(TOKEN_STORAGE_KEY, serialized);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to persist tenant token to localStorage', error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
window.sessionStorage.setItem(TOKEN_SESSION_KEY, serialized);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to persist tenant token to sessionStorage', error);
|
|
}
|
|
}
|
|
|
|
cachedToken = token;
|
|
}
|
|
|
|
export function storePersonalAccessToken(accessToken: string, abilities: string[]): StoredToken {
|
|
const stored: StoredToken = {
|
|
accessToken,
|
|
abilities,
|
|
issuedAt: Date.now(),
|
|
};
|
|
|
|
persistToken(stored);
|
|
|
|
return stored;
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
cachedToken = null;
|
|
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to remove stored tenant token', error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
window.sessionStorage.removeItem(TOKEN_SESSION_KEY);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Failed to remove session tenant token', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
type AuthFailureHandler = () => void;
|
|
const authFailureHandlers = new Set<AuthFailureHandler>();
|
|
|
|
function notifyAuthFailure(): void {
|
|
authFailureHandlers.forEach((handler) => {
|
|
try {
|
|
handler();
|
|
} catch (error) {
|
|
console.error('[Auth] Failure handler threw', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function registerAuthFailureHandler(handler: AuthFailureHandler): () => void {
|
|
authFailureHandlers.add(handler);
|
|
return () => {
|
|
authFailureHandlers.delete(handler);
|
|
};
|
|
}
|
|
|
|
export function isAuthError(value: unknown): value is AuthError {
|
|
return value instanceof AuthError;
|
|
}
|
|
|
|
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
|
const stored = loadToken();
|
|
if (!stored) {
|
|
notifyAuthFailure();
|
|
throw new AuthError('unauthenticated', 'No active tenant admin token');
|
|
}
|
|
|
|
const headers = new Headers(init.headers);
|
|
headers.set('Authorization', `Bearer ${stored.accessToken}`);
|
|
if (!headers.has('Accept')) {
|
|
headers.set('Accept', 'application/json');
|
|
}
|
|
|
|
const response = await fetch(input, { ...init, headers });
|
|
|
|
if (response.status === 401) {
|
|
clearTokens();
|
|
notifyAuthFailure();
|
|
throw new AuthError('unauthorized', 'Token rejected by API');
|
|
}
|
|
|
|
return response;
|
|
}
|