返回笔记首页

SSO SDK —— 完整技术实现 + 简历话术

主题配置

支持 ESM import 引入 / script 外联引入,框架无关

代码点击下载 安装依赖&运行

sso-sdk.zip


一、SDK 目录结构

markdown
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 管理

javascript
// 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 登出同步

javascript
// 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 实例 + 无感刷新

javascript
// 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 主入口

javascript
// 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)

javascript
// 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 打进来,开箱即用
})
json
// 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 / 现代工程项目)

javascript
// 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 中使用

javascript
// 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')
vue
<!-- 业务组件里用 -->
<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 中使用
jsx
// 用 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)
}
jsx
// main.jsx
import { SSOProvider } from './SSOContext'

createRoot(document.getElementById('root')).render(
  <SSOProvider>
    <App />
  </SSOProvider>
)
jsx
// 业务组件
import { useSSO } from './SSOContext'

export default function Header() {
  const sso = useSSO()
  return (
    <button onClick={() => sso.logout()}>退出</button>
  )
}

4.2 script 外联引入(无框架 / 老项目 / 不想走构建流程)

html
<!-- 直接外联,不需要 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>
html
<!-- 页面入口鉴权(纯 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 后端接口(配套)

javascript
// 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 后路由放行,用户不会感知到跳登录页。"