1
- import jwt from "jsonwebtoken " ;
1
+ import crypto from "node:crypto " ;
2
2
3
3
import * as config from "#src/config.js" ;
4
4
import { Logger } from "#src/utils/utils.js" ;
5
5
import { AuthenticationError } from "#src/utils/errors.js" ;
6
6
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
+
7
37
let jwtKey ;
8
38
const 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
+ } ;
9
45
10
46
/**
11
47
* @param {WithImplicitCoercion<string> } [key] buffer/b64 str
12
48
*/
13
- export async function start ( key ) {
49
+ export function start ( key ) {
14
50
const keyB64str = key || config . AUTH_KEY ;
15
51
jwtKey = Buffer . from ( keyB64str , "base64" ) ;
16
52
logger . info ( `auth key set` ) ;
@@ -20,18 +56,119 @@ export function close() {
20
56
jwtKey = undefined ;
21
57
}
22
58
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
+
23
140
/**
24
141
* @param {string } jsonWebToken
25
142
* @param {WithImplicitCoercion<string> } [key] buffer/b64 str
26
- * @returns {Promise<any> } json serialized data
143
+ * @returns {JWTClaims } claims
27
144
* @throws {AuthenticationError }
28
145
*/
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 ;
30
149
try {
31
- return jwt . verify ( jsonWebToken , key , {
32
- algorithms : [ "HS256" ] ,
33
- } ) ;
150
+ parsedJWT = parseJwt ( jsonWebToken ) ;
34
151
} 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" ) ;
36
172
}
173
+ return claims ;
37
174
}
0 commit comments