Last active
September 16, 2021 15:14
-
-
Save Panman82/d2a176319307c3955fc16127cb815692 to your computer and use it in GitHub Desktop.
ColdFusion component which has functions to work with JSON Web Tokens.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
component | |
displayName = "JSON Web Token" | |
hint = "Utility library to work with JSON Web Tokens." | |
{ | |
// Supported algorithms | |
variables.algorithms = { | |
"HS256" = "HmacSHA256", | |
"HS384" = "HmacSHA384", | |
"HS512" = "HmacSHA512" | |
}; | |
public jwt function init( string algorithm = "HS512", string secret = "", struct claims = {} ) | |
hint = "Arguments passed in here are purposely kept private in the 'variables' scope." | |
{ | |
variables.algorithm = arguments.algorithm; | |
variables.secret = arguments.secret; | |
variables.claims = arguments.claims; | |
return this; | |
} // init() | |
public string function extract( struct headers = {} ) | |
description = "Extract the JSON Web Token from the Authorization header." | |
hint = "When 'headers' are not passed in, they will be retrieved from the HTTP request." | |
{ | |
// See "hint" above | |
if ( arguments.headers.isEmpty() ) { | |
arguments.headers = GetHttpRequestData().headers; | |
} | |
// Check for the "Authorization" header in the struct of headers | |
if ( !arguments.headers.keyExists( "Authorization" ) ) { | |
throw( | |
type = "jwt", | |
message = "JWT extract: Authorization header does not exist", | |
detail = "The JSON Web Token must be in an Authorization header.", | |
extendedInfo = "headers: #arguments.headers.keyList(', ')#" | |
); | |
} | |
// Header must begin with "Bearer" | |
if ( arguments.headers.Authorization.listFirst(" ") != "Bearer" ) { | |
throw( | |
type = "jwt", | |
message = "JWT extract: Authorization header missing Bearer", | |
detail = "The Authorization header should begin with 'Bearer'.", | |
extendedInfo = "authorization: #arguments.headers.Authorization#" | |
); | |
} | |
// Get the JWT part of the Authorization header | |
if ( arguments.headers.Authorization.listRest(" ").trim() == "" ) { | |
throw( | |
type = "jwt", | |
message = "JWT extract: Authorization header missing token", | |
detail = "The token should be after 'Bearer'.", | |
extendedInfo = "authorization: #arguments.headers.Authorization#" | |
); | |
} | |
// Return the JWT part of the Authorization header | |
return arguments.headers.Authorization.listRest(" ").trim(); | |
} // extract() | |
public string function encode( required struct payload, string algorithm = variables.algorithm, string secret = variables.secret ) | |
description = "Encode the payload (claims) into a signed JSON Web Token using an HMAC algorithm." | |
hint = "The 'algorithm' and 'secret' can be set during object initialization. Ex: 'new jwt(algorithm, secret)'" | |
{ | |
// Standard JWT header.. | |
local.header = { | |
"typ" = "JWT", | |
"alg" = arguments.algorithm | |
}; | |
// Add "expected claims" from init() to the payload | |
for ( local.claim in variables.claims ) { | |
if ( !arguments.payload.keyExists( local.claim ) ) { | |
arguments.payload[ local.claim ] = variables.claims[ local.claim ]; | |
} | |
} | |
// Serialize the structs | |
local.header = SerializeJSON( local.header ); | |
local.payload = SerializeJSON( arguments.payload ); | |
// Base64 encode segments | |
local.header = variables.base64Encode( local.header ); | |
local.payload = variables.base64Encode( local.payload ); | |
// URL escape the Base64 segments | |
local.message = variables.base64Escape( local.header ); | |
local.message &= "."; | |
local.message &= variables.base64Escape( local.payload ); | |
// Sign the message (first two segments) | |
try { | |
local.signature = variables.sign( local.message, arguments.algorithm, arguments.secret ); | |
} catch ( jwt e ) { | |
rethrow; | |
} | |
// Append the signature to the JWT | |
return local.message & "." & variables.base64Escape( local.signature ); | |
} // encode() | |
public struct function decode( required string token, string secret = variables.secret ) | |
description = "Decode a JSON Web Token, validating the signature, and returning the payload (claims)." | |
hint = "The 'secret' can be set during object initialization. Ex: 'new jwt(secret='AbcXyz')'" | |
{ | |
// JWT must have three separate segments | |
if ( arguments.token.listLen( "." ) != 3 ) { | |
throw( | |
type = "jwt", | |
message = "JWT decode: Invalid token format", | |
detail = "The JSON Web Token must have three segments separated by periods.", | |
extendedInfo = "token: #arguments.token#" | |
); | |
} | |
// Separate each segment | |
local.header = arguments.token.listGetAt( 1, "." ); | |
local.payload = arguments.token.listGetAt( 2, "." ); | |
local.signature = arguments.token.listGetAt( 3, "." ); | |
// Unescape the Base64Url segments | |
local.header = variables.base64Unescape( local.header ); | |
local.payload = variables.base64Unescape( local.payload ); | |
local.signature = variables.base64Unescape( local.signature ); | |
// Decode the Base64 segments | |
local.header = variables.base64Decode( local.header ); | |
local.payload = variables.base64Decode( local.payload ); | |
// Ensure segments are valid json | |
if ( !IsJSON( local.header ) ) { | |
throw( | |
type = "jwt", | |
message = "JWT decode: Header invalid JSON", | |
detail = "The JSON Web Token header is not valid JSON.", | |
extendedInfo = "header: #local.header#" | |
); | |
} | |
if ( !IsJSON( local.payload ) ) { | |
throw( | |
type = "jwt", | |
message = "JWT decode: Payload invalid JSON", | |
detail = "The JSON Web Token payload is not valid JSON.", | |
extendedInfo = "payload: #local.payload#" | |
); | |
} | |
// Deserialize the structs | |
local.header = DeserializeJSON( local.header ); | |
local.payload = DeserializeJSON( local.payload ); | |
// JWT header must contain the algorithm used | |
if ( !local.header.keyExists( "alg" ) ) { | |
throw( | |
type = "jwt", | |
message = "JWT decode: Header missing alg", | |
detail = "The JSON Web Token header does not have 'alg' to indicate what algorithm was used.", | |
extendedInfo = "keys: #local.header.keyList(', ')#" | |
); | |
} | |
// Resign the message with our secret to compare signatures | |
try { | |
local.message = arguments.token.listDeleteAt( 3, "." ); | |
local.resigned = variables.sign( local.message, local.header.alg, arguments.secret ); | |
} catch ( jwt e ) { | |
rethrow; | |
} | |
// Compare JWT signature to the expected signature (case sensitive) | |
if ( Compare( local.signature, local.resigned ) != 0 ) { | |
throw( | |
type = "jwt", | |
message = "JWT decode: Signature validation failed", | |
detail = "The JSON Web Token signature segment does not match the expected signature.", | |
extendedInfo = "signature: #local.signature#" | |
); | |
} | |
// Everything passed, payload is valid | |
return local.payload; | |
} // decode() | |
public void function verify( required struct payload, struct claims = variables.claims ) | |
description = "Verifies the 'payload' claims match the expected 'claims'. Additionally verifies the iat, nbf, and exp timestamps are valid." | |
hint = "The expected 'claims' can be set during object initialization. Ex: 'new jwt(claims={})'" | |
{ | |
// Check for an "issued at" timestamp | |
if ( arguments.payload.keyExists("iat") && variables.epochToDate( arguments.payload.iat ) > Now() ) { | |
throw( | |
type = "jwt", | |
message = "JWT verify: Token not issued yet", | |
detail = "The JSON Web Token 'iat' claim timestamp is in the future.", | |
extendedInfo = "iat: #variables.epochToDate(arguments.payload.iat).dateTimeFormat('long')# now: #Now().dateTimeFormat('long')#" | |
); | |
} | |
// Check for an "not before" timestamp | |
if ( arguments.payload.keyExists("nbf") && variables.epochToDate( arguments.payload.nbf ) > Now() ) { | |
throw( | |
type = "jwt", | |
message = "JWT verify: Token not active yet", | |
detail = "The JSON Web Token 'nbf' claim timestamp is in the future.", | |
extendedInfo = "nbf: #variables.epochToDate(arguments.payload.nbf).dateTimeFormat('long')# now: #Now().dateTimeFormat('long')#" | |
); | |
} | |
// Check for an "expiration" timestamp | |
if ( arguments.payload.keyExists("exp") && variables.epochToDate( arguments.payload.exp ) < Now() ) { | |
throw( | |
type = "jwt", | |
message = "JWT verify: Token expired", | |
detail = "The JSON Web Token 'exp' claim timestamp has passed.", | |
extendedInfo = "exp: #variables.epochToDate(arguments.payload.exp).dateTimeFormat('long')# now: #Now().dateTimeFormat('long')#" | |
); | |
} | |
// Check the "expected" claims passed in.. | |
for ( local.claim in arguments.claims ) { | |
if ( !arguments.payload.keyExists( local.claim ) ) { | |
throw( | |
type = "jwt", | |
message = "JWT verify: Missing claim #local.claim#", | |
detail = "The expected JSON Web Token claim '#local.claim#' does not exist in the payload.", | |
extendedInfo = "payload claims: #arguments.payload.keyList(', ')#" | |
); | |
} | |
if ( arguments.payload[ local.claim ] != arguments.claims[ local.claim ] ) { | |
throw( | |
type = "jwt", | |
message = "JWT verify: Claim #local.claim# invalid", | |
detail = "The JSON Web Token claim '#local.claim#' does not have the expected value.", | |
extendedInfo = "#local.claim#: #arguments.payload[ local.claim ]#" | |
); | |
} | |
} | |
} // verify() | |
private string function sign( required string message, required string algorithm, required string secret ) | |
description = "Signs the first two segments of the JSON Web Token and returns the base64 encoded result." | |
{ | |
// Make sure the requested algorithm is supported | |
if ( !variables.algorithms.keyExists( arguments.algorithm ) ) { | |
throw( | |
type = "jwt", | |
message = "JWT sign: Invalid algorithm", | |
detail = "The algorithm requested (#arguments.algorithm#) is not supported.", | |
extendedInfo = "supported algorithms: #variables.algorithms.keyList(', ')#" | |
); | |
} | |
// Foolish people, the secret cannot be blank | |
if ( arguments.secret.trim() == "" ) { | |
throw( | |
type = "jwt", | |
message = "JWT sign: Invalid secret", | |
detail = "The secret cannot be blank." | |
); | |
} | |
// Break down strings into bytes for use with Java libs | |
local.messageBytes = CharsetDecode( arguments.message, "utf-8" ); | |
local.secretBytes = CharsetDecode( arguments.secret.trim(), "utf-8" ); | |
// Create Java objects for signing | |
local.algorithm = variables.algorithms[ arguments.algorithm ]; | |
local.key = CreateObject( "java", "javax.crypto.spec.SecretKeySpec" ).init( local.secretBytes, local.algorithm ); | |
local.mac = CreateObject( "java", "javax.crypto.Mac" ).getInstance( JavaCast( "string", local.algorithm ) ); | |
local.mac.init( local.key ); | |
// Create the signature for the message | |
local.signatureBytes = local.mac.doFinal( local.messageBytes ); | |
// Base64 encode the signature | |
return BinaryEncode( local.signatureBytes, "base64" ); | |
} // sign() | |
private string function base64Encode( required string segment ) | |
description = "Takes a serialized segment string and encodes it in base64 string." | |
{ | |
local.segment = CharsetDecode( arguments.segment, "utf-8" ); | |
return BinaryEncode( local.segment, "base64" ); | |
} // base64Encode() | |
private string function base64Decode( required string segment ) | |
description = "Takes a base64 segment string and decodes it back to a serialized string." | |
{ | |
local.segment = BinaryDecode( arguments.segment, "base64" ); | |
return CharsetEncode( local.segment, "utf-8" ); | |
} // base64Decode() | |
private string function base64Escape( required string segment ) | |
description = "Takes a base64 segment string and URL escapes the proper characters." | |
{ | |
local.segment = arguments.segment.replace( "+", "-", "all" ); | |
local.segment = local.segment.replace( "/", "_", "all" ); | |
local.segment = local.segment.replace( "=", "", "all" ); | |
return local.segment; | |
} // base64Escape() | |
private string function base64Unescape( required string segment ) | |
description = "Takes a URL escaped base64 segment and un-escapes the proper characters." | |
{ | |
local.segment = arguments.segment.replace( "-", "+", "all" ); | |
local.segment = local.segment.replace( "_", "/", "all" ); | |
local.segment &= RepeatString( "=", 4 - ( local.segment.len() % 4 ) ); | |
return local.segment; | |
} // base64Unescape() | |
private date function epochToDate( required numeric epoch ) | |
description = "Takes a JavaScript epoch number and converts it to a Date type." | |
{ | |
// JavaScript epoch stamps are an hour off of ColdFusion epoch | |
return CreateObject( "java", "java.util.Date" ).init( arguments.epoch * 1000 ); | |
} // epochToDate() | |
} // component |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment