1- import jwt from "jsonwebtoken " ;
1+ import crypto from "node:crypto " ;
22
33import * as config from "#src/config.js" ;
44import { Logger } from "#src/utils/utils.js" ;
55import { AuthenticationError } from "#src/utils/errors.js" ;
66
7+ /**
8+ * JsonWebToken header
9+ * https://datatracker.ietf.org/doc/html/rfc7519#section-5
10+ *
11+ * @typedef {Object } JWTHeader
12+ * @property {string } alg - Algorithm used to sign the token
13+ * @property {string } typ - Type of the token, usually "JWT"
14+ */
15+
16+ /**
17+ * @typeDef {Object} PrivateJWTClaims
18+ * @property {string } sfu_channel_uuid
19+ * @property {number } session_id
20+ * @property {Object[] } ice_servers
21+ */
22+
23+ /**
24+ * JsonWebToken claims
25+ * https://datatracker.ietf.org/doc/html/rfc7519#section-4
26+ *
27+ * @typedef {PrivateJWTClaims & Object } JWTClaims
28+ * @property {number } [exp] - Expiration time (in seconds since epoch)
29+ * @property {number } [iat] - Issued at (in seconds since epoch)
30+ * @property {number } [nbf] - Not before (in seconds since epoch)
31+ * @property {string } [iss] - Issuer
32+ * @property {string } [sub] - Subject
33+ * @property {string } [aud] - Audience
34+ * @property {string } [jti] - JWT ID
35+ */
36+
737let jwtKey ;
838const logger = new Logger ( "AUTH" ) ;
39+ const ALGORITHM = {
40+ HS256 : "HS256" ,
41+ } ;
42+ const ALGORITHM_FUNCTIONS = {
43+ [ ALGORITHM . HS256 ] : ( data , key ) => crypto . createHmac ( "sha256" , key ) . update ( data ) . digest ( ) ,
44+ } ;
945
1046/**
1147 * @param {WithImplicitCoercion<string> } [key] buffer/b64 str
1248 */
13- export async function start ( key ) {
49+ export function start ( key ) {
1450 const keyB64str = key || config . AUTH_KEY ;
1551 jwtKey = Buffer . from ( keyB64str , "base64" ) ;
1652 logger . info ( `auth key set` ) ;
@@ -20,18 +56,119 @@ export function close() {
2056 jwtKey = undefined ;
2157}
2258
59+ /**
60+ * @param {Buffer|string } data - The data to encode
61+ * @returns {string } - base64 encoded string
62+ */
63+ export function base64Encode ( data ) {
64+ if ( typeof data === "string" ) {
65+ data = Buffer . from ( data ) ;
66+ }
67+ return data . toString ( "base64" ) ;
68+ }
69+
70+ /**
71+ * @param {string } str base64 encoded string
72+ * @returns {Buffer }
73+ */
74+ function base64Decode ( str ) {
75+ let output = str ;
76+ const paddingLength = 4 - ( output . length % 4 ) ;
77+ if ( paddingLength < 4 ) {
78+ output += "=" . repeat ( paddingLength ) ;
79+ }
80+ return Buffer . from ( output , "base64" ) ;
81+ }
82+
83+ /**
84+ * Signs and creates a JsonWebToken
85+ *
86+ * @param {JWTClaims } claims - The claims to include in the token
87+ * @param {WithImplicitCoercion<string> } [key] - Optional key, defaults to the configured jwtKey
88+ * @param {Object } [options]
89+ * @param {string } [options.algorithm] - The algorithm to use, defaults to HS256
90+ * @returns {string } - The signed JsonWebToken
91+ * @throws {AuthenticationError }
92+ */
93+ export function sign ( claims , key = jwtKey , { algorithm = ALGORITHM . HS256 } = { } ) {
94+ if ( ! key ) {
95+ throw new AuthenticationError ( "JWT signing key is not set" ) ;
96+ }
97+ const keyBuffer = Buffer . isBuffer ( key ) ? key : Buffer . from ( key , "base64" ) ;
98+ const headerB64 = base64Encode ( JSON . stringify ( { alg : algorithm , typ : "JWT" } ) ) ;
99+ const claimsB64 = base64Encode ( JSON . stringify ( claims ) ) ;
100+ const signedData = `${ headerB64 } .${ claimsB64 } ` ;
101+ const signature = ALGORITHM_FUNCTIONS [ algorithm ] ?. ( signedData , keyBuffer ) ;
102+ if ( ! signature ) {
103+ throw new AuthenticationError ( `Unsupported algorithm: ${ algorithm } ` ) ;
104+ }
105+ const signatureB64 = base64Encode ( signature ) ;
106+ return `${ headerB64 } .${ claimsB64 } .${ signatureB64 } ` ;
107+ }
108+
109+ /**
110+ * Parses a JsonWebToken into its components
111+ *
112+ * @param {string } token
113+ * @returns {{header: JWTHeader, claims: JWTClaims, signature: Buffer, signedData: string} }
114+ */
115+ function parseJwt ( token ) {
116+ const parts = token . split ( "." ) ;
117+ if ( parts . length !== 3 ) {
118+ throw new AuthenticationError ( "Invalid JWT format" ) ;
119+ }
120+ const [ headerB64 , claimsB64 , signatureB64 ] = parts ;
121+ const header = JSON . parse ( base64Decode ( headerB64 ) . toString ( ) ) ;
122+ const claims = JSON . parse ( base64Decode ( claimsB64 ) . toString ( ) ) ;
123+ const signature = base64Decode ( signatureB64 ) ;
124+ const signedData = `${ headerB64 } .${ claimsB64 } ` ;
125+
126+ return { header, claims, signature, signedData } ;
127+ }
128+
129+ function safeEqual ( a , b ) {
130+ if ( a . length !== b . length ) {
131+ return false ;
132+ }
133+ try {
134+ return crypto . timingSafeEqual ( a , b ) ;
135+ } catch {
136+ return false ;
137+ }
138+ }
139+
23140/**
24141 * @param {string } jsonWebToken
25142 * @param {WithImplicitCoercion<string> } [key] buffer/b64 str
26- * @returns {Promise<any> } json serialized data
143+ * @returns {JWTClaims } claims
27144 * @throws {AuthenticationError }
28145 */
29- export async function verify ( jsonWebToken , key = jwtKey ) {
146+ export function verify ( jsonWebToken , key = jwtKey ) {
147+ const keyBuffer = Buffer . isBuffer ( key ) ? key : Buffer . from ( key , "base64" ) ;
148+ let parsedJWT ;
30149 try {
31- return jwt . verify ( jsonWebToken , key , {
32- algorithms : [ "HS256" ] ,
33- } ) ;
150+ parsedJWT = parseJwt ( jsonWebToken ) ;
34151 } catch {
35- throw new AuthenticationError ( "JsonWebToken verification error" ) ;
152+ throw new AuthenticationError ( "Invalid JWT format" ) ;
153+ }
154+ const { header, claims, signature, signedData } = parsedJWT ;
155+ const expectedSignature = ALGORITHM_FUNCTIONS [ header . alg ] ?. ( signedData , keyBuffer ) ;
156+ if ( ! expectedSignature ) {
157+ throw new AuthenticationError ( `Unsupported algorithm: ${ header . alg } ` ) ;
158+ }
159+ if ( ! safeEqual ( signature , expectedSignature ) ) {
160+ throw new AuthenticationError ( "Invalid signature" ) ;
161+ }
162+ // `exp`, `iat` and `nbf` are in seconds (`NumericDate` per RFC7519)
163+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
164+ if ( claims . exp && claims . exp < now ) {
165+ throw new AuthenticationError ( "Token expired" ) ;
166+ }
167+ if ( claims . nbf && claims . nbf > now ) {
168+ throw new AuthenticationError ( "Token not valid yet" ) ;
169+ }
170+ if ( claims . iat && claims . iat > now + 60 ) {
171+ throw new AuthenticationError ( "Token issued in the future" ) ;
36172 }
173+ return claims ;
37174}
0 commit comments