@classic-homes/ auth (英语)
为经典主题设计系统配有斯维尔特捆绑的框架-不可知认证核心.
特征
- 基于 JWT 并自动刷新令牌的认证
- 可配置提供者的SSO(单一签名)支持和登录
- 多要素认证(MFA/TOTP)支持类型守护
- 成功登录时自动设置认证状态
- 插件存储适配器( localStorage, sessionStorage, 或自定义)
- 认证状态的 Svelte 反应存储
- 保护页面的路由守卫
- TypeScript 第一种具有完全类型安全性
安装
npm install @classic-homes/auth
快速启动
1. 初始化认证
在您的应用程序的切入点( 例如 ), hooks.client.ts 用于斯维尔特基特:
import { initAuth } from '@classic-homes/auth';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
initAuth({
baseUrl: 'https://api.example.com',
storage: {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
},
// ⚠️ SECURITY: localStorage (also the default when `storage` is omitted)
// persists the long-lived refresh token where any XSS can read it.
// Safer options exported from '@classic-homes/auth/core':
// storage: createSessionStorage() // per-tab, cleared on close
// storage: createMemoryStorage() // never persisted, lost on reload
// or keep tokens out of JS entirely with an httpOnly-cookie flow.
// SSO configuration (optional)
sso: {
enabled: true,
provider: 'authentik',
},
// Callback when auth errors occur
onAuthError: (error) => {
console.error('Auth error:', error);
},
// Callback when tokens are refreshed
onTokenRefresh: (tokens) => {
console.log('Tokens refreshed');
},
// Callback when user is logged out
onLogout: () => {
goto(`${base}/auth/login`);
},
});
2. 登录自动设定 Auth
这个 authService.login() 方法在成功登录时自动设置认证状态 :
import {
authService,
isMfaChallengeResponse,
getMfaToken,
getAvailableMethods,
} from '@classic-homes/auth/core';
import { goto } from '$app/navigation';
async function handleLogin(email: string, password: string) {
const response = await authService.login({
username: email,
password: password,
});
// Check if MFA is required
if (isMfaChallengeResponse(response)) {
const mfaToken = getMfaToken(response);
const methods = getAvailableMethods(response);
// Redirect to MFA challenge page
goto(`/auth/mfa-challenge?token=${mfaToken}&methods=${methods.join(',')}`);
return;
}
// Auth state is automatically set - redirect to dashboard
goto('/dashboard');
}
Email-MFA账户通过电子邮件获得代码: 发送它与挑战符,
然后与 method: 'email'冷却(一分钟一个代码,五分钟一个)
小时)抛出 MFAChallengeEmailError 与 retryAfterSeconds。 。 。 .
import { authService, MFAChallengeEmailError } from '@classic-homes/auth';
try {
const { email } = await authService.sendMFAChallengeEmail(mfaToken); // "jo***@example.com"
} catch (error) {
if (error instanceof MFAChallengeEmailError && error.retryAfterSeconds) {
// Show "Resend in Ns"
}
}
await authService.verifyMFAChallenge({ mfaToken, code, method: 'email' });
要禁用自动设置认证( 用于手动控制) :
const response = await authService.login(credentials, { autoSetAuth: false });
// Manually set auth state
authActions.setAuth(response.accessToken, response.refreshToken, response.user);
3. 使用 Auth 存储器( Svelte)
<script lang="ts">
import { authStore, authActions, isAuthenticated, currentUser } from '@classic-homes/auth/svelte';
// Using derived stores
// $isAuthenticated - boolean
// $currentUser - User | null
async function handleLogout() {
// SSO-aware logout
const result = await authActions.logoutWithSSO();
if (result.ssoLogoutUrl) {
// Redirect to SSO provider logout
window.location.href = result.ssoLogoutUrl;
} else {
goto('/auth/login');
}
}
</script>
{#if $isAuthenticated}
<p>Welcome, {$currentUser?.firstName}</p>
<button onclick={handleLogout}>Logout</button>
{:else}
<a href="/auth/login">Login</a>
{/if}
4. 包含重定向 URL 的 SSO 登录
import { authService } from '@classic-homes/auth/core';
function handleSSOLogin(redirectUrl: string) {
// Specify where to redirect after SSO callback
authService.initiateSSOLogin({
callbackUrl: `${window.location.origin}/auth/sso-callback`,
redirectUrl: redirectUrl, // Final destination after auth
});
}
initiateSSOLogin 也可以随机发送 client_state,保留在这个标签的
sessionStorage(单用,10分钟TTL). API在回电中回响
散列( 散列)client_state- 检查
这个 在此之前 存储散列中的任何符号,所以一个精心设计的回调 URL 无法
将用户签入他人账户( 登录 CSRF ):
import { verifySsoClientState } from '@classic-homes/auth/core';
const params = new URLSearchParams(window.location.hash.slice(1));
const { accepted, result } = verifySsoClientState(params); // 'ok' | 'mismatch' | 'missing'
if (!accepted) {
window.history.replaceState({}, '', window.location.pathname);
throw new Error('This sign-in link was not started from this browser tab. Please sign in again.');
}
设定 sso.requireClientState: true 一旦API回响 client_state 实值
部署在那个环境中 在此之前(默认),每个结果都是
已接受,所以日志 result 以观看推出.
5. 外交部挑战核查
import { authService } from '@classic-homes/auth/core';
async function handleMFAVerify(mfaToken: string, code: string, trustDevice: boolean) {
// Auto-sets auth state on success
const response = await authService.verifyMFAChallenge({
mfaToken,
code,
method: 'totp',
trustDevice,
});
// Auth state is automatically set - redirect to dashboard
goto('/dashboard');
}
6. 保护路线
// src/routes/dashboard/+page.ts
import { checkAuth, requireRole } from '@classic-homes/auth/svelte';
import { redirect } from '@sveltejs/kit';
import { browser } from '$app/environment';
export function load({ url }) {
if (browser) {
const result = checkAuth();
if (!result.allowed) {
throw redirect(302, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`);
}
}
return {};
}
// For role-based access:
export function load({ url }) {
if (browser) {
const result = checkAuth({ roles: ['admin', 'manager'] });
if (!result.allowed) {
if (result.reason === 'not_authenticated') {
throw redirect(302, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`);
}
if (result.reason === 'missing_role') {
throw redirect(302, '/unauthorized');
}
}
}
return {};
}
作用
用户持有 设定 作用问题user.roles) (中文(简体) ). 他们的许可是工会
因此用户的访问比任何单一的角色都要广泛.
import { authStore } from '@classic-homes/auth/svelte';
// A manager who is also an agent
authStore.user?.roles; // ['manager', 'agent']
authStore.hasRole('manager'); // true
authStore.hasRole('agent'); // true — secondary roles count
authStore.hasAnyRole(['admin', 'agent']); // true
authStore.hasAllRoles(['manager', 'agent']); // true
角色检查是准确的成员. 持有 admin 并不意味着 agent数字 :
authStore.user?.roles; // ['admin']
authStore.hasRole('agent'); // false
这个镜像了API,所以UI不能提供服务器拒绝的东西.
也让管理员通过,列出你接受的角色:
checkAuth({ roles: ['admin', 'agent'] })。 。 。 .
显示一个角色
computePrimaryRole 解决用于显示的最高级别角色。 这是一个标签,
不是一种能力,绝不是任何一种能力:
import { computePrimaryRole, getEffectiveLevel } from '@classic-homes/auth/core';
computePrimaryRole(['user', 'admin']); // 'admin'
computePrimaryRole([]); // 'guest'
getEffectiveLevel(['user', 'manager']); // 3
// Or format the whole set
import { formatUserRoles } from '@classic-homes/auth/core';
formatUserRoles(user); // 'Manager, Agent'
formatUserRoles(user, { max: 1 }); // 'Manager +1 more'
管理另一个用户的角色
要求数 users:read 列表和 users:update 改为:
import { authApi } from '@classic-homes/auth/core';
const { roles, primaryRole } = await authApi.getUserRoles(userId);
await authApi.grantUserRole(userId, 'agent'); // idempotent
await authApi.revokeUserRole(userId, 'agent');
await authApi.setUserRoles(userId, ['manager', 'agent']); // replaces the set
围绕设计的两个服务器规则:
- 角色变化不影响该用户的活会话. 权限是 在铸造一个符号时计算,所以补助金在他们的下个符号上生效 刷新——不是他们的下个请求.
- 服务器拒绝删除用户的 最后一个 作用,或授予在 高于代理用户自己的级别。 两者都是错误的.
托肯刷新
访问令牌是短寿命并刷新令牌旋转(单用),所以 a
刷新永远不能为同一目的运行两次. refreshSession() 是那个
刷新路径——客户端自己的401重试使用它,任何代码也应该使用
主动刷新 :
import { refreshSession } from '@classic-homes/auth/core';
// Before a route guard reads claims (roles/permissions live in the JWT):
const outcome = await refreshSession({ onlyIfExpired: true });
// 'fresh' — token still valid, nothing sent
// 'refreshed' — tokens stored and the Svelte store already updated
// 'rejected' — session cleared (handleSessionExpired/onSessionExpired/onLogout ran)
// 'unavailable' — server unreachable/5xx even after retries; session kept, try later
保证:每分页单飞(目前呼叫者共用一个呼叫),
以 Web 锁定在标签间序列化, 如果旋转了另一个标签, 则调节
首先 短暂的失败 refreshRetryDelays 备份
(默认) [1000, 3000]) (中文(简体) ). 不要打电话 authApi.refreshToken + 键 updateStoredTokens
绕过上面所有的东西 并让一个旋转的孤儿
符号 .
打电话 initCrossTabSync() 一旦在应用中开始这样一个刷新或签出另一个
选项卡将这个标签的斯维尔特商店补水.
API 错误和访问调试
非-2xx的答复 apiRequest 编号 : api.* 丢出一个 ApiError。 。
message 是服务器的消息(如以前),加 status,《宪章》 code, (中文(简体) ),
details, (中文(简体) ), method 财务报告和审定财务报表 endpoint数字 :
import { isApiError } from '@classic-homes/auth/core';
try {
await api.get('/homes', true);
} catch (e) {
if (isApiError(e) && e.status === 403) {
console.log(e.details?.required); // e.g. ['homes:read']
}
}
访问探测器
钩子,比如搜索器 他们是惰性的 直到安装,在服务器上什么都不做.
setAccessProbe(fn)收到每个访问决定:核心hasRole*存储has*, (中文(简体) ),checkAuth(单位:千美元)source: 'route', (中文(简体) ),filterByAccess编号 :canAccess(单位:千美元)'nav')和API 403s (英语:'http',只有路径,没有查询字符串). 每次活动都有要求,allowed和什么情况missing。 。 。 .- 在安装探测器时, 请求 API 源头( 从来没有其他主机)
发送
X-Access-Debug: 1。CHAPI 回复管理会话X-Access-Redacted: taxId=vendors:read:sensitive, notes=*,作为 资料source: 'redacted'活动(allowed: true,只有路径)fields: { name, permission }[]· ;*表示没有配置权限 (默认-否定). 它反映了真正的象征, 从来没有"视同"。 没有头,没有头 活动。 自定义头条使跨源 API 请求在飞行前完成 . setAccessSubject(userId, { roles, permissions })将 UI 预览为另一个 角色集 (“ 视图为” ) 仅针对该用户。 其他用户的检查是 未受影响,authStore.user从未更改,预览结束于任意 登录、登录或会话过期。 签入角色标注( E)allowedRoles总是 使用真正的角色.evaluateAccess(subject, requirement)是共享的任意/所有决定checkAuth用途;checkAuth成果现在包括missing拒绝.
这些改变只涉及UI的决定. API 授权每个请求与真实 符号,所以预览永远不能允许访问 .
API 参考
核心出口
import {
// Initialization
initAuth,
getConfig,
isInitialized,
// Service
authService,
AuthService,
// API
authApi,
// MFA Guards
isMfaChallengeResponse,
isLoginSuccessResponse,
getMfaToken,
getAvailableMethods,
// JWT Utilities
decodeJWT,
isTokenExpired,
getTokenRemainingTime,
// Roles
ROLE_NAMES,
ROLE_HIERARCHY,
getUserRoles,
computePrimaryRole,
getEffectiveLevel,
hasRole,
hasAnyRole,
hasAllRoles,
// Errors
ApiError,
isApiError,
// Access probe (see "API errors and access debugging")
setAccessProbe,
setAccessSubject,
clearAccessSubject,
evaluateAccess,
// Types
type User,
type UserRolesResponse,
type RoleName,
type AuthState,
type LoginCredentials,
type LoginResponse,
type LogoutResponse,
type RegisterData,
type AuthConfig,
type LoginOptions,
type MFAVerifyOptions,
} from '@classic-homes/auth/core';
Svelte 导出
import {
// Store
authStore,
isAuthenticated,
currentUser,
// Actions
authActions,
// Guards
checkAuth,
createAuthGuard,
requireAuth,
requireRole,
requirePermission,
protectedLoad,
} from '@classic-homes/auth/svelte';
配置选项
interface AuthConfig {
/** Base URL for the auth API */
baseUrl: string;
/** Custom fetch implementation (useful for SSR or testing) */
fetch?: typeof fetch;
/** Storage adapter for token persistence */
storage?: StorageAdapter;
/** Storage key prefix for auth data */
storageKey?: string;
/** SSO configuration */
sso?: {
enabled: boolean;
provider: string;
authorizeUrl?: string;
/** Reject SSO callbacks whose client_state is missing or wrong (default false) */
requireClientState?: boolean;
};
/** Callback when auth errors occur */
onAuthError?: (error: Error) => void;
/** Callback when tokens are refreshed */
onTokenRefresh?: (tokens: { accessToken: string; refreshToken: string }) => void;
/** Callback when user is logged out */
onLogout?: () => void;
}
Auth 动作
这个 authActions 对象提供认证操作的方法:
// Set auth data after login
authActions.setAuth(accessToken, refreshToken, user, sessionToken);
// Update tokens after refresh
authActions.updateTokens(accessToken, refreshToken);
// Update user profile
authActions.updateUser(user);
// Clear auth state (local logout)
authActions.logout();
// SSO-aware logout (calls API, returns SSO logout URL if applicable)
const result = await authActions.logoutWithSSO();
if (result.ssoLogoutUrl) {
window.location.href = result.ssoLogoutUrl;
}
// Permission and role checks
authActions.hasPermission('users:read');
authActions.hasRole('admin');
authActions.hasAnyRole(['admin', 'manager']);
authActions.hasAllRoles(['admin', 'manager']);
authActions.hasAnyPermission(['users:read', 'users:write']);
authActions.hasAllPermissions(['users:read', 'users:write']);
// Reload auth from storage
authActions.rehydrate();
Auth 存储状态
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: User | null;
isAuthenticated: boolean;
}
使用时使用@ classic- homes/ some- svelte
认证软件包与窗体验证结合 @classic-homes/theme-svelte数字 :
<script lang="ts">
import { useForm, loginSchema } from '@classic-homes/theme-svelte';
import { authService, isMfaChallengeResponse, getMfaToken } from '@classic-homes/auth/core';
import { goto } from '$app/navigation';
const form = useForm({
schema: loginSchema,
initialValues: {
email: '',
password: '',
rememberMe: false,
},
onSubmit: async (data) => {
const response = await authService.login({
username: data.email,
password: data.password,
});
if (isMfaChallengeResponse(response)) {
const mfaToken = getMfaToken(response);
goto(`/auth/mfa-challenge?token=${mfaToken}`);
return;
}
// Auth state automatically set
goto('/dashboard');
},
});
</script>
<form onsubmit={form.handleSubmit}>
<input bind:value={form.data.email} />
{#if form.errors.email}
<span class="error">{form.errors.email}</span>
{/if}
<!-- ... -->
</form>
自动刷新
当 :
- API 请求返回 401 未授权
- 刷新令牌是有效的
Svelte 商店在刷新符号时会自动更新, 因此您的 UI 保持同步 .
测试公用事业
认证包包括用于单位的综合测试设施和整合测试.
安装
# The testing utilities are included in the main package
npm install @classic-homes/auth
快速启动
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import {
setupTestAuth,
mockUser,
configureMFAFlow,
assertAuthenticated,
} from '@classic-homes/auth/testing';
import { authService, isMfaChallengeResponse } from '@classic-homes/auth/core';
describe('Login Flow', () => {
let cleanup: () => void;
let mockFetch;
beforeEach(() => {
const ctx = setupTestAuth();
cleanup = ctx.cleanup;
mockFetch = ctx.mockFetch;
});
afterEach(() => cleanup());
it('handles successful login', async () => {
const response = await authService.login({
username: '[email protected]',
password: 'password',
});
expect(response.user).toMatchObject(mockUser);
mockFetch.assertCalled('/auth/login');
});
it('handles MFA flow', async () => {
configureMFAFlow(mockFetch);
const response = await authService.login({
username: '[email protected]',
password: 'password',
});
expect(isMfaChallengeResponse(response)).toBe(true);
});
});
测试出口
import {
// Fixtures - Pre-defined test data
mockUser,
mockAdminUser,
mockSSOUser,
mockMFAUser,
mockAccessToken,
mockRefreshToken,
mockLoginSuccess,
mockMFARequired,
createMockUser,
createMockTokenPair,
createMockLoginSuccess,
// Mocks - Test doubles for dependencies
MockStorageAdapter,
MockFetchInstance,
MockAuthStore,
createMockStorage,
createMockFetch,
createMockAuthStore,
// Setup Helpers
setupTestAuth,
createTestAuthHelpers,
quickSetupAuth,
withTestAuth,
// State Simulation
authScenarios,
applyScenario,
configureMFAFlow,
configureTokenRefresh,
configureSSOLogout,
simulateLogin,
simulateLogout,
// Assertions
assertAuthenticated,
assertUnauthenticated,
assertHasPermissions,
assertHasRoles,
assertTokenValid,
assertApiCalled,
assertStoreMethodCalled,
assertRequiresMFA,
} from '@classic-homes/auth/testing';
雾取
这个 MockFetchInstance 提供带有预先定义的认证路径的可配置的模拟获取 :
const ctx = setupTestAuth();
const { mockFetch } = ctx;
// Default routes are pre-configured for all auth endpoints
// Customize responses
mockFetch.requireMFA(); // Login requires MFA
mockFetch.failLogin('Invalid credentials'); // Login fails
mockFetch.enableSSOLogout(); // Logout returns SSO URL
// Add custom routes
mockFetch.addRoute({
method: 'GET',
path: '/custom/endpoint',
response: { data: 'custom response' },
});
// Fail specific endpoints
mockFetch.failEndpoint('GET', '/auth/profile', 403, 'Forbidden');
// Check call history
expect(mockFetch.wasCalled('/auth/login')).toBe(true);
mockFetch.assertCalled('/auth/profile');
mockFetch.assertNotCalled('/auth/logout');
Mock Auth 存储器
这个 MockAuthStore 模仿 Svelte 认证商店 :
const store = createMockAuthStore();
// Simulate states
store.simulateAuthenticated(mockAdminUser);
store.simulateUnauthenticated();
// Direct state manipulation
store.setState({ isAuthenticated: true, user: mockUser });
// Check method calls
store.assertMethodCalled('setAuth');
store.assertMethodNotCalled('logout');
// Get call history
const calls = store.getCallsFor('setAuth');
预定义情景
应用常见的认证方案进行测试 :
import { authScenarios, applyScenario } from '@classic-homes/auth/testing';
// Available scenarios:
// - 'unauthenticated'
// - 'authenticated'
// - 'admin'
// - 'ssoUser'
// - 'mfaEnabled'
// - 'unverifiedEmail'
// - 'inactive'
// - 'expiredToken'
const store = createMockAuthStore();
applyScenario(store, 'admin');
expect(store.user?.roles).toContain('admin');
自定义请求
使用内置断言进行常见检查:
import {
assertAuthenticated,
assertHasPermissions,
assertTokenValid,
assertApiCalled,
assertRequiresMFA,
} from '@classic-homes/auth/testing';
// Auth state assertions
assertAuthenticated(store.getState());
assertUnauthenticated(store.getState());
// Permission assertions
assertHasPermissions(user, ['read:profile', 'write:profile']);
assertHasRoles(user, ['admin']);
// Token assertions
assertTokenValid(accessToken);
assertTokenExpired(oldToken);
// API call assertions
assertApiCalled(mockFetch, 'POST', '/auth/login', {
times: 1,
body: { username: 'test', password: 'pass' },
});
// MFA assertions
assertRequiresMFA(loginResponse);
assertNoMFARequired(loginResponse);
孤立的试验背景
在孤立的认证背景中运行测试 :
import { withTestAuth } from '@classic-homes/auth/testing';
// Automatic setup and cleanup
await withTestAuth(async ({ mockFetch, mockStore }) => {
mockFetch.requireMFA();
const response = await authService.login({ username: 'test', password: 'pass' });
expect(response.requiresMFA).toBe(true);
});
用户固定
创建自定义测试用户 :
import {
mockUser,
mockAdminUser,
createMockUser,
createMockUserWithRoles,
} from '@classic-homes/auth/testing';
// Use pre-defined users
expect(mockUser.roles).toEqual(['user']);
expect(mockAdminUser.permissions).toContain('manage:system');
// Create custom users
const customUser = createMockUser({
email: '[email protected]',
firstName: 'Custom',
});
// Create users with specific RBAC
const managerUser = createMockUserWithRoles(['manager', 'user'], ['read:reports', 'write:reports']);
许可证
麻省理工学院