mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: block OIDC sign ups without invitation (#7600)
This commit is contained in:
parent
39432a7aa0
commit
cf63917f0c
18 changed files with 482 additions and 151 deletions
|
|
@ -207,4 +207,76 @@ describe('oidc', () => {
|
|||
cy.visit('/auth/oidc?id=invalid');
|
||||
cy.get('[data-cy="auth-card-header-description"]').contains('Could not find OIDC integration.');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// 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();
|
||||
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
|
||||
// Check if user has admin role
|
||||
cy.visit(`/${slug}/view/members?page=list`);
|
||||
cy.contains('tr', 'tom.sailor@gmail.com').contains('Admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,17 +96,20 @@ const createSession = async (
|
|||
],
|
||||
});
|
||||
|
||||
const { user } = await internalApi.ensureUser.mutate({
|
||||
const ensureUserResult = await internalApi.ensureUser.mutate({
|
||||
superTokensUserId,
|
||||
email,
|
||||
oidcIntegrationId,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
if (!ensureUserResult.ok) {
|
||||
throw new Error(ensureUserResult.reason);
|
||||
}
|
||||
|
||||
const sessionData = createSessionPayload({
|
||||
superTokensUserId,
|
||||
userId: user.id,
|
||||
userId: ensureUserResult.user.id,
|
||||
oidcIntegrationId,
|
||||
email,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -767,25 +767,28 @@ describe('restrictions', () => {
|
|||
|
||||
expect(refetchedOrg.organization?.oidcIntegration?.oidcUserJoinOnly).toEqual(true);
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const email = userEmail('non-oidc-user');
|
||||
const invitation = await inviteMember(email);
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const nonOidcAccount = await seed.authenticate(email);
|
||||
const joinResult = await joinMemberUsingCode(
|
||||
invitationCode,
|
||||
nonOidcAccount.access_token,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
).then(r => r.expectGraphQLErrors());
|
||||
|
||||
expect(joinResult.joinOrganization).toEqual(
|
||||
expect.objectContaining({
|
||||
__typename: 'OrganizationInvitationError',
|
||||
message:
|
||||
'The user is not authorized through the OIDC integration required for the organization',
|
||||
}),
|
||||
expect(joinResult).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: expect.objectContaining({
|
||||
code: 'NEEDS_OIDC',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -837,14 +840,15 @@ describe('restrictions', () => {
|
|||
orgAfterDisablingOidcRestrictions.organization?.oidcIntegration?.oidcUserJoinOnly,
|
||||
).toEqual(false);
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const email = userEmail('non-oidc-user');
|
||||
const invitation = await inviteMember(email);
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const nonOidcAccount = await seed.authenticate(email);
|
||||
const joinResult = await joinMemberUsingCode(invitationCode, nonOidcAccount.access_token).then(
|
||||
r => r.expectNoGraphQLErrors(),
|
||||
);
|
||||
|
|
@ -859,14 +863,15 @@ describe('restrictions', () => {
|
|||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
const { organization, inviteMember, joinMemberUsingCode } = await createOrg();
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const email = userEmail('non-oidc-user');
|
||||
const invitation = await inviteMember(email);
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const nonOidcAccount = await seed.authenticate(email);
|
||||
const joinResult = await joinMemberUsingCode(
|
||||
invitationCode,
|
||||
nonOidcAccount.access_token,
|
||||
|
|
@ -898,14 +903,15 @@ describe('restrictions', () => {
|
|||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
const { organization, inviteMember, joinMemberUsingCode } = await createOrg();
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const email = userEmail('non-oidc-user');
|
||||
const invitation = await inviteMember(email);
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const nonOidcAccount = await seed.authenticate(email);
|
||||
const joinResult = await joinMemberUsingCode(
|
||||
invitationCode,
|
||||
nonOidcAccount.access_token,
|
||||
|
|
|
|||
|
|
@ -187,12 +187,12 @@ test.concurrent(
|
|||
const { inviteMember, joinMemberUsingCode } = await createOrg();
|
||||
|
||||
// Invite
|
||||
const invitationResult = await inviteMember();
|
||||
const extra = seed.generateEmail();
|
||||
const invitationResult = await inviteMember(extra);
|
||||
const inviteCode = invitationResult.ok!.createdOrganizationInvitation.code;
|
||||
expect(inviteCode).toBeDefined();
|
||||
|
||||
// Join
|
||||
const extra = seed.generateEmail();
|
||||
const { access_token: member_access_token } = await seed.authenticate(extra);
|
||||
const joinResult = await (
|
||||
await joinMemberUsingCode(inviteCode, member_access_token)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { type MigrationExecutor } from '../pg-migrator';
|
||||
|
||||
export default {
|
||||
name: '2026.01.30T10-00-00.oidc-require-invitation.ts',
|
||||
run: ({ sql }) => [
|
||||
{
|
||||
name: 'add `require_invitation` column to `oidc_integrations` table',
|
||||
query: sql`
|
||||
ALTER TABLE IF EXISTS "oidc_integrations"
|
||||
ADD COLUMN IF NOT EXISTS "require_invitation" boolean NOT NULL DEFAULT false
|
||||
;
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies MigrationExecutor;
|
||||
|
|
@ -182,5 +182,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
|
|||
await import('./actions/2026.01.09T00-00-00.email-verifications'),
|
||||
await import('./actions/2026.01.30T00-00-00.account-linking'),
|
||||
await import('./actions/2026.02.06T00-00-00.zendesk-unique'),
|
||||
await import('./actions/2026.01.30T10-00-00.oidc-require-invitation'),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default gql`
|
|||
additionalScopes: [String!]!
|
||||
oidcUserJoinOnly: Boolean!
|
||||
oidcUserAccessOnly: Boolean!
|
||||
requireInvitation: Boolean!
|
||||
defaultMemberRole: MemberRole!
|
||||
defaultResourceAssignment: ResourceAssignment
|
||||
}
|
||||
|
|
@ -167,6 +168,7 @@ export default gql`
|
|||
"""
|
||||
oidcUserJoinOnly: Boolean
|
||||
oidcUserAccessOnly: Boolean
|
||||
requireInvitation: Boolean
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ export class OIDCIntegrationsProvider {
|
|||
oidcIntegrationId: string;
|
||||
oidcUserJoinOnly: boolean | null;
|
||||
oidcUserAccessOnly: boolean | null;
|
||||
requireInvitation: boolean | null;
|
||||
}) {
|
||||
if (this.isEnabled() === false) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const updateOIDCRestrictions: NonNullable<
|
|||
oidcIntegrationId: input.oidcIntegrationId,
|
||||
oidcUserJoinOnly: input.oidcUserJoinOnly ?? null,
|
||||
oidcUserAccessOnly: input.oidcUserAccessOnly ?? null,
|
||||
requireInvitation: input.requireInvitation ?? null,
|
||||
});
|
||||
|
||||
if (result.type === 'ok') {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { TaskScheduler } from '@hive/workflows/kit';
|
|||
import { OrganizationInvitationTask } from '@hive/workflows/tasks/organization-invitation';
|
||||
import { OrganizationOwnershipTransferTask } from '@hive/workflows/tasks/organization-ownership-transfer';
|
||||
import * as GraphQLSchema from '../../../__generated__/types';
|
||||
import { Organization } from '../../../shared/entities';
|
||||
import { Organization, User } from '../../../shared/entities';
|
||||
import { AccessError, HiveError, OIDCRequiredError } from '../../../shared/errors';
|
||||
import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder';
|
||||
import { Session } from '../../auth/lib/authz';
|
||||
|
|
@ -225,17 +225,30 @@ export class OrganizationManager {
|
|||
|
||||
async getOrganizationByInviteCode({
|
||||
code,
|
||||
user: maybeUser,
|
||||
}: {
|
||||
code: string;
|
||||
user?: User;
|
||||
}): Promise<Organization | { message: string } | never> {
|
||||
this.logger.debug('Fetching organization (inviteCode=%s)', code);
|
||||
|
||||
let user = maybeUser;
|
||||
if (!user) {
|
||||
const actor = await this.session.getActor();
|
||||
if (actor.type !== 'user') {
|
||||
throw new Error('Only users can fetch organization by invite code');
|
||||
}
|
||||
user = actor.user;
|
||||
}
|
||||
|
||||
const organization = await this.storage.getOrganizationByInviteCode({
|
||||
inviteCode: code,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return {
|
||||
message: 'Invitation expired',
|
||||
message: 'Invitation is invalid or expired',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -662,6 +675,7 @@ export class OrganizationManager {
|
|||
|
||||
const organization = await this.getOrganizationByInviteCode({
|
||||
code,
|
||||
user: actor.user,
|
||||
});
|
||||
|
||||
if ('message' in organization) {
|
||||
|
|
@ -674,10 +688,11 @@ export class OrganizationManager {
|
|||
});
|
||||
|
||||
if (oidcIntegration?.oidcUserJoinOnly && actor.oidcIntegrationId !== oidcIntegration.id) {
|
||||
return {
|
||||
message:
|
||||
'The user is not authorized through the OIDC integration required for the organization',
|
||||
};
|
||||
throw new OIDCRequiredError(
|
||||
organization.slug,
|
||||
oidcIntegration.id,
|
||||
'The user should be authenticated through the OIDC provider linked to the organization',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,10 +79,17 @@ export interface Storage {
|
|||
};
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
}): Promise<{
|
||||
user: User;
|
||||
action: 'created' | 'no_action';
|
||||
}>;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
user: User;
|
||||
action: 'created' | 'no_action';
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
>;
|
||||
|
||||
getUserBySuperTokenId(_: { superTokensUserId: string }): Promise<User | null>;
|
||||
getUserById(_: { id: string }): Promise<User | null>;
|
||||
|
|
@ -90,7 +97,10 @@ export interface Storage {
|
|||
updateUser(_: { id: string; fullName: string; displayName: string }): Promise<User | never>;
|
||||
|
||||
getOrganizationId(_: { organizationSlug: string }): Promise<string | null>;
|
||||
getOrganizationByInviteCode(_: { inviteCode: string }): Promise<Organization | null>;
|
||||
getOrganizationByInviteCode(_: {
|
||||
inviteCode: string;
|
||||
email: string;
|
||||
}): Promise<Organization | null>;
|
||||
getOrganizationBySlug(_: { slug: string }): Promise<Organization | null>;
|
||||
getOrganizationByGitHubInstallationId(_: {
|
||||
installationId: string;
|
||||
|
|
@ -647,6 +657,7 @@ export interface Storage {
|
|||
oidcIntegrationId: string;
|
||||
oidcUserJoinOnly: boolean | null;
|
||||
oidcUserAccessOnly: boolean | null;
|
||||
requireInvitation: boolean | null;
|
||||
}): Promise<OIDCIntegration>;
|
||||
|
||||
updateOIDCDefaultMemberRole(_: {
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export interface OIDCIntegration {
|
|||
additionalScopes: string[];
|
||||
oidcUserJoinOnly: boolean;
|
||||
oidcUserAccessOnly: boolean;
|
||||
requireInvitation: boolean;
|
||||
defaultMemberRoleId: string | null;
|
||||
defaultResourceAssignment: ResourceAssignmentGroup | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,17 +169,21 @@ export const backendConfig = (requirements: {
|
|||
);
|
||||
}
|
||||
|
||||
const internalUser = await internalApi.ensureUser({
|
||||
const ensureUserResult = await internalApi.ensureUser({
|
||||
superTokensUserId: user.id,
|
||||
email: user.emails[0],
|
||||
oidcIntegrationId: input.userContext['oidcId'] ?? null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
if (!ensureUserResult.ok) {
|
||||
throw new SessionCreationError(ensureUserResult.reason);
|
||||
}
|
||||
|
||||
const payload: SuperTokensSessionPayload = {
|
||||
version: '2',
|
||||
superTokensUserId: input.userId,
|
||||
userId: internalUser.user.id,
|
||||
userId: ensureUserResult.user.id,
|
||||
oidcIntegrationId: input.userContext['oidcId'] ?? null,
|
||||
email: user.emails[0],
|
||||
};
|
||||
|
|
@ -253,22 +257,46 @@ const getEnsureUserOverrides = (
|
|||
};
|
||||
}
|
||||
|
||||
const response = await originalImplementation.emailPasswordSignUpPOST(input);
|
||||
try {
|
||||
const response = await originalImplementation.emailPasswordSignUpPOST(input);
|
||||
|
||||
const firstName = input.formFields.find(field => field.id === 'firstName')?.value ?? null;
|
||||
const lastName = input.formFields.find(field => field.id === 'lastName')?.value ?? null;
|
||||
const firstName = input.formFields.find(field => field.id === 'firstName')?.value ?? null;
|
||||
const lastName = input.formFields.find(field => field.id === 'lastName')?.value ?? null;
|
||||
|
||||
if (response.status === 'OK') {
|
||||
await internalApi.ensureUser({
|
||||
superTokensUserId: response.user.id,
|
||||
email: response.user.emails[0],
|
||||
oidcIntegrationId: null,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
if (response.status === 'SIGN_UP_NOT_ALLOWED') {
|
||||
return {
|
||||
status: 'SIGN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign up not allowed.',
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 'OK') {
|
||||
const result = await internalApi.ensureUser({
|
||||
superTokensUserId: response.user.id,
|
||||
email: response.user.emails[0],
|
||||
oidcIntegrationId: null,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
status: 'SIGN_UP_NOT_ALLOWED',
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (e instanceof SessionCreationError) {
|
||||
return {
|
||||
status: 'SIGN_UP_NOT_ALLOWED',
|
||||
reason: e.reason,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async emailPasswordSignInPOST(input) {
|
||||
if (originalImplementation.emailPasswordSignInPOST === undefined) {
|
||||
|
|
@ -287,20 +315,44 @@ const getEnsureUserOverrides = (
|
|||
};
|
||||
}
|
||||
|
||||
const response = await originalImplementation.emailPasswordSignInPOST(input);
|
||||
try {
|
||||
const response = await originalImplementation.emailPasswordSignInPOST(input);
|
||||
|
||||
if (response.status === 'OK') {
|
||||
await internalApi.ensureUser({
|
||||
superTokensUserId: response.user.id,
|
||||
email: response.user.emails[0],
|
||||
oidcIntegrationId: null,
|
||||
// They are not available during sign in.
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
if (response.status === 'SIGN_IN_NOT_ALLOWED') {
|
||||
return {
|
||||
status: 'SIGN_IN_NOT_ALLOWED',
|
||||
reason: 'Sign in not allowed.',
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 'OK') {
|
||||
const result = await internalApi.ensureUser({
|
||||
superTokensUserId: response.user.id,
|
||||
email: response.user.emails[0],
|
||||
oidcIntegrationId: null,
|
||||
// They are not available during sign in.
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
status: 'SIGN_IN_NOT_ALLOWED',
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (e instanceof SessionCreationError) {
|
||||
return {
|
||||
status: 'SIGN_IN_NOT_ALLOWED',
|
||||
reason: e.reason,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async thirdPartySignInUpPOST(input) {
|
||||
if (originalImplementation.thirdPartySignInUpPOST === undefined) {
|
||||
|
|
@ -316,20 +368,45 @@ const getEnsureUserOverrides = (
|
|||
}
|
||||
return null;
|
||||
}
|
||||
const response = await originalImplementation.thirdPartySignInUpPOST(input);
|
||||
|
||||
if (response.status === 'OK') {
|
||||
await internalApi.ensureUser({
|
||||
superTokensUserId: response.user.id,
|
||||
email: response.user.emails[0],
|
||||
oidcIntegrationId: extractOidcId(input),
|
||||
// TODO: should we somehow extract the first and last name from the third party provider?
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
try {
|
||||
const response = await originalImplementation.thirdPartySignInUpPOST(input);
|
||||
|
||||
if (response.status === 'SIGN_IN_UP_NOT_ALLOWED') {
|
||||
return {
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: 'Sign in not allowed.',
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 'OK') {
|
||||
const result = await internalApi.ensureUser({
|
||||
superTokensUserId: response.user.id,
|
||||
email: response.user.emails[0],
|
||||
oidcIntegrationId: extractOidcId(input),
|
||||
// TODO: should we somehow extract the first and last name from the third party provider?
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (e instanceof SessionCreationError) {
|
||||
return {
|
||||
status: 'SIGN_IN_UP_NOT_ALLOWED',
|
||||
reason: e.reason,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async passwordResetPOST(input) {
|
||||
const logger = getLoggerFromUserContext(input.userContext);
|
||||
|
|
@ -448,3 +525,9 @@ export async function oidcIdLookup(
|
|||
id: oidcId,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionCreationError extends Error {
|
||||
constructor(public reason: string) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export interface oidc_integrations {
|
|||
oauth_api_url: string | null;
|
||||
oidc_user_access_only: boolean;
|
||||
oidc_user_join_only: boolean;
|
||||
require_invitation: boolean;
|
||||
token_endpoint: string | null;
|
||||
updated_at: Date;
|
||||
userinfo_endpoint: string | null;
|
||||
|
|
|
|||
|
|
@ -223,9 +223,11 @@ export async function createStorage(
|
|||
): OrganizationInvitation {
|
||||
return {
|
||||
get id() {
|
||||
return Buffer.from(
|
||||
[invitation.organization_id, invitation.email, invitation.code].join(':'),
|
||||
).toString('hex');
|
||||
return getOrganizationInvitationId({
|
||||
organizationId: invitation.organization_id,
|
||||
email: invitation.email,
|
||||
code: invitation.code,
|
||||
});
|
||||
},
|
||||
email: invitation.email,
|
||||
organizationId: invitation.organization_id,
|
||||
|
|
@ -531,6 +533,7 @@ export async function createStorage(
|
|||
args: {
|
||||
oidcIntegrationId: string;
|
||||
userId: string;
|
||||
invitation: OrganizationInvitation | null;
|
||||
},
|
||||
connection: Connection,
|
||||
) {
|
||||
|
|
@ -548,7 +551,7 @@ export async function createStorage(
|
|||
return;
|
||||
}
|
||||
|
||||
// Add user and assign default role (either Viewer or custom default role)
|
||||
// Add user and assign role (either the invited role, custom default role, or Viewer)
|
||||
await connection.query(
|
||||
sql`/* addOrganizationMemberViaOIDCIntegrationId */
|
||||
INSERT INTO organization_member
|
||||
|
|
@ -559,23 +562,22 @@ export async function createStorage(
|
|||
${args.userId},
|
||||
(
|
||||
COALESCE(
|
||||
${args.invitation?.roleId ?? null},
|
||||
(SELECT default_role_id FROM oidc_integrations
|
||||
WHERE id = ${args.oidcIntegrationId}),
|
||||
(SELECT id FROM organization_member_roles
|
||||
WHERE organization_id = ${linkedOrganizationId} AND name = 'Viewer')
|
||||
)
|
||||
),
|
||||
(
|
||||
SELECT
|
||||
default_assigned_resources
|
||||
FROM
|
||||
oidc_integrations
|
||||
WHERE
|
||||
id = ${args.oidcIntegrationId}
|
||||
),
|
||||
${
|
||||
args.invitation?.assignedResources
|
||||
? sql.jsonb(args.invitation.assignedResources)
|
||||
: sql`(SELECT default_assigned_resources FROM oidc_integrations
|
||||
WHERE id = ${args.oidcIntegrationId})`
|
||||
},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
${args.invitation ? sql`` : sql`ON CONFLICT DO NOTHING`}
|
||||
RETURNING *
|
||||
`,
|
||||
);
|
||||
|
|
@ -651,81 +653,138 @@ export async function createStorage(
|
|||
id: string;
|
||||
};
|
||||
}) {
|
||||
return tracedTransaction('ensureUserExists', pool, async t => {
|
||||
let action: 'created' | 'no_action' = 'no_action';
|
||||
class EnsureUserExistsError extends Error {}
|
||||
|
||||
// try searching existing user first
|
||||
let internalUser = await t
|
||||
.maybeOne<unknown>(
|
||||
sql`
|
||||
SELECT
|
||||
${userFields(sql`"users".`)}
|
||||
FROM "users"
|
||||
WHERE
|
||||
"users"."supertoken_user_id" = ${superTokensUserId}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "users_linked_identities" "uli"
|
||||
WHERE "uli"."user_id" = "users"."id"
|
||||
AND "uli"."identity_id" = ${superTokensUserId}
|
||||
)
|
||||
`,
|
||||
)
|
||||
.then(v => UserModel.nullable().parse(v));
|
||||
try {
|
||||
return await tracedTransaction('ensureUserExists', pool, async t => {
|
||||
let action: 'created' | 'no_action' = 'no_action';
|
||||
|
||||
if (!internalUser) {
|
||||
// try automatic account linking
|
||||
const sameEmailUsers = await t
|
||||
.any<unknown>(
|
||||
sql`/* ensureUserExists */
|
||||
SELECT ${userFields(sql`"users".`)}
|
||||
// try searching existing user first
|
||||
let internalUser = await t
|
||||
.maybeOne<unknown>(
|
||||
sql`
|
||||
SELECT
|
||||
${userFields(sql`"users".`)}
|
||||
FROM "users"
|
||||
WHERE "users"."email" = ${email}
|
||||
ORDER BY "users"."created_at";
|
||||
WHERE
|
||||
"users"."supertoken_user_id" = ${superTokensUserId}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "users_linked_identities" "uli"
|
||||
WHERE "uli"."user_id" = "users"."id"
|
||||
AND "uli"."identity_id" = ${superTokensUserId}
|
||||
)
|
||||
`,
|
||||
)
|
||||
.then(users => users.map(user => UserModel.parse(user)));
|
||||
.then(v => UserModel.nullable().parse(v));
|
||||
|
||||
if (sameEmailUsers.length === 1) {
|
||||
internalUser = sameEmailUsers[0];
|
||||
await t.query(sql`
|
||||
INSERT INTO "users_linked_identities" ("user_id", "identity_id")
|
||||
VALUES (${internalUser.id}, ${superTokensUserId})
|
||||
`);
|
||||
if (!internalUser) {
|
||||
// try automatic account linking
|
||||
const sameEmailUsers = await t
|
||||
.any<unknown>(
|
||||
sql`/* ensureUserExists */
|
||||
SELECT ${userFields(sql`"users".`)}
|
||||
FROM "users"
|
||||
WHERE "users"."email" = ${email}
|
||||
ORDER BY "users"."created_at";
|
||||
`,
|
||||
)
|
||||
.then(users => users.map(user => UserModel.parse(user)));
|
||||
|
||||
if (sameEmailUsers.length === 1) {
|
||||
internalUser = sameEmailUsers[0];
|
||||
await t.query(sql`
|
||||
INSERT INTO "users_linked_identities" ("user_id", "identity_id")
|
||||
VALUES (${internalUser.id}, ${superTokensUserId})
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// either user is brand new or user is not linkable (multiple accounts with the same email exist)
|
||||
if (!internalUser) {
|
||||
internalUser = await shared.createUser(
|
||||
buildUserData({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
superTokensUserId,
|
||||
oidcIntegrationId: oidcIntegration?.id ?? null,
|
||||
}),
|
||||
t,
|
||||
);
|
||||
let invitation: OrganizationInvitation | null = null;
|
||||
|
||||
action = 'created';
|
||||
}
|
||||
|
||||
if (oidcIntegration !== null) {
|
||||
// Add user to OIDC linked integration
|
||||
await shared.addOrganizationMemberViaOIDCIntegrationId(
|
||||
{
|
||||
if (oidcIntegration) {
|
||||
const oidcConfig = await this.getOIDCIntegrationById({
|
||||
oidcIntegrationId: oidcIntegration.id,
|
||||
userId: internalUser.id,
|
||||
},
|
||||
t,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
user: internalUser,
|
||||
action,
|
||||
};
|
||||
});
|
||||
if (oidcConfig?.requireInvitation) {
|
||||
invitation = await t
|
||||
.maybeOne<unknown>(
|
||||
sql`
|
||||
DELETE FROM "organization_invitations" AS "oi"
|
||||
WHERE
|
||||
"oi"."organization_id" = ${oidcConfig.linkedOrganizationId}
|
||||
AND "oi"."email" = ${email}
|
||||
AND "oi"."expires_at" > now()
|
||||
RETURNING
|
||||
"oi"."organization_id" "organizationId"
|
||||
, "oi"."code" "code"
|
||||
, "oi"."email" "email"
|
||||
, "oi"."created_at" "createdAt"
|
||||
, "oi"."expires_at" "expiresAt"
|
||||
, "oi"."role_id" "roleId"
|
||||
, "oi"."assigned_resources" "assignedResources"
|
||||
`,
|
||||
)
|
||||
.then(v => OrganizationInvitationModel.nullable().parse(v));
|
||||
|
||||
if (!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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// either user is brand new or user is not linkable (multiple accounts with the same email exist)
|
||||
if (!internalUser) {
|
||||
internalUser = await shared.createUser(
|
||||
buildUserData({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
superTokensUserId,
|
||||
oidcIntegrationId: oidcIntegration?.id ?? null,
|
||||
}),
|
||||
t,
|
||||
);
|
||||
|
||||
action = 'created';
|
||||
}
|
||||
|
||||
if (oidcIntegration !== null) {
|
||||
// Add user to OIDC linked integration
|
||||
await shared.addOrganizationMemberViaOIDCIntegrationId(
|
||||
{
|
||||
oidcIntegrationId: oidcIntegration.id,
|
||||
userId: internalUser.id,
|
||||
invitation,
|
||||
},
|
||||
t,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: internalUser,
|
||||
action,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof EnsureUserExistsError) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: e.message,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async getUserBySuperTokenId({ superTokensUserId }) {
|
||||
return shared.getUserBySuperTokenId({ superTokensUserId }, pool);
|
||||
|
|
@ -1460,12 +1519,15 @@ export async function createStorage(
|
|||
|
||||
return results.rows.map(transformOrganization);
|
||||
},
|
||||
async getOrganizationByInviteCode({ inviteCode }) {
|
||||
async getOrganizationByInviteCode({ inviteCode, email }) {
|
||||
const result = await pool.maybeOne<Slonik<organizations>>(
|
||||
sql`/* getOrganizationByInviteCode */
|
||||
SELECT o.* FROM organizations as o
|
||||
LEFT JOIN organization_invitations as i ON (i.organization_id = o.id)
|
||||
WHERE i.code = ${inviteCode} AND i.expires_at > NOW()
|
||||
WHERE
|
||||
i.code = ${inviteCode}
|
||||
AND i.email = ${email}
|
||||
AND i.expires_at > NOW()
|
||||
GROUP BY o.id
|
||||
LIMIT 1
|
||||
`,
|
||||
|
|
@ -3185,6 +3247,7 @@ export async function createStorage(
|
|||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
FROM
|
||||
"oidc_integrations"
|
||||
WHERE
|
||||
|
|
@ -3215,6 +3278,7 @@ export async function createStorage(
|
|||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
FROM
|
||||
"oidc_integrations"
|
||||
WHERE
|
||||
|
|
@ -3287,6 +3351,7 @@ export async function createStorage(
|
|||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
`);
|
||||
|
||||
return {
|
||||
|
|
@ -3346,6 +3411,7 @@ export async function createStorage(
|
|||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
`);
|
||||
|
||||
return decodeOktaIntegrationRecord(result);
|
||||
|
|
@ -3357,6 +3423,7 @@ export async function createStorage(
|
|||
SET
|
||||
"oidc_user_join_only" = ${args.oidcUserJoinOnly ?? sql`"oidc_user_join_only"`}
|
||||
, "oidc_user_access_only" = ${args.oidcUserAccessOnly ?? sql`"oidc_user_access_only"`}
|
||||
, "require_invitation" = ${args.requireInvitation ?? sql`"require_invitation"`}
|
||||
WHERE
|
||||
"id" = ${args.oidcIntegrationId}
|
||||
RETURNING
|
||||
|
|
@ -3373,6 +3440,7 @@ export async function createStorage(
|
|||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
`);
|
||||
|
||||
return decodeOktaIntegrationRecord(result);
|
||||
|
|
@ -3400,6 +3468,7 @@ export async function createStorage(
|
|||
, "additional_scopes"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
`);
|
||||
|
||||
return decodeOktaIntegrationRecord(result);
|
||||
|
|
@ -3442,6 +3511,7 @@ export async function createStorage(
|
|||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
, "require_invitation"
|
||||
`);
|
||||
|
||||
return decodeOktaIntegrationRecord(result);
|
||||
|
|
@ -4968,6 +5038,7 @@ const OktaIntegrationBaseModel = zod.object({
|
|||
oidc_user_access_only: zod.boolean(),
|
||||
default_role_id: zod.string().nullable(),
|
||||
default_assigned_resources: zod.any().nullable(),
|
||||
require_invitation: zod.boolean(),
|
||||
});
|
||||
|
||||
const OktaIntegrationLegacyModel = zod.intersection(
|
||||
|
|
@ -5004,6 +5075,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => {
|
|||
additionalScopes: rawRecord.additional_scopes,
|
||||
oidcUserJoinOnly: rawRecord.oidc_user_join_only,
|
||||
oidcUserAccessOnly: rawRecord.oidc_user_access_only,
|
||||
requireInvitation: rawRecord.require_invitation,
|
||||
defaultMemberRoleId: rawRecord.default_role_id,
|
||||
defaultResourceAssignment: rawRecord.default_assigned_resources,
|
||||
};
|
||||
|
|
@ -5020,6 +5092,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => {
|
|||
additionalScopes: rawRecord.additional_scopes,
|
||||
oidcUserJoinOnly: rawRecord.oidc_user_join_only,
|
||||
oidcUserAccessOnly: rawRecord.oidc_user_access_only,
|
||||
requireInvitation: rawRecord.require_invitation,
|
||||
defaultMemberRoleId: rawRecord.default_role_id,
|
||||
defaultResourceAssignment: rawRecord.default_assigned_resources,
|
||||
};
|
||||
|
|
@ -5641,6 +5714,31 @@ export type PaginatedOrganizationInvitationConnection = Readonly<{
|
|||
}>;
|
||||
}>;
|
||||
|
||||
const getOrganizationInvitationId = (keys: {
|
||||
organizationId: string;
|
||||
email: string;
|
||||
code: string;
|
||||
}) => Buffer.from([keys.organizationId, keys.email, keys.code].join(':')).toString('hex');
|
||||
const OrganizationInvitationModel = zod
|
||||
.object({
|
||||
organizationId: zod.string(),
|
||||
code: zod.string(),
|
||||
email: zod.string().email(),
|
||||
createdAt: zod.number().transform(v => new Date(v).toISOString()),
|
||||
expiresAt: zod.number().transform(v => new Date(v).toISOString()),
|
||||
roleId: zod.string(),
|
||||
assignedResources: zod.any(),
|
||||
})
|
||||
.transform(
|
||||
invitation =>
|
||||
({
|
||||
...invitation,
|
||||
get id(): string {
|
||||
return getOrganizationInvitationId(this);
|
||||
},
|
||||
}) as OrganizationInvitation,
|
||||
);
|
||||
|
||||
export const userFields = (user: TaggedTemplateLiteralInvocation) => sql`
|
||||
${user}"id"
|
||||
, ${user}"email"
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export function MemberInvitationButton(props: {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="ml-4 min-w-[140px]">
|
||||
<Button className="ml-4 min-w-[140px]" data-cy="send-invite-trigger">
|
||||
<MailIcon size={14} className="mr-2" /> Send Invite
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
|
|
|||
|
|
@ -871,6 +871,7 @@ const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(`
|
|||
additionalScopes
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
requireInvitation
|
||||
defaultMemberRole {
|
||||
id
|
||||
...OIDCDefaultRoleSelector_MemberRoleFragment
|
||||
|
|
@ -922,6 +923,7 @@ const UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation = graphql(`
|
|||
id
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
requireInvitation
|
||||
}
|
||||
}
|
||||
error {
|
||||
|
|
@ -985,7 +987,7 @@ function UpdateOIDCIntegrationForm(props: {
|
|||
});
|
||||
|
||||
const onOidcRestrictionChange = async (
|
||||
name: 'oidcUserJoinOnly' | 'oidcUserAccessOnly',
|
||||
name: 'oidcUserJoinOnly' | 'oidcUserAccessOnly' | 'requireInvitation',
|
||||
value: boolean,
|
||||
) => {
|
||||
if (oidcRestrictionsMutation.fetching) {
|
||||
|
|
@ -1014,6 +1016,9 @@ function UpdateOIDCIntegrationForm(props: {
|
|||
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 {
|
||||
|
|
@ -1113,6 +1118,22 @@ function UpdateOIDCIntegrationForm(props: {
|
|||
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={props.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',
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function AuthCallback(props: { provider: Provider; redirectToPath: string }) {
|
|||
description={
|
||||
auth.data.status === 'NO_EMAIL_GIVEN_BY_PROVIDER'
|
||||
? 'No email address was provided by the auth provider. Please try again.'
|
||||
: 'Sign in not allowed.'
|
||||
: auth.data.reason
|
||||
}
|
||||
/>
|
||||
</AuthCard>
|
||||
|
|
|
|||
Loading…
Reference in a new issue