session.js 2.57 KB
/**
 * 会话与 token 签发的共享编排 —— register / login / oauth / refresh 共用。
 *
 * 一次签发产出一对 access + refresh:access 是无状态 JWT(claims 含 sub/sid),
 * refresh 是不透明随机串,其哈希连同会话族(family)落库以支持轮转与 token-reuse 检测。
 * 签名/哈希全走注入 Port,本文件不碰密码学,只生成随机标识与组织 claims。
 */

const DEFAULT_ACCESS_TTL_MS = 15 * 60 * 1000 // 15 分钟
const DEFAULT_REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天

/** 生成不透明随机标识(CSPRNG,非自研)。 @returns {string} */
export function randomId() {
  return globalThis.crypto.randomUUID().replace(/-/g, '')
}

/**
 * 为某 user + 会话族签发新 access+refresh,并把会话落库。
 * 用于首次签发(family 新建)与轮转(沿用旧 family)。
 *
 * @param {Object} deps
 * @param {import('./index.js').UserRepo} deps.userRepo
 * @param {import('./index.js').TokenSigner} deps.tokenSigner
 * @param {import('./index.js').Hasher} deps.hasher
 * @param {number} [deps.accessTtlMs]
 * @param {number} [deps.refreshTtlMs]
 * @param {Object} args
 * @param {string} args.user_id
 * @param {string} [args.family]    轮转时沿用旧族;缺省新建。
 * @returns {Promise<{ access_token: string, refresh_token: string, sid: string, family: string }>}
 */
export async function issueSession(
  { userRepo, tokenSigner, hasher, accessTtlMs = DEFAULT_ACCESS_TTL_MS, refreshTtlMs = DEFAULT_REFRESH_TTL_MS },
  { user_id, family },
) {
  const sid = randomId()
  const fam = family ?? randomId()
  const refreshSecret = randomId() + randomId()
  const refresh_hash = await hasher.hash(refreshSecret)
  const expires_at = new Date(Date.now() + refreshTtlMs)

  await userRepo.saveSession({ user_id, sid, refresh_hash, family: fam, expires_at })

  const access_token = await tokenSigner.sign(
    { sub: user_id, sid, fam },
    { ttlMs: accessTtlMs },
  )
  // refresh 客户端态需携带 sid 以便 refresh 时定位会话(不暴露 hash)。
  return { access_token, refresh_token: `${sid}.${refreshSecret}`, sid, family: fam }
}

/**
 * 拆包客户端 refresh token → { sid, secret }。格式非法返 null。
 * @param {string} token
 * @returns {{ sid: string, secret: string }|null}
 */
export function parseRefreshToken(token) {
  if (typeof token !== 'string') return null
  const idx = token.indexOf('.')
  if (idx <= 0 || idx === token.length - 1) return null
  return { sid: token.slice(0, idx), secret: token.slice(idx + 1) }
}

export { DEFAULT_ACCESS_TTL_MS, DEFAULT_REFRESH_TTL_MS }