Skip to content

Commit

Permalink
Custom Auth UI and proper SSO login flow
Browse files Browse the repository at this point in the history
Closes #3055
Closes #4367
  • Loading branch information
kamilkisiela committed Jul 3, 2024
1 parent a2406d8 commit bae692f
Show file tree
Hide file tree
Showing 42 changed files with 2,355 additions and 440 deletions.
81 changes: 49 additions & 32 deletions cypress/e2e/app.cy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
const getUser = () =>
({ email: `${crypto.randomUUID()}@local.host`, password: 'Loc@l.h0st' }) as const;
({
email: `${crypto.randomUUID()}@local.host`,
password: 'Loc@l.h0st',
firstName: 'Local',
lastName: 'Host',
}) as const;

Cypress.on('uncaught:exception', (_err, _runnable) => {
return false;
Expand All @@ -14,7 +19,7 @@ describe('basic user flow', () => {

it('should redirect anon to auth', () => {
cy.visit('/');
cy.url().should('include', '/auth?redirectToPath=');
cy.url().should('include', '/auth/sign-in?redirectToPath=');
});

it('should sign up', () => {
Expand All @@ -34,7 +39,7 @@ describe('basic user flow', () => {
// Logout
cy.get('[data-cy="user-menu-trigger"]').click();
cy.get('[data-cy="user-menu-logout"]').click();
cy.url().should('include', '/auth?redirectToPath=');
cy.url().should('include', '/auth/sign-in?redirectToPath=');
});
});

Expand All @@ -47,46 +52,58 @@ it('create organization', () => {
cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
});

it('oidc login for organization', () => {
const organizationAdminUser = getUser();
cy.visit('/');
cy.signup(organizationAdminUser);
cy.get('input[name="name"]').type('Bubatzbieber');
cy.get('button[type="submit"]').click();
cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
cy.get('a[href$="/view/settings"]').click();
cy.get('a[href$="/view/settings#create-oidc-integration"]').click();
cy.get('input[id="tokenEndpoint"]').type('http://oidc-server-mock:80/connect/token');
cy.get('input[id="userinfoEndpoint"]').type('http://oidc-server-mock:80/connect/userinfo');
cy.get('input[id="authorizationEndpoint"]').type('http://localhost:7043/connect/authorize');
cy.get('input[id="clientId"]').type('implicit-mock-client');
cy.get('input[id="clientSecret"]').type('client-credentials-mock-client-secret');

cy.get('div[role="dialog"]').find('button[type="submit"]').click();
cy.get('div[role="dialog"]')
.find('code')
.last()
.then($elem => $elem.text())
.then(url => {
describe('oidc', () => {
it('oidc login for organization', () => {
const organizationAdminUser = getUser();
cy.visit('/');
cy.signup(organizationAdminUser);

cy.createOIDCIntegration('Bubatzbieber').then(({ loginUrl }) => {
cy.visit('/logout');

cy.clearAllCookies();
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.visit(url);
cy.visit(loginUrl);

cy.get('input[id="Input_Username"]').type('test-user');
cy.get('input[id="Input_Password"]').type('password');
cy.get('button[value="login"]').click();

cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
});
});
});

it('oidc login with organization slug', () => {
const organizationAdminUser = getUser();
cy.visit('/');
cy.signup(organizationAdminUser);

cy.createOIDCIntegration('Bubatzbieber').then(({ organizationSlug }) => {
cy.visit('/logout');

cy.clearAllCookies();
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.get('a[href^="/auth/sso"]').click();

// Select organization
cy.get('input[name="slug"]').type(organizationSlug);
cy.get('button[type="submit"]').click();

it('oidc login for invalid url shows correct error message', () => {
cy.clearAllCookies();
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.visit('/auth/oidc?id=invalid');
cy.get('div.text-red-500').contains('Could not find OIDC integration.');
cy.get('input[id="Input_Username"]').type('test-user');
cy.get('input[id="Input_Password"]').type('password');
cy.get('button[value="login"]').click();

cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
});
});

it('oidc login for invalid url shows correct error message', () => {
cy.clearAllCookies();
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.visit('/auth/oidc?id=invalid');
cy.get('[data-cy="auth-card-header-description"]').contains('Could not find OIDC integration.');
});
});
99 changes: 62 additions & 37 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,69 @@
namespace Cypress {
export interface Chainable {
fillSupertokensFormAndSubmit(data: { email: string; password: string }): Chainable;

signup(data: { email: string; password: string }): Chainable;

fillSignInFormAndSubmit(data: { email: string; password: string; }): Chainable;
fillSignUpFormAndSubmit(data: { email: string; password: string; firstName: string; lastName: string }): Chainable;
signup(data: { email: string; password: string; firstName: string; lastName: string; }): Chainable;
login(data: { email: string; password: string }): Chainable;

loginAndSetCookie(data: { email: string; password: string }): Chainable;

dataCy(name: string): Chainable<JQuery<HTMLElement>>;
createOIDCIntegration(organizationName: string): Chainable<{
loginUrl: string;
organizationSlug: string;
}>;
}
}

Cypress.Commands.add('fillSupertokensFormAndSubmit', user => {
cy.get('form', { includeShadowDom: true }).within(() => {
Cypress.Commands.add('createOIDCIntegration', (organizationName: string) => {
cy.get('input[name="name"]').type(organizationName);
cy.get('button[type="submit"]').click();
cy.get('[data-cy="organization-picker-current"]').contains(organizationName);
cy.get('a[href$="/view/settings"]').click();
cy.get('a[href$="/view/settings#create-oidc-integration"]').click();
cy.get('input[id="tokenEndpoint"]').type('http://oidc-server-mock:80/connect/token');
cy.get('input[id="userinfoEndpoint"]').type('http://oidc-server-mock:80/connect/userinfo');
cy.get('input[id="authorizationEndpoint"]').type('http://localhost:7043/connect/authorize');
cy.get('input[id="clientId"]').type('implicit-mock-client');
cy.get('input[id="clientSecret"]').type('client-credentials-mock-client-secret');

cy.get('div[role="dialog"]').find('button[type="submit"]').click();

cy.url().then(url => {
return new URL(url).pathname.split('/')[0]
});

return cy.get('div[role="dialog"]')
.find('code')
.last()
.then($elem => $elem.text())
.then(loginUrl => {
return cy.url().then(url => {
const organizationSlug = new URL(url).pathname.split('/')[1];

if (!organizationSlug) {
throw new Error('Failed to resolve organization slug from URL:' + url);
}

return {
loginUrl,
organizationSlug,
}
});
});
});

Cypress.Commands.add('fillSignInFormAndSubmit', user => {
cy.get('form').within(() => {
cy.get('input[name="email"]').type(user.email);
cy.get('input[name="password"]').type(user.password, {
force: true, // skip waiting for async email validation
});
cy.root().submit();
});
});

Cypress.Commands.add('fillSignUpFormAndSubmit', user => {
cy.get('form').within(() => {
cy.get('input[name="firstName"]').type(user.firstName);
cy.get('input[name="lastName"]').type(user.lastName);
cy.get('input[name="email"]').type(user.email);
cy.get('input[name="password"]').type(user.password, {
force: true, // skip waiting for async email validation
Expand All @@ -25,45 +75,20 @@ Cypress.Commands.add('fillSupertokensFormAndSubmit', user => {
Cypress.Commands.add('signup', user => {
cy.visit('/');

cy.get('span[data-supertokens="link"]', { includeShadowDom: true }).contains('Sign Up').click();
cy.fillSupertokensFormAndSubmit(user);
cy.get('a[data-auth-link="sign-up"]').click();
cy.fillSignUpFormAndSubmit(user);

cy.contains('Create Organization');
});

Cypress.Commands.add('login', user => {
cy.visit('/');

cy.fillSupertokensFormAndSubmit(user);
cy.fillSignInFormAndSubmit(user);

cy.contains('Create Organization');
});

Cypress.Commands.add('loginAndSetCookie', ({ email, password }) => {
cy.request({
method: 'POST',
url: '/api/auth/signin',
body: {
formFields: [
{ id: 'email', value: email },
{ id: 'password', value: password },
],
},
}).then(response => {
const { status, headers, body } = response;
if (status !== 200) {
throw new Error(`Create session failed. ${status}.\n${JSON.stringify(body)}`);
}
const frontToken = headers['front-token'] as string;
const accessToken = headers['st-access-token'] as string;
const timeJoined = String(body.user.timeJoined);

cy.setCookie('sAccessToken', accessToken);
cy.setCookie('sFrontToken', frontToken);
cy.setCookie('st-last-access-token-update', timeJoined);
});
});

Cypress.Commands.add('dataCy', value => {
return cy.get(`[data-cy="${value}"]`);
});
2 changes: 2 additions & 0 deletions integration-tests/testkit/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ const createSession = async (
superTokensUserId,
email,
oidcIntegrationId,
firstName: null,
lastName: null,
});

const sessionData = createSessionPayload(superTokensUserId, email);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"packageManager": "pnpm@9.4.0",
"engines": {
"node": ">=22",
"pnpm": ">=9.3.0"
"pnpm": ">=9.4.0"
},
"scripts": {
"build": "pnpm turbo build --color",
Expand Down
2 changes: 1 addition & 1 deletion packages/libraries/apollo/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = '0.33.0';
export const version = '0.33.3';
2 changes: 1 addition & 1 deletion packages/libraries/core/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = '0.3.1';
export const version = '0.4.0';
2 changes: 1 addition & 1 deletion packages/libraries/envelop/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = '0.33.0';
export const version = '0.33.2';
2 changes: 1 addition & 1 deletion packages/libraries/yoga/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = '0.33.0';
export const version = '0.33.2';
19 changes: 19 additions & 0 deletions packages/services/api/src/modules/organization/module.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default gql`
input: DeleteOrganizationInvitationInput!
): DeleteOrganizationInvitationResult!
updateOrganizationName(input: UpdateOrganizationNameInput!): UpdateOrganizationNameResult!
updateOrganizationSlug(input: UpdateOrganizationSlugInput!): UpdateOrganizationSlugResult!
updateOrganizationMemberAccess(input: OrganizationMemberAccessInput!): OrganizationPayload!
requestOrganizationTransfer(
input: RequestOrganizationTransferInput!
Expand Down Expand Up @@ -54,6 +55,19 @@ export default gql`
message: String!
}
type UpdateOrganizationSlugResult {
ok: UpdateOrganizationSlugOk
error: UpdateOrganizationSlugError
}
type UpdateOrganizationSlugOk {
updatedOrganizationPayload: OrganizationPayload!
}
type UpdateOrganizationSlugError implements Error {
message: String!
}
type CreateOrganizationOk {
createdOrganizationPayload: OrganizationPayload!
}
Expand Down Expand Up @@ -170,6 +184,11 @@ export default gql`
name: String!
}
input UpdateOrganizationSlugInput {
organization: ID!
slug: String!
}
input InviteToOrganizationByEmailInput {
organization: ID!
role: ID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules';
import { paramCase } from 'param-case';
import { Organization, OrganizationMemberRole } from '../../../shared/entities';
import { HiveError } from '../../../shared/errors';
import { cache, diffArrays, share, uuid } from '../../../shared/helpers';
import { cache, diffArrays, share } from '../../../shared/helpers';
import { ActivityManager } from '../../activity/providers/activity-manager';
import { AuthManager } from '../../auth/providers/auth-manager';
import { OrganizationAccessScope } from '../../auth/providers/organization-access';
Expand Down Expand Up @@ -418,15 +418,12 @@ export class OrganizationManager {
}),
]);

let cleanId = paramCase(name);

if (await this.storage.getOrganizationByCleanId({ cleanId })) {
cleanId = paramCase(`${name}-${uuid(4)}`);
if (organization.name === name) {
return organization;
}

const result = await this.storage.updateOrganizationName({
name,
cleanId,
organization: organization.id,
user: user.id,
});
Expand All @@ -444,6 +441,53 @@ export class OrganizationManager {
return result;
}

async updateSlug(
input: {
slug: string;
} & OrganizationSelector,
) {
const { slug } = input;
this.logger.info('Updating an organization clean id (input=%o)', input);
await this.authManager.ensureOrganizationAccess({
...input,
scope: OrganizationAccessScope.SETTINGS,
});
const [user, organization] = await Promise.all([
this.authManager.getCurrentUser(),
this.getOrganization({
organization: input.organization,
}),
]);

if (organization.cleanId === slug) {
return {
ok: true,
organization,
} as const;
}

const result = await this.storage.updateOrganizationCleanId({
cleanId: slug,
organization: organization.id,
user: user.id,
reservedNames: reservedOrganizationNames,
});

if (result.ok) {
await this.activityManager.create({
type: 'ORGANIZATION_ID_UPDATED',
selector: {
organization: organization.id,
},
meta: {
value: result.organization.cleanId,
},
});
}

return result;
}

async deleteInvitation(input: { email: string; organization: string }) {
await this.authManager.ensureOrganizationAccess({
scope: OrganizationAccessScope.MEMBERS,
Expand Down
Loading

0 comments on commit bae692f

Please sign in to comment.