From 33bff4134240964bbf3bd97b878572202c13c256 Mon Sep 17 00:00:00 2001 From: Laurin Date: Mon, 9 Mar 2026 13:08:27 +0100 Subject: [PATCH] feat: oidc domain registration and verification (#7745) --- .changeset/smooth-turtles-arrive.md | 6 + .github/workflows/tests-e2e.yaml | 7 - cypress.config.ts | 58 + cypress/e2e/app.cy.ts | 433 +++-- cypress/support/commands.ts | 55 +- .../oidc-server-mock/users-config.json | 17 + docker/docker-compose.end2end.yml | 14 + integration-tests/.env | 3 + integration-tests/testkit/auth.ts | 10 +- integration-tests/testkit/seed.ts | 59 +- .../tests/api/rate-limit/emails.spec.ts | 2 + ...02.25T00-00-00.oidc-integration-domains.ts | 30 + packages/migrations/src/run-pg-migrations.ts | 1 + packages/services/api/package.json | 3 +- .../modules/auth/lib/supertokens-strategy.ts | 98 +- .../src/modules/oidc-integrations/index.ts | 3 +- .../module.graphql.mappers.ts | 2 + .../oidc-integrations/module.graphql.ts | 109 ++ .../providers/oidc-integration.store.ts | 239 +++ .../providers/oidc-integrations.provider.ts | 316 ++++ .../resolvers/Mutation/deleteOIDCDomain.ts | 27 + .../resolvers/Mutation/registerOIDCDomain.ts | 28 + .../Mutation/requestOIDCDomainChallenge.ts | 24 + .../Mutation/verifyOIDCDomainChallenge.ts | 24 + .../resolvers/OIDCIntegration.ts | 5 + .../resolvers/OIDCIntegrationDomain.ts | 26 + packages/services/server/src/index.ts | 2 + packages/services/storage/src/db/types.ts | 10 + packages/services/storage/src/index.ts | 24 +- packages/services/workflows/src/index.ts | 9 +- .../workflows/src/lib/emails/providers.ts | 8 +- .../settings/oidc-integration-section.tsx | 1479 ----------------- .../connect-single-sign-on-provider-sheet.tsx | 474 ++++++ .../debug-oidc-integration-modal.tsx | 98 ++ .../oidc-default-resource-selector.tsx | 127 ++ .../oidc-default-role-selector.tsx | 90 + .../oidc-integration-configuration.tsx | 685 ++++++++ .../oidc-registered-domain-sheet.tsx | 496 ++++++ .../single-sign-on/single-sign-on-subpage.tsx | 224 +++ .../src/components/ui/copy-icon-button.tsx | 30 + .../web/app/src/pages/auth-verify-email.tsx | 4 +- .../app/src/pages/organization-settings.tsx | 42 +- packages/web/app/src/pages/target-trace.tsx | 28 +- packages/web/app/src/pages/target-traces.tsx | 7 +- 44 files changed, 3654 insertions(+), 1782 deletions(-) create mode 100644 .changeset/smooth-turtles-arrive.md create mode 100644 packages/migrations/src/actions/2026.02.25T00-00-00.oidc-integration-domains.ts create mode 100644 packages/services/api/src/modules/oidc-integrations/providers/oidc-integration.store.ts create mode 100644 packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/deleteOIDCDomain.ts create mode 100644 packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/registerOIDCDomain.ts create mode 100644 packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/requestOIDCDomainChallenge.ts create mode 100644 packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/verifyOIDCDomainChallenge.ts create mode 100644 packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegrationDomain.ts delete mode 100644 packages/web/app/src/components/organization/settings/oidc-integration-section.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/connect-single-sign-on-provider-sheet.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/debug-oidc-integration-modal.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-resource-selector.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-role-selector.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/oidc-integration-configuration.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/oidc-registered-domain-sheet.tsx create mode 100644 packages/web/app/src/components/organization/settings/single-sign-on/single-sign-on-subpage.tsx create mode 100644 packages/web/app/src/components/ui/copy-icon-button.tsx diff --git a/.changeset/smooth-turtles-arrive.md b/.changeset/smooth-turtles-arrive.md new file mode 100644 index 000000000..b083c4a8d --- /dev/null +++ b/.changeset/smooth-turtles-arrive.md @@ -0,0 +1,6 @@ +--- +'hive': minor +--- + +Allow organization owners register domain ownership. +The registration will allow users with matching email domains bypassing mandatory email verification. diff --git a/.github/workflows/tests-e2e.yaml b/.github/workflows/tests-e2e.yaml index e443e8337..7e2d34bff 100644 --- a/.github/workflows/tests-e2e.yaml +++ b/.github/workflows/tests-e2e.yaml @@ -38,13 +38,6 @@ jobs: with: cmd: yq -i 'del(.services.*.volumes)' docker/docker-compose.community.yml - - name: disable rate limiting - uses: mikefarah/yq@4839dbbf80445070a31c7a9c1055da527db2d5ee # v4.44.6 - with: - cmd: - yq -i '.services.server.environment.SUPERTOKENS_RATE_LIMIT = "0"' - docker/docker-compose.community.yml - - name: run containers timeout-minutes: 10 run: | diff --git a/cypress.config.ts b/cypress.config.ts index 6dc8f9f95..739672eda 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,5 +1,6 @@ import 'reflect-metadata'; import * as fs from 'node:fs'; +import asyncRetry from 'async-retry'; // eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency import { defineConfig } from 'cypress'; import { initSeed } from './integration-tests/testkit/seed'; @@ -30,6 +31,24 @@ export default defineConfig({ e2e: { setupNodeEvents(on) { on('task', { + async seedOrg() { + const owner = await seed.createOwner(); + const org = await owner.createOrg(); + + return { + slug: org.organization.slug, + refreshToken: owner.ownerRefreshToken, + email: owner.ownerEmail, + }; + }, + async purgeOIDCDomains() { + await seed.purgeOIDCDomains(); + return {}; + }, + async forgeOIDCDNSChallenge(orgSlug: string) { + await seed.forgeOIDCDNSChallenge(orgSlug); + return {}; + }, async seedTarget() { const owner = await seed.createOwner(); const org = await owner.createOrg(); @@ -41,6 +60,42 @@ export default defineConfig({ email: owner.ownerEmail, }; }, + async getEmailConfirmationLink(input: string | { email: string; now: number }) { + const email = typeof input === 'string' ? input : input.email; + const now = new Date( + typeof input === 'string' ? Date.now() - 10_000 : input.now, + ).toISOString(); + const url = new URL('http://localhost:3014/_history'); + url.searchParams.set('after', now); + + return await asyncRetry( + async () => { + const emails = await fetch(url.toString()) + .then(res => res.json()) + .then(emails => + emails.filter(e => e.to === email && e.subject === 'Verify your email'), + ); + + if (emails.length === 0) { + throw new Error('Could not find email'); + } + + // take the latest one + const result = emails[emails.length - 1]; + + const urlMatch = result.body.match(/href=\"(http:\/\/[^\s"]+)/); + if (!urlMatch) throw new Error('No URL found in email'); + + const confirmUrl = new URL(urlMatch[1]); + return confirmUrl.pathname + confirmUrl.search; + }, + { + retries: 10, + minTimeout: 1000, + maxTimeout: 10000, + }, + ); + }, }); on('after:spec', (_, results) => { @@ -56,5 +111,8 @@ export default defineConfig({ } }); }, + env: { + RUN_AGAINST_LOCAL_SERVICES: process.env.RUN_AGAINST_LOCAL_SERVICES || '0', + }, }, }); diff --git a/cypress/e2e/app.cy.ts b/cypress/e2e/app.cy.ts index 6fb3d0397..7c47dae79 100644 --- a/cypress/e2e/app.cy.ts +++ b/cypress/e2e/app.cy.ts @@ -45,158 +45,203 @@ it('create organization', () => { }); describe('oidc', () => { - it('oidc login for organization', () => { - const organizationAdminUser = getUserData(); - cy.visit('/'); - cy.signup(organizationAdminUser); + it('oidc login for organization via link', () => { + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); - const slug = generateRandomSlug(); - cy.createOIDCIntegration(slug).then(({ loginUrl }) => { - cy.visit('/logout'); + cy.createOIDCIntegration().then(({ loginUrl }) => { + cy.visit('/logout'); - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.visit(loginUrl); + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + 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('input[id="Input_Username"]').type('test-user'); + cy.get('input[id="Input_Password"]').type('password'); + cy.get('button[value="login"]').click(); - cy.get(`a[href="/${slug}"]`).should('exist'); + cy.contains('Verify your email address'); + + const email = 'sam.tailor@gmail.com'; + return cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); + cy.get(`a[href="/${slug}"]`).should('exist'); + }); + }); }); }); - it('oidc login with organization slug', () => { - const organizationAdminUser = getUserData(); - cy.visit('/'); - cy.signup(organizationAdminUser); + it('oidc login with organization slug input', () => { + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); + cy.createOIDCIntegration().then(() => { + cy.visit('/logout'); - const slug = generateRandomSlug(); - cy.createOIDCIntegration(slug).then(({ organizationSlug }) => { - cy.visit('/logout'); + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.get('a[href^="/auth/sso"]').click(); - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.get('a[href^="/auth/sso"]').click(); + // Select organization + cy.get('input[name="slug"]').type(slug); + cy.get('button[type="submit"]').click(); - // Select organization - cy.get('input[name="slug"]').type(organizationSlug); - cy.get('button[type="submit"]').click(); + 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('input[id="Input_Username"]').type('test-user'); - cy.get('input[id="Input_Password"]').type('password'); - cy.get('button[value="login"]').click(); + cy.contains('Verify your email address'); - cy.get(`a[href="/${slug}"]`).should('exist'); + const email = 'sam.tailor@gmail.com'; + return cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); + cy.get(`a[href="/${slug}"]`).should('exist'); + }); + }); }); }); it('first time oidc login of non-admin user', () => { - const organizationAdminUser = getUserData(); - cy.visit('/'); - cy.signup(organizationAdminUser); + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); + cy.createOIDCIntegration().then(() => { + cy.visit('/logout'); - const slug = generateRandomSlug(); - cy.createOIDCIntegration(slug).then(({ organizationSlug }) => { - cy.visit('/logout'); + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.get('a[href^="/auth/sso"]').click(); - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.get('a[href^="/auth/sso"]').click(); + // Select organization + cy.get('input[name="slug"]').type(slug); + cy.get('button[type="submit"]').click(); - // Select organization - cy.get('input[name="slug"]').type(organizationSlug); - cy.get('button[type="submit"]').click(); + cy.get('input[id="Input_Username"]').type('test-user-2'); + cy.get('input[id="Input_Password"]').type('password'); + cy.get('button[value="login"]').click(); - cy.get('input[id="Input_Username"]').type('test-user-2'); - cy.get('input[id="Input_Password"]').type('password'); - cy.get('button[value="login"]').click(); + cy.contains('Verify your email address'); - cy.get(`a[href="/${slug}"]`).should('exist'); + const email = 'tom.sailor@gmail.com'; + return cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); + cy.get(`a[href="/${slug}"]`).should('exist'); + }); + }); }); }); it('default member role for first time oidc login', () => { - const organizationAdminUser = getUserData(); - cy.visit('/'); - cy.signup(organizationAdminUser); + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); + cy.createOIDCIntegration().then(() => { + // Pick Admin role as the default role + cy.get('[data-cy="role-selector-trigger"]').click(); + cy.contains('[data-cy="role-selector-item"]', 'Admin').click(); + cy.visit('/logout'); - const slug = generateRandomSlug(); - cy.createOIDCIntegration(slug); + // First time login + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.get('a[href^="/auth/sso"]').click(); + cy.get('input[name="slug"]').type(slug); + cy.get('button[type="submit"]').click(); + // OIDC login + cy.get('input[id="Input_Username"]').type('test-user-2'); + cy.get('input[id="Input_Password"]').type('password'); + cy.get('button[value="login"]').click(); - // Pick Admin role as the default role - cy.get('[data-cy="role-selector-trigger"]').click(); - cy.contains('[data-cy="role-selector-item"]', 'Admin').click(); - cy.visit('/logout'); + cy.contains('Verify your email address'); + const email = 'tom.sailor@gmail.com'; + return cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); - // First time login - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.get('a[href^="/auth/sso"]').click(); - cy.get('input[name="slug"]').type(slug); - cy.get('button[type="submit"]').click(); - // OIDC login - cy.get('input[id="Input_Username"]').type('test-user-2'); - cy.get('input[id="Input_Password"]').type('password'); - cy.get('button[value="login"]').click(); - - cy.get(`a[href="/${slug}"]`).should('exist'); - // Check if the user has the Admin role by checking if the Members tab is visible - cy.get(`a[href^="/${slug}/view/members"]`).should('exist'); + cy.get(`a[href="/${slug}"]`).should('exist'); + // Check if the user has the Admin role by checking if the Members tab is visible + cy.get(`a[href^="/${slug}/view/members"]`).should('exist'); + }); + }); + }); }); it('emailpassword account linking with existing oidc user', () => { - const organizationAdminUser = getUserData(); - cy.visit('/'); - cy.signup(organizationAdminUser); + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); - const slug = generateRandomSlug(); - cy.createOIDCIntegration(slug).then(({ organizationSlug }) => { - cy.visit('/logout'); - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.get('a[href^="/auth/sso"]').click(); + cy.createOIDCIntegration().then(() => { + 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(); + // Select organization + cy.get('input[name="slug"]').type(slug); + cy.get('button[type="submit"]').click(); - cy.get('input[id="Input_Username"]').type('test-user-2'); - cy.get('input[id="Input_Password"]').type('password'); - cy.get('button[value="login"]').click(); + cy.get('input[id="Input_Username"]').type('test-user-2'); + cy.get('input[id="Input_Password"]').type('password'); + cy.get('button[value="login"]').click(); - cy.get(`a[href="/${slug}"]`).should('exist'); + cy.contains('Verify your email address'); + const email = 'tom.sailor@gmail.com'; + return cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); - cy.visit('/logout'); - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); + cy.get(`a[href="/${slug}"]`).should('exist'); - // Sign up/in through emailpassword, with email address used previously in OIDC - const memberData = { - ...getUserData(), - email: 'tom.sailor@gmail.com', // see docker/configs/oidc-server-mock/users-config.json - }; - cy.visit('/auth/sign-up'); - cy.fillSignUpFormAndSubmit(memberData); - cy.wait(500); + cy.visit('/logout'); + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); - // Sign up can fail if the account already exists (due to using a fixed email address) - // Therefore sign out and re-sign in - cy.visit('/logout'); - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.visit('/auth/sign-in'); - cy.fillSignInFormAndSubmit(memberData); - cy.wait(500); + const now = Date.now(); - cy.get(`a[href="/${slug}"]`).should('exist'); + // Sign up/in through emailpassword, with email address used previously in OIDC + const memberData = { + ...getUserData(), + email: 'tom.sailor@gmail.com', // see docker/configs/oidc-server-mock/users-config.json + }; + cy.visit('/auth/sign-up'); + cy.fillSignUpFormAndSubmit(memberData); + cy.contains('Verify your email address'); + + return cy.task('getEmailConfirmationLink', { email, now }).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); + + // Sign up can fail if the account already exists (due to using a fixed email address) + // Therefore sign out and re-sign in + cy.visit('/logout'); + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.visit('/auth/sign-in'); + cy.fillSignInFormAndSubmit(memberData); + cy.wait(500); + + cy.get(`a[href="/${slug}"]`).should('exist'); + }); + }); + }); }); }); @@ -209,74 +254,122 @@ describe('oidc', () => { }); describe('requireInvitation', () => { - const getOrgPrepared = () => { - const organizationAdminUser = getUserData(); - cy.visit('/'); - cy.signup(organizationAdminUser); - - const slug = generateRandomSlug(); - cy.createOIDCIntegration(slug); - - // Enable the requireInvitation setting - cy.get('[data-cy="oidc-require-invitation-toggle"]').click(); - cy.contains('updated'); - return { organizationAdminUser, slug }; - }; - it('oidc user cannot join the org without invitation', () => { - const { slug } = getOrgPrepared(); - cy.visit('/logout'); + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); - // First time login - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.get('a[href^="/auth/sso"]').click(); - cy.get('input[name="slug"]').type(slug); - cy.get('button[type="submit"]').click(); - // OIDC login - cy.get('input[id="Input_Username"]').type('test-user-2'); - cy.get('input[id="Input_Password"]').type('password'); - cy.get('button[value="login"]').click(); + cy.createOIDCIntegration().then(() => { + cy.get('[data-cy="oidc-require-invitation-toggle"]').click(); + cy.contains('updated'); + cy.visit('/logout'); - // Check if OIDC authentication failed as intended - cy.get(`a[href="/${slug}"]`).should('not.exist'); - cy.contains('not invited'); + // First time login + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.get('a[href^="/auth/sso"]').click(); + cy.get('input[name="slug"]').type(slug); + cy.get('button[type="submit"]').click(); + // OIDC login + cy.get('input[id="Input_Username"]').type('test-user-2'); + cy.get('input[id="Input_Password"]').type('password'); + cy.get('button[value="login"]').click(); + + // Check if OIDC authentication failed as intended + cy.get(`a[href="/${slug}"]`).should('not.exist'); + cy.contains('not invited'); + }); + }); }); it('oidc user can join the org with an invitation', () => { - const { slug } = getOrgPrepared(); + cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); - // Send an invite for the SSO user, with admin role specified - cy.visit(`/${slug}/view/members?page=invitations`); - cy.get('button[data-cy="send-invite-trigger"]').click(); - cy.get('input[name="email"]').type('tom.sailor@gmail.com'); - cy.get('button[data-cy="role-selector-trigger"]').click(); - cy.contains('[data-cy="role-selector-item"]', 'Admin').click(); - cy.get('button[type="submit"]').click(); - cy.get('.container table').contains('tom.sailor@gmail.com'); + cy.createOIDCIntegration().then(() => { + // Send an invite for the SSO user, with admin role specified + cy.visit(`/${slug}/view/members?page=invitations`); + cy.get('button[data-cy="send-invite-trigger"]').click(); + cy.get('input[name="email"]').type('tom.sailor@gmail.com'); + cy.get('button[data-cy="role-selector-trigger"]').click(); + cy.contains('[data-cy="role-selector-item"]', 'Admin').click(); + cy.get('button[type="submit"]').click(); + cy.get('.container table').contains('tom.sailor@gmail.com'); - cy.visit('/logout'); + cy.visit('/logout'); - // First time login - cy.clearAllCookies(); - cy.clearAllLocalStorage(); - cy.clearAllSessionStorage(); - cy.get('a[href^="/auth/sso"]').click(); - cy.get('input[name="slug"]').type(slug); - cy.get('button[type="submit"]').click(); - // OIDC login - cy.get('input[id="Input_Username"]').type('test-user-2'); - cy.get('input[id="Input_Password"]').type('password'); - cy.get('button[value="login"]').click(); + // First time login + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.get('a[href^="/auth/sso"]').click(); + cy.get('input[name="slug"]').type(slug); + cy.get('button[type="submit"]').click(); + // OIDC login + cy.get('input[id="Input_Username"]').type('test-user-2'); + cy.get('input[id="Input_Password"]').type('password'); + cy.get('button[value="login"]').click(); - // Check if user joined successfully - cy.get(`a[href="/${slug}"]`).should('exist'); - cy.contains('not invited').should('not.exist'); + const email = 'tom.sailor@gmail.com'; + cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); + cy.visit('/' + slug); - // Check if user has admin role - cy.visit(`/${slug}/view/members?page=list`); - cy.contains('tr', 'tom.sailor@gmail.com').contains('Admin'); + // Check if user joined successfully + cy.get(`a[href="/${slug}"]`).should('exist'); + cy.contains('not invited').should('not.exist'); + + // Check if user has admin role + cy.visit(`/${slug}/view/members?page=list`); + cy.contains('tr', 'tom.sailor@gmail.com').contains('Admin'); + }); + }); + }); + }); + }); +}); + +describe('oidc domain verification', () => { + beforeEach(() => { + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + cy.task('purgeOIDCDomains'); + }); + + it('registering a domain does not require email verification', () => { + return cy.task('seedOrg').then(({ refreshToken, slug }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + cy.visit('/' + slug); + cy.contains('Settings', { timeout: 10_000 }); + + return cy.createOIDCIntegration().then(({ loginUrl, organizationSlug }) => { + cy.get('[data-button-add-new-domain]').click(); + cy.get('input[name="domainName"]').type('buzzcheck.dev'); + cy.get('[data-button-next-verify-domain-ownership]').click(); + + cy.task('forgeOIDCDNSChallenge', organizationSlug); + + cy.get('[data-button-next-complete]').click(); + + cy.visit('/logout'); + + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + + cy.visit(loginUrl); + + cy.get('#Input_Username').type('test-user-3'); + cy.get('#Input_Password').type('password'); + cy.get('button[value="login"]').click(); + + cy.get(`a[href="/${organizationSlug}"]`).should('exist'); + }); }); }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 1b3541002..5bedcb736 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -15,32 +15,45 @@ namespace Cypress { }): Chainable; login(data: { email: string; password: string }): Chainable; dataCy(name: string): Chainable>; - createOIDCIntegration(organizationSlug: string): Chainable<{ + createOIDCIntegration(): Chainable<{ loginUrl: string; organizationSlug: string; }>; } } -Cypress.Commands.add('createOIDCIntegration', (organizationSlug: string) => { - cy.get('input[name="slug"]').type(organizationSlug); - cy.get('button[type="submit"]').click(); - cy.get('[data-cy="organization-picker-current"]').contains(organizationSlug); - 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'); +Cypress.Commands.add('createOIDCIntegration', () => { + const isLocal = Cypress.env('RUN_AGAINST_LOCAL_SERVICES') == '1'; - cy.get('div[role="dialog"]').find('button[type="submit"]').last().click(); + cy.contains('a', 'Settings').click(); + cy.get('[data-cy="link-sso"]').click(); + cy.get('button[data-button-connect-open-id-provider]').click(); + cy.get('button[data-button-oidc-manual]').click(); + const form = () => cy.get('form[data-form-oidc]'); + form() + .find('input[name="token_endpoint"]') + .type( + isLocal ? 'http://localhost:7043/connect/token' : 'http://oidc-server-mock:80/connect/token', + ); + form() + .find('input[name="userinfo_endpoint"]') + .type( + isLocal + ? 'http://localhost:7043/connect/userinfo' + : 'http://oidc-server-mock:80/connect/userinfo', + ); + form() + .find('input[name="authorization_endpoint"]') + .type('http://localhost:7043/connect/authorize'); + form().find('input[name="clientId"]').type('implicit-mock-client'); + form().find('input[name="clientSecret"]').type('client-credentials-mock-client-secret'); + + cy.get('button[data-button-oidc-save]').click(); return cy - .get('div[role="dialog"]') - .find('input[id="sign-in-uri"]') + .get('span[data-oidc-property-sign-in-url]') .then(async $elem => { - const url = $elem.val(); + const url = $elem.text(); if (!url) { throw new Error('Failed to resolve OIDC integration URL'); @@ -96,7 +109,15 @@ Cypress.Commands.add('signup', user => { cy.get('a[data-auth-link="sign-up"]').click(); cy.fillSignUpFormAndSubmit(user); - cy.contains('Create Organization'); + cy.contains('Verify your email address'); + + const email = user.email; + return cy.task('getEmailConfirmationLink', email).then((url: string) => { + cy.visit(url); + cy.contains('Success!'); + cy.get('[data-button-verify-email-continue]').click(); + cy.contains('Create Organization'); + }); }); Cypress.Commands.add('login', user => { diff --git a/docker/configs/oidc-server-mock/users-config.json b/docker/configs/oidc-server-mock/users-config.json index 24f392ee5..9a57eddef 100644 --- a/docker/configs/oidc-server-mock/users-config.json +++ b/docker/configs/oidc-server-mock/users-config.json @@ -32,5 +32,22 @@ "ValueType": "string" } ] + }, + { + "SubjectId": "3", + "Username": "test-user-3", + "Password": "password", + "Claims": [ + { + "Type": "name", + "Value": "Hive Bro", + "ValueType": "string" + }, + { + "Type": "email", + "Value": "hive.bro@buzzcheck.dev", + "ValueType": "string" + } + ] } ] diff --git a/docker/docker-compose.end2end.yml b/docker/docker-compose.end2end.yml index 5f80b24b4..0e12549f0 100644 --- a/docker/docker-compose.end2end.yml +++ b/docker/docker-compose.end2end.yml @@ -41,5 +41,19 @@ services: ports: - '5432:5432' + workflows: + ports: + - '3014:3014' + environment: + EMAIL_PROVIDER: mock + + redis: + ports: + - '6379:6379' + + server: + environment: + SUPERTOKENS_RATE_LIMIT: '0' + AUTH_REQUIRE_EMAIL_VERIFICATION: '1' networks: stack: {} diff --git a/integration-tests/.env b/integration-tests/.env index 641a2f102..8b1a2755f 100644 --- a/integration-tests/.env +++ b/integration-tests/.env @@ -5,6 +5,9 @@ POSTGRES_PASSWORD=postgres POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=registry +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= HIVE_ENCRYPTION_SECRET=wowverysecuremuchsecret HIVE_EMAIL_FROM=no-reply@graphql-hive.com HIVE_APP_BASE_URL=http://localhost:8080 diff --git a/integration-tests/testkit/auth.ts b/integration-tests/testkit/auth.ts index 2a1596849..b8368ee4c 100644 --- a/integration-tests/testkit/auth.ts +++ b/integration-tests/testkit/auth.ts @@ -112,6 +112,7 @@ const createSessionAtHome = async ( return { access_token: session.accessToken.token, refresh_token: session.refreshToken, + supertokensUserId: session.session.userId, }; }; @@ -230,7 +231,7 @@ export async function authenticate( pool: DatabasePool, email: string, oidcIntegrationId?: string, -): Promise<{ access_token: string; refresh_token: string }> { +): Promise<{ access_token: string; refresh_token: string; supertokensUserId: string }> { if (process.env.SUPERTOKENS_AT_HOME === '1') { const supertokensStore = new SuperTokensStore(pool, new NoopLogger()); if (!tokenResponsePromise[email]) { @@ -257,7 +258,8 @@ export async function authenticate( })); } - return tokenResponsePromise[email]!.then(data => - createSession(data.userId, data.email, oidcIntegrationId ?? null), - ); + return tokenResponsePromise[email]!.then(async data => ({ + ...(await createSession(data.userId, data.email, oidcIntegrationId ?? null)), + supertokensUserId: data.userId, + })); } diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 377578512..e4d4cdf6f 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -1,6 +1,8 @@ import { formatISO, subHours } from 'date-fns'; import { humanId } from 'human-id'; import { createPool, sql } from 'slonik'; +import { NoopLogger } from '@hive/api/modules/shared/providers/logger'; +import { createRedisClient } from '@hive/api/modules/shared/providers/redis'; import type { Report } from '../../packages/libraries/core/src/client/usage.js'; import { authenticate, userEmail } from './auth'; import { @@ -97,15 +99,55 @@ async function createDbConnection() { export function initSeed() { let sharedDBPoolPromise: ReturnType; - async function doAuthenticate(email: string, oidcIntegrationId?: string) { + function getPool() { if (!sharedDBPoolPromise) { sharedDBPoolPromise = createDbConnection(); } - const sharedPool = await sharedDBPoolPromise; - return await authenticate(sharedPool.pool, email, oidcIntegrationId); + return sharedDBPoolPromise.then(res => res.pool); + } + + async function doAuthenticate(email: string, oidcIntegrationId?: string) { + return await authenticate(await getPool(), email, oidcIntegrationId); } return { + async purgeOIDCDomains() { + const pool = await getPool(); + await pool.query(sql` + TRUNCATE "oidc_integration_domains" + `); + }, + async forgeOIDCDNSChallenge(orgSlug: string) { + const pool = await getPool(); + + const domainChallengeId = await pool.oneFirst(sql` + SELECT "oidc_integration_domains"."id" + FROM "oidc_integration_domains" INNER JOIN "organizations" ON "oidc_integration_domains"."organization_id" = "organizations"."id" + WHERE "organizations"."clean_id" = ${orgSlug} + `); + const key = `hive:oidcDomainChallenge:${domainChallengeId}`; + + const challenge = { + id: domainChallengeId, + recordName: `_hive-challenge`, + // hardcoded value + value: 'a894723a5d52a30d73790752b0169835e6f81dd77d2737dba809bee7fde39092', + }; + + const redis = createRedisClient( + '', + { + host: ensureEnv('REDIS_HOST'), + password: ensureEnv('REDIS_PASSWORD'), + port: parseInt(ensureEnv('REDIS_PORT'), 10), + tlsEnabled: false, + }, + new NoopLogger(), + ); + + await redis.set(key, JSON.stringify(challenge)); + await redis.disconnect(); + }, createDbConnection, authenticate: doAuthenticate, generateEmail: () => userEmail(generateUnique()), @@ -118,9 +160,18 @@ export function initSeed() { }, ).then(res => res.json()); }, - async createOwner() { + async createOwner(verifyEmail: boolean = true) { const ownerEmail = userEmail(generateUnique()); const auth = await doAuthenticate(ownerEmail); + + if (verifyEmail) { + const pool = await getPool(); + await pool.query(sql` + INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at") + VALUES (${auth.supertokensUserId}, ${ownerEmail}, NOW()) + `); + } + const ownerRefreshToken = auth.refresh_token; const ownerToken = auth.access_token; diff --git a/integration-tests/tests/api/rate-limit/emails.spec.ts b/integration-tests/tests/api/rate-limit/emails.spec.ts index 581d07a95..97d3747f4 100644 --- a/integration-tests/tests/api/rate-limit/emails.spec.ts +++ b/integration-tests/tests/api/rate-limit/emails.spec.ts @@ -59,6 +59,7 @@ test('rate limit approaching and reached for organization', async () => { expect(sent).toEqual([ { to: ownerEmail, + date: expect.any(String), subject: `${organization.slug} is approaching its rate limit`, body: expect.any(String), }, @@ -82,6 +83,7 @@ test('rate limit approaching and reached for organization', async () => { expect(sent).toContainEqual({ to: ownerEmail, + date: expect.any(String), subject: `GraphQL-Hive operations quota for ${organization.slug} exceeded`, body: expect.any(String), }); diff --git a/packages/migrations/src/actions/2026.02.25T00-00-00.oidc-integration-domains.ts b/packages/migrations/src/actions/2026.02.25T00-00-00.oidc-integration-domains.ts new file mode 100644 index 000000000..a1b0626b8 --- /dev/null +++ b/packages/migrations/src/actions/2026.02.25T00-00-00.oidc-integration-domains.ts @@ -0,0 +1,30 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.02.25T00-00-00.oidc-integration-domains.ts', + run: ({ sql }) => sql` + CREATE TABLE IF NOT EXISTS "oidc_integration_domains" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4() + , "organization_id" uuid NOT NULL REFERENCES "organizations"("id") ON DELETE CASCADE + , "oidc_integration_id" uuid NOT NULL REFERENCES "oidc_integrations"("id") ON DELETE CASCADE + , "domain_name" text NOT NULL + , "created_at" timestamptz NOT NULL DEFAULT NOW() + , "verified_at" timestamptz DEFAULT NULL + , PRIMARY KEY ("id") + , UNIQUE ("oidc_integration_id", "domain_name") + ); + + CREATE INDEX IF NOT EXISTS "oidc_integration_domains_oidc_integration_id_idx" + ON "oidc_integration_domains" ("oidc_integration_id") + ; + CREATE INDEX IF NOT EXISTS "oidc_integration_domains_organization_id_idx" + ON "oidc_integration_domains" ("organization_id") + ; + CREATE INDEX IF NOT EXISTS "oidc_integration_domains_domain_name_idx" + ON "oidc_integration_domains" ("domain_name") + ; + CREATE UNIQUE INDEX IF NOT EXISTS "only_one_verified_domain_name_idx" + ON "oidc_integration_domains" ("domain_name") + WHERE "verified_at" IS NOT NULL; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 1b213dcfb..88c22b4e7 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -190,5 +190,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri ? [await import('./actions/2026.02.18T00-00-00.ensure-supertokens-tables')] : []), await import('./actions/2026.02.24T00-00-00.proposal-composition'), + await import('./actions/2026.02.25T00-00-00.oidc-integration-domains'), ], }); diff --git a/packages/services/api/package.json b/packages/services/api/package.json index a03311a91..4132ac478 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -9,7 +9,8 @@ "exports": { "./modules/auth/lib/supertokens-at-home/crypto": "./src/modules/auth/lib/supertokens-at-home/crypto.ts", "./modules/auth/providers/supertokens-store": "./src/modules/auth/providers/supertokens-store.ts", - "./modules/shared/providers/logger": "./src/modules/shared/providers/logger" + "./modules/shared/providers/logger": "./src/modules/shared/providers/logger.ts", + "./modules/shared/providers/redis": "./src/modules/shared/providers/redis.ts" }, "peerDependencies": { "graphql": "^16.0.0", diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index 9b6e5c256..39bc049e6 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -6,6 +6,7 @@ import type { FastifyReply, FastifyRequest } from '@hive/service-common'; import { captureException } from '@sentry/node'; import { AccessError, HiveError, OIDCRequiredError } from '../../../shared/errors'; import { isUUID } from '../../../shared/is-uuid'; +import { OIDCIntegrationStore } from '../../oidc-integrations/providers/oidc-integration.store'; import { OrganizationMembers } from '../../organization/providers/organization-members'; import { Logger } from '../../shared/providers/logger'; import type { Storage } from '../../shared/providers/storage'; @@ -164,12 +165,12 @@ export class SuperTokensCookieBasedSession extends Session { } export class SuperTokensUserAuthNStrategy extends AuthNStrategy { - private logger: Logger; private organizationMembers: OrganizationMembers; private storage: Storage; private supertokensStore: SuperTokensStore; private emailVerification: EmailVerification | null; private accessTokenKey: AccessTokenKeyContainer | null; + private oidcIntegrationStore: OIDCIntegrationStore; constructor(deps: { logger: Logger; @@ -177,17 +178,21 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy { let session: SessionNode.SessionContainer | undefined; try { @@ -196,9 +201,9 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy { let session: SessionInfo | null = null; args.req.log.debug('attempt parsing access token from cookie'); @@ -273,7 +287,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy { + if (!sessionData.oidcIntegrationId) { + logger.debug('email verification is required.'); + return true; + } + + const [, domainName] = sessionData.email.split('@'); + const record = + await this.oidcIntegrationStore.findVerifiedDomainByOIDCIntegrationIdAndDomainName( + sessionData.oidcIntegrationId, + domainName, + ); + + if (record) { + logger.debug('no email verification is required, as the domain is verified.'); + return false; + } + + logger.debug('email verification is required, as the domain is not verified.'); + return true; + } + private async verifySuperTokensSession(args: { req: FastifyRequest; reply: FastifyReply; }): Promise { - this.logger.debug('Attempt verifying SuperTokens session'); + args.req.log.debug('Attempt verifying SuperTokens session'); if (args.req.headers['ignore-session']) { - this.logger.debug('Ignoring session due to header'); + args.req.log.debug('Ignoring session due to header'); return null; } @@ -357,11 +404,15 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy; +type SuperTokensSessionPayloadV2 = zod.TypeOf; diff --git a/packages/services/api/src/modules/oidc-integrations/index.ts b/packages/services/api/src/modules/oidc-integrations/index.ts index 81e45d855..d98e5ba48 100644 --- a/packages/services/api/src/modules/oidc-integrations/index.ts +++ b/packages/services/api/src/modules/oidc-integrations/index.ts @@ -1,5 +1,6 @@ import { createModule } from 'graphql-modules'; import { ResourceAssignments } from '../organization/providers/resource-assignments'; +import { OIDCIntegrationStore } from './providers/oidc-integration.store'; import { OIDCIntegrationsProvider } from './providers/oidc-integrations.provider'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -9,5 +10,5 @@ export const oidcIntegrationsModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [OIDCIntegrationsProvider, ResourceAssignments], + providers: [OIDCIntegrationsProvider, ResourceAssignments, OIDCIntegrationStore], }); diff --git a/packages/services/api/src/modules/oidc-integrations/module.graphql.mappers.ts b/packages/services/api/src/modules/oidc-integrations/module.graphql.mappers.ts index d01657188..fcfdf9155 100644 --- a/packages/services/api/src/modules/oidc-integrations/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/oidc-integrations/module.graphql.mappers.ts @@ -1,3 +1,5 @@ import type { OIDCIntegration } from '../../shared/entities'; +import type { OIDCIntegrationDomain } from './providers/oidc-integration.store'; export type OIDCIntegrationMapper = OIDCIntegration; +export type OIDCIntegrationDomainMapper = OIDCIntegrationDomain; diff --git a/packages/services/api/src/modules/oidc-integrations/module.graphql.ts b/packages/services/api/src/modules/oidc-integrations/module.graphql.ts index e6ad12a45..4fa9f15e5 100644 --- a/packages/services/api/src/modules/oidc-integrations/module.graphql.ts +++ b/packages/services/api/src/modules/oidc-integrations/module.graphql.ts @@ -23,6 +23,10 @@ export default gql` requireInvitation: Boolean! defaultMemberRole: MemberRole! defaultResourceAssignment: ResourceAssignment + """ + List of domains registered with this OIDC integration. + """ + registeredDomains: [OIDCIntegrationDomain!]! } extend type Mutation { @@ -36,6 +40,111 @@ export default gql` updateOIDCDefaultResourceAssignment( input: UpdateOIDCDefaultResourceAssignmentInput! ): UpdateOIDCDefaultResourceAssignmentResult! + """ + Register a domain for the OIDC provider for a verification challenge. + """ + registerOIDCDomain(input: RegisterOIDCDomainInput!): RegisterOIDCDomainResult! + """ + Remove a domain from the OIDC provider list. + """ + deleteOIDCDomain(input: DeleteOIDCDomainInput!): DeleteOIDCDomainResult! + """ + Verify the domain verification challenge + """ + verifyOIDCDomainChallenge( + input: VerifyOIDCDomainChallengeInput! + ): VerifyOIDCDomainChallengeResult! + """ + Request a new domain verification challenge + """ + requestOIDCDomainChallenge( + input: RequestOIDCDomainChallengeInput! + ): RequestOIDCDomainChallengeResult! + } + + input RegisterOIDCDomainInput { + oidcIntegrationId: ID! + domainName: String! + } + + type RegisterOIDCDomainResult { + ok: RegisterOIDCDomainResultOk + error: RegisterOIDCDomainResultError + } + + type RegisterOIDCDomainResultOk { + createdOIDCIntegrationDomain: OIDCIntegrationDomain! + oidcIntegration: OIDCIntegration! + } + + type RegisterOIDCDomainResultError { + message: String! + } + + input DeleteOIDCDomainInput { + oidcDomainId: ID! + } + + type DeleteOIDCDomainResult { + ok: DeleteOIDCDomainOk + error: DeleteOIDCDomainError + } + + type DeleteOIDCDomainOk { + deletedOIDCIntegrationId: ID! + oidcIntegration: OIDCIntegration + } + + type DeleteOIDCDomainError { + message: String! + } + + input VerifyOIDCDomainChallengeInput { + oidcDomainId: ID! + } + + type VerifyOIDCDomainChallengeResult { + ok: VerifyOIDCDomainChallengeOk + error: VerifyOIDCDomainChallengeError + } + + type VerifyOIDCDomainChallengeOk { + verifiedOIDCIntegrationDomain: OIDCIntegrationDomain! + } + + type VerifyOIDCDomainChallengeError { + message: String! + } + + input RequestOIDCDomainChallengeInput { + oidcDomainId: ID! + } + + type RequestOIDCDomainChallengeResult { + ok: RequestOIDCDomainChallengeResultOk + error: RequestOIDCDomainChallengeResultError + } + + type RequestOIDCDomainChallengeResultOk { + oidcIntegrationDomain: OIDCIntegrationDomain + } + + type RequestOIDCDomainChallengeResultError { + message: String! + } + + type OIDCIntegrationDomain { + id: ID! + domainName: String! + createdAt: DateTime! + verifiedAt: DateTime + challenge: OIDCIntegrationDomainChallenge + } + + type OIDCIntegrationDomainChallenge { + recordName: String! + recordType: String! + recordValue: String! } """ diff --git a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integration.store.ts b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integration.store.ts new file mode 100644 index 000000000..a6f05c4d4 --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integration.store.ts @@ -0,0 +1,239 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import { sha256 } from '../../auth/lib/supertokens-at-home/crypto'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; + +const SharedOIDCIntegrationDomainFieldsModel = z.object({ + id: z.string().uuid(), + organizationId: z.string().uuid(), + oidcIntegrationId: z.string().uuid(), + domainName: z.string(), + createdAt: z.string(), +}); + +const PendingOIDCIntegrationDomainModel = SharedOIDCIntegrationDomainFieldsModel.extend({ + verifiedAt: z.null(), +}); + +const ValidatedOIDCIntegrationDomainModel = SharedOIDCIntegrationDomainFieldsModel.extend({ + verifiedAt: z.string(), +}); + +const OIDCIntegrationDomainModel = z.union([ + PendingOIDCIntegrationDomainModel, + ValidatedOIDCIntegrationDomainModel, +]); + +export type OIDCIntegrationDomain = z.TypeOf; + +const oidcIntegrationDomainsFields = sql` + "id" + , "organization_id" AS "organizationId" + , "oidc_integration_id" AS "oidcIntegrationId" + , "domain_name" AS "domainName" + , to_json("created_at") AS "createdAt" + , to_json("verified_at") AS "verifiedAt" +`; + +const OIDCIntegrationDomainListModel = z.array(OIDCIntegrationDomainModel); + +@Injectable({ + global: true, + scope: Scope.Operation, +}) +export class OIDCIntegrationStore { + private logger: Logger; + + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + @Inject(REDIS_INSTANCE) private redis: Redis, + logger: Logger, + ) { + this.logger = logger.child({ module: 'OIDCIntegrationStore' }); + } + + async getDomainsForOIDCIntegrationId(oidcIntegrationId: string) { + this.logger.debug( + 'load registered domains for oidc integration. (oidcIntegrationId=%s)', + oidcIntegrationId, + ); + + const query = sql` + SELECT + ${oidcIntegrationDomainsFields} + FROM + "oidc_integration_domains" + WHERE + "oidc_integration_id" = ${oidcIntegrationId} + `; + + return this.pool.any(query).then(OIDCIntegrationDomainListModel.parse); + } + + async createDomain(organizationId: string, oidcIntegrationId: string, domainName: string) { + this.logger.debug( + 'create domain on oidc integration. (oidcIntegrationId=%s)', + oidcIntegrationId, + ); + + const query = sql` + INSERT INTO "oidc_integration_domains" ( + "organization_id" + , "oidc_integration_id" + , "domain_name" + ) VALUES ( + ${organizationId} + , ${oidcIntegrationId} + , ${domainName} + ) + ON CONFLICT ("oidc_integration_id", "domain_name") + DO NOTHING + RETURNING + ${oidcIntegrationDomainsFields} + `; + + return this.pool.maybeOne(query).then(OIDCIntegrationDomainModel.nullable().parse); + } + + async deleteDomain(domainId: string) { + this.logger.debug('delete domain on oidc integration. (oidcIntegrationId=%s)', domainId); + + const query = sql` + DELETE + FROM + "oidc_integration_domains" + WHERE + "id" = ${domainId} + `; + + await this.pool.query(query); + } + + async findDomainById(domainId: string) { + const query = sql` + SELECT + ${oidcIntegrationDomainsFields} + FROM + "oidc_integration_domains" + WHERE + "id" = ${domainId} + `; + + return this.pool.maybeOne(query).then(OIDCIntegrationDomainModel.nullable().parse); + } + + async findVerifiedDomainByName(domainName: string) { + const query = sql` + SELECT + ${oidcIntegrationDomainsFields} + FROM + "oidc_integration_domains" + WHERE + "domain_name" = ${domainName} + AND "verified_at" IS NOT NULL + `; + + return this.pool.maybeOne(query).then(OIDCIntegrationDomainModel.nullable().parse); + } + + async findVerifiedDomainByOIDCIntegrationIdAndDomainName( + oidcIntegrationId: string, + domainName: string, + ) { + const query = sql` + SELECT + ${oidcIntegrationDomainsFields} + FROM + "oidc_integration_domains" + WHERE + "oidc_integration_id" = ${oidcIntegrationId} + AND "domain_name" = ${domainName} + AND "verified_at" IS NOT NULL + `; + + return this.pool.maybeOne(query).then(ValidatedOIDCIntegrationDomainModel.nullable().parse); + } + + async updateDomainVerifiedAt(domainId: string) { + this.logger.debug( + 'set verified at date for domain on oidc integration. (oidcIntegrationId=%s)', + domainId, + ); + + // The NOT EXISTS statement is to avoid verifying the domain twice for two different otganizations + // only one org can own a domain + const query = sql` + UPDATE + "oidc_integration_domains" + SET + "verified_at" = NOW() + WHERE + "id" = ${domainId} + AND NOT EXISTS ( + SELECT 1 + FROM "oidc_integration_domains" "x" + WHERE "x"."domain_name" = "oidc_integration_domains".domain_name + AND "x"."verified_at" IS NOT NULL + ) + RETURNING + ${oidcIntegrationDomainsFields} + `; + + return this.pool.maybeOne(query).then(OIDCIntegrationDomainModel.nullable().parse); + } + + async createDomainChallenge(domainId: string) { + this.logger.debug('create domain challenge (domainId=%s)', domainId); + const challenge = createChallengePayload(); + const key = `hive:oidcDomainChallenge:${domainId}`; + const oneDaySeconds = 60 * 60 * 24; + await this.redis.set(key, JSON.stringify(challenge), 'EX', oneDaySeconds); + return challenge; + } + + async deleteDomainChallenge(domainId: string) { + this.logger.debug('delete domain challenge (domainId=%s)', domainId); + const key = `hive:oidcDomainChallenge:${domainId}`; + await this.redis.del(key); + } + + async getDomainChallenge(domainId: string) { + this.logger.debug('load domain challenge (domainId=%s)', domainId); + const key = `hive:oidcDomainChallenge:${domainId}`; + const rawChallenge = await this.redis.get(key); + if (rawChallenge === null) { + this.logger.debug('no domain challenge found (domainId=%s)', domainId); + return null; + } + + try { + return ChallengePayloadModel.parse(JSON.parse(rawChallenge)); + } catch (err) { + this.logger.error( + 'domain challange is invalid JSON (domainId=%s, err=%s)', + domainId, + String(err), + ); + throw err; + } + } +} + +const ChallengePayloadModel = z.object({ + id: z.string(), + recordName: z.string(), + value: z.string(), +}); + +type ChallengePayload = z.TypeOf; + +function createChallengePayload(): ChallengePayload { + return { + id: crypto.randomUUID(), + recordName: `_hive-challenge`, + value: sha256(crypto.randomUUID()), + }; +} diff --git a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts index 904cfdc9b..928a50b6d 100644 --- a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts +++ b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts @@ -1,3 +1,4 @@ +import dns from 'node:dns/promises'; import { Inject, Injectable, Scope } from 'graphql-modules'; import zod from 'zod'; import { maskToken } from '@hive/service-common'; @@ -12,8 +13,18 @@ import { CryptoProvider } from '../../shared/providers/crypto'; import { Logger } from '../../shared/providers/logger'; import { PUB_SUB_CONFIG, type HivePubSub } from '../../shared/providers/pub-sub'; import { Storage } from '../../shared/providers/storage'; +import { OIDCIntegrationDomain, OIDCIntegrationStore } from './oidc-integration.store'; import { OIDC_INTEGRATIONS_ENABLED } from './tokens'; +const dnsList = [ + // Google + '8.8.8.8', + '8.8.4.4', + // Cloudflare + '1.1.1.1', + '1.0.0.1', +]; + @Injectable({ global: true, scope: Scope.Operation, @@ -30,6 +41,7 @@ export class OIDCIntegrationsProvider { @Inject(OIDC_INTEGRATIONS_ENABLED) private enabled: boolean, private session: Session, private resourceAssignments: ResourceAssignments, + private oidcIntegrationStore: OIDCIntegrationStore, ) { this.logger = logger.child({ source: 'OIDCIntegrationsProvider' }); } @@ -534,6 +546,304 @@ export class OIDCIntegrationsProvider { return this.pubSub.subscribe('oidcIntegrationLogs', integration.id); } + + async registerDomain(args: { oidcIntegrationId: string; domain: string }) { + const parsedId = zod.string().uuid().safeParse(args.oidcIntegrationId); + + if (parsedId.error) { + this.session.raise('oidc:modify'); + } + + const integration = await this.storage.getOIDCIntegrationById({ + oidcIntegrationId: parsedId.data, + }); + + if (!integration) { + this.session.raise('oidc:modify'); + } + + await this.session.assertPerformAction({ + organizationId: integration.linkedOrganizationId, + action: 'oidc:modify', + params: { + organizationId: integration.linkedOrganizationId, + }, + }); + + const fqdnResult = FQDNModel.safeParse(args.domain); + + if (fqdnResult.error) { + return { + type: 'error' as const, + message: 'Invalid FQDN provided.', + }; + } + + const existingVerifiedDomain = await this.oidcIntegrationStore.findVerifiedDomainByName( + fqdnResult.data, + ); + + if (existingVerifiedDomain) { + return { + type: 'error' as const, + message: 'This domain has already been verified with another organization.', + }; + } + + const domain = await this.oidcIntegrationStore.createDomain( + integration.linkedOrganizationId, + integration.id, + fqdnResult.data, + ); + + if (!domain) { + return { + type: 'error' as const, + message: 'This domain has already been registered for this organization.', + }; + } + + const challenge = await this.oidcIntegrationStore.createDomainChallenge(domain.id); + + return { + type: 'result' as const, + domain, + challenge, + integration, + }; + } + + async requestDomainChallenge(args: { domainId: string }) { + const parsedId = zod.string().uuid().safeParse(args.domainId); + + if (parsedId.error) { + this.logger.debug('the provided domain ID is invalid.'); + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + let domain = await this.oidcIntegrationStore.findDomainById(parsedId.data); + + if (!domain) { + this.logger.debug('the domain does not exist.'); + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + if ( + !(await this.session.canPerformAction({ + organizationId: domain.organizationId, + action: 'oidc:modify', + params: { + organizationId: domain.organizationId, + }, + })) + ) { + this.logger.debug('insuffidient permissions for accessing the domain.'); + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + if (domain.verifiedAt) { + this.logger.debug('the domain was already verified.'); + return { + type: 'error' as const, + message: 'Domain is already verified.', + }; + } + + let challenge = await this.oidcIntegrationStore.getDomainChallenge(domain.id); + + if (challenge) { + this.logger.debug('a challenge for this domain already exists.'); + return { + type: 'error' as const, + message: 'A challenge already exists.', + }; + } + + challenge = await this.oidcIntegrationStore.createDomainChallenge(domain.id); + + this.logger.debug('a new challenge for this domain was created.'); + + return { + type: 'success' as const, + domain, + challenge, + }; + } + + async verifyDomainChallenge(args: { domainId: string }) { + this.logger.debug('attempt to verify the domain challenge.'); + const parsedId = zod.string().uuid().safeParse(args.domainId); + + if (parsedId.error) { + this.logger.debug('invalid it provided.'); + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + let domain = await this.oidcIntegrationStore.findDomainById(parsedId.data); + if (!domain) { + this.logger.debug('the domain does not exist.'); + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + if ( + !(await this.session.canPerformAction({ + organizationId: domain.organizationId, + action: 'oidc:modify', + params: { + organizationId: domain.organizationId, + }, + })) + ) { + this.logger.debug('insufficient permissions.'); + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + const challenge = await this.oidcIntegrationStore.getDomainChallenge(domain.id); + + if (!challenge) { + this.logger.debug('no challenge was found for this domain.'); + return { + type: 'error' as const, + message: 'Pending challenge not found.', + }; + } + + const recordName = challenge.recordName + '.' + domain.domainName; + + const records = ( + await Promise.all( + dnsList.map(async provider => { + const resolver = new dns.Resolver({ timeout: 10_000 }); + resolver.setServers([provider]); + return await resolver.resolveTxt(recordName).catch(err => { + this.logger.debug(`failed lookup record on '%s': %s`, provider, String(err)); + return [] as string[][]; + }); + }), + ) + ) + .flatMap(record => record) + .flatMap(record => record); + + if (!records) { + this.logger.debug('no records could be resolved.'); + return { + type: 'error' as const, + message: 'The TXT record could not be resolved.', + }; + } + + if (records.length === 0) { + this.logger.debug('no records were found.'); + return { + type: 'error' as const, + message: + 'No TXT record value was found for that domain. The propagation of the DNS record could take some time. Please try again later.', + }; + } + + // At least one record needs to match for the challenge to succeed + if (!records.find(record => record === challenge.value)) { + this.logger.debug('no records match the expected value were found.'); + return { + type: 'error' as const, + message: + 'A TXT record with the provided value was not found. Please make sure you set the correct value. The propagation of the DNS record can take some time, so please try again later.', + }; + } + + domain = await this.oidcIntegrationStore.updateDomainVerifiedAt(domain.id); + + if (!domain) { + this.logger.debug('the domain has already been verified with another organization.'); + return { + type: 'error' as const, + message: 'This domain has already been verified for another organization.', + }; + } + + await this.oidcIntegrationStore.deleteDomainChallenge(domain.id); + this.logger.debug('the domain challenge was completed sucessfully.'); + + return { + type: 'success' as const, + domain, + }; + } + + async getDomainChallenge(domain: OIDCIntegrationDomain) { + const challenge = await this.oidcIntegrationStore.getDomainChallenge(domain.id); + + if (!challenge) { + return null; + } + + return { + recordType: 'TXT', + recordName: `${challenge.recordName}.${domain.domainName}`, + recordValue: challenge.value, + }; + } + + async deleteDomain(args: { domainId: string }) { + const parsedId = zod.string().uuid().safeParse(args.domainId); + + if (parsedId.error) { + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + + const domain = await this.oidcIntegrationStore.findDomainById(parsedId.data); + + if ( + !domain || + (await this.session.canPerformAction({ + organizationId: domain.organizationId, + action: 'oidc:modify', + params: { + organizationId: domain.organizationId, + }, + })) === false + ) { + return { + type: 'error' as const, + message: 'Domain not found.', + }; + } + const integration = await this.storage.getOIDCIntegrationById({ + oidcIntegrationId: domain.oidcIntegrationId, + }); + await this.oidcIntegrationStore.deleteDomain(args.domainId); + + return { + type: 'success' as const, + integration, + }; + } + + async getRegisteredDomainsForOIDCIntegration(integration: OIDCIntegration) { + return await this.oidcIntegrationStore.getDomainsForOIDCIntegrationId(integration.id); + } } const OIDCIntegrationClientIdModel = zod @@ -560,3 +870,9 @@ const OIDCAdditionalScopesModel = zod .max(20, 'Can not be more than 20 items.'); const maybe = (schema: zod.ZodSchema) => zod.union([schema, zod.null()]); + +const FQDNModel = zod + .string() + .min(3, 'Must be at least 3 characters long') + .max(255, 'Must be at most 255 characters long.') + .regex(/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]+$/, 'Invalid domain provided.'); diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/deleteOIDCDomain.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/deleteOIDCDomain.ts new file mode 100644 index 000000000..9c4c1e840 --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/deleteOIDCDomain.ts @@ -0,0 +1,27 @@ +import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const deleteOIDCDomain: NonNullable = async ( + _parent, + args, + ctx, +) => { + const result = await ctx.injector.get(OIDCIntegrationsProvider).deleteDomain({ + domainId: args.input.oidcDomainId, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + deletedOIDCIntegrationId: args.input.oidcDomainId, + oidcIntegration: result.integration, + }, + }; +}; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/registerOIDCDomain.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/registerOIDCDomain.ts new file mode 100644 index 000000000..4b61cfe46 --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/registerOIDCDomain.ts @@ -0,0 +1,28 @@ +import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const registerOIDCDomain: NonNullable = async ( + _parent, + args, + ctx, +) => { + const result = await ctx.injector.get(OIDCIntegrationsProvider).registerDomain({ + oidcIntegrationId: args.input.oidcIntegrationId, + domain: args.input.domainName, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + createdOIDCIntegrationDomain: result.domain, + oidcIntegration: result.integration, + }, + }; +}; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/requestOIDCDomainChallenge.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/requestOIDCDomainChallenge.ts new file mode 100644 index 000000000..416fe4253 --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/requestOIDCDomainChallenge.ts @@ -0,0 +1,24 @@ +import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const requestOIDCDomainChallenge: NonNullable< + MutationResolvers['requestOIDCDomainChallenge'] +> = async (_parent, args, ctx) => { + const result = await ctx.injector.get(OIDCIntegrationsProvider).requestDomainChallenge({ + domainId: args.input.oidcDomainId, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + oidcIntegrationDomain: result.domain, + }, + }; +}; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/verifyOIDCDomainChallenge.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/verifyOIDCDomainChallenge.ts new file mode 100644 index 000000000..db8a29b9f --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/verifyOIDCDomainChallenge.ts @@ -0,0 +1,24 @@ +import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const verifyOIDCDomainChallenge: NonNullable< + MutationResolvers['verifyOIDCDomainChallenge'] +> = async (_parent, args, ctx) => { + const result = await ctx.injector.get(OIDCIntegrationsProvider).verifyDomainChallenge({ + domainId: args.input.oidcDomainId, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + verifiedOIDCIntegrationDomain: result.domain, + }, + }; +}; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts index 597e47cb8..10909b300 100644 --- a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts @@ -51,4 +51,9 @@ export const OIDCIntegration: OidcIntegrationResolvers = { resources: oidcIntegration.defaultResourceAssignment, }); }, + registeredDomains: async (oidcIntegration, _arg, { injector }) => { + return injector + .get(OIDCIntegrationsProvider) + .getRegisteredDomainsForOIDCIntegration(oidcIntegration); + }, }; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegrationDomain.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegrationDomain.ts new file mode 100644 index 000000000..609124ab7 --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegrationDomain.ts @@ -0,0 +1,26 @@ +import { OIDCIntegrationsProvider } from '../providers/oidc-integrations.provider'; +import type { OidcIntegrationDomainResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "OIDCIntegrationDomainMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const OIDCIntegrationDomain: OidcIntegrationDomainResolvers = { + challenge: async (domain, _arg, { injector }) => { + return injector.get(OIDCIntegrationsProvider).getDomainChallenge(domain); + }, + createdAt: ({ createdAt }, _arg, _ctx) => { + return new Date(createdAt); + }, + verifiedAt: ({ verifiedAt }, _arg, _ctx) => { + if (!verifiedAt) { + return null; + } + return new Date(verifiedAt); + }, +}; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 58f117718..93c2d92c1 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -22,6 +22,7 @@ import { import { AccessTokenKeyContainer } from '@hive/api/modules/auth/lib/supertokens-at-home/crypto'; import { EmailVerification } from '@hive/api/modules/auth/providers/email-verification'; import { OAuthCache } from '@hive/api/modules/auth/providers/oauth-cache'; +import { OIDCIntegrationStore } from '@hive/api/modules/oidc-integrations/providers/oidc-integration.store'; import { createRedisClient } from '@hive/api/modules/shared/providers/redis'; import { RedisRateLimiter } from '@hive/api/modules/shared/providers/redis-rate-limiter'; import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache'; @@ -399,6 +400,7 @@ export async function main() { env.supertokens.type === 'atHome' ? new AccessTokenKeyContainer(env.supertokens.secrets.accessTokenKey) : null, + oidcIntegrationStore: new OIDCIntegrationStore(storage.pool, redis, logger), }), organizationAccessTokenStrategy, (logger: Logger) => diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index abfa18881..2e97be429 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -159,6 +159,15 @@ export interface migration { name: string; } +export interface oidc_integration_domains { + created_at: Date; + domain_name: string; + id: string; + oidc_integration_id: string; + organization_id: string; + verified_at: Date | null; +} + export interface oidc_integrations { additional_scopes: Array | null; authorization_endpoint: string | null; @@ -530,6 +539,7 @@ export interface DBTables { email_verifications: email_verifications; graphile_worker_deduplication: graphile_worker_deduplication; migration: migration; + oidc_integration_domains: oidc_integration_domains; oidc_integrations: oidc_integrations; organization_access_tokens: organization_access_tokens; organization_invitations: organization_invitations; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index b230420f6..b12c7d663 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -701,12 +701,12 @@ export async function createStorage( let invitation: OrganizationInvitation | null = null; - if (oidcIntegration) { + if (oidcIntegration?.id) { const oidcConfig = await this.getOIDCIntegrationById({ oidcIntegrationId: oidcIntegration.id, }); - if (oidcConfig?.requireInvitation) { + if (oidcConfig) { invitation = await t .maybeOne( sql` @@ -726,18 +726,18 @@ export async function createStorage( `, ) .then(v => OrganizationInvitationModel.nullable().parse(v)); + } - if (!invitation) { - const member = internalUser - ? await this.getOrganizationMember({ - organizationId: oidcConfig.linkedOrganizationId, - userId: internalUser.id, - }) - : null; + if (oidcConfig?.requireInvitation && !invitation) { + const member = internalUser + ? await this.getOrganizationMember({ + organizationId: oidcConfig.linkedOrganizationId, + userId: internalUser.id, + }) + : null; - if (!member) { - throw new EnsureUserExistsError('User is not invited to the organization.'); - } + if (!member) { + throw new EnsureUserExistsError('User is not invited to the organization.'); } } } diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index db80dd74e..d1fb2ae6e 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -117,7 +117,14 @@ if (context.email.id === 'mock') { server.route({ method: ['GET'], url: '/_history', - handler(_, res) { + handler(req, res) { + const query = new URLSearchParams(req.query as any); + const after = query.get('after'); + if (after) { + return void res + .status(200) + .send(context.email.history.filter(h => h.date > new Date(after))); + } void res.status(200).send(context.email.history); }, }); diff --git a/packages/services/workflows/src/lib/emails/providers.ts b/packages/services/workflows/src/lib/emails/providers.ts index 35c968a20..66a041944 100644 --- a/packages/services/workflows/src/lib/emails/providers.ts +++ b/packages/services/workflows/src/lib/emails/providers.ts @@ -5,6 +5,7 @@ interface Email { to: string; subject: string; body: string; + date: Date; } const emailProviders = { @@ -16,7 +17,7 @@ const emailProviders = { export interface EmailProvider { id: keyof typeof emailProviders; - send(email: Email): Promise; + send(email: Omit): Promise; history: Email[]; } @@ -102,7 +103,10 @@ function mock(_config: MockEmailProviderConfig, _emailFrom: string): EmailProvid return { id: 'mock' as const, async send(email: Email) { - history.push(email); + history.push({ + ...email, + date: new Date(), + }); }, history, }; diff --git a/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx b/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx deleted file mode 100644 index 2652ad58a..000000000 --- a/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx +++ /dev/null @@ -1,1479 +0,0 @@ -import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { useFormik } from 'formik'; -import { useForm } from 'react-hook-form'; -import { useClient, useMutation, useQuery } from 'urql'; -import { useDebouncedCallback } from 'use-debounce'; -import { z } from 'zod'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormMessage, -} from '@/components/ui/form'; -import { AlertTriangleIcon, CheckIcon, KeyIcon, XIcon } from '@/components/ui/icon'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; -import { Spinner } from '@/components/ui/spinner'; -import { Switch } from '@/components/ui/switch'; -import { useToast } from '@/components/ui/use-toast'; -import { VirtualLogList } from '@/components/ui/virtual-log-list'; -import { Tag } from '@/components/v2'; -import { env } from '@/env/frontend'; -import { DocumentType, FragmentType, graphql, useFragment } from '@/gql'; -import { useClipboard } from '@/lib/hooks'; -import { useResetState } from '@/lib/hooks/use-reset-state'; -import { cn } from '@/lib/utils'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation as useRQMutation } from '@tanstack/react-query'; -import { Link, useRouter } from '@tanstack/react-router'; -import { RoleSelector } from '../members/common'; -import { - createResourceSelectionFromResourceAssignment, - ResourceSelection, - ResourceSelector, - resourceSlectionToGraphQLSchemaResourceAssignmentInput, -} from '../members/resource-selector'; - -function CopyInput(props: { value: string; id?: string }) { - const copy = useClipboard(); - - return ( -
- - -
- ); -} - -const classes = { - container: cn('flex flex-col items-stretch gap-2'), - modal: cn('w-[550px]'), -}; - -function FormError({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -const OrganizationSettingsOIDCIntegrationSectionQuery = graphql(` - query OrganizationSettingsOIDCIntegrationSectionQuery($organizationSlug: String!) { - organization: organizationBySlug(organizationSlug: $organizationSlug) { - ...OIDCIntegrationSection_OrganizationFragment - } - } -`); - -const OIDCIntegrationSection_OrganizationFragment = graphql(` - fragment OIDCIntegrationSection_OrganizationFragment on Organization { - id - slug - availableOrganizationAccessTokenPermissionGroups { - ...PermissionSelector_PermissionGroupsFragment - ...SelectedPermissionOverview_PermissionGroupFragment - } - ...ResourceSelector_OrganizationFragment - oidcIntegration { - id - ...UpdateOIDCIntegration_OIDCIntegrationFragment - authorizationEndpoint - } - memberRoles { - edges { - node { - ...OIDCDefaultRoleSelector_MemberRoleFragment - } - } - } - me { - id - role { - id - name - } - } - } -`); - -function extractDomain(rawUrl: string) { - const url = new URL(rawUrl); - return url.host; -} - -export function OIDCIntegrationSection(props: { organizationSlug: string }): ReactElement { - const router = useRouter(); - const [query] = useQuery({ - query: OrganizationSettingsOIDCIntegrationSectionQuery, - variables: { - organizationSlug: props.organizationSlug, - }, - }); - - const hash = router.latestLocation.hash; - const openCreateModalHash = 'create-oidc-integration'; - const openEditModalHash = 'manage-oidc-integration'; - const openDeleteModalHash = 'remove-oidc-integration'; - const openDebugModalHash = 'debug-oidc-integration'; - const isCreateOIDCIntegrationModalOpen = hash.endsWith(openCreateModalHash); - const isUpdateOIDCIntegrationModalOpen = hash.endsWith(openEditModalHash); - const isDeleteOIDCIntegrationModalOpen = hash.endsWith(openDeleteModalHash); - const isDebugOIDCIntegrationModalOpen = hash.endsWith(openDebugModalHash); - - const closeModal = () => { - void router.navigate({ - hash: undefined, - }); - }; - - const organization = useFragment( - OIDCIntegrationSection_OrganizationFragment, - query.data?.organization, - ); - if (!organization) return ; - - const isAdmin = organization.me?.role.name === 'Admin'; - - return ( - <> -
- {organization.oidcIntegration ? ( - <> - - - Manage OIDC Provider ( - {extractDomain(organization.oidcIntegration.authorizationEndpoint)}) - - - Show Debug Logs - - - Remove - - - ) : ( - - )} -
- { - // TODO(router) - void router.navigate({ - hash: 'manage-oidc-integration', - }); - }} - /> - edge.node) ?? null} - isOpen={isUpdateOIDCIntegrationModalOpen} - close={closeModal} - openCreateModalHash={openCreateModalHash} - /> - - {organization.oidcIntegration && ( - - )} - - ); -} - -const CreateOIDCIntegrationModal_CreateOIDCIntegrationMutation = graphql(` - mutation CreateOIDCIntegrationModal_CreateOIDCIntegrationMutation( - $input: CreateOIDCIntegrationInput! - ) { - createOIDCIntegration(input: $input) { - ok { - organization { - ...OIDCIntegrationSection_OrganizationFragment - } - } - error { - message - details { - clientId - clientSecret - tokenEndpoint - userinfoEndpoint - authorizationEndpoint - additionalScopes - } - } - } - } -`); - -function CreateOIDCIntegrationModal(props: { - isOpen: boolean; - close: () => void; - hasOIDCIntegration: boolean; - organizationId: string; - openEditModalHash: string; - transitionToManageScreen: () => void; -}): ReactElement { - return ( - - - {props.hasOIDCIntegration ? ( - <> - - Connect OpenID Connect Provider - - You are trying to create an OpenID Connect integration for an organization that - already has a provider attached. Please configure the existing provider instead. - - - - - - - - ) : ( - - )} - - - ); -} - -const OIDCMetadataSchema = z.object({ - token_endpoint: z - .string({ - required_error: 'Token endpoint not found', - }) - .url('Token endpoint must be a valid URL'), - userinfo_endpoint: z - .string({ - required_error: 'Userinfo endpoint not found', - }) - .url('Userinfo endpoint must be a valid URL'), - authorization_endpoint: z - .string({ - required_error: 'Authorization endpoint not found', - }) - .url('Authorization endpoint must be a valid URL'), -}); - -async function fetchOIDCMetadata(url: string) { - const res = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); - - if (!res.ok) { - return { - ok: false, - error: { - message: 'Failed to fetch metadata', - details: { - url, - status: res.status, - statusText: res.statusText, - body: await res.text(), - }, - }, - } as const; - } - - return { - ok: true, - metadata: await res.json(), - } as const; -} - -const OIDCMetadataFormSchema = z.object({ - url: z.string().url('Must be a valid URL'), -}); - -function OIDCMetadataFetcher(props: { - onEndpointChange(endpoints: { token: string; userinfo: string; authorization: string }): void; -}) { - const { toast } = useToast(); - - const fetchMetadata = useRQMutation({ - mutationFn: fetchOIDCMetadata, - onSuccess(data) { - if (!data.ok) { - toast({ - title: data.error.message, - description: ( -
-

Status: {data.error.details.status}

-

Response: {data.error.details.body ?? data.error.details.statusText}

-
- ), - variant: 'destructive', - }); - return; - } - - const metadataResult = OIDCMetadataSchema.safeParse(data.metadata); - if (!metadataResult.success) { - toast({ - title: 'Failed to parse OIDC metadata', - description: ( - <> - {[ - metadataResult.error.formErrors.fieldErrors.authorization_endpoint?.[0], - metadataResult.error.formErrors.fieldErrors.token_endpoint?.[0], - metadataResult.error.formErrors.fieldErrors.userinfo_endpoint?.[0], - ] - .filter(Boolean) - .map((msg, i) => ( -

{msg}

- ))} - - ), - variant: 'destructive', - }); - return; - } - - props.onEndpointChange({ - token: metadataResult.data.token_endpoint, - userinfo: metadataResult.data.userinfo_endpoint, - authorization: metadataResult.data.authorization_endpoint, - }); - }, - onError(error) { - console.error(error); - toast({ - title: 'Failed to fetch OIDC metadata', - description: 'Provide the endpoints manually or try again later', - variant: 'destructive', - }); - }, - }); - - function onSubmit(data: z.infer) { - fetchMetadata.mutate(data.url); - } - - const form = useForm({ - resolver: zodResolver(OIDCMetadataFormSchema), - defaultValues: { - url: '', - }, - mode: 'onSubmit', - }); - - return ( -
- - { - return ( - -
- - - - -
- - Provide the OIDC metadata URL to automatically fill in the fields below. - - -
- ); - }} - /> - - - ); -} - -function CreateOIDCIntegrationForm(props: { - organizationId: string; - close: () => void; - transitionToManageScreen: () => void; -}): ReactElement { - const [mutation, mutate] = useMutation(CreateOIDCIntegrationModal_CreateOIDCIntegrationMutation); - - const formik = useFormik({ - initialValues: { - tokenEndpoint: '', - userinfoEndpoint: '', - authorizationEndpoint: '', - clientId: '', - clientSecret: '', - additionalScopes: '', - }, - async onSubmit(values) { - const result = await mutate({ - input: { - organizationId: props.organizationId, - tokenEndpoint: values.tokenEndpoint, - userinfoEndpoint: values.userinfoEndpoint, - authorizationEndpoint: values.authorizationEndpoint, - clientId: values.clientId, - clientSecret: values.clientSecret, - additionalScopes: values.additionalScopes ? values.additionalScopes.split(' ') : [], - }, - }); - - if (result.error) { - // TODO handle unexpected error - alert(result.error); - return; - } - - if (result.data?.createOIDCIntegration.ok) { - props.transitionToManageScreen(); - } - }, - }); - - return ( -
- - Connect OpenID Connect Provider - - Connecting an OIDC provider to this organization allows users to automatically log in and - be part of this organization. - - - Use Okta, Auth0, Google Workspaces or any other OAuth2 Open ID Connect compatible - provider. - - -
-
- { - void formik.setFieldValue('tokenEndpoint', endpoints.token); - void formik.setFieldValue('userinfoEndpoint', endpoints.userinfo); - void formik.setFieldValue('authorizationEndpoint', endpoints.authorization); - }} - /> -
-
-
- - - - - {mutation.data?.createOIDCIntegration.error?.details.tokenEndpoint} - -
- -
- - - - {mutation.data?.createOIDCIntegration.error?.details.userinfoEndpoint} - -
- -
- - - - {mutation.data?.createOIDCIntegration.error?.details.authorizationEndpoint} - -
- -
- - - {mutation.data?.createOIDCIntegration.error?.details.clientId} -
- -
- - - - {mutation.data?.createOIDCIntegration.error?.details.clientSecret} - -
- -
- - - - {mutation.data?.createOIDCIntegration.error?.details.additionalScopes} - -
- -
- - -
-
-
-
- ); -} - -function ManageOIDCIntegrationModal(props: { - isOpen: boolean; - close: () => void; - organizationId: string; - isAdmin: boolean; - openCreateModalHash: string; - oidcIntegration: FragmentType | null; - memberRoles: Array> | null; - organization: DocumentType; -}) { - const oidcIntegration = useFragment( - UpdateOIDCIntegration_OIDCIntegrationFragment, - props.oidcIntegration, - ); - - if (oidcIntegration == null) { - return ( - - - - Manage OpenID Connect Integration - - You are trying to update an OpenID Connect integration for an organization that has no - integration. - - - - - - - - - ); - } - - if (!props.memberRoles) { - console.error('ManageOIDCIntegrationModal is missing member roles'); - return null; - } - - return ( - - ); -} - -const OIDCDefaultRoleSelector_MemberRoleFragment = graphql(` - fragment OIDCDefaultRoleSelector_MemberRoleFragment on MemberRole { - id - name - description - } -`); - -const OIDCDefaultRoleSelector_UpdateMutation = graphql(` - mutation OIDCDefaultRoleSelector_UpdateMutation($input: UpdateOIDCDefaultMemberRoleInput!) { - updateOIDCDefaultMemberRole(input: $input) { - ok { - updatedOIDCIntegration { - id - defaultMemberRole { - ...OIDCDefaultRoleSelector_MemberRoleFragment - } - } - } - error { - message - } - } - } -`); - -const OIDCDefaultResourceSelector_UpdateMutation = graphql(` - mutation OIDCDefaultResourceSelector_UpdateMutation( - $input: UpdateOIDCDefaultResourceAssignmentInput! - ) { - updateOIDCDefaultResourceAssignment(input: $input) { - ok { - updatedOIDCIntegration { - id - defaultResourceAssignment { - ...OIDCDefaultResourceSelector_ResourceAssignmentFragment - } - } - } - error { - message - } - } - } -`); - -function OIDCDefaultRoleSelector(props: { - oidcIntegrationId: string; - disabled: boolean; - defaultRole: FragmentType; - memberRoles: Array>; - className?: string; -}) { - const defaultRole = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.defaultRole); - const memberRoles = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.memberRoles); - const [_, mutate] = useMutation(OIDCDefaultRoleSelector_UpdateMutation); - const { toast } = useToast(); - - return ( - { - if (role.id === defaultRole.id) { - return; - } - - try { - const result = await mutate({ - input: { - oidcIntegrationId: props.oidcIntegrationId, - defaultMemberRoleId: role.id, - }, - }); - - if (result.data?.updateOIDCDefaultMemberRole.ok) { - toast({ - title: 'Default member role updated', - description: `${role.name} is now the default role for new OIDC members`, - }); - return; - } - - toast({ - title: 'Failed to update default member role', - description: - result.data?.updateOIDCDefaultMemberRole.error?.message ?? - result.error?.message ?? - 'Please try again later', - }); - } catch (error) { - toast({ - title: 'Failed to update default member role', - description: 'Please try again later', - variant: 'destructive', - }); - console.error(error); - } - }} - isRoleActive={_ => true} - /> - ); -} - -const OIDCDefaultResourceSelector_ResourceAssignmentFragment = graphql(` - fragment OIDCDefaultResourceSelector_ResourceAssignmentFragment on ResourceAssignment { - ...createResourceSelectionFromResourceAssignment_ResourceAssignmentFragment - } -`); - -function OIDCDefaultResourceSelector(props: { - disabled?: boolean; - oidcIntegrationId: string; - organization: DocumentType; - resourceAssignment: FragmentType; -}) { - const resourceAssignment = useFragment( - OIDCDefaultResourceSelector_ResourceAssignmentFragment, - props.resourceAssignment, - ); - const [_, mutate] = useMutation(OIDCDefaultResourceSelector_UpdateMutation); - const [selection, setSelection] = useState(() => - createResourceSelectionFromResourceAssignment(resourceAssignment), - ); - - const [mutateState, setMutateState] = useState(null); - const debouncedMutate = useDebouncedCallback( - async (args: Parameters[0]) => { - setMutateState('loading'); - await mutate(args) - .then(data => { - if (data.error) { - setMutateState('error'); - } else { - setMutateState('success'); - } - return data; - }) - .catch((err: unknown) => { - console.error(err); - setMutateState('error'); - }); - }, - 1500, - { leading: false }, - ); - - function MutateState() { - if (debouncedMutate.isPending() || mutateState === 'loading') { - return ; - } - - if (mutateState === 'error') { - return ; - } - - if (mutateState === 'success') { - return ; - } - - return null; - } - - const _setSelection = useCallback( - async (resources: ResourceSelection) => { - setSelection(resources); - await debouncedMutate({ - input: { - oidcIntegrationId: props.oidcIntegrationId, - resources: resourceSlectionToGraphQLSchemaResourceAssignmentInput(resources), - }, - }); - }, - [debouncedMutate, setSelection, props.oidcIntegrationId], - ); - - return ( -
- - void 0 : _setSelection} - organization={props.organization} - /> -
- ); -} - -const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(` - fragment UpdateOIDCIntegration_OIDCIntegrationFragment on OIDCIntegration { - id - tokenEndpoint - userinfoEndpoint - authorizationEndpoint - clientId - clientSecretPreview - additionalScopes - oidcUserJoinOnly - oidcUserAccessOnly - requireInvitation - defaultMemberRole { - id - ...OIDCDefaultRoleSelector_MemberRoleFragment - } - defaultResourceAssignment { - ...OIDCDefaultResourceSelector_ResourceAssignmentFragment - } - } -`); - -const UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation = graphql(` - mutation UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation( - $input: UpdateOIDCIntegrationInput! - ) { - updateOIDCIntegration(input: $input) { - ok { - updatedOIDCIntegration { - id - tokenEndpoint - userinfoEndpoint - authorizationEndpoint - clientId - clientSecretPreview - additionalScopes - } - } - error { - message - details { - clientId - clientSecret - tokenEndpoint - userinfoEndpoint - authorizationEndpoint - additionalScopes - } - } - } - } -`); - -const UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation = graphql(` - mutation UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation( - $input: UpdateOIDCRestrictionsInput! - ) { - updateOIDCRestrictions(input: $input) { - ok { - updatedOIDCIntegration { - id - oidcUserJoinOnly - oidcUserAccessOnly - requireInvitation - } - } - error { - message - } - } - } -`); - -function UpdateOIDCIntegrationForm(props: { - close: () => void; - isOpen: boolean; - oidcIntegration: DocumentType; - isAdmin: boolean; - memberRoles: Array>; - organization: DocumentType; -}): ReactElement { - const [oidcUpdateMutation, oidcUpdateMutate] = useMutation( - UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation, - ); - const [oidcRestrictionsMutation, oidcRestrictionsMutate] = useMutation( - UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation, - ); - const { toast } = useToast(); - - const formik = useFormik({ - initialValues: { - tokenEndpoint: props.oidcIntegration.tokenEndpoint, - userinfoEndpoint: props.oidcIntegration.userinfoEndpoint, - authorizationEndpoint: props.oidcIntegration.authorizationEndpoint, - clientId: props.oidcIntegration.clientId, - clientSecret: '', - additionalScopes: props.oidcIntegration.additionalScopes.join(' '), - }, - async onSubmit(values) { - const result = await oidcUpdateMutate({ - input: { - oidcIntegrationId: props.oidcIntegration.id, - tokenEndpoint: values.tokenEndpoint, - userinfoEndpoint: values.userinfoEndpoint, - authorizationEndpoint: values.authorizationEndpoint, - clientId: values.clientId, - clientSecret: values.clientSecret === '' ? undefined : values.clientSecret, - additionalScopes: values.additionalScopes ? values.additionalScopes.split(' ') : [], - }, - }); - - if (result.error) { - toast({ - title: 'Failed to update OIDC integration', - description: result.error.message, - variant: 'destructive', - }); - return; - } - - if (result.data?.updateOIDCIntegration.ok) { - props.close(); - } - }, - }); - - const onOidcRestrictionChange = async ( - name: 'oidcUserJoinOnly' | 'oidcUserAccessOnly' | 'requireInvitation', - value: boolean, - ) => { - if (oidcRestrictionsMutation.fetching) { - return; - } - - try { - toast({ - title: 'Updating OIDC restrictions...', - variant: 'default', - }); - const result = await oidcRestrictionsMutate({ - input: { - oidcIntegrationId: props.oidcIntegration.id, - [name]: value, - }, - }); - - if (result.data?.updateOIDCRestrictions.ok) { - toast({ - title: 'OIDC restrictions updated successfully', - description: { - oidcUserJoinOnly: value - ? 'Only OIDC users can now join the organization' - : 'Joining the organization is no longer restricted to OIDC users', - oidcUserAccessOnly: value - ? 'Only OIDC users can now access the organization' - : 'Access to the organization is no longer restricted to OIDC users', - requireInvitation: value - ? 'Only invited users can now access the organization.' - : 'Access to the organization is no longer restricted to invited users.', - }[name], - }); - } else { - toast({ - title: 'Failed to update OIDC restrictions', - description: result.data?.updateOIDCRestrictions.error?.message ?? result.error?.message, - variant: 'destructive', - }); - } - } catch (error) { - toast({ - title: 'Failed to update OIDC restrictions', - description: String(error), - variant: 'destructive', - }); - } - }; - - return ( - - -
-
-
-
-
-
-
OIDC Provider Instructions
-

- Configure your OIDC provider with the following settings -

-
-
-
- - -
-
- - -
-
- - -
-
-
- - - -
-
Other Options
-
-
-
-

Require OIDC to Join

-

- Restricts new accounts joining the organization to be authenticated via - OIDC. -
- - Existing non-OIDC members will keep their access. - -

-
- - onOidcRestrictionChange('oidcUserJoinOnly', checked) - } - disabled={oidcRestrictionsMutation.fetching} - /> -
-
-
-

Require OIDC to Access

-

- Prompt users to authenticate with OIDC before accessing the organization. -
- - Existing users without OIDC credentials will not be able to access the - organization. - -

-
- - onOidcRestrictionChange('oidcUserAccessOnly', checked) - } - disabled={oidcRestrictionsMutation.fetching} - /> -
-
-
-

Require Invitation to Join

-

- Restricts only invited OIDC accounts to join the organization. -

-
- - onOidcRestrictionChange('requireInvitation', checked) - } - disabled={oidcRestrictionsMutation.fetching} - /> -
-
-

Default Member Role

-
-
-

- This role is assigned to new members who sign in via OIDC.{' '} - - Only members with the Admin role can modify it. - -

-
-
- -
-
-
-
-
-
-
- -
-
-
Properties
-

- Configure your OIDC provider with the following settings -

-
- -
-
- - - - {oidcUpdateMutation.data?.updateOIDCIntegration.error?.details.tokenEndpoint} - -
- -
- - - - {oidcUpdateMutation.data?.updateOIDCIntegration.error?.details.userinfoEndpoint} - -
- -
- - - - { - oidcUpdateMutation.data?.updateOIDCIntegration.error?.details - .authorizationEndpoint - } - -
- -
- - - - {oidcUpdateMutation.data?.updateOIDCIntegration.error?.details.clientId} - -
- -
- - - - {oidcUpdateMutation.data?.updateOIDCIntegration.error?.details.clientSecret} - -
- -
- - - - {oidcUpdateMutation.data?.updateOIDCIntegration.error?.details.additionalScopes} - -
- -
- - -
-
-
-
-
-

Default Resource Assignments

-

- This permitted resources for new members who sign in via OIDC.{' '} - Only members with the Admin role can modify it. -

-
-
- -
-
-
-
- ); -} - -const RemoveOIDCIntegrationForm_DeleteOIDCIntegrationMutation = graphql(` - mutation RemoveOIDCIntegrationForm_DeleteOIDCIntegrationMutation( - $input: DeleteOIDCIntegrationInput! - ) { - deleteOIDCIntegration(input: $input) { - ok { - organization { - ...OIDCIntegrationSection_OrganizationFragment - } - } - error { - message - } - } - } -`); - -function RemoveOIDCIntegrationModal(props: { - isOpen: boolean; - close: () => void; - oidcIntegrationId: null | string; -}): ReactElement { - const [mutation, mutate] = useMutation(RemoveOIDCIntegrationForm_DeleteOIDCIntegrationMutation); - const { oidcIntegrationId } = props; - - return ( - - - - Remove OpenID Connect Integration - - {mutation.data?.deleteOIDCIntegration.ok ? ( - <> -

The OIDC integration has been removed successfully.

-
- -
- - ) : oidcIntegrationId === null ? ( - <> -

This organization does not have an OIDC integration.

-
- -
- - ) : ( - <> - - -

- This action is not reversible and deletes all users that have signed in with - this OIDC integration. -

-
-

Do you really want to proceed?

- -
- - -
- - )} -
-
- ); -} - -const SubscribeToOIDCIntegrationLogSubscription = graphql(` - subscription oidcProviderLog($oidcIntegrationId: ID!) { - oidcIntegrationLog(input: { oidcIntegrationId: $oidcIntegrationId }) { - timestamp - message - } - } -`); - -type OIDCLogEventType = DocumentType< - typeof SubscribeToOIDCIntegrationLogSubscription ->['oidcIntegrationLog']; - -function DebugOIDCIntegrationModal(props: { - isOpen: boolean; - close: () => void; - oidcIntegrationId: string; -}) { - const client = useClient(); - - const [isSubscribing, setIsSubscribing] = useResetState(true, [props.isOpen]); - - const [logs, setLogs] = useResetState>([], [props.isOpen]); - - useEffect(() => { - if (isSubscribing && props.oidcIntegrationId && props.isOpen) { - setLogs(logs => [ - ...logs, - { - __typename: 'OIDCIntegrationLogEvent', - timestamp: new Date().toISOString(), - message: 'Subscribing to logs...', - }, - ]); - const sub = client - .subscription(SubscribeToOIDCIntegrationLogSubscription, { - oidcIntegrationId: props.oidcIntegrationId, - }) - .subscribe(next => { - if (next.data?.oidcIntegrationLog) { - const log = next.data.oidcIntegrationLog; - setLogs(logs => [...logs, log]); - } - }); - - return () => { - setLogs(logs => [ - ...logs, - { - __typename: 'OIDCIntegrationLogEvent', - timestamp: new Date().toISOString(), - message: 'Stopped subscribing to logs...', - }, - ]); - - sub.unsubscribe(); - }; - } - }, [props.oidcIntegrationId, props.isOpen, isSubscribing]); - - return ( - - - - Debug OpenID Connect Integration - - Here you can listen to the live logs for debugging your OIDC integration. - - - - - - - - - - ); -} diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/connect-single-sign-on-provider-sheet.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/connect-single-sign-on-provider-sheet.tsx new file mode 100644 index 000000000..621555e48 --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/connect-single-sign-on-provider-sheet.tsx @@ -0,0 +1,474 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import * as Sheet from '@/components/ui/sheet'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useToast } from '@/components/ui/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation as useRQMutation } from '@tanstack/react-query'; + +type ConnectSingleSignOnProviderSheetProps = { + onClose: () => void; + initialValues: null | { + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecretPreview: string; + additionalScopes: string; + }; + onSave: (args: { + tokenEndpoint: string; + userinfoEndpoint: string; + authorizationEndpoint: string; + clientId: string; + clientSecret: null | string; + additionalScopes: string; + }) => Promise< + | { + type: 'success'; + } + | { + type: 'error'; + tokenEndpoint: string | null; + userinfoEndpoint: string | null; + authorizationEndpoint: string | null; + clientId: string | null; + clientSecret: string | null; + additionalScopes: string | null; + } + >; +}; + +export function ConnectSingleSignOnProviderSheet( + props: ConnectSingleSignOnProviderSheetProps, +): React.ReactNode { + const [state, setState] = useState<'discovery' | 'manual'>('discovery'); + const form = useForm({ + resolver: zodResolver(OIDCMetadataSchema), + defaultValues: { + authorization_endpoint: props.initialValues?.authorizationEndpoint ?? '', + token_endpoint: props.initialValues?.tokenEndpoint ?? '', + userinfo_endpoint: props.initialValues?.userinfoEndpoint ?? '', + clientId: props.initialValues?.clientId ?? '', + clientSecret: '', + additionalScopes: props.initialValues?.additionalScopes ?? '', + }, + mode: 'onSubmit', + }); + + async function onSubmit() { + const state = form.getValues(); + const result = await props.onSave({ + tokenEndpoint: state.token_endpoint, + userinfoEndpoint: state.userinfo_endpoint, + authorizationEndpoint: state.authorization_endpoint, + clientId: state.clientId, + clientSecret: props.initialValues?.clientSecretPreview + ? state.clientSecret || null + : state.clientSecret, + additionalScopes: state.additionalScopes, + }); + + if (result.type === 'success') { + props.onClose(); + return; + } + + if (result.additionalScopes) { + form.setError('additionalScopes', { + message: result.additionalScopes, + }); + } + + if (result.clientId) { + form.setError('clientId', { + message: result.clientId, + }); + } + if (result.clientSecret) { + form.setError('clientSecret', { + message: result.clientSecret, + }); + } + + if (result.authorizationEndpoint) { + form.setError('authorization_endpoint', { + message: result.authorizationEndpoint, + }); + } + + if (result.tokenEndpoint) { + form.setError('token_endpoint', { + message: result.tokenEndpoint, + }); + } + + if (result.userinfoEndpoint) { + form.setError('userinfo_endpoint', { + message: result.userinfoEndpoint, + }); + } + } + + const formNode = ( +
+ + { + return ( + + Authorization Endpoint + + + + + + ); + }} + /> + { + return ( + + Token Endpoint + + + + + + ); + }} + /> + { + return ( + + Userinfo Endpoint + + + + + + ); + }} + /> + { + return ( + + Client ID + + + + + + ); + }} + /> + { + return ( + + Client Secret + + + + + + ); + }} + /> + { + return ( + + Additional Scopes + + + + + + ); + }} + /> + + + ); + + return ( + + + + Connect OpenID Connect Provider + + Connecting an OIDC provider to this organization allows users to automatically log in + and be part of this organization. + + + Use Okta, Auth0, Google Workspaces or any other OAuth2 Open ID Connect compatible + provider. + + + + + setState('discovery')} + data-button-oidc-discovery + > + Discovery Document + + setState('manual')} + data-button-oidc-manual + > + Manual + + + + { + form.setValue('authorization_endpoint', args.authorization, { + shouldValidate: true, + }); + form.setValue('token_endpoint', args.token, { + shouldValidate: true, + }); + form.setValue('userinfo_endpoint', args.userinfo, { + shouldValidate: true, + }); + }} + /> + {formNode} + + + {formNode} + + + + + + + + + ); +} + +function OIDCMetadataFetcher(props: { + onEndpointChange(endpoints: { token: string; userinfo: string; authorization: string }): void; +}) { + const { toast } = useToast(); + + const fetchMetadata = useRQMutation({ + mutationFn: fetchOIDCMetadata, + onSuccess(data) { + if (!data.ok) { + toast({ + title: data.error.message, + description: ( +
+

Status: {data.error.details.status}

+

Response: {data.error.details.body ?? data.error.details.statusText}

+
+ ), + variant: 'destructive', + }); + return; + } + + const metadataResult = OIDCMetadataSchema.safeParse(data.metadata); + if (!metadataResult.success) { + toast({ + title: 'Failed to parse OIDC metadata', + description: ( + <> + {[ + metadataResult.error.formErrors.fieldErrors.authorization_endpoint?.[0], + metadataResult.error.formErrors.fieldErrors.token_endpoint?.[0], + metadataResult.error.formErrors.fieldErrors.userinfo_endpoint?.[0], + ] + .filter(Boolean) + .map((msg, i) => ( +

{msg}

+ ))} + + ), + variant: 'destructive', + }); + return; + } + + props.onEndpointChange({ + token: metadataResult.data.token_endpoint, + userinfo: metadataResult.data.userinfo_endpoint, + authorization: metadataResult.data.authorization_endpoint, + }); + }, + onError(error) { + console.error(error); + toast({ + title: 'Failed to fetch OIDC metadata', + description: 'Provide the endpoints manually or try again later', + variant: 'destructive', + }); + }, + }); + + function onSubmit(data: z.infer) { + fetchMetadata.mutate(data.url); + } + + const form = useForm({ + resolver: zodResolver(OIDCMetadataFormSchema), + defaultValues: { + url: '', + }, + mode: 'onSubmit', + }); + + return ( +
+ + { + return ( + +
+ + + + +
+ + Provide the OIDC metadata URL to automatically fill in the fields below. + + +
+ ); + }} + /> + + + ); +} + +const OIDCMetadataFormSchema = z.object({ + url: z.string().url('Must be a valid URL'), +}); + +async function fetchOIDCMetadata(url: string) { + const res = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + if (!res.ok) { + return { + ok: false, + error: { + message: 'Failed to fetch metadata', + details: { + url, + status: res.status, + statusText: res.statusText, + body: await res.text(), + }, + }, + } as const; + } + + return { + ok: true, + metadata: await res.json(), + } as const; +} + +const OIDCMetadataSchema = z.object({ + token_endpoint: z + .string({ + required_error: 'Token endpoint not found', + }) + .url('Token endpoint must be a valid URL'), + userinfo_endpoint: z + .string({ + required_error: 'Userinfo endpoint not found', + }) + .url('Userinfo endpoint must be a valid URL'), + authorization_endpoint: z + .string({ + required_error: 'Authorization endpoint not found', + }) + .url('Authorization endpoint must be a valid URL'), +}); diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/debug-oidc-integration-modal.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/debug-oidc-integration-modal.tsx new file mode 100644 index 000000000..93ca1e9fb --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/debug-oidc-integration-modal.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { useClient } from 'urql'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { VirtualLogList } from '@/components/ui/virtual-log-list'; +import { DocumentType, graphql } from '@/gql'; + +const SubscribeToOIDCIntegrationLogSubscription = graphql(` + subscription oidcProviderLog($oidcIntegrationId: ID!) { + oidcIntegrationLog(input: { oidcIntegrationId: $oidcIntegrationId }) { + timestamp + message + } + } +`); + +type OIDCLogEventType = DocumentType< + typeof SubscribeToOIDCIntegrationLogSubscription +>['oidcIntegrationLog']; + +export function DebugOIDCIntegrationModal(props: { close: () => void; oidcIntegrationId: string }) { + const client = useClient(); + + const [isSubscribing, setIsSubscribing] = useState(true); + + const [logs, setLogs] = useState>([]); + + useEffect(() => { + if (isSubscribing && props.oidcIntegrationId) { + setLogs(logs => [ + ...logs, + { + __typename: 'OIDCIntegrationLogEvent', + timestamp: new Date().toISOString(), + message: 'Subscribing to logs...', + }, + ]); + const sub = client + .subscription(SubscribeToOIDCIntegrationLogSubscription, { + oidcIntegrationId: props.oidcIntegrationId, + }) + .subscribe(next => { + if (next.data?.oidcIntegrationLog) { + const log = next.data.oidcIntegrationLog; + setLogs(logs => [...logs, log]); + } + }); + + return () => { + setLogs(logs => [ + ...logs, + { + __typename: 'OIDCIntegrationLogEvent', + timestamp: new Date().toISOString(), + message: 'Stopped subscribing to logs...', + }, + ]); + + sub.unsubscribe(); + }; + } + }, [props.oidcIntegrationId, isSubscribing]); + + return ( + + + + Debug OpenID Connect Integration + + Here you can see to the live logs of users attempting to sign in. It can help + identifying issues with the OpenID Connect configuration. + + + + + + + + + + ); +} diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-resource-selector.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-resource-selector.tsx new file mode 100644 index 000000000..0282e359d --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-resource-selector.tsx @@ -0,0 +1,127 @@ +import { useCallback, useState } from 'react'; +import { useMutation } from 'urql'; +import { useDebouncedCallback } from 'use-debounce'; +import { CheckIcon, XIcon } from '@/components/ui/icon'; +import { Spinner } from '@/components/ui/spinner'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { + createResourceSelectionFromResourceAssignment, + ResourceSelection, + ResourceSelector, + resourceSlectionToGraphQLSchemaResourceAssignmentInput, +} from '../../members/resource-selector'; + +const OIDCDefaultResourceSelector_UpdateMutation = graphql(` + mutation OIDCDefaultResourceSelector_UpdateMutation( + $input: UpdateOIDCDefaultResourceAssignmentInput! + ) { + updateOIDCDefaultResourceAssignment(input: $input) { + ok { + updatedOIDCIntegration { + id + defaultResourceAssignment { + ...OIDCDefaultResourceSelector_ResourceAssignmentFragment + } + } + } + error { + message + } + } + } +`); + +const OIDCDefaultResourceSelector_OrganizationFragment = graphql(` + fragment OIDCDefaultResourceSelector_OrganizationFragment on Organization { + id + ...ResourceSelector_OrganizationFragment + } +`); + +const OIDCDefaultResourceSelector_ResourceAssignmentFragment = graphql(` + fragment OIDCDefaultResourceSelector_ResourceAssignmentFragment on ResourceAssignment { + ...createResourceSelectionFromResourceAssignment_ResourceAssignmentFragment + } +`); + +export function OIDCDefaultResourceSelector(props: { + disabled?: boolean; + oidcIntegrationId: string; + organization: FragmentType; + resourceAssignment: FragmentType; +}) { + const organization = useFragment( + OIDCDefaultResourceSelector_OrganizationFragment, + props.organization, + ); + const resourceAssignment = useFragment( + OIDCDefaultResourceSelector_ResourceAssignmentFragment, + props.resourceAssignment, + ); + const [_, mutate] = useMutation(OIDCDefaultResourceSelector_UpdateMutation); + const [selection, setSelection] = useState(() => + createResourceSelectionFromResourceAssignment(resourceAssignment), + ); + + const [mutateState, setMutateState] = useState(null); + const debouncedMutate = useDebouncedCallback( + async (args: Parameters[0]) => { + setMutateState('loading'); + await mutate(args) + .then(data => { + if (data.error) { + setMutateState('error'); + } else { + setMutateState('success'); + } + return data; + }) + .catch((err: unknown) => { + console.error(err); + setMutateState('error'); + }); + }, + 1500, + { leading: false }, + ); + + function MutateState() { + if (debouncedMutate.isPending() || mutateState === 'loading') { + return ; + } + + if (mutateState === 'error') { + return ; + } + + if (mutateState === 'success') { + return ; + } + + return null; + } + + const _setSelection = useCallback( + async (resources: ResourceSelection) => { + setSelection(resources); + await debouncedMutate({ + input: { + oidcIntegrationId: props.oidcIntegrationId, + resources: resourceSlectionToGraphQLSchemaResourceAssignmentInput(resources), + }, + }); + }, + [debouncedMutate, setSelection, props.oidcIntegrationId], + ); + + return ( +
+ + void 0 : _setSelection} + organization={organization} + /> +
+ ); +} diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-role-selector.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-role-selector.tsx new file mode 100644 index 000000000..f110c9c05 --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-default-role-selector.tsx @@ -0,0 +1,90 @@ +import { useMutation } from 'urql'; +import { useToast } from '@/components/ui/use-toast'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { RoleSelector } from '../../members/common'; + +const OIDCDefaultRoleSelector_MemberRoleFragment = graphql(` + fragment OIDCDefaultRoleSelector_MemberRoleFragment on MemberRole { + id + name + description + } +`); + +const OIDCDefaultRoleSelector_UpdateMutation = graphql(` + mutation OIDCDefaultRoleSelector_UpdateMutation($input: UpdateOIDCDefaultMemberRoleInput!) { + updateOIDCDefaultMemberRole(input: $input) { + ok { + updatedOIDCIntegration { + id + defaultMemberRole { + ...OIDCDefaultRoleSelector_MemberRoleFragment + } + } + } + error { + message + } + } + } +`); + +export function OIDCDefaultRoleSelector(props: { + oidcIntegrationId: string; + disabled: boolean; + defaultRole: FragmentType; + memberRoles: Array>; + className?: string; +}) { + const defaultRole = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.defaultRole); + const memberRoles = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.memberRoles); + const [_, mutate] = useMutation(OIDCDefaultRoleSelector_UpdateMutation); + const { toast } = useToast(); + + return ( + { + if (role.id === defaultRole.id) { + return; + } + + try { + const result = await mutate({ + input: { + oidcIntegrationId: props.oidcIntegrationId, + defaultMemberRoleId: role.id, + }, + }); + + if (result.data?.updateOIDCDefaultMemberRole.ok) { + toast({ + title: 'Default member role updated', + description: `${role.name} is now the default role for new OIDC members`, + }); + return; + } + + toast({ + title: 'Failed to update default member role', + description: + result.data?.updateOIDCDefaultMemberRole.error?.message ?? + result.error?.message ?? + 'Please try again later', + }); + } catch (error) { + toast({ + title: 'Failed to update default member role', + description: 'Please try again later', + variant: 'destructive', + }); + console.error(error); + } + }} + isRoleActive={_ => true} + /> + ); +} diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/oidc-integration-configuration.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-integration-configuration.tsx new file mode 100644 index 000000000..3e8cb6d17 --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-integration-configuration.tsx @@ -0,0 +1,685 @@ +import { ReactElement, useState } from 'react'; +import { AlertOctagonIcon, BugPlayIcon, CheckIcon, PlusIcon, SettingsIcon } from 'lucide-react'; +import { useMutation } from 'urql'; +import { Button } from '@/components/ui/button'; +import { CopyIconButton } from '@/components/ui/copy-icon-button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Heading } from '@/components/ui/heading'; +import { Switch } from '@/components/ui/switch'; +import * as Table from '@/components/ui/table'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { useToast } from '@/components/ui/use-toast'; +import { Tag } from '@/components/v2'; +import { env } from '@/env/frontend'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { cn } from '@/lib/utils'; +import { ConnectSingleSignOnProviderSheet } from './connect-single-sign-on-provider-sheet'; +import { DebugOIDCIntegrationModal } from './debug-oidc-integration-modal'; +import { OIDCDefaultResourceSelector } from './oidc-default-resource-selector'; +import { OIDCDefaultRoleSelector } from './oidc-default-role-selector'; +import { OIDCRegisteredDomainSheet } from './oidc-registered-domain-sheet'; + +const UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation = graphql(` + mutation UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation( + $input: UpdateOIDCRestrictionsInput! + ) { + updateOIDCRestrictions(input: $input) { + ok { + updatedOIDCIntegration { + id + oidcUserJoinOnly + oidcUserAccessOnly + requireInvitation + } + } + error { + message + } + } + } +`); + +const UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation = graphql(` + mutation UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation( + $input: UpdateOIDCIntegrationInput! + ) { + updateOIDCIntegration(input: $input) { + ok { + updatedOIDCIntegration { + id + tokenEndpoint + userinfoEndpoint + authorizationEndpoint + clientId + clientSecretPreview + additionalScopes + } + } + error { + message + details { + clientId + clientSecret + tokenEndpoint + userinfoEndpoint + authorizationEndpoint + additionalScopes + } + } + } + } +`); + +const OIDCIntegrationConfiguration_OIDCIntegration = graphql(` + fragment OIDCIntegrationConfiguration_OIDCIntegration on OIDCIntegration { + id + oidcUserJoinOnly + oidcUserAccessOnly + requireInvitation + authorizationEndpoint + tokenEndpoint + userinfoEndpoint + clientId + clientSecretPreview + additionalScopes + defaultMemberRole { + id + ...OIDCDefaultRoleSelector_MemberRoleFragment + } + defaultResourceAssignment { + ...OIDCDefaultResourceSelector_ResourceAssignmentFragment + } + ...OIDCDomainConfiguration_OIDCIntegrationFragment + } +`); + +const OIDCIntegrationConfiguration_Organization = graphql(` + fragment OIDCIntegrationConfiguration_Organization on Organization { + id + me { + id + role { + id + name + } + } + memberRoles { + edges { + node { + id + ...OIDCDefaultRoleSelector_MemberRoleFragment + } + } + } + ...OIDCDefaultResourceSelector_OrganizationFragment + } +`); + +const enum ModalState { + closed, + openSettings, + openDelete, + openDebugLogs, + /** show confirmation dialog to ditch draft state of new access token */ + closing, +} + +export function OIDCIntegrationConfiguration(props: { + organization: FragmentType; + oidcIntegration: FragmentType; +}) { + const organization = useFragment(OIDCIntegrationConfiguration_Organization, props.organization); + const oidcIntegration = useFragment( + OIDCIntegrationConfiguration_OIDCIntegration, + props.oidcIntegration, + ); + const isAdmin = organization?.me?.role.name === 'Admin'; + const { toast } = useToast(); + const [oidcRestrictionsMutation, oidcRestrictionsMutate] = useMutation( + UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation, + ); + const [_, updateOIDCIntegrationMutate] = useMutation( + UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation, + ); + const [modalState, setModalState] = useState(ModalState.closed); + + const onOidcRestrictionChange = async ( + name: 'oidcUserJoinOnly' | 'oidcUserAccessOnly' | 'requireInvitation', + value: boolean, + ) => { + if (oidcRestrictionsMutation.fetching) { + return; + } + + try { + toast({ + title: 'Updating OIDC restrictions...', + variant: 'default', + }); + const result = await oidcRestrictionsMutate({ + input: { + oidcIntegrationId: oidcIntegration.id, + [name]: value, + }, + }); + + if (result.data?.updateOIDCRestrictions.ok) { + toast({ + title: 'OIDC restrictions updated successfully', + description: { + oidcUserJoinOnly: value + ? 'Only OIDC users can now join the organization' + : 'Joining the organization is no longer restricted to OIDC users', + oidcUserAccessOnly: value + ? 'Only OIDC users can now access the organization' + : 'Access to the organization is no longer restricted to OIDC users', + requireInvitation: value + ? 'Only invited users can now access the organization.' + : 'Access to the organization is no longer restricted to invited users.', + }[name], + }); + } else { + toast({ + title: 'Failed to update OIDC restrictions', + description: result.data?.updateOIDCRestrictions.error?.message ?? result.error?.message, + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: 'Failed to update OIDC restrictions', + description: String(error), + variant: 'destructive', + }); + } + }; + + return ( +
+
+
+ Overview + + + + + + Debug OIDC Integration + + +
+

Endpoints for configuring the OIDC provider.

+ + + + Endpoint + URL + + + + + Sign-in redirect URI + + {`${env.appBaseUrl}/auth/callback/oidc`}{' '} + + + + + Sign-out redirect URI + + {`${env.appBaseUrl}/logout`}{' '} + + + + + Sign-in URL + + {`${env.appBaseUrl}/auth/oidc?id=${oidcIntegration.id}`}{' '} + + + + + +
+
+
+ OIDC Configuration + + + + + + Update endpoint configuration + + +
+ + + + Configuration + Value + + + + + Authorization Endpoint + {oidcIntegration.authorizationEndpoint} + + + Token Endpoint + {oidcIntegration.tokenEndpoint} + + + User Info Endpoint + {oidcIntegration.userinfoEndpoint} + + + Client ID + {oidcIntegration.clientId} + + + Client Secret + + •••••••{oidcIntegration.clientSecretPreview} + + + + Additional Scopes + {oidcIntegration.additionalScopes.join(' ')} + + + +
+ +
+ Access Settings +
+
+
+

Require OIDC to Join

+

+ Restricts new accounts joining the organization to be authenticated via OIDC. +
+ Existing non-OIDC members will keep their access. +

+
+ onOidcRestrictionChange('oidcUserJoinOnly', checked)} + disabled={oidcRestrictionsMutation.fetching} + /> +
+
+
+

Require OIDC to Access

+

+ Prompt users to authenticate with OIDC before accessing the organization. +
+ + Existing users without OIDC credentials will not be able to access the + organization. + +

+
+ onOidcRestrictionChange('oidcUserAccessOnly', checked)} + disabled={oidcRestrictionsMutation.fetching} + /> +
+
+
+

Require Invitation to Join

+

+ Restricts only invited OIDC accounts to join the organization. +

+
+ onOidcRestrictionChange('requireInvitation', checked)} + disabled={oidcRestrictionsMutation.fetching} + /> +
+
+

Default Member Role

+
+
+

+ This role is assigned to new members who sign in via OIDC.{' '} + + Only members with the Admin role can modify it. + +

+
+
+ edge.node) ?? []} + /> +
+
+
+
+
+ +
+
+
+ Remove OIDC Provider +

Completly disconnect the OIDC provider and all configuration.

+ +
+ {modalState === ModalState.openSettings && ( + setModalState(ModalState.closed)} + initialValues={{ + additionalScopes: oidcIntegration.additionalScopes.join(' '), + clientId: oidcIntegration.clientId, + authorizationEndpoint: oidcIntegration.authorizationEndpoint, + tokenEndpoint: oidcIntegration.tokenEndpoint, + userinfoEndpoint: oidcIntegration.userinfoEndpoint, + clientSecretPreview: oidcIntegration.clientSecretPreview, + }} + onSave={async args => { + const result = await updateOIDCIntegrationMutate({ + input: { + oidcIntegrationId: oidcIntegration.id, + clientId: args.clientId || undefined, + clientSecret: args.clientSecret || undefined, + additionalScopes: args.additionalScopes?.trim() + ? args.additionalScopes.trim().split(' ') + : undefined, + authorizationEndpoint: args.authorizationEndpoint || undefined, + tokenEndpoint: args.tokenEndpoint || undefined, + userinfoEndpoint: args.userinfoEndpoint || undefined, + }, + }); + + if (result.data?.updateOIDCIntegration.error) { + const { error } = result.data.updateOIDCIntegration; + + return { + type: 'error', + clientId: error.details.clientId ?? null, + clientSecret: error.details.clientSecret ?? null, + authorizationEndpoint: error.details.authorizationEndpoint ?? null, + userinfoEndpoint: error.details.userinfoEndpoint ?? null, + tokenEndpoint: error.details.tokenEndpoint ?? null, + additionalScopes: error.details.additionalScopes ?? null, + }; + } + + toast({ + variant: 'default', + title: 'Updated OIDC Configuration', + }); + + return { + type: 'success', + }; + }} + /> + )} + {modalState === ModalState.openDelete && ( + setModalState(ModalState.closed)} + oidcIntegrationId={oidcIntegration.id} + /> + )} + {modalState === ModalState.openDebugLogs && ( + setModalState(ModalState.closed)} + oidcIntegrationId={oidcIntegration.id} + /> + )} +
+ ); +} + +const OIDCDomainConfiguration_OIDCIntegrationFragment = graphql(` + fragment OIDCDomainConfiguration_OIDCIntegrationFragment on OIDCIntegration { + id + registeredDomains { + id + domainName + createdAt + verifiedAt + ...OIDCRegisteredDomainSheet_RegisteredDomain + } + } +`); + +function OIDCDomainConfiguration(props: { + oidcIntegration: FragmentType; +}) { + const oidcIntegration = useFragment( + OIDCDomainConfiguration_OIDCIntegrationFragment, + props.oidcIntegration, + ); + + const [state, setState] = useState( + null as + | null + | { + type: 'create'; + } + | { + type: 'manage'; + domainId: string; + }, + ); + + return ( +
+
+ Registered Domains + + + + + + Add new domain + + +
+

Verify domain ownership to skip mandatory email confirmation for organization members.

+ + + + Domain + Status + + + + + {oidcIntegration.registeredDomains.map(domain => ( + + + {domain.domainName} + + + {domain.verifiedAt ? ( + <> + Verified + + ) : ( + + + + Pending + + + The domain ownership challenge has not been completed. + + + + )} + + + + + + Manage + + + + + ))} + + {oidcIntegration.registeredDomains.length === 0 && ( + No Domains registered + )} + + {state && ( + domain.id === state.domainId) + : null) ?? null + } + onClose={() => setState(null)} + onRegisterDomainSuccess={domainId => + setState({ + type: 'manage', + domainId, + }) + } + /> + )} +
+ ); +} + +const RemoveOIDCIntegrationModal_DeleteOIDCIntegrationMutation = graphql(` + mutation RemoveOIDCIntegrationModal_DeleteOIDCIntegrationMutation( + $input: DeleteOIDCIntegrationInput! + ) { + deleteOIDCIntegration(input: $input) { + ok { + organization { + id + oidcIntegration { + id + } + } + } + error { + message + } + } + } +`); + +function RemoveOIDCIntegrationModal(props: { + close: () => void; + oidcIntegrationId: null | string; +}): ReactElement { + const [mutation, mutate] = useMutation(RemoveOIDCIntegrationModal_DeleteOIDCIntegrationMutation); + const { oidcIntegrationId } = props; + + return ( + + + + Remove OpenID Connect Integration + + {mutation.data?.deleteOIDCIntegration.ok ? ( + <> +

The OIDC integration has been removed successfully.

+
+ +
+ + ) : oidcIntegrationId === null ? ( + <> +

This organization does not have an OIDC integration.

+
+ +
+ + ) : ( + <> + +

+ This action is not reversible and revoke access to all users that have signed in + with this OIDC integration. +

+
+

Do you really want to proceed?

+ +
+ + +
+ + )} +
+
+ ); +} diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/oidc-registered-domain-sheet.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-registered-domain-sheet.tsx new file mode 100644 index 000000000..cae4f7271 --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/oidc-registered-domain-sheet.tsx @@ -0,0 +1,496 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useMutation } from 'urql'; +import z from 'zod'; +import * as AlertDialog from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { CopyIconButton } from '@/components/ui/copy-icon-button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import * as Sheet from '@/components/ui/sheet'; +import { defineStepper } from '@/components/ui/stepper'; +import * as Table from '@/components/ui/table'; +import { useToast } from '@/components/ui/use-toast'; +import { Tag } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { cn } from '@/lib/utils'; +import { zodResolver } from '@hookform/resolvers/zod'; + +const OIDCRegisteredDomainSheet_RegisteredDomain = graphql(` + fragment OIDCRegisteredDomainSheet_RegisteredDomain on OIDCIntegrationDomain { + id + domainName + createdAt + verifiedAt + challenge { + recordValue + recordName + recordType + } + } +`); + +const OIDCRegisteredDomainSheet_RegisterDomainMutation = graphql(` + mutation OIDCRegisteredDomainSheet_RegisterDomainMutation($input: RegisterOIDCDomainInput!) { + registerOIDCDomain(input: $input) { + ok { + createdOIDCIntegrationDomain { + id + ...OIDCRegisteredDomainSheet_RegisteredDomain + } + oidcIntegration { + ...OIDCDomainConfiguration_OIDCIntegrationFragment + } + } + error { + message + } + } + } +`); + +const OIDCRegisteredDomainSheet_VerifyDomainMutation = graphql(` + mutation OIDCRegisteredDomainSheet_VerifyDomainMutation($input: VerifyOIDCDomainChallengeInput!) { + verifyOIDCDomainChallenge(input: $input) { + ok { + verifiedOIDCIntegrationDomain { + ...OIDCRegisteredDomainSheet_RegisteredDomain + } + } + error { + message + } + } + } +`); + +const OIDCRegisteredDomainSheet_RequestDomainChallengeMutation = graphql(` + mutation OIDCRegisteredDomainSheet_RequestDomainChallengeMutation( + $input: RequestOIDCDomainChallengeInput! + ) { + requestOIDCDomainChallenge(input: $input) { + ok { + oidcIntegrationDomain { + ...OIDCRegisteredDomainSheet_RegisteredDomain + } + } + error { + message + } + } + } +`); + +const OIDCRegisteredDomainSheet_DeleteDomainMutation = graphql(` + mutation OIDCRegisteredDomainSheet_DeleteDomainMutation($input: DeleteOIDCDomainInput!) { + deleteOIDCDomain(input: $input) { + ok { + deletedOIDCIntegrationId + oidcIntegration { + ...OIDCDomainConfiguration_OIDCIntegrationFragment + } + } + error { + message + } + } + } +`); + +const FQDNModel = z + .string() + .min(3, 'Must be at least 3 characters long') + .max(255, 'Must be at most 255 characters long.') + .regex(/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]+$/, 'Invalid domain provided.'); + +const RegisterDomainFormSchema = z.object({ + domainName: FQDNModel, +}); + +export function OIDCRegisteredDomainSheet(props: { + onClose: () => void; + onRegisterDomainSuccess: (domainId: string) => void; + domain: null | FragmentType; + oidcIntegrationId: string; +}): React.ReactElement { + const domain = useFragment(OIDCRegisteredDomainSheet_RegisteredDomain, props.domain); + // track whether we arer in the process of a verification + // eslint-disable-next-line react/hook-use-state + const [isInStepperProcess] = useState(!domain?.verifiedAt); + + const [registerDomainMutationState, registerDomainMutation] = useMutation( + OIDCRegisteredDomainSheet_RegisterDomainMutation, + ); + const [verifyDomainMutationState, verifyDomainMutation] = useMutation( + OIDCRegisteredDomainSheet_VerifyDomainMutation, + ); + const [deleteDomainMutationState, deleteDomainMutation] = useMutation( + OIDCRegisteredDomainSheet_DeleteDomainMutation, + ); + const [requestDomainChallengeMutationState, requestDomainChallengeMutation] = useMutation( + OIDCRegisteredDomainSheet_RequestDomainChallengeMutation, + ); + + const { toast } = useToast(); + const form = useForm({ + resolver: zodResolver(RegisterDomainFormSchema), + defaultValues: { + domainName: '', + }, + mode: 'onSubmit', + }); + + async function onCreateDomain() { + const result = await registerDomainMutation({ + input: { + oidcIntegrationId: props.oidcIntegrationId, + domainName: form.getValues().domainName, + }, + }); + + if (result.error) { + toast({ + variant: 'destructive', + title: 'Error', + description: result.error.message, + }); + return; + } + + if (result.data?.registerOIDCDomain.error) { + form.setError('domainName', { + message: result.data.registerOIDCDomain.error.message, + }); + return; + } + + if (result.data?.registerOIDCDomain.ok) { + props.onRegisterDomainSuccess( + result.data.registerOIDCDomain.ok.createdOIDCIntegrationDomain.id, + ); + } + } + + async function onVerifyDomain(onSuccess: () => void) { + if (!domain) { + return; + } + + const result = await verifyDomainMutation({ + input: { + oidcDomainId: domain.id, + }, + }); + + if (result.error) { + return; + } + + if (result.data?.verifyOIDCDomainChallenge.error) { + return; + } + + onSuccess(); + } + + const [showDeleteDomainConfirmation, setShowDeleteDomainConfirmation] = useState(false); + + async function onDeleteDomain() { + if (!domain) { + return; + } + + const result = await deleteDomainMutation({ + input: { + oidcDomainId: domain.id, + }, + }); + + if (result.error) { + return; + } + + if (result.data?.deleteOIDCDomain.error) { + return; + } + + toast({ + variant: 'default', + title: `Domain '${domain.domainName}' was removed.`, + }); + props.onClose(); + } + + // eslint-disable-next-line react/hook-use-state + const [Stepper] = useState(() => + defineStepper( + { + id: 'step-1-general', + title: 'Register Domain', + }, + { + id: 'step-2-challenge', + title: 'Verify Domain Ownership', + }, + { + id: 'step-3-complete', + title: 'Complete', + }, + ), + ); + + return ( + <> + + + + {({ stepper }) => ( + <> + + + {isInStepperProcess ? stepper.current.title : 'Domain Settings'}{' '} + {domain?.domainName && ( + {domain?.domainName} + )} + + + {isInStepperProcess && ( + + {stepper.all.map(step => ( + + {step.title} + + ))} + + )} + {stepper.switch({ + 'step-1-general': () => ( +
+ + { + return ( + + Domain Name + + + + + The domain you want to register with this OIDC provider. + + + + ); + }} + /> + + + ), + 'step-2-challenge': () => ( + <> +

+ In order to prove the ownership of the domain we have to perform a DNS + challenge. +

+

Within your hosted zone create the following DNS record.

+ + + + Property + Value + + + + + Type + + {domain?.challenge?.recordType}{' '} + + + + + Name + + {domain?.challenge?.recordName}{' '} + + + + + Value + + {domain?.challenge?.recordValue} + + + + + + {domain && !domain.challenge && ( + <> + +

This challenge has expired.

+
+
+ {requestDomainChallengeMutationState.error?.message ?? + requestDomainChallengeMutationState.data?.requestOIDCDomainChallenge + .error?.message} +
+ + + )} + + ), + 'step-3-complete': () => ( + <> +

+ This domain was successfully verified. Users logging in with that email do + not need to confirm their email. +

+ + ), + })} + + {stepper.switch({ + 'step-1-general': () => ( + <> + + + + ), + 'step-2-challenge': () => ( + <> +
+ {deleteDomainMutationState.error?.message ?? + deleteDomainMutationState.data?.deleteOIDCDomain.error?.message ?? + verifyDomainMutationState.error?.message ?? + verifyDomainMutationState.data?.verifyOIDCDomainChallenge.error + ?.message} +
+ + + + + ), + 'step-3-complete': () => ( + <> + + + + ), + })} +
+ + )} +
+
+
+ {showDeleteDomainConfirmation && ( + setShowDeleteDomainConfirmation(false)} + onConfirm={onDeleteDomain} + /> + )} + + ); +} + +function DeleteDomainConfirmationDialogue(props: { onClose: () => void; onConfirm: () => void }) { + return ( + + + + + Do you want to delete this domain? + + + + + Cancel + + + Delete Domain + + + + + ); +} diff --git a/packages/web/app/src/components/organization/settings/single-sign-on/single-sign-on-subpage.tsx b/packages/web/app/src/components/organization/settings/single-sign-on/single-sign-on-subpage.tsx new file mode 100644 index 000000000..27440d1a1 --- /dev/null +++ b/packages/web/app/src/components/organization/settings/single-sign-on/single-sign-on-subpage.tsx @@ -0,0 +1,224 @@ +import { useState } from 'react'; +import { useMutation, useQuery } from 'urql'; +import { Button } from '@/components/ui/button'; +import { CardDescription } from '@/components/ui/card'; +import { DocsLink } from '@/components/ui/docs-note'; +import { KeyIcon } from '@/components/ui/icon'; +import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useToast } from '@/components/ui/use-toast'; +import { graphql } from '@/gql'; +import { ConnectSingleSignOnProviderSheet } from './connect-single-sign-on-provider-sheet'; +import { OIDCIntegrationConfiguration } from './oidc-integration-configuration'; + +type SingleSignOnSubPageProps = { + organizationSlug: string; +}; + +const SingleSignOnSubpageQuery = graphql(` + query SingleSignOnSubpageQuery($organizationSlug: String!) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + oidcIntegration { + __typename + id + ...OIDCIntegrationConfiguration_OIDCIntegration + } + ...OIDCIntegrationConfiguration_Organization + } + } +`); + +const SingleSignOnSubpage_CreateOIDCIntegrationMutation = graphql(` + mutation SingleSignOnSubpage_CreateOIDCIntegrationMutation($input: CreateOIDCIntegrationInput!) { + createOIDCIntegration(input: $input) { + ok { + organization { + id + oidcIntegration { + ...OIDCIntegrationConfiguration_OIDCIntegration + } + } + } + error { + message + details { + clientId + clientSecret + tokenEndpoint + userinfoEndpoint + authorizationEndpoint + additionalScopes + } + } + } + } +`); + +const enum ConnectSingleSignOnProviderState { + closed, + open, + /** show confirmation dialog to ditch draft state of new access token */ + closing, +} + +export function SingleSignOnSubpage(props: SingleSignOnSubPageProps): React.ReactNode { + const [query] = useQuery({ + query: SingleSignOnSubpageQuery, + variables: { + organizationSlug: props.organizationSlug, + }, + requestPolicy: 'network-only', + }); + const { toast } = useToast(); + const [_, mutate] = useMutation(SingleSignOnSubpage_CreateOIDCIntegrationMutation); + + const [modalState, setModalState] = useState(ConnectSingleSignOnProviderState.closed); + + const organization = query.data?.organization; + const oidcIntegration = organization?.oidcIntegration; + + return ( + + + + Link your Hive organization to a single-sign-on provider such as Okta or Microsoft + Entra ID via OpenID Connect. + + + + Instructions for connecting your provider. + + + + } + /> +
+ {query.fetching ? ( + + ) : oidcIntegration ? ( + + ) : ( + <> + +

Your organization has currently no Open ID Connect provider configured.

+ {modalState === ConnectSingleSignOnProviderState.open && ( + setModalState(ConnectSingleSignOnProviderState.closed)} + initialValues={null} + onSave={async values => { + const result = await mutate({ + input: { + organizationId: organization?.id ?? '', + clientId: values.clientId, + clientSecret: values.clientSecret ?? '', + authorizationEndpoint: values.authorizationEndpoint, + tokenEndpoint: values.tokenEndpoint, + userinfoEndpoint: values.userinfoEndpoint, + additionalScopes: + values.additionalScopes.trim() === '' + ? [] + : values.additionalScopes.trim().split(' '), + }, + }); + + if (result.data?.createOIDCIntegration.error) { + const { error } = result.data.createOIDCIntegration; + return { + type: 'error', + clientId: error.details.clientId ?? null, + clientSecret: error.details.clientSecret ?? null, + authorizationEndpoint: error.details.authorizationEndpoint ?? null, + userinfoEndpoint: error.details.userinfoEndpoint ?? null, + tokenEndpoint: error.details.tokenEndpoint ?? null, + additionalScopes: error.details.additionalScopes ?? null, + }; + } + + toast({ + variant: 'default', + title: 'Set up OIDC provider.', + }); + + return { + type: 'success', + }; + }} + /> + )} + + )} +
+
+ ); +} + +function LoadingSkeleton() { + return ( + <> + {/* Overview Section */} +
+ + +
+ {[...Array(3)].map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* OIDC Configuration Section */} +
+ +
+ {[...Array(6)].map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Registered Domains Section */} +
+ + +
+ + +
+
+ + {/* Access Settings Section */} +
+ + {[...Array(4)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ + ); +} diff --git a/packages/web/app/src/components/ui/copy-icon-button.tsx b/packages/web/app/src/components/ui/copy-icon-button.tsx new file mode 100644 index 000000000..9959c48d8 --- /dev/null +++ b/packages/web/app/src/components/ui/copy-icon-button.tsx @@ -0,0 +1,30 @@ +import { CopyIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useClipboard } from '@/lib/hooks'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'; + +type CopyIconButtonProps = { + label: string; + value: string; +}; + +export function CopyIconButton(props: CopyIconButtonProps) { + const clipboard = useClipboard(); + return ( + + + + + + {props.label} + + + ); +} diff --git a/packages/web/app/src/pages/auth-verify-email.tsx b/packages/web/app/src/pages/auth-verify-email.tsx index dcb231200..f446c0c14 100644 --- a/packages/web/app/src/pages/auth-verify-email.tsx +++ b/packages/web/app/src/pages/auth-verify-email.tsx @@ -143,7 +143,9 @@ function AuthVerifyEmail() { diff --git a/packages/web/app/src/pages/organization-settings.tsx b/packages/web/app/src/pages/organization-settings.tsx index c7fdacec0..1eaa6b429 100644 --- a/packages/web/app/src/pages/organization-settings.tsx +++ b/packages/web/app/src/pages/organization-settings.tsx @@ -7,8 +7,8 @@ import { Checkbox } from '@/components/base/checkbox/checkbox'; import { OrganizationLayout, Page } from '@/components/layouts/organization'; import { SubPageNavigationLink } from '@/components/navigation/sub-page-navigation-link'; import { AccessTokensSubPage } from '@/components/organization/settings/access-tokens/access-tokens-sub-page'; -import { OIDCIntegrationSection } from '@/components/organization/settings/oidc-integration-section'; import { PersonalAccessTokensSubPage } from '@/components/organization/settings/personal-access-tokens/personal-access-tokens-sub-page'; +import { SingleSignOnSubpage } from '@/components/organization/settings/single-sign-on/single-sign-on-subpage'; import { PolicySettings } from '@/components/policy/policy-settings'; import { Button } from '@/components/ui/button'; import { CardDescription } from '@/components/ui/card'; @@ -315,33 +315,6 @@ const OrganizationSettingsContent = (props: { )} - {organization.viewerCanManageOIDCIntegration && ( - - - - Link your Hive organization to a single-sign-on provider such as Okta or Microsoft - Entra ID via OpenID Connect. - - - - Instructions for connecting your provider. - - - - } - /> -
- -
-
- )} - {organization.viewerCanModifySlackIntegration && ( { return ( { @@ -737,6 +720,9 @@ function SettingsPageContent(props: { organization={currentOrganization} /> ) : null} + {resolvedPage.key === 'sso' ? ( + + ) : null} {resolvedPage.key === 'policy' ? ( ) : null} diff --git a/packages/web/app/src/pages/target-trace.tsx b/packages/web/app/src/pages/target-trace.tsx index 2a9aeeac5..6dfe3f9dd 100644 --- a/packages/web/app/src/pages/target-trace.tsx +++ b/packages/web/app/src/pages/target-trace.tsx @@ -14,7 +14,6 @@ import { ChevronDown, ChevronUp, Clock, - CopyIcon, Link as LinkLucide, PieChart, Play, @@ -28,6 +27,7 @@ import { Page, TargetLayout } from '@/components/layouts/target'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { CardDescription } from '@/components/ui/card'; +import { CopyIconButton } from '@/components/ui/copy-icon-button'; import { Meta } from '@/components/ui/meta'; import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; @@ -1740,32 +1740,6 @@ function AttributeRow(props: AttributeRowProps) { ); } -type CopyIconButtonProps = { - label: string; - value: string; -}; - -export function CopyIconButton(props: CopyIconButtonProps) { - const clipboard = useClipboard(); - return ( - - - - - - {props.label} - {' '} - - ); -} - function ExceptionTeaser(props: { name: string; message: string; diff --git a/packages/web/app/src/pages/target-traces.tsx b/packages/web/app/src/pages/target-traces.tsx index fbbefdd2c..1d0f405e9 100644 --- a/packages/web/app/src/pages/target-traces.tsx +++ b/packages/web/app/src/pages/target-traces.tsx @@ -32,6 +32,7 @@ import { ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart'; +import { CopyIconButton } from '@/components/ui/copy-icon-button'; import { DateRangePicker, Preset, presetLast7Days } from '@/components/ui/date-range-picker'; import { Meta } from '@/components/ui/meta'; import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; @@ -74,11 +75,7 @@ import { useReactTable, } from '@tanstack/react-table'; import * as GraphQLSchema from '../gql/graphql'; -import { - CopyIconButton, - formatNanoseconds, - TraceSheet as ImportedTraceSheet, -} from './target-trace'; +import { formatNanoseconds, TraceSheet as ImportedTraceSheet } from './target-trace'; import { DurationFilter, MultiInputFilter, MultiSelectFilter } from './traces/target-traces-filter'; const chartConfig = {