init
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
|
||||
jwt
|
||||
3
.npmrc
Normal file
3
.npmrc
Normal 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
20
bun.config.ts
Normal 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
35
package.json
Normal 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
132
pnpm-lock.yaml
generated
Normal 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
53
readme.md
Normal 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
123
src/auth.ts
Normal 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
39
src/generate.ts
Normal 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
1
src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth.ts';
|
||||
8
src/jwks/common.ts
Normal file
8
src/jwks/common.ts
Normal 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
23
src/jwks/create.ts
Normal 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
22
src/jwks/get.ts
Normal 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
2
src/router.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import type { App } from '@kevisual/router';
|
||||
|
||||
38
test/create.ts
Normal file
38
test/create.ts
Normal 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();
|
||||
Reference in New Issue
Block a user