Skip to content

Commit

Permalink
feat: implement new experimental login workflow for local development (
Browse files Browse the repository at this point in the history
…#1934)

* feat: implement experimental oidc-like workflow for local development

* refactor: callback route

* fix: invalidate session if requested scope change

* refactor: enable oidc login workflow behind a feature flag

* refactor: allow to switch projects to then trigger a new login

* refactor(playground): pass initial project key as env placeholder

* refactor: make the initial project key optional

* docs: improvements

* chore: keep formatting

* refactor: revert experimental changes in playground app

* fix: set authorization header only if session token is defined

* feat: allow to pass the teamId as claim

* fix: processing app config

* test: fix test data

* chore: remove .env local

* refactor: redirect to /authorize endpoint

* fix: authorize redirect

* refactor: render a nicer error page for auth callback

* refactor(app-config): allow to pass the custom app json as arg

* refactor(app-config): expose types

* feat: add new cypress package

* test: enable oidc for playground app and adjust e2e tests accordingly

* refactor(cypress): keep command a JS file, to avoid cypress types conflicts

* test: missing props

* chore: update to latest cypress

* refactor(cypress): remove unused command

* refactor(cypress): use ts file, mock Cypress types

* chore: configure CI to use oidc flow when testing playground app

* docs: changeset

* test(playground): adjust permissions

* feat(app-shell): export ConfigureIntlProvider

* fix(playground): menu permission

* test(playground): adjust permissions

* fix(cypress): always try to read dotenv files

* fix(cypress/task): merge loaded dotenv with process.env

* refactor: use JSON.parse

* test: remove duplicates

* refactor: extract oidc storage operations into utils

* refactor: rename route to /oidc/callback

* docs: improve comment

* refactor: use PublicPageLayout component

* refactor(config): rename permissions to oAuthScopes, move shared fn into config package

* fix: do not use application-config in browser env

* fix(public-page-layout): use fixed widths, keep content layout more agnostic

* docs: update changeset list of packages
  • Loading branch information
emmenko authored Jan 19, 2021
1 parent dc7a443 commit d86c2e8
Show file tree
Hide file tree
Showing 72 changed files with 1,366 additions and 334 deletions.
21 changes: 21 additions & 0 deletions .changeset/clean-cooks-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@commercetools-frontend/application-components': minor
'@commercetools-frontend/application-config': minor
'@commercetools-frontend/application-shell': minor
'@commercetools-frontend/application-shell-connectors': minor
'@commercetools-frontend/constants': minor
'@commercetools-frontend/cypress': minor
'@commercetools-frontend/mc-scripts': minor
'@commercetools-frontend/sdk': minor
'playground': minor
'@commercetools-local/visual-testing-app': minor
---

Introduce a new **experimental opt-in** feature to authenticate the application for local development, using an OIDC-like workflow.

> Disclaimer: this is an opt-in experimental feature. Use it at your own risk.
> We want to test this feature internally first. Until then, we discourage you to try it out.
The feature can be enabled by setting the `ENABLE_OIDC_FOR_DEVELOPMENT=true` environment variable.

In addition to that, we have a new package `@commercetools-frontend/cypress`, to include some useful commands for testing Custom Applications.
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ jobs:
CYPRESS_LOGIN_PASSWORD: ${{ secrets.CYPRESS_LOGIN_PASSWORD }}
CYPRESS_PROJECT_KEY: ${{ secrets. CYPRESS_PROJECT_KEY }}
HOST_GCP_STAGING: ${{ secrets.HOST_GCP_STAGING }}
MC_API_URL_GCP_STAGING: ${{ secrets.MC_API_URL_GCP_STAGING }}
CTP_INITIAL_PROJECT_KEY: ${{ secrets. CYPRESS_PROJECT_KEY }}
ENABLE_OIDC_FOR_DEVELOPMENT: "true"

- name: Building Starter template application
run: yarn template-starter:build
Expand Down
5 changes: 3 additions & 2 deletions cypress/.eslintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ plugins:
env:
cypress/globals: true
rules:
prefer-object-spread/prefer-object-spread: 0
testing-library/await-async-query: 0
jest/valid-expect: off
prefer-object-spread/prefer-object-spread: off
testing-library/await-async-query: off
48 changes: 36 additions & 12 deletions cypress/integration/playground/application-shell.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,75 @@
/* eslint-disable jest/valid-expect-in-promise */
import { encode } from 'qss';
import { LOGOUT_REASONS } from '@commercetools-frontend/constants';
import { URL_BASE, URL_STATE_MACHINES } from '../../support/urls';
import {
URL_BASE,
URL_STATE_MACHINES,
ENTRY_POINT_STATE_MACHINES,
} from '../../support/urls';

describe('when user is authenticated', () => {
beforeEach(() => {
cy.loginByOidc({ entryPointUriPath: ENTRY_POINT_STATE_MACHINES });
});
it('should log out with reason "user"', () => {
cy.login({ redirectToUri: URL_STATE_MACHINES });

cy.findByRole('button', { name: /open user settings menu/i }).click();
cy.findByRole('link', { name: /logout/i }).click();

const queryParams = encode({
reason: LOGOUT_REASONS.USER,
});
cy.url().should('include', `/logout?${queryParams}`);
cy.findByText('This is the logout page for local development.').should(
'exist'
cy.findByRole('link', { name: /logout/i }).should(
'have.attr',
'href',
`/logout?${queryParams}`
);
});
describe('when navigating to an unknown route', () => {
it('should render a not found page', () => {
cy.login({ redirectToUri: URL_STATE_MACHINES });
cy.visit(`${URL_BASE}/a-non-existing-route`);
cy.findByText('We could not find what you are looking for').should(
'exist'
);
cy.percySnapshot();
});
});
});

describe('navigation menu', () => {
beforeEach(() => {
cy.loginByOidc({ entryPointUriPath: ENTRY_POINT_STATE_MACHINES });
});
it('should stay collapsed for small viewports', () => {
cy.login({ redirectToUri: URL_STATE_MACHINES });
cy.viewport(900, 800);
cy.findAllByText('Initial').should('exist');
cy.percySnapshot(cy.state('runnable').fullTitle(), {
widths: [900],
});
});
it('should expand menu when clicking on the expand button', () => {
cy.login({ redirectToUri: URL_STATE_MACHINES });
cy.findAllByText('Initial').should('exist');
cy.findByTestId('menu-expander').click();
// eslint-disable-next-line jest/valid-expect-in-promise
cy.window().then((win) =>
// eslint-disable-next-line jest/valid-expect
expect(win.localStorage.getItem('isForcedMenuOpen')).to.equal('true')
);
cy.percySnapshot();
});
});

describe('failed OIDC authentication', () => {
describe('when sessionToken is missing', () => {
it('should show oidc callback error page', () => {
cy.visit(`/${URL_STATE_MACHINES}/oidc/callback`);
cy.findByText('Authentication error');
cy.findByText(/missing sessionToken/i);
cy.percySnapshot();
});
});
describe('when sessionToken is invalid', () => {
it('should show oidc callback error page', () => {
cy.visit(`/${URL_STATE_MACHINES}/oidc/callback#sessionToken=123`);
cy.findByText('Authentication error');
cy.findByText(/invalid token specified/i);
cy.percySnapshot();
});
});
});
12 changes: 7 additions & 5 deletions cypress/integration/playground/state-machines.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { URL_STATE_MACHINES, URL_STATE_MACHINES_ID } from '../../support/urls';
import {
URL_STATE_MACHINES_ID,
ENTRY_POINT_STATE_MACHINES,
} from '../../support/urls';

describe('State machines', () => {
beforeEach(() => {
cy.loginByOidc({ entryPointUriPath: ENTRY_POINT_STATE_MACHINES });
});
it('should render list view', () => {
cy.login({ redirectToUri: URL_STATE_MACHINES });
// NOTE: 'State Machines' exists once in the menu
// and once in `main`.
cy.get('main').within(() => {
cy.findByText('State Machines').should('exist');
});
cy.findAllByText('Initial').should('exist');
cy.percySnapshot();
});
it('should render list view and go to details page', () => {
cy.login({ redirectToUri: URL_STATE_MACHINES });
// Go to details page
cy.findAllByText('Initial').first().click();
cy.url().should('include', URL_STATE_MACHINES_ID);
Expand Down
2 changes: 2 additions & 0 deletions cypress/plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

/* eslint-disable global-require */
const percyHealthCheck = require('@percy/cypress/task');
const customApplications = require('@commercetools-frontend/cypress/task');

// plugins file
module.exports = (on, cypressConfig) => {
Expand All @@ -34,6 +35,7 @@ module.exports = (on, cypressConfig) => {

on('task', {
...percyHealthCheck,
...customApplications,
});

return Object.assign({}, cypressConfig, {
Expand Down
5 changes: 1 addition & 4 deletions cypress/support/commands.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@percy/cypress';
import '@testing-library/cypress/add-commands';
import '@commercetools-frontend/cypress/add-commands';

function isLocalhost() {
const url = new URL(Cypress.config('baseUrl'));
Expand Down Expand Up @@ -32,10 +33,6 @@ function isLocalhost() {
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

Cypress.Commands.add('logout', () => {
cy.visit('/logout');
});

Cypress.Commands.add(
'login',
({ redirectToUri, isForcedMenuOpen = false } = {}) => {
Expand Down
8 changes: 5 additions & 3 deletions cypress/support/urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ export const projectKey = Cypress.env('PROJECT_KEY');

export const URL_BASE = `/${projectKey}`;

export const URL_STATE_MACHINES = `${URL_BASE}/state-machines`;
export const URL_STATE_MACHINES_ID = `${URL_BASE}/state-machines/12ad40eb-b33f-4e0e-ae91-bca373ccfd58`;
export const ENTRY_POINT_STATE_MACHINES = 'state-machines';
export const URL_STATE_MACHINES = `${URL_BASE}/${ENTRY_POINT_STATE_MACHINES}`;
export const URL_STATE_MACHINES_ID = `${URL_STATE_MACHINES}/12ad40eb-b33f-4e0e-ae91-bca373ccfd58`;

export const URL_EXAMPLES_STARTER = `${URL_BASE}/examples-starter`;
export const ENTRY_POINT_EXAMPLES_STARTER = 'examples-starter';
export const URL_EXAMPLES_STARTER = `${URL_BASE}/${ENTRY_POINT_EXAMPLES_STARTER}`;
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"postinstall": "manypkg check && preconstruct dev",
"auth": "npm_config_registry=https://registry.npmjs.org npm whoami",
"clean": "lerna exec 'rm -rf build dist test-utils/dist'",
"clean": "lerna exec 'rm -rf build dist test-utils/dist experimental/dist task/dist add-commands/dist'",
"extract-intl": "formatjs extract --format=$(pwd)/packages/i18n/transifex-transformer.js --out-file=$(pwd)/packages/i18n/data/core.json 'packages/**/*messages.ts'",
"compile-intl": "yarn --cwd packages/i18n compile-data",
"l10n:build": "pushd packages/l10n; yarn generate-data",
Expand Down Expand Up @@ -76,6 +76,7 @@
"packages/application-shell-connectors",
"packages/browser-history",
"packages/constants",
"packages/cypress",
"packages/i18n",
"packages/l10n",
"packages/notifications",
Expand Down Expand Up @@ -143,7 +144,7 @@
"babel-plugin-typescript-to-proptypes": "1.4.2",
"bulk-update-versions": "1.1.2",
"cross-env": "7.0.3",
"cypress": "6.2.0",
"cypress": "6.2.1",
"dotenv": "8.2.0",
"enzyme": "3.11.0",
"eslint": "7.17.0",
Expand Down Expand Up @@ -212,7 +213,6 @@
"@types/react": "16.14.2",
"@types/react-router": "5.1.9",
"**/sharp": "0.26.3",
"cypress": "6.2.0",
"graphql": "14.7.0",
"pretty-format": "26.6.2",
"intl-messageformat-parser": "6.1.2",
Expand Down
1 change: 0 additions & 1 deletion packages/application-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
"prop-types": "15.7.2",
"react-modal": "3.12.1",
"react-required-if": "1.0.3",
"tiny-invariant": "1.1.0",
"uuid": "8.3.2"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import React, { FC, ReactNode } from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import invariant from 'tiny-invariant';
import CommercetoolsLogoSvg from '@commercetools-frontend/assets/logos/commercetools_primary-logo_horizontal_white-text_RGB.svg';
import { customProperties } from '@commercetools-uikit/design-system';
import Constraints from '@commercetools-uikit/constraints';
import Spacings from '@commercetools-uikit/spacings';
import Text from '@commercetools-uikit/text';
import Card from '@commercetools-uikit/card';
// https://babeljs.io/blog/2017/09/11/zero-config-with-babel-macros
import base64Background from /* preval */ './public-background';

Expand Down Expand Up @@ -47,50 +44,35 @@ const Container = styled.div`
background-image: url(data:image/png;base64,${base64Background});
background-position: center;
`;
const ContainerWideColumn = styled.div`
const ContainerColumn = styled.div`
width: calc(${customProperties.constraint15} / 2);
`;
const ContainerColumnWide = styled.div`
width: ${customProperties.constraint15};
`;

const PublicPageLayoutContent: FC<TProps> = (props) => {
if (props.contentScale === 'wide') {
invariant(
React.Children.count(props.children) === 2,
`@commercetools-frontend/application-components/PublicPageLayout: using the "wide" size requires to pass 2 children.`
);

return (
<Card
css={css`
display: flex;
width: ${customProperties.constraint15};
padding: 0;
`}
>
{React.Children.map(props.children, (child, index) => (
<ContainerWideColumn key={index}>{child}</ContainerWideColumn>
))}
</Card>
);
return <ContainerColumnWide>{props.children}</ContainerColumnWide>;
}

return <Card>{props.children}</Card>;
return <ContainerColumn>{props.children}</ContainerColumn>;
};

const PublicPageLayout: FC<TProps> = (props) => {
return (
<Container>
<Spacings.Stack scale="xl" alignItems="center">
<Constraints.Horizontal max={8}>
<ContainerColumn>
<div>
<img
width="100%"
src={CommercetoolsLogoSvg}
alt="commercetools logo"
/>
</div>
</Constraints.Horizontal>
</ContainerColumn>
{props.welcomeMessage && (
<Constraints.Horizontal max={8}>
<ContainerColumn>
<Text.Headline as="h2">
<div
css={css`
Expand All @@ -100,22 +82,21 @@ const PublicPageLayout: FC<TProps> = (props) => {
{props.welcomeMessage}
</div>
</Text.Headline>
</Constraints.Horizontal>
</ContainerColumn>
)}
<Constraints.Horizontal max={props.contentScale === 'wide' ? 15 : 8}>
<Spacings.Stack scale="s">
<PublicPageLayoutContent {...props} />
<Spacings.Stack
scale="xs"
alignItems={props.contentScale === 'wide' ? 'center' : 'stretch'}
>
{props.legalMessage && (
<Text.Body tone="inverted">{props.legalMessage}</Text.Body>
)}
<Text.Body tone="inverted">{`${year} © commercetools`}</Text.Body>
</Spacings.Stack>
<Spacings.Stack scale="s">
<PublicPageLayoutContent {...props} />

<Spacings.Stack
scale="xs"
alignItems={props.contentScale === 'wide' ? 'center' : 'stretch'}
>
{props.legalMessage && (
<Text.Body tone="inverted">{props.legalMessage}</Text.Body>
)}
<Text.Body tone="inverted">{`${year} © commercetools`}</Text.Body>
</Spacings.Stack>
</Constraints.Horizontal>
</Spacings.Stack>
</Spacings.Stack>
</Container>
);
Expand Down
Loading

1 comment on commit d86c2e8

@vercel
Copy link

@vercel vercel bot commented on d86c2e8 Jan 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.