commit bc0a20679bd8fb9fbd78a025683180cf82445020 Author: abearxiong Date: Sun Jan 25 02:05:23 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81618ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.pnpm-store +dist + +jwt \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6948c8c --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +//npm.cnb.cool/kevisual/registry/-/packages/:_authToken=${CNB_API_KEY} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} diff --git a/bun.config.ts b/bun.config.ts new file mode 100644 index 0000000..bc5c581 --- /dev/null +++ b/bun.config.ts @@ -0,0 +1,20 @@ +import { resolvePath } from '@kevisual/use-config'; +import { execSync } from 'node:child_process'; + +const entry = 'src/index.ts'; +const naming = 'app'; +const external = ['pm2']; +await Bun.build({ + target: 'browser', + format: 'esm', + entrypoints: [resolvePath(entry, { meta: import.meta })], + outdir: resolvePath('./dist', { meta: import.meta }), + naming: { + entry: `${naming}.js`, + }, + external, +}); + +const cmd ='dts -i src/index.ts -o app.d.ts' +execSync(cmd, { stdio: 'inherit' }); +console.log('Build completed.'); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7748f18 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@kevisual/auth", + "version": "2.0.1", + "description": "", + "scripts": { + "build": "bun run bun.config.ts" + }, + "files": [ + "dist", + "src" + ], + "keywords": [], + "author": "abearxiong (https://www.xiongxiao.me)", + "license": "MIT", + "packageManager": "pnpm@10.28.1", + "type": "module", + "dependencies": {}, + "devDependencies": { + "@kevisual/types": "^0.0.12", + "@kevisual/use-config": "^1.0.28", + "@kevisual/query": "^0.0.38", + "@kevisual/router": "^0.0.60", + "@types/bun": "^1.3.6", + "@types/node": "^25.0.10", + "es-toolkit": "^1.44.0", + "jose": "^6.1.3" + }, + "exports": { + ".": "./dist/app.js", + "./src/*": "./dist/src/*" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f7eed51 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,132 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@kevisual/query': + specifier: ^0.0.38 + version: 0.0.38 + '@kevisual/router': + specifier: ^0.0.60 + version: 0.0.60 + '@kevisual/types': + specifier: ^0.0.12 + version: 0.0.12 + '@kevisual/use-config': + specifier: ^1.0.28 + version: 1.0.28(dotenv@17.2.3) + '@types/bun': + specifier: ^1.3.6 + version: 1.3.6 + '@types/node': + specifier: ^25.0.10 + version: 25.0.10 + es-toolkit: + specifier: ^1.44.0 + version: 1.44.0 + jose: + specifier: ^6.1.3 + version: 6.1.3 + +packages: + + '@kevisual/load@0.0.6': + resolution: {integrity: sha512-+3YTFehRcZ1haGel5DKYMUwmi5i6f2psyaPZlfkKU/cOXgkpwoG9/BEqPCnPjicKqqnksEpixVRkyHJ+5bjLVA==} + + '@kevisual/query@0.0.38': + resolution: {integrity: sha512-bfvbSodsZyMfwY+1T2SvDeOCKsT/AaIxlVe0+B1R/fNhlg2MDq2CP0L9HKiFkEm+OXrvXcYDMKPUituVUM5J6Q==} + + '@kevisual/router@0.0.60': + resolution: {integrity: sha512-2v/ZzUstsaq+Uqo+tZX9ys5E+/2erPggCtljv9jTb3NA88ZdHsYUAsd5wUFvLtf9QucpJCzyWEt+InDV/98FKw==} + + '@kevisual/types@0.0.12': + resolution: {integrity: sha512-zJXH2dosir3jVrQ6QG4i0+iLQeT9gJ3H+cKXs8ReWboxBSYzUZO78XssVeVrFPsJ33iaAqo4q3DWbSS1dWGn7Q==} + + '@kevisual/use-config@1.0.28': + resolution: {integrity: sha512-ngF+LDbjxpXWrZNmnShIKF/jPpAa+ezV+DcgoZIIzHlRnIjE+rr9sLkN/B7WJbiH9C/j1tQXOILY8ujBqILrow==} + peerDependencies: + dotenv: ^17 + + '@types/bun@1.3.6': + resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==} + + '@types/node@25.0.10': + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} + + bun-types@1.3.6: + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + hono@4.11.5: + resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} + engines: {node: '>=16.9.0'} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + +snapshots: + + '@kevisual/load@0.0.6': + dependencies: + eventemitter3: 5.0.4 + + '@kevisual/query@0.0.38': + dependencies: + tslib: 2.8.1 + + '@kevisual/router@0.0.60': + dependencies: + hono: 4.11.5 + + '@kevisual/types@0.0.12': {} + + '@kevisual/use-config@1.0.28(dotenv@17.2.3)': + dependencies: + '@kevisual/load': 0.0.6 + dotenv: 17.2.3 + + '@types/bun@1.3.6': + dependencies: + bun-types: 1.3.6 + + '@types/node@25.0.10': + dependencies: + undici-types: 7.16.0 + + bun-types@1.3.6: + dependencies: + '@types/node': 25.0.10 + + dotenv@17.2.3: {} + + es-toolkit@1.44.0: {} + + eventemitter3@5.0.4: {} + + hono@4.11.5: {} + + jose@6.1.3: {} + + tslib@2.8.1: {} + + undici-types@7.16.0: {} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..81db3e4 --- /dev/null +++ b/readme.md @@ -0,0 +1,53 @@ +## JWT Configuration + +### Convex auth.config.ts + +issuer: https://convex.kevisual.cn +applicationID: convex-app + +issuer必须与JWT中的iss字段匹配,applicationID必须与aud字段匹配。 + +```ts +import { AuthConfig } from 'convex/server'; + +export default { + providers: [ + { + type: 'customJwt', + applicationID: 'convex-app', + issuer: 'https://convex.kevisual.cn', + jwks: 'https://api-convex.kevisual.cn/root/convex/jwks.json', + algorithm: 'RS256', + }, + ], +}; +``` + +### Payload 例子 + +header必须包含kid字段以匹配jwks中的密钥ID。 + +```ts +import * as jose from "jose"; +// 加载测试私钥 +const keys = JSON.parse(await Bun.file("./jwt/privateKey.json").text()); +const privateKey = await jose.importJWK(keys, "RS256"); + +// 生成 RS256 JWT +const payload = { + iss: "https://convex.kevisual.cn", + sub: "user:8fa2be73c2229e85", + aud: "convex-app", + exp: Math.floor(Date.now() / 1000) + 3600, + name: "Test User AA", + email: "test@example.com", +}; +const token = await new jose.SignJWT(payload) + .setProtectedHeader({ + "alg": "RS256", + "typ": "JWT", + "kid": "kid-key-1" + }) + .setIssuedAt() + .sign(privateKey); +``` diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..86e5313 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,123 @@ +import * as jose from 'jose'; + +/*** + * 验证 JWT + * @param token JWT 字符串 + * @param publicKey 公钥,可以是 CryptoKey、PEM 字符串或 JWK/JWKS 对象 + * @returns 解码后的有效载荷 + * @throws 如果验证失败或令牌无效,则抛出错误 + */ +export async function verifyJWT(token: string, publicKey: jose.CryptoKey | string | jose.JWK) { + try { + let key: any; + if (typeof publicKey === 'string') { + key = await jose.importSPKI(publicKey, 'RS256') + } else if (typeof publicKey === 'object' && publicKey !== null && 'keys' in publicKey) { + // JWKS 格式 + key = jose.createLocalJWKSet(publicKey as jose.JSONWebKeySet); + } else { + key = publicKey; + } + + const { payload } = await jose.jwtVerify(token, key, { + algorithms: ['RS256'], + }); + return payload; + } catch (error) { + if (error instanceof jose.errors.JWTExpired) { + console.error('JWT has expired:'); + throw new Error('Token has expired'); + } + console.error('JWT verification failed:', error); + throw new Error('Invalid token'); + } +} +export type JWTPayload = { + /** + * 会设置默认值: "https://convex.kevisual.cn" + */ + iss?: string, + /** + * "user:8fa2be73c2229e85" + * "ip:192.168.1.1" + */ + sub: string, + /** + * 会设置默认值:"convex-app" + */ + aud?: string, + /** + * 会设置默认值:当前时间 + 2 小时 + */ + exp?: number, + /** + * 会设置默认值:当前时间 + */ + iat?: number, + /** + * 其他自定义字段 + */ + name?: string, + /** + * email + */ + email?: string +} & T; + +/*** + * 签发 JWT + * @param payload 有效载荷 + * @param privateKey 私钥,可以是 CryptoKey、PEM 字符串或 JWK 对象 + * @returns 签发的 JWT 字符串 + */ +export async function signJWT(payload: JWTPayload, privateKey: jose.CryptoKey | string | jose.JWK): Promise { + const expirationTime = Math.floor(Date.now() / 1000) + (2 * 60 * 60); // 2 hour from now + if (!payload.exp) { + payload.exp = expirationTime; + } + let cryptoKey: jose.CryptoKey; + if (typeof privateKey === 'string') { + cryptoKey = await jose.importPKCS8(privateKey, "RS256") as jose.CryptoKey; + } else if (typeof privateKey === 'object' && privateKey !== null && 'kty' in privateKey) { + cryptoKey = await jose.importJWK(privateKey, "RS256") as jose.CryptoKey; + } else { + cryptoKey = privateKey as jose.CryptoKey; + } + const iss = payload.iss || "https://convex.kevisual.cn"; + if (!payload.iss) { + payload.iss = iss; + } + if (!payload.iat) { + payload.iat = Math.floor(Date.now() / 1000); + } + if (!payload.aud) { + payload.aud = "convex-app"; + } + + const token = await new jose.SignJWT(payload) + .setProtectedHeader({ + "alg": "RS256", + "typ": "JWT", + "kid": "kid-key-1" + }) + .setIssuedAt() + .setExpirationTime(payload.exp) + .setIssuer(iss) + .setSubject(payload.sub || "") + .sign(cryptoKey); + return token; +} + +/** + * 单独解码 JWT,不验证签名 + * @param token + * @returns + */ +export const decodeJWT = (token: string): JWTPayload => { + try { + const decoded = jose.decodeJwt(token); + return decoded as JWTPayload; + } catch (error) { + throw new Error('Invalid token'); + } +} \ No newline at end of file diff --git a/src/generate.ts b/src/generate.ts new file mode 100644 index 0000000..8cddef8 --- /dev/null +++ b/src/generate.ts @@ -0,0 +1,39 @@ +import * as jose from 'jose'; + +async function generateKeyPair() { + const { privateKey, publicKey } = await jose.generateKeyPair('RS256', { + modulusLength: 2048, + extractable: true, + }); + + return { privateKey, publicKey }; +} + +async function createJWKS(publicKey: CryptoKey, kid?: string) { + const jwk = await jose.exportJWK(publicKey); + // 添加 kid 字段 + jwk.kid = kid || 'kid-key-1'; + const jwks = { + keys: [jwk] + }; + return jwks; +} + +type GenerateOpts = { + kid?: string; +} +export const generate = async (opts: GenerateOpts = {}) => { + const { privateKey, publicKey } = await generateKeyPair(); + const jwks = await createJWKS(publicKey, opts.kid); + + // 将私钥和 JWKS 保存到文件 + const privateJWK = await jose.exportJWK(privateKey); + const privatePEM = await jose.exportPKCS8(privateKey); + const publicPEM = await jose.exportSPKI(publicKey); + return { + jwks, + privateJWK, + privatePEM, + publicPEM + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..801a082 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from './auth.ts'; \ No newline at end of file diff --git a/src/jwks/common.ts b/src/jwks/common.ts new file mode 100644 index 0000000..23f12fe --- /dev/null +++ b/src/jwks/common.ts @@ -0,0 +1,8 @@ +import path from 'node:path'; +const dir = path.join(process.cwd(), 'jwt'); + +export const JWKS_PATH = path.join(dir, 'jwks.json'); +export const PRIVATE_JWK_PATH = path.join(dir, 'privateKey.json'); + +export const PRIVATE_KEY_PATH = path.join(dir, 'privateKey.txt'); +export const PUBLIC_KEY_PATH = path.join(dir, 'publicKey.txt'); diff --git a/src/jwks/create.ts b/src/jwks/create.ts new file mode 100644 index 0000000..d221c9a --- /dev/null +++ b/src/jwks/create.ts @@ -0,0 +1,23 @@ + +import fs from 'node:fs' +import path from 'node:path' +import * as common from './common.ts'; +import { generate } from '../generate.ts' + +async function main() { + const { jwks, privateJWK, privatePEM, publicPEM } = await generate(); + const dir = path.join(process.cwd(), 'jwt'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + fs.writeFileSync(common.PUBLIC_KEY_PATH, publicPEM); + + fs.writeFileSync(common.PRIVATE_KEY_PATH, privatePEM); + + fs.writeFileSync(common.PRIVATE_JWK_PATH, JSON.stringify(privateJWK, null, 2)); + fs.writeFileSync(common.JWKS_PATH, JSON.stringify(jwks, null, 2)); + + console.log('Private key and JWKS have been saved to files.'); +} + +main().catch(console.error); \ No newline at end of file diff --git a/src/jwks/get.ts b/src/jwks/get.ts new file mode 100644 index 0000000..d76e566 --- /dev/null +++ b/src/jwks/get.ts @@ -0,0 +1,22 @@ + +import fs from 'node:fs' +import path from 'node:path' +import * as common from './common.ts'; + +export const getValues = () => { + if (!fs.existsSync(common.JWKS_PATH)) { + throw new Error('JWKS file does not exist. Please create it first.'); + } + const jwksData = fs.readFileSync(common.JWKS_PATH, 'utf-8'); + const privateJWKData = fs.readFileSync(common.PRIVATE_JWK_PATH, 'utf-8'); + const publicKeyPEM = fs.readFileSync(common.PUBLIC_KEY_PATH, 'utf-8'); + const privateKeyPEM = fs.readFileSync(common.PRIVATE_KEY_PATH, 'utf-8'); + + return { + jwks: JSON.parse(jwksData), + privateJWK: JSON.parse(privateJWKData), + publicKeyPEM, + privateKeyPEM + } +} + diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..82040dd --- /dev/null +++ b/src/router.ts @@ -0,0 +1,2 @@ +import type { App } from '@kevisual/router'; + diff --git a/test/create.ts b/test/create.ts new file mode 100644 index 0000000..2c59095 --- /dev/null +++ b/test/create.ts @@ -0,0 +1,38 @@ +import * as jose from 'jose'; +import { signJWT, decodeJWT, verifyJWT } from '../src/auth.ts'; +import { getValues } from '../src/jwks/get.ts'; + +const payload = { + iss: "https://convex.kevisual.cn", + sub: "user:123456", + aud: "convex-app", + name: "John Doe", + email: "john.doe@example.com", + // exp: Math.floor(Date.now() / 1000) - (2 * 60 * 60) // 2 hours from now +} + +const createToken = async () => { + const { privateJWK, privateKeyPEM } = getValues(); + const token = await signJWT(payload, privateKeyPEM); + console.log('Generated JWT:', token); + + // console.log('expited at:', new Date(payload.exp * 1000).toLocaleString()); + return token; +} +const token = await createToken() + +const decode = decodeJWT(token) + +console.log('Decoded JWT:', decode); + +const verify = async () => { + const { publicKeyPEM, privateJWK, jwks } = getValues(); + try { + const verifiedPayload = await verifyJWT(token, publicKeyPEM); + console.log('Verified JWT payload:', verifiedPayload); + } catch (error) { + console.error('Verification failed:', error); + } +} + +await verify();