From 62e1ad848ae2aee76ebe2191498a0a8f52ce9f47 Mon Sep 17 00:00:00 2001 From: Sam David Date: Thu, 15 Feb 2024 23:58:15 +0530 Subject: [PATCH] oidc login flow updates --- app/authenticators/irene.js | 73 ++-- app/components/oidc-authorize/index.hbs | 69 ++++ app/components/oidc-authorize/index.scss | 22 ++ app/components/oidc-authorize/index.ts | 63 ++++ app/controllers/oidc/error.ts | 12 + app/router.ts | 5 + app/routes/authenticated.ts | 4 + app/routes/login.ts | 17 +- app/routes/oidc/authorize.ts | 17 + app/routes/oidc/error.ts | 9 + app/routes/oidc/redirect.ts | 17 + app/services/oidc.ts | 165 +++++++++ app/styles/_component-variables.scss | 19 + app/templates/oidc/authorize.hbs | 3 + app/templates/oidc/error.hbs | 38 ++ tests/acceptance/oidc-test.js | 403 ++++++++++++++++++++++ tests/helpers/acceptance-utils.js | 12 +- tests/unit/controllers/oidc/error-test.js | 12 + tests/unit/routes/oidc/authorize.js | 11 + tests/unit/routes/oidc/error.js | 11 + tests/unit/routes/oidc/redirect.js | 11 + tests/unit/services/oidc-test.js | 12 + translations/en.json | 7 +- translations/ja.json | 7 +- 24 files changed, 984 insertions(+), 35 deletions(-) create mode 100644 app/components/oidc-authorize/index.hbs create mode 100644 app/components/oidc-authorize/index.scss create mode 100644 app/components/oidc-authorize/index.ts create mode 100644 app/controllers/oidc/error.ts create mode 100644 app/routes/oidc/authorize.ts create mode 100644 app/routes/oidc/error.ts create mode 100644 app/routes/oidc/redirect.ts create mode 100644 app/services/oidc.ts create mode 100644 app/templates/oidc/authorize.hbs create mode 100644 app/templates/oidc/error.hbs create mode 100644 tests/acceptance/oidc-test.js create mode 100644 tests/unit/controllers/oidc/error-test.js create mode 100644 tests/unit/routes/oidc/authorize.js create mode 100644 tests/unit/routes/oidc/error.js create mode 100644 tests/unit/routes/oidc/redirect.js create mode 100644 tests/unit/services/oidc-test.js diff --git a/app/authenticators/irene.js b/app/authenticators/irene.js index a372310857..2a35518ca8 100644 --- a/app/authenticators/irene.js +++ b/app/authenticators/irene.js @@ -1,15 +1,15 @@ -/* eslint-disable prettier/prettier, ember/no-get */ +/* eslint-disable ember/no-get */ import Base from 'ember-simple-auth/authenticators/base'; import ENV from 'irene/config/environment'; import { inject as service } from '@ember/service'; import { getOwner } from '@ember/application'; - -const b64EncodeUnicode = str => - btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode(`0x${p1}`)) - ) - ; - +const b64EncodeUnicode = (str) => + btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => + String.fromCharCode(`0x${p1}`) + ) + ); const getB64Token = (user, token) => b64EncodeUnicode(`${user}:${token}`); const processData = (data) => { @@ -18,55 +18,72 @@ const processData = (data) => { }; const IreneAuthenticator = Base.extend({ - ajax: service(), + router: service(), + window: service('browser/window'), resumeTransistion() { - const authenticatedRoute = getOwner(this).lookup("route:authenticated"); - const lastTransition = authenticatedRoute.get("lastTransition"); + const authenticatedRoute = getOwner(this).lookup('route:authenticated'); + const lastTransition = authenticatedRoute.get('lastTransition'); + if (lastTransition) { return lastTransition.retry(); } else { - const applicationRoute = getOwner(this).lookup("route:application"); - return applicationRoute.transitionTo(ENV['ember-simple-auth']["routeAfterAuthentication"]); + const applicationRoute = getOwner(this).lookup('route:application'); + return applicationRoute.transitionTo( + ENV['ember-simple-auth']['routeAfterAuthentication'] + ); + } + }, + + async checkAndPerformFrdeskRedirect(username) { + const window = this.get('window'); + const queryParams = this.get('router')?.currentRoute?.queryParams; + + if (queryParams?.next) { + const nextRoute = `${queryParams.next}&username=${username}`; + window.location = nextRoute; + + return; } }, async authenticate(identification, password, otp) { - const ajax = this.get("ajax"); + const ajax = this.get('ajax'); const data = { username: identification, password, - otp - } + otp, + }; const url = ENV['ember-simple-auth']['loginEndPoint']; - return ajax.post(url, { data }) - .then(data => { - data = processData(data); - this.resumeTransistion(); - return data; - }); + return ajax.post(url, { data }).then((data) => { + data = processData(data); + + this.checkAndPerformFrdeskRedirect(identification); + this.resumeTransistion(identification); + + return data; + }); }, async restore(data) { - const ajax = this.get("ajax"); + const ajax = this.get('ajax'); const url = ENV['ember-simple-auth']['checkEndPoint']; await ajax.post(url, { data: {}, headers: { - 'Authorization': `Basic ${data.b64token}` - } - }) + Authorization: `Basic ${data.b64token}`, + }, + }); return data; }, async invalidate() { - const ajax = this.get("ajax"); + const ajax = this.get('ajax'); const url = ENV['ember-simple-auth']['logoutEndPoint']; await ajax.post(url); location.reload(); - } + }, }); - export default IreneAuthenticator; diff --git a/app/components/oidc-authorize/index.hbs b/app/components/oidc-authorize/index.hbs new file mode 100644 index 0000000000..217035b4c9 --- /dev/null +++ b/app/components/oidc-authorize/index.hbs @@ -0,0 +1,69 @@ +{{#if @data.form_data.authorization_needed}} +
+
+
+ +
+ +
+ + {{t + 'oidcModule.permissionHeading' + applicationName=this.applicationName + }} + + +
+ + {{#each this.scopeDescriptions as |sd|}} + + + + + + + + {{/each}} + +
+ + + + + + {{t 'cancel'}} + + + + {{t 'authorize'}} + + +
+
+
+{{/if}} \ No newline at end of file diff --git a/app/components/oidc-authorize/index.scss b/app/components/oidc-authorize/index.scss new file mode 100644 index 0000000000..568d943816 --- /dev/null +++ b/app/components/oidc-authorize/index.scss @@ -0,0 +1,22 @@ +.oidc-authorize-root { + width: 100%; + height: 100vh; + display: flex; + justify-content: center; + background-color: var(--oidc-authorize-container-background-color); + overflow: auto; + padding: 3em; + box-sizing: border-box; + + .oidc-authorize-container { + margin: auto; + } + + .oidc-authorize-card { + width: 360px; + border-radius: 4px; + background-color: var(--oidc-authorize-card-background-color); + box-shadow: var(--oidc-authorize-card-box-shadow); + border: 1px solid var(--oidc-authorize-card-border-color); + } +} diff --git a/app/components/oidc-authorize/index.ts b/app/components/oidc-authorize/index.ts new file mode 100644 index 0000000000..3e3f731149 --- /dev/null +++ b/app/components/oidc-authorize/index.ts @@ -0,0 +1,63 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +import OidcService, { OidcAuthorizationResponse } from 'irene/services/oidc'; + +export interface OidcAuthorizeSignature { + Args: { + token?: string; + data?: OidcAuthorizationResponse; + }; +} + +export default class OidcAuthorizeComponent extends Component { + @service declare oidc: OidcService; + + constructor(owner: unknown, args: OidcAuthorizeSignature['Args']) { + super(owner, args); + + this.authorizeIfNoUserAuthorizationNeeded(); + } + + get applicationName() { + return this.args.data?.form_data?.application_name; + } + + get scopeDescriptions() { + return this.args.data?.form_data?.scopes_descriptions; + } + + authorizeIfNoUserAuthorizationNeeded() { + const formData = this.args.data?.form_data; + + const authorizationNotNeeded = + typeof formData !== 'undefined' && + formData !== null && + !formData.authorization_needed; + + if (authorizationNotNeeded) { + this.oidc.authorizeOidcAppPermissions.perform(this.args.token as string); + } + } + + cancelAuthorization = task(async () => { + await this.oidc.authorizeOidcAppPermissions.perform( + this.args.token as string, + false + ); + }); + + allowAuthorization = task(async () => { + await this.oidc.authorizeOidcAppPermissions.perform( + this.args.token as string, + true + ); + }); +} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + OidcAuthorize: typeof OidcAuthorizeComponent; + } +} diff --git a/app/controllers/oidc/error.ts b/app/controllers/oidc/error.ts new file mode 100644 index 0000000000..2f72c14fc4 --- /dev/null +++ b/app/controllers/oidc/error.ts @@ -0,0 +1,12 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; + +export interface OidcError { + statusCode: number; + code?: string; + description?: string; +} + +export default class OidcErrorController extends Controller { + @tracked error: OidcError | null = null; +} diff --git a/app/router.ts b/app/router.ts index 062e30637a..fffe246a20 100644 --- a/app/router.ts +++ b/app/router.ts @@ -16,6 +16,11 @@ Router.map(function () { this.route('redirect'); }); + this.route('oidc', { path: 'dashboard/oidc' }, function () { + this.route('redirect'); + this.route('authorize'); + }); + this.route('register'); this.route('register-via-invite', { diff --git a/app/routes/authenticated.ts b/app/routes/authenticated.ts index 3525988937..eb4e8d5c45 100644 --- a/app/routes/authenticated.ts +++ b/app/routes/authenticated.ts @@ -13,6 +13,7 @@ import DatetimeService from 'irene/services/datetime'; import TrialService from 'irene/services/trial'; import IntegrationService from 'irene/services/integration'; import OrganizationService from 'irene/services/organization'; +import OidcService from 'irene/services/oidc'; import UserModel from 'irene/models/user'; import { CSBMap } from 'irene/router'; import ENV from 'irene/config/environment'; @@ -29,13 +30,16 @@ export default class AuthenticatedRoute extends Route { @service declare websocket: any; @service declare integration: IntegrationService; @service declare store: Store; + @service declare oidc: OidcService; @service('notifications') declare notify: NotificationService; @service('organization') declare org: OrganizationService; + @service('browser/window') declare window: Window; @tracked lastTransition?: Transition; beforeModel(transition: Transition) { this.session.requireAuthentication(transition, 'login'); + this.oidc.checkForOidcTokenAndRedirect(); this.lastTransition = transition; } diff --git a/app/routes/login.ts b/app/routes/login.ts index cc32c7d2b5..d6790e3243 100644 --- a/app/routes/login.ts +++ b/app/routes/login.ts @@ -1,3 +1,18 @@ import Route from '@ember/routing/route'; +import RouterService from '@ember/routing/router-service'; +import { inject as service } from '@ember/service'; -export default class LoginRoute extends Route {} +import ENV from 'irene/config/environment'; + +export default class LoginRoute extends Route { + @service declare session: any; + @service declare router: RouterService; + + activate() { + if (this.session.isAuthenticated) { + this.router.transitionTo( + ENV['ember-simple-auth']['routeIfAlreadyAuthenticated'] + ); + } + } +} diff --git a/app/routes/oidc/authorize.ts b/app/routes/oidc/authorize.ts new file mode 100644 index 0000000000..5b6bed3291 --- /dev/null +++ b/app/routes/oidc/authorize.ts @@ -0,0 +1,17 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import OidcService from 'irene/services/oidc'; + +export default class OidcAuthorizeRoute extends Route { + @service declare oidc: OidcService; + + queryParams = { + oidc_token: { + refreshModel: true, + }, + }; + + async model({ oidc_token }: { oidc_token: string }) { + return await this.oidc.fetchOidcAuthorizationDataOrRedirect(oidc_token); + } +} diff --git a/app/routes/oidc/error.ts b/app/routes/oidc/error.ts new file mode 100644 index 0000000000..ac79ae63bc --- /dev/null +++ b/app/routes/oidc/error.ts @@ -0,0 +1,9 @@ +import Route from '@ember/routing/route'; + +import OidcErrorController, { OidcError } from 'irene/controllers/oidc/error'; + +export default class OidcErrorRoute extends Route { + setupController(controller: OidcErrorController, error: OidcError) { + controller.set('error', error); + } +} diff --git a/app/routes/oidc/redirect.ts b/app/routes/oidc/redirect.ts new file mode 100644 index 0000000000..662ec223d5 --- /dev/null +++ b/app/routes/oidc/redirect.ts @@ -0,0 +1,17 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import OidcService from 'irene/services/oidc'; + +export default class OidcRedirectRoute extends Route { + @service declare oidc: OidcService; + + queryParams = { + oidc_token: { + refreshModel: true, + }, + }; + + async model({ oidc_token }: { oidc_token: string }) { + await this.oidc.validateOidcTokenOrRedirect(oidc_token); + } +} diff --git a/app/services/oidc.ts b/app/services/oidc.ts new file mode 100644 index 0000000000..e2109f071f --- /dev/null +++ b/app/services/oidc.ts @@ -0,0 +1,165 @@ +import Service, { inject as service } from '@ember/service'; +import RouterService from '@ember/routing/router-service'; +import { task } from 'ember-concurrency'; +import IntlService from 'ember-intl/services/intl'; + +import NetworkService from 'irene/services/network'; + +interface OidcResponse { + valid: boolean; + redirect_url: null | string; + error: null | { + code: string; + description: string; + }; +} + +type ValidateOidcTokenResponse = OidcResponse; + +type OidcAuthorizeResult = OidcResponse; + +type OidcValidationResult = OidcResponse; + +interface OidcAuthorizationFormData { + application_name: string; + scopes_descriptions: string[]; + authorization_needed: boolean; +} + +export interface OidcAuthorizationResponse { + form_data: OidcAuthorizationFormData | null; + validation_result: OidcValidationResult; +} + +export default class OidcService extends Service { + @service declare network: NetworkService; + @service declare session: any; + @service declare intl: IntlService; + @service declare router: RouterService; + @service('browser/window') declare window: Window; + @service('notifications') declare notify: NotificationService; + + oidcTokenValidateEndpoint = '/api/v2/oidc/authorization/validate'; + oidcAuthorizationEndpoint = '/api/v2/oidc/authorization'; + oidcAuthorizeEndpoint = '/api/v2/oidc/authorization/authorize'; + + checkForOidcTokenAndRedirect() { + if (this.session.isAuthenticated) { + const oidc_token = this.window.sessionStorage.getItem('oidc_token'); + + if (oidc_token) { + this.router.transitionTo('oidc.redirect', { + queryParams: { + oidc_token, + }, + }); + + this.window.sessionStorage.removeItem('oidc_token'); + } + } + } + + authorizeOidcAppPermissions = task(async (token: string, allow?: boolean) => { + const res = await this.network.post(this.oidcAuthorizeEndpoint, { + oidc_token: token, + allow, + }); + + const data = (await res.json()) as OidcAuthorizeResult; + + if (data.error) { + if (data.redirect_url) { + this.window.location.href = data.redirect_url; + } else { + this.notify.error( + data.error.description || this.intl.t('somethingWentWrong') + ); + } + + return; + } + + if (data.valid && data.redirect_url) { + this.window.location.href = data.redirect_url; + } + }); + + async validateOidcTokenOrRedirect(token: string) { + if (this.session.isAuthenticated) { + await this.validateOidcToken.perform(token); + } else { + this.router.transitionTo('login'); + + this.window.sessionStorage.setItem('oidc_token', token); + } + } + + validateOidcToken = task(async (token: string) => { + const res = await this.network.post(this.oidcTokenValidateEndpoint, { + oidc_token: token, + }); + + const data = (await res.json()) as ValidateOidcTokenResponse; + + if (res.status === 400 || data.error) { + if (data.redirect_url) { + this.window.location.href = data.redirect_url; + + return; + } else { + throw { + name: 'Error', + statusCode: res.status, + code: data.error?.code, + description: data.error?.description, + }; + } + } + + if (data.valid) { + this.router.transitionTo('oidc.authorize', { + queryParams: { + oidc_token: token, + }, + }); + } + }); + + async fetchOidcAuthorizationDataOrRedirect(token: string) { + if (this.session.isAuthenticated) { + return { + token: token, + data: await this.fetchOidcAuthorizationData.perform(token), + }; + } else { + this.router.transitionTo('login'); + + this.window.sessionStorage.setItem('oidc_token', token); + } + } + + fetchOidcAuthorizationData = task(async (token: string) => { + const res = await this.network.post(this.oidcAuthorizationEndpoint, { + oidc_token: token, + }); + + const data = (await res.json()) as OidcAuthorizationResponse; + + if (res.status === 400 || data.validation_result.error) { + if (data.validation_result.redirect_url) { + this.window.location.href = data.validation_result.redirect_url; + + return; + } else { + throw { + name: 'Error', + statusCode: res.status, + code: data.validation_result.error?.code, + description: data.validation_result.error?.description, + }; + } + } + + return data; + }); +} diff --git a/app/styles/_component-variables.scss b/app/styles/_component-variables.scss index ba8c53e266..762b85bc64 100644 --- a/app/styles/_component-variables.scss +++ b/app/styles/_component-variables.scss @@ -1001,4 +1001,23 @@ body { // variables for project-overview --project-overview-card-hover-shadow: var(--box-shadow-7); + + // variables for oidc-authorize + &.theme-light { + --oidc-authorize-container-background-color: var(--background-main); + --oidc-authorize-card-border-color: var(--border-color-1); + } + + &.theme-dark { + --oidc-authorize-container-background-color: var(--background-dark); + --oidc-authorize-card-border-color: transparent; + } + + --oidc-authorize-card-background-color: var(--background-main); + --oidc-authorize-card-box-shadow: var(--box-shadow-7); + + // variables for oidc/error + --oidc-error-container-background-color: var(--background-light); + --oidc-error-card-background-color: var(--background-main); + --oidc-error-card-border-radius: var(--border-radius); } diff --git a/app/templates/oidc/authorize.hbs b/app/templates/oidc/authorize.hbs new file mode 100644 index 0000000000..a41574d123 --- /dev/null +++ b/app/templates/oidc/authorize.hbs @@ -0,0 +1,3 @@ +{{page-title 'Open ID Connect Authorize'}} + + \ No newline at end of file diff --git a/app/templates/oidc/error.hbs b/app/templates/oidc/error.hbs new file mode 100644 index 0000000000..ef79d88d0c --- /dev/null +++ b/app/templates/oidc/error.hbs @@ -0,0 +1,38 @@ + + + + + + + {{or this.error.description (t 'somethingWentWrong')}} + + + + {{t 'oidcModule.errorHelperText'}} + + + + \ No newline at end of file diff --git a/tests/acceptance/oidc-test.js b/tests/acceptance/oidc-test.js new file mode 100644 index 0000000000..e96181a09f --- /dev/null +++ b/tests/acceptance/oidc-test.js @@ -0,0 +1,403 @@ +import { module, test } from 'qunit'; + +import { + click, + currentURL, + fillIn, + visit, + waitFor, + waitUntil, +} from '@ember/test-helpers'; + +import { setupApplicationTest } from 'ember-qunit'; +import { createAuthSession } from 'irene/tests/helpers/acceptance-utils'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { t } from 'ember-intl/test-support'; +import { setupBrowserFakes } from 'ember-browser-services/test-support'; +import { faker } from '@faker-js/faker'; +import { Response } from 'miragejs'; +import Service from '@ember/service'; + +class NotificationsStub extends Service { + errorMsg = null; + successMsg = null; + + error(msg) { + this.errorMsg = msg; + } + success(msg) { + this.successMsg = msg; + } +} + +const createOidcTokenValidateResponse = (overrides = {}) => ({ + valid: true, + redirect_url: null, + error: null, + ...overrides, +}); + +const createOidcAuthorizeFormResponse = (overrides = {}) => ({ + form_data: { + application_name: faker.company.name(), + scopes_descriptions: [faker.lorem.sentence(5), faker.lorem.sentence(5)], + authorization_needed: false, + }, + validation_result: { + valid: true, + redirect_url: null, + error: null, + }, + ...overrides, +}); + +const createOidcAuthorizeResponse = (overrides = {}) => ({ + valid: true, + redirect_url: faker.internet.url({ appendSlash: true }), + error: null, + ...overrides, +}); + +module('Acceptance | oidc login', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + setupBrowserFakes(hooks, { window: true, sessionStorage: true }); + + hooks.beforeEach(async function () { + const token = (length) => faker.string.alphanumeric({ length }); + + this.setProperties({ + oidcToken: encodeURIComponent(`${token(84)}:${token(6)}:${token(43)}`), + }); + }); + + const handleUserAuthorizeFlow = async ( + assert, + authorizationResponse, + authorize + ) => { + assert.dom('[data-test-img-logo]').exists(); + + assert.dom('[data-test-oidcAuthorize-heading]').hasText( + t('oidcModule.permissionHeading', { + applicationName: authorizationResponse.form_data.application_name, + }) + ); + + authorizationResponse.form_data.scopes_descriptions.forEach((sd) => { + assert + .dom(`[data-test-oidcAuthorize-scopeDescription="${sd}"]`) + .hasText(sd); + }); + + assert + .dom('[data-test-oidcAuthorize-cancelBtn]') + .isNotDisabled() + .hasText(t('cancel')); + + assert + .dom('[data-test-oidcAuthorize-authorizeBtn]') + .isNotDisabled() + .hasText(t('authorize')); + + if (authorize) { + await click('[data-test-oidcAuthorize-authorizeBtn]'); + } else { + await click('[data-test-oidcAuthorize-cancelBtn]'); + } + }; + + const handleUserLoginFlow = async (assert, server, sso = false) => { + server.post( + 'v2/sso/check', + () => + new Response( + 200, + {}, + { + is_sso: Boolean(sso), + is_sso_enforced: Boolean(sso), + token: sso ? faker.string.alphanumeric({ length: 50 }) : null, + } + ) + ); + + server.get('/sso/saml2', () => ({ + url: faker.internet.url({ appendSlash: true }), + })); + + server.post( + '/sso/saml2/login', + (_, req) => + new Response( + 200, + {}, + { + user_id: '1', + token: req.queryParams.token, + } + ) + ); + + server.post( + '/login', + () => + new Response( + 200, + {}, + { + user_id: '1', + token: faker.string.alphanumeric({ length: 50 }), + } + ) + ); + + await fillIn('[data-test-login-check-username-input]', 'appknoxusername'); + + await click('[data-test-login-check-button]'); + + if (sso) { + assert + .dom('[data-test-login-sso-forced-username-input]') + .hasValue('appknoxusername'); + + assert.dom('[data-test-login-sso-forced-button]').isNotDisabled(); + + await click('[data-test-login-sso-forced-button]'); + } else { + assert + .dom('[data-test-login-login-username-input]') + .hasValue('appknoxusername'); + + assert.dom('[data-test-login-login-password-input]').exists(); + assert.dom('[data-test-login-login-button]').isNotDisabled(); + + await fillIn('[data-test-login-login-password-input]', 'appknoxpassword'); + + await click('[data-test-login-login-button]'); + } + }; + + test.each( + 'oidc login flow for athenticated/unathenticated user', + [ + { authorizationNeeded: false, authenticated: true }, + { authorizationNeeded: true, authenticated: true }, + { authorizationNeeded: false, loginByUsernamePassword: true }, + { authorizationNeeded: true, loginByUsernamePassword: true }, + // { authorizationNeeded: false, loginBySSO: true }, + ], + async function ( + assert, + { + authorizationNeeded, + authenticated, + loginByUsernamePassword, + loginBySSO, + } + ) { + const tokenValidateResponse = createOidcTokenValidateResponse(); + + const oidcAuthorizationResponse = createOidcAuthorizeFormResponse(); + + oidcAuthorizationResponse.form_data.authorization_needed = + authorizationNeeded; + + const oidcAuthorizeResponse = createOidcAuthorizeResponse(); + + this.server.post( + '/v2/oidc/authorization/validate', + () => new Response(200, {}, tokenValidateResponse) + ); + + this.server.post( + '/v2/oidc/authorization', + () => new Response(200, {}, oidcAuthorizationResponse) + ); + + this.server.post( + '/v2/oidc/authorization/authorize', + () => new Response(200, {}, oidcAuthorizeResponse) + ); + + // create auth session for pre authentication + if (authenticated) { + await createAuthSession(); + } + + try { + await visit(`/dashboard/oidc/redirect?oidc_token=${this.oidcToken}`); + } catch (error) { + if (error.message !== 'TransitionAborted') { + throw error; + } + } + + if (!authenticated) { + assert.strictEqual(currentURL(), '/login', 'Redirected to /login'); + + if (loginByUsernamePassword) { + await handleUserLoginFlow(assert, this.server); + } + + if (loginBySSO) { + await handleUserLoginFlow(assert, this.server, true); + } + } + + await waitUntil(() => currentURL().includes('/dashboard/oidc/authorize')); + + assert.strictEqual( + currentURL(), + `/dashboard/oidc/authorize?oidc_token=${this.oidcToken}`, + `Redirected to /dashboard/oidc/authorize?oidc_token=${this.oidcToken}` + ); + + if (authorizationNeeded) { + await handleUserAuthorizeFlow(assert, oidcAuthorizationResponse, true); + } + + const window = this.owner.lookup('service:browser/window'); + + await waitUntil( + () => oidcAuthorizeResponse.redirect_url === window.location.href, + { timeout: 1000 } + ); + + assert.strictEqual( + oidcAuthorizeResponse.redirect_url, + window.location.href, + `Redirected to ${window.location.href}` + ); + } + ); + + test.each( + 'oidc login error scenarios', + [ + { validateFailed: true }, + { authorizeFormDataFailed: true }, + { authorizeFailed: true }, + ], + async function ( + assert, + { validateFailed, authorizeFormDataFailed, authorizeFailed } + ) { + this.owner.register('service:notifications', NotificationsStub); + + const tokenValidateResponse = createOidcTokenValidateResponse( + validateFailed + ? { + error: { + code: 'validate_invalid_token', + description: 'validate invalid token', + }, + } + : {} + ); + + const oidcAuthorizationResponse = createOidcAuthorizeFormResponse(); + + oidcAuthorizationResponse.form_data.authorization_needed = true; + + if (authorizeFormDataFailed) { + oidcAuthorizationResponse.validation_result.error = { + code: 'authorize_form_invalid_token', + description: 'authorize form invalid token', + }; + } + + const oidcAuthorizeResponse = createOidcAuthorizeResponse( + authorizeFailed + ? { + error: { + code: 'authorize_failed', + description: 'authorize failed', + }, + redirect_url: null, + } + : {} + ); + + this.server.post( + '/v2/oidc/authorization/validate', + () => + new Response(validateFailed ? 400 : 200, {}, tokenValidateResponse) + ); + + this.server.post( + '/v2/oidc/authorization', + () => + new Response( + authorizeFormDataFailed ? 400 : 200, + {}, + oidcAuthorizationResponse + ) + ); + + this.server.post( + '/v2/oidc/authorization/authorize', + () => + new Response(authorizeFailed ? 400 : 200, {}, oidcAuthorizeResponse) + ); + + // create auth session for pre authentication + await createAuthSession(); + + visit(`/dashboard/oidc/redirect?oidc_token=${this.oidcToken}`); + + await waitUntil(() => currentURL()?.includes('/dashboard/oidc/redirect')); + + assert.strictEqual( + currentURL(), + `/dashboard/oidc/redirect?oidc_token=${this.oidcToken}`, + `Redirected to /dashboard/oidc/redirect?oidc_token=${this.oidcToken}` + ); + + const assertErrorPage = async (error) => { + await waitFor('[data-test-oidcError-SvgIcon]'); + + assert.dom('[data-test-oidcError-SvgIcon]').exists(); + + assert.dom('[data-test-oidcError-text]').hasText(error.description); + + assert + .dom('[data-test-oidcError-helperText]') + .hasText(t('oidcModule.errorHelperText')); + }; + + if (validateFailed) { + await assertErrorPage(tokenValidateResponse.error); + + return; + } + + if (authorizeFormDataFailed) { + await assertErrorPage( + oidcAuthorizationResponse.validation_result.error + ); + + return; + } + + await waitUntil(() => currentURL().includes('/dashboard/oidc/authorize')); + + assert.strictEqual( + currentURL(), + `/dashboard/oidc/authorize?oidc_token=${this.oidcToken}`, + `Redirected to /dashboard/oidc/authorize?oidc_token=${this.oidcToken}` + ); + + if (authorizeFailed) { + await handleUserAuthorizeFlow(assert, oidcAuthorizationResponse, true); + + const notify = this.owner.lookup('service:notifications'); + + assert.strictEqual( + notify.errorMsg, + oidcAuthorizeResponse.error.description + ); + } + } + ); +}); diff --git a/tests/helpers/acceptance-utils.js b/tests/helpers/acceptance-utils.js index 7c6adaa16d..992e90a7fd 100644 --- a/tests/helpers/acceptance-utils.js +++ b/tests/helpers/acceptance-utils.js @@ -2,6 +2,13 @@ import { authenticateSession } from 'ember-simple-auth/test-support'; import { faker } from '@faker-js/faker'; +export async function createAuthSession(userId = '1') { + await authenticateSession({ + authToken: faker.git.commitSha(), + user_id: userId, + }); +} + export async function setupRequiredEndpoints(server, skipLogin = true) { // clear out default data server.db.emptyData(); @@ -24,10 +31,7 @@ export async function setupRequiredEndpoints(server, skipLogin = true) { }); if (skipLogin) { - await authenticateSession({ - authToken: faker.git.commitSha(), - user_id: currentUser.id, - }); + await createAuthSession(currentUser.id); } else { // login endpoints } diff --git a/tests/unit/controllers/oidc/error-test.js b/tests/unit/controllers/oidc/error-test.js new file mode 100644 index 0000000000..7f0864a165 --- /dev/null +++ b/tests/unit/controllers/oidc/error-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | oidc/error', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:oidc/error'); + assert.ok(controller); + }); +}); diff --git a/tests/unit/routes/oidc/authorize.js b/tests/unit/routes/oidc/authorize.js new file mode 100644 index 0000000000..1abf081e61 --- /dev/null +++ b/tests/unit/routes/oidc/authorize.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | oidc/authorize', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const route = this.owner.lookup('route:oidc/authorize'); + assert.ok(route); + }); +}); diff --git a/tests/unit/routes/oidc/error.js b/tests/unit/routes/oidc/error.js new file mode 100644 index 0000000000..06166f7d3d --- /dev/null +++ b/tests/unit/routes/oidc/error.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | oidc/error', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const route = this.owner.lookup('route:oidc/error'); + assert.ok(route); + }); +}); diff --git a/tests/unit/routes/oidc/redirect.js b/tests/unit/routes/oidc/redirect.js new file mode 100644 index 0000000000..71edda44f3 --- /dev/null +++ b/tests/unit/routes/oidc/redirect.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | oidc/redirect', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const route = this.owner.lookup('route:oidc/redirect'); + assert.ok(route); + }); +}); diff --git a/tests/unit/services/oidc-test.js b/tests/unit/services/oidc-test.js new file mode 100644 index 0000000000..c79848a9e2 --- /dev/null +++ b/tests/unit/services/oidc-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | oidc', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:oidc'); + assert.ok(service); + }); +}); diff --git a/translations/en.json b/translations/en.json index d708209ce0..676ba1be08 100644 --- a/translations/en.json +++ b/translations/en.json @@ -86,6 +86,7 @@ "asvsExpansion": "Application Security Verification Standard", "attachments": "Attachments", "author": "Author", + "authorize": "Authorize", "automated": "Automated", "available": "available", "availableCredits": "Available Scan Credits", @@ -808,6 +809,10 @@ "viewAppOnStore": "View App on Store" }, "off": "off", + "oidcModule": { + "permissionHeading": "{applicationName} requires the following permissions", + "errorHelperText": "Not sure what to do? Try Logging in again from start." + }, "ok": "Ok", "on": "on", "openInNewTab": "Open in new tab", @@ -1287,7 +1292,7 @@ "upload": "Upload", "uploadApp": "Upload App", "uploadAnApp": "Upload an App", - "uploadAppModule":{ + "uploadAppModule": { "linkPastePlaceholder": "Paste the link here...", "viaLink": "Via Link", "supportedStores": "Supported Stores", diff --git a/translations/ja.json b/translations/ja.json index d33d28665c..3d9d56b6c4 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -86,6 +86,7 @@ "asvsExpansion": "Application Security Verification Standard", "attachments": "添付ファイル", "author": "Author", + "authorize": "Authorize", "automated": "Automated", "available": "available", "availableCredits": "Available Scan Credits", @@ -807,6 +808,10 @@ "viewAppOnStore": "View App on Store" }, "off": "off", + "oidcModule": { + "permissionHeading": "{applicationName} requires the following permissions", + "errorHelperText": "Not sure what to do? Try Logging in again from start." + }, "ok": "OK", "on": "on", "openInNewTab": "Open in new tab", @@ -1286,7 +1291,7 @@ "upload": "Upload", "uploadApp": "アプリのアップロード", "uploadAnApp": "Upload an App", - "uploadAppModule":{ + "uploadAppModule": { "linkPastePlaceholder": "Paste the link here...", "viaLink": "Via Link", "supportedStores": "Supported Stores",