learning_ai_common_plat/packages/auth/src/jwt.ts
saravanakumardb1 53f2a97d40 feat(auth): SmartAuth SDK packages — OAuth, MFA, passkeys, devices, RS256, auth-ui
Phase 1C: @bytelyst/auth-client + @bytelyst/react-auth Google Sign-In
- loginWithGoogle/Microsoft/Apple(idToken) → POST /auth/oauth/:provider
- getProviders/linkProvider/unlinkProvider → provider management
- React context: loginWithGoogle, providers state, refreshProviders

Phase 2D: MFA + Social Login SDK + Auth UI
- verifyMfa/setupTotp/verifyTotpSetup/disableMfa/getMfaStatus
- regenerateRecoveryCodes → recovery code management
- React context: mfaRequired/mfaChallenge/mfaMethods state, verifyMfa action
- login() handles MfaLoginResult (returns false, sets MFA state)
- NEW @bytelyst/auth-ui: LoginForm, MfaChallenge, SocialButtons components

Phase 3: Passkeys + Device SDK
- getPasskeyRegisterOptions/verifyPasskeyRegistration
- getPasskeyAuthOptions/verifyPasskeyAuth/listPasskeys/deletePasskey
- listDevices/trustDevice/revokeDevice/revokeAllDevices

Phase 4C: @bytelyst/auth RS256 support
- createJwtUtils({ algorithm: 'RS256', rsaPrivateKey, rsaPublicKey })
- Dual verification: RS256 first, HS256 fallback (migration-safe)
- Remote JWKS support via jwksUrl option
- Backward-compatible: HS256 remains default

Phase 5B: Admin security endpoints
- getSecurityOverview/unlockUser/exportAuthData/cancelDeletion

Tests: 101 total (36 auth-client + 21 react-auth + 13 auth-ui + 31 auth)
Builds: all 4 packages pass tsc
2026-03-12 10:50:56 -07:00

177 lines
5.3 KiB
TypeScript

/**
* JWT utilities — configurable issuer, expiry, and algorithm.
* Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose.
*
* RS256 mode (Phase 4C SmartAuth):
* - Sign with RSA private key (PEM)
* - Verify with RSA public key (PEM) or remote JWKS URL
* - Dual verification: tries RS256 first, falls back to HS256 during migration
*/
import {
SignJWT,
jwtVerify,
importPKCS8,
importSPKI,
createRemoteJWKSet,
type CryptoKey as JoseCryptoKey,
} from 'jose';
import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js';
function getHmacSecret(): Uint8Array {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET must be set');
return new TextEncoder().encode(secret);
}
/**
* Create a JWT utility set with the given issuer and expiry configuration.
*
* @example
* ```ts
* // HS256 (default, backward-compatible)
* const jwt = createJwtUtils({ issuer: "bytelyst-platform" });
*
* // RS256 (SmartAuth Phase 4C)
* const jwt = createJwtUtils({
* issuer: "bytelyst-platform",
* algorithm: "RS256",
* rsaPrivateKey: process.env.JWT_PRIVATE_KEY,
* rsaPublicKey: process.env.JWT_PUBLIC_KEY,
* });
*
* // RS256 verify-only (product backends — no private key)
* const jwt = createJwtUtils({
* issuer: "bytelyst-platform",
* algorithm: "RS256",
* jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json",
* });
* ```
*/
export function createJwtUtils(options: JwtUtilsOptions): JwtUtils {
const {
issuer,
accessTokenExpiry = '1h',
refreshTokenExpiry = '30d',
algorithm = 'HS256',
rsaPrivateKey,
rsaPublicKey,
jwksUrl,
} = options;
// ── Key caches ────────────────────────────────────
let _rsaPrivateKeyObj: JoseCryptoKey | null = null;
let _rsaPublicKeyObj: JoseCryptoKey | null = null;
let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;
async function getRsaPrivateKey(): Promise<JoseCryptoKey> {
if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj;
if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing');
_rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey;
return _rsaPrivateKeyObj;
}
async function getRsaPublicKey(): Promise<JoseCryptoKey> {
if (_rsaPublicKeyObj) return _rsaPublicKeyObj;
if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification');
_rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey;
return _rsaPublicKeyObj;
}
function getJwksKeySet(): ReturnType<typeof createRemoteJWKSet> {
if (_jwksKeySet) return _jwksKeySet;
if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification');
_jwksKeySet = createRemoteJWKSet(new URL(jwksUrl));
return _jwksKeySet;
}
// ── Signing ───────────────────────────────────────
async function sign(claims: Record<string, unknown>, expiry: string): Promise<string> {
if (algorithm === 'RS256') {
const key = await getRsaPrivateKey();
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime(expiry)
.setIssuer(issuer)
.sign(key);
}
return new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiry)
.setIssuer(issuer)
.sign(getHmacSecret());
}
// ── Verification (dual: RS256 first, HS256 fallback) ──
async function verifyWithRS256(token: string): Promise<TokenPayload | null> {
try {
if (jwksUrl) {
const keySet = getJwksKeySet();
const { payload } = await jwtVerify(token, keySet, { issuer });
return payload as unknown as TokenPayload;
}
if (rsaPublicKey) {
const key = await getRsaPublicKey();
const { payload } = await jwtVerify(token, key, { issuer });
return payload as unknown as TokenPayload;
}
return null;
} catch {
return null;
}
}
async function verifyWithHS256(token: string): Promise<TokenPayload | null> {
try {
const secret = getHmacSecret();
const { payload } = await jwtVerify(token, secret, { issuer });
return payload as unknown as TokenPayload;
} catch {
return null;
}
}
return {
async createAccessToken(payload) {
return sign(
{
...payload,
productId: payload.productId || issuer,
type: 'access',
},
accessTokenExpiry
);
},
async createRefreshToken(payload) {
return sign(
{
sub: payload.sub,
productId: payload.productId || issuer,
type: 'refresh',
},
refreshTokenExpiry
);
},
async verifyToken(token: string) {
// Dual verification: try RS256 first (if configured), then HS256 fallback
if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) {
const rs256Result = await verifyWithRS256(token);
if (rs256Result) return rs256Result;
}
// HS256 fallback (safe during migration; removed after full RS256 rollout)
try {
return await verifyWithHS256(token);
} catch {
return null;
}
},
};
}