diff --git a/src/auth/AuthMiddleware.res b/src/auth/AuthMiddleware.res index 33b227c..ad43202 100644 --- a/src/auth/AuthMiddleware.res +++ b/src/auth/AuthMiddleware.res @@ -23,18 +23,24 @@ let authenticateBearerToken = async (c: Hono.context, config: AuthTypes.Types.au | Some(a) if String.startsWith(a, "Bearer ") => { let token = String.substring(a, ~start=7, ~end=String.length(a)) try { - let payload = switch config.oidc { - | Some(oidc) => await Jwt.verifyJwt(token, (oidc :> Jwt.Types.oidcConfig)) - | None => { - let decoded = Jwt.decodeJwt(token) - %raw(`decoded.payload`) - } + // Require OIDC config for OIDC/OAuth2 bearer-token methods. Without a + // JWKS source we have no way to verify signatures; failing closed is + // the only safe behaviour. Previously this branch silently decoded the + // payload of an unverified JWT and returned authenticated: true. + let oidc = switch config.oidc { + | Some(o) => o + | None => + failwith( + "Bearer-token authentication requires OIDC configuration (no JWKS source to verify against)", + ) } + let payload = await Jwt.verifyJwt(token, (oidc :> Jwt.Types.oidcConfig)) + let scope: option = %raw(`payload.scope`) { authenticated: true, method: #oidc, - subject: %raw("payload.sub"), - scopes: %raw("payload.scope")->Option.map(s => String.split(s, " "))->Option.getOr([]), + subject: payload.sub, + scopes: scope->Option.map(s => String.split(s, " "))->Option.getOr([]), token: Obj.magic(payload), } } catch { @@ -63,7 +69,7 @@ let authMiddleware = (config: AuthTypes.Types.authConfig) => { | #none => {authenticated: true, method: #none} | _ => {authenticated: false, method: #none, error: "Method not implemented"} } - + if res.authenticated { result := res } diff --git a/src/auth/AuthMiddleware.res.mjs b/src/auth/AuthMiddleware.res.mjs index 98fa811..81cf696 100644 --- a/src/auth/AuthMiddleware.res.mjs +++ b/src/auth/AuthMiddleware.res.mjs @@ -2,7 +2,8 @@ import * as Jwt from "./Jwt.res.mjs"; import * as Belt_Array from "@rescript/runtime/lib/es6/Belt_Array.js"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js"; +import * as Pervasives from "@rescript/runtime/lib/es6/Pervasives.js"; +import * as Core__Option from "@rescript/core/src/Core__Option.res.mjs"; let Hono = {}; @@ -24,13 +25,15 @@ async function authenticateBearerToken(c, config) { } let token = auth.substring(7, auth.length); try { - let oidc = config.oidc; - let payload = oidc !== undefined ? await Jwt.verifyJwt(token, oidc) : (Jwt.decodeJwt(token), decoded.payload); + let o = config.oidc; + let oidc = o !== undefined ? o : Pervasives.failwith("Bearer-token authentication requires OIDC configuration (no JWKS source to verify against)"); + let payload = await Jwt.verifyJwt(token, oidc); + let scope = payload.scope; return { authenticated: true, method: "oidc", subject: payload.sub, - scopes: Stdlib_Option.getOr(Stdlib_Option.map(payload.scope, s => s.split(" ")), []), + scopes: Core__Option.getOr(Core__Option.map(scope, s => s.split(" ")), []), token: payload }; } catch (exn) { diff --git a/src/auth/Jwt.res b/src/auth/Jwt.res index 46dfda1..4b76b34 100644 --- a/src/auth/Jwt.res +++ b/src/auth/Jwt.res @@ -42,14 +42,19 @@ module Crypto = { type cryptoKey module Subtle = { @val @scope(("crypto", "subtle")) - external importKey: (string, JSON.t, JSON.t, bool, array) => promise = + external importKey: (JSON.t, JSON.t, JSON.t, bool, array) => promise = "importKey" @val @scope(("crypto", "subtle")) - external verify: (JSON.t, cryptoKey, ArrayBuffer.t, ArrayBuffer.t) => promise = "verify" + external verify: (JSON.t, cryptoKey, Uint8Array.t, Uint8Array.t) => promise = "verify" } } +// TextEncoder for building the signing-input bytes. +type textEncoder +@new external makeTextEncoder: unit => textEncoder = "TextEncoder" +@send external textEncoderEncode: (textEncoder, string) => Uint8Array.t = "encode" + @val external atob: string => string = "atob" let jwksCache: Map.t = Map.make() @@ -65,31 +70,49 @@ let fetchJwks = async (jwksUri: string): Types.jwks => { failwith(`Failed to fetch JWKS: ${Int.toString(Fetch.Response.status(response))}`) } let jwks: Types.jwks = %raw("await response.json()") - Map.set(jwksCache, jwksUri, {jwks: jwks, expiresAt: now +. jwksCacheTtl}) + Map.set(jwksCache, jwksUri, {jwks, expiresAt: now +. jwksCacheTtl}) jwks } } } +// Base64url-decode a string into a Uint8Array. let base64UrlDecode = (str: string): Uint8Array.t => { let base64 = str ->String.replaceRegExp(%re("/-/g"), "+") ->String.replaceRegExp(%re("/_/g"), "/") let mod4 = mod(String.length(base64), 4) - let padding = if mod4 > 0 { String.repeat("=", 4 - mod4) } else { "" } - let binary = atob(base64 ++ padding) - let bytes = Uint8Array.fromLength(String.length(binary)) - for _i in 0 to String.length(binary) - 1 { - let _ = %raw("bytes[i] = binary.charCodeAt(i)") + let padding = if mod4 > 0 { + String.repeat("=", 4 - mod4) + } else { + "" } + let binary = atob(base64 ++ padding) + let len = String.length(binary) + let bytes = Uint8Array.fromLength(len) + %raw(`(function() { for (var i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); } })()`) bytes } -let decodeJwt = (token: string) => { +// Result of decoding a JWT without verifying. +// Carries the raw base64url segments so verifyJwt can reconstruct the signing +// input. +type decoded = { + headerB64: string, + payloadB64: string, + signatureB64: string, + header: JSON.t, + payload: JSON.t, +} + +let decodeJwt = (token: string): decoded => { let parts = String.split(token, ".") if Array.length(parts) != 3 { failwith("Invalid JWT format") } + let headerB64 = Array.getUnsafe(parts, 0) + let payloadB64 = Array.getUnsafe(parts, 1) + let signatureB64 = Array.getUnsafe(parts, 2) let decodePart = (p: string): JSON.t => { p @@ -100,18 +123,94 @@ let decodeJwt = (token: string) => { } { - "header": decodePart(Array.getUnsafe(parts, 0)), - "payload": decodePart(Array.getUnsafe(parts, 1)), + headerB64, + payloadB64, + signatureB64, + header: decodePart(headerB64), + payload: decodePart(payloadB64), } } +// Map a JWT `alg` to a (importKey-algorithm, verify-algorithm) pair. +// 'none' and any unrecognised algorithm are rejected. +let algToWebCrypto = (alg: string): result<(JSON.t, JSON.t), string> => { + switch alg { + | "none" => Error("Algorithm 'none' is rejected for security reasons") + | "RS256" => + Ok(( + %raw(`{name: "RSASSA-PKCS1-v1_5", hash: "SHA-256"}`), + %raw(`{name: "RSASSA-PKCS1-v1_5"}`), + )) + | "RS384" => + Ok(( + %raw(`{name: "RSASSA-PKCS1-v1_5", hash: "SHA-384"}`), + %raw(`{name: "RSASSA-PKCS1-v1_5"}`), + )) + | "RS512" => + Ok(( + %raw(`{name: "RSASSA-PKCS1-v1_5", hash: "SHA-512"}`), + %raw(`{name: "RSASSA-PKCS1-v1_5"}`), + )) + | "PS256" => + Ok(( + %raw(`{name: "RSA-PSS", hash: "SHA-256"}`), + %raw(`{name: "RSA-PSS", saltLength: 32}`), + )) + | "PS384" => + Ok(( + %raw(`{name: "RSA-PSS", hash: "SHA-384"}`), + %raw(`{name: "RSA-PSS", saltLength: 48}`), + )) + | "PS512" => + Ok(( + %raw(`{name: "RSA-PSS", hash: "SHA-512"}`), + %raw(`{name: "RSA-PSS", saltLength: 64}`), + )) + | "ES256" => + Ok(( + %raw(`{name: "ECDSA", namedCurve: "P-256", hash: "SHA-256"}`), + %raw(`{name: "ECDSA", hash: "SHA-256"}`), + )) + | "ES384" => + Ok(( + %raw(`{name: "ECDSA", namedCurve: "P-384", hash: "SHA-384"}`), + %raw(`{name: "ECDSA", hash: "SHA-384"}`), + )) + | "ES512" => + Ok(( + %raw(`{name: "ECDSA", namedCurve: "P-521", hash: "SHA-512"}`), + %raw(`{name: "ECDSA", hash: "SHA-512"}`), + )) + | "EdDSA" => Ok((%raw(`{name: "Ed25519"}`), %raw(`{name: "Ed25519"}`))) + | other => Error(`Unsupported JWT algorithm: ${other}`) + } +} + +// Verify a JWT against the JWKS at `config.jwksUri`. Throws on: +// - malformed token +// - 'none' alg or unsupported alg +// - exp in the past +// - issuer mismatch +// - kid not found in JWKS +// - JWK→CryptoKey import failure +// - SIGNATURE INVALID (the central guarantee) +// +// Returns the payload only when the signature is valid. let verifyJwt = async (token: string, config: Types.oidcConfig): Types.tokenPayload => { let decoded = decodeJwt(token) + + let alg: string = %raw(`decoded.header.alg`) + let kid: string = %raw(`decoded.header.kid`) + + // Map alg first so we reject 'none' / unsupported BEFORE doing any other work. + let (importAlg, verifyAlg) = switch algToWebCrypto(alg) { + | Ok(pair) => pair + | Error(msg) => failwith(msg) + } + let payload: Types.tokenPayload = %raw(`decoded.payload`) - let _header = decoded["header"] let now = Date.now() /. 1000.0 - switch payload.exp { | Some(exp) if exp < now => failwith("Token expired") | _ => () @@ -122,15 +221,29 @@ let verifyJwt = async (token: string, config: Types.oidcConfig): Types.tokenPayl } let jwks = await fetchJwks(config.jwksUri) - let kid = %raw(`header.kid`) let keyOpt = jwks.keys->Array.find(k => k.kid == kid) - - switch keyOpt { + let jwk = switch keyOpt { | None => failwith(`Key not found: ${kid}`) - | Some(_key) => { - // Import and verify logic here... - // (Simplified for now to match the scope of logic port) - payload - } + | Some(j) => j + } + + // Import the JWK as a CryptoKey usable for verification only. + let jwkJson: JSON.t = Obj.magic(jwk) + let formatJwk: JSON.t = %raw(`"jwk"`) + let cryptoKey = await Crypto.Subtle.importKey(formatJwk, jwkJson, importAlg, false, ["verify"]) + + // Build the signing input: "." as UTF-8 bytes. + let signingInput = + makeTextEncoder()->textEncoderEncode(decoded.headerB64 ++ "." ++ decoded.payloadB64) + + // Base64url-decode the signature segment. + let signatureBytes = base64UrlDecode(decoded.signatureB64) + + // The central check. + let ok = await Crypto.Subtle.verify(verifyAlg, cryptoKey, signatureBytes, signingInput) + if !ok { + failwith("JWT signature verification failed") } + + payload } diff --git a/src/auth/Jwt.res.mjs b/src/auth/Jwt.res.mjs index 0147478..9a822bd 100644 --- a/src/auth/Jwt.res.mjs +++ b/src/auth/Jwt.res.mjs @@ -37,10 +37,9 @@ function base64UrlDecode(str) { let mod4 = base64.length % 4; let padding = mod4 > 0 ? "=".repeat(4 - mod4 | 0) : ""; let binary = atob(base64 + padding); - let bytes = new Uint8Array(binary.length); - for (let _i = 0, _i_finish = binary.length; _i < _i_finish; ++_i) { - ((bytes[i] = binary.charCodeAt(i))); - } + let len = binary.length; + let bytes = new Uint8Array(len); + (((function() { for (var i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); } })())); return bytes; } @@ -49,15 +48,121 @@ function decodeJwt(token) { if (parts.length !== 3) { Pervasives.failwith("Invalid JWT format"); } + let headerB64 = parts[0]; + let payloadB64 = parts[1]; + let signatureB64 = parts[2]; let decodePart = p => JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/"))); return { - header: decodePart(parts[0]), - payload: decodePart(parts[1]) + headerB64: headerB64, + payloadB64: payloadB64, + signatureB64: signatureB64, + header: decodePart(headerB64), + payload: decodePart(payloadB64) }; } +function algToWebCrypto(alg) { + switch (alg) { + case "ES256" : + return { + TAG: "Ok", + _0: [ + {name: "ECDSA", namedCurve: "P-256", hash: "SHA-256"}, + {name: "ECDSA", hash: "SHA-256"} + ] + }; + case "ES384" : + return { + TAG: "Ok", + _0: [ + {name: "ECDSA", namedCurve: "P-384", hash: "SHA-384"}, + {name: "ECDSA", hash: "SHA-384"} + ] + }; + case "ES512" : + return { + TAG: "Ok", + _0: [ + {name: "ECDSA", namedCurve: "P-521", hash: "SHA-512"}, + {name: "ECDSA", hash: "SHA-512"} + ] + }; + case "EdDSA" : + return { + TAG: "Ok", + _0: [ + {name: "Ed25519"}, + {name: "Ed25519"} + ] + }; + case "PS256" : + return { + TAG: "Ok", + _0: [ + {name: "RSA-PSS", hash: "SHA-256"}, + {name: "RSA-PSS", saltLength: 32} + ] + }; + case "PS384" : + return { + TAG: "Ok", + _0: [ + {name: "RSA-PSS", hash: "SHA-384"}, + {name: "RSA-PSS", saltLength: 48} + ] + }; + case "PS512" : + return { + TAG: "Ok", + _0: [ + {name: "RSA-PSS", hash: "SHA-512"}, + {name: "RSA-PSS", saltLength: 64} + ] + }; + case "RS256" : + return { + TAG: "Ok", + _0: [ + {name: "RSASSA-PKCS1-v1_5", hash: "SHA-256"}, + {name: "RSASSA-PKCS1-v1_5"} + ] + }; + case "RS384" : + return { + TAG: "Ok", + _0: [ + {name: "RSASSA-PKCS1-v1_5", hash: "SHA-384"}, + {name: "RSASSA-PKCS1-v1_5"} + ] + }; + case "RS512" : + return { + TAG: "Ok", + _0: [ + {name: "RSASSA-PKCS1-v1_5", hash: "SHA-512"}, + {name: "RSASSA-PKCS1-v1_5"} + ] + }; + case "none" : + return { + TAG: "Error", + _0: "Algorithm 'none' is rejected for security reasons" + }; + default: + return { + TAG: "Error", + _0: `Unsupported JWT algorithm: ` + alg + }; + } +} + async function verifyJwt(token, config) { - decodeJwt(token); + let decoded = decodeJwt(token); + let alg = decoded.header.alg; + let kid = decoded.header.kid; + let pair = algToWebCrypto(alg); + let match; + match = pair.TAG === "Ok" ? pair._0 : Pervasives.failwith(pair._0); let payload = decoded.payload; let now = Date.now() / 1000.0; let exp = payload.exp; @@ -68,13 +173,17 @@ async function verifyJwt(token, config) { Pervasives.failwith(`Invalid issuer: expected ` + config.issuer + `, got ` + payload.iss); } let jwks = await fetchJwks(config.jwksUri); - let kid = header.kid; let keyOpt = jwks.keys.find(k => k.kid === kid); - if (keyOpt !== undefined) { - return payload; - } else { - return Pervasives.failwith(`Key not found: ` + kid); + let jwk = keyOpt !== undefined ? keyOpt : Pervasives.failwith(`Key not found: ` + kid); + let formatJwk = "jwk"; + let cryptoKey = await crypto.subtle.importKey(formatJwk, jwk, match[0], false, ["verify"]); + let signingInput = new TextEncoder().encode(decoded.headerB64 + "." + decoded.payloadB64); + let signatureBytes = base64UrlDecode(decoded.signatureB64); + let ok = await crypto.subtle.verify(match[1], cryptoKey, signatureBytes, signingInput); + if (!ok) { + Pervasives.failwith("JWT signature verification failed"); } + return payload; } let jwksCacheTtl = 3600000.0; @@ -87,6 +196,7 @@ export { fetchJwks, base64UrlDecode, decodeJwt, + algToWebCrypto, verifyJwt, } /* jwksCache Not a pure module */ diff --git a/src/auth/OAuth2.res b/src/auth/OAuth2.res index ad52565..45c862f 100644 --- a/src/auth/OAuth2.res +++ b/src/auth/OAuth2.res @@ -6,7 +6,9 @@ open AuthTypes -module URLSearchParams = { +// Named `Usp` (not `URLSearchParams`) so the module placeholder doesn't +// shadow the global `URLSearchParams` constructor in the compiled output. +module Usp = { type t @new external make: JSON.t => t = "URLSearchParams" @send external toString: t => string = "toString" @@ -23,7 +25,7 @@ type tokenResponse = { } let getAuthorizationUrl = (config: Types.oauth2Config, state: string, ~nonce: option=?) => { - let params = URLSearchParams.make( + let params = Usp.make( Obj.magic({ "response_type": "code", "client_id": config.clientId, @@ -34,15 +36,15 @@ let getAuthorizationUrl = (config: Types.oauth2Config, state: string, ~nonce: op ) switch nonce { - | Some(n) => params->URLSearchParams.set("nonce", n) + | Some(n) => params->Usp.set("nonce", n) | None => () } - `${config.authorizationEndpoint}?${params->URLSearchParams.toString}` + `${config.authorizationEndpoint}?${params->Usp.toString}` } let exchangeCode = async (config: Types.oauth2Config, code: string): tokenResponse => { - let params = URLSearchParams.make( + let params = Usp.make( Obj.magic({ "grant_type": "authorization_code", "code": code, @@ -57,7 +59,7 @@ let exchangeCode = async (config: Types.oauth2Config, code: string): tokenRespon { "method": #POST, "headers": Fetch.Headers.fromObject({"Content-Type": "application/x-www-form-urlencoded"}), - "body": Fetch.Body.string(params->URLSearchParams.toString), + "body": Fetch.Body.string(params->Usp.toString), }, ) @@ -70,10 +72,13 @@ let exchangeCode = async (config: Types.oauth2Config, code: string): tokenRespon } let generateState = (): string => { - let _array = Uint8Array.fromLength(32) - %raw(`crypto.getRandomValues(array)`) - Array.fromInitializer(~length=32, _i => %raw("_array[_i]")->Int.toStringWithRadix(~radix=16)->String.padStart(2, "0")) - ->Array.joinUnsafe("") + // Single self-contained JS expression so ReScript doesn't elide the + // intermediate bindings via opaque %raw references. + %raw(`(function() { + const a = new Uint8Array(32); + crypto.getRandomValues(a); + return Array.from(a, b => b.toString(16).padStart(2, "0")).join(""); + })()`) } let generateNonce = () => generateState() diff --git a/src/auth/OAuth2.res.mjs b/src/auth/OAuth2.res.mjs index 3684a9d..178fb9e 100644 --- a/src/auth/OAuth2.res.mjs +++ b/src/auth/OAuth2.res.mjs @@ -1,9 +1,8 @@ // Generated by ReScript, PLEASE EDIT WITH CARE import * as Pervasives from "@rescript/runtime/lib/es6/Pervasives.js"; -import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js"; -// URLSearchParams module binding removed — use global URLSearchParams constructor directly +let Usp = {}; function getAuthorizationUrl(config, state, nonce) { let params = new URLSearchParams({ @@ -42,9 +41,11 @@ async function exchangeCode(config, code) { } function generateState() { - const _array = new Uint8Array(32); - crypto.getRandomValues(_array); - return Array.from({length: 32}, (_, _i) => _array[_i].toString(16).padStart(2, "0")).join(""); + return ((function() { + const a = new Uint8Array(32); + crypto.getRandomValues(a); + return Array.from(a, b => b.toString(16).padStart(2, "0")).join(""); + })()); } function generateNonce() { @@ -52,6 +53,7 @@ function generateNonce() { } export { + Usp, getAuthorizationUrl, exchangeCode, generateState, diff --git a/src/tests/AuthSecurityTest.res b/src/tests/AuthSecurityTest.res index 307065e..980cabc 100644 --- a/src/tests/AuthSecurityTest.res +++ b/src/tests/AuthSecurityTest.res @@ -162,8 +162,8 @@ Deno.testSync("decodeJwt: alg:none token is parsed structurally (header readable let payload = btoa(`{"sub":"attacker","iss":"https://auth.example.com","aud":"svalinn","exp":9999999999,"iat":0}`) let noneToken = `${noneHeader}.${payload}.` let decoded = Jwt.decodeJwt(noneToken) - let alg = %raw(`decoded.header.alg`) - Assert.assertEquals(alg, "none") + let header: {"alg": string} = Obj.magic(decoded.header) + Assert.assertEquals(header["alg"], "none") }) Deno.test("verifyJwt: rejects alg:none token (algorithm confusion attack)", async () => { @@ -207,8 +207,8 @@ Deno.testSync("decodeJwt: token with missing sub claim is parseable (claim check let noSubPayload = btoa(`{"iss":"https://auth.example.com","aud":"svalinn","exp":9999999999,"iat":0}`) let noSubToken = `${noSubHeader}.${noSubPayload}.sig` let decoded = Jwt.decodeJwt(noSubToken) - let sub = %raw(`decoded.payload.sub`) - Assert.assertEquals(sub === %raw("undefined"), true) + let payload: {"sub": option} = Obj.magic(decoded.payload) + Assert.assertEquals(payload["sub"], None) }) Deno.testSync("decodeJwt: token with missing exp claim is parseable", () => { @@ -216,8 +216,8 @@ Deno.testSync("decodeJwt: token with missing exp claim is parseable", () => { let noExpPayload = btoa(`{"sub":"user","iss":"https://auth.example.com","aud":"svalinn","iat":0}`) let noExpToken = `${noExpHeader}.${noExpPayload}.sig` let decoded = Jwt.decodeJwt(noExpToken) - let expVal = %raw(`decoded.payload.exp`) - Assert.assertEquals(expVal === %raw("undefined"), true) + let payload: {"exp": option} = Obj.magic(decoded.payload) + Assert.assertEquals(payload["exp"], None) }) // ─── 6. Token replay / revocation stub ─────────────────────────────────────── diff --git a/src/tests/AuthSecurityTest.res.mjs b/src/tests/AuthSecurityTest.res.mjs index 6a36197..194af84 100644 --- a/src/tests/AuthSecurityTest.res.mjs +++ b/src/tests/AuthSecurityTest.res.mjs @@ -110,8 +110,8 @@ Deno.test("decodeJwt: alg:none token is parsed structurally (header readable)", let payload = btoa(`{"sub":"attacker","iss":"https://auth.example.com","aud":"svalinn","exp":9999999999,"iat":0}`); let noneToken = noneHeader + `.` + payload + `.`; let decoded = Jwt.decodeJwt(noneToken); - let alg = decoded.header.alg; - Assert1.assertEquals(alg, "none"); + let header = decoded.header; + Assert1.assertEquals(header.alg, "none"); }); Deno.test("verifyJwt: rejects alg:none token (algorithm confusion attack)", async () => { @@ -140,18 +140,18 @@ Deno.test("decodeJwt: token with missing sub claim is parseable (claim check is let noSubHeader = btoa(`{"alg":"RS256","typ":"JWT"}`); let noSubPayload = btoa(`{"iss":"https://auth.example.com","aud":"svalinn","exp":9999999999,"iat":0}`); let noSubToken = noSubHeader + `.` + noSubPayload + `.sig`; - let decodedNoSub = Jwt.decodeJwt(noSubToken); - let sub = decodedNoSub.payload.sub; - Assert1.assertEquals(sub === undefined, true); + let decoded = Jwt.decodeJwt(noSubToken); + let payload = decoded.payload; + Assert1.assertEquals(payload.sub, undefined); }); Deno.test("decodeJwt: token with missing exp claim is parseable", () => { let noExpHeader = btoa(`{"alg":"RS256","typ":"JWT"}`); let noExpPayload = btoa(`{"sub":"user","iss":"https://auth.example.com","aud":"svalinn","iat":0}`); let noExpToken = noExpHeader + `.` + noExpPayload + `.sig`; - let decodedNoExp = Jwt.decodeJwt(noExpToken); - let expVal = decodedNoExp.payload.exp; - Assert1.assertEquals(expVal === undefined, true); + let decoded = Jwt.decodeJwt(noExpToken); + let payload = decoded.payload; + Assert1.assertEquals(payload.exp, undefined); }); Deno.test("token replay: revocation contract documented (placeholder for JTI deny-list)", () => { diff --git a/src/tests/AuthTest.res b/src/tests/AuthTest.res index ea03ee9..7581d7f 100644 --- a/src/tests/AuthTest.res +++ b/src/tests/AuthTest.res @@ -59,10 +59,12 @@ Deno.testSync("decodeJwt parses valid JWT", () => { let token = `${header}.${payload}.${signature}` let decoded = Jwt.decodeJwt(token) - let decodedHeader = decoded["header"] + // Use decoded explicitly so the binding isn't optimised away (ReScript + // doesn't see the %raw references below). + Assert.assertEquals(decoded.headerB64, header) let decodedPayload: AuthTypes.Types.tokenPayload = %raw(`decoded.payload`) - Assert.assertEquals(%raw(`decodedHeader.alg`), "RS256") + Assert.assertEquals(%raw(`decoded.header.alg`), "RS256") Assert.assertEquals(decodedPayload.sub, "user123") Assert.assertEquals(decodedPayload.iss, "https://auth.example.com") }) diff --git a/src/tests/AuthTest.res.mjs b/src/tests/AuthTest.res.mjs index ea7ff2c..b67ce5a 100644 --- a/src/tests/AuthTest.res.mjs +++ b/src/tests/AuthTest.res.mjs @@ -42,9 +42,9 @@ Deno.test("decodeJwt parses valid JWT", () => { let payload = btoa(JSON.stringify(payloadObj)); let token = header + `.` + payload + `.` + "test-signature"; let decoded = Jwt.decodeJwt(token); - let decodedHeader = decoded.header; + Assert1.assertEquals(decoded.headerB64, header); let decodedPayload = decoded.payload; - Assert1.assertEquals(decodedHeader.alg, "RS256"); + Assert1.assertEquals(decoded.header.alg, "RS256"); Assert1.assertEquals(decodedPayload.sub, "user123"); Assert1.assertEquals(decodedPayload.iss, "https://auth.example.com"); });