支持 ESM import 引入 / script 外联引入,框架无关
代码点击下载 安装依赖&运行
一、SDK 目录结构
sso-sdk/
├── src/
│ ├── token.js # Token 存取、JWT 解析
│ ├── broadcast.js # 多 Tab 登出同步
│ ├── request.js # axios 实例 + 无感刷新
│ └── index.js # 统一入口,export + UMD 兼容
├── dist/
│ └── sso-sdk.js # 构建产物,script 外联用
├── build.js # esbuild 打包脚本
└── package.json
二、核心实现
2.1 Token 管理
// src/token.js
// Access Token 存内存,防止 XSS 从 localStorage 读取
let _accessToken = null
const REFRESH_KEY = 'sso_refresh_token'
export function getAccessToken() {
return _accessToken
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_KEY)
}
export function setTokens(access, refresh) {
_accessToken = access
if (refresh) localStorage.setItem(REFRESH_KEY, refresh)
}
export function clearTokens() {
_accessToken = null
localStorage.removeItem(REFRESH_KEY)
}
// 解析 JWT payload,不依赖任何库
export function parseToken(token) {
try {
return JSON.parse(atob(token.split('.')[1]))
} catch {
return null
}
}
// 判断 access token 是否已过期
export function isExpired() {
if (!_accessToken) return true
const payload = parseToken(_accessToken)
if (!payload?.exp) return true
return payload.exp < Date.now() / 1000
}
2.2 多 Tab 登出同步
// src/broadcast.js
const CHANNEL_NAME = 'sso_auth_sync'
let _channel = null
export function initBroadcast(handlers = {}) {
if (typeof BroadcastChannel === 'undefined') return
_channel = new BroadcastChannel(CHANNEL_NAME)
_channel.onmessage = ({ data }) => {
if (data.type === 'LOGOUT') handlers.onLogout?.()
if (data.type === 'TOKEN_REFRESH') handlers.onRefresh?.(data.accessToken)
}
}
export function broadcastLogout() {
_channel?.postMessage({ type: 'LOGOUT' })
}
export function broadcastRefresh(accessToken) {
_channel?.postMessage({ type: 'TOKEN_REFRESH', accessToken })
}
export function destroyBroadcast() {
_channel?.close()
_channel = null
}
2.3 axios 实例 + 无感刷新
// src/request.js
import axios from 'axios'
import { getAccessToken, getRefreshToken, setTokens } from './token.js'
let isRefreshing = false
let pendingQueue = []
function subscribeRefresh(cb) {
pendingQueue.push(cb)
}
function notifyRefresh(newToken) {
pendingQueue.forEach((cb) => cb(newToken))
pendingQueue = []
}
function flushQueue(error) {
pendingQueue.forEach((cb) => cb(null, error))
pendingQueue = []
}
export function createRequest({ baseURL, refreshPath, onLogout }) {
const instance = axios.create({ baseURL, timeout: 10000 })
// 注入 Token
instance.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 无感刷新
instance.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
// 已在刷新中,当前请求进队列挂起
if (isRefreshing) {
return new Promise((resolve, reject) => {
subscribeRefresh((newToken, err) => {
if (err) return reject(err)
original.headers['Authorization'] = `Bearer ${newToken}`
resolve(instance(original))
})
})
}
original._retry = true
isRefreshing = true
try {
const { data } = await axios.post(`${baseURL}${refreshPath}`, {
refreshToken: getRefreshToken(),
})
setTokens(data.accessToken, data.refreshToken)
notifyRefresh(data.accessToken)
original.headers['Authorization'] = `Bearer ${data.accessToken}`
return instance(original)
} catch (err) {
flushQueue(err)
onLogout?.()
return Promise.reject(err)
} finally {
isRefreshing = false
}
}
)
return instance
}
2.4 SDK 主入口
// src/index.js
import axios from 'axios'
import { setTokens, clearTokens, getAccessToken, getRefreshToken, isExpired } from './token.js'
import { createRequest } from './request.js'
import { initBroadcast, broadcastLogout, destroyBroadcast, broadcastRefresh } from './broadcast.js'
export function createSSOClient(options = {}) {
const {
baseURL,
loginPath = '/api/auth/login',
logoutPath = '/api/auth/logout',
refreshPath = '/api/auth/refresh',
onLogout,
} = options
function handleLogout() {
clearTokens()
broadcastLogout()
onLogout?.()
}
initBroadcast({
onLogout: handleLogout,
onRefresh: (newToken) => {
// 其他 Tab 刷新了 Token,同步当前 Tab 内存
setTokens(newToken, getRefreshToken())
broadcastRefresh(newToken)
},
})
const request = createRequest({ baseURL, refreshPath, onLogout: handleLogout })
async function login(username, password) {
const { data } = await axios.post(`${baseURL}${loginPath}`, { username, password })
setTokens(data.accessToken, data.refreshToken)
return data
}
async function logout() {
try {
await request.post(logoutPath)
} finally {
handleLogout()
}
}
function getToken() {
return getAccessToken()
}
function isLoggedIn() {
return !!getAccessToken() && !isExpired()
}
function destroy() {
destroyBroadcast()
}
return { login, logout, getToken, isLoggedIn, request, destroy }
}
// 兼容 script 外联:挂到 window 上
if (typeof window !== 'undefined') {
window.SSOClient = { createSSOClient }
}
三、打包配置(支持外联 script)
// build.js
const esbuild = require('esbuild')
// ESM 产物(import 引入)
esbuild.build({
entryPoints: ['src/index.js'],
bundle: true,
format: 'esm',
outfile: 'dist/sso-sdk.esm.js',
external: ['axios'], // axios 由宿主提供,不打进包
})
// UMD 产物(script 外联,axios 一起打进去)
esbuild.build({
entryPoints: ['src/index.js'],
bundle: true,
format: 'iife',
globalName: 'SSOClient',
outfile: 'dist/sso-sdk.js',
// 外联版本把 axios 打进来,开箱即用
})
// package.json
{
"name": "@company/sso-sdk",
"version": "1.0.0",
"main": "dist/sso-sdk.esm.js",
"module": "dist/sso-sdk.esm.js",
"exports": {
".": "./dist/sso-sdk.esm.js"
},
"scripts": {
"build": "node build.js"
},
"peerDependencies": {
"axios": "^1.0.0"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}
四、两种接入方式
4.1 ESM import 引入(Vue3 / React / 现代工程项目)
// npm install @company/sso-sdk
import { createSSOClient } from '@company/sso-sdk'
const sso = createSSOClient({
baseURL: 'https://sso.company.com',
onLogout: () => {
window.location.href = '/login'
},
})
export default sso
Vue3 中使用
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import sso from './sso' // 上面初始化好的实例
// 挂到 app 全局
const app = createApp(App)
app.config.globalProperties.$sso = sso
// 路由守卫
router.beforeEach(async (to, _from, next) => {
const WHITE_LIST = ['/login']
if (WHITE_LIST.includes(to.path)) return next()
if (sso.isLoggedIn()) return next()
try {
await sso.request.get('/api/auth/check')
next()
} catch {
next({ path: '/login', query: { redirect: to.fullPath } })
}
})
app.use(router).mount('#app')
<!-- 业务组件里用 -->
<script setup>
import { getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
const sso = proxy.$sso
async function handleLogin() {
await sso.login('admin', '123456')
proxy.$router.push('/dashboard')
}
</script>
React 中使用
// 用 Context 把 sso 实例透传下去
import { createContext, useContext } from 'react'
import sso from './sso'
const SSOContext = createContext(sso)
export function SSOProvider({ children }) {
return <SSOContext.Provider value={sso}>{children}</SSOContext.Provider>
}
export function useSSO() {
return useContext(SSOContext)
}
// main.jsx
import { SSOProvider } from './SSOContext'
createRoot(document.getElementById('root')).render(
<SSOProvider>
<App />
</SSOProvider>
)
// 业务组件
import { useSSO } from './SSOContext'
export default function Header() {
const sso = useSSO()
return (
<button onClick={() => sso.logout()}>退出</button>
)
}
4.2 script 外联引入(无框架 / 老项目 / 不想走构建流程)
<!-- 直接外联,不需要 npm,不需要构建 -->
<script src="https://static.company.com/sso-sdk.js"></script>
<script>
// sso-sdk.js 构建时挂在 window.SSOClient 上
const sso = SSOClient.createSSOClient({
baseURL: 'https://sso.company.com',
onLogout: function() {
window.location.href = '/login.html'
},
})
// 发请求(自动带 Token + 无感刷新)
sso.request.get('/api/user/profile').then(function(res) {
console.log(res.data)
})
// 登录
document.getElementById('login-btn').addEventListener('click', function() {
var username = document.getElementById('username').value
var password = document.getElementById('password').value
sso.login(username, password).then(function() {
window.location.href = '/index.html'
})
})
// 登出
document.getElementById('logout-btn').addEventListener('click', function() {
sso.logout()
})
</script>
<!-- 页面入口鉴权(纯 JS 多页面项目) -->
<script src="https://static.company.com/sso-sdk.js"></script>
<script>
var sso = SSOClient.createSSOClient({
baseURL: 'https://sso.company.com',
onLogout: function() {
window.location.href = '/login.html'
},
})
var PUBLIC_PAGES = ['/login.html', '/register.html']
var isPublic = PUBLIC_PAGES.some(function(p) {
return window.location.pathname.indexOf(p) > -1
})
if (!isPublic && !sso.isLoggedIn()) {
window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.href)
}
</script>
五、Node.js 后端接口(配套)
// routes/auth.js
const express = require('express')
const jwt = require('jsonwebtoken')
const router = express.Router()
// 登录
router.post('/login', async (req, res) => {
const { username, password } = req.body
// ... 验证逻辑
const accessToken = jwt.sign(
{ userId: user.id },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
)
res.json({ accessToken, refreshToken })
})
// 无感刷新
router.post('/refresh', (req, res) => {
const { refreshToken } = req.body
if (!refreshToken) return res.status(401).json({ message: 'No refresh token' })
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET)
const newAccessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
)
// Refresh Token 轮转
const newRefreshToken = jwt.sign(
{ userId: payload.userId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
)
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken })
} catch {
res.status(401).json({ message: 'Invalid refresh token' })
}
})
// 轻量鉴权校验(路由守卫用)
router.get('/check', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).end()
try {
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET)
res.status(200).end()
} catch {
res.status(401).end()
}
})
module.exports = router
六、简历话术
【项目描述】
将公司多个业务系统的登录认证逻辑从各项目中抽离,独立封装为前端 SSO SDK,支持 ESM import 和 script 外联两种接入方式,框架无关,已在 Vue3、React 及纯 JS 项目中落地。
【职责/亮点】
- 设计并实现前端 SSO SDK,核心层纯 JS 编写,不依赖任何框架,对外暴露 login、logout、getToken、isLoggedIn 及内置 axios 实例
- 内置 Token 无感刷新机制,通过请求拦截器统一处理 401,引入 Promise 锁 + 请求队列解决并发刷新竞态问题,刷新期间所有挂起请求在新 Token 到位后自动重放
- 使用 BroadcastChannel 实现多 Tab 登出同步,任意标签页触发登出后其他标签页实时响应,解决多 Tab 状态不一致问题
- 通过 esbuild 分别打出 ESM 和 IIFE 两种产物:ESM 版 axios 作为 peerDependency 由宿主提供;IIFE 版将依赖打包进去,支持 script 标签直接外联,覆盖无构建工具的老项目
- Access Token 存内存不落 localStorage,Refresh Token 存 localStorage,兼顾安全性与跨页面持久化
- SDK 接入后各项目鉴权代码从平均 200 行降为 10 行以内,统一维护,修复一处全部生效
【面试追问话术】
Q:为什么要同时支持 ESM 和 script 外联两种方式?
"公司内部有几个技术栈不统一的项目,新项目用 Vue3/React,有脚手架;老项目是纯 HTML 多页面,直接在页面里写 script,没有 npm 流程。如果只出 ESM 包,老项目接不了;只出 script 版,新项目又会把 axios 打两份进来。所以用 esbuild 分两次构建:ESM 版把 axios 标记为 external,由宿主提供;IIFE 版把所有依赖打进去,挂到 window.SSOClient,script 标签引一下开箱即用。"
Q:并发 401 怎么处理的?
"加了一个 isRefreshing 布尔值做互斥锁。第一个 401 进来把锁设为 true,开始刷新;后续 401 检测到锁,把各自的 resolve 注册进 pendingQueue 挂起。刷新成功后 notifyRefresh 遍历队列,把新 Token 广播给每个挂起的请求,它们拿到 Token 后重新发一次。刷新失败就 flushQueue 把错误传下去,同时清 Token 跳登录页。"
Q:Access Token 存内存,页面刷新不就丢了吗?
"对,刷新后内存清空,但 Refresh Token 还在 localStorage 里。路由守卫里我加了一层判断:有 Refresh Token 就先请求 /api/auth/check,这个请求会触发 axios 拦截器走刷新流程,拿到新 Access Token 后路由放行,用户不会感知到跳登录页。"