Skip to content

Commit 1dd3029

Browse files
Merge pull request #1024 from VH-Lab/claude/fix-decodejwt-base64
Fix cloud login: decode JWT payload with matlab.net.base64decode
2 parents 846c4bd + 4a571dc commit 1dd3029

2 files changed

Lines changed: 105 additions & 1 deletion

File tree

‎src/ndi/+ndi/+cloud/+internal/decodeJwt.m‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
if padding > 0
4141
payloadBase64 = [payloadBase64, repmat('=', 1, 4 - padding)];
4242
end
43-
payloadBytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(payloadBase64);
43+
payloadBytes = matlab.net.base64decode(payloadBase64);
4444
payloadJSON = native2unicode(payloadBytes, 'UTF-8');
4545

4646
% Ensure json payload is a row vector
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
classdef DecodeJwtTest < matlab.unittest.TestCase
2+
% DECODEJWTTEST - offline tests for ndi.cloud.internal.decodeJwt
3+
%
4+
% decodeJwt used javax.xml.bind.DatatypeConverter to Base64-decode the JWT
5+
% payload. That class is gone from the Java shipped with recent MATLAB
6+
% releases, so every cloud login failed in getTokenExpiration. It now uses
7+
% matlab.net.base64decode. These tests build a JWT locally from a known
8+
% JSON payload, so they need no network or credentials.
9+
%
10+
% Authored without a local MATLAB runtime; needs MATLAB to validate/run.
11+
12+
methods (Test)
13+
14+
function testPayloadWithoutPadding(testCase)
15+
% Payload byte count is a multiple of 3: no '=' to restore.
16+
payload = ndi.unittest.cloud.DecodeJwtTest.examplePayload();
17+
[token, payloadSegment] = ...
18+
ndi.unittest.cloud.DecodeJwtTest.makeJwt(payload, 0);
19+
testCase.verifyEqual(mod(numel(payloadSegment), 4), 0);
20+
21+
decoded = ndi.cloud.internal.decodeJwt(token);
22+
ndi.unittest.cloud.DecodeJwtTest.verifyPayload(testCase, decoded, payload);
23+
end
24+
25+
function testPayloadNeedingOnePadCharacter(testCase)
26+
% Payload byte count mod 3 == 2: one '=' was stripped.
27+
payload = ndi.unittest.cloud.DecodeJwtTest.examplePayload();
28+
[token, payloadSegment] = ...
29+
ndi.unittest.cloud.DecodeJwtTest.makeJwt(payload, 2);
30+
testCase.verifyEqual(mod(numel(payloadSegment), 4), 3);
31+
32+
decoded = ndi.cloud.internal.decodeJwt(token);
33+
ndi.unittest.cloud.DecodeJwtTest.verifyPayload(testCase, decoded, payload);
34+
end
35+
36+
function testPayloadNeedingTwoPadCharacters(testCase)
37+
% Payload byte count mod 3 == 1: two '=' were stripped.
38+
payload = ndi.unittest.cloud.DecodeJwtTest.examplePayload();
39+
[token, payloadSegment] = ...
40+
ndi.unittest.cloud.DecodeJwtTest.makeJwt(payload, 1);
41+
testCase.verifyEqual(mod(numel(payloadSegment), 4), 2);
42+
43+
decoded = ndi.cloud.internal.decodeJwt(token);
44+
ndi.unittest.cloud.DecodeJwtTest.verifyPayload(testCase, decoded, payload);
45+
end
46+
47+
function testReturnsTokenExpiration(testCase)
48+
% The real caller: getTokenExpiration reads the 'exp' claim.
49+
payload = ndi.unittest.cloud.DecodeJwtTest.examplePayload();
50+
token = ndi.unittest.cloud.DecodeJwtTest.makeJwt(payload, 0);
51+
52+
decoded = ndi.cloud.internal.decodeJwt(token);
53+
testCase.verifyTrue(isfield(decoded, 'exp'));
54+
testCase.verifyEqual(decoded.exp, 1893456000);
55+
end
56+
57+
end
58+
59+
methods (Static, Access = private)
60+
61+
function payload = examplePayload()
62+
% A JWT-like payload. The name includes non-ASCII characters
63+
% (e-acute and the euro sign) to exercise the UTF-8 decode.
64+
payload = struct( ...
65+
'exp', 1893456000, ...
66+
'email', 'test.user@example.com', ...
67+
'name', ['Ren', char(233), ' ', char(8364)], ...
68+
'nonce', '');
69+
end
70+
71+
function [token, payloadSegment] = makeJwt(payload, targetMod3)
72+
% Build HEADER.PAYLOAD.SIGNATURE with Base64Url segments. The
73+
% 'nonce' field is lengthened until the UTF-8 payload byte count
74+
% mod 3 equals targetMod3, which fixes how much padding the
75+
% decoder has to restore.
76+
payloadBytes = unicode2native(jsonencode(payload), 'UTF-8');
77+
while mod(numel(payloadBytes), 3) ~= targetMod3
78+
payload.nonce = [payload.nonce, 'x'];
79+
payloadBytes = unicode2native(jsonencode(payload), 'UTF-8');
80+
end
81+
82+
headerBytes = unicode2native('{"alg":"HS256","typ":"JWT"}', 'UTF-8');
83+
headerSegment = ndi.unittest.cloud.DecodeJwtTest.base64UrlEncode(headerBytes);
84+
payloadSegment = ndi.unittest.cloud.DecodeJwtTest.base64UrlEncode(payloadBytes);
85+
token = [headerSegment, '.', payloadSegment, '.', 'dummySignature'];
86+
end
87+
88+
function encoded = base64UrlEncode(bytes)
89+
encoded = char(matlab.net.base64encode(uint8(bytes)));
90+
encoded = strrep(encoded, '+', '-');
91+
encoded = strrep(encoded, '/', '_');
92+
encoded = strrep(encoded, '=', '');
93+
end
94+
95+
function verifyPayload(testCase, decoded, payload)
96+
testCase.verifyClass(decoded, 'struct');
97+
testCase.verifyEqual(decoded.exp, payload.exp);
98+
testCase.verifyEqual(decoded.email, payload.email);
99+
testCase.verifyEqual(decoded.name, payload.name);
100+
end
101+
102+
end
103+
104+
end

0 commit comments

Comments
 (0)