feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)

feat(oidc): add Azure Entra ID support with configurable email claim resolution

Adds two new OIDC provider fields: email_claim and require_email_verified.
This commit is contained in:
Sn0rrii
2026-04-25 13:32:42 +02:00
committed by GitHub
parent 4304a42542
commit fdaec47378
22 changed files with 2288 additions and 73 deletions
+56
View File
@@ -33,6 +33,8 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => {
server.resetHandlers();
sessionStorageMock.clear();
vi.mocked(localStorage.setItem).mockClear();
vi.mocked(localStorage.removeItem).mockClear();
setAuthToken(null);
});
afterAll(() => server.close());
@@ -50,6 +52,60 @@ describe('Auth Token Management', () => {
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
expect(getAuthToken()).toBeNull();
});
it("setAuthToken('persistent') writes to both sessionStorage and localStorage", () => {
setAuthToken('persist-token', 'persistent');
expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'persist-token');
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'persist-token');
expect(getAuthToken()).toBe('persist-token');
});
it("setAuthToken('session') writes only to sessionStorage, not localStorage", () => {
setAuthToken('session-token', 'session');
expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'session-token');
expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
});
it('setAuthToken(null) removes from both storages regardless of previous persistence', () => {
setAuthToken('some-token', 'persistent');
vi.mocked(localStorage.setItem).mockClear();
setAuthToken(null);
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
expect(vi.mocked(localStorage.removeItem)).toHaveBeenCalledWith('auth_token');
expect(getAuthToken()).toBeNull();
});
it('setAuthToken keeps in-memory token when sessionStorage throws', () => {
sessionStorageMock.setItem.mockImplementationOnce(() => {
throw new DOMException('QuotaExceededError');
});
// Should not throw even when storage is unavailable
expect(() => setAuthToken('fallback-token')).not.toThrow();
// In-memory token must still be set
expect(getAuthToken()).toBe('fallback-token');
});
it('setAuthToken(null) removes from sessionStorage even when localStorage.removeItem throws', () => {
setAuthToken('some-token', 'persistent');
vi.mocked(localStorage.removeItem).mockImplementationOnce(() => {
throw new DOMException('SecurityError');
});
// Must not throw — localStorage failure must not abort the sessionStorage removal
expect(() => setAuthToken(null)).not.toThrow();
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
expect(getAuthToken()).toBeNull();
});
it('setAuthToken(null) removes from localStorage even when sessionStorage.removeItem throws', () => {
setAuthToken('some-token', 'persistent');
sessionStorageMock.removeItem.mockImplementationOnce(() => {
throw new DOMException('SecurityError');
});
// Must not throw — sessionStorage failure must not abort the localStorage removal
expect(() => setAuthToken(null)).not.toThrow();
expect(vi.mocked(localStorage.removeItem)).toHaveBeenCalledWith('auth_token');
expect(getAuthToken()).toBeNull();
});
});
describe('API Client Auth Header', () => {
@@ -0,0 +1,125 @@
/**
* Tests for OIDCProviderSettings — focused on the auto_link / require_email_verified
* toggle interaction (SEC-1/SEC-6 UI enforcement).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockProviders = [
{
id: 1,
name: 'TestIdP',
issuer_url: 'https://idp.example.com',
client_id: 'test-client',
scopes: 'openid email profile',
is_enabled: true,
auto_create_users: false,
auto_link_existing_accounts: false,
email_claim: 'email',
require_email_verified: true,
icon_url: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
];
beforeEach(() => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json(mockProviders))
);
});
describe('OIDCProviderSettings', () => {
describe('ProviderForm — require_email_verified description logic', () => {
it('shows standard description when require_email_verified is on and auto_link is off', async () => {
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
});
await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
await waitFor(() => {
// Default state: require_email_verified=true, auto_link=false → standard description
expect(
screen.getByText(/only.*accept.*email.*verified/i)
).toBeInTheDocument();
});
});
it('shows "Disable auto-link first" description when auto_link is enabled', async () => {
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
const user = userEvent.setup();
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
});
await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
await waitFor(() => {
expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument();
});
// Find the Auto Link switch by aria-label or by position
const switches = screen.getAllByRole('switch');
// Switches order in form: Enabled, AutoCreate, AutoLink, RequireEmailVerified
// AutoLink is the 3rd switch (index 2)
const autoLinkSwitch = switches[2];
await user.click(autoLinkSwitch);
await waitFor(() => {
expect(
screen.getByText(/disable auto.?link first/i)
).toBeInTheDocument();
});
});
it('shows warning text when require_email_verified is toggled off', async () => {
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
const user = userEvent.setup();
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
});
await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
await waitFor(() => {
expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
});
// RequireEmailVerified is the 4th switch (index 3)
const switches = screen.getAllByRole('switch');
const reqEvSwitch = switches[3];
await user.click(reqEvSwitch);
await waitFor(() => {
expect(
screen.getByText(/warning.*accept.*without.*verif/i)
).toBeInTheDocument();
});
});
});
describe('Provider info view', () => {
it('renders email_claim and require_email_verified fields in provider details', async () => {
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('TestIdP')).toBeInTheDocument();
});
// The provider card shows field labels in the details section
expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
});
});
});
+328 -1
View File
@@ -2,7 +2,7 @@
* Tests for the LoginPage component.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
@@ -300,4 +300,331 @@ describe('LoginPage', () => {
});
});
});
describe('Remember Me', () => {
const mockUser = {
id: 1,
username: 'testuser',
role: 'admin' as const,
is_active: true,
created_at: new Date().toISOString(),
};
beforeEach(() => {
vi.mocked(localStorage.setItem).mockClear();
sessionStorage.clear();
server.use(
http.post('/api/v1/auth/login', () =>
HttpResponse.json({
access_token: 'test-token',
token_type: 'bearer',
user: mockUser,
})
),
// Prevent checkAuthStatus from clearing the token when getCurrentUser is called
http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser))
);
});
it('renders Remember Me checkbox on credentials step', async () => {
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/Remember Me/i)).toBeInTheDocument();
});
expect(screen.getByRole('checkbox', { name: /Remember Me/i })).not.toBeChecked();
});
it('does not persist token to localStorage when unchecked (default)', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
});
await user.type(screen.getByLabelText(/Username/i), 'testuser');
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
await user.click(screen.getByRole('button', { name: /Sign in/i }));
// Token must be in sessionStorage (tab-only) but not in localStorage
await waitFor(() => {
expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
expect(sessionStorage.getItem('auth_token')).toBe('test-token');
});
});
it('persists token to localStorage when Remember Me is checked', async () => {
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
});
await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
await user.type(screen.getByLabelText(/Username/i), 'testuser');
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
await user.click(screen.getByRole('button', { name: /Sign in/i }));
await waitFor(() => {
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'test-token');
});
});
it('carries Remember Me through 2FA verification', async () => {
server.use(
http.post('/api/v1/auth/login', () =>
HttpResponse.json({
requires_2fa: true,
pre_auth_token: 'pre-token',
two_fa_methods: ['totp'],
})
),
http.post('/api/v1/auth/2fa/verify', () =>
HttpResponse.json({
access_token: 'final-token',
token_type: 'bearer',
user: mockUser,
})
)
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
});
// Check Remember Me before submitting credentials
await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
await user.type(screen.getByLabelText(/Username/i), 'testuser');
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
await user.click(screen.getByRole('button', { name: /Sign in/i }));
// Now on 2FA step — enter code and verify
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
});
await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
await user.click(screen.getByRole('button', { name: /Verify/i }));
// Token must be persisted to localStorage because Remember Me was checked
await waitFor(() => {
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'final-token');
});
});
it('checkbox is not shown on 2FA step', async () => {
server.use(
http.post('/api/v1/auth/login', () =>
HttpResponse.json({
requires_2fa: true,
pre_auth_token: 'pre-token',
two_fa_methods: ['totp'],
})
)
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
});
await user.type(screen.getByLabelText(/Username/i), 'testuser');
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
await user.click(screen.getByRole('button', { name: /Sign in/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
});
expect(screen.queryByLabelText(/Remember Me/i)).not.toBeInTheDocument();
});
});
describe('OIDC with Remember Me', () => {
const mockUser = {
id: 1,
username: 'oidcuser',
role: 'admin' as const,
is_active: true,
created_at: new Date().toISOString(),
};
beforeEach(() => {
vi.mocked(localStorage.setItem).mockClear();
sessionStorage.clear();
});
afterEach(() => {
window.location.hash = '';
window.history.pushState({}, '', '/login');
sessionStorage.clear();
});
it('persists token to localStorage after OIDC redirect when Remember Me was set', async () => {
sessionStorage.setItem('auth_remember_me', '1');
server.use(
http.post('/api/v1/auth/oidc/exchange', () =>
HttpResponse.json({
access_token: 'oidc-token',
token_type: 'bearer',
user: mockUser,
})
)
);
window.location.hash = '#oidc_token=test-exchange-token';
render(<LoginPage />);
await waitFor(() => {
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-token');
});
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
});
it('carries Remember Me through OIDC + 2FA flow', async () => {
sessionStorage.setItem('auth_remember_me', '1');
server.use(
http.post('/api/v1/auth/oidc/exchange', () =>
HttpResponse.json({
requires_2fa: true,
pre_auth_token: 'oidc-pre-token',
two_fa_methods: ['totp'],
})
),
http.post('/api/v1/auth/2fa/verify', () =>
HttpResponse.json({
access_token: 'oidc-2fa-token',
token_type: 'bearer',
user: mockUser,
})
)
);
window.location.hash = '#oidc_token=test-exchange-token';
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
});
// Flag consumed on mount — no stale value for future flows
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
await user.click(screen.getByRole('button', { name: /Verify/i }));
await waitFor(() => {
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-2fa-token');
});
});
it('cleans up auth_remember_me flag when OIDC returns an error', async () => {
sessionStorage.setItem('auth_remember_me', '1');
window.history.pushState({}, '', '/login?oidc_error=invalid_state');
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
});
});
it('does not persist token to localStorage after OIDC redirect when Remember Me was not set', async () => {
// No auth_remember_me flag set — token must stay session-only
server.use(
http.post('/api/v1/auth/oidc/exchange', () =>
HttpResponse.json({
access_token: 'oidc-session-token',
token_type: 'bearer',
user: mockUser,
})
)
);
window.location.hash = '#oidc_token=test-exchange-token';
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('auth_token')).toBe('oidc-session-token');
});
expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
});
it('shows error toast when OIDC exchange returns unexpected response shape', async () => {
sessionStorage.setItem('auth_remember_me', '1');
server.use(
// Response is missing both access_token and requires_2fa — hits the else branch
http.post('/api/v1/auth/oidc/exchange', () =>
HttpResponse.json({ token_type: 'bearer' })
)
);
window.location.hash = '#oidc_token=test-exchange-token';
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByText(/Login.*failed|failed.*login/i)).toBeInTheDocument();
});
// Flag must still be cleaned up even on malformed response
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
});
it('writes auth_remember_me flag to sessionStorage before OIDC provider redirect', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers', () =>
HttpResponse.json([
{
id: 42,
name: 'FlagIdP',
issuer_url: 'https://flag.test',
client_id: 'c',
is_enabled: true,
icon_url: null,
email_claim: 'email',
require_email_verified: true,
auto_create_users: false,
auto_link_existing_accounts: false,
},
])
),
http.get('/api/v1/auth/oidc/authorize/42', () =>
HttpResponse.json({ auth_url: 'https://flag.test/authorize?state=abc' })
)
);
const user = userEvent.setup();
render(<LoginPage />);
// Tick "Remember Me"
await waitFor(() => {
expect(screen.getByRole('checkbox', { name: /Remember Me/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
// Wait for OIDC provider button to appear
await waitFor(() => {
expect(screen.getByRole('button', { name: /FlagIdP/i })).toBeInTheDocument();
});
// Stub window.location so the OIDC redirect doesn't actually navigate.
// Keep href valid so relative fetch URLs resolve correctly.
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, href: 'http://localhost:3000/' },
});
await user.click(screen.getByRole('button', { name: /FlagIdP/i }));
await waitFor(() => {
expect(sessionStorage.getItem('auth_remember_me')).toBe('1');
});
});
});
});
+25 -11
View File
@@ -2,23 +2,33 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
const API_BASE = '/api/v1';
// Auth token storage
// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
// When the token originates from the ?token= URL param (kiosk bootstrap), it is
// additionally persisted in localStorage so the kiosk survives page reloads.
// 'persistent' also writes to localStorage so the token survives tab close
// (used by Remember Me and the ?token= kiosk bootstrap).
let authToken: string | null =
sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
export function setAuthToken(token: string | null, persist = false) {
export type TokenPersistence = 'session' | 'persistent';
export function setAuthToken(token: string | null, persistence: TokenPersistence = 'session') {
authToken = token;
if (token) {
sessionStorage.setItem('auth_token', token);
if (persist) {
try {
if (token) {
sessionStorage.setItem('auth_token', token);
} else {
sessionStorage.removeItem('auth_token');
}
} catch (err) {
// Storage unavailable (quota exceeded, private mode): in-memory token still works for this tab.
console.warn('setAuthToken: sessionStorage unavailable, token kept in-memory only', err);
}
try {
if (!token) {
localStorage.removeItem('auth_token');
} else if (persistence === 'persistent') {
localStorage.setItem('auth_token', token);
}
} else {
sessionStorage.removeItem('auth_token');
localStorage.removeItem('auth_token');
} catch (err) {
console.warn('setAuthToken: localStorage operation failed', err);
}
}
@@ -2521,6 +2531,8 @@ export interface OIDCProvider {
is_enabled: boolean;
auto_create_users: boolean;
auto_link_existing_accounts: boolean;
email_claim: string;
require_email_verified: boolean;
icon_url?: string | null;
}
@@ -2533,6 +2545,8 @@ export interface OIDCProviderCreate {
is_enabled?: boolean;
auto_create_users?: boolean;
auto_link_existing_accounts?: boolean;
email_claim?: string;
require_email_verified?: boolean;
icon_url?: string | null;
}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -19,6 +19,8 @@ const EMPTY_FORM: OIDCProviderCreate = {
is_enabled: true,
auto_create_users: false,
auto_link_existing_accounts: false,
email_claim: 'email',
require_email_verified: true,
icon_url: undefined,
};
@@ -54,6 +56,19 @@ function ProviderForm({
onSave(payload);
};
const autoLinkOn = form.auto_link_existing_accounts === true;
const emailVerifiedOn = form.require_email_verified ?? true;
let requireEmailVerifiedDesc: ReactNode;
if (autoLinkOn) {
requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedAutoLink');
} else if (emailVerifiedOn) {
requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedDesc');
} else {
requireEmailVerifiedDesc = (
<span className="text-red-400">{t('settings.oidc.form.requireEmailVerifiedWarning')}</span>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -115,6 +130,28 @@ function ProviderForm({
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<Toggle
checked={emailVerifiedOn}
onChange={(v) => set('require_email_verified', v)}
disabled={autoLinkOn}
/>
<div>
<p className="text-white text-sm">{t('settings.oidc.form.requireEmailVerified')}</p>
<p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
</div>
</label>
</div>
<div>
<label className={labelCls}>{t('settings.oidc.form.emailClaim')}</label>
<input
className={inputCls}
value={form.email_claim}
onChange={(e) => set('email_claim', e.target.value || 'email')}
placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
/>
<p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
</div>
<div className="flex gap-3 pt-2">
@@ -304,6 +341,8 @@ export function OIDCProviderSettings() {
is_enabled: provider.is_enabled,
auto_create_users: provider.auto_create_users,
auto_link_existing_accounts: provider.auto_link_existing_accounts,
email_claim: provider.email_claim,
require_email_verified: provider.require_email_verified,
icon_url: provider.icon_url ?? undefined,
}}
onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
@@ -337,6 +376,16 @@ export function OIDCProviderSettings() {
{provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
</dd>
</div>
<div>
<dt className="text-bambu-gray">{t('settings.oidc.form.emailClaim')}</dt>
<dd className="text-white font-mono">{provider.email_claim}</dd>
</div>
<div>
<dt className="text-bambu-gray">{t('settings.oidc.form.requireEmailVerified')}</dt>
<dd className={provider.require_email_verified ? 'text-green-400' : 'text-red-400'}>
{provider.require_email_verified ? t('common.yes') : t('common.no')}
</dd>
</div>
</dl>
</CardContent>
)}
+9 -9
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { api, getAuthToken, setAuthToken } from '../api/client';
import type { LoginResponse, Permission, UserResponse } from '../api/client';
import type { LoginResponse, Permission, TokenPersistence, UserResponse } from '../api/client';
interface AuthContextType {
user: UserResponse | null;
@@ -9,9 +9,9 @@ interface AuthContextType {
loading: boolean;
isAdmin: boolean;
/** Login with username/password. Returns LoginResponse (may include requires_2fa). */
login: (username: string, password: string) => Promise<LoginResponse>;
login: (username: string, password: string, persistence?: TokenPersistence) => Promise<LoginResponse>;
/** Finalise login after 2FA or OIDC — store token and set user directly. */
loginWithToken: (token: string, user: UserResponse) => void;
loginWithToken: (token: string, user: UserResponse, persistence?: TokenPersistence) => void;
logout: () => void;
refreshUser: () => Promise<void>;
refreshAuth: () => Promise<void>;
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get('token');
if (urlToken) {
setAuthToken(urlToken, false); // session-only until server confirms it's valid
setAuthToken(urlToken, 'session'); // session-only until server confirms it's valid
urlParams.delete('token');
const cleanSearch = urlParams.toString();
const cleanUrl = window.location.pathname
@@ -64,7 +64,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(currentUser);
// Persist kiosk token only after the server confirms it is valid.
if (urlToken && token === urlToken) {
setAuthToken(urlToken, true);
setAuthToken(urlToken, 'persistent');
}
} catch {
// Token invalid, clear it (removes from both sessionStorage and localStorage)
@@ -116,17 +116,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [loading, requiresSetup, authEnabled]);
const login = async (username: string, password: string): Promise<LoginResponse> => {
const login = async (username: string, password: string, persistence: TokenPersistence = 'session'): Promise<LoginResponse> => {
const response = await api.login({ username, password });
if (!response.requires_2fa && response.access_token) {
setAuthToken(response.access_token);
setAuthToken(response.access_token, persistence);
await checkAuthStatus();
}
return response;
};
const loginWithToken = (token: string, userObj: UserResponse) => {
setAuthToken(token);
const loginWithToken = (token: string, userObj: UserResponse, persistence: TokenPersistence = 'session') => {
setAuthToken(token, persistence);
setUser(userObj);
setAuthEnabled(true);
};
+8
View File
@@ -2202,6 +2202,13 @@ export default {
autoLinkDesc: 'Verknüpft beim ersten Login vorhandene lokale Konten anhand der E-Mail-Adresse.',
secretHint: 'leer lassen zum Beibehalten',
secretPlaceholder: 'neues Secret',
emailClaim: 'E-Mail-Claim',
emailClaimDesc: "JWT-Claim für die E-Mail-Identität. Für Azure Entra ID 'preferred_username' oder 'upn' verwenden (sendet kein email_verified). Nur vertrauenswürdige Claim-Namen verwenden.",
emailClaimPlaceholder: 'email',
requireEmailVerified: 'E-Mail-Verifizierung erforderlich',
requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',
requireEmailVerifiedAutoLink: 'Auto-Verknüpfung zuerst deaktivieren, um diese Einstellung zu ändern.',
},
},
@@ -2331,6 +2338,7 @@ export default {
passwordPlaceholder: 'Passwort eingeben',
signIn: 'Anmelden',
signingIn: 'Anmeldung läuft...',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
loginSuccess: 'Erfolgreich angemeldet',
loginFailed: 'Anmeldung fehlgeschlagen',
+8
View File
@@ -2205,6 +2205,13 @@ export default {
autoLinkDesc: 'Link existing local accounts by matching email on first login.',
secretHint: 'leave blank to keep current',
secretPlaceholder: 'new secret',
emailClaim: 'Email Claim',
emailClaimDesc: "JWT claim used as email identity. Use 'preferred_username' or 'upn' for Azure Entra ID (which does not send email_verified). Only use trusted claim names.",
emailClaimPlaceholder: 'email',
requireEmailVerified: 'Require email verified',
requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',
requireEmailVerifiedAutoLink: 'Disable auto-link first to change this setting.',
},
},
@@ -2334,6 +2341,7 @@ export default {
passwordPlaceholder: 'Enter your password',
signIn: 'Sign in',
signingIn: 'Logging in...',
rememberMe: 'Remember Me',
forgotPassword: 'Forgot your password?',
loginSuccess: 'Logged in successfully',
loginFailed: 'Login failed',
+8
View File
@@ -2138,6 +2138,13 @@ export default {
autoLinkDesc: 'Lie les comptes locaux existants par e-mail lors de la première connexion.',
secretHint: 'laisser vide pour conserver',
secretPlaceholder: 'nouveau secret',
emailClaim: 'Claim e-mail',
emailClaimDesc: "Claim JWT utilisé comme identité e-mail. Utiliser 'preferred_username' ou 'upn' pour Azure Entra ID (qui n'envoie pas email_verified). Utiliser uniquement des noms de claims de confiance.",
emailClaimPlaceholder: 'email',
requireEmailVerified: 'Exiger la vérification e-mail',
requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",
requireEmailVerifiedAutoLink: 'Désactiver le lien automatique d\'abord pour modifier ce paramètre.',
},
},
@@ -2267,6 +2274,7 @@ export default {
passwordPlaceholder: 'Entrez votre mot de passe',
signIn: 'Se connecter',
signingIn: 'Connexion...',
rememberMe: 'Se souvenir de moi',
forgotPassword: 'Mot de passe oublié ?',
loginSuccess: 'Connecté avec succès',
loginFailed: 'Échec de connexion',
+8
View File
@@ -2137,6 +2137,13 @@ export default {
autoLinkDesc: 'Collega gli account locali esistenti tramite email al primo accesso.',
secretHint: 'lascia vuoto per mantenere',
secretPlaceholder: 'nuovo segreto',
emailClaim: 'Claim email',
emailClaimDesc: "Claim JWT usato come identità email. Usare 'preferred_username' o 'upn' per Azure Entra ID (che non invia email_verified). Usare solo nomi di claim affidabili.",
emailClaimPlaceholder: 'email',
requireEmailVerified: 'Richiedi verifica email',
requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
requireEmailVerifiedAutoLink: 'Disabilitare prima il collegamento automatico per modificare questa impostazione.',
},
},
@@ -2266,6 +2273,7 @@ export default {
passwordPlaceholder: 'Inserisci la password',
signIn: 'Accedi',
signingIn: 'Accesso in corso...',
rememberMe: 'Ricordami',
forgotPassword: 'Hai dimenticato la password?',
loginSuccess: 'Accesso riuscito',
loginFailed: 'Accesso fallito',
+8
View File
@@ -2176,6 +2176,13 @@ export default {
autoLinkDesc: '初回ログイン時にメールアドレスで既存のローカルアカウントにリンクします。',
secretHint: '空白のままで現在のものを維持',
secretPlaceholder: '新しいシークレット',
emailClaim: 'メールクレーム',
emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。",
emailClaimPlaceholder: 'email',
requireEmailVerified: 'メール確認を要求',
requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
requireEmailVerifiedAutoLink: 'この設定を変更するには、まず自動リンクを無効にしてください。',
},
},
@@ -2305,6 +2312,7 @@ export default {
passwordPlaceholder: 'パスワードを入力',
signIn: 'サインイン',
signingIn: 'ログイン中...',
rememberMe: 'ログイン状態を保持する',
forgotPassword: 'パスワードをお忘れですか?',
loginSuccess: 'ログインしました',
loginFailed: 'ログインに失敗しました',
+8
View File
@@ -2137,6 +2137,13 @@ export default {
autoLinkDesc: 'Vincula contas locais existentes por e-mail no primeiro login.',
secretHint: 'deixe em branco para manter',
secretPlaceholder: 'novo segredo',
emailClaim: 'Claim de e-mail',
emailClaimDesc: "Claim JWT usado como identidade de e-mail. Use 'preferred_username' ou 'upn' para Azure Entra ID (que não envia email_verified). Use apenas nomes de claim confiáveis.",
emailClaimPlaceholder: 'email',
requireEmailVerified: 'Exigir e-mail verificado',
requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',
requireEmailVerifiedAutoLink: 'Desabilite o vínculo automático primeiro para alterar esta configuração.',
},
},
@@ -2266,6 +2273,7 @@ export default {
passwordPlaceholder: 'Digite sua senha',
signIn: 'Entrar',
signingIn: 'Entrando...',
rememberMe: 'Lembrar de mim',
forgotPassword: 'Esqueceu sua senha?',
loginSuccess: 'Login realizado com sucesso',
loginFailed: 'Falha no login',
+8
View File
@@ -2189,6 +2189,13 @@ export default {
autoLinkDesc: '首次登录时通过邮箱匹配现有本地账户并自动关联。',
secretHint: '留空以保留当前',
secretPlaceholder: '新密钥',
emailClaim: '邮箱声明',
emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。",
emailClaimPlaceholder: 'email',
requireEmailVerified: '要求邮箱已验证',
requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
requireEmailVerifiedAutoLink: '请先禁用自动关联以更改此设置。',
},
},
@@ -2318,6 +2325,7 @@ export default {
passwordPlaceholder: '输入您的密码',
signIn: '登录',
signingIn: '登录中...',
rememberMe: '记住我',
forgotPassword: '忘记密码?',
loginSuccess: '登录成功',
loginFailed: '登录失败',
+8
View File
@@ -2187,6 +2187,13 @@ export default {
autoCreateDesc: '首次登入時自動建立本機帳戶。',
autoLink: '自動連結已有帳戶',
autoLinkDesc: '首次登入時透過信箱匹配現有本機帳戶並自動連結。',
emailClaim: '電子郵件聲明',
emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。",
emailClaimPlaceholder: 'email',
requireEmailVerified: '要求電子郵件已驗證',
requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。',
requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。',
requireEmailVerifiedAutoLink: '請先停用自動連結以變更此設定。',
secretHint: '留空以保留目前',
secretPlaceholder: '新金鑰',
},
@@ -2318,6 +2325,7 @@ export default {
passwordPlaceholder: '輸入您的密碼',
signIn: '登入',
signingIn: '登入中...',
rememberMe: '記住我',
forgotPassword: '忘記密碼?',
loginSuccess: '登入成功',
loginFailed: '登入失敗',
+60 -7
View File
@@ -6,12 +6,32 @@ import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
import { api, type LoginResponse } from '../api/client';
import { api, type LoginResponse, type TokenPersistence } from '../api/client';
import { Card, CardHeader, CardContent } from '../components/Card';
import { Button } from '../components/Button';
type LoginStep = 'credentials' | '2fa' | 'reset-password';
// sessionStorage survives the OIDC provider round-trip; React state does not.
// Read + remove in one try so all branches in the OIDC useEffect see the same
// value and a subsequent page load does not replay the flag.
const REMEMBER_ME_KEY = 'auth_remember_me';
function toPersistence(remember: boolean): TokenPersistence {
return remember ? 'persistent' : 'session';
}
function consumeSavedRememberMe(): boolean {
try {
const saved = sessionStorage.getItem(REMEMBER_ME_KEY) === '1';
sessionStorage.removeItem(REMEMBER_ME_KEY);
return saved;
} catch (err) {
console.warn('consumeSavedRememberMe: sessionStorage unavailable, Remember Me preference lost across OIDC redirect', err);
return false;
}
}
export function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -35,6 +55,8 @@ export function LoginPage() {
const [emailOTPSent, setEmailOTPSent] = useState(false);
const twoFAInputRef = useRef<HTMLInputElement>(null);
const [rememberMe, setRememberMe] = useState(false);
// H-6: Password reset step state
const [resetToken, setResetToken] = useState('');
const [newPassword, setNewPassword] = useState('');
@@ -74,6 +96,10 @@ export function LoginPage() {
const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
const oidcError = searchParams.get('oidc_error');
if (!oidcToken && !oidcError) return;
const savedRememberMe = consumeSavedRememberMe();
if (oidcError) {
// L-3: Whitelist known OIDC error codes so provider-controlled text is never
// shown verbatim. Any unknown code falls back to a generic message.
@@ -100,7 +126,6 @@ export function LoginPage() {
const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
showToast(errorMsg, 'error');
// Remove query params from URL cleanly
navigate('/login', { replace: true });
return;
}
@@ -109,6 +134,7 @@ export function LoginPage() {
api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
if (resp.requires_2fa && resp.pre_auth_token) {
// OIDC user has 2FA enabled — redirect to 2FA step
setRememberMe(savedRememberMe);
setPreAuthToken(resp.pre_auth_token);
const methods = resp.two_fa_methods ?? [];
setTwoFAMethods(methods);
@@ -119,12 +145,16 @@ export function LoginPage() {
// Remove oidc_token from URL so page refresh doesn't re-trigger exchange
navigate('/login', { replace: true });
} else if (resp.access_token && resp.user) {
loginWithToken(resp.access_token, resp.user);
loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
showToast(t('login.loginSuccess'));
navigate('/', { replace: true });
} else {
showToast(t('login.oidcLoginFailed'), 'error');
navigate('/login', { replace: true });
}
}).catch((err: Error) => {
showToast(err.message || t('login.oidcLoginFailed'), 'error');
}).catch((err: unknown) => {
console.error('OIDC token exchange failed', err);
showToast(t('login.oidcLoginFailed'), 'error');
navigate('/login', { replace: true });
});
}
@@ -132,7 +162,7 @@ export function LoginPage() {
// --- Step 1: Credentials login ---
const loginMutation = useMutation({
mutationFn: () => login(username, password),
mutationFn: () => login(username, password, toPersistence(rememberMe)),
onSuccess: (resp: LoginResponse) => {
if (resp.requires_2fa && resp.pre_auth_token) {
// 2FA required — switch to verification step
@@ -200,9 +230,12 @@ export function LoginPage() {
api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
onSuccess: (resp: LoginResponse) => {
if (resp.access_token && resp.user) {
loginWithToken(resp.access_token, resp.user);
loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
showToast(t('login.loginSuccess'));
navigate('/');
} else {
console.error('2FA verify: unexpected response shape', resp);
showToast(t('login.loginFailed'), 'error');
}
},
onError: (error: Error) => {
@@ -215,6 +248,13 @@ export function LoginPage() {
const oidcLoginMutation = useMutation({
mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
onSuccess: (data) => {
if (rememberMe) {
try {
sessionStorage.setItem(REMEMBER_ME_KEY, '1');
} catch (err) {
console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
}
}
window.location.href = data.auth_url;
},
onError: (error: Error) => {
@@ -568,6 +608,19 @@ export function LoginPage() {
</div>
</div>
<div className="flex items-center gap-2">
<input
id="remember-me"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
/>
<label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
{t('login.rememberMe')}
</label>
</div>
<div>
<button
type="submit"