feat: add OAuth2/OIDC with PKCE for SSO login
Add opt-in SSO authentication alongside Basic Auth. OAuth endpoints are auto-discovered via .well-known, with support for external IdPs (Keycloak, Authentik) via configurable OAUTH_ISSUER_URL. Sessions persist through httpOnly refresh token cookies with automatic renewal.
This commit is contained in:
+232
-72
@@ -19,13 +19,101 @@ interface AuthState {
|
||||
client: JMAPClient | null;
|
||||
identities: Identity[];
|
||||
primaryIdentity: Identity | null;
|
||||
authMode: 'basic' | 'oauth';
|
||||
accessToken: string | null;
|
||||
tokenExpiresAt: number | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||
refreshAccessToken: () => Promise<string | null>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
|
||||
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
|
||||
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED'] },
|
||||
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
|
||||
];
|
||||
|
||||
function classifyLoginError(error: unknown): string {
|
||||
if (!(error instanceof Error)) return 'generic';
|
||||
const msg = error.message;
|
||||
for (const { key, matches } of ERROR_PATTERNS) {
|
||||
if (matches.some((pattern) => msg.includes(pattern))) return key;
|
||||
}
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
||||
const identities = [...rawIdentities].sort((a, b) => {
|
||||
const aMatch = a.email === username ? -1 : 0;
|
||||
const bMatch = b.email === username ? -1 : 0;
|
||||
return aMatch - bMatch;
|
||||
});
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
return { identities, primaryIdentity };
|
||||
}
|
||||
|
||||
function markSessionExpired(): void {
|
||||
try { sessionStorage.setItem('session_expired', 'true'); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
function initializeFeatureStores(client: JMAPClient): void {
|
||||
if (client.supportsContacts()) {
|
||||
const contactStore = useContactStore.getState();
|
||||
contactStore.setSupportsSync(true);
|
||||
contactStore.fetchAddressBooks(client).catch((err) => debug.error('Failed to fetch address books:', err));
|
||||
contactStore.fetchContacts(client).catch((err) => debug.error('Failed to fetch contacts:', err));
|
||||
} else {
|
||||
useContactStore.getState().setSupportsSync(false);
|
||||
}
|
||||
|
||||
const vacationStore = useVacationStore.getState();
|
||||
if (client.supportsVacationResponse()) {
|
||||
vacationStore.setSupported(true);
|
||||
vacationStore.fetchVacationResponse(client).catch((err) => debug.error('Failed to fetch vacation response:', err));
|
||||
} else {
|
||||
vacationStore.setSupported(false);
|
||||
}
|
||||
|
||||
if (client.supportsCalendars()) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
calendarStore.setSupported(true);
|
||||
calendarStore.fetchCalendars(client).catch((err) => debug.error('Failed to fetch calendars:', err));
|
||||
}
|
||||
|
||||
if (client.supportsSieve()) {
|
||||
const filterStore = useFilterStore.getState();
|
||||
filterStore.setSupported(true);
|
||||
filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err));
|
||||
}
|
||||
}
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>): void {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshFn().catch((err) => {
|
||||
debug.error('Scheduled token refresh failed:', err);
|
||||
});
|
||||
}, refreshAt);
|
||||
}
|
||||
|
||||
function clearRefreshTimer(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
refreshPromise = null;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -37,6 +125,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
authMode: 'basic',
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
|
||||
login: async (serverUrl, username, password, totp) => {
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
@@ -46,46 +137,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||
await client.connect();
|
||||
|
||||
const rawIdentities = await client.getIdentities();
|
||||
const identities = [...rawIdentities].sort((a, b) => {
|
||||
const aMatch = a.email === username ? -1 : 0;
|
||||
const bMatch = b.email === username ? -1 : 0;
|
||||
return aMatch - bMatch;
|
||||
});
|
||||
const primaryIdentity = identities.length > 0 ? identities[0] : null;
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
// Fetch contacts if server supports JMAP Contacts
|
||||
if (client.supportsContacts()) {
|
||||
const contactStore = useContactStore.getState();
|
||||
contactStore.setSupportsSync(true);
|
||||
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
|
||||
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
|
||||
} else {
|
||||
useContactStore.getState().setSupportsSync(false);
|
||||
}
|
||||
|
||||
const vacationStore = useVacationStore.getState();
|
||||
if (client.supportsVacationResponse()) {
|
||||
vacationStore.setSupported(true);
|
||||
vacationStore.fetchVacationResponse(client).catch((err) => console.error('Failed to fetch vacation response:', err));
|
||||
} else {
|
||||
vacationStore.setSupported(false);
|
||||
}
|
||||
|
||||
if (client.supportsCalendars()) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
calendarStore.setSupported(true);
|
||||
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
|
||||
}
|
||||
|
||||
if (client.supportsSieve()) {
|
||||
const filterStore = useFilterStore.getState();
|
||||
filterStore.setSupported(true);
|
||||
filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err));
|
||||
}
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -94,39 +148,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
client,
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: 'basic',
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
debug.error('Login error:', error);
|
||||
let errorKey = 'generic';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'CORS_ERROR') {
|
||||
errorKey = 'cors_blocked';
|
||||
} else if (error.message.includes('Invalid username or password') ||
|
||||
error.message.includes('401') ||
|
||||
error.message.includes('Unauthorized')) {
|
||||
errorKey = 'invalid_credentials';
|
||||
} else if (error.message.includes('network') ||
|
||||
error.message.includes('Failed to fetch') ||
|
||||
error.message.includes('NetworkError') ||
|
||||
error.message.includes('ECONNREFUSED')) {
|
||||
errorKey = 'connection_failed';
|
||||
} else if (error.message.includes('500') ||
|
||||
error.message.includes('502') ||
|
||||
error.message.includes('503') ||
|
||||
error.message.includes('504') ||
|
||||
error.message.includes('Internal Server Error') ||
|
||||
error.message.includes('Service Unavailable')) {
|
||||
errorKey = 'server_error';
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorKey,
|
||||
error: classifyLoginError(error),
|
||||
isAuthenticated: false,
|
||||
client: null,
|
||||
});
|
||||
@@ -134,13 +167,108 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch('/api/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
throw new Error('token_exchange_failed');
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await tokenRes.json();
|
||||
|
||||
const refreshFn = get().refreshAccessToken;
|
||||
const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn());
|
||||
await client.connect();
|
||||
|
||||
const username = client.getUsername();
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl,
|
||||
username,
|
||||
client,
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: 'oauth',
|
||||
accessToken: access_token,
|
||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||
error: null,
|
||||
});
|
||||
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
debug.error('OAuth login error:', error);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'generic',
|
||||
isAuthenticated: false,
|
||||
client: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
refreshAccessToken: async () => {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/token', { method: 'PUT' });
|
||||
|
||||
if (!res.ok) {
|
||||
markSessionExpired();
|
||||
get().logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json();
|
||||
|
||||
get().client?.updateAccessToken(access_token);
|
||||
|
||||
set({
|
||||
accessToken: access_token,
|
||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||
});
|
||||
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken);
|
||||
return access_token;
|
||||
} catch (error) {
|
||||
debug.error('Token refresh failed:', error);
|
||||
markSessionExpired();
|
||||
get().logout();
|
||||
return null;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
const state = get();
|
||||
|
||||
if (state.client) {
|
||||
state.client.disconnect();
|
||||
if (state.authMode === 'oauth') {
|
||||
fetch('/api/auth/token', { method: 'DELETE' }).catch((err) => {
|
||||
debug.error('Token revocation failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
clearRefreshTimer();
|
||||
state.client?.disconnect();
|
||||
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
serverUrl: null,
|
||||
@@ -148,6 +276,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
authMode: 'basic',
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -175,9 +306,35 @@ export const useAuthStore = create<AuthState>()(
|
||||
const state = get();
|
||||
|
||||
if (state.isAuthenticated && !state.client) {
|
||||
try {
|
||||
sessionStorage.setItem('session_expired', 'true');
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
if (state.authMode === 'oauth' && state.serverUrl) {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const token = await get().refreshAccessToken();
|
||||
if (token && state.serverUrl) {
|
||||
const refreshFn = get().refreshAccessToken;
|
||||
const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn());
|
||||
await client.connect();
|
||||
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
|
||||
initializeFeatureStores(client);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
client,
|
||||
identities,
|
||||
primaryIdentity,
|
||||
accessToken: token,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('OAuth session restore failed:', error);
|
||||
clearRefreshTimer();
|
||||
}
|
||||
}
|
||||
|
||||
markSessionExpired();
|
||||
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
@@ -185,6 +342,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
client: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
authMode: 'basic',
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,11 +356,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
// Only persist non-sensitive data
|
||||
serverUrl: state.serverUrl,
|
||||
username: state.username,
|
||||
// Don't persist isAuthenticated since we can't restore the session without a password
|
||||
authMode: state.authMode,
|
||||
isAuthenticated: state.authMode === 'oauth' ? state.isAuthenticated : undefined,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user