@@ -153,6 +156,7 @@ export function updateForm(origConfig: Config): void {
(document.getElementById('f_responseType') as HTMLInputElement).value = config.responseType.join(',');
(document.getElementById('f_scopes') as HTMLInputElement).value = config.scopes.join(',');
(document.getElementById('f_acrValues') as HTMLInputElement).value = config.acrValues || '';
+ (document.getElementById('f_enroll_amr_values') as HTMLInputElement).value = (config.enrollAmrValues || []).join(',');
(document.getElementById('f_postLogoutRedirectUri') as HTMLInputElement).value = config.postLogoutRedirectUri;
(document.getElementById('f_clientId') as HTMLInputElement).value = config.clientId;
(document.getElementById('f_clientSecret') as HTMLInputElement).value = config.clientSecret;
diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts
index 9108116a9..ce8200121 100644
--- a/test/apps/app/src/testApp.ts
+++ b/test/apps/app/src/testApp.ts
@@ -22,6 +22,7 @@ import {
AccessToken,
AuthnTransaction,
TokenParams,
+ EnrollAuthenticatorOptions,
isAuthorizationCodeError,
IdxStatus,
IdxTransaction,
@@ -90,6 +91,9 @@ function loginLinks(app: TestApp, onProtectedPage?: boolean): string {
+
${protectedPageLink}
@@ -184,6 +188,7 @@ function bindFunctions(testApp: TestApp, window: Window): void {
simulateCrossTabTokenRenew: testApp.simulateCrossTabTokenRenew.bind(testApp),
startService: testApp.startService.bind(testApp),
stopService: testApp.stopService.bind(testApp),
+ enrollAuthenticator: testApp.enrollAuthenticator.bind(testApp),
};
Object.keys(boundFunctions).forEach(functionName => {
(window as any)[functionName] = makeClickHandler((boundFunctions as any)[functionName]);
@@ -807,7 +812,9 @@ class TestApp {
async getTokensFromUrl(): Promise {
// parseFromUrl() Will parse the authorization code from the URL fragment and exchange it for tokens
const res = await this.oktaAuth.token.parseFromUrl();
- this.oktaAuth.tokenManager.setTokens(res.tokens);
+ if (res.responseType !== 'none') {
+ this.oktaAuth.tokenManager.setTokens(res.tokens);
+ }
return res;
}
@@ -892,6 +899,21 @@ class TestApp {
}
}
+ enrollAuthenticator(): void {
+ this.config.state = this.config.state || 'enroll-authenticator-redirect' + Math.round(Math.random() * 1000);
+ saveConfigToStorage(this.config);
+ const options: EnrollAuthenticatorOptions = Object.assign({}, {
+ state: this.config.state,
+ enrollAmrValues: this.config.enrollAmrValues,
+ acrValues: this.config.acrValues,
+ });
+ try {
+ this.oktaAuth.endpoints.authorize.enrollAuthenticator(options);
+ } catch(e) {
+ this.renderError(e);
+ }
+ }
+
configHTML(): string {
const config = htmlString(this.config);
return `
@@ -947,6 +969,9 @@ class TestApp {
+
${protectedLink(this)}
@@ -970,12 +995,15 @@ class TestApp {
`;
}
+ /* eslint-disable complexity */
callbackHTML(res: TokenResponse): string {
const tokensReceived = res.tokens ? Object.keys(res.tokens): [];
+ const isEnrollSuccess = res.responseType === 'none';
const success = res.tokens && tokensReceived.length;
- const errorMessage = success ? '' : 'Tokens not returned. Check error console for more details';
- const successMessage = success ?
- 'Successfully received tokens on the callback page: ' + tokensReceived.join(', ') : '';
+ const errorMessage = isEnrollSuccess ? '' :
+ success ? '' : 'Tokens not returned. Check error console for more details';
+ const successMessage = isEnrollSuccess ? 'Authenticator enrollment completed' :
+ success ? 'Successfully received tokens on the callback page: ' + tokensReceived.join(', ') : '';
const originalUri = this.oktaAuth.getOriginalUri(res.state);
const content = `
diff --git a/test/e2e/config.js b/test/e2e/config.js
index 625bd01e4..3ba75cf51 100644
--- a/test/e2e/config.js
+++ b/test/e2e/config.js
@@ -19,7 +19,8 @@ const config = [
],
features: [
'login.feature',
- 'acr-values.feature'
+ 'acr-values.feature',
+ 'enroll-authenticator.feature',
]
},
{
diff --git a/test/e2e/features/enroll-authenticator.feature b/test/e2e/features/enroll-authenticator.feature
new file mode 100644
index 000000000..3899fdf7c
--- /dev/null
+++ b/test/e2e/features/enroll-authenticator.feature
@@ -0,0 +1,43 @@
+Feature: Enroll Authenticator via Authorize Endpoint
+
+Background:
+ Given an App that assigned to a test group
+ And a Policy that defines "Authentication"
+ And with a Policy Rule that defines "Password as the only factor"
+ And a Policy that defines "Profile Enrollment"
+ And with a Policy Rule that defines "collecting default attributes and emailVerification is not required"
+ And a Policy that defines "MFA Enrollment" with properties
+ | okta_password | REQUIRED |
+ | okta_email | REQUIRED |
+ | security_question | OPTIONAL |
+ And with a Policy Rule that defines "MFA Enrollment Challenge"
+ And a user named "Mary"
+ And she has an account with "active" state in the org
+
+Scenario: Mary Enrolls into Security Question
+ Given Mary is on the default view in an UNAUTHENTICATED state
+ And she is not enrolled in the "question" factors
+ When she enters "kba" into "Enroll AMR values"
+ And she selects "urn:okta:loa:2fa:any:ifpossible" into "ACR values"
+ And she clicks the "Update Config" button
+ Then she sees "kba" in "Enroll AMR values"
+ Then she sees "urn:okta:loa:2fa:any:ifpossible" in "ACR values"
+ When she clicks the "Enroll Authenticator" button
+ Then the app should construct an authorize request with params
+ | prompt | enroll_authenticator |
+ | acr_values | urn:okta:loa:2fa:any:ifpossible |
+ | enroll_amr_values | kba |
+ | response_type | none |
+ | max_age | 0 |
+ And she should be redirected to the Okta Sign In Widget
+ When she inputs her username and password in widget
+ Then she should be challenged to verify her email
+ When she verifies her email
+ Then she is required to set up authenticator "Security Question"
+ When she creates security question answer
+ Then she is redirected to the handle callback page
+ When she clicks the "Handle callback (Continue Login)" button
+ Then the callback is handled with message "Authenticator enrollment completed"
+ When she returns home
+ Then she is redirected to the default view in an UNAUTHENTICATED state
+ And she is enrolled in the "question" factors
diff --git a/test/e2e/features/step-definitions/steps.ts b/test/e2e/features/step-definitions/steps.ts
index 3dccadbb9..bdebfac6d 100644
--- a/test/e2e/features/step-definitions/steps.ts
+++ b/test/e2e/features/step-definitions/steps.ts
@@ -3,9 +3,14 @@ import ActionContext from 'support/context';
import TestApp from '../../pageobjects/TestApp';
import OktaLogin from '../../pageobjects/OktaLogin';
import { openPKCE } from '../../util/appUtils';
+import listFactors from 'management-api/listFactors';
const ORG_OIE_ENABLED = process.env.ORG_OIE_ENABLED;
+interface DataTable {
+ rawTable: string[][]
+}
+
When(/^she logins with (\w+) and (.+)$/, async function (username, password) {
await $('#username').setValue(username);
await $('#password').setValue(password);
@@ -43,6 +48,9 @@ When('she clicks the {string} button', async function (buttonName) {
case 'Login with ACR':
el = await TestApp.loginWithAcrBtn;
break;
+ case 'Enroll Authenticator':
+ el = await TestApp.enrollAuthenticator;
+ break;
case 'Handle callback (Continue Login)':
el = await TestApp.handleCallbackBtn;
break;
@@ -72,6 +80,18 @@ Then(
}
);
+Then(
+ 'the callback is handled with message {string}',
+ async function (expectedMsg: string) {
+ await (await TestApp.success).waitForDisplayed({
+ timeout: 3*1000,
+ });
+
+ const successText = await (await TestApp.success).getText();
+ expect(successText).toBe(expectedMsg);
+ }
+);
+
Then(
'the app should construct an authorize request for the protected action, not including an ACR Token in the request or an ACR value',
async function () {
@@ -120,6 +140,34 @@ Then(
}
);
+Then(
+ 'the app should construct an authorize request with params',
+ async function (dataTable: DataTable) {
+ const expectedParams: Record = dataTable?.rawTable.
+ reduce((acc: any, [key, value]) => {
+ acc[key] = value;
+ return acc;
+ }, {});
+
+ await browser.waitUntil(async () => {
+ const url = await browser.getUrl();
+ return url.includes('/authorize');
+ });
+
+ const url = await browser.getUrl();
+ const queryStr = url.split('?')[1];
+ const urlParams = new URLSearchParams(queryStr);
+ const params = {};
+ urlParams.forEach((value, key) => {
+ params[key] = value;
+ });
+
+ for (const [k, v] of Object.entries(expectedParams)) {
+ expect(params[k]).toBe(v);
+ }
+ }
+);
+
Then(
'she should be redirected to the Okta Sign In Widget',
async function () {
@@ -137,7 +185,6 @@ When(
}
);
-
Then(
/^she (?:is redirected to|sees) the default view in an AUTHENTICATED state$/,
{ timeout: 10*1000 },
@@ -160,6 +207,14 @@ When(
}
);
+Then(
+ /^she (?:is redirected to|sees) the default view in an UNAUTHENTICATED state$/,
+ { timeout: 10*1000 },
+ async function () {
+ await TestApp.assertLoggedOut();
+ }
+);
+
Then(
'she sees her ID and Access Tokens',
async function () {
@@ -178,7 +233,7 @@ When('she selects {string} into {string}', async function (value, field) {
let f;
switch (field) {
case 'ACR values':
- f = await TestApp.acrValues;
+ f = await TestApp.acrValues;
break;
default:
throw new Error(`Unknown field ${field}`);
@@ -186,6 +241,18 @@ When('she selects {string} into {string}', async function (value, field) {
await f.selectByAttribute('value', value);
});
+Given('she enters {string} into {string}', async function (value, field) {
+ let f;
+ switch (field) {
+ case 'Enroll AMR values':
+ f = await TestApp.enrollAmrValues;
+ break;
+ default:
+ throw new Error(`Unknown field ${field}`);
+ }
+ await f.setValue(value);
+});
+
When('she selects incorrect value in {string}', async function (field) {
let f: string;
switch (field) {
@@ -207,6 +274,9 @@ When('she selects incorrect value in {string}', async function (field) {
Then('she sees {string} in {string}', async function (value, field) {
let el;
switch (field) {
+ case 'Enroll AMR values':
+ el = await TestApp.enrollAmrValues;
+ break;
case 'ACR values':
el = await TestApp.acrValues;
break;
@@ -253,7 +323,7 @@ When(
await OktaLogin.verifyWithEmailCode();
const code = await this.a18nClient.getEmailCode(this.credentials.profileId);
await OktaLogin.enterCode(code);
- await OktaLogin.clickVerifyEmail();
+ await OktaLogin.clickVerify();
}
);
@@ -275,3 +345,56 @@ Then(
async function() {}
);
+Then(
+ 'she is required to set up authenticator "Security Question"',
+ { timeout: 10*1000 },
+ async function () {
+ await browser.waitUntil(async () => {
+ const list = await OktaLogin.authenticatorsList;
+ const isListDisplayed = await list?.isDisplayed();
+ return isListDisplayed;
+ }, {
+ timeout: 10*1000
+ });
+
+ await OktaLogin.selectSecurityQuestionAuthenticator();
+ }
+);
+
+When(
+ 'she creates security question answer',
+ { timeout: 20*1000 },
+ async function (this: ActionContext) {
+ const answer = 'okta';
+ await OktaLogin.enterAnswer(answer);
+ await OktaLogin.clickVerify();
+ }
+);
+
+Given(
+ 'she is enrolled in the {string} factors',
+ { timeout: 30*1000 },
+ async function(this: ActionContext, factorTypesStr: string) {
+ const enrolledFactorTypes = await listFactors(this.config, {
+ userId: this.user.id
+ });
+ const factorTypes = factorTypesStr.split(',').map(f => f.trim());
+ for (const f of factorTypes) {
+ expect(enrolledFactorTypes).toContain(f);
+ }
+ }
+);
+
+Given(
+ 'she is not enrolled in the {string} factors',
+ { timeout: 30*1000 },
+ async function(this: ActionContext, factorTypesStr: string) {
+ const enrolledFactorTypes = await listFactors(this.config, {
+ userId: this.user.id
+ });
+ const factorTypes = factorTypesStr.split(',').map(f => f.trim());
+ for (const f of factorTypes) {
+ expect(enrolledFactorTypes).not.toContain(f);
+ }
+ }
+);
diff --git a/test/e2e/pageobjects/OktaLogin.js b/test/e2e/pageobjects/OktaLogin.js
index ca34cb262..c34c41755 100644
--- a/test/e2e/pageobjects/OktaLogin.js
+++ b/test/e2e/pageobjects/OktaLogin.js
@@ -44,6 +44,8 @@ class OktaLogin {
get verifyBtn() { return $('form[data-se="o-form"] input[type=submit][value=Verify]'); }
get authenticatorsList() { return $('form[data-se="o-form"] .authenticator-list'); }
get authenticatorEmail() { return $('form[data-se="o-form"] .authenticator-list [data-se="okta_email"] .select-factor'); }
+ get authenticatorSecurityQuestion() { return $('form[data-se="o-form"] .authenticator-list [data-se="security_question"] .select-factor'); }
+ get securityQuestionAnswer() { return $('form[data-se="o-form"] input[name="credentials.answer"]'); }
async signin(username, password) {
await this.waitForLoad();
@@ -83,7 +85,14 @@ class OktaLogin {
(await this.authenticatorEmail).click();
}
- async clickVerifyEmail() {
+ async selectSecurityQuestionAuthenticator() {
+ await browser.waitUntil(async () => {
+ return (await this.authenticatorSecurityQuestion).isDisplayed();
+ }, 5000, 'wait for email authenticator in list');
+ (await this.authenticatorSecurityQuestion).click();
+ }
+
+ async clickVerify() {
await browser.waitUntil(async () => {
return (await this.verifyBtn).isDisplayed();
}, 5000, 'wait for verify btn');
@@ -113,6 +122,13 @@ class OktaLogin {
(await this.code).setValue(code);
}
+ async enterAnswer(answer) {
+ await browser.waitUntil(async () => {
+ return (await this.securityQuestionAnswer).isDisplayed();
+ }, 5000, 'wait for security question answer');
+ (await this.securityQuestionAnswer).setValue(answer);
+ }
+
async waitForLoad() {
if (process.env.ORG_OIE_ENABLED) {
// With Step Up MFA there can be no Submit button displayed,
diff --git a/test/e2e/pageobjects/TestApp.js b/test/e2e/pageobjects/TestApp.js
index 82b029695..eb513cea2 100644
--- a/test/e2e/pageobjects/TestApp.js
+++ b/test/e2e/pageobjects/TestApp.js
@@ -36,6 +36,7 @@ class TestApp {
get sessionExpired() { return $('#session-expired'); }
get testConcurrentGetTokenBtn() { return $('#test-concurrent-get-token'); }
get loginWithAcrBtn() { return $('#login-acr'); }
+ get enrollAuthenticator() { return $('#enroll-authenticator'); }
get tokenError() { return $('#token-error'); }
get tokenMsg() { return $('#token-msg'); }
@@ -60,6 +61,7 @@ class TestApp {
get issuer() { return $('#f_issuer'); }
get interactionCodeOption() { return $('#f_useInteractionCodeFlow-on'); }
get acrValues() { return $('#f_acrValues'); }
+ get enrollAmrValues() { return $('#f_enroll_amr_values'); }
get submit() { return $('#f_submit'); }
// Callback
@@ -86,7 +88,11 @@ class TestApp {
async open(queryObj, openInNewWindow) {
const qs = toQueryString(queryObj);
- await openInNewWindow ? browser.newWindow(qs, { windowFeatures: 'noopener=yes' }) : browser.url(qs);
+ if (openInNewWindow) {
+ await browser.newWindow(qs, { windowFeatures: 'noopener=yes' });
+ } else {
+ await browser.url('/' + qs);
+ }
await browser.waitUntil(async () => this.readySelector.then(el => el.isExisting()), 5000, 'wait for ready selector');
}
diff --git a/test/integration/util/getTokens.ts b/test/integration/util/getTokens.ts
index fa746344c..9b6ef4c9d 100644
--- a/test/integration/util/getTokens.ts
+++ b/test/integration/util/getTokens.ts
@@ -9,16 +9,17 @@ import { sleep } from './sleep';
function mockGetWithRedirect(client, testContext) {
jest.spyOn(client, 'getOriginalUri').mockImplementation(() => {});
jest.spyOn(client, 'setOriginalUri').mockImplementation(() => {});
- jest.spyOn(client.token.getWithRedirect, '_setLocation').mockImplementation(authorizeUrl => {
+ testContext.origSetLocation = client.options.setLocation;
+ client.options.setLocation = authorizeUrl => {
testContext.authorizeUrl = authorizeUrl;
- });
+ };
jest.spyOn(client.token.parseFromUrl, '_getLocation').mockImplementation(() => {});
}
-function unmockGetWithRedirect(client) {
+function unmockGetWithRedirect(client, testContext) {
client.getOriginalUri.mockRestore();
client.setOriginalUri.mockRestore();
- client.token.getWithRedirect._setLocation.mockRestore();
+ client.options.setLocation = testContext.origSetLocation;
client.token.parseFromUrl._getLocation.mockRestore();
}
@@ -43,7 +44,7 @@ async function getTokens(client, tokenParams) {
});
const transactionMeta = client.transactionManager.load();
const tokenResponse = await handleOAuthResponse(client, transactionMeta, oauthResponse, undefined as unknown as CustomUrls);
- unmockGetWithRedirect(client);
+ unmockGetWithRedirect(client, localContext);
return tokenResponse;
}
diff --git a/test/spec/OktaAuth/browser.ts b/test/spec/OktaAuth/browser.ts
index 5971806ed..829d013c0 100644
--- a/test/spec/OktaAuth/browser.ts
+++ b/test/spec/OktaAuth/browser.ts
@@ -482,6 +482,13 @@ describe('OktaAuth (browser)', function() {
await auth.storeTokensFromRedirect();
expect(auth.tokenManager.setTokens).toHaveBeenCalledWith({ accessToken, idToken });
});
+ it('does not store tokens if responseType is "none"', async () => {
+ auth.token.parseFromUrl = jest.fn().mockResolvedValue({
+ responseType: 'none'
+ });
+ await auth.storeTokensFromRedirect();
+ expect(auth.tokenManager.setTokens).not.toHaveBeenCalled();
+ });
});
describe('setOriginalUri', () => {
@@ -727,4 +734,20 @@ describe('OktaAuth (browser)', function() {
});
+ describe('handleRedirect', () => {
+ beforeEach(() => {
+ jest.spyOn(auth, 'handleLoginRedirect');
+ });
+
+ it('calls handleLoginRedirect', async () => {
+ await auth.handleRedirect();
+ expect(auth.handleLoginRedirect).toHaveBeenCalledWith(undefined, undefined);
+ });
+
+ it('calls handleLoginRedirect and passes originalUri', async () => {
+ await auth.handleRedirect('/overridden');
+ expect(auth.handleLoginRedirect).toHaveBeenCalledWith(undefined, '/overridden');
+ });
+ });
+
});
diff --git a/test/spec/oidc/endpoints/authorize.ts b/test/spec/oidc/endpoints/authorize.ts
index 4627f9529..629cded59 100644
--- a/test/spec/oidc/endpoints/authorize.ts
+++ b/test/spec/oidc/endpoints/authorize.ts
@@ -38,14 +38,15 @@ describe('authorize endpoint', () => {
})).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&response_type=id_token&scope=openid%20email');
});
- it('converts array parameters "idpScope", "responseType", and "scopes" to space-separated string', () => {
+ it('converts array parameters "idpScope", "responseType", "scopes" and "enrollAmrValues" to space-separated string', () => {
expect(buildAuthorizeParams({
clientId: 'fakeClientId',
codeChallenge: 'fakeCodeChallenge',
scopes: ['openid', 'email'],
idpScope: ['scope1', 'scope2'],
- responseType: ['id_token', 'token']
- })).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&idp_scope=scope1%20scope2&response_type=id_token%20token&scope=openid%20email');
+ responseType: ['id_token', 'token'],
+ enrollAmrValues: ['okta_verify', 'pop'],
+ })).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&idp_scope=scope1%20scope2&response_type=id_token%20token&enroll_amr_values=okta_verify%20pop&scope=openid%20email');
});
it('throws if responseType includes id_token but scopes does not include openid', () => {
@@ -79,5 +80,14 @@ describe('authorize endpoint', () => {
acrValues: 'urn:okta:loa:1fa:any'
})).toBe('?client_id=fakeClientId&code_challenge=fakeCodeChallenge&response_type=code&acr_values=urn%3Aokta%3Aloa%3A1fa%3Aany&scope=openid');
});
+
+ it('respects enroll_amr_values', () => {
+ expect(buildAuthorizeParams({
+ clientId: 'fakeClientId',
+ prompt: 'enroll_authenticator',
+ responseType: 'none',
+ enrollAmrValues: ['okta_verify', 'pop']
+ })).toBe('?client_id=fakeClientId&prompt=enroll_authenticator&response_type=none&enroll_amr_values=okta_verify%20pop');
+ });
});
});
diff --git a/test/spec/oidc/enrollAuthenticator.ts b/test/spec/oidc/enrollAuthenticator.ts
new file mode 100644
index 000000000..ce0580e14
--- /dev/null
+++ b/test/spec/oidc/enrollAuthenticator.ts
@@ -0,0 +1,107 @@
+import { enrollAuthenticator } from '../../../lib/oidc/enrollAuthenticator';
+
+jest.mock('../../../lib/oidc/util', () => {
+ return {
+ prepareEnrollAuthenticatorParams: () => {},
+ createEnrollAuthenticatorMeta: () => {},
+ getOAuthUrls: () => {}
+ };
+});
+
+jest.mock('../../../lib/oidc/endpoints/authorize', () => {
+ return {
+ buildAuthorizeParams: () => {}
+ };
+});
+
+const mocked = {
+ util: require('../../../lib/oidc/util'),
+ authorize: require('../../../lib/oidc/endpoints/authorize')
+};
+
+describe('enrollAuthenticator', () => {
+ let testContext;
+ let originalLocation;
+ beforeEach(() => {
+ originalLocation = global.window.location;
+ delete (global.window as any).location;
+ global.window.location = {
+ protocol: 'https:',
+ hostname: 'somesite.local',
+ href: 'https://somesite.local',
+ assign: jest.fn()
+ } as unknown as Location;
+
+ const sdk = {
+ options: {
+ issuer: 'http://fake',
+ clientId: 'fakeClientId',
+ redirectUri: 'http://fake-redirect'
+ },
+ transactionManager: {
+ save: () => {}
+ }
+ };
+ const preparedParams = {
+ clientId: 'fakeClientId',
+ responseType: 'none',
+ prompt: 'enroll_authenticator',
+ enrollAmrValues: ['okta_verify']
+ };
+ const enrollParams = {
+ enrollAmrValues: ['okta_verify']
+ };
+ const authorizeParams = '?client_id=fakeClientId&prompt=enroll_authenticator&response_type=none&enroll_amr_values=okta_verify';
+ const urls = {
+ authorizeUrl: 'http://fake-authorize'
+ };
+ const meta = {
+ urls
+ };
+ testContext = {
+ sdk,
+ preparedParams,
+ authorizeParams,
+ enrollParams,
+ urls,
+ meta
+ };
+ jest.spyOn(mocked.util, 'prepareEnrollAuthenticatorParams').mockReturnValue(testContext.preparedParams);
+ jest.spyOn(mocked.util, 'getOAuthUrls').mockReturnValue(testContext.urls);
+ jest.spyOn(mocked.authorize, 'buildAuthorizeParams').mockReturnValue(testContext.authorizeParams);
+ jest.spyOn(mocked.util, 'createEnrollAuthenticatorMeta').mockReturnValue(testContext.meta);
+ });
+
+ afterEach(() => {
+ global.window.location = originalLocation;
+ });
+
+ describe('transactionMeta', () => {
+ beforeEach(() => {
+ const { sdk } = testContext;
+ jest.spyOn(sdk.transactionManager, 'save');
+ });
+
+ it('saves the transaction meta', () => {
+ const { sdk, meta, enrollParams } = testContext;
+ enrollAuthenticator(sdk, enrollParams);
+ expect(sdk.transactionManager.save).toHaveBeenCalledWith(meta);
+ });
+ });
+
+ it('redirects to the authorize endpoint with options.setLocation', () => {
+ const { sdk, preparedParams, enrollParams, authorizeParams } = testContext;
+ sdk.options.setLocation = jest.fn();
+ enrollAuthenticator(sdk, enrollParams);
+ expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(preparedParams);
+ expect(sdk.options.setLocation).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`);
+ });
+
+ it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', () => {
+ const { sdk, preparedParams, enrollParams, authorizeParams } = testContext;
+ enrollAuthenticator(sdk, enrollParams);
+ expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(preparedParams);
+ expect(window.location.assign).toHaveBeenCalledWith(`http://fake-authorize${authorizeParams}`);
+ });
+
+});
\ No newline at end of file
diff --git a/test/spec/oidc/getWithRedirect.ts b/test/spec/oidc/getWithRedirect.ts
index 51bcd3404..cf4e457bf 100644
--- a/test/spec/oidc/getWithRedirect.ts
+++ b/test/spec/oidc/getWithRedirect.ts
@@ -21,19 +21,23 @@ const mocked = {
describe('getWithRedirect', () => {
let testContext;
+ let originalLocation;
beforeEach(() => {
+ originalLocation = global.window.location;
+ delete (global.window as any).location;
+ global.window.location = {
+ protocol: 'https:',
+ hostname: 'somesite.local',
+ href: 'https://somesite.local',
+ assign: jest.fn()
+ } as unknown as Location;
+
const sdk = {
options: {
-
},
getOriginalUri: () => {},
transactionManager: {
save: () => {}
- },
- token: {
- getWithRedirect: {
- _setLocation: () => {}
- }
}
};
const tokenParams = {
@@ -59,6 +63,10 @@ describe('getWithRedirect', () => {
jest.spyOn(mocked.util, 'createOAuthMeta').mockReturnValue(testContext.meta);
});
+ afterEach(() => {
+ global.window.location = originalLocation;
+ });
+
it('throws an error if more than 2 parameters are passed', async () => {
const { sdk } = testContext;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -83,13 +91,19 @@ describe('getWithRedirect', () => {
});
+ it('redirects to the authorize endpoint with options.setLocation', async () => {
+ const { sdk, tokenParams } = testContext;
+ sdk.options.setLocation = jest.fn();
+ await getWithRedirect(sdk, {});
+ expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams);
+ expect(sdk.options.setLocation).toHaveBeenCalledWith('http://fake-authorize?fake=true');
+ });
- it('redirects to the authorize endpoint', async () => {
+ it('redirects to the authorize endpoint with window.location.assign if options.setLocation is not set', async () => {
const { sdk, tokenParams } = testContext;
- jest.spyOn(sdk.token.getWithRedirect, '_setLocation');
await getWithRedirect(sdk, {});
expect(mocked.authorize.buildAuthorizeParams).toHaveBeenCalledWith(tokenParams);
- expect(sdk.token.getWithRedirect._setLocation).toHaveBeenCalledWith('http://fake-authorize?fake=true');
+ expect(window.location.assign).toHaveBeenCalledWith('http://fake-authorize?fake=true');
});
});
\ No newline at end of file
diff --git a/test/spec/oidc/util/enrollAuthenticatorMeta.ts b/test/spec/oidc/util/enrollAuthenticatorMeta.ts
new file mode 100644
index 000000000..44240bcb2
--- /dev/null
+++ b/test/spec/oidc/util/enrollAuthenticatorMeta.ts
@@ -0,0 +1,72 @@
+import { createEnrollAuthenticatorMeta } from '../../../../lib/oidc/util/enrollAuthenticatorMeta';
+
+jest.mock('../../../../lib/oidc/util/oauth', () => {
+ return {
+ getOAuthUrls: () => {}
+ };
+});
+
+
+const mocked = {
+ oauth: require('../../../../lib/oidc/util/oauth'),
+};
+
+describe('enrollAuthenticatorMeta', () => {
+ let testContext;
+ beforeEach(() => {
+ const sdk = {
+ options: {
+ },
+ };
+ const enrollAuthenticatorOptions = {
+ };
+ const urls = {
+ authorizeUrl: 'http://fake-authorize'
+ };
+ testContext = {
+ sdk,
+ enrollAuthenticatorOptions,
+ urls,
+ };
+ });
+
+ it('saves issuer from sdk', async () => {
+ const { sdk, enrollAuthenticatorOptions } = testContext;
+ const issuer = 'http://fake';
+ sdk.options.issuer = issuer;
+ const meta = createEnrollAuthenticatorMeta(sdk, enrollAuthenticatorOptions);
+ expect(meta.issuer).toBe(issuer);
+ });
+
+ it('saves urls from `getOAuthUrls`', async () => {
+ const { sdk, urls, enrollAuthenticatorOptions } = testContext;
+ jest.spyOn(mocked.oauth, 'getOAuthUrls').mockReturnValue(urls);
+ const meta = createEnrollAuthenticatorMeta(sdk, enrollAuthenticatorOptions);
+ expect(mocked.oauth.getOAuthUrls).toHaveBeenCalledWith(sdk, enrollAuthenticatorOptions);
+ expect(meta.urls).toEqual(urls);
+ });
+
+ it('saves OAuth values from the enrollAuthenticatorOptions', async () => {
+ const { sdk, enrollAuthenticatorOptions } = testContext;
+ Object.assign(enrollAuthenticatorOptions, {
+ responseType: 'none',
+ responseMode: 'query',
+ state: 'mock-state',
+ clientId: 'mock-clientId',
+ redirectUri: 'http://localhost/login/callback',
+ acrValues: 'foo',
+ enrollAmrValues: ['a', 'b']
+ });
+
+ const meta = createEnrollAuthenticatorMeta(sdk, enrollAuthenticatorOptions);
+ expect(meta).toEqual({
+ responseType: 'none',
+ responseMode: 'query',
+ state: 'mock-state',
+ clientId: 'mock-clientId',
+ redirectUri: 'http://localhost/login/callback',
+ acrValues: 'foo',
+ enrollAmrValues: ['a', 'b']
+ });
+ });
+});
diff --git a/test/spec/oidc/util/handleOAuthResponse.ts b/test/spec/oidc/util/handleOAuthResponse.ts
index 408a7e760..df719afdc 100644
--- a/test/spec/oidc/util/handleOAuthResponse.ts
+++ b/test/spec/oidc/util/handleOAuthResponse.ts
@@ -104,6 +104,16 @@ describe('handleOAuthResponse', () => {
expect(errorThrown).toBe(false);
});
+ it('does not throw if responseType is "none" and response contains no tokens', async () => {
+ let errorThrown = false;
+ try {
+ await handleOAuthResponse(sdk, {responseType: 'none'}, {}, undefined as unknown as CustomUrls);
+ } catch (err) {
+ errorThrown = true;
+ }
+ expect(errorThrown).toBe(false);
+ });
+
it('throws if response contains both "error" and "error_description"', async () => {
let errorThrown = false;
try {
diff --git a/test/spec/oidc/util/oauthMeta.ts b/test/spec/oidc/util/oauthMeta.ts
index 8c424bd9d..7f23ded8d 100644
--- a/test/spec/oidc/util/oauthMeta.ts
+++ b/test/spec/oidc/util/oauthMeta.ts
@@ -16,16 +16,11 @@ describe('oauthMeta', () => {
beforeEach(() => {
const sdk = {
options: {
-
+ setLocation: () => {}
},
getOriginalUri: () => {},
transactionManager: {
save: () => {}
- },
- token: {
- getWithRedirect: {
- _setLocation: () => {}
- }
}
};
const tokenParams = {
@@ -72,6 +67,7 @@ describe('oauthMeta', () => {
codeChallenge: 'efgh',
codeChallengeMethod: 'fake',
acrValues: 'foo',
+ enrollAmrValues: ['a', 'b']
});
const meta = createOAuthMeta(sdk, tokenParams);
diff --git a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts
new file mode 100644
index 000000000..4ab16cee3
--- /dev/null
+++ b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts
@@ -0,0 +1,174 @@
+/*!
+ * Copyright (c) 2015-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+
+const mocked = {
+ features: {
+ isBrowser: () => typeof window !== 'undefined',
+ isLocalhost: () => true,
+ isHTTPS: () => false,
+ isPKCESupported: () => true,
+ },
+};
+jest.mock('../../../../lib/features', () => {
+ return mocked.features;
+});
+import { OktaAuth, AuthSdkError } from '@okta/okta-auth-js';
+import { prepareEnrollAuthenticatorParams } from '../../../../lib/oidc';
+
+const DEFAULT_ACR_VALUES = 'urn:okta:2fa:any:ifpossible';
+
+describe('prepareEnrollAuthenticatorParams', function() {
+ it('throws an error if enrollAmrValues not specified', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ let errorThrown = false;
+ try {
+ prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: '',
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ } catch (err) {
+ errorThrown = true;
+ expect(err).toBeInstanceOf(AuthSdkError);
+ expect((err as AuthSdkError).message).toEqual('enroll_amr_values must be specified');
+ }
+ expect(errorThrown).toBe(true);
+ });
+
+ it('throws an error if acrValues not specified', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ let errorThrown = false;
+ try {
+ prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: 'foo',
+ acrValues: '',
+ });
+ } catch (err) {
+ errorThrown = true;
+ expect(err).toBeInstanceOf(AuthSdkError);
+ expect((err as AuthSdkError).message).toEqual('acr_values must be specified');
+ }
+ expect(errorThrown).toBe(true);
+ });
+
+ it('sets responseType to none', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ expect(params.responseType).toBe('none');
+ });
+
+ it('overrides responseType with none', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ responseType: 'token',
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ expect(params.responseType).toBe('none');
+ });
+
+ it('sets prompt to enroll_authenticator', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ expect(params.prompt).toBe('enroll_authenticator');
+ });
+
+ it('overrides prompt with enroll_authenticator', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ prompt: 'login',
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ expect(params.prompt).toBe('enroll_authenticator');
+ });
+
+ it('does not prepare PKCE params', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com',
+ pkce: true
+ });
+ spyOn(mocked.features, 'isPKCESupported').and.returnValue(true);
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ expect(params.codeVerifier).toBe(undefined);
+ expect(params.codeChallenge).toBe(undefined);
+ expect(params.codeChallengeMethod).toBe(undefined);
+ });
+
+ it('does not use acrValues from sdk.options', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com',
+ acrValues: 'foo'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ });
+ expect(params.acrValues).toBe(DEFAULT_ACR_VALUES);
+ });
+
+ it('removes scopes, nonce', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ scopes: ['openid','email'],
+ nonce: 'fake-nonce',
+ maxAge: 100,
+ });
+ expect(params.scopes).toBe(undefined);
+ expect(params.nonce).toBe(undefined);
+ expect(params.maxAge).toBe(0);
+ });
+
+ it('overrides maxAge with 0', () => {
+ const sdk = new OktaAuth({
+ issuer: 'https://foo.com'
+ });
+ const params = prepareEnrollAuthenticatorParams(sdk, {
+ enrollAmrValues: ['a'],
+ acrValues: DEFAULT_ACR_VALUES,
+ maxAge: 100,
+ });
+ expect(params.maxAge).toBe(0);
+ });
+
+ // Note:
+ // The only suported `acrValues` is 'urn:okta:2fa:ifpossible'
+ // Autorize endpoint will throw an error otherwise,
+ // but this can change in the future,
+ // so not checking this in okta-auth-js
+
+});
\ No newline at end of file
diff --git a/test/support/oauthUtil.js b/test/support/oauthUtil.js
index 99e9131a2..a9a45feae 100644
--- a/test/support/oauthUtil.js
+++ b/test/support/oauthUtil.js
@@ -419,14 +419,14 @@ oauthUtil.setupRedirect = function(opts) {
pkce: false,
issuer: 'https://auth-js-test.okta.com',
clientId: 'NPSfOkH5eZrTy8PMDlvx',
- redirectUri: 'https://example.com/redirect'
+ redirectUri: 'https://example.com/redirect',
+ setLocation: jest.fn()
}, opts.oktaAuthArgs));
// Mock the well-known and keys request
oauthUtil.loadWellKnownAndKeysCache(client);
oauthUtil.mockStateAndNonce();
- var windowLocationMock = util.mockSetWindowLocation(client);
var setCookieMock = util.mockSetCookie();
jest.spyOn(storageUtil, 'getSessionStorage')
@@ -446,7 +446,7 @@ oauthUtil.setupRedirect = function(opts) {
return promise
.then(function() {
- expect(windowLocationMock).toHaveBeenCalledWith(opts.expectedRedirectUrl);
+ expect(client.options.setLocation).toHaveBeenCalledWith(opts.expectedRedirectUrl);
expect(setCookieMock.mock.calls).toEqual(opts.expectedCookies);
})
.finally(() => {
diff --git a/test/support/util.js b/test/support/util.js
index f026bb030..a942d4644 100644
--- a/test/support/util.js
+++ b/test/support/util.js
@@ -381,10 +381,6 @@ util.parseQueryParams = function (query) {
return obj;
};
-util.mockSetWindowLocation = function (client) {
- return jest.spyOn(client.token.getWithRedirect, '_setLocation');
-};
-
util.mockSetCookie = function () {
return jest.spyOn(cookies, 'set');
};
diff --git a/test/types/auth.test-d.ts b/test/types/auth.test-d.ts
index db82c05e3..41607e61d 100644
--- a/test/types/auth.test-d.ts
+++ b/test/types/auth.test-d.ts
@@ -120,7 +120,10 @@ const authorizeOptions2: TokenParams = {
expectType(await authClient.handleLoginRedirect());
const tokens = await authClient.tokenManager.getTokens();
expectType(await authClient.handleLoginRedirect(tokens));
+ expectType(await authClient.handleLoginRedirect(tokens, `${window.location.href}`));
expectType(await authClient.storeTokensFromRedirect());
+ expectType(await authClient.handleRedirect());
+ expectType(await authClient.handleRedirect(`${window.location.href}`));
// signOut
expectType(await authClient.signOut());
diff --git a/test/types/token.test-d.ts b/test/types/token.test-d.ts
index eeefb3d2b..c33ef16d2 100644
--- a/test/types/token.test-d.ts
+++ b/test/types/token.test-d.ts
@@ -18,6 +18,7 @@ import {
Tokens,
UserClaims,
TokenParams,
+ EnrollAuthenticatorOptions,
TokenResponse,
JWTObject,
RefreshToken,
@@ -64,6 +65,8 @@ const refreshTokenExample = {
};
expectAssignable(refreshTokenExample);
+const DEFAULT_ACR_VALUES = 'urn:okta:2fa:any:ifpossible';
+
const tokens = {
accessToken: accessTokenExample,
idToken: idTokenExample,
@@ -86,6 +89,33 @@ const tokens = {
expectType(await authClient.token.getWithRedirect(authorizeOptions));
expectType(await authClient.token.parseFromUrl());
+ const enrollAuthenticatorOptons: EnrollAuthenticatorOptions = {
+ enrollAmrValues: ['email', 'kba'],
+ acrValues: DEFAULT_ACR_VALUES
+ };
+ const enrollAuthenticatorOptons2: EnrollAuthenticatorOptions = {
+ enrollAmrValues: 'email',
+ acrValues: DEFAULT_ACR_VALUES,
+ responseType: 'none'
+ };
+ expectType(await authClient.endpoints.authorize.enrollAuthenticator(enrollAuthenticatorOptons));
+ expectType(await authClient.endpoints.authorize.enrollAuthenticator(enrollAuthenticatorOptons2));
+ expectError(async () => {
+ // missing acrValues
+ await authClient.endpoints.authorize.enrollAuthenticator({
+ enrollAmrValues: ['email', 'kba'],
+ });
+ });
+ expectError(async () => {
+ // missing enrollAmrValues
+ await authClient.endpoints.authorize.enrollAuthenticator({
+ acrValues: DEFAULT_ACR_VALUES
+ });
+ });
+ expectError(async () => {
+ await authClient.endpoints.authorize.enrollAuthenticator();
+ });
+
const customUrls = {
issuer: 'https://{yourOktaDomain}/oauth2/{authorizationServerId}',
authorizeUrl: 'https://{yourOktaDomain}/oauth2/v1/authorize',