feat: oidc domain registration and verification (#7745)

This commit is contained in:
Laurin 2026-03-09 13:08:27 +01:00 committed by GitHub
parent 42bc3a0f1f
commit 33bff41342
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 3654 additions and 1782 deletions

View 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.

View file

@ -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: |

View file

@ -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',
},
},
});

View file

@ -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');
});
});
});
});

View file

@ -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 => {

View file

@ -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"
}
]
}
]

View file

@ -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: {}

View file

@ -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

View file

@ -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,
}));
}

View file

@ -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;

View file

@ -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),
});

View file

@ -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;

View file

@ -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'),
],
});

View file

@ -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",

View file

@ -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>;

View file

@ -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],
});

View file

@ -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;

View file

@ -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!
}
"""

View file

@ -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()),
};
}

View file

@ -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.');

View file

@ -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,
},
};
};

View file

@ -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,
},
};
};

View file

@ -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,
},
};
};

View file

@ -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,
},
};
};

View file

@ -51,4 +51,9 @@ export const OIDCIntegration: OidcIntegrationResolvers = {
resources: oidcIntegration.defaultResourceAssignment,
});
},
registeredDomains: async (oidcIntegration, _arg, { injector }) => {
return injector
.get(OIDCIntegrationsProvider)
.getRegisteredDomainsForOIDCIntegration(oidcIntegration);
},
};

View file

@ -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);
},
};

View file

@ -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) =>

View file

@ -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;

View file

@ -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.');
}
}
}

View file

@ -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);
},
});

View file

@ -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,
};

View file

@ -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'),
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -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}

View file

@ -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;

View file

@ -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 = {