238 lines
5.7 KiB
TypeScript
238 lines
5.7 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 getCsrfToken(): string | undefined {
|
|
const meta = typeof document !== 'undefined'
|
|
? (document.querySelector('meta[name=\"csrf-token\"]') as HTMLMetaElement | null)
|
|
: null;
|
|
return meta?.content;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function exchangeSessionForToken(): Promise<StoredToken | null> {
|
|
const csrf = getCsrfToken();
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/tenant-auth/exchange', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
|
},
|
|
credentials: 'same-origin',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const data = (await response.json()) as { token?: string; abilities?: string[] } | null;
|
|
if (!data?.token) {
|
|
return null;
|
|
}
|
|
|
|
return storePersonalAccessToken(data.token, Array.isArray(data.abilities) ? data.abilities : []);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('[Auth] Session token exchange failed', error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 headers = new Headers(init.headers);
|
|
if (!headers.has('Accept')) {
|
|
headers.set('Accept', 'application/json');
|
|
}
|
|
|
|
let stored = loadToken();
|
|
if (!stored) {
|
|
stored = await exchangeSessionForToken();
|
|
}
|
|
if (stored?.accessToken) {
|
|
headers.set('Authorization', `Bearer ${stored.accessToken}`);
|
|
}
|
|
|
|
const response = await fetch(input, {
|
|
...init,
|
|
headers,
|
|
credentials: init.credentials ?? 'same-origin',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
if (stored) {
|
|
clearTokens();
|
|
}
|
|
notifyAuthFailure();
|
|
throw new AuthError('unauthorized', 'Token rejected by API');
|
|
}
|
|
|
|
return response;
|
|
}
|