Files
fotospiel-app/resources/js/admin/auth/tokens.ts

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;
}