feat: block OIDC sign ups without invitation (#7600)

This commit is contained in:
Iha Shin (신의하) 2026-02-11 22:50:37 +09:00 committed by GitHub
parent 39432a7aa0
commit cf63917f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 482 additions and 151 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -363,6 +363,7 @@ export class OIDCIntegrationsProvider {
oidcIntegrationId: string;
oidcUserJoinOnly: boolean | null;
oidcUserAccessOnly: boolean | null;
requireInvitation: boolean | null;
}) {
if (this.isEnabled() === false) {
return {

View file

@ -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') {

View file

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

View file

@ -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(_: {

View file

@ -225,6 +225,7 @@ export interface OIDCIntegration {
additionalScopes: string[];
oidcUserJoinOnly: boolean;
oidcUserAccessOnly: boolean;
requireInvitation: boolean;
defaultMemberRoleId: string | null;
defaultResourceAssignment: ResourceAssignmentGroup | null;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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