This commit is contained in:
2026-01-25 02:05:23 +08:00
commit bc0a20679b
14 changed files with 504 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.pnpm-store
dist
jwt

3
.npmrc Normal file
View File

@@ -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}

20
bun.config.ts Normal file
View File

@@ -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.');

35
package.json Normal file
View File

@@ -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 <xiongxiao@xiongxiao.me> (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"
}
}

132
pnpm-lock.yaml generated Normal file
View File

@@ -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: {}

53
readme.md Normal file
View File

@@ -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);
```

123
src/auth.ts Normal file
View File

@@ -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<T = {}> = {
/**
* 会设置默认值: "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<string> {
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');
}
}

39
src/generate.ts Normal file
View File

@@ -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
}
}

1
src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './auth.ts';

8
src/jwks/common.ts Normal file
View File

@@ -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');

23
src/jwks/create.ts Normal file
View File

@@ -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);

22
src/jwks/get.ts Normal file
View File

@@ -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
}
}

2
src/router.ts Normal file
View File

@@ -0,0 +1,2 @@
import type { App } from '@kevisual/router';

38
test/create.ts Normal file
View File

@@ -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();