diff --git a/src/modules/token/index.ts b/src/modules/token/index.ts index 93ed4fc..053374b 100644 --- a/src/modules/token/index.ts +++ b/src/modules/token/index.ts @@ -12,6 +12,7 @@ import { import { ecRecover } from '../../utils/ec-recover'; import { parseDIDToken } from '../../utils/parse-didt'; import { parsePublicAddressFromIssuer } from '../../utils/issuer'; +import { parseJWT } from '../../utils/parse-jwt'; export class TokenModule extends BaseModule { public validate(DIDToken: string, attachment = 'none') { @@ -81,4 +82,72 @@ export class TokenModule extends BaseModule { public getIssuer(DIDToken: string): string { return this.decode(DIDToken)[1].iss; } + + /** + * Validates a JWT token. + * Similar to validate() but for JWT format tokens. + * + * A JWT has three parts: header.payload.signature + * The signature is automatically extracted, but verifying it cryptographically + * requires checking it against the header+payload using the signing key. + * + * @param jwtToken - The JWT token string to validate (format: header.payload.signature) + * @param options - Optional configuration + * @param options.verifySignature - Optional function to verify the JWT signature. + * The signature is already extracted from the JWT, + * but this function performs the cryptographic verification. + * Function receives: (header, payload, signature) => boolean + * If not provided, only claim validation is performed. + */ + public validateJWT( + jwtToken: string, + options?: { + verifySignature?: (header: any, payload: any, signature: string) => boolean; + }, + ) { + let parsedJWT; + + try { + // Parse JWT into header, payload, and signature (signature is the 3rd part) + parsedJWT = parseJWT(jwtToken); + } catch { + throw createMalformedTokenError(); + } + + const { payload, header, signature } = parsedJWT; + + // Verify signature cryptographically if verifier function is provided + // The signature is already extracted above, but we need to verify it's valid + if (options?.verifySignature) { + try { + const isValid = options.verifySignature(header, payload, signature); + if (!isValid) { + throw createFailedRecoveringProofError(); + } + } catch { + throw createFailedRecoveringProofError(); + } + } + + const timeSecs = Math.floor(Date.now() / 1000); + const nbfLeeway = 300; // 5 min grace period + + // Assert the token is not expired + if (payload.exp && payload.exp < timeSecs) { + throw createTokenExpiredError(); + } + + // Assert the token is not used before allowed. + if (payload.nbf && payload.nbf - nbfLeeway > timeSecs) { + throw createTokenCannotBeUsedYetError(); + } + + // Assert the audience matches the client ID. + if (this.sdk.clientId && payload.aud) { + const audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!audience.includes(this.sdk.clientId)) { + throw createAudienceMismatchError(); + } + } + } } diff --git a/src/utils/parse-jwt.ts b/src/utils/parse-jwt.ts new file mode 100644 index 0000000..3413f46 --- /dev/null +++ b/src/utils/parse-jwt.ts @@ -0,0 +1,90 @@ +import { createMalformedTokenError } from '../core/sdk-exceptions'; + +export interface JWTPayload { + iss?: string; // Issuer + sub?: string; // Subject + aud?: string | string[]; // Audience + exp?: number; // Expiration time + nbf?: number; // Not before + iat?: number; // Issued at + jti?: string; // JWT ID + [key: string]: any; // Allow additional claims +} + +export interface JWTHeader { + alg: string; // Algorithm + typ?: string; // Type + kid?: string; // Key ID + [key: string]: any; // Allow additional header fields +} + +export interface ParsedJWT { + header: JWTHeader; + payload: JWTPayload; + signature: string; + raw: { + header: string; + payload: string; + signature: string; + }; +} + +/** + * Base64URL decode helper + */ +function base64UrlDecode(str: string): string { + // Add padding if needed + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) { + base64 += '='; + } + + try { + if (typeof Buffer !== 'undefined') { + return Buffer.from(base64, 'base64').toString('utf-8'); + } + // Browser fallback + return decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + } catch { + throw createMalformedTokenError(); + } +} + +/** + * Parses a JWT token into its components. + */ +export function parseJWT(jwt: string): ParsedJWT { + try { + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + + const [headerB64, payloadB64, signature] = parts; + + const headerStr = base64UrlDecode(headerB64); + const payloadStr = base64UrlDecode(payloadB64); + + const header = JSON.parse(headerStr) as JWTHeader; + const payload = JSON.parse(payloadStr) as JWTPayload; + + return { + header, + payload, + signature, + raw: { + header: headerB64, + payload: payloadB64, + signature, + }, + }; + } catch { + throw createMalformedTokenError(); + } +} +