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
177 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
};
|
|
}
|