Return the OIDC id_token in the callback response so the frontend can store it and pass it as id_token_hint to the provider's end-session endpoint on logout. This lets Authentik (or any OIDC provider) honor the post_logout_redirect_uri and redirect back to the Cameleer login page instead of showing the provider's own logout page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
import { create } from 'zustand';
|
|
import { api } from '../api/client';
|
|
|
|
interface AuthState {
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
username: string | null;
|
|
roles: string[];
|
|
isAuthenticated: boolean;
|
|
error: string | null;
|
|
loading: boolean;
|
|
login: (username: string, password: string) => Promise<void>;
|
|
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
|
refresh: () => Promise<boolean>;
|
|
logout: () => void;
|
|
}
|
|
|
|
function parseRolesFromJwt(token: string): string[] {
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return Array.isArray(payload.roles) ? payload.roles : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function loadTokens() {
|
|
return {
|
|
accessToken: localStorage.getItem('cameleer-access-token'),
|
|
refreshToken: localStorage.getItem('cameleer-refresh-token'),
|
|
username: localStorage.getItem('cameleer-username'),
|
|
};
|
|
}
|
|
|
|
function persistTokens(access: string, refresh: string, username: string) {
|
|
localStorage.setItem('cameleer-access-token', access);
|
|
localStorage.setItem('cameleer-refresh-token', refresh);
|
|
localStorage.setItem('cameleer-username', username);
|
|
}
|
|
|
|
function clearTokens() {
|
|
localStorage.removeItem('cameleer-access-token');
|
|
localStorage.removeItem('cameleer-refresh-token');
|
|
localStorage.removeItem('cameleer-username');
|
|
}
|
|
|
|
const initial = loadTokens();
|
|
|
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
|
accessToken: initial.accessToken,
|
|
refreshToken: initial.refreshToken,
|
|
username: initial.username,
|
|
roles: initial.accessToken ? parseRolesFromJwt(initial.accessToken) : [],
|
|
isAuthenticated: !!initial.accessToken,
|
|
error: null,
|
|
loading: false,
|
|
|
|
login: async (username, password) => {
|
|
set({ loading: true, error: null });
|
|
try {
|
|
const { data, error } = await api.POST('/auth/login', {
|
|
body: { username, password },
|
|
});
|
|
if (error || !data) {
|
|
throw new Error('Invalid credentials');
|
|
}
|
|
const { accessToken, refreshToken, displayName } = data;
|
|
localStorage.removeItem('cameleer-oidc-end-session');
|
|
localStorage.removeItem('cameleer-oidc-id-token');
|
|
const name = displayName ?? username;
|
|
persistTokens(accessToken, refreshToken, name);
|
|
set({
|
|
accessToken,
|
|
refreshToken,
|
|
username: name,
|
|
roles: parseRolesFromJwt(accessToken),
|
|
isAuthenticated: true,
|
|
loading: false,
|
|
});
|
|
} catch (e: unknown) {
|
|
set({
|
|
error: e instanceof Error ? e.message : 'Login failed',
|
|
loading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
loginWithOidcCode: async (code, redirectUri) => {
|
|
set({ loading: true, error: null });
|
|
try {
|
|
const { data, error } = await api.POST('/auth/oidc/callback', {
|
|
body: { code, redirectUri },
|
|
});
|
|
if (error || !data) {
|
|
throw new Error('OIDC login failed');
|
|
}
|
|
const { accessToken, refreshToken, displayName, idToken } = data;
|
|
const username = displayName ?? 'oidc-user';
|
|
persistTokens(accessToken, refreshToken, username);
|
|
if (idToken) {
|
|
localStorage.setItem('cameleer-oidc-id-token', idToken);
|
|
}
|
|
set({
|
|
accessToken,
|
|
refreshToken,
|
|
username,
|
|
roles: parseRolesFromJwt(accessToken),
|
|
isAuthenticated: true,
|
|
loading: false,
|
|
});
|
|
} catch (e: unknown) {
|
|
set({
|
|
error: e instanceof Error ? e.message : 'OIDC login failed',
|
|
loading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
refresh: async () => {
|
|
const { refreshToken } = get();
|
|
if (!refreshToken) return false;
|
|
try {
|
|
const { data, error } = await api.POST('/auth/refresh', {
|
|
body: { refreshToken },
|
|
});
|
|
if (error || !data) return false;
|
|
const username = data.displayName ?? get().username ?? '';
|
|
persistTokens(data.accessToken, data.refreshToken, username);
|
|
set({
|
|
accessToken: data.accessToken,
|
|
refreshToken: data.refreshToken,
|
|
username,
|
|
roles: parseRolesFromJwt(data.accessToken),
|
|
isAuthenticated: true,
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
logout: () => {
|
|
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
|
|
const idToken = localStorage.getItem('cameleer-oidc-id-token');
|
|
clearTokens();
|
|
localStorage.removeItem('cameleer-oidc-end-session');
|
|
localStorage.removeItem('cameleer-oidc-id-token');
|
|
set({
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
username: null,
|
|
roles: [],
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
if (endSessionEndpoint && idToken) {
|
|
const postLogoutRedirect = `${window.location.origin}/login`;
|
|
const params = new URLSearchParams({
|
|
id_token_hint: idToken,
|
|
post_logout_redirect_uri: postLogoutRedirect,
|
|
});
|
|
window.location.href = `${endSessionEndpoint}?${params}`;
|
|
}
|
|
},
|
|
}));
|