Skip to content

Commit 06209ea

Browse files
fix: handle WebAuthn transports as comma separated string (#4041)
OKTA-1184940 fix: handle webauthn transports as comma separated list
1 parent 52b316d commit 06209ea

7 files changed

Lines changed: 302 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@
246246
"webpack-dev-server": "^4.15.2"
247247
},
248248
"dependencies": {
249-
"@okta/okta-auth-js": "7.14.2",
249+
"@okta/okta-auth-js": "7.14.4",
250250
"@sindresorhus/to-milliseconds": "^1.0.0",
251251
"@types/backbone": "^1.4.15",
252252
"@types/eslint-scope": "^3.7.3",

src/v2/view-builder/views/webauthn/ChallengeWebauthnView.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ const Body = BaseForm.extend({
8888
type: 'public-key',
8989
id: CryptoUtil.strToBin(enrollement.credentialId),
9090
};
91-
const transports = enrollement.transports ?? enrollement.profile?.transports;
91+
// okta-core may emit profile.transports as a comma separated string so the entire
92+
// profile serializes as Map<String,String>; tolerate both array and string shapes.
93+
let transports = enrollement.transports ?? enrollement.profile?.transports;
94+
if (transports && typeof transports === 'string') {
95+
transports = transports.split(',');
96+
}
9297
if (Array.isArray(transports)) {
9398
credential.transports = transports;
9499
}

src/v2/view-builder/views/webauthn/EnrollWebauthnView.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ function getExcludeCredentials(authenticatorEnrollments = []) {
1616
type: 'public-key',
1717
id: CryptoUtil.strToBin(enrollement.credentialId),
1818
};
19-
const transports = enrollement.transports ?? enrollement.profile?.transports;
19+
// okta-core may emit profile.transports as a comma separated string so the entire
20+
// profile serializes as Map<String,String>; tolerate both array and string shapes.
21+
let transports = enrollement.transports ?? enrollement.profile?.transports;
22+
if (typeof transports === 'string') {
23+
transports = transports.split(',');
24+
}
2025
if (Array.isArray(transports)) {
2126
credential.transports = transports;
2227
}

src/v3/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"@mui/material": "^5.8.5",
5555
"@okta/odyssey-design-tokens": "1.44.0",
5656
"@okta/odyssey-react-mui": "1.44.0",
57-
"@okta/okta-auth-js": "7.14.2",
57+
"@okta/okta-auth-js": "7.14.4",
5858
"altcha": "^1.4.2",
5959
"chroma-js": "^2.4.2",
6060
"cross-fetch": "^3.1.5",

test/unit/spec/v2/view-builder/views/ChallengeWebauthnView_spec.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,148 @@ describe('v2/view-builder/views/webauthn/ChallengeWebauthnView', function() {
508508
.catch(done.fail);
509509
});
510510

511+
// OKTA-1184438: okta-core emits profile.transports as a comma separated string
512+
// so the entire profile serializes as Map<String,String>; tolerate both shapes.
513+
it('parses comma separated string profile.transports in allowCredentials', function(done) {
514+
const assertion = {
515+
response: {
516+
clientDataJSON: 123,
517+
authenticatorData: 234,
518+
signature: 'magizh',
519+
},
520+
};
521+
jest.spyOn(BaseForm.prototype, 'saveForm');
522+
jest.spyOn(navigator.credentials, 'get').mockReturnValue(Promise.resolve(assertion));
523+
524+
const enrollmentsWithStringTransports = {
525+
value: [
526+
{
527+
displayName: 'Touch ID',
528+
type: 'security_key',
529+
key: 'webauthn',
530+
id: 'autwa6eD9o02iBbtv0g3',
531+
authenticatorId: 'fwftheidkwh282hv8g3',
532+
credentialId: '7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w',
533+
profile: { transports: 'usb,nfc' },
534+
},
535+
],
536+
};
537+
538+
testContext.init(
539+
ChallengeWebauthnResponse.currentAuthenticator.value,
540+
enrollmentsWithStringTransports,
541+
);
542+
Expect.wait(() => {
543+
return BaseForm.prototype.saveForm.mock.calls.length > 0;
544+
}).then(() => {
545+
const calledWith = navigator.credentials.get.mock.calls[0][0];
546+
expect(calledWith.publicKey.allowCredentials).toEqual([
547+
{
548+
type: 'public-key',
549+
id: CryptoUtil.strToBin(
550+
'7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w'
551+
),
552+
transports: ['usb', 'nfc'],
553+
},
554+
]);
555+
done();
556+
})
557+
.catch(done.fail);
558+
});
559+
560+
it('parses single string profile.transports in allowCredentials', function(done) {
561+
const assertion = {
562+
response: {
563+
clientDataJSON: 123,
564+
authenticatorData: 234,
565+
signature: 'magizh',
566+
},
567+
};
568+
jest.spyOn(BaseForm.prototype, 'saveForm');
569+
jest.spyOn(navigator.credentials, 'get').mockReturnValue(Promise.resolve(assertion));
570+
571+
const enrollmentsWithStringTransports = {
572+
value: [
573+
{
574+
displayName: 'Touch ID',
575+
type: 'security_key',
576+
key: 'webauthn',
577+
id: 'autwa6eD9o02iBbtv0g3',
578+
authenticatorId: 'fwftheidkwh282hv8g3',
579+
credentialId: '7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w',
580+
profile: { transports: 'usb' },
581+
},
582+
],
583+
};
584+
585+
testContext.init(
586+
ChallengeWebauthnResponse.currentAuthenticator.value,
587+
enrollmentsWithStringTransports,
588+
);
589+
Expect.wait(() => {
590+
return BaseForm.prototype.saveForm.mock.calls.length > 0;
591+
}).then(() => {
592+
const calledWith = navigator.credentials.get.mock.calls[0][0];
593+
expect(calledWith.publicKey.allowCredentials).toEqual([
594+
{
595+
type: 'public-key',
596+
id: CryptoUtil.strToBin(
597+
'7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w'
598+
),
599+
transports: ['usb'],
600+
},
601+
]);
602+
done();
603+
})
604+
.catch(done.fail);
605+
});
606+
607+
it('omits transports in allowCredentials when profile.transports is not a string', function(done) {
608+
const assertion = {
609+
response: {
610+
clientDataJSON: 123,
611+
authenticatorData: 234,
612+
signature: 'magizh',
613+
},
614+
};
615+
jest.spyOn(BaseForm.prototype, 'saveForm');
616+
jest.spyOn(navigator.credentials, 'get').mockReturnValue(Promise.resolve(assertion));
617+
618+
const enrollmentsWithBadTransports = {
619+
value: [
620+
{
621+
displayName: 'Touch ID',
622+
type: 'security_key',
623+
key: 'webauthn',
624+
id: 'autwa6eD9o02iBbtv0g3',
625+
authenticatorId: 'fwftheidkwh282hv8g3',
626+
credentialId: '7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w',
627+
profile: { transports: 123 },
628+
},
629+
],
630+
};
631+
632+
testContext.init(
633+
ChallengeWebauthnResponse.currentAuthenticator.value,
634+
enrollmentsWithBadTransports,
635+
);
636+
Expect.wait(() => {
637+
return BaseForm.prototype.saveForm.mock.calls.length > 0;
638+
}).then(() => {
639+
const calledWith = navigator.credentials.get.mock.calls[0][0];
640+
expect(calledWith.publicKey.allowCredentials).toEqual([
641+
{
642+
type: 'public-key',
643+
id: CryptoUtil.strToBin(
644+
'7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w'
645+
),
646+
},
647+
]);
648+
done();
649+
})
650+
.catch(done.fail);
651+
});
652+
511653
describe('WebAuthn displayName variations', function() {
512654
it('shows DEFAULT title', function() {
513655
testContext.init(ChallengeWebauthnResponse.currentAuthenticator.value);

test/unit/spec/v2/view-builder/views/EnrollWebauthnView_spec.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,148 @@ describe('v2/view-builder/views/webauthn/EnrollWebauthnView', function() {
512512
.catch(done.fail);
513513
});
514514

515+
// OKTA-1184438: okta-core emits profile.transports as a comma separated string
516+
// so the entire profile serializes as Map<String,String>; tolerate both shapes.
517+
it('excludeCredentials parses comma separated string profile.transports', function(done) {
518+
const newCredential = {
519+
response: {
520+
clientDataJSON: 123,
521+
attestationObject: 234,
522+
getTransports: function() { return ['internal']; },
523+
},
524+
getClientExtensionResults: function() { return {}; },
525+
};
526+
spyOn(webauthn, 'isNewApiAvailable').and.callFake(() => true);
527+
spyOn(navigator.credentials, 'create').and.returnValue(Promise.resolve(newCredential));
528+
spyOn(BaseForm.prototype, 'saveForm');
529+
530+
const enrollmentsWithStringTransports = {
531+
value: [
532+
{
533+
displayName: 'Touch ID',
534+
type: 'security_key',
535+
key: 'webauthn',
536+
id: 'autwa6eD9o02iBbtv0g3',
537+
authenticatorId: 'fwftheidkwh282hv8g3',
538+
credentialId: '7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w',
539+
profile: { transports: 'usb,nfc' },
540+
},
541+
],
542+
};
543+
544+
testContext.init(EnrollWebauthnResponse.currentAuthenticator.value, enrollmentsWithStringTransports);
545+
testContext.view.$('.webauthn-setup').click();
546+
547+
Expect.waitForSpyCall(testContext.view.form.saveForm)
548+
.then(() => {
549+
const calledWith = navigator.credentials.create.calls.mostRecent().args[0];
550+
expect(calledWith.publicKey.excludeCredentials).toEqual([
551+
{
552+
type: 'public-key',
553+
id: CryptoUtil.strToBin(
554+
'7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w'
555+
),
556+
transports: ['usb', 'nfc'],
557+
},
558+
]);
559+
done();
560+
})
561+
.catch(done.fail);
562+
});
563+
564+
it('excludeCredentials parses single string profile.transports', function(done) {
565+
const newCredential = {
566+
response: {
567+
clientDataJSON: 123,
568+
attestationObject: 234,
569+
getTransports: function() { return ['internal']; },
570+
},
571+
getClientExtensionResults: function() { return {}; },
572+
};
573+
spyOn(webauthn, 'isNewApiAvailable').and.callFake(() => true);
574+
spyOn(navigator.credentials, 'create').and.returnValue(Promise.resolve(newCredential));
575+
spyOn(BaseForm.prototype, 'saveForm');
576+
577+
const enrollmentsWithStringTransports = {
578+
value: [
579+
{
580+
displayName: 'Touch ID',
581+
type: 'security_key',
582+
key: 'webauthn',
583+
id: 'autwa6eD9o02iBbtv0g3',
584+
authenticatorId: 'fwftheidkwh282hv8g3',
585+
credentialId: '7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w',
586+
profile: { transports: 'usb' },
587+
},
588+
],
589+
};
590+
591+
testContext.init(EnrollWebauthnResponse.currentAuthenticator.value, enrollmentsWithStringTransports);
592+
testContext.view.$('.webauthn-setup').click();
593+
594+
Expect.waitForSpyCall(testContext.view.form.saveForm)
595+
.then(() => {
596+
const calledWith = navigator.credentials.create.calls.mostRecent().args[0];
597+
expect(calledWith.publicKey.excludeCredentials).toEqual([
598+
{
599+
type: 'public-key',
600+
id: CryptoUtil.strToBin(
601+
'7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w'
602+
),
603+
transports: ['usb'],
604+
},
605+
]);
606+
done();
607+
})
608+
.catch(done.fail);
609+
});
610+
611+
it('excludeCredentials omits transports when profile.transports is not a valid string', function(done) {
612+
const newCredential = {
613+
response: {
614+
clientDataJSON: 123,
615+
attestationObject: 234,
616+
getTransports: function() { return ['internal']; },
617+
},
618+
getClientExtensionResults: function() { return {}; },
619+
};
620+
spyOn(webauthn, 'isNewApiAvailable').and.callFake(() => true);
621+
spyOn(navigator.credentials, 'create').and.returnValue(Promise.resolve(newCredential));
622+
spyOn(BaseForm.prototype, 'saveForm');
623+
624+
const enrollmentsWithBadTransports = {
625+
value: [
626+
{
627+
displayName: 'Touch ID',
628+
type: 'security_key',
629+
key: 'webauthn',
630+
id: 'autwa6eD9o02iBbtv0g3',
631+
authenticatorId: 'fwftheidkwh282hv8g3',
632+
credentialId: '7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w',
633+
profile: { transports: 123 },
634+
},
635+
],
636+
};
637+
638+
testContext.init(EnrollWebauthnResponse.currentAuthenticator.value, enrollmentsWithBadTransports);
639+
testContext.view.$('.webauthn-setup').click();
640+
641+
Expect.waitForSpyCall(testContext.view.form.saveForm)
642+
.then(() => {
643+
const calledWith = navigator.credentials.create.calls.mostRecent().args[0];
644+
expect(calledWith.publicKey.excludeCredentials).toEqual([
645+
{
646+
type: 'public-key',
647+
id: CryptoUtil.strToBin(
648+
'7Ag2iWUqfz0SanWDj-ZZ2fpDsgiEDt_08O1VSSRZHpgkUS1zhLSyWYDrxXXB5VE_w1iiqSvPaRgXcmG5rPwB-w'
649+
),
650+
},
651+
]);
652+
done();
653+
})
654+
.catch(done.fail);
655+
});
656+
515657
it('credentials.get should be called with empty excludeCredentials when requreResidentKey=true', function(done) {
516658
const newCredential = {
517659
response: {

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4738,10 +4738,10 @@
47384738
react-window "^1.8.10"
47394739
word-wrap "^1.2.5"
47404740

4741-
"@okta/okta-auth-js@7.14.2":
4742-
version "7.14.2"
4743-
resolved "https://registry.npmjs.org/@okta/okta-auth-js/-/okta-auth-js-7.14.2.tgz#1da3ecf1168259f843f7e6d04afae732deccd7bb"
4744-
integrity sha512-1RH+53jF7NvH6DrTxWFk+vD1yvb4tfQp5yX+Jp51nyTVePcGtG+Q3yzvgDty/EEEJt02cxWuQAlKwwg6mKwygg==
4741+
"@okta/okta-auth-js@7.14.4":
4742+
version "7.14.4"
4743+
resolved "https://registry.npmjs.org/@okta/okta-auth-js/-/okta-auth-js-7.14.4.tgz#e600bdd7474d03535b9743b4806068dedade325b"
4744+
integrity sha512-FiZPGU2lZ5i61SdZaLgaY9rhie9r7KuenA5QcztlUs1GgTqsQvAUAQTkNaciKt0IeokrqquE9p0excH9prxmCQ==
47454745
dependencies:
47464746
"@babel/runtime" "^7.27.0"
47474747
"@peculiar/webcrypto" "^1.4.0"

0 commit comments

Comments
 (0)