mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: oidc domain registration and verification (#7745)
This commit is contained in:
parent
42bc3a0f1f
commit
33bff41342
44 changed files with 3654 additions and 1782 deletions
6
.changeset/smooth-turtles-arrive.md
Normal file
6
.changeset/smooth-turtles-arrive.md
Normal file
|
|
@ -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.
|
||||
7
.github/workflows/tests-e2e.yaml
vendored
7
.github/workflows/tests-e2e.yaml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,32 +15,45 @@ namespace Cypress {
|
|||
}): Chainable;
|
||||
login(data: { email: string; password: string }): Chainable;
|
||||
dataCy<Node = HTMLElement>(name: string): Chainable<JQuery<Node>>;
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof createDbConnection>;
|
||||
|
||||
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<string>(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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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'),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<SuperTokensCookieBasedSession> {
|
||||
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<SuperTokensCooki
|
|||
organizationMembers: OrganizationMembers;
|
||||
emailVerification: EmailVerification | null;
|
||||
accessTokenKey: AccessTokenKeyContainer | null;
|
||||
oidcIntegrationStore: OIDCIntegrationStore;
|
||||
}) {
|
||||
super();
|
||||
this.logger = deps.logger.child({ module: 'SuperTokensUserAuthNStrategy' });
|
||||
this.organizationMembers = deps.organizationMembers;
|
||||
this.storage = deps.storage;
|
||||
this.emailVerification = deps.emailVerification;
|
||||
this.supertokensStore = new SuperTokensStore(deps.storage.pool, deps.logger);
|
||||
this.accessTokenKey = deps.accessTokenKey;
|
||||
this.oidcIntegrationStore = deps.oidcIntegrationStore;
|
||||
}
|
||||
|
||||
private async _verifySuperTokensCoreSession(args: { req: FastifyRequest; reply: FastifyReply }) {
|
||||
private async _verifySuperTokensCoreSession(args: {
|
||||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
}): Promise<SuperTokensSessionPayloadV2 | null> {
|
||||
let session: SessionNode.SessionContainer | undefined;
|
||||
|
||||
try {
|
||||
|
|
@ -196,9 +201,9 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
antiCsrfCheck: false,
|
||||
checkDatabase: true,
|
||||
});
|
||||
this.logger.debug('Session resolution ended successfully');
|
||||
args.req.log.debug('Session resolution ended successfully');
|
||||
} catch (error) {
|
||||
this.logger.debug('Session resolution failed');
|
||||
args.req.log.debug('Session resolution failed');
|
||||
if (SessionNode.Error.isErrorFromSuperTokens(error)) {
|
||||
if (
|
||||
error.type === SessionNode.Error.TRY_REFRESH_TOKEN ||
|
||||
|
|
@ -212,7 +217,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
}
|
||||
}
|
||||
|
||||
this.logger.error('Error while resolving user');
|
||||
args.req.log.error('Error while resolving user');
|
||||
console.log(error);
|
||||
captureException(error);
|
||||
|
||||
|
|
@ -220,7 +225,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
}
|
||||
|
||||
if (!session) {
|
||||
this.logger.debug('No session found');
|
||||
args.req.log.debug('No session found');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -229,9 +234,9 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
const result = SuperTokensSessionPayloadModel.safeParse(payload);
|
||||
|
||||
if (result.success === false) {
|
||||
this.logger.error('SuperTokens session payload is invalid');
|
||||
this.logger.debug('SuperTokens session payload: %s', JSON.stringify(payload));
|
||||
this.logger.debug(
|
||||
args.req.log.error('SuperTokens session payload is invalid');
|
||||
args.req.log.debug('SuperTokens session payload: %s', JSON.stringify(payload));
|
||||
args.req.log.debug(
|
||||
'SuperTokens session parsing errors: %s',
|
||||
JSON.stringify(result.error.flatten().fieldErrors),
|
||||
);
|
||||
|
|
@ -242,6 +247,15 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
});
|
||||
}
|
||||
|
||||
if (result.data.version === '1') {
|
||||
args.req.log.debug('legacy session detected, require session refresh');
|
||||
throw new HiveError('Invalid session.', {
|
||||
extensions: {
|
||||
code: 'NEEDS_REFRESH',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +265,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
reply: FastifyReply;
|
||||
},
|
||||
accessTokenKey: AccessTokenKeyContainer,
|
||||
) {
|
||||
): Promise<SuperTokensSessionPayloadV2 | null> {
|
||||
let session: SessionInfo | null = null;
|
||||
|
||||
args.req.log.debug('attempt parsing access token from cookie');
|
||||
|
|
@ -273,7 +287,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
try {
|
||||
accessToken = parseAccessToken(rawAccessToken, accessTokenKey.publicKey);
|
||||
} catch (err) {
|
||||
args.req.log.debug('Failed verifying the access token. Ask for refresh.');
|
||||
args.req.log.debug('Failed verifying the access token. Ask for refresh. err=%s', String(err));
|
||||
throw new HiveError('Invalid session', {
|
||||
extensions: {
|
||||
code: 'NEEDS_REFRESH',
|
||||
|
|
@ -315,7 +329,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
);
|
||||
|
||||
// old access token in use, there was alreadya refresh
|
||||
throw new HiveError('Invalid session.', {
|
||||
throw new HiveError('Expired session.', {
|
||||
extensions: {
|
||||
code: 'NEEDS_REFRESH',
|
||||
},
|
||||
|
|
@ -325,9 +339,9 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
const result = SuperTokensSessionPayloadModel.safeParse(JSON.parse(session.sessionData));
|
||||
|
||||
if (result.success === false) {
|
||||
this.logger.error('SuperTokens session payload is invalid');
|
||||
this.logger.debug('SuperTokens session payload: %s', session.sessionData);
|
||||
this.logger.debug(
|
||||
args.req.log.error('SuperTokens session payload is invalid');
|
||||
args.req.log.debug('SuperTokens session payload: %s', session.sessionData);
|
||||
args.req.log.debug(
|
||||
'SuperTokens session parsing errors: %s',
|
||||
JSON.stringify(result.error.flatten().fieldErrors),
|
||||
);
|
||||
|
|
@ -338,17 +352,50 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
});
|
||||
}
|
||||
|
||||
if (result.data.version === '1') {
|
||||
throw new HiveError('Expired session.', {
|
||||
extensions: {
|
||||
code: 'NEEDS_REFRESH',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private async requireEmailVerification(
|
||||
sessionData: SuperTokensSessionPayloadV2,
|
||||
logger: Logger,
|
||||
): Promise<boolean> {
|
||||
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<SuperTokensSessionPayload | null> {
|
||||
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<SuperTokensCooki
|
|||
: await this._verifySuperTokensCoreSession(args);
|
||||
|
||||
if (!sessionData) {
|
||||
this.logger.debug('No session found');
|
||||
args.req.log.debug('No session found');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.emailVerification) {
|
||||
if (
|
||||
this.emailVerification &&
|
||||
(await this.requireEmailVerification(sessionData, args.req.log))
|
||||
) {
|
||||
args.req.log.debug('check if email is verified');
|
||||
// Check whether the email is already verified.
|
||||
// If it is not then we need to redirect to the email verification page - which will trigger the email sending.
|
||||
const { verified } = await this.emailVerification.checkUserEmailVerified({
|
||||
|
|
@ -370,6 +421,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
});
|
||||
|
||||
if (!verified) {
|
||||
args.req.log.debug('email is not yet verified');
|
||||
throw new HiveError('Your account is not verified. Please verify your email address.', {
|
||||
extensions: {
|
||||
code: 'VERIFY_EMAIL',
|
||||
|
|
@ -378,7 +430,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
}
|
||||
}
|
||||
|
||||
this.logger.debug('SuperTokens session resolved.');
|
||||
args.req.log.debug('SuperTokens session resolved.');
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
|
|
@ -391,7 +443,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug('SuperTokens session resolved successfully');
|
||||
args.req.log.debug('SuperTokens session resolved successfully');
|
||||
|
||||
return new SuperTokensCookieBasedSession(sessionPayload, {
|
||||
storage: this.storage,
|
||||
|
|
@ -409,7 +461,6 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
const SuperTokensSessionPayloadV1Model = zod.object({
|
||||
version: zod.literal('1'),
|
||||
superTokensUserId: zod.string(),
|
||||
email: zod.string(),
|
||||
});
|
||||
|
||||
const SuperTokensSessionPayloadV2Model = zod.object({
|
||||
|
|
@ -426,3 +477,4 @@ const SuperTokensSessionPayloadModel = zod.union([
|
|||
]);
|
||||
|
||||
type SuperTokensSessionPayload = zod.TypeOf<typeof SuperTokensSessionPayloadModel>;
|
||||
type SuperTokensSessionPayloadV2 = zod.TypeOf<typeof SuperTokensSessionPayloadV2Model>;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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<typeof OIDCIntegrationDomainModel>;
|
||||
|
||||
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<typeof ChallengePayloadModel>;
|
||||
|
||||
function createChallengePayload(): ChallengePayload {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
recordName: `_hive-challenge`,
|
||||
value: sha256(crypto.randomUUID()),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 = <TSchema>(schema: zod.ZodSchema<TSchema>) => 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.');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const deleteOIDCDomain: NonNullable<MutationResolvers['deleteOIDCDomain']> = 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const registerOIDCDomain: NonNullable<MutationResolvers['registerOIDCDomain']> = 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -51,4 +51,9 @@ export const OIDCIntegration: OidcIntegrationResolvers = {
|
|||
resources: oidcIntegration.defaultResourceAssignment,
|
||||
});
|
||||
},
|
||||
registeredDomains: async (oidcIntegration, _arg, { injector }) => {
|
||||
return injector
|
||||
.get(OIDCIntegrationsProvider)
|
||||
.getRegisteredDomainsForOIDCIntegration(oidcIntegration);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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<string> | 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;
|
||||
|
|
|
|||
|
|
@ -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<unknown>(
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
send(email: Omit<Email, 'date'>): Promise<void>;
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 = (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} data-form-oidc>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authorization_endpoint"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Authorization Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={state === 'discovery'}
|
||||
placeholder="https://my.okta.com/oauth2/v1/authorize"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token_endpoint"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Token Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={state === 'discovery'}
|
||||
placeholder="https://my.okta.com/oauth2/v1/token"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userinfo_endpoint"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Userinfo Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={state === 'discovery'}
|
||||
placeholder="https://my.okta.com/oauth2/v1/userinfo"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Client ID" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
props.initialValues
|
||||
? `Value ending with ${props.initialValues?.clientSecretPreview}`
|
||||
: 'Client Secret'
|
||||
}
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="additionalScopes"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Additional Scopes</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Separated by spaces" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet.Sheet open onOpenChange={props.onClose}>
|
||||
<Sheet.SheetContent className="flex max-h-screen min-w-[700px] flex-col overflow-y-scroll">
|
||||
<Sheet.SheetHeader>
|
||||
<Sheet.SheetTitle>Connect OpenID Connect Provider</Sheet.SheetTitle>
|
||||
<Sheet.SheetDescription>
|
||||
Connecting an OIDC provider to this organization allows users to automatically log in
|
||||
and be part of this organization.
|
||||
</Sheet.SheetDescription>
|
||||
<Sheet.SheetDescription>
|
||||
Use Okta, Auth0, Google Workspaces or any other OAuth2 Open ID Connect compatible
|
||||
provider.
|
||||
</Sheet.SheetDescription>
|
||||
</Sheet.SheetHeader>
|
||||
<Tabs value={state}>
|
||||
<TabsList variant="content" className="mt-1">
|
||||
<TabsTrigger
|
||||
variant="content"
|
||||
value="discovery"
|
||||
onClick={() => setState('discovery')}
|
||||
data-button-oidc-discovery
|
||||
>
|
||||
Discovery Document
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
variant="content"
|
||||
value="manual"
|
||||
onClick={() => setState('manual')}
|
||||
data-button-oidc-manual
|
||||
>
|
||||
Manual
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="discovery" variant="content">
|
||||
<OIDCMetadataFetcher
|
||||
onEndpointChange={args => {
|
||||
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}
|
||||
</TabsContent>
|
||||
<TabsContent value="manual" variant="content">
|
||||
{formNode}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Sheet.SheetFooter className="mb-0 mt-auto">
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Abort
|
||||
</Button>
|
||||
<Button
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
type="submit"
|
||||
data-button-oidc-save
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Sheet.SheetFooter>
|
||||
</Sheet.SheetContent>
|
||||
</Sheet.Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
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: (
|
||||
<div>
|
||||
<p>Status: {data.error.details.status}</p>
|
||||
<p>Response: {data.error.details.body ?? data.error.details.statusText}</p>
|
||||
</div>
|
||||
),
|
||||
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) => (
|
||||
<p key={i}>{msg}</p>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
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<typeof OIDCMetadataFormSchema>) {
|
||||
fetchMetadata.mutate(data.url);
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(OIDCMetadataFormSchema),
|
||||
defaultValues: {
|
||||
url: '',
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex flex-row justify-center gap-x-4">
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={fetchMetadata.isPending}
|
||||
placeholder="https://my.okta.com/.well-known/openid-configuration"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button type="submit" className="w-48" disabled={fetchMetadata.isPending}>
|
||||
{fetchMetadata.isPending ? 'Fetching...' : 'Fetch endpoints'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Provide the OIDC metadata URL to automatically fill in the fields below.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
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'),
|
||||
});
|
||||
|
|
@ -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<Array<OIDCLogEventType>>([]);
|
||||
|
||||
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 (
|
||||
<Dialog open onOpenChange={props.close}>
|
||||
<DialogContent className="min-w-[750px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Debug OpenID Connect Integration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Here you can see to the live logs of users attempting to sign in. It can help
|
||||
identifying issues with the OpenID Connect configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<VirtualLogList logs={logs} className="h-[300px]" />
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={props.close} tabIndex={0} variant="destructive">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
setIsSubscribing(isSubscribed => !isSubscribed);
|
||||
}}
|
||||
>
|
||||
{isSubscribing ? 'Stop subscription' : 'Subscribe to logs'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof OIDCDefaultResourceSelector_OrganizationFragment>;
|
||||
resourceAssignment: FragmentType<typeof OIDCDefaultResourceSelector_ResourceAssignmentFragment>;
|
||||
}) {
|
||||
const organization = useFragment(
|
||||
OIDCDefaultResourceSelector_OrganizationFragment,
|
||||
props.organization,
|
||||
);
|
||||
const resourceAssignment = useFragment(
|
||||
OIDCDefaultResourceSelector_ResourceAssignmentFragment,
|
||||
props.resourceAssignment,
|
||||
);
|
||||
const [_, mutate] = useMutation(OIDCDefaultResourceSelector_UpdateMutation);
|
||||
const [selection, setSelection] = useState<ResourceSelection>(() =>
|
||||
createResourceSelectionFromResourceAssignment(resourceAssignment),
|
||||
);
|
||||
|
||||
const [mutateState, setMutateState] = useState<null | 'loading' | 'success' | 'error'>(null);
|
||||
const debouncedMutate = useDebouncedCallback(
|
||||
async (args: Parameters<typeof mutate>[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 <Spinner className="absolute right-0 top-0" />;
|
||||
}
|
||||
|
||||
if (mutateState === 'error') {
|
||||
return <XIcon className="absolute right-0 top-0 text-red-500" />;
|
||||
}
|
||||
|
||||
if (mutateState === 'success') {
|
||||
return <CheckIcon className="absolute right-0 top-0 text-emerald-500" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const _setSelection = useCallback(
|
||||
async (resources: ResourceSelection) => {
|
||||
setSelection(resources);
|
||||
await debouncedMutate({
|
||||
input: {
|
||||
oidcIntegrationId: props.oidcIntegrationId,
|
||||
resources: resourceSlectionToGraphQLSchemaResourceAssignmentInput(resources),
|
||||
},
|
||||
});
|
||||
},
|
||||
[debouncedMutate, setSelection, props.oidcIntegrationId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<MutateState />
|
||||
<ResourceSelector
|
||||
selection={selection}
|
||||
onSelectionChange={props.disabled ? () => void 0 : _setSelection}
|
||||
organization={organization}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof OIDCDefaultRoleSelector_MemberRoleFragment>;
|
||||
memberRoles: Array<FragmentType<typeof OIDCDefaultRoleSelector_MemberRoleFragment>>;
|
||||
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 (
|
||||
<RoleSelector
|
||||
className={props.className}
|
||||
roles={memberRoles}
|
||||
defaultRole={defaultRole}
|
||||
disabled={props.disabled}
|
||||
onSelect={async role => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof OIDCIntegrationConfiguration_Organization>;
|
||||
oidcIntegration: FragmentType<typeof OIDCIntegrationConfiguration_OIDCIntegration>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<Heading size="lg">Overview</Heading>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setModalState(ModalState.openDebugLogs)}
|
||||
>
|
||||
<BugPlayIcon size="12" />{' '}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Debug OIDC Integration</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p>Endpoints for configuring the OIDC provider.</p>
|
||||
<Table.Table>
|
||||
<Table.TableHeader>
|
||||
<Table.TableRow>
|
||||
<Table.TableHead>Endpoint</Table.TableHead>
|
||||
<Table.TableHead>URL</Table.TableHead>
|
||||
</Table.TableRow>
|
||||
</Table.TableHeader>
|
||||
<Table.TableBody>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Sign-in redirect URI</Table.TableCell>
|
||||
<Table.TableCell>
|
||||
<span
|
||||
data-oidc-property-sign-in-redirect-uri
|
||||
>{`${env.appBaseUrl}/auth/callback/oidc`}</span>{' '}
|
||||
<CopyIconButton label="Copy" value={`${env.appBaseUrl}/auth/callback/oidc`} />
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Sign-out redirect URI</Table.TableCell>
|
||||
<Table.TableCell>
|
||||
<span data-oidc-property-sign-out-redirect-uri>{`${env.appBaseUrl}/logout`}</span>{' '}
|
||||
<CopyIconButton label="Copy" value={`${env.appBaseUrl}/logout`} />
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Sign-in URL</Table.TableCell>
|
||||
<Table.TableCell>
|
||||
<span
|
||||
data-oidc-property-sign-in-url
|
||||
>{`${env.appBaseUrl}/auth/oidc?id=${oidcIntegration.id}`}</span>{' '}
|
||||
<CopyIconButton
|
||||
label="Copy"
|
||||
value={`${env.appBaseUrl}/auth/oidc?id=${oidcIntegration.id}`}
|
||||
/>
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
</Table.TableBody>
|
||||
</Table.Table>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<Heading size="lg">OIDC Configuration</Heading>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setModalState(ModalState.openSettings)}
|
||||
>
|
||||
<SettingsIcon size="12" />{' '}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Update endpoint configuration</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Table.Table>
|
||||
<Table.TableHeader>
|
||||
<Table.TableRow>
|
||||
<Table.TableHead>Configuration</Table.TableHead>
|
||||
<Table.TableHead>Value</Table.TableHead>
|
||||
</Table.TableRow>
|
||||
</Table.TableHeader>
|
||||
<Table.TableBody>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Authorization Endpoint</Table.TableCell>
|
||||
<Table.TableCell>{oidcIntegration.authorizationEndpoint}</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Token Endpoint</Table.TableCell>
|
||||
<Table.TableCell>{oidcIntegration.tokenEndpoint}</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">User Info Endpoint</Table.TableCell>
|
||||
<Table.TableCell>{oidcIntegration.userinfoEndpoint}</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Client ID</Table.TableCell>
|
||||
<Table.TableCell>{oidcIntegration.clientId}</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Client Secret</Table.TableCell>
|
||||
<Table.TableCell className="font-mono">
|
||||
•••••••{oidcIntegration.clientSecretPreview}
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell className="font-medium">Additional Scopes</Table.TableCell>
|
||||
<Table.TableCell>{oidcIntegration.additionalScopes.join(' ')}</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
</Table.TableBody>
|
||||
</Table.Table>
|
||||
</div>
|
||||
<OIDCDomainConfiguration oidcIntegration={oidcIntegration} />
|
||||
<div className="space-y-2">
|
||||
<Heading size="lg">Access Settings</Heading>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex flex-col space-y-1 text-sm font-medium leading-none">
|
||||
<p>Require OIDC to Join</p>
|
||||
<p className="text-neutral-10 text-xs font-normal leading-snug">
|
||||
Restricts new accounts joining the organization to be authenticated via OIDC.
|
||||
<br />
|
||||
<span className="font-bold">Existing non-OIDC members will keep their access.</span>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={oidcIntegration.oidcUserJoinOnly}
|
||||
onCheckedChange={checked => onOidcRestrictionChange('oidcUserJoinOnly', checked)}
|
||||
disabled={oidcRestrictionsMutation.fetching}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex flex-col space-y-1 text-sm font-medium leading-none">
|
||||
<p>Require OIDC to Access</p>
|
||||
<p className="text-neutral-10 text-xs font-normal leading-snug">
|
||||
Prompt users to authenticate with OIDC before accessing the organization.
|
||||
<br />
|
||||
<span className="font-bold">
|
||||
Existing users without OIDC credentials will not be able to access the
|
||||
organization.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={oidcIntegration.oidcUserAccessOnly}
|
||||
onCheckedChange={checked => onOidcRestrictionChange('oidcUserAccessOnly', checked)}
|
||||
disabled={oidcRestrictionsMutation.fetching}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex flex-col space-y-1 text-sm font-medium leading-none">
|
||||
<p>Require Invitation to Join</p>
|
||||
<p className="text-neutral-10 text-xs font-normal leading-snug">
|
||||
Restricts only invited OIDC accounts to join the organization.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={oidcIntegration.requireInvitation}
|
||||
data-cy="oidc-require-invitation-toggle"
|
||||
onCheckedChange={checked => onOidcRestrictionChange('requireInvitation', checked)}
|
||||
disabled={oidcRestrictionsMutation.fetching}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-1 text-sm font-medium leading-none',
|
||||
isAdmin ? null : 'cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<p>Default Member Role</p>
|
||||
<div className="flex items-start justify-between space-x-4">
|
||||
<div className="flex basis-2/3 flex-col md:basis-1/2">
|
||||
<p className="text-neutral-10 text-xs font-normal leading-snug">
|
||||
This role is assigned to new members who sign in via OIDC.{' '}
|
||||
<span className="font-medium">
|
||||
Only members with the Admin role can modify it.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-[150px] basis-1/3 md:basis-1/2">
|
||||
<OIDCDefaultRoleSelector
|
||||
className="w-full"
|
||||
disabled={!isAdmin}
|
||||
oidcIntegrationId={oidcIntegration.id}
|
||||
defaultRole={oidcIntegration.defaultMemberRole}
|
||||
memberRoles={organization.memberRoles?.edges.map(edge => edge.node) ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<OIDCDefaultResourceSelector
|
||||
oidcIntegrationId={oidcIntegration.id}
|
||||
organization={organization}
|
||||
resourceAssignment={oidcIntegration.defaultResourceAssignment ?? {}}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Heading size="lg">Remove OIDC Provider</Heading>
|
||||
<p>Completly disconnect the OIDC provider and all configuration.</p>
|
||||
<Button variant="destructive" onClick={() => setModalState(ModalState.openDelete)}>
|
||||
Delete OIDC Provider
|
||||
</Button>
|
||||
</div>
|
||||
{modalState === ModalState.openSettings && (
|
||||
<ConnectSingleSignOnProviderSheet
|
||||
onClose={() => 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 && (
|
||||
<RemoveOIDCIntegrationModal
|
||||
close={() => setModalState(ModalState.closed)}
|
||||
oidcIntegrationId={oidcIntegration.id}
|
||||
/>
|
||||
)}
|
||||
{modalState === ModalState.openDebugLogs && (
|
||||
<DebugOIDCIntegrationModal
|
||||
close={() => setModalState(ModalState.closed)}
|
||||
oidcIntegrationId={oidcIntegration.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OIDCDomainConfiguration_OIDCIntegrationFragment = graphql(`
|
||||
fragment OIDCDomainConfiguration_OIDCIntegrationFragment on OIDCIntegration {
|
||||
id
|
||||
registeredDomains {
|
||||
id
|
||||
domainName
|
||||
createdAt
|
||||
verifiedAt
|
||||
...OIDCRegisteredDomainSheet_RegisteredDomain
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function OIDCDomainConfiguration(props: {
|
||||
oidcIntegration: FragmentType<typeof OIDCDomainConfiguration_OIDCIntegrationFragment>;
|
||||
}) {
|
||||
const oidcIntegration = useFragment(
|
||||
OIDCDomainConfiguration_OIDCIntegrationFragment,
|
||||
props.oidcIntegration,
|
||||
);
|
||||
|
||||
const [state, setState] = useState(
|
||||
null as
|
||||
| null
|
||||
| {
|
||||
type: 'create';
|
||||
}
|
||||
| {
|
||||
type: 'manage';
|
||||
domainId: string;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<Heading size="lg">Registered Domains</Heading>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-button-add-new-domain
|
||||
size="icon-sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setState({ type: 'create' })}
|
||||
>
|
||||
<PlusIcon size="12" />{' '}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add new domain</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p>Verify domain ownership to skip mandatory email confirmation for organization members.</p>
|
||||
<Table.Table>
|
||||
<Table.TableHeader>
|
||||
<Table.TableRow>
|
||||
<Table.TableHead>Domain</Table.TableHead>
|
||||
<Table.TableHead>Status</Table.TableHead>
|
||||
<Table.TableHead />
|
||||
</Table.TableRow>
|
||||
</Table.TableHeader>
|
||||
<Table.TableBody>
|
||||
{oidcIntegration.registeredDomains.map(domain => (
|
||||
<Table.TableRow key={domain.id}>
|
||||
<Table.TableCell className="font-mono font-medium">
|
||||
{domain.domainName}
|
||||
</Table.TableCell>
|
||||
<Table.TableCell>
|
||||
{domain.verifiedAt ? (
|
||||
<>
|
||||
Verified <CheckIcon size="12" className="inline-block" />
|
||||
</>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0} disableHoverableContent>
|
||||
<TooltipTrigger>
|
||||
Pending <AlertOctagonIcon size="12" className="inline-block" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
The domain ownership challenge has not been completed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Table.TableCell>
|
||||
<Table.TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0} disableHoverableContent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setState({
|
||||
domainId: domain.id,
|
||||
type: 'manage',
|
||||
})
|
||||
}
|
||||
className="ml-auto"
|
||||
>
|
||||
<SettingsIcon size="10" />
|
||||
</Button>
|
||||
<TooltipContent className="text-xs">Manage</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
))}
|
||||
</Table.TableBody>
|
||||
{oidcIntegration.registeredDomains.length === 0 && (
|
||||
<Table.TableCaption>No Domains registered</Table.TableCaption>
|
||||
)}
|
||||
</Table.Table>
|
||||
{state && (
|
||||
<OIDCRegisteredDomainSheet
|
||||
key={state.type}
|
||||
oidcIntegrationId={oidcIntegration.id}
|
||||
domain={
|
||||
(state.type === 'manage'
|
||||
? oidcIntegration.registeredDomains.find(domain => domain.id === state.domainId)
|
||||
: null) ?? null
|
||||
}
|
||||
onClose={() => setState(null)}
|
||||
onRegisterDomainSuccess={domainId =>
|
||||
setState({
|
||||
type: 'manage',
|
||||
domainId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open onOpenChange={props.close}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove OpenID Connect Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
{mutation.data?.deleteOIDCIntegration.ok ? (
|
||||
<>
|
||||
<p>The OIDC integration has been removed successfully.</p>
|
||||
<div className="text-right">
|
||||
<Button onClick={props.close}>Close</Button>
|
||||
</div>
|
||||
</>
|
||||
) : oidcIntegrationId === null ? (
|
||||
<>
|
||||
<p>This organization does not have an OIDC integration.</p>
|
||||
<div className="text-right">
|
||||
<Button onClick={props.close}>Close</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag color="yellow" className="px-4 py-2.5">
|
||||
<p>
|
||||
This action is not reversible and revoke access to all users that have signed in
|
||||
with this OIDC integration.
|
||||
</p>
|
||||
</Tag>
|
||||
<p>Do you really want to proceed?</p>
|
||||
|
||||
<div className="space-x-2 text-right">
|
||||
<Button variant="outline" onClick={props.close}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={mutation.fetching}
|
||||
onClick={async () => {
|
||||
await mutate({ input: { oidcIntegrationId } });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof OIDCRegisteredDomainSheet_RegisteredDomain>;
|
||||
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 (
|
||||
<>
|
||||
<Sheet.Sheet open onOpenChange={props.onClose}>
|
||||
<Sheet.SheetContent className="flex max-h-screen min-w-[700px] flex-col overflow-y-scroll">
|
||||
<Stepper.StepperProvider
|
||||
variant="horizontal"
|
||||
initialStep={
|
||||
domain
|
||||
? domain.verifiedAt
|
||||
? 'step-3-complete'
|
||||
: 'step-2-challenge'
|
||||
: 'step-1-general'
|
||||
}
|
||||
>
|
||||
{({ stepper }) => (
|
||||
<>
|
||||
<Sheet.SheetHeader>
|
||||
<Sheet.SheetTitle>
|
||||
{isInStepperProcess ? stepper.current.title : 'Domain Settings'}{' '}
|
||||
{domain?.domainName && (
|
||||
<span className="ml-3 font-mono">{domain?.domainName}</span>
|
||||
)}
|
||||
</Sheet.SheetTitle>
|
||||
</Sheet.SheetHeader>
|
||||
{isInStepperProcess && (
|
||||
<Stepper.StepperNavigation className="pb-4">
|
||||
{stepper.all.map(step => (
|
||||
<Stepper.StepperStep key={step.id} of={step.id} clickable={false}>
|
||||
<Stepper.StepperTitle>{step.title}</Stepper.StepperTitle>
|
||||
</Stepper.StepperStep>
|
||||
))}
|
||||
</Stepper.StepperNavigation>
|
||||
)}
|
||||
{stepper.switch({
|
||||
'step-1-general': () => (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onCreateDomain)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainName"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Domain Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The domain you want to register with this OIDC provider.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
),
|
||||
'step-2-challenge': () => (
|
||||
<>
|
||||
<p>
|
||||
In order to prove the ownership of the domain we have to perform a DNS
|
||||
challenge.
|
||||
</p>
|
||||
<p>Within your hosted zone create the following DNS record.</p>
|
||||
<Table.Table
|
||||
className={cn(!domain?.challenge && 'opacity-33 pointer-events-none')}
|
||||
>
|
||||
<Table.TableHeader>
|
||||
<Table.TableRow>
|
||||
<Table.TableHead>Property</Table.TableHead>
|
||||
<Table.TableHead>Value</Table.TableHead>
|
||||
</Table.TableRow>
|
||||
</Table.TableHeader>
|
||||
<Table.TableBody>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell>Type</Table.TableCell>
|
||||
<Table.TableCell className="font-mono font-medium">
|
||||
{domain?.challenge?.recordType}{' '}
|
||||
<CopyIconButton
|
||||
label="Copy"
|
||||
value={domain?.challenge?.recordType ?? ''}
|
||||
/>
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell>Name</Table.TableCell>
|
||||
<Table.TableCell className="font-mono font-medium">
|
||||
{domain?.challenge?.recordName}{' '}
|
||||
<CopyIconButton
|
||||
label="Copy"
|
||||
value={domain?.challenge?.recordName ?? ''}
|
||||
/>
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
<Table.TableRow>
|
||||
<Table.TableCell>Value</Table.TableCell>
|
||||
<Table.TableCell className="font-mono font-medium">
|
||||
{domain?.challenge?.recordValue}
|
||||
<CopyIconButton
|
||||
label="Copy"
|
||||
value={domain?.challenge?.recordValue ?? ''}
|
||||
/>
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
</Table.TableBody>
|
||||
</Table.Table>
|
||||
{domain && !domain.challenge && (
|
||||
<>
|
||||
<Tag color="yellow" className="text-neutral-11 px-4 py-2.5">
|
||||
<p>This challenge has expired.</p>
|
||||
</Tag>
|
||||
<div className="text-red-500">
|
||||
{requestDomainChallengeMutationState.error?.message ??
|
||||
requestDomainChallengeMutationState.data?.requestOIDCDomainChallenge
|
||||
.error?.message}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
requestDomainChallengeMutation({
|
||||
input: {
|
||||
oidcDomainId: domain.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
variant="primary"
|
||||
disabled={requestDomainChallengeMutationState.fetching}
|
||||
>
|
||||
Request new challenge
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
'step-3-complete': () => (
|
||||
<>
|
||||
<p>
|
||||
This domain was successfully verified. Users logging in with that email do
|
||||
not need to confirm their email.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
})}
|
||||
<Sheet.SheetFooter className="mb-0 mt-auto flex-wrap">
|
||||
{stepper.switch({
|
||||
'step-1-general': () => (
|
||||
<>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Abort
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={form.handleSubmit(onCreateDomain)}
|
||||
disabled={registerDomainMutationState.fetching}
|
||||
data-button-next-verify-domain-ownership
|
||||
>
|
||||
Next: Verify Domain Ownership
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
'step-2-challenge': () => (
|
||||
<>
|
||||
<div className="mb-10 basis-full text-red-500">
|
||||
{deleteDomainMutationState.error?.message ??
|
||||
deleteDomainMutationState.data?.deleteOIDCDomain.error?.message ??
|
||||
verifyDomainMutationState.error?.message ??
|
||||
verifyDomainMutationState.data?.verifyOIDCDomainChallenge.error
|
||||
?.message}
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDomainConfirmation(true)}
|
||||
disabled={
|
||||
deleteDomainMutationState.fetching || verifyDomainMutationState.fetching
|
||||
}
|
||||
>
|
||||
Delete Domain
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={props.onClose} className="ml-auto">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
data-button-next-complete
|
||||
variant="primary"
|
||||
onClick={() => onVerifyDomain(() => stepper.goTo('step-3-complete'))}
|
||||
disabled={
|
||||
verifyDomainMutationState.fetching ||
|
||||
deleteDomainMutationState.fetching ||
|
||||
!domain?.challenge
|
||||
}
|
||||
>
|
||||
Next: Complete
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
'step-3-complete': () => (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDomainConfirmation(true)}
|
||||
disabled={deleteDomainMutationState.fetching}
|
||||
>
|
||||
Delete Domain
|
||||
</Button>
|
||||
<Button variant="primary" onClick={props.onClose} className="ml-auto">
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
})}
|
||||
</Sheet.SheetFooter>
|
||||
</>
|
||||
)}
|
||||
</Stepper.StepperProvider>
|
||||
</Sheet.SheetContent>
|
||||
</Sheet.Sheet>
|
||||
{showDeleteDomainConfirmation && (
|
||||
<DeleteDomainConfirmationDialogue
|
||||
onClose={() => setShowDeleteDomainConfirmation(false)}
|
||||
onConfirm={onDeleteDomain}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteDomainConfirmationDialogue(props: { onClose: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<AlertDialog.AlertDialog open>
|
||||
<AlertDialog.AlertDialogContent>
|
||||
<AlertDialog.AlertDialogHeader>
|
||||
<AlertDialog.AlertDialogTitle>
|
||||
Do you want to delete this domain?
|
||||
</AlertDialog.AlertDialogTitle>
|
||||
</AlertDialog.AlertDialogHeader>
|
||||
<AlertDialog.AlertDialogFooter>
|
||||
<AlertDialog.AlertDialogCancel onClick={props.onClose}>
|
||||
Cancel
|
||||
</AlertDialog.AlertDialogCancel>
|
||||
<AlertDialog.AlertDialogAction onClick={props.onConfirm}>
|
||||
Delete Domain
|
||||
</AlertDialog.AlertDialogAction>
|
||||
</AlertDialog.AlertDialogFooter>
|
||||
</AlertDialog.AlertDialogContent>
|
||||
</AlertDialog.AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<SubPageLayout>
|
||||
<SubPageLayoutHeader
|
||||
subPageTitle="Single Sign On Provider"
|
||||
description={
|
||||
<>
|
||||
<CardDescription>
|
||||
Link your Hive organization to a single-sign-on provider such as Okta or Microsoft
|
||||
Entra ID via OpenID Connect.
|
||||
</CardDescription>
|
||||
<CardDescription>
|
||||
<DocsLink className="text-neutral-10 text-sm" href="/management/sso-oidc-provider">
|
||||
Instructions for connecting your provider.
|
||||
</DocsLink>
|
||||
</CardDescription>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="text-neutral-10 max-w-[800px] space-y-4">
|
||||
{query.fetching ? (
|
||||
<LoadingSkeleton />
|
||||
) : oidcIntegration ? (
|
||||
<OIDCIntegrationConfiguration
|
||||
oidcIntegration={oidcIntegration}
|
||||
organization={organization}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="mt-5"
|
||||
onClick={() => setModalState(ConnectSingleSignOnProviderState.open)}
|
||||
data-button-connect-open-id-provider
|
||||
>
|
||||
<KeyIcon className="mr-2" />
|
||||
Connect Open ID Connect Provider
|
||||
</Button>
|
||||
<p>Your organization has currently no Open ID Connect provider configured.</p>
|
||||
{modalState === ConnectSingleSignOnProviderState.open && (
|
||||
<ConnectSingleSignOnProviderSheet
|
||||
onClose={() => 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',
|
||||
};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{/* Overview Section */}
|
||||
<section className="space-y-8">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex gap-8">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* OIDC Configuration Section */}
|
||||
<section className="space-y-8">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-8">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Registered Domains Section */}
|
||||
<section className="space-y-8">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<div className="flex gap-8">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Access Settings Section */}
|
||||
<section className="space-y-8">
|
||||
<Skeleton className="h-6 w-36" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
packages/web/app/src/components/ui/copy-icon-button.tsx
Normal file
30
packages/web/app/src/components/ui/copy-icon-button.tsx
Normal file
|
|
@ -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 (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0} disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => clipboard(props.value)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<CopyIcon size="10" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">{props.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -143,7 +143,9 @@ function AuthVerifyEmail() {
|
|||
<AuthCardContent>
|
||||
<AuthCardStack>
|
||||
<Button className="w-full" asChild>
|
||||
<Link to="/">Continue</Link>
|
||||
<Link to="/" data-button-verify-email-continue>
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</AuthCardStack>
|
||||
</AuthCardContent>
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
</Form>
|
||||
)}
|
||||
|
||||
{organization.viewerCanManageOIDCIntegration && (
|
||||
<SubPageLayout>
|
||||
<SubPageLayoutHeader
|
||||
subPageTitle="Single Sign On Provider"
|
||||
description={
|
||||
<>
|
||||
<CardDescription>
|
||||
Link your Hive organization to a single-sign-on provider such as Okta or Microsoft
|
||||
Entra ID via OpenID Connect.
|
||||
</CardDescription>
|
||||
<CardDescription>
|
||||
<DocsLink
|
||||
className="text-neutral-10 text-sm"
|
||||
href="/management/sso-oidc-provider"
|
||||
>
|
||||
Instructions for connecting your provider.
|
||||
</DocsLink>
|
||||
</CardDescription>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="text-neutral-10">
|
||||
<OIDCIntegrationSection organizationSlug={organization.slug} />
|
||||
</div>
|
||||
</SubPageLayout>
|
||||
)}
|
||||
|
||||
{organization.viewerCanModifySlackIntegration && (
|
||||
<SubPageLayout>
|
||||
<SubPageLayoutHeader
|
||||
|
|
@ -620,12 +593,14 @@ const OrganizationSettingsPageQuery = graphql(`
|
|||
viewerCanAccessSettings
|
||||
viewerCanManageAccessTokens
|
||||
viewerCanManagePersonalAccessTokens
|
||||
viewerCanManageOIDCIntegration
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const OrganizationSettingsPageEnum = z.enum([
|
||||
'general',
|
||||
'sso',
|
||||
'policy',
|
||||
'access-tokens',
|
||||
'personal-access-tokens',
|
||||
|
|
@ -664,6 +639,13 @@ function SettingsPageContent(props: {
|
|||
title: 'Policy',
|
||||
});
|
||||
|
||||
if (currentOrganization?.viewerCanManageOIDCIntegration) {
|
||||
pages.push({
|
||||
key: 'sso',
|
||||
title: 'Single Sign On',
|
||||
});
|
||||
}
|
||||
|
||||
if (currentOrganization?.viewerCanManageAccessTokens) {
|
||||
pages.push({
|
||||
key: 'access-tokens',
|
||||
|
|
@ -715,6 +697,7 @@ function SettingsPageContent(props: {
|
|||
{subPages.map(subPage => {
|
||||
return (
|
||||
<SubPageNavigationLink
|
||||
dataCy={`link-${subPage.key}`}
|
||||
key={subPage.key}
|
||||
isActive={resolvedPage.key === subPage.key}
|
||||
onClick={() => {
|
||||
|
|
@ -737,6 +720,9 @@ function SettingsPageContent(props: {
|
|||
organization={currentOrganization}
|
||||
/>
|
||||
) : null}
|
||||
{resolvedPage.key === 'sso' ? (
|
||||
<SingleSignOnSubpage organizationSlug={props.organizationSlug} />
|
||||
) : null}
|
||||
{resolvedPage.key === 'policy' ? (
|
||||
<OrganizationPolicySettings organization={currentOrganization} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0} disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => clipboard(props.value)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<CopyIcon size="10" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">{props.label}</TooltipContent>
|
||||
</Tooltip>{' '}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ExceptionTeaser(props: {
|
||||
name: string;
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue