mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Co-authored-by: Iha Shin <me@xiniha.dev>
This commit is contained in:
parent
ef246a17fe
commit
1f38d9064c
44 changed files with 909 additions and 497 deletions
7
.github/workflows/tests-e2e.yaml
vendored
7
.github/workflows/tests-e2e.yaml
vendored
|
|
@ -38,6 +38,13 @@ 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: |
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ describe('oidc', () => {
|
|||
cy.get('button[value="login"]').click();
|
||||
|
||||
cy.get(`a[href="/${slug}"]`).should('exist');
|
||||
// Organization picker should not be visible
|
||||
cy.get('[data-cy="organization-picker-current"]').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -151,6 +149,57 @@ describe('oidc', () => {
|
|||
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);
|
||||
|
||||
const slug = generateRandomSlug();
|
||||
cy.createOIDCIntegration(slug).then(({ organizationSlug }) => {
|
||||
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();
|
||||
|
||||
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.visit('/logout');
|
||||
cy.clearAllCookies();
|
||||
cy.clearAllLocalStorage();
|
||||
cy.clearAllSessionStorage();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
it('oidc login for invalid url shows correct error message', () => {
|
||||
cy.clearAllCookies();
|
||||
cy.clearAllLocalStorage();
|
||||
|
|
|
|||
|
|
@ -57,10 +57,17 @@ const signUpUserViaEmail = async (
|
|||
}
|
||||
};
|
||||
|
||||
const createSessionPayload = (superTokensUserId: string, email: string) => ({
|
||||
version: '1',
|
||||
superTokensUserId,
|
||||
email,
|
||||
const createSessionPayload = (payload: {
|
||||
superTokensUserId: string;
|
||||
userId: string;
|
||||
oidcIntegrationId: string | null;
|
||||
email: string;
|
||||
}) => ({
|
||||
version: '2',
|
||||
superTokensUserId: payload.superTokensUserId,
|
||||
userId: payload.userId,
|
||||
oidcIntegrationId: payload.oidcIntegrationId,
|
||||
email: payload.email,
|
||||
});
|
||||
|
||||
const CreateSessionModel = z.object({
|
||||
|
|
@ -89,7 +96,7 @@ const createSession = async (
|
|||
],
|
||||
});
|
||||
|
||||
await internalApi.ensureUser.mutate({
|
||||
const { user } = await internalApi.ensureUser.mutate({
|
||||
superTokensUserId,
|
||||
email,
|
||||
oidcIntegrationId,
|
||||
|
|
@ -97,7 +104,12 @@ const createSession = async (
|
|||
lastName: null,
|
||||
});
|
||||
|
||||
const sessionData = createSessionPayload(superTokensUserId, email);
|
||||
const sessionData = createSessionPayload({
|
||||
superTokensUserId,
|
||||
userId: user.id,
|
||||
oidcIntegrationId,
|
||||
email,
|
||||
});
|
||||
const payload = {
|
||||
enableAntiCsrf: false,
|
||||
userId: superTokensUserId,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,17 @@ export function getOrganization(organizationSlug: string, authToken: string) {
|
|||
reportingOperations
|
||||
enablingUsageBasedBreakingChanges
|
||||
}
|
||||
me {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
role {
|
||||
id
|
||||
name
|
||||
permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
|
|
|
|||
|
|
@ -891,48 +891,68 @@ export function initSeed() {
|
|||
},
|
||||
};
|
||||
},
|
||||
async inviteAndJoinMember(
|
||||
inviteToken: string = ownerToken,
|
||||
memberRoleId: string | undefined = undefined,
|
||||
resources: GraphQLSchema.ResourceAssignmentInput | undefined = undefined,
|
||||
) {
|
||||
const memberEmail = userEmail(generateUnique());
|
||||
const memberToken = await authenticate(memberEmail).then(r => r.access_token);
|
||||
|
||||
const invitationResult = await inviteToOrganization(
|
||||
async inviteAndJoinMember(options?: {
|
||||
inviteToken?: string;
|
||||
memberRoleId?: string | undefined;
|
||||
oidcIntegrationId?: string | undefined;
|
||||
resources?: GraphQLSchema.ResourceAssignmentInput | undefined;
|
||||
}) {
|
||||
const { inviteToken, memberRoleId, oidcIntegrationId, resources } = Object.assign(
|
||||
options ?? {},
|
||||
{
|
||||
organization: {
|
||||
bySelector: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
},
|
||||
email: memberEmail,
|
||||
memberRoleId,
|
||||
resources,
|
||||
inviteToken: ownerToken,
|
||||
},
|
||||
inviteToken,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const code =
|
||||
invitationResult.inviteToOrganizationByEmail.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!code) {
|
||||
throw new Error(
|
||||
`Could not create invitation for ${memberEmail} to join org ${organization.slug}`,
|
||||
);
|
||||
}
|
||||
|
||||
const joinResult = await joinOrganization(code, memberToken).then(r =>
|
||||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
const memberEmail = userEmail(generateUnique());
|
||||
const memberToken = await authenticate(memberEmail, oidcIntegrationId).then(
|
||||
r => r.access_token,
|
||||
);
|
||||
|
||||
if (joinResult.joinOrganization.__typename !== 'OrganizationPayload') {
|
||||
throw new Error(
|
||||
`Member ${memberEmail} could not join organization ${organization.slug}`,
|
||||
if (!oidcIntegrationId) {
|
||||
const invitationResult = await inviteToOrganization(
|
||||
{
|
||||
organization: {
|
||||
bySelector: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
},
|
||||
email: memberEmail,
|
||||
memberRoleId,
|
||||
resources,
|
||||
},
|
||||
inviteToken,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
const code =
|
||||
invitationResult.inviteToOrganizationByEmail.ok?.createdOrganizationInvitation
|
||||
.code;
|
||||
|
||||
if (!code) {
|
||||
throw new Error(
|
||||
`Could not create invitation for ${memberEmail} to join org ${organization.slug}`,
|
||||
);
|
||||
}
|
||||
|
||||
const joinResult = await joinOrganization(code, memberToken).then(r =>
|
||||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
|
||||
if (joinResult.joinOrganization.__typename !== 'OrganizationPayload') {
|
||||
throw new Error(
|
||||
`Member ${memberEmail} could not join organization ${organization.slug}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const member = joinResult.joinOrganization.organization.me;
|
||||
const orgAfterJoin = await getOrganization(organization.slug, memberToken).then(r =>
|
||||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
const member = orgAfterJoin.organization?.me;
|
||||
|
||||
if (!member) {
|
||||
throw new Error(
|
||||
`Could not retrieve membership for ${memberEmail} in ${organization.slug} after joining`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
member,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ const OrganizationWithOIDCIntegration = graphql(`
|
|||
id
|
||||
oidcIntegration {
|
||||
id
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
tokenEndpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +37,7 @@ const CreateOIDCIntegrationMutation = graphql(`
|
|||
userinfoEndpoint
|
||||
authorizationEndpoint
|
||||
additionalScopes
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +62,7 @@ const UpdateOIDCRestrictionsMutation = graphql(`
|
|||
ok {
|
||||
updatedOIDCIntegration {
|
||||
id
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +106,8 @@ describe('create', () => {
|
|||
tokenEndpoint: 'http://localhost:8888/oauth/token',
|
||||
userinfoEndpoint: 'http://localhost:8888/oauth/userinfo',
|
||||
authorizationEndpoint: 'http://localhost:8888/oauth/authorize',
|
||||
oidcUserAccessOnly: true,
|
||||
oidcUserJoinOnly: true,
|
||||
oidcUserAccessOnly: false,
|
||||
additionalScopes: [],
|
||||
},
|
||||
},
|
||||
|
|
@ -122,7 +127,9 @@ describe('create', () => {
|
|||
id: organization.id,
|
||||
oidcIntegration: {
|
||||
id: result.createOIDCIntegration.ok!.createdOIDCIntegration.id,
|
||||
oidcUserAccessOnly: true,
|
||||
oidcUserJoinOnly: true,
|
||||
oidcUserAccessOnly: false,
|
||||
tokenEndpoint: 'http://localhost:8888/oauth/token',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -386,7 +393,8 @@ describe('create', () => {
|
|||
tokenEndpoint: 'http://localhost:8888/oauth/token',
|
||||
userinfoEndpoint: 'http://localhost:8888/oauth/userinfo',
|
||||
authorizationEndpoint: 'http://localhost:8888/oauth/authorize',
|
||||
oidcUserAccessOnly: true,
|
||||
oidcUserJoinOnly: true,
|
||||
oidcUserAccessOnly: false,
|
||||
additionalScopes: [],
|
||||
},
|
||||
},
|
||||
|
|
@ -479,7 +487,9 @@ describe('delete', () => {
|
|||
id: organization.id,
|
||||
oidcIntegration: {
|
||||
id: oidcIntegrationId,
|
||||
oidcUserAccessOnly: true,
|
||||
oidcUserJoinOnly: true,
|
||||
oidcUserAccessOnly: false,
|
||||
tokenEndpoint: 'http://localhost:8888/oauth/token',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -561,79 +571,6 @@ describe('delete', () => {
|
|||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test.concurrent(
|
||||
'success: upon integration deletion oidc members are also deleted',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
const { organization } = await createOrg();
|
||||
|
||||
const createResult = await execute({
|
||||
document: CreateOIDCIntegrationMutation,
|
||||
variables: {
|
||||
input: {
|
||||
organizationId: organization.id,
|
||||
clientId: 'foo',
|
||||
clientSecret: 'foofoofoofoo',
|
||||
tokenEndpoint: 'http://localhost:8888/oauth/token',
|
||||
userinfoEndpoint: 'http://localhost:8888/oauth/userinfo',
|
||||
authorizationEndpoint: 'http://localhost:8888/oauth/authorize',
|
||||
additionalScopes: [],
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const oidcIntegrationId = createResult.createOIDCIntegration.ok!.createdOIDCIntegration.id;
|
||||
|
||||
const MeQuery = graphql(`
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const { access_token: memberAccessToken } = await seed.authenticate(
|
||||
seed.generateEmail(),
|
||||
oidcIntegrationId,
|
||||
);
|
||||
const meResult = await execute({
|
||||
document: MeQuery,
|
||||
authToken: memberAccessToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(meResult).toEqual({
|
||||
me: {
|
||||
id: expect.any(String),
|
||||
},
|
||||
});
|
||||
|
||||
await execute({
|
||||
document: DeleteOIDCIntegrationMutation,
|
||||
variables: {
|
||||
input: {
|
||||
oidcIntegrationId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const refetchedMeResult = await execute({
|
||||
document: MeQuery,
|
||||
authToken: memberAccessToken,
|
||||
}).then(r => r.expectGraphQLErrors());
|
||||
|
||||
expect(refetchedMeResult).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: `No access (reason: "User not found")`,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -792,7 +729,8 @@ describe('restrictions', () => {
|
|||
ok: {
|
||||
createdOIDCIntegration: {
|
||||
id: expect.any(String),
|
||||
oidcUserAccessOnly: true,
|
||||
oidcUserJoinOnly: true,
|
||||
oidcUserAccessOnly: false,
|
||||
clientId: 'foo',
|
||||
clientSecretPreview: 'ofoo',
|
||||
tokenEndpoint: 'http://localhost:8888/oauth/token',
|
||||
|
|
@ -807,45 +745,50 @@ describe('restrictions', () => {
|
|||
return result.createOIDCIntegration.ok!.createdOIDCIntegration.id;
|
||||
}
|
||||
|
||||
test.concurrent('non-oidc users cannot join an organization (default)', async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
const { organization, inviteMember, joinMemberUsingCode } = await createOrg();
|
||||
test.concurrent(
|
||||
'users authorized with non-OIDC method cannot join an organization (default)',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
const { organization, inviteMember, joinMemberUsingCode } = await createOrg();
|
||||
|
||||
await configureOIDC({
|
||||
ownerToken,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
await configureOIDC({
|
||||
ownerToken,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const refetchedOrg = await execute({
|
||||
document: OrganizationWithOIDCIntegration,
|
||||
variables: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
const refetchedOrg = await execute({
|
||||
document: OrganizationWithOIDCIntegration,
|
||||
variables: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(refetchedOrg.organization?.oidcIntegration?.oidcUserAccessOnly).toEqual(true);
|
||||
expect(refetchedOrg.organization?.oidcIntegration?.oidcUserJoinOnly).toEqual(true);
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const joinResult = await joinMemberUsingCode(invitationCode, nonOidcAccount.access_token).then(
|
||||
r => r.expectNoGraphQLErrors(),
|
||||
);
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const joinResult = await joinMemberUsingCode(
|
||||
invitationCode,
|
||||
nonOidcAccount.access_token,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(joinResult.joinOrganization).toEqual(
|
||||
expect.objectContaining({
|
||||
__typename: 'OrganizationInvitationError',
|
||||
message: 'Non-OIDC users are not allowed to join this organization.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(joinResult.joinOrganization).toEqual(
|
||||
expect.objectContaining({
|
||||
__typename: 'OrganizationInvitationError',
|
||||
message:
|
||||
'The user is not authorized through the OIDC integration required for the organization',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent('non-oidc users can join an organization (opt-in)', async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
|
|
@ -865,21 +808,21 @@ describe('restrictions', () => {
|
|||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(orgAfterOidc.organization?.oidcIntegration?.oidcUserAccessOnly).toEqual(true);
|
||||
expect(orgAfterOidc.organization?.oidcIntegration?.oidcUserJoinOnly).toEqual(true);
|
||||
|
||||
const restrictionsUpdateResult = await execute({
|
||||
document: UpdateOIDCRestrictionsMutation,
|
||||
variables: {
|
||||
input: {
|
||||
oidcIntegrationId,
|
||||
oidcUserAccessOnly: false,
|
||||
oidcUserJoinOnly: false,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(
|
||||
restrictionsUpdateResult.updateOIDCRestrictions.ok?.updatedOIDCIntegration.oidcUserAccessOnly,
|
||||
restrictionsUpdateResult.updateOIDCRestrictions.ok?.updatedOIDCIntegration.oidcUserJoinOnly,
|
||||
).toEqual(false);
|
||||
|
||||
const orgAfterDisablingOidcRestrictions = await execute({
|
||||
|
|
@ -891,7 +834,7 @@ describe('restrictions', () => {
|
|||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(
|
||||
orgAfterDisablingOidcRestrictions.organization?.oidcIntegration?.oidcUserAccessOnly,
|
||||
orgAfterDisablingOidcRestrictions.organization?.oidcIntegration?.oidcUserJoinOnly,
|
||||
).toEqual(false);
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
|
|
@ -910,7 +853,7 @@ describe('restrictions', () => {
|
|||
});
|
||||
|
||||
test.concurrent(
|
||||
'existing non-oidc users can always access the organization',
|
||||
'existing non-oidc users can access the organization (default)',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
|
|
@ -947,6 +890,82 @@ describe('restrictions', () => {
|
|||
expect(readAccessCheck.organization?.id).toEqual(organization.id);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'existing non-oidc users should lose access to the organization (opt-in)',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { ownerToken, createOrg } = await seed.createOwner();
|
||||
const { organization, inviteMember, joinMemberUsingCode } = await createOrg();
|
||||
|
||||
const invitation = await inviteMember('example@example.com');
|
||||
const invitationCode = invitation.ok?.createdOrganizationInvitation.code;
|
||||
|
||||
if (!invitationCode) {
|
||||
throw new Error('No invitation code');
|
||||
}
|
||||
|
||||
const nonOidcAccount = await seed.authenticate(userEmail('non-oidc-user'));
|
||||
const joinResult = await joinMemberUsingCode(
|
||||
invitationCode,
|
||||
nonOidcAccount.access_token,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(joinResult.joinOrganization.__typename).toEqual('OrganizationPayload');
|
||||
|
||||
const oidcIntegrationId = await configureOIDC({
|
||||
ownerToken,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const orgAfterOidc = await execute({
|
||||
document: OrganizationWithOIDCIntegration,
|
||||
variables: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(orgAfterOidc.organization?.oidcIntegration?.oidcUserAccessOnly).toEqual(false);
|
||||
|
||||
const restrictionsUpdateResult = await execute({
|
||||
document: UpdateOIDCRestrictionsMutation,
|
||||
variables: {
|
||||
input: {
|
||||
oidcIntegrationId,
|
||||
oidcUserAccessOnly: true,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(
|
||||
restrictionsUpdateResult.updateOIDCRestrictions.ok?.updatedOIDCIntegration
|
||||
.oidcUserAccessOnly,
|
||||
).toEqual(true);
|
||||
|
||||
const orgAfterEnablingOidcRestrictions = await execute({
|
||||
document: OrganizationWithOIDCIntegration,
|
||||
variables: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(
|
||||
orgAfterEnablingOidcRestrictions.organization?.oidcIntegration?.oidcUserAccessOnly,
|
||||
).toEqual(true);
|
||||
|
||||
const orgReadErrors = await execute({
|
||||
document: OrganizationReadTest,
|
||||
variables: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
authToken: nonOidcAccount.access_token,
|
||||
}).then(r => r.expectGraphQLErrors());
|
||||
expect(orgReadErrors.some(e => e.message.includes('requires OIDC'))).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.concurrent(
|
||||
|
|
@ -955,10 +974,8 @@ test.concurrent(
|
|||
const seed = initSeed();
|
||||
const { createOrg, ownerToken } = await seed.createOwner();
|
||||
const { organization, inviteAndJoinMember } = await createOrg();
|
||||
const { createMemberRole, assignMemberRole, updateMemberRole, memberToken, member } =
|
||||
await inviteAndJoinMember();
|
||||
|
||||
await execute({
|
||||
const createOIDCIntegrationResult = await execute({
|
||||
document: CreateOIDCIntegrationMutation,
|
||||
variables: {
|
||||
input: {
|
||||
|
|
@ -973,7 +990,11 @@ test.concurrent(
|
|||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
const oidcIntegrationId =
|
||||
createOIDCIntegrationResult.createOIDCIntegration.ok?.createdOIDCIntegration.id;
|
||||
|
||||
const { createMemberRole, assignMemberRole, updateMemberRole, memberToken, member } =
|
||||
await inviteAndJoinMember({ oidcIntegrationId });
|
||||
const role = await createMemberRole([]);
|
||||
await assignMemberRole({ roleId: role.id, userId: member.id });
|
||||
|
||||
|
|
|
|||
|
|
@ -156,18 +156,21 @@ test.concurrent('invite user with assigned resouces', async ({ expect }) => {
|
|||
const m = await org.inviteAndJoinMember();
|
||||
const role = await m.createMemberRole(['organization:describe', 'project:describe']);
|
||||
|
||||
const member = await org.inviteAndJoinMember(undefined, role.id, {
|
||||
mode: ResourceAssignmentModeType.Granular,
|
||||
projects: [
|
||||
{
|
||||
projectId: project1.id,
|
||||
targets: { mode: ResourceAssignmentModeType.Granular, targets: [] },
|
||||
},
|
||||
{
|
||||
projectId: project3.id,
|
||||
targets: { mode: ResourceAssignmentModeType.Granular, targets: [] },
|
||||
},
|
||||
],
|
||||
const member = await org.inviteAndJoinMember({
|
||||
memberRoleId: role.id,
|
||||
resources: {
|
||||
mode: ResourceAssignmentModeType.Granular,
|
||||
projects: [
|
||||
{
|
||||
projectId: project1.id,
|
||||
targets: { mode: ResourceAssignmentModeType.Granular, targets: [] },
|
||||
},
|
||||
{
|
||||
projectId: project3.id,
|
||||
targets: { mode: ResourceAssignmentModeType.Granular, targets: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await org.projects(member.memberToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { type MigrationExecutor } from '../pg-migrator';
|
||||
|
||||
export default {
|
||||
name: '2026.01.30T00-00-00.account-linking.ts',
|
||||
run: ({ sql }) => [
|
||||
{
|
||||
name: 'create `users_linked_identities` table',
|
||||
query: sql`
|
||||
CREATE TABLE IF NOT EXISTS "users_linked_identities" (
|
||||
"user_id" uuid NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
, "identity_id" uuid NOT NULL
|
||||
, "created_at" timestamptz NOT NULL DEFAULT now()
|
||||
, UNIQUE ("user_id", "identity_id")
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'rename `oidc_user_access_only` to `oidc_user_join_only` and re-add `oidc_user_access_only` column',
|
||||
query: sql`
|
||||
ALTER TABLE IF EXISTS "oidc_integrations"
|
||||
RENAME COLUMN "oidc_user_access_only" TO "oidc_user_join_only";
|
||||
ALTER TABLE IF EXISTS "oidc_integrations"
|
||||
ADD COLUMN IF NOT EXISTS "oidc_user_access_only" boolean NOT NULL DEFAULT false;
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies MigrationExecutor;
|
||||
|
|
@ -180,5 +180,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
|
|||
await import('./actions/2026.01.25T00-00-00.checks-proposals-changes'),
|
||||
await import('./actions/2026.01.27T00-00-00.app-deployment-protection'),
|
||||
await import('./actions/2026.01.09T00-00-00.email-verifications'),
|
||||
await import('./actions/2026.01.30T00-00-00.account-linking'),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub';
|
|||
import { REDIS_INSTANCE } from './modules/shared/providers/redis';
|
||||
import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config';
|
||||
import { Storage } from './modules/shared/providers/storage';
|
||||
import { FORWARDED_IP_HEADER_NAME, WEB_APP_URL } from './modules/shared/providers/tokens';
|
||||
import { RateLimitConfig, WEB_APP_URL } from './modules/shared/providers/tokens';
|
||||
import { supportModule } from './modules/support';
|
||||
import { provideSupportConfig, SupportConfig } from './modules/support/providers/config';
|
||||
import { targetModule } from './modules/target';
|
||||
|
|
@ -154,7 +154,9 @@ export function createRegistry({
|
|||
encryptionSecret: string;
|
||||
app: {
|
||||
baseUrl: string;
|
||||
forwardedIPHeaderName: string;
|
||||
rateLimit: null | {
|
||||
ipHeaderName: string;
|
||||
};
|
||||
} | null;
|
||||
schemaConfig: SchemaModuleConfig;
|
||||
supportConfig: SupportConfig | null;
|
||||
|
|
@ -300,8 +302,8 @@ export function createRegistry({
|
|||
scope: Scope.Singleton,
|
||||
},
|
||||
{
|
||||
provide: FORWARDED_IP_HEADER_NAME,
|
||||
useValue: app?.forwardedIPHeaderName,
|
||||
provide: RateLimitConfig,
|
||||
useValue: new RateLimitConfig(app?.rateLimit ?? null),
|
||||
scope: Scope.Singleton,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function parseResourceIdentifier(resource: string) {
|
|||
export type UserActor = {
|
||||
type: 'user';
|
||||
user: User;
|
||||
oidcIntegrationId: string | null;
|
||||
};
|
||||
|
||||
export type OrganizationAccessTokenActor = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import SessionNode from 'supertokens-node/recipe/session/index.js';
|
|||
import * as zod from 'zod';
|
||||
import type { FastifyReply, FastifyRequest } from '@hive/service-common';
|
||||
import { captureException } from '@sentry/node';
|
||||
import { AccessError, HiveError } from '../../../shared/errors';
|
||||
import { AccessError, HiveError, OIDCRequiredError } from '../../../shared/errors';
|
||||
import { isUUID } from '../../../shared/is-uuid';
|
||||
import { OrganizationMembers } from '../../organization/providers/organization-members';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
|
|
@ -14,15 +14,27 @@ export class SuperTokensCookieBasedSession extends Session {
|
|||
public superTokensUserId: string;
|
||||
private organizationMembers: OrganizationMembers;
|
||||
private storage: Storage;
|
||||
/**
|
||||
* The properties `userId` and `oidcIntegrationId` are nullable for backwards compatibility.
|
||||
* In the future, when all still active sessions are using the new format, we can remove the nullability.
|
||||
*/
|
||||
public userId: string | null = null;
|
||||
public oidcIntegrationId: string | null = null;
|
||||
|
||||
constructor(
|
||||
args: { superTokensUserId: string; email: string },
|
||||
sessionPayload: SuperTokensSessionPayload,
|
||||
deps: { organizationMembers: OrganizationMembers; storage: Storage; logger: Logger },
|
||||
) {
|
||||
super({ logger: deps.logger });
|
||||
this.superTokensUserId = args.superTokensUserId;
|
||||
this.superTokensUserId = sessionPayload.superTokensUserId;
|
||||
|
||||
this.organizationMembers = deps.organizationMembers;
|
||||
this.storage = deps.storage;
|
||||
|
||||
if (sessionPayload.version === '2') {
|
||||
this.userId = sessionPayload.userId;
|
||||
this.oidcIntegrationId = sessionPayload.oidcIntegrationId;
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
|
|
@ -55,7 +67,12 @@ export class SuperTokensCookieBasedSession extends Session {
|
|||
user.id,
|
||||
organizationId,
|
||||
);
|
||||
const organization = await this.storage.getOrganization({ organizationId });
|
||||
const [organization, oidcIntegration] = await Promise.all([
|
||||
this.storage.getOrganization({ organizationId }),
|
||||
this.storage.getOIDCIntegrationForOrganization({
|
||||
organizationId,
|
||||
}),
|
||||
]);
|
||||
const organizationMembership = await this.organizationMembers.findOrganizationMembership({
|
||||
organization,
|
||||
userId: user.id,
|
||||
|
|
@ -100,6 +117,10 @@ export class SuperTokensCookieBasedSession extends Session {
|
|||
];
|
||||
}
|
||||
|
||||
if (oidcIntegration?.oidcUserAccessOnly && this.oidcIntegrationId !== oidcIntegration.id) {
|
||||
throw new OIDCRequiredError(organization.slug, oidcIntegration.id);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
'Translate organization role assignments to policy statements. (userId=%s, organizationId=%s)',
|
||||
user.id,
|
||||
|
|
@ -110,9 +131,9 @@ export class SuperTokensCookieBasedSession extends Session {
|
|||
}
|
||||
|
||||
public async getActor(): Promise<UserActor> {
|
||||
const user = await this.storage.getUserBySuperTokenId({
|
||||
superTokensUserId: this.superTokensUserId,
|
||||
});
|
||||
const user = this.userId
|
||||
? await this.storage.getUserById({ id: this.userId })
|
||||
: await this.storage.getUserBySuperTokenId({ superTokensUserId: this.superTokensUserId });
|
||||
|
||||
if (!user) {
|
||||
throw new AccessError('User not found');
|
||||
|
|
@ -121,6 +142,7 @@ export class SuperTokensCookieBasedSession extends Session {
|
|||
return {
|
||||
type: 'user',
|
||||
user,
|
||||
oidcIntegrationId: this.oidcIntegrationId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +170,10 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
this.emailVerification = deps.emailVerification;
|
||||
}
|
||||
|
||||
private async verifySuperTokensSession(args: { req: FastifyRequest; reply: FastifyReply }) {
|
||||
private async verifySuperTokensSession(args: {
|
||||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
}): Promise<SuperTokensSessionPayload | null> {
|
||||
this.logger.debug('Attempt verifying SuperTokens session');
|
||||
|
||||
if (args.req.headers['ignore-session']) {
|
||||
|
|
@ -199,7 +224,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
return null;
|
||||
}
|
||||
|
||||
const result = SuperTokenAccessTokenModel.safeParse(payload);
|
||||
const result = SuperTokensSessionPayloadModel.safeParse(payload);
|
||||
|
||||
if (result.success === false) {
|
||||
this.logger.error('SuperTokens session payload is invalid');
|
||||
|
|
@ -208,7 +233,11 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
'SuperTokens session parsing errors: %s',
|
||||
JSON.stringify(result.error.flatten().fieldErrors),
|
||||
);
|
||||
throw new HiveError(`Invalid access token provided`);
|
||||
throw new HiveError('Invalid access token provided', {
|
||||
extensions: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.emailVerification) {
|
||||
|
|
@ -235,29 +264,43 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
|
|||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
}): Promise<SuperTokensCookieBasedSession | null> {
|
||||
const session = await this.verifySuperTokensSession(args);
|
||||
if (!session) {
|
||||
const sessionPayload = await this.verifySuperTokensSession(args);
|
||||
if (!sessionPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug('SuperTokens session resolved successfully');
|
||||
|
||||
return new SuperTokensCookieBasedSession(
|
||||
{
|
||||
superTokensUserId: session.superTokensUserId,
|
||||
email: session.email,
|
||||
},
|
||||
{
|
||||
storage: this.storage,
|
||||
organizationMembers: this.organizationMembers,
|
||||
logger: args.req.log,
|
||||
},
|
||||
);
|
||||
return new SuperTokensCookieBasedSession(sessionPayload, {
|
||||
storage: this.storage,
|
||||
organizationMembers: this.organizationMembers,
|
||||
logger: args.req.log,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const SuperTokenAccessTokenModel = zod.object({
|
||||
/**
|
||||
* This is the legacy format that is no longer issued for new logins.
|
||||
* In the future, when all sessions using this access token payload format are expired
|
||||
* we can remove it from here.
|
||||
*/
|
||||
const SuperTokensSessionPayloadV1Model = zod.object({
|
||||
version: zod.literal('1'),
|
||||
superTokensUserId: zod.string(),
|
||||
email: zod.string(),
|
||||
});
|
||||
|
||||
const SuperTokensSessionPayloadV2Model = zod.object({
|
||||
version: zod.literal('2'),
|
||||
superTokensUserId: zod.string(),
|
||||
email: zod.string(),
|
||||
userId: zod.string(),
|
||||
oidcIntegrationId: zod.string().nullable(),
|
||||
});
|
||||
|
||||
const SuperTokensSessionPayloadModel = zod.union([
|
||||
SuperTokensSessionPayloadV1Model,
|
||||
SuperTokensSessionPayloadV2Model,
|
||||
]);
|
||||
|
||||
type SuperTokensSessionPayload = zod.TypeOf<typeof SuperTokensSessionPayloadModel>;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export class EmailVerification {
|
|||
userIdentityId: string;
|
||||
resend?: boolean;
|
||||
},
|
||||
ipAddress: string,
|
||||
ipAddress: string | null,
|
||||
): Promise<
|
||||
| { ok: true; expiresAt: Date }
|
||||
| {
|
||||
|
|
@ -109,13 +109,15 @@ export class EmailVerification {
|
|||
emailAlreadyVerified: boolean;
|
||||
}
|
||||
> {
|
||||
await this.rateLimiter.check(
|
||||
'sendVerificationEmail',
|
||||
ipAddress,
|
||||
60_000,
|
||||
3,
|
||||
`Exceeded rate limit for sending verification emails.`,
|
||||
);
|
||||
if (ipAddress) {
|
||||
await this.rateLimiter.check(
|
||||
'sendVerificationEmail',
|
||||
ipAddress,
|
||||
60_000,
|
||||
3,
|
||||
`Exceeded rate limit for sending verification emails.`,
|
||||
);
|
||||
}
|
||||
|
||||
const superTokensUser = await this.pool
|
||||
.maybeOne(
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { FORWARDED_IP_HEADER_NAME } from '../../../../modules/shared/providers/tokens';
|
||||
import { RateLimitConfig } from '../../../../modules/shared/providers/tokens';
|
||||
import { EmailVerification } from '../../providers/email-verification';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const sendVerificationEmail: NonNullable<
|
||||
MutationResolvers['sendVerificationEmail']
|
||||
> = async (_, { input }, { injector, req }) => {
|
||||
const forwardedIPHeaderName = injector.get(FORWARDED_IP_HEADER_NAME);
|
||||
const rateLimitConfig = injector.get(RateLimitConfig);
|
||||
|
||||
const result = await injector.get(EmailVerification).sendVerificationEmail(
|
||||
{
|
||||
userIdentityId: input.userIdentityId,
|
||||
resend: input.resend ?? undefined,
|
||||
},
|
||||
req.headers[forwardedIPHeaderName]?.toString() ?? req.ip,
|
||||
rateLimitConfig.config
|
||||
? (req.headers[rateLimitConfig.config.ipHeaderName]?.toString() ?? req.ip)
|
||||
: null,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export default gql`
|
|||
userinfoEndpoint: String!
|
||||
authorizationEndpoint: String!
|
||||
additionalScopes: [String!]!
|
||||
oidcUserJoinOnly: Boolean!
|
||||
oidcUserAccessOnly: Boolean!
|
||||
defaultMemberRole: MemberRole!
|
||||
defaultResourceAssignment: ResourceAssignment
|
||||
|
|
@ -164,7 +165,8 @@ export default gql`
|
|||
Applies only to newly invited members.
|
||||
Existing members are not affected.
|
||||
"""
|
||||
oidcUserAccessOnly: Boolean!
|
||||
oidcUserJoinOnly: Boolean
|
||||
oidcUserAccessOnly: Boolean
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export class OIDCIntegrationsProvider {
|
|||
|
||||
async getOIDCIntegrationForOrganization(args: {
|
||||
organizationId: string;
|
||||
skipAccessCheck?: boolean;
|
||||
}): Promise<OIDCIntegration | null> {
|
||||
this.logger.debug(
|
||||
'getting oidc integration for organization (organizationId=%s)',
|
||||
|
|
@ -64,16 +65,18 @@ export class OIDCIntegrationsProvider {
|
|||
return null;
|
||||
}
|
||||
|
||||
const canPerformAction = await this.session.canPerformAction({
|
||||
organizationId: args.organizationId,
|
||||
action: 'oidc:modify',
|
||||
params: {
|
||||
if (!args.skipAccessCheck) {
|
||||
const canPerformAction = await this.session.canPerformAction({
|
||||
organizationId: args.organizationId,
|
||||
},
|
||||
});
|
||||
action: 'oidc:modify',
|
||||
params: {
|
||||
organizationId: args.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (canPerformAction === false) {
|
||||
return null;
|
||||
if (canPerformAction === false) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.storage.getOIDCIntegrationForOrganization({
|
||||
|
|
@ -356,7 +359,11 @@ export class OIDCIntegrationsProvider {
|
|||
} as const;
|
||||
}
|
||||
|
||||
async updateOIDCRestrictions(args: { oidcIntegrationId: string; oidcUserAccessOnly: boolean }) {
|
||||
async updateOIDCRestrictions(args: {
|
||||
oidcIntegrationId: string;
|
||||
oidcUserJoinOnly: boolean | null;
|
||||
oidcUserAccessOnly: boolean | null;
|
||||
}) {
|
||||
if (this.isEnabled() === false) {
|
||||
return {
|
||||
type: 'error',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export const updateOIDCRestrictions: NonNullable<
|
|||
> = async (_, { input }, { injector }) => {
|
||||
const result = await injector.get(OIDCIntegrationsProvider).updateOIDCRestrictions({
|
||||
oidcIntegrationId: input.oidcIntegrationId,
|
||||
oidcUserAccessOnly: input.oidcUserAccessOnly,
|
||||
oidcUserJoinOnly: input.oidcUserJoinOnly ?? null,
|
||||
oidcUserAccessOnly: input.oidcUserAccessOnly ?? null,
|
||||
});
|
||||
|
||||
if (result.type === 'ok') {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { UserResolvers } from './../../../__generated__/types';
|
||||
|
||||
export const User: Pick<UserResolvers, 'canSwitchOrganization'> = {
|
||||
canSwitchOrganization: user => !user.oidcIntegrationId,
|
||||
canSwitchOrganization: () => true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { OrganizationInvitationTask } from '@hive/workflows/tasks/organization-i
|
|||
import { OrganizationOwnershipTransferTask } from '@hive/workflows/tasks/organization-ownership-transfer';
|
||||
import * as GraphQLSchema from '../../../__generated__/types';
|
||||
import { Organization } from '../../../shared/entities';
|
||||
import { HiveError } from '../../../shared/errors';
|
||||
import { AccessError, HiveError, OIDCRequiredError } from '../../../shared/errors';
|
||||
import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder';
|
||||
import { Session } from '../../auth/lib/authz';
|
||||
import { AuthManager } from '../../auth/providers/auth-manager';
|
||||
|
|
@ -103,13 +103,21 @@ export class OrganizationManager {
|
|||
return null;
|
||||
}
|
||||
|
||||
const canAccess = await this.session.canPerformAction({
|
||||
action: 'organization:describe',
|
||||
organizationId: organization.id,
|
||||
params: {
|
||||
const canAccess = await this.session
|
||||
.assertPerformAction({
|
||||
action: 'organization:describe',
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
params: {
|
||||
organizationId: organization.id,
|
||||
},
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(err => {
|
||||
if (err instanceof AccessError && !(err instanceof OIDCRequiredError)) {
|
||||
return false;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
if (canAccess === false) {
|
||||
return null;
|
||||
|
|
@ -299,20 +307,11 @@ export class OrganizationManager {
|
|||
user: {
|
||||
id: string;
|
||||
superTokensUserId: string | null;
|
||||
oidcIntegrationId: string | null;
|
||||
};
|
||||
}) {
|
||||
const { slug, user } = input;
|
||||
this.logger.info('Creating an organization (input=%o)', input);
|
||||
|
||||
if (user.oidcIntegrationId) {
|
||||
this.logger.debug(
|
||||
'Failed to create organization as oidc user is not allowed to do so (input=%o)',
|
||||
input,
|
||||
);
|
||||
throw new HiveError('Cannot create organization with OIDC user.');
|
||||
}
|
||||
|
||||
const result = await this.storage.createOrganization({
|
||||
slug,
|
||||
userId: user.id,
|
||||
|
|
@ -656,13 +655,9 @@ export class OrganizationManager {
|
|||
async joinOrganization({ code }: { code: string }): Promise<Organization | { message: string }> {
|
||||
this.logger.info('Joining an organization (code=%s)', code);
|
||||
|
||||
const user = await this.session.getViewer();
|
||||
const isOIDCUser = user.oidcIntegrationId !== null;
|
||||
|
||||
if (isOIDCUser) {
|
||||
return {
|
||||
message: `You cannot join an organization with an OIDC account.`,
|
||||
};
|
||||
const actor = await this.session.getActor();
|
||||
if (actor.type !== 'user') {
|
||||
throw new Error('Only users can join organizations');
|
||||
}
|
||||
|
||||
const organization = await this.getOrganizationByInviteCode({
|
||||
|
|
@ -678,9 +673,10 @@ export class OrganizationManager {
|
|||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
if (oidcIntegration?.oidcUserAccessOnly && !isOIDCUser) {
|
||||
if (oidcIntegration?.oidcUserJoinOnly && actor.oidcIntegrationId !== oidcIntegration.id) {
|
||||
return {
|
||||
message: 'Non-OIDC users are not allowed to join this organization.',
|
||||
message:
|
||||
'The user is not authorized through the OIDC integration required for the organization',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -689,7 +685,7 @@ export class OrganizationManager {
|
|||
|
||||
await this.storage.addOrganizationMemberViaInvitationCode({
|
||||
code,
|
||||
userId: user.id,
|
||||
userId: actor.user.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
|
|
@ -705,7 +701,7 @@ export class OrganizationManager {
|
|||
eventType: 'USER_JOINED',
|
||||
organizationId: organization.id,
|
||||
metadata: {
|
||||
inviteeEmail: user.email,
|
||||
inviteeEmail: actor.user.email,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AccessError } from '../../../../shared/errors';
|
||||
import { Session } from '../../../auth/lib/authz';
|
||||
import { OIDCIntegrationsProvider } from '../../../oidc-integrations/providers/oidc-integrations.provider';
|
||||
import { IdTranslator } from '../../../shared/providers/id-translator';
|
||||
|
|
@ -9,29 +10,12 @@ export const myDefaultOrganization: NonNullable<QueryResolvers['myDefaultOrganiz
|
|||
{ previouslyVisitedOrganizationId: previouslyVisitedOrganizationSlug },
|
||||
{ injector },
|
||||
) => {
|
||||
const user = await injector.get(Session).getViewer();
|
||||
const organizationManager = injector.get(OrganizationManager);
|
||||
|
||||
// For an OIDC Integration User we want to return the linked organization
|
||||
if (user?.oidcIntegrationId) {
|
||||
const oidcIntegration = await injector.get(OIDCIntegrationsProvider).getOIDCIntegrationById({
|
||||
oidcIntegrationId: user.oidcIntegrationId,
|
||||
});
|
||||
if (oidcIntegration.type === 'ok') {
|
||||
const org = await organizationManager.getOrganization({
|
||||
organizationId: oidcIntegration.organizationId,
|
||||
});
|
||||
|
||||
return {
|
||||
selector: {
|
||||
organizationSlug: org.slug,
|
||||
},
|
||||
organization: org,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
const actor = await injector.get(Session).getActor();
|
||||
if (actor.type !== 'user') {
|
||||
throw new AccessError('Only authenticated users can perform this action.');
|
||||
}
|
||||
const organizationManager = injector.get(OrganizationManager);
|
||||
const oidcManager = injector.get(OIDCIntegrationsProvider);
|
||||
|
||||
// This is the organization that got stored as an cookie
|
||||
// We make sure it actually exists before directing to it.
|
||||
|
|
@ -54,17 +38,41 @@ export const myDefaultOrganization: NonNullable<QueryResolvers['myDefaultOrganiz
|
|||
}
|
||||
}
|
||||
|
||||
if (user?.id) {
|
||||
if (actor.user?.id) {
|
||||
const allOrganizations = await organizationManager.getOrganizations();
|
||||
const orgsWithOIDCConfig = await Promise.all(
|
||||
allOrganizations.map(async organization => ({
|
||||
organization,
|
||||
oidcIntegration: await oidcManager.getOIDCIntegrationForOrganization({
|
||||
organizationId: organization.id,
|
||||
skipAccessCheck: true,
|
||||
}),
|
||||
})),
|
||||
).then(arr => arr.filter(v => v != null));
|
||||
|
||||
if (allOrganizations.length > 0) {
|
||||
const firstOrg = allOrganizations[0];
|
||||
|
||||
const getPriority = (org: (typeof orgsWithOIDCConfig)[number]) => {
|
||||
// prioritize user's own organization
|
||||
if (org.organization.ownerId === actor.user.id) {
|
||||
return 2;
|
||||
}
|
||||
if (actor.oidcIntegrationId) {
|
||||
// prioritize OIDC connected organization when user is authenticated with SSO
|
||||
if (org.oidcIntegration?.id === actor.oidcIntegrationId) {
|
||||
return 1;
|
||||
}
|
||||
} else if (org.oidcIntegration?.oidcUserAccessOnly) {
|
||||
// deprioritize OIDC forced organizations when user is not authenticated with SSO
|
||||
return 4;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
const selectedOrg = orgsWithOIDCConfig.toSorted((a, b) => getPriority(a) - getPriority(b))[0];
|
||||
if (selectedOrg) {
|
||||
return {
|
||||
selector: {
|
||||
organizationSlug: firstOrg.slug,
|
||||
organizationSlug: selectedOrg.organization.slug,
|
||||
},
|
||||
organization: firstOrg,
|
||||
organization: selectedOrg.organization,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,10 @@ export interface Storage {
|
|||
};
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
}): Promise<'created' | 'no_action'>;
|
||||
}): Promise<{
|
||||
user: User;
|
||||
action: 'created' | 'no_action';
|
||||
}>;
|
||||
|
||||
getUserBySuperTokenId(_: { superTokensUserId: string }): Promise<User | null>;
|
||||
getUserById(_: { id: string }): Promise<User | null>;
|
||||
|
|
@ -642,7 +645,8 @@ export interface Storage {
|
|||
|
||||
updateOIDCRestrictions(_: {
|
||||
oidcIntegrationId: string;
|
||||
oidcUserAccessOnly: boolean;
|
||||
oidcUserJoinOnly: boolean | null;
|
||||
oidcUserAccessOnly: boolean | null;
|
||||
}): Promise<OIDCIntegration>;
|
||||
|
||||
updateOIDCDefaultMemberRole(_: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { InjectionToken } from 'graphql-modules';
|
||||
import { Injectable, InjectionToken } from 'graphql-modules';
|
||||
|
||||
export const WEB_APP_URL = new InjectionToken<string>('WEB_APP_URL');
|
||||
export const FORWARDED_IP_HEADER_NAME = new InjectionToken<string>('FORWARDED_IP_HEADER_NAME');
|
||||
|
||||
@Injectable()
|
||||
export class RateLimitConfig {
|
||||
constructor(
|
||||
public readonly config: null | {
|
||||
ipHeaderName: string;
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export interface OIDCIntegration {
|
|||
userinfoEndpoint: string;
|
||||
authorizationEndpoint: string;
|
||||
additionalScopes: string[];
|
||||
oidcUserJoinOnly: boolean;
|
||||
oidcUserAccessOnly: boolean;
|
||||
defaultMemberRoleId: string | null;
|
||||
defaultResourceAssignment: ResourceAssignmentGroup | null;
|
||||
|
|
@ -348,7 +349,6 @@ export interface User {
|
|||
provider: AuthProviderType;
|
||||
superTokensUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
oidcIntegrationId: string | null;
|
||||
zendeskId: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,15 +27,26 @@ export function isGraphQLError(error: unknown): error is GraphQLError {
|
|||
export const HiveError = GraphQLError;
|
||||
|
||||
export class AccessError extends HiveError {
|
||||
constructor(reason: string, code: string = 'UNAUTHORISED') {
|
||||
constructor(reason: string, code: string = 'UNAUTHORISED', extensions?: Record<string, unknown>) {
|
||||
super(`No access (reason: "${reason}")`, {
|
||||
extensions: {
|
||||
code,
|
||||
...extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class OIDCRequiredError extends AccessError {
|
||||
constructor(
|
||||
organizationSlug: string,
|
||||
oidcIntegrationId: string,
|
||||
reason: string = 'This action requires OIDC authentication to proceed.',
|
||||
) {
|
||||
super(reason, 'NEEDS_OIDC', { organizationSlug, oidcIntegrationId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This error indicates that the user forgot to provide a target reference input
|
||||
* when using a organization access token.
|
||||
|
|
|
|||
|
|
@ -185,13 +185,13 @@ type BatchGroup<TItem, TResult> = {
|
|||
*/
|
||||
export function batchBy<TItem, TResult>(
|
||||
/** Function to determine the batch group. */
|
||||
buildBatchKey: (arg: TItem) => string,
|
||||
buildBatchKey: (arg: TItem) => unknown,
|
||||
/** Loader for each batch group. */
|
||||
loader: (args: TItem[]) => Promise<Promise<TResult>[]>,
|
||||
/** Maximum amount of items per batch, if it is exceeded a new batch for a given batchKey is created. */
|
||||
maxBatchSize = Infinity,
|
||||
) {
|
||||
let batchGroups = new Map<string, BatchGroup<TItem, TResult>>();
|
||||
let batchGroups = new Map<unknown, BatchGroup<TItem, TResult>>();
|
||||
let didSchedule = false;
|
||||
|
||||
function startLoadingBatch(currentBatch: BatchGroup<TItem, TResult>): void {
|
||||
|
|
@ -226,7 +226,7 @@ export function batchBy<TItem, TResult>(
|
|||
);
|
||||
}
|
||||
|
||||
function getBatchGroup(batchKey: string) {
|
||||
function getBatchGroup(batchKey: unknown) {
|
||||
// get the batch collection for the batch key
|
||||
let currentBatch = batchGroups.get(batchKey);
|
||||
// if it does not exist or the batch is full, create a new batch
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ The GraphQL API for GraphQL Hive.
|
|||
| `CDN_API_KV_BASE_URL` | No (**Optional** if `CDN_API` is set to `1`) | The base URL for the KV for API Provider. Used for scenarios where we cache CDN access. | `https://key-cache.graphql-hive.com` |
|
||||
| `SUPERTOKENS_CONNECTION_URI` | **Yes** | The URI of the SuperTokens instance. | `http://127.0.0.1:3567` |
|
||||
| `SUPERTOKENS_API_KEY` | **Yes** | The API KEY of the SuperTokens instance. | `iliketurtlesandicannotlie` |
|
||||
| `SUPERTOKENS_RATE_LIMIT` | No (Default value: `1`) | Whether supertokens requests should be rate limited. | `1` (enabled) or `0` (disabled) |
|
||||
| `SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME` | No (Default value: `CF-Connecting-IP`) | Name of the header to be used for rate limiting. | `CF-Connecting-IP` |
|
||||
| `AUTH_GITHUB` | No | Whether login via GitHub should be allowed | `1` (enabled) or `0` (disabled) |
|
||||
| `AUTH_GITHUB_CLIENT_ID` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client ID. | `g6aff8102efda5e1d12e` |
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ const RedisModel = zod.object({
|
|||
const SuperTokensModel = zod.object({
|
||||
SUPERTOKENS_CONNECTION_URI: zod.string().url(),
|
||||
SUPERTOKENS_API_KEY: zod.string(),
|
||||
SUPERTOKENS_RATE_LIMIT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
|
||||
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
|
||||
});
|
||||
|
||||
|
|
@ -433,7 +434,12 @@ export const env = {
|
|||
supertokens: {
|
||||
connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI,
|
||||
apiKey: supertokens.SUPERTOKENS_API_KEY,
|
||||
rateLimitIPHeaderName: supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
|
||||
rateLimit:
|
||||
supertokens.SUPERTOKENS_RATE_LIMIT === '0'
|
||||
? null
|
||||
: {
|
||||
ipHeaderName: supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
github:
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ export async function main() {
|
|||
app: env.hiveServices.webApp
|
||||
? {
|
||||
baseUrl: env.hiveServices.webApp.url,
|
||||
forwardedIPHeaderName: env.supertokens.rateLimitIPHeaderName,
|
||||
rateLimit: env.supertokens.rateLimit,
|
||||
}
|
||||
: null,
|
||||
tokens: {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,15 @@ import {
|
|||
} from './supertokens/oidc-provider';
|
||||
import { createThirdPartyEmailPasswordNodeOktaProvider } from './supertokens/okta-provider';
|
||||
|
||||
const SuperTokenAccessTokenModel = zod.object({
|
||||
version: zod.literal('1'),
|
||||
const SuperTokensSessionPayloadV2Model = zod.object({
|
||||
version: zod.literal('2'),
|
||||
superTokensUserId: zod.string(),
|
||||
email: zod.string(),
|
||||
userId: zod.string(),
|
||||
oidcIntegrationId: zod.string().nullable(),
|
||||
});
|
||||
|
||||
export type SupertokensSession = zod.TypeOf<typeof SuperTokenAccessTokenModel>;
|
||||
type SuperTokensSessionPayload = zod.TypeOf<typeof SuperTokensSessionPayloadV2Model>;
|
||||
|
||||
export const backendConfig = (requirements: {
|
||||
storage: Storage;
|
||||
|
|
@ -167,17 +169,23 @@ export const backendConfig = (requirements: {
|
|||
);
|
||||
}
|
||||
|
||||
input.accessTokenPayload = {
|
||||
version: '1',
|
||||
const internalUser = await internalApi.ensureUser({
|
||||
superTokensUserId: user.id,
|
||||
email: user.emails[0],
|
||||
oidcIntegrationId: input.userContext['oidcId'] ?? null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
const payload: SuperTokensSessionPayload = {
|
||||
version: '2',
|
||||
superTokensUserId: input.userId,
|
||||
userId: internalUser.user.id,
|
||||
oidcIntegrationId: input.userContext['oidcId'] ?? null,
|
||||
email: user.emails[0],
|
||||
};
|
||||
|
||||
input.sessionDataInDatabase = {
|
||||
version: '1',
|
||||
superTokensUserId: input.userId,
|
||||
email: user.emails[0],
|
||||
};
|
||||
input.accessTokenPayload = structuredClone(payload);
|
||||
input.sessionDataInDatabase = structuredClone(payload);
|
||||
|
||||
return originalImplementation.createNewSession(input);
|
||||
},
|
||||
|
|
@ -190,14 +198,23 @@ export const backendConfig = (requirements: {
|
|||
};
|
||||
|
||||
function extractIPFromUserContext(userContext: unknown): string {
|
||||
const defaultIp = (userContext as any)._default.request.original.ip;
|
||||
if (!env.supertokens?.rateLimit) {
|
||||
return defaultIp;
|
||||
}
|
||||
|
||||
return (
|
||||
(userContext as any)._default.request.getHeaderValue(env.supertokens.rateLimitIPHeaderName) ||
|
||||
(userContext as any)._default.request.original.ip
|
||||
(userContext as any)._default.request.getHeaderValue(env.supertokens.rateLimit.ipHeaderName) ??
|
||||
defaultIp
|
||||
);
|
||||
}
|
||||
|
||||
function createRedisRateLimiter(redis: Redis, windowSeconds = 5 * 60, maxRequests = 10) {
|
||||
async function isRateLimited(action: string, ip: string): Promise<boolean> {
|
||||
if (env.supertokens.rateLimit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = `supertokens-rate-limit:${action}:${ip}`;
|
||||
const current = await redis.incr(key);
|
||||
if (current === 1) {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export interface oidc_integrations {
|
|||
linked_organization_id: string;
|
||||
oauth_api_url: string | null;
|
||||
oidc_user_access_only: boolean;
|
||||
oidc_user_join_only: boolean;
|
||||
token_endpoint: string | null;
|
||||
updated_at: Date;
|
||||
userinfo_endpoint: string | null;
|
||||
|
|
@ -473,6 +474,12 @@ export interface users {
|
|||
zendesk_user_id: string | null;
|
||||
}
|
||||
|
||||
export interface users_linked_identities {
|
||||
created_at: Date;
|
||||
identity_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface version_commit {
|
||||
commit_id: string;
|
||||
url: string | null;
|
||||
|
|
@ -528,6 +535,7 @@ export interface DBTables {
|
|||
targets: targets;
|
||||
tokens: tokens;
|
||||
users: users;
|
||||
users_linked_identities: users_linked_identities;
|
||||
version_commit: version_commit;
|
||||
versions: versions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,32 +462,62 @@ export async function createStorage(
|
|||
|
||||
return UserModel.parse(record);
|
||||
},
|
||||
getUserById: batchBy(
|
||||
(item: { id: string; connection: Connection }) => item.connection,
|
||||
async input => {
|
||||
const userIds = input.map(i => i.id);
|
||||
const records = await input[0].connection.any<unknown>(sql`/* getUserById */
|
||||
SELECT
|
||||
${userFields(sql`"users".`, sql`"stu".`)}
|
||||
FROM
|
||||
"users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
WHERE
|
||||
"users"."id" = ANY(${sql.array(userIds, 'uuid')})
|
||||
`);
|
||||
|
||||
const mappings = new Map<string, UserType>();
|
||||
for (const record of records) {
|
||||
const user = UserModel.parse(record);
|
||||
mappings.set(user.id, user);
|
||||
}
|
||||
|
||||
return userIds.map(async id => mappings.get(id) ?? null);
|
||||
},
|
||||
),
|
||||
async createUser(
|
||||
{
|
||||
superTokensUserId,
|
||||
email,
|
||||
fullName,
|
||||
displayName,
|
||||
superTokensUserId,
|
||||
oidcIntegrationId,
|
||||
}: {
|
||||
superTokensUserId: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
displayName: string;
|
||||
superTokensUserId: string;
|
||||
oidcIntegrationId: string | null;
|
||||
},
|
||||
connection: Connection,
|
||||
connection: DatabaseTransactionConnection,
|
||||
) {
|
||||
await connection.query<unknown>(
|
||||
const { id } = await connection.one<{ id: string }>(
|
||||
sql`/* createUser */
|
||||
INSERT INTO users
|
||||
("email", "supertoken_user_id", "full_name", "display_name", "oidc_integration_id")
|
||||
("email", "full_name", "display_name", "supertoken_user_id", "oidc_integration_id")
|
||||
VALUES
|
||||
(${email}, ${superTokensUserId}, ${fullName}, ${displayName}, ${oidcIntegrationId})
|
||||
(${email}, ${fullName}, ${displayName}, ${superTokensUserId}, ${oidcIntegrationId})
|
||||
RETURNING id
|
||||
`,
|
||||
);
|
||||
|
||||
const user = await this.getUserBySuperTokenId({ superTokensUserId }, connection);
|
||||
await connection.query(sql`
|
||||
INSERT INTO "users_linked_identities" ("user_id", "identity_id")
|
||||
VALUES (${id}, ${superTokensUserId})
|
||||
`);
|
||||
|
||||
const user = await shared.getUserById({ id, connection });
|
||||
if (!user) {
|
||||
throw new Error('Something went wrong.');
|
||||
}
|
||||
|
|
@ -577,11 +607,11 @@ export async function createStorage(
|
|||
};
|
||||
|
||||
function buildUserData(input: {
|
||||
superTokensUserId: string;
|
||||
email: string;
|
||||
oidcIntegrationId: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
superTokensUserId: string;
|
||||
oidcIntegrationId: string | null;
|
||||
}) {
|
||||
const { firstName, lastName } = input;
|
||||
const name =
|
||||
|
|
@ -590,10 +620,10 @@ export async function createStorage(
|
|||
: input.email.split('@')[0].slice(0, 25).padEnd(2, '1');
|
||||
|
||||
return {
|
||||
superTokensUserId: input.superTokensUserId,
|
||||
email: input.email,
|
||||
displayName: name,
|
||||
fullName: name,
|
||||
superTokensUserId: input.superTokensUserId,
|
||||
oidcIntegrationId: input.oidcIntegrationId,
|
||||
};
|
||||
}
|
||||
|
|
@ -627,16 +657,60 @@ export async function createStorage(
|
|||
}) {
|
||||
return tracedTransaction('ensureUserExists', pool, async t => {
|
||||
let action: 'created' | 'no_action' = 'no_action';
|
||||
let internalUser = await shared.getUserBySuperTokenId({ superTokensUserId }, t);
|
||||
|
||||
// try searching existing user first
|
||||
let internalUser = await t
|
||||
.maybeOne<unknown>(
|
||||
sql`
|
||||
SELECT
|
||||
${userFields(sql`"users".`, sql`"stu".`)}
|
||||
FROM "users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
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));
|
||||
|
||||
if (!internalUser) {
|
||||
// try automatic account linking
|
||||
const sameEmailUsers = await t
|
||||
.any<unknown>(
|
||||
sql`/* ensureUserExists */
|
||||
SELECT ${userFields(sql`"users".`, sql`"stu".`)}
|
||||
FROM "users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
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({
|
||||
superTokensUserId,
|
||||
email,
|
||||
oidcIntegrationId: oidcIntegration?.id ?? null,
|
||||
firstName,
|
||||
lastName,
|
||||
superTokensUserId,
|
||||
oidcIntegrationId: oidcIntegration?.id ?? null,
|
||||
}),
|
||||
t,
|
||||
);
|
||||
|
|
@ -655,33 +729,18 @@ export async function createStorage(
|
|||
);
|
||||
}
|
||||
|
||||
return action;
|
||||
return {
|
||||
user: internalUser,
|
||||
action,
|
||||
};
|
||||
});
|
||||
},
|
||||
async getUserBySuperTokenId({ superTokensUserId }) {
|
||||
return shared.getUserBySuperTokenId({ superTokensUserId }, pool);
|
||||
},
|
||||
getUserById: batch(async input => {
|
||||
const userIds = input.map(i => i.id);
|
||||
const records = await pool.any<unknown>(sql`/* getUserById */
|
||||
SELECT
|
||||
${userFields(sql`"users".`, sql`"stu".`)}
|
||||
FROM
|
||||
"users"
|
||||
LEFT JOIN "supertokens_thirdparty_users" AS "stu"
|
||||
ON ("stu"."user_id" = "users"."supertoken_user_id")
|
||||
WHERE
|
||||
"users"."id" = ANY(${sql.array(userIds, 'uuid')})
|
||||
`);
|
||||
|
||||
const mappings = new Map<string, UserType>();
|
||||
for (const record of records) {
|
||||
const user = UserModel.parse(record);
|
||||
mappings.set(user.id, user);
|
||||
}
|
||||
|
||||
return userIds.map(async id => mappings.get(id) ?? null);
|
||||
}),
|
||||
async getUserById({ id }) {
|
||||
return shared.getUserById({ id, connection: pool });
|
||||
},
|
||||
async updateUser({ id, displayName, fullName }) {
|
||||
await pool.query<users>(sql`/* updateUser */
|
||||
UPDATE "users"
|
||||
|
|
@ -3132,6 +3191,7 @@ export async function createStorage(
|
|||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "additional_scopes"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
|
|
@ -3149,8 +3209,8 @@ export async function createStorage(
|
|||
return decodeOktaIntegrationRecord(result);
|
||||
},
|
||||
|
||||
async getOIDCIntegrationForOrganization({ organizationId }) {
|
||||
const result = await pool.maybeOne<unknown>(sql`/* getOIDCIntegrationForOrganization */
|
||||
getOIDCIntegrationForOrganization: batch(async selectors => {
|
||||
const result = await pool.query<unknown>(sql`/* getOIDCIntegrationForOrganization */
|
||||
SELECT
|
||||
"id"
|
||||
, "linked_organization_id"
|
||||
|
|
@ -3161,22 +3221,27 @@ export async function createStorage(
|
|||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "additional_scopes"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
FROM
|
||||
"oidc_integrations"
|
||||
WHERE
|
||||
"linked_organization_id" = ${organizationId}
|
||||
LIMIT 1
|
||||
"linked_organization_id" = ANY(${sql.array(
|
||||
selectors.map(s => s.organizationId),
|
||||
'uuid',
|
||||
)})
|
||||
`);
|
||||
const integrations = new Map(
|
||||
result.rows.map(row => {
|
||||
const integration = decodeOktaIntegrationRecord(row);
|
||||
return [integration.linkedOrganizationId, integration] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeOktaIntegrationRecord(result);
|
||||
},
|
||||
return selectors.map(async s => integrations.get(s.organizationId) ?? null);
|
||||
}),
|
||||
|
||||
async getOIDCIntegrationIdForOrganizationSlug({ slug }) {
|
||||
const id = await pool.maybeOneFirst<string>(sql`/* getOIDCIntegrationIdForOrganizationSlug */
|
||||
|
|
@ -3228,6 +3293,7 @@ export async function createStorage(
|
|||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "additional_scopes"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
|
|
@ -3286,6 +3352,7 @@ export async function createStorage(
|
|||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "additional_scopes"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
|
|
@ -3298,7 +3365,8 @@ export async function createStorage(
|
|||
const result = await pool.one(sql`/* updateOIDCRestrictions */
|
||||
UPDATE "oidc_integrations"
|
||||
SET
|
||||
"oidc_user_access_only" = ${args.oidcUserAccessOnly}
|
||||
"oidc_user_join_only" = ${args.oidcUserJoinOnly ?? sql`"oidc_user_join_only"`}
|
||||
, "oidc_user_access_only" = ${args.oidcUserAccessOnly ?? sql`"oidc_user_access_only"`}
|
||||
WHERE
|
||||
"id" = ${args.oidcIntegrationId}
|
||||
RETURNING
|
||||
|
|
@ -3311,6 +3379,7 @@ export async function createStorage(
|
|||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "additional_scopes"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
|
|
@ -3336,6 +3405,7 @@ export async function createStorage(
|
|||
, "token_endpoint"
|
||||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "additional_scopes"
|
||||
, "default_role_id"
|
||||
|
|
@ -3378,6 +3448,7 @@ export async function createStorage(
|
|||
, "userinfo_endpoint"
|
||||
, "authorization_endpoint"
|
||||
, "additional_scopes"
|
||||
, "oidc_user_join_only"
|
||||
, "oidc_user_access_only"
|
||||
, "default_role_id"
|
||||
, "default_assigned_resources"
|
||||
|
|
@ -4903,6 +4974,7 @@ const OktaIntegrationBaseModel = zod.object({
|
|||
.array(zod.string())
|
||||
.nullable()
|
||||
.transform(value => (value === null ? [] : value)),
|
||||
oidc_user_join_only: zod.boolean(),
|
||||
oidc_user_access_only: zod.boolean(),
|
||||
default_role_id: zod.string().nullable(),
|
||||
default_assigned_resources: zod.any().nullable(),
|
||||
|
|
@ -4940,6 +5012,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => {
|
|||
userinfoEndpoint: `${rawRecord.oauth_api_url}/userinfo`,
|
||||
authorizationEndpoint: `${rawRecord.oauth_api_url}/authorize`,
|
||||
additionalScopes: rawRecord.additional_scopes,
|
||||
oidcUserJoinOnly: rawRecord.oidc_user_join_only,
|
||||
oidcUserAccessOnly: rawRecord.oidc_user_access_only,
|
||||
defaultMemberRoleId: rawRecord.default_role_id,
|
||||
defaultResourceAssignment: rawRecord.default_assigned_resources,
|
||||
|
|
@ -4955,6 +5028,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => {
|
|||
userinfoEndpoint: rawRecord.userinfo_endpoint,
|
||||
authorizationEndpoint: rawRecord.authorization_endpoint,
|
||||
additionalScopes: rawRecord.additional_scopes,
|
||||
oidcUserJoinOnly: rawRecord.oidc_user_join_only,
|
||||
oidcUserAccessOnly: rawRecord.oidc_user_access_only,
|
||||
defaultMemberRoleId: rawRecord.default_role_id,
|
||||
defaultResourceAssignment: rawRecord.default_assigned_resources,
|
||||
|
|
@ -5599,7 +5673,7 @@ export const UserModel = zod.object({
|
|||
createdAt: zod.string(),
|
||||
displayName: zod.string(),
|
||||
fullName: zod.string(),
|
||||
superTokensUserId: zod.string(),
|
||||
superTokensUserId: zod.string().nullable(),
|
||||
isAdmin: zod
|
||||
.boolean()
|
||||
.nullable()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { PrimaryNavigationLink } from '@/components/navigation/primary-navigation-link';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
|
@ -15,7 +14,6 @@ const OrganizationSelector_OrganizationConnectionFragment = graphql(`
|
|||
export function OrganizationSelector(props: {
|
||||
currentOrganizationSlug: string;
|
||||
organizations: FragmentType<typeof OrganizationSelector_OrganizationConnectionFragment> | null;
|
||||
isOIDCUser: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const organizations = useFragment(
|
||||
|
|
@ -31,26 +29,14 @@ export function OrganizationSelector(props: {
|
|||
return <div className="bg-neutral-5 h-5 w-48 animate-pulse rounded-full" />;
|
||||
}
|
||||
|
||||
if (props.isOIDCUser) {
|
||||
return (
|
||||
<PrimaryNavigationLink
|
||||
linkProps={{
|
||||
to: '/$organizationSlug',
|
||||
params: { organizationSlug: props.currentOrganizationSlug },
|
||||
}}
|
||||
linkText={props.currentOrganizationSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={props.currentOrganizationSlug}
|
||||
onValueChange={id => {
|
||||
onValueChange={slug => {
|
||||
void router.navigate({
|
||||
to: '/$organizationSlug',
|
||||
params: {
|
||||
organizationSlug: id,
|
||||
organizationSlug: slug,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ import { Input } from '@/components/ui/input';
|
|||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { UserMenu } from '@/components/ui/user-menu';
|
||||
import { graphql, useFragment } from '@/gql';
|
||||
import { AuthProviderType, ProjectType } from '@/gql/graphql';
|
||||
import { graphql } from '@/gql';
|
||||
import { ProjectType } from '@/gql/graphql';
|
||||
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
|
||||
import { useToggle } from '@/lib/hooks';
|
||||
import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org';
|
||||
|
|
@ -51,36 +51,27 @@ export enum Page {
|
|||
Support = 'support',
|
||||
Subscription = 'subscription',
|
||||
}
|
||||
|
||||
const OrganizationLayout_OrganizationFragment = graphql(`
|
||||
fragment OrganizationLayout_OrganizationFragment on Organization {
|
||||
id
|
||||
slug
|
||||
viewerCanCreateProject
|
||||
viewerCanManageSupportTickets
|
||||
viewerCanDescribeBilling
|
||||
viewerCanSeeMembers
|
||||
...ProPlanBilling_OrganizationFragment
|
||||
...RateLimitWarn_OrganizationFragment
|
||||
}
|
||||
`);
|
||||
|
||||
const OrganizationLayoutQuery = graphql(`
|
||||
query OrganizationLayoutQuery($organizationSlug: String!) {
|
||||
query OrganizationLayoutQuery($organizationSlug: String!, $minimal: Boolean!) {
|
||||
me {
|
||||
id
|
||||
provider
|
||||
...UserMenu_MeFragment
|
||||
}
|
||||
organizationBySlug(organizationSlug: $organizationSlug) {
|
||||
organizationBySlug(organizationSlug: $organizationSlug) @skip(if: $minimal) {
|
||||
id
|
||||
slug
|
||||
viewerCanCreateProject
|
||||
viewerCanManageSupportTickets
|
||||
viewerCanDescribeBilling
|
||||
viewerCanSeeMembers
|
||||
...UserMenu_OrganizationFragment
|
||||
...ProPlanBilling_OrganizationFragment
|
||||
...RateLimitWarn_OrganizationFragment
|
||||
}
|
||||
organizations {
|
||||
...OrganizationSelector_OrganizationConnectionFragment
|
||||
...UserMenu_OrganizationConnectionFragment
|
||||
nodes {
|
||||
...OrganizationLayout_OrganizationFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
|
@ -93,6 +84,7 @@ export function OrganizationLayout({
|
|||
}: {
|
||||
page?: Page;
|
||||
className?: string;
|
||||
minimal?: boolean;
|
||||
organizationSlug: string;
|
||||
children: ReactNode;
|
||||
}): ReactElement | null {
|
||||
|
|
@ -101,18 +93,12 @@ export function OrganizationLayout({
|
|||
query: OrganizationLayoutQuery,
|
||||
variables: {
|
||||
organizationSlug: props.organizationSlug,
|
||||
minimal: props.minimal ?? false,
|
||||
},
|
||||
requestPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const organizationExists = query.data?.organizationBySlug;
|
||||
|
||||
const organizations = useFragment(
|
||||
OrganizationLayout_OrganizationFragment,
|
||||
query.data?.organizations.nodes,
|
||||
);
|
||||
const currentOrganization = organizations?.find(org => org.slug === props.organizationSlug);
|
||||
|
||||
const currentOrganization = query.data?.organizationBySlug;
|
||||
useLastVisitedOrganizationWriter(currentOrganization?.slug);
|
||||
|
||||
if (query.error) {
|
||||
|
|
@ -121,7 +107,7 @@ export function OrganizationLayout({
|
|||
|
||||
// Only show the null state state if the query has finished fetching and data is not stale
|
||||
// This prevents showing null state when switching between orgs with cached data
|
||||
const shouldShowNoOrg = !query.fetching && !query.stale && !organizationExists;
|
||||
const shouldShowNoOrg = !query.fetching && !query.stale && !currentOrganization && !props.minimal;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -129,14 +115,13 @@ export function OrganizationLayout({
|
|||
<div className="flex flex-row items-center gap-4">
|
||||
<HiveLink className="size-8" />
|
||||
<OrganizationSelector
|
||||
isOIDCUser={query.data?.me.provider === AuthProviderType.Oidc}
|
||||
currentOrganizationSlug={props.organizationSlug}
|
||||
organizations={query.data?.organizations ?? null}
|
||||
/>
|
||||
</div>
|
||||
<UserMenu
|
||||
me={query.data?.me ?? null}
|
||||
currentOrganizationSlug={props.organizationSlug}
|
||||
currentOrganization={query.data?.organizationBySlug ?? null}
|
||||
organizations={query.data?.organizations ?? null}
|
||||
/>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const ProjectLayoutQuery = graphql(`
|
|||
viewerCanCreateTarget
|
||||
viewerCanModifyAlerts
|
||||
}
|
||||
...UserMenu_OrganizationFragment
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
|
@ -100,7 +101,7 @@ export function ProjectLayout({
|
|||
<div>
|
||||
<UserMenu
|
||||
me={me ?? null}
|
||||
currentOrganizationSlug={props.organizationSlug}
|
||||
currentOrganization={currentOrganization ?? null}
|
||||
organizations={query.data?.organizations ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ const TargetLayoutQuery = graphql(`
|
|||
}
|
||||
}
|
||||
}
|
||||
...UserMenu_OrganizationFragment
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
|
@ -171,7 +172,7 @@ export const TargetLayout = ({
|
|||
<div>
|
||||
<UserMenu
|
||||
me={me ?? null}
|
||||
currentOrganizationSlug={props.organizationSlug}
|
||||
currentOrganization={currentOrganization ?? null}
|
||||
organizations={query.data?.organizations ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { memo, useEffect, useState } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { FaGithub, FaGoogle, FaOpenid, FaUserLock } from 'react-icons/fa';
|
||||
import { FaUserLock } from 'react-icons/fa';
|
||||
import { useMutation, type UseQueryExecute } from 'urql';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import {
|
||||
|
|
@ -33,31 +32,6 @@ import { organizationMembersRoute } from '../../../router';
|
|||
import { MemberInvitationButton } from './invitations';
|
||||
import { MemberRolePicker } from './member-role-picker';
|
||||
|
||||
export const authProviderToIconAndTextMap: Record<
|
||||
GraphQLSchema.AuthProviderType,
|
||||
{
|
||||
icon: IconType;
|
||||
text: string;
|
||||
}
|
||||
> = {
|
||||
[GraphQLSchema.AuthProviderType.Google]: {
|
||||
icon: FaGoogle,
|
||||
text: 'Google OAuth 2.0',
|
||||
},
|
||||
[GraphQLSchema.AuthProviderType.Github]: {
|
||||
icon: FaGithub,
|
||||
text: 'GitHub OAuth 2.0',
|
||||
},
|
||||
[GraphQLSchema.AuthProviderType.Oidc]: {
|
||||
icon: FaOpenid,
|
||||
text: 'OpenID Connect',
|
||||
},
|
||||
[GraphQLSchema.AuthProviderType.UsernamePassword]: {
|
||||
icon: FaUserLock,
|
||||
text: 'Email & Password',
|
||||
},
|
||||
};
|
||||
|
||||
const OrganizationMemberRow_DeleteMember = graphql(`
|
||||
mutation OrganizationMemberRow_DeleteMember($input: OrganizationMemberInput!) {
|
||||
deleteOrganizationMember(input: $input) {
|
||||
|
|
@ -102,8 +76,6 @@ const OrganizationMemberRow = memo(function OrganizationMemberRow(props: {
|
|||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteMemberState, deleteMember] = useMutation(OrganizationMemberRow_DeleteMember);
|
||||
const IconToUse = authProviderToIconAndTextMap[member.user.provider].icon;
|
||||
const authMethod = authProviderToIconAndTextMap[member.user.provider].text;
|
||||
return (
|
||||
<>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -163,16 +135,9 @@ const OrganizationMemberRow = memo(function OrganizationMemberRow(props: {
|
|||
</AlertDialog>
|
||||
<tr key={member.id}>
|
||||
<td className="w-12">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<IconToUse className="mx-auto size-5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>User's authentication method: {authMethod}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div>
|
||||
<FaUserLock className="mx-auto size-5" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="grow overflow-hidden py-3 text-sm font-medium">
|
||||
<h3 className="line-clamp-1 font-medium">{member.user.displayName}</h3>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { format } from 'date-fns';
|
|||
import { useFormik } from 'formik';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useClient, useMutation } from 'urql';
|
||||
import { useClient, useMutation, useQuery } from 'urql';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { z } from 'zod';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
|
@ -76,6 +76,14 @@ function FormError({ children }: { children: React.ReactNode }) {
|
|||
return <div className="text-sm text-red-500">{children}</div>;
|
||||
}
|
||||
|
||||
const OrganizationSettingsOIDCIntegrationSectionQuery = graphql(`
|
||||
query OrganizationSettingsOIDCIntegrationSectionQuery($organizationSlug: String!) {
|
||||
organization: organizationBySlug(organizationSlug: $organizationSlug) {
|
||||
...OIDCIntegrationSection_OrganizationFragment
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const OIDCIntegrationSection_OrganizationFragment = graphql(`
|
||||
fragment OIDCIntegrationSection_OrganizationFragment on Organization {
|
||||
id
|
||||
|
|
@ -112,12 +120,14 @@ function extractDomain(rawUrl: string) {
|
|||
return url.host;
|
||||
}
|
||||
|
||||
export function OIDCIntegrationSection(props: {
|
||||
organization: FragmentType<typeof OIDCIntegrationSection_OrganizationFragment>;
|
||||
}): ReactElement {
|
||||
export function OIDCIntegrationSection(props: { organizationSlug: string }): ReactElement {
|
||||
const router = useRouter();
|
||||
const organization = useFragment(OIDCIntegrationSection_OrganizationFragment, props.organization);
|
||||
const isAdmin = organization.me.role.name === 'Admin';
|
||||
const [query] = useQuery({
|
||||
query: OrganizationSettingsOIDCIntegrationSectionQuery,
|
||||
variables: {
|
||||
organizationSlug: props.organizationSlug,
|
||||
},
|
||||
});
|
||||
|
||||
const hash = router.latestLocation.hash;
|
||||
const openCreateModalHash = 'create-oidc-integration';
|
||||
|
|
@ -135,6 +145,14 @@ export function OIDCIntegrationSection(props: {
|
|||
});
|
||||
};
|
||||
|
||||
const organization = useFragment(
|
||||
OIDCIntegrationSection_OrganizationFragment,
|
||||
query.data?.organization,
|
||||
);
|
||||
if (!organization) return <Spinner />;
|
||||
|
||||
const isAdmin = organization.me.role.name === 'Admin';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
|
|
@ -851,6 +869,7 @@ const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(`
|
|||
clientId
|
||||
clientSecretPreview
|
||||
additionalScopes
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
defaultMemberRole {
|
||||
id
|
||||
|
|
@ -901,6 +920,7 @@ const UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation = graphql(`
|
|||
ok {
|
||||
updatedOIDCIntegration {
|
||||
id
|
||||
oidcUserJoinOnly
|
||||
oidcUserAccessOnly
|
||||
}
|
||||
}
|
||||
|
|
@ -964,7 +984,10 @@ function UpdateOIDCIntegrationForm(props: {
|
|||
},
|
||||
});
|
||||
|
||||
const onOidcUserAccessOnlyChange = async (oidcUserAccessOnly: boolean) => {
|
||||
const onOidcRestrictionChange = async (
|
||||
name: 'oidcUserJoinOnly' | 'oidcUserAccessOnly',
|
||||
value: boolean,
|
||||
) => {
|
||||
if (oidcRestrictionsMutation.fetching) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -977,16 +1000,21 @@ function UpdateOIDCIntegrationForm(props: {
|
|||
const result = await oidcRestrictionsMutate({
|
||||
input: {
|
||||
oidcIntegrationId: props.oidcIntegration.id,
|
||||
oidcUserAccessOnly,
|
||||
[name]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.data?.updateOIDCRestrictions.ok) {
|
||||
toast({
|
||||
title: 'OIDC restrictions updated successfully',
|
||||
description: oidcUserAccessOnly
|
||||
? 'Only OIDC users can now access the organization'
|
||||
: 'Access to the organization is no longer restricted to OIDC users',
|
||||
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',
|
||||
}[name],
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
|
|
@ -1047,18 +1075,41 @@ function UpdateOIDCIntegrationForm(props: {
|
|||
<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>OIDC-Only Access</p>
|
||||
<p>Require OIDC to Join</p>
|
||||
<p className="text-neutral-10 text-xs font-normal leading-snug">
|
||||
Restricts organization access to only authenticated OIDC accounts.
|
||||
Restricts new accounts joining the organization to be authenticated via
|
||||
OIDC.
|
||||
<br />
|
||||
<span className="font-medium">
|
||||
<span className="font-bold">
|
||||
Existing non-OIDC members will keep their access.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={props.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={props.oidcIntegration.oidcUserAccessOnly}
|
||||
onCheckedChange={onOidcUserAccessOnlyChange}
|
||||
onCheckedChange={checked =>
|
||||
onOidcRestrictionChange('oidcUserAccessOnly', checked)
|
||||
}
|
||||
disabled={oidcRestrictionsMutation.fetching}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import cookies from 'js-cookie';
|
||||
import { LifeBuoyIcon } from 'lucide-react';
|
||||
import { FaGithub, FaGoogle, FaKey, FaUsersSlash } from 'react-icons/fa';
|
||||
import { FaUsersSlash } from 'react-icons/fa';
|
||||
import { useMutation } from 'urql';
|
||||
import { ThemeSwitcher } from '@/components/theme/theme-switcher';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -38,7 +38,6 @@ import { Avatar } from '@/components/v2';
|
|||
import { LAST_VISITED_ORG_KEY } from '@/constants';
|
||||
import { env } from '@/env/frontend';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { AuthProviderType } from '@/gql/graphql';
|
||||
import { getDocsUrl } from '@/lib/docs-url';
|
||||
import { useToggle } from '@/lib/hooks';
|
||||
import { useNotifications } from '@/lib/hooks/use-notifications';
|
||||
|
|
@ -54,13 +53,20 @@ const UserMenu_OrganizationConnectionFragment = graphql(`
|
|||
nodes {
|
||||
id
|
||||
slug
|
||||
me {
|
||||
id
|
||||
...UserMenu_MemberFragment
|
||||
}
|
||||
getStarted {
|
||||
...GetStartedWizard_GetStartedProgress
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const UserMenu_OrganizationFragment = graphql(`
|
||||
fragment UserMenu_OrganizationFragment on Organization {
|
||||
id
|
||||
slug
|
||||
me {
|
||||
id
|
||||
canLeaveOrganization
|
||||
}
|
||||
getStarted {
|
||||
...GetStartedWizard_GetStartedProgress
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
|
@ -76,16 +82,10 @@ const UserMenu_MeFragment = graphql(`
|
|||
}
|
||||
`);
|
||||
|
||||
const UserMenu_MemberFragment = graphql(`
|
||||
fragment UserMenu_MemberFragment on Member {
|
||||
canLeaveOrganization
|
||||
}
|
||||
`);
|
||||
|
||||
export function UserMenu(props: {
|
||||
me: FragmentType<typeof UserMenu_MeFragment> | null;
|
||||
organizations: FragmentType<typeof UserMenu_OrganizationConnectionFragment> | null;
|
||||
currentOrganizationSlug: string;
|
||||
currentOrganization: FragmentType<typeof UserMenu_OrganizationFragment> | null;
|
||||
}) {
|
||||
const docsUrl = getDocsUrl();
|
||||
const me = useFragment(UserMenu_MeFragment, props.me);
|
||||
|
|
@ -93,14 +93,9 @@ export function UserMenu(props: {
|
|||
UserMenu_OrganizationConnectionFragment,
|
||||
props.organizations,
|
||||
)?.nodes;
|
||||
const currentOrganization = useFragment(UserMenu_OrganizationFragment, props.currentOrganization);
|
||||
const [isUserSettingsModalOpen, toggleUserSettingsModalOpen] = useToggle();
|
||||
const [isLeaveOrganizationModalOpen, toggleLeaveOrganizationModalOpen] = useToggle();
|
||||
const currentOrganization = organizations?.find(
|
||||
org => org.slug === props.currentOrganizationSlug,
|
||||
);
|
||||
const meInOrg = useFragment(UserMenu_MemberFragment, currentOrganization?.me);
|
||||
|
||||
const canLeaveOrganization = !!currentOrganization && meInOrg?.canLeaveOrganization === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -108,7 +103,7 @@ export function UserMenu(props: {
|
|||
toggleModalOpen={toggleUserSettingsModalOpen}
|
||||
isOpen={isUserSettingsModalOpen}
|
||||
/>
|
||||
{canLeaveOrganization ? (
|
||||
{currentOrganization?.me.canLeaveOrganization ? (
|
||||
<LeaveOrganizationModal
|
||||
toggleModalOpen={toggleLeaveOrganizationModalOpen}
|
||||
isOpen={isLeaveOrganizationModalOpen}
|
||||
|
|
@ -139,15 +134,6 @@ export function UserMenu(props: {
|
|||
{me?.email}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{me?.provider === AuthProviderType.Google ? (
|
||||
<FaGoogle title="Signed in using Google" />
|
||||
) : me?.provider === AuthProviderType.Github ? (
|
||||
<FaGithub title="Signed in using Github" />
|
||||
) : (
|
||||
<FaKey title="Signed in using username and password" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
|
|
@ -251,7 +237,7 @@ export function UserMenu(props: {
|
|||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{canLeaveOrganization ? (
|
||||
{currentOrganization?.me.canLeaveOrganization ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
toggleLeaveOrganizationModalOpen();
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ export const getOIDCOverrides = (): UserInput['override'] => ({
|
|||
}),
|
||||
});
|
||||
|
||||
export const startAuthFlowForOIDCProvider = async (oidcId: string) => {
|
||||
export const startAuthFlowForOIDCProvider = async (oidcId: string, redirectToPath: string) => {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'oidc',
|
||||
frontendRedirectURI: `${env.appBaseUrl}/auth/callback/oidc`,
|
||||
frontendRedirectURI: `${env.appBaseUrl}/auth/callback/oidc?redirectToPath=${encodeURIComponent(redirectToPath)}`,
|
||||
// The user context is very important - we store the OIDC ID so we can use it later on.
|
||||
userContext: {
|
||||
oidcId,
|
||||
|
|
|
|||
|
|
@ -176,7 +176,11 @@ export const urqlClient = createClient({
|
|||
}),
|
||||
networkStatusExchange,
|
||||
authExchange(async () => {
|
||||
let action: 'NEEDS_REFRESH' | 'VERIFY_EMAIL' | 'UNAUTHENTICATED' = 'UNAUTHENTICATED';
|
||||
let action:
|
||||
| { type: 'NEEDS_REFRESH' | 'VERIFY_EMAIL' | 'UNAUTHENTICATED' }
|
||||
| { type: 'NEEDS_OIDC'; organizationSlug: string; oidcIntegrationId: string } = {
|
||||
type: 'UNAUTHENTICATED',
|
||||
};
|
||||
|
||||
return {
|
||||
addAuthToOperation(operation) {
|
||||
|
|
@ -187,28 +191,41 @@ export const urqlClient = createClient({
|
|||
},
|
||||
didAuthError(error) {
|
||||
if (error.graphQLErrors.some(e => e.extensions?.code === 'UNAUTHENTICATED')) {
|
||||
action = 'UNAUTHENTICATED';
|
||||
action = { type: 'UNAUTHENTICATED' };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error.graphQLErrors.some(e => e.extensions?.code === 'VERIFY_EMAIL')) {
|
||||
action = 'VERIFY_EMAIL';
|
||||
action = { type: 'VERIFY_EMAIL' };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error.graphQLErrors.some(e => e.extensions?.code === 'NEEDS_REFRESH')) {
|
||||
action = 'NEEDS_REFRESH';
|
||||
action = { type: 'NEEDS_REFRESH' };
|
||||
return true;
|
||||
}
|
||||
|
||||
const oidcError = error.graphQLErrors.find(e => e.extensions?.code === 'NEEDS_OIDC');
|
||||
if (oidcError) {
|
||||
action = {
|
||||
type: 'NEEDS_OIDC',
|
||||
organizationSlug: oidcError.extensions?.organizationSlug as string,
|
||||
oidcIntegrationId: oidcError.extensions?.oidcIntegrationId as string,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async refreshAuth() {
|
||||
if (action === 'NEEDS_REFRESH' && (await Session.attemptRefreshingSession())) {
|
||||
if (action.type === 'NEEDS_REFRESH' && (await Session.attemptRefreshingSession())) {
|
||||
location.reload();
|
||||
} else if (action === 'VERIFY_EMAIL') {
|
||||
} else if (action.type === 'VERIFY_EMAIL') {
|
||||
window.location.href = '/auth/verify-email';
|
||||
} else if (action.type === 'NEEDS_OIDC') {
|
||||
window.location.href = `/${action.organizationSlug}/oidc-request?id=${action.oidcIntegrationId}&redirectToPath=${encodeURIComponent(window.location.pathname)}`;
|
||||
} else {
|
||||
await Session.signOut();
|
||||
window.location.href = `/auth?redirectToPath=${encodeURIComponent(window.location.pathname)}`;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function AuthOIDC(props: { oidcId: string; redirectToPath: string }) {
|
|||
if (!env.auth.oidc) {
|
||||
throw new Error('OIDC provider is not configured');
|
||||
}
|
||||
await startAuthFlowForOIDCProvider(props.oidcId);
|
||||
await startAuthFlowForOIDCProvider(props.oidcId, props.redirectToPath);
|
||||
// we need to return something, otherwise react-query will throw an error
|
||||
return true;
|
||||
},
|
||||
|
|
|
|||
51
packages/web/app/src/pages/organization-oidc-request.tsx
Normal file
51
packages/web/app/src/pages/organization-oidc-request.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Lock } from 'lucide-react';
|
||||
import { OrganizationLayout } from '@/components/layouts/organization';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Meta } from '@/components/ui/meta';
|
||||
import { isProviderEnabled } from '@/lib/supertokens/thirdparty';
|
||||
import { Navigate, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export function OrganizationOIDCRequestPage(props: {
|
||||
organizationSlug: string;
|
||||
oidcId: string;
|
||||
redirectToPath: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!isProviderEnabled('oidc')) {
|
||||
return <Navigate to={props.redirectToPath} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Single sign-on" />
|
||||
<OrganizationLayout organizationSlug={props.organizationSlug} minimal>
|
||||
<Card className="min-h-140 my-6 flex flex-col items-center justify-center gap-y-6 p-5">
|
||||
<Lock className="size-20 stroke-amber-400" />
|
||||
<div className="flex flex-col gap-y-2 text-center">
|
||||
<Heading>Single sign-on</Heading>
|
||||
<span className="text-neutral-10 text-center text-sm font-medium">
|
||||
To access the organization's resources, authenticate your account with single sign-on.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="min-w-32"
|
||||
onClick={() => {
|
||||
void router.navigate({
|
||||
to: '/auth/oidc',
|
||||
search: {
|
||||
id: props.oidcId,
|
||||
redirectToPath: props.redirectToPath,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Card>
|
||||
</OrganizationLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -184,7 +184,6 @@ const SettingsPageRenderer_OrganizationFragment = graphql(`
|
|||
viewerCanModifySlackIntegration
|
||||
viewerCanModifyGitHubIntegration
|
||||
viewerCanExportAuditLogs
|
||||
...OIDCIntegrationSection_OrganizationFragment
|
||||
...TransferOrganizationOwnershipModal_OrganizationFragment
|
||||
...GitHubIntegrationSection_OrganizationFragment
|
||||
...SlackIntegrationSection_OrganizationFragment
|
||||
|
|
@ -338,7 +337,7 @@ const OrganizationSettingsContent = (props: {
|
|||
}
|
||||
/>
|
||||
<div className="text-neutral-10">
|
||||
<OIDCIntegrationSection organization={organization} />
|
||||
<OIDCIntegrationSection organizationSlug={organization.slug} />
|
||||
</div>
|
||||
</SubPageLayout>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { OrganizationIndexRouteSearch, OrganizationPage } from './pages/organiza
|
|||
import { JoinOrganizationPage } from './pages/organization-join';
|
||||
import { OrganizationMembersPage } from './pages/organization-members';
|
||||
import { NewOrgPage } from './pages/organization-new';
|
||||
import { OrganizationOIDCRequestPage } from './pages/organization-oidc-request';
|
||||
import {
|
||||
OrganizationSettingsPage,
|
||||
OrganizationSettingsPageEnum,
|
||||
|
|
@ -369,6 +370,29 @@ const organizationRoute = createRoute({
|
|||
errorComponent: ErrorComponent,
|
||||
});
|
||||
|
||||
const OrganizationOIDCRequestRouteSearch = z.object({
|
||||
id: z.string({ required_error: 'OIDC ID is required' }),
|
||||
redirectToPath: z.string().optional().default('/'),
|
||||
});
|
||||
const organizationOIDCRequestRoute = createRoute({
|
||||
getParentRoute: () => organizationRoute,
|
||||
path: 'oidc-request',
|
||||
validateSearch(search) {
|
||||
return OrganizationOIDCRequestRouteSearch.parse(search);
|
||||
},
|
||||
component: function OrganizationOIDCRequestRoute() {
|
||||
const { organizationSlug } = organizationRoute.useParams();
|
||||
const { id, redirectToPath } = organizationOIDCRequestRoute.useSearch();
|
||||
return (
|
||||
<OrganizationOIDCRequestPage
|
||||
organizationSlug={organizationSlug}
|
||||
oidcId={id}
|
||||
redirectToPath={redirectToPath}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const organizationIndexRoute = createRoute({
|
||||
getParentRoute: () => organizationRoute,
|
||||
path: '/',
|
||||
|
|
@ -1067,6 +1091,7 @@ const routeTree = root.addChildren([
|
|||
organizationIndexRoute,
|
||||
joinOrganizationRoute,
|
||||
transferOrganizationRoute,
|
||||
organizationOIDCRequestRoute,
|
||||
organizationSupportRoute,
|
||||
organizationSupportTicketRoute,
|
||||
organizationSubscriptionRoute,
|
||||
|
|
|
|||
Loading…
Reference in a new issue