Revert "feat: introduce account linking for new users (#7390)" (#7638)

This commit is contained in:
Laurin 2026-02-05 17:14:55 +01:00 committed by GitHub
parent a9c490ebb6
commit 1bb6656dee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 473 additions and 882 deletions

View file

@ -38,13 +38,6 @@ jobs:
with:
cmd: yq -i 'del(.services.*.volumes)' docker/docker-compose.community.yml
- name: disable rate limiting
uses: mikefarah/yq@4839dbbf80445070a31c7a9c1055da527db2d5ee # v4.44.6
with:
cmd:
yq -i '.services.server.environment.SUPERTOKENS_RATE_LIMIT = "0"'
docker/docker-compose.community.yml
- name: run containers
timeout-minutes: 10
run: |

View file

@ -64,6 +64,8 @@ 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');
});
});
@ -149,57 +151,6 @@ 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();

View file

@ -57,17 +57,10 @@ const signUpUserViaEmail = async (
}
};
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 createSessionPayload = (superTokensUserId: string, email: string) => ({
version: '1',
superTokensUserId,
email,
});
const CreateSessionModel = z.object({
@ -96,7 +89,7 @@ const createSession = async (
],
});
const { user } = await internalApi.ensureUser.mutate({
await internalApi.ensureUser.mutate({
superTokensUserId,
email,
oidcIntegrationId,
@ -104,12 +97,7 @@ const createSession = async (
lastName: null,
});
const sessionData = createSessionPayload({
superTokensUserId,
userId: user.id,
oidcIntegrationId,
email,
});
const sessionData = createSessionPayload(superTokensUserId, email);
const payload = {
enableAntiCsrf: false,
userId: superTokensUserId,

View file

@ -156,17 +156,6 @@ export function getOrganization(organizationSlug: string, authToken: string) {
reportingOperations
enablingUsageBasedBreakingChanges
}
me {
id
user {
id
}
role {
id
name
permissions
}
}
}
}
`),

View file

@ -891,69 +891,49 @@ export function initSeed() {
},
};
},
async inviteAndJoinMember(options?: {
inviteToken?: string;
memberRoleId?: string | undefined;
oidcIntegrationId?: string | undefined;
resources?: GraphQLSchema.ResourceAssignmentInput | undefined;
}) {
const { inviteToken, memberRoleId, oidcIntegrationId, resources } = Object.assign(
options ?? {},
{
inviteToken: ownerToken,
},
);
async inviteAndJoinMember(
inviteToken: string = ownerToken,
memberRoleId: string | undefined = undefined,
resources: GraphQLSchema.ResourceAssignmentInput | undefined = undefined,
) {
const memberEmail = userEmail(generateUnique());
const memberToken = await authenticate(memberEmail, oidcIntegrationId).then(
r => r.access_token,
);
const memberToken = await authenticate(memberEmail).then(r => r.access_token);
if (!oidcIntegrationId) {
const invitationResult = await inviteToOrganization(
{
organization: {
bySelector: {
organizationSlug: organization.slug,
},
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;
email: memberEmail,
memberRoleId,
resources,
},
inviteToken,
).then(r => r.expectNoGraphQLErrors());
if (!code) {
throw new Error(
`Could not create invitation for ${memberEmail} to join org ${organization.slug}`,
);
}
const code =
invitationResult.inviteToOrganizationByEmail.ok?.createdOrganizationInvitation.code;
const joinResult = await joinOrganization(code, memberToken).then(r =>
r.expectNoGraphQLErrors(),
if (!code) {
throw new Error(
`Could not create invitation for ${memberEmail} to join org ${organization.slug}`,
);
if (joinResult.joinOrganization.__typename !== 'OrganizationPayload') {
throw new Error(
`Member ${memberEmail} could not join organization ${organization.slug}`,
);
}
}
const orgAfterJoin = await getOrganization(organization.slug, memberToken).then(r =>
const joinResult = await joinOrganization(code, memberToken).then(r =>
r.expectNoGraphQLErrors(),
);
const member = orgAfterJoin.organization?.me;
if (!member) {
if (joinResult.joinOrganization.__typename !== 'OrganizationPayload') {
throw new Error(
`Could not retrieve membership for ${memberEmail} in ${organization.slug} after joining`,
`Member ${memberEmail} could not join organization ${organization.slug}`,
);
}
const member = joinResult.joinOrganization.organization.me;
return {
member,
memberEmail,

View file

@ -9,9 +9,7 @@ const OrganizationWithOIDCIntegration = graphql(`
id
oidcIntegration {
id
oidcUserJoinOnly
oidcUserAccessOnly
tokenEndpoint
}
}
}
@ -37,7 +35,6 @@ const CreateOIDCIntegrationMutation = graphql(`
userinfoEndpoint
authorizationEndpoint
additionalScopes
oidcUserJoinOnly
oidcUserAccessOnly
}
}
@ -62,7 +59,6 @@ const UpdateOIDCRestrictionsMutation = graphql(`
ok {
updatedOIDCIntegration {
id
oidcUserJoinOnly
oidcUserAccessOnly
}
}
@ -106,8 +102,7 @@ describe('create', () => {
tokenEndpoint: 'http://localhost:8888/oauth/token',
userinfoEndpoint: 'http://localhost:8888/oauth/userinfo',
authorizationEndpoint: 'http://localhost:8888/oauth/authorize',
oidcUserJoinOnly: true,
oidcUserAccessOnly: false,
oidcUserAccessOnly: true,
additionalScopes: [],
},
},
@ -127,9 +122,7 @@ describe('create', () => {
id: organization.id,
oidcIntegration: {
id: result.createOIDCIntegration.ok!.createdOIDCIntegration.id,
oidcUserJoinOnly: true,
oidcUserAccessOnly: false,
tokenEndpoint: 'http://localhost:8888/oauth/token',
oidcUserAccessOnly: true,
},
},
});
@ -393,8 +386,7 @@ describe('create', () => {
tokenEndpoint: 'http://localhost:8888/oauth/token',
userinfoEndpoint: 'http://localhost:8888/oauth/userinfo',
authorizationEndpoint: 'http://localhost:8888/oauth/authorize',
oidcUserJoinOnly: true,
oidcUserAccessOnly: false,
oidcUserAccessOnly: true,
additionalScopes: [],
},
},
@ -487,9 +479,7 @@ describe('delete', () => {
id: organization.id,
oidcIntegration: {
id: oidcIntegrationId,
oidcUserJoinOnly: true,
oidcUserAccessOnly: false,
tokenEndpoint: 'http://localhost:8888/oauth/token',
oidcUserAccessOnly: true,
},
},
});
@ -571,6 +561,79 @@ 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")`,
}),
]),
);
},
);
});
});
@ -729,8 +792,7 @@ describe('restrictions', () => {
ok: {
createdOIDCIntegration: {
id: expect.any(String),
oidcUserJoinOnly: true,
oidcUserAccessOnly: false,
oidcUserAccessOnly: true,
clientId: 'foo',
clientSecretPreview: 'ofoo',
tokenEndpoint: 'http://localhost:8888/oauth/token',
@ -745,50 +807,45 @@ describe('restrictions', () => {
return result.createOIDCIntegration.ok!.createdOIDCIntegration.id;
}
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();
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();
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?.oidcUserJoinOnly).toEqual(true);
expect(refetchedOrg.organization?.oidcIntegration?.oidcUserAccessOnly).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:
'The user is not authorized through the OIDC integration required for the organization',
}),
);
},
);
expect(joinResult.joinOrganization).toEqual(
expect.objectContaining({
__typename: 'OrganizationInvitationError',
message: 'Non-OIDC users are not allowed to join this organization.',
}),
);
});
test.concurrent('non-oidc users can join an organization (opt-in)', async ({ expect }) => {
const seed = initSeed();
@ -808,21 +865,21 @@ describe('restrictions', () => {
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(orgAfterOidc.organization?.oidcIntegration?.oidcUserJoinOnly).toEqual(true);
expect(orgAfterOidc.organization?.oidcIntegration?.oidcUserAccessOnly).toEqual(true);
const restrictionsUpdateResult = await execute({
document: UpdateOIDCRestrictionsMutation,
variables: {
input: {
oidcIntegrationId,
oidcUserJoinOnly: false,
oidcUserAccessOnly: false,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(
restrictionsUpdateResult.updateOIDCRestrictions.ok?.updatedOIDCIntegration.oidcUserJoinOnly,
restrictionsUpdateResult.updateOIDCRestrictions.ok?.updatedOIDCIntegration.oidcUserAccessOnly,
).toEqual(false);
const orgAfterDisablingOidcRestrictions = await execute({
@ -834,7 +891,7 @@ describe('restrictions', () => {
}).then(r => r.expectNoGraphQLErrors());
expect(
orgAfterDisablingOidcRestrictions.organization?.oidcIntegration?.oidcUserJoinOnly,
orgAfterDisablingOidcRestrictions.organization?.oidcIntegration?.oidcUserAccessOnly,
).toEqual(false);
const invitation = await inviteMember('example@example.com');
@ -853,7 +910,7 @@ describe('restrictions', () => {
});
test.concurrent(
'existing non-oidc users can access the organization (default)',
'existing non-oidc users can always access the organization',
async ({ expect }) => {
const seed = initSeed();
const { ownerToken, createOrg } = await seed.createOwner();
@ -890,82 +947,6 @@ 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(
@ -974,8 +955,10 @@ test.concurrent(
const seed = initSeed();
const { createOrg, ownerToken } = await seed.createOwner();
const { organization, inviteAndJoinMember } = await createOrg();
const { createMemberRole, assignMemberRole, updateMemberRole, memberToken, member } =
await inviteAndJoinMember();
const createOIDCIntegrationResult = await execute({
await execute({
document: CreateOIDCIntegrationMutation,
variables: {
input: {
@ -990,11 +973,7 @@ 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 });

View file

@ -156,21 +156,18 @@ 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({
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 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 result = await org.projects(member.memberToken);

View file

@ -1,27 +0,0 @@
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;

View file

@ -180,6 +180,5 @@ 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'),
],
});

View file

@ -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 { RateLimitConfig, WEB_APP_URL } from './modules/shared/providers/tokens';
import { FORWARDED_IP_HEADER_NAME, 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,9 +154,7 @@ export function createRegistry({
encryptionSecret: string;
app: {
baseUrl: string;
rateLimit: null | {
ipHeaderName: string;
};
forwardedIPHeaderName: string;
} | null;
schemaConfig: SchemaModuleConfig;
supportConfig: SupportConfig | null;
@ -302,8 +300,8 @@ export function createRegistry({
scope: Scope.Singleton,
},
{
provide: RateLimitConfig,
useValue: new RateLimitConfig(app?.rateLimit ?? null),
provide: FORWARDED_IP_HEADER_NAME,
useValue: app?.forwardedIPHeaderName,
scope: Scope.Singleton,
},
{

View file

@ -51,7 +51,6 @@ function parseResourceIdentifier(resource: string) {
export type UserActor = {
type: 'user';
user: User;
oidcIntegrationId: string | null;
};
export type OrganizationAccessTokenActor = {

View file

@ -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, OIDCRequiredError } from '../../../shared/errors';
import { AccessError, HiveError } from '../../../shared/errors';
import { isUUID } from '../../../shared/is-uuid';
import { OrganizationMembers } from '../../organization/providers/organization-members';
import { Logger } from '../../shared/providers/logger';
@ -12,24 +12,15 @@ import { AuthNStrategy, AuthorizationPolicyStatement, Session, UserActor } from
export class SuperTokensCookieBasedSession extends Session {
public superTokensUserId: string;
public userId: string;
public oidcIntegrationId: string | null;
private organizationMembers: OrganizationMembers;
private storage: Storage;
constructor(
args: {
superTokensUserId: string;
userId: string;
oidcIntegrationId: string | null;
email: string;
},
args: { superTokensUserId: string; email: string },
deps: { organizationMembers: OrganizationMembers; storage: Storage; logger: Logger },
) {
super({ logger: deps.logger });
this.superTokensUserId = args.superTokensUserId;
this.userId = args.userId;
this.oidcIntegrationId = args.oidcIntegrationId;
this.organizationMembers = deps.organizationMembers;
this.storage = deps.storage;
}
@ -64,12 +55,7 @@ export class SuperTokensCookieBasedSession extends Session {
user.id,
organizationId,
);
const [organization, oidcIntegration] = await Promise.all([
this.storage.getOrganization({ organizationId }),
this.storage.getOIDCIntegrationForOrganization({
organizationId,
}),
]);
const organization = await this.storage.getOrganization({ organizationId });
const organizationMembership = await this.organizationMembers.findOrganizationMembership({
organization,
userId: user.id,
@ -114,10 +100,6 @@ 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,
@ -128,7 +110,9 @@ export class SuperTokensCookieBasedSession extends Session {
}
public async getActor(): Promise<UserActor> {
const user = await this.storage.getUserById({ id: this.userId });
const user = await this.storage.getUserBySuperTokenId({
superTokensUserId: this.superTokensUserId,
});
if (!user) {
throw new AccessError('User not found');
@ -137,7 +121,6 @@ export class SuperTokensCookieBasedSession extends Session {
return {
type: 'user',
user,
oidcIntegrationId: this.oidcIntegrationId,
};
}
@ -225,15 +208,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
'SuperTokens session parsing errors: %s',
JSON.stringify(result.error.flatten().fieldErrors),
);
throw new HiveError('Invalid access token provided', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
if (result.data.version === '1') {
throw new AccessError('Expired authorization token.', 'UNAUTHENTICATED');
throw new HiveError(`Invalid access token provided`);
}
if (this.emailVerification) {
@ -270,8 +245,6 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
return new SuperTokensCookieBasedSession(
{
superTokensUserId: session.superTokensUserId,
userId: session.userId,
oidcIntegrationId: session.oidcIntegrationId,
email: session.email,
},
{
@ -283,21 +256,8 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
}
}
const SuperTokenAccessTokenV1Model = zod.object({
const SuperTokenAccessTokenModel = zod.object({
version: zod.literal('1'),
superTokensUserId: zod.string(),
email: zod.string(),
});
const SuperTokenAccessTokenV2Model = zod.object({
version: zod.literal('2'),
superTokensUserId: zod.string(),
userId: zod.string(),
oidcIntegrationId: zod.string().nullable(),
email: zod.string(),
});
const SuperTokenAccessTokenModel = zod.union([
SuperTokenAccessTokenV1Model,
SuperTokenAccessTokenV2Model,
]);

View file

@ -100,7 +100,7 @@ export class EmailVerification {
userIdentityId: string;
resend?: boolean;
},
ipAddress: string | null,
ipAddress: string,
): Promise<
| { ok: true; expiresAt: Date }
| {
@ -109,15 +109,13 @@ export class EmailVerification {
emailAlreadyVerified: boolean;
}
> {
if (ipAddress) {
await this.rateLimiter.check(
'sendVerificationEmail',
ipAddress,
60_000,
3,
`Exceeded rate limit for sending verification emails.`,
);
}
await this.rateLimiter.check(
'sendVerificationEmail',
ipAddress,
60_000,
3,
`Exceeded rate limit for sending verification emails.`,
);
const superTokensUser = await this.pool
.maybeOne(

View file

@ -1,20 +1,17 @@
import { RateLimitConfig } from '../../../../modules/shared/providers/tokens';
import { FORWARDED_IP_HEADER_NAME } 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 rateLimitConfig = injector.get(RateLimitConfig);
const forwardedIPHeaderName = injector.get(FORWARDED_IP_HEADER_NAME);
const result = await injector.get(EmailVerification).sendVerificationEmail(
{
userIdentityId: input.userIdentityId,
resend: input.resend ?? undefined,
},
rateLimitConfig.config
? (req.headers[rateLimitConfig.config.ipHeaderName]?.toString() ?? req.ip)
: null,
req.headers[forwardedIPHeaderName]?.toString() ?? req.ip,
);
if (!result.ok) {

View file

@ -18,7 +18,6 @@ export default gql`
userinfoEndpoint: String!
authorizationEndpoint: String!
additionalScopes: [String!]!
oidcUserJoinOnly: Boolean!
oidcUserAccessOnly: Boolean!
defaultMemberRole: MemberRole!
defaultResourceAssignment: ResourceAssignment
@ -165,8 +164,7 @@ export default gql`
Applies only to newly invited members.
Existing members are not affected.
"""
oidcUserJoinOnly: Boolean
oidcUserAccessOnly: Boolean
oidcUserAccessOnly: Boolean!
}
"""

View file

@ -54,7 +54,6 @@ export class OIDCIntegrationsProvider {
async getOIDCIntegrationForOrganization(args: {
organizationId: string;
skipAccessCheck?: boolean;
}): Promise<OIDCIntegration | null> {
this.logger.debug(
'getting oidc integration for organization (organizationId=%s)',
@ -65,18 +64,16 @@ export class OIDCIntegrationsProvider {
return null;
}
if (!args.skipAccessCheck) {
const canPerformAction = await this.session.canPerformAction({
const canPerformAction = await this.session.canPerformAction({
organizationId: args.organizationId,
action: 'oidc:modify',
params: {
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({
@ -359,11 +356,7 @@ export class OIDCIntegrationsProvider {
} as const;
}
async updateOIDCRestrictions(args: {
oidcIntegrationId: string;
oidcUserJoinOnly: boolean | null;
oidcUserAccessOnly: boolean | null;
}) {
async updateOIDCRestrictions(args: { oidcIntegrationId: string; oidcUserAccessOnly: boolean }) {
if (this.isEnabled() === false) {
return {
type: 'error',

View file

@ -6,8 +6,7 @@ export const updateOIDCRestrictions: NonNullable<
> = async (_, { input }, { injector }) => {
const result = await injector.get(OIDCIntegrationsProvider).updateOIDCRestrictions({
oidcIntegrationId: input.oidcIntegrationId,
oidcUserJoinOnly: input.oidcUserJoinOnly ?? null,
oidcUserAccessOnly: input.oidcUserAccessOnly ?? null,
oidcUserAccessOnly: input.oidcUserAccessOnly,
});
if (result.type === 'ok') {

View file

@ -1,5 +1,5 @@
import type { UserResolvers } from './../../../__generated__/types';
export const User: Pick<UserResolvers, 'canSwitchOrganization'> = {
canSwitchOrganization: () => true,
canSwitchOrganization: user => !user.oidcIntegrationId,
};

View file

@ -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 { AccessError, HiveError, OIDCRequiredError } from '../../../shared/errors';
import { HiveError } 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,21 +103,13 @@ export class OrganizationManager {
return null;
}
const canAccess = await this.session
.assertPerformAction({
action: 'organization:describe',
const canAccess = await this.session.canPerformAction({
action: 'organization:describe',
organizationId: organization.id,
params: {
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;
@ -307,11 +299,20 @@ 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,
@ -655,9 +656,13 @@ export class OrganizationManager {
async joinOrganization({ code }: { code: string }): Promise<Organization | { message: string }> {
this.logger.info('Joining an organization (code=%s)', code);
const actor = await this.session.getActor();
if (actor.type !== 'user') {
throw new Error('Only users can join organizations');
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 organization = await this.getOrganizationByInviteCode({
@ -673,10 +678,9 @@ export class OrganizationManager {
organizationId: organization.id,
});
if (oidcIntegration?.oidcUserJoinOnly && actor.oidcIntegrationId !== oidcIntegration.id) {
if (oidcIntegration?.oidcUserAccessOnly && !isOIDCUser) {
return {
message:
'The user is not authorized through the OIDC integration required for the organization',
message: 'Non-OIDC users are not allowed to join this organization.',
};
}
}
@ -685,7 +689,7 @@ export class OrganizationManager {
await this.storage.addOrganizationMemberViaInvitationCode({
code,
userId: actor.user.id,
userId: user.id,
organizationId: organization.id,
});
@ -701,7 +705,7 @@ export class OrganizationManager {
eventType: 'USER_JOINED',
organizationId: organization.id,
metadata: {
inviteeEmail: actor.user.email,
inviteeEmail: user.email,
},
}),
]);

View file

@ -1,4 +1,3 @@
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';
@ -10,12 +9,29 @@ export const myDefaultOrganization: NonNullable<QueryResolvers['myDefaultOrganiz
{ previouslyVisitedOrganizationId: previouslyVisitedOrganizationSlug },
{ injector },
) => {
const actor = await injector.get(Session).getActor();
if (actor.type !== 'user') {
throw new AccessError('Only authenticated users can perform this action.');
}
const user = await injector.get(Session).getViewer();
const organizationManager = injector.get(OrganizationManager);
const oidcManager = injector.get(OIDCIntegrationsProvider);
// 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;
}
// This is the organization that got stored as an cookie
// We make sure it actually exists before directing to it.
@ -38,41 +54,17 @@ export const myDefaultOrganization: NonNullable<QueryResolvers['myDefaultOrganiz
}
}
if (actor.user?.id) {
if (user?.id) {
const allOrganizations = await organizationManager.getOrganizations();
const orgsWithOIDCConfig = await Promise.all(
allOrganizations.map(async org => ({
...org,
oidcIntegration: await oidcManager.getOIDCIntegrationForOrganization({
organizationId: org.id,
skipAccessCheck: true,
}),
})),
).then(arr => arr.filter(v => v != null));
const getPriority = (org: (typeof orgsWithOIDCConfig)[number]) => {
// prioritize user's own organization
if (org.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) {
if (allOrganizations.length > 0) {
const firstOrg = allOrganizations[0];
return {
selector: {
organizationSlug: selectedOrg.slug,
organizationSlug: firstOrg.slug,
},
organization: selectedOrg,
organization: firstOrg,
};
}
}

View file

@ -79,10 +79,7 @@ export interface Storage {
};
firstName: string | null;
lastName: string | null;
}): Promise<{
user: User;
action: 'created' | 'no_action';
}>;
}): Promise<'created' | 'no_action'>;
getUserBySuperTokenId(_: { superTokensUserId: string }): Promise<User | null>;
getUserById(_: { id: string }): Promise<User | null>;
@ -645,8 +642,7 @@ export interface Storage {
updateOIDCRestrictions(_: {
oidcIntegrationId: string;
oidcUserJoinOnly: boolean | null;
oidcUserAccessOnly: boolean | null;
oidcUserAccessOnly: boolean;
}): Promise<OIDCIntegration>;
updateOIDCDefaultMemberRole(_: {

View file

@ -1,12 +1,4 @@
import { Injectable, InjectionToken } from 'graphql-modules';
import { InjectionToken } from 'graphql-modules';
export const WEB_APP_URL = new InjectionToken<string>('WEB_APP_URL');
@Injectable()
export class RateLimitConfig {
constructor(
public readonly config: null | {
ipHeaderName: string;
},
) {}
}
export const FORWARDED_IP_HEADER_NAME = new InjectionToken<string>('FORWARDED_IP_HEADER_NAME');

View file

@ -223,7 +223,6 @@ export interface OIDCIntegration {
userinfoEndpoint: string;
authorizationEndpoint: string;
additionalScopes: string[];
oidcUserJoinOnly: boolean;
oidcUserAccessOnly: boolean;
defaultMemberRoleId: string | null;
defaultResourceAssignment: ResourceAssignmentGroup | null;
@ -349,6 +348,7 @@ export interface User {
provider: AuthProviderType;
superTokensUserId: string | null;
isAdmin: boolean;
oidcIntegrationId: string | null;
zendeskId: string | null;
}

View file

@ -27,26 +27,15 @@ export function isGraphQLError(error: unknown): error is GraphQLError {
export const HiveError = GraphQLError;
export class AccessError extends HiveError {
constructor(reason: string, code: string = 'UNAUTHORISED', extensions?: Record<string, unknown>) {
constructor(reason: string, code: string = 'UNAUTHORISED') {
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.

View file

@ -185,13 +185,13 @@ type BatchGroup<TItem, TResult> = {
*/
export function batchBy<TItem, TResult>(
/** Function to determine the batch group. */
buildBatchKey: (arg: TItem) => unknown,
buildBatchKey: (arg: TItem) => string,
/** 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<unknown, BatchGroup<TItem, TResult>>();
let batchGroups = new Map<string, 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: unknown) {
function getBatchGroup(batchKey: string) {
// 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

View file

@ -46,7 +46,6 @@ 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` |

View file

@ -102,7 +102,6 @@ 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()),
});
@ -434,12 +433,7 @@ export const env = {
supertokens: {
connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI,
apiKey: supertokens.SUPERTOKENS_API_KEY,
rateLimit:
supertokens.SUPERTOKENS_RATE_LIMIT === '0'
? null
: {
ipHeaderName: supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
},
rateLimitIPHeaderName: supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
},
auth: {
github:

View file

@ -295,7 +295,7 @@ export async function main() {
app: env.hiveServices.webApp
? {
baseUrl: env.hiveServices.webApp.url,
rateLimit: env.supertokens.rateLimit,
forwardedIPHeaderName: env.supertokens.rateLimitIPHeaderName,
}
: null,
tokens: {

View file

@ -22,15 +22,13 @@ import {
} from './supertokens/oidc-provider';
import { createThirdPartyEmailPasswordNodeOktaProvider } from './supertokens/okta-provider';
const SuperTokenAccessTokenV2Model = zod.object({
version: zod.literal('2'),
const SuperTokenAccessTokenModel = zod.object({
version: zod.literal('1'),
superTokensUserId: zod.string(),
userId: zod.string(),
oidcIntegrationId: zod.string().nullable(),
email: zod.string(),
});
export type SupertokensSession = zod.TypeOf<typeof SuperTokenAccessTokenV2Model>;
export type SupertokensSession = zod.TypeOf<typeof SuperTokenAccessTokenModel>;
export const backendConfig = (requirements: {
storage: Storage;
@ -169,23 +167,17 @@ export const backendConfig = (requirements: {
);
}
const internalUser = await internalApi.ensureUser({
superTokensUserId: user.id,
email: user.emails[0],
oidcIntegrationId: input.userContext['oidcId'] ?? null,
firstName: null,
lastName: null,
});
const payload: SupertokensSession = {
version: '2',
input.accessTokenPayload = {
version: '1',
superTokensUserId: input.userId,
userId: internalUser.user.id,
oidcIntegrationId: input.userContext['oidcId'] ?? null,
email: user.emails[0],
};
input.accessTokenPayload = structuredClone(payload);
input.sessionDataInDatabase = structuredClone(payload);
input.sessionDataInDatabase = {
version: '1',
superTokensUserId: input.userId,
email: user.emails[0],
};
return originalImplementation.createNewSession(input);
},
@ -198,23 +190,14 @@ 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.rateLimit.ipHeaderName) ??
defaultIp
(userContext as any)._default.request.getHeaderValue(env.supertokens.rateLimitIPHeaderName) ||
(userContext as any)._default.request.original.ip
);
}
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) {

View file

@ -170,7 +170,6 @@ 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;
@ -474,12 +473,6 @@ 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;
@ -535,7 +528,6 @@ export interface DBTables {
targets: targets;
tokens: tokens;
users: users;
users_linked_identities: users_linked_identities;
version_commit: version_commit;
versions: versions;
}

View file

@ -462,62 +462,32 @@ 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: DatabaseTransactionConnection,
connection: Connection,
) {
const { id } = await connection.one<{ id: string }>(
await connection.query<unknown>(
sql`/* createUser */
INSERT INTO users
("email", "full_name", "display_name", "supertoken_user_id", "oidc_integration_id")
("email", "supertoken_user_id", "full_name", "display_name", "oidc_integration_id")
VALUES
(${email}, ${fullName}, ${displayName}, ${superTokensUserId}, ${oidcIntegrationId})
RETURNING id
(${email}, ${superTokensUserId}, ${fullName}, ${displayName}, ${oidcIntegrationId})
`,
);
await connection.query(sql`
INSERT INTO "users_linked_identities" ("user_id", "identity_id")
VALUES (${id}, ${superTokensUserId})
`);
const user = await shared.getUserById({ id, connection });
const user = await this.getUserBySuperTokenId({ superTokensUserId }, connection);
if (!user) {
throw new Error('Something went wrong.');
}
@ -607,11 +577,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 =
@ -620,10 +590,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,
};
}
@ -657,60 +627,16 @@ 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,
);
@ -729,18 +655,33 @@ export async function createStorage(
);
}
return {
user: internalUser,
action,
};
return action;
});
},
async getUserBySuperTokenId({ superTokensUserId }) {
return shared.getUserBySuperTokenId({ superTokensUserId }, pool);
},
async getUserById({ id }) {
return shared.getUserById({ id, connection: 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 updateUser({ id, displayName, fullName }) {
await pool.query<users>(sql`/* updateUser */
UPDATE "users"
@ -3191,7 +3132,6 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "additional_scopes"
, "oidc_user_join_only"
, "oidc_user_access_only"
, "default_role_id"
, "default_assigned_resources"
@ -3209,8 +3149,8 @@ export async function createStorage(
return decodeOktaIntegrationRecord(result);
},
getOIDCIntegrationForOrganization: batch(async selectors => {
const result = await pool.query<unknown>(sql`/* getOIDCIntegrationForOrganization */
async getOIDCIntegrationForOrganization({ organizationId }) {
const result = await pool.maybeOne<unknown>(sql`/* getOIDCIntegrationForOrganization */
SELECT
"id"
, "linked_organization_id"
@ -3221,27 +3161,22 @@ 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" = ANY(${sql.array(
selectors.map(s => s.organizationId),
'uuid',
)})
"linked_organization_id" = ${organizationId}
LIMIT 1
`);
const integrations = new Map(
result.rows.map(row => {
const integration = decodeOktaIntegrationRecord(row);
return [integration.linkedOrganizationId, integration] as const;
}),
);
return selectors.map(async s => integrations.get(s.organizationId) ?? null);
}),
if (result === null) {
return null;
}
return decodeOktaIntegrationRecord(result);
},
async getOIDCIntegrationIdForOrganizationSlug({ slug }) {
const id = await pool.maybeOneFirst<string>(sql`/* getOIDCIntegrationIdForOrganizationSlug */
@ -3293,7 +3228,6 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "additional_scopes"
, "oidc_user_join_only"
, "oidc_user_access_only"
, "default_role_id"
, "default_assigned_resources"
@ -3352,7 +3286,6 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "additional_scopes"
, "oidc_user_join_only"
, "oidc_user_access_only"
, "default_role_id"
, "default_assigned_resources"
@ -3365,8 +3298,7 @@ export async function createStorage(
const result = await pool.one(sql`/* updateOIDCRestrictions */
UPDATE "oidc_integrations"
SET
"oidc_user_join_only" = ${args.oidcUserJoinOnly ?? sql`"oidc_user_join_only"`}
, "oidc_user_access_only" = ${args.oidcUserAccessOnly ?? sql`"oidc_user_access_only"`}
"oidc_user_access_only" = ${args.oidcUserAccessOnly}
WHERE
"id" = ${args.oidcIntegrationId}
RETURNING
@ -3379,7 +3311,6 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "additional_scopes"
, "oidc_user_join_only"
, "oidc_user_access_only"
, "default_role_id"
, "default_assigned_resources"
@ -3405,7 +3336,6 @@ export async function createStorage(
, "token_endpoint"
, "userinfo_endpoint"
, "authorization_endpoint"
, "oidc_user_join_only"
, "oidc_user_access_only"
, "additional_scopes"
, "default_role_id"
@ -3448,7 +3378,6 @@ export async function createStorage(
, "userinfo_endpoint"
, "authorization_endpoint"
, "additional_scopes"
, "oidc_user_join_only"
, "oidc_user_access_only"
, "default_role_id"
, "default_assigned_resources"
@ -4974,7 +4903,6 @@ 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(),
@ -5012,7 +4940,6 @@ 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,
@ -5028,7 +4955,6 @@ 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,
@ -5673,7 +5599,7 @@ export const UserModel = zod.object({
createdAt: zod.string(),
displayName: zod.string(),
fullName: zod.string(),
superTokensUserId: zod.string().nullable(),
superTokensUserId: zod.string(),
isAdmin: zod
.boolean()
.nullable()

View file

@ -1,3 +1,4 @@
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';
@ -14,6 +15,7 @@ const OrganizationSelector_OrganizationConnectionFragment = graphql(`
export function OrganizationSelector(props: {
currentOrganizationSlug: string;
organizations: FragmentType<typeof OrganizationSelector_OrganizationConnectionFragment> | null;
isOIDCUser: boolean;
}) {
const router = useRouter();
const organizations = useFragment(
@ -29,14 +31,26 @@ 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={slug => {
onValueChange={id => {
void router.navigate({
to: '/$organizationSlug',
params: {
organizationSlug: slug,
organizationSlug: id,
},
});
}}

View file

@ -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 } from '@/gql';
import { ProjectType } from '@/gql/graphql';
import { graphql, useFragment } from '@/gql';
import { AuthProviderType, 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,27 +51,36 @@ 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!, $minimal: Boolean!) {
query OrganizationLayoutQuery($organizationSlug: String!) {
me {
id
provider
...UserMenu_MeFragment
}
organizationBySlug(organizationSlug: $organizationSlug) @skip(if: $minimal) {
organizationBySlug(organizationSlug: $organizationSlug) {
id
slug
viewerCanCreateProject
viewerCanManageSupportTickets
viewerCanDescribeBilling
viewerCanSeeMembers
...UserMenu_OrganizationFragment
...ProPlanBilling_OrganizationFragment
...RateLimitWarn_OrganizationFragment
}
organizations {
...OrganizationSelector_OrganizationConnectionFragment
...UserMenu_OrganizationConnectionFragment
nodes {
...OrganizationLayout_OrganizationFragment
}
}
}
`);
@ -84,7 +93,6 @@ export function OrganizationLayout({
}: {
page?: Page;
className?: string;
minimal?: boolean;
organizationSlug: string;
children: ReactNode;
}): ReactElement | null {
@ -93,12 +101,18 @@ export function OrganizationLayout({
query: OrganizationLayoutQuery,
variables: {
organizationSlug: props.organizationSlug,
minimal: props.minimal ?? false,
},
requestPolicy: 'cache-first',
});
const currentOrganization = query.data?.organizationBySlug;
const organizationExists = query.data?.organizationBySlug;
const organizations = useFragment(
OrganizationLayout_OrganizationFragment,
query.data?.organizations.nodes,
);
const currentOrganization = organizations?.find(org => org.slug === props.organizationSlug);
useLastVisitedOrganizationWriter(currentOrganization?.slug);
if (query.error) {
@ -107,7 +121,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 && !currentOrganization && !props.minimal;
const shouldShowNoOrg = !query.fetching && !query.stale && !organizationExists;
return (
<>
@ -115,13 +129,14 @@ 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}
currentOrganization={query.data?.organizationBySlug ?? null}
currentOrganizationSlug={props.organizationSlug}
organizations={query.data?.organizations ?? null}
/>
</Header>

View file

@ -54,7 +54,6 @@ const ProjectLayoutQuery = graphql(`
viewerCanCreateTarget
viewerCanModifyAlerts
}
...UserMenu_OrganizationFragment
}
}
`);
@ -101,7 +100,7 @@ export function ProjectLayout({
<div>
<UserMenu
me={me ?? null}
currentOrganization={currentOrganization ?? null}
currentOrganizationSlug={props.organizationSlug}
organizations={query.data?.organizations ?? null}
/>
</div>

View file

@ -114,7 +114,6 @@ const TargetLayoutQuery = graphql(`
}
}
}
...UserMenu_OrganizationFragment
}
}
`);
@ -172,7 +171,7 @@ export const TargetLayout = ({
<div>
<UserMenu
me={me ?? null}
currentOrganization={currentOrganization ?? null}
currentOrganizationSlug={props.organizationSlug}
organizations={query.data?.organizations ?? null}
/>
</div>

View file

@ -1,6 +1,7 @@
import { memo, useEffect, useState } from 'react';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import { FaUserLock } from 'react-icons/fa';
import type { IconType } from 'react-icons';
import { FaGithub, FaGoogle, FaOpenid, FaUserLock } from 'react-icons/fa';
import { useMutation, type UseQueryExecute } from 'urql';
import { useDebouncedCallback } from 'use-debounce';
import {
@ -32,6 +33,31 @@ 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) {
@ -76,6 +102,8 @@ 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}>
@ -135,9 +163,16 @@ const OrganizationMemberRow = memo(function OrganizationMemberRow(props: {
</AlertDialog>
<tr key={member.id}>
<td className="w-12">
<div>
<FaUserLock className="mx-auto size-5" />
</div>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<div>
<IconToUse className="mx-auto size-5" />
</div>
</TooltipTrigger>
<TooltipContent>User's authentication method: {authMethod}</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
<td className="grow overflow-hidden py-3 text-sm font-medium">
<h3 className="line-clamp-1 font-medium">{member.user.displayName}</h3>

View file

@ -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, useQuery } from 'urql';
import { useClient, useMutation } from 'urql';
import { useDebouncedCallback } from 'use-debounce';
import { z } from 'zod';
import { Button, buttonVariants } from '@/components/ui/button';
@ -76,14 +76,6 @@ 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
@ -120,14 +112,12 @@ function extractDomain(rawUrl: string) {
return url.host;
}
export function OIDCIntegrationSection(props: { organizationSlug: string }): ReactElement {
export function OIDCIntegrationSection(props: {
organization: FragmentType<typeof OIDCIntegrationSection_OrganizationFragment>;
}): ReactElement {
const router = useRouter();
const [query] = useQuery({
query: OrganizationSettingsOIDCIntegrationSectionQuery,
variables: {
organizationSlug: props.organizationSlug,
},
});
const organization = useFragment(OIDCIntegrationSection_OrganizationFragment, props.organization);
const isAdmin = organization.me.role.name === 'Admin';
const hash = router.latestLocation.hash;
const openCreateModalHash = 'create-oidc-integration';
@ -145,14 +135,6 @@ export function OIDCIntegrationSection(props: { organizationSlug: string }): Rea
});
};
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">
@ -869,7 +851,6 @@ const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(`
clientId
clientSecretPreview
additionalScopes
oidcUserJoinOnly
oidcUserAccessOnly
defaultMemberRole {
id
@ -920,7 +901,6 @@ const UpdateOIDCIntegrationForm_UpdateOIDCRestrictionsMutation = graphql(`
ok {
updatedOIDCIntegration {
id
oidcUserJoinOnly
oidcUserAccessOnly
}
}
@ -984,10 +964,7 @@ function UpdateOIDCIntegrationForm(props: {
},
});
const onOidcRestrictionChange = async (
name: 'oidcUserJoinOnly' | 'oidcUserAccessOnly',
value: boolean,
) => {
const onOidcUserAccessOnlyChange = async (oidcUserAccessOnly: boolean) => {
if (oidcRestrictionsMutation.fetching) {
return;
}
@ -1000,21 +977,16 @@ function UpdateOIDCIntegrationForm(props: {
const result = await oidcRestrictionsMutate({
input: {
oidcIntegrationId: props.oidcIntegration.id,
[name]: value,
oidcUserAccessOnly,
},
});
if (result.data?.updateOIDCRestrictions.ok) {
toast({
title: 'OIDC restrictions updated successfully',
description: {
oidcUserJoinOnly: value
? 'Only OIDC users can now join the organization'
: 'Joining the organization is no longer restricted to OIDC users',
oidcUserAccessOnly: value
? 'Only OIDC users can now access the organization'
: 'Access to the organization is no longer restricted to OIDC users',
}[name],
description: oidcUserAccessOnly
? 'Only OIDC users can now access the organization'
: 'Access to the organization is no longer restricted to OIDC users',
});
} else {
toast({
@ -1075,41 +1047,18 @@ 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>Require OIDC to Join</p>
<p>OIDC-Only Access</p>
<p className="text-neutral-10 text-xs font-normal leading-snug">
Restricts new accounts joining the organization to be authenticated via
OIDC.
Restricts organization access to only authenticated OIDC accounts.
<br />
<span className="font-bold">
<span className="font-medium">
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={checked =>
onOidcRestrictionChange('oidcUserAccessOnly', checked)
}
onCheckedChange={onOidcUserAccessOnlyChange}
disabled={oidcRestrictionsMutation.fetching}
/>
</div>

View file

@ -1,6 +1,6 @@
import cookies from 'js-cookie';
import { LifeBuoyIcon } from 'lucide-react';
import { FaUsersSlash } from 'react-icons/fa';
import { FaGithub, FaGoogle, FaKey, FaUsersSlash } from 'react-icons/fa';
import { useMutation } from 'urql';
import { ThemeSwitcher } from '@/components/theme/theme-switcher';
import { Button } from '@/components/ui/button';
@ -38,6 +38,7 @@ 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';
@ -53,20 +54,13 @@ const UserMenu_OrganizationConnectionFragment = graphql(`
nodes {
id
slug
}
}
`);
const UserMenu_OrganizationFragment = graphql(`
fragment UserMenu_OrganizationFragment on Organization {
id
slug
me {
id
canLeaveOrganization
}
getStarted {
...GetStartedWizard_GetStartedProgress
me {
id
...UserMenu_MemberFragment
}
getStarted {
...GetStartedWizard_GetStartedProgress
}
}
}
`);
@ -82,10 +76,16 @@ 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;
currentOrganization: FragmentType<typeof UserMenu_OrganizationFragment> | null;
currentOrganizationSlug: string;
}) {
const docsUrl = getDocsUrl();
const me = useFragment(UserMenu_MeFragment, props.me);
@ -93,9 +93,14 @@ 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 (
<>
@ -103,7 +108,7 @@ export function UserMenu(props: {
toggleModalOpen={toggleUserSettingsModalOpen}
isOpen={isUserSettingsModalOpen}
/>
{currentOrganization?.me.canLeaveOrganization ? (
{canLeaveOrganization ? (
<LeaveOrganizationModal
toggleModalOpen={toggleLeaveOrganizationModalOpen}
isOpen={isLeaveOrganizationModalOpen}
@ -134,6 +139,15 @@ 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>
@ -237,7 +251,7 @@ export function UserMenu(props: {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{currentOrganization?.me.canLeaveOrganization ? (
{canLeaveOrganization ? (
<DropdownMenuItem
onClick={() => {
toggleLeaveOrganizationModalOpen();

View file

@ -70,10 +70,10 @@ export const getOIDCOverrides = (): UserInput['override'] => ({
}),
});
export const startAuthFlowForOIDCProvider = async (oidcId: string, redirectToPath: string) => {
export const startAuthFlowForOIDCProvider = async (oidcId: string) => {
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
thirdPartyId: 'oidc',
frontendRedirectURI: `${env.appBaseUrl}/auth/callback/oidc?redirectToPath=${encodeURIComponent(redirectToPath)}`,
frontendRedirectURI: `${env.appBaseUrl}/auth/callback/oidc`,
// The user context is very important - we store the OIDC ID so we can use it later on.
userContext: {
oidcId,

View file

@ -176,11 +176,7 @@ export const urqlClient = createClient({
}),
networkStatusExchange,
authExchange(async () => {
let action:
| { type: 'NEEDS_REFRESH' | 'VERIFY_EMAIL' | 'UNAUTHENTICATED' }
| { type: 'NEEDS_OIDC'; organizationSlug: string; oidcIntegrationId: string } = {
type: 'UNAUTHENTICATED',
};
let action: 'NEEDS_REFRESH' | 'VERIFY_EMAIL' | 'UNAUTHENTICATED' = 'UNAUTHENTICATED';
return {
addAuthToOperation(operation) {
@ -191,41 +187,28 @@ export const urqlClient = createClient({
},
didAuthError(error) {
if (error.graphQLErrors.some(e => e.extensions?.code === 'UNAUTHENTICATED')) {
action = { type: 'UNAUTHENTICATED' };
action = 'UNAUTHENTICATED';
return true;
}
if (error.graphQLErrors.some(e => e.extensions?.code === 'VERIFY_EMAIL')) {
action = { type: 'VERIFY_EMAIL' };
action = 'VERIFY_EMAIL';
return true;
}
if (error.graphQLErrors.some(e => e.extensions?.code === '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,
};
action = 'NEEDS_REFRESH';
return true;
}
return false;
},
async refreshAuth() {
if (action.type === 'NEEDS_REFRESH' && (await Session.attemptRefreshingSession())) {
if (action === 'NEEDS_REFRESH' && (await Session.attemptRefreshingSession())) {
location.reload();
} else if (action.type === 'VERIFY_EMAIL') {
} else if (action === '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)}`;
}
},

View file

@ -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, props.redirectToPath);
await startAuthFlowForOIDCProvider(props.oidcId);
// we need to return something, otherwise react-query will throw an error
return true;
},

View file

@ -1,51 +0,0 @@
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>
</>
);
}

View file

@ -184,6 +184,7 @@ const SettingsPageRenderer_OrganizationFragment = graphql(`
viewerCanModifySlackIntegration
viewerCanModifyGitHubIntegration
viewerCanExportAuditLogs
...OIDCIntegrationSection_OrganizationFragment
...TransferOrganizationOwnershipModal_OrganizationFragment
...GitHubIntegrationSection_OrganizationFragment
...SlackIntegrationSection_OrganizationFragment
@ -337,7 +338,7 @@ const OrganizationSettingsContent = (props: {
}
/>
<div className="text-neutral-10">
<OIDCIntegrationSection organizationSlug={organization.slug} />
<OIDCIntegrationSection organization={organization} />
</div>
</SubPageLayout>
)}

View file

@ -49,7 +49,6 @@ 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,
@ -370,29 +369,6 @@ 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: '/',
@ -1089,7 +1065,6 @@ const routeTree = root.addChildren([
organizationIndexRoute,
joinOrganizationRoute,
transferOrganizationRoute,
organizationOIDCRequestRoute,
organizationSupportRoute,
organizationSupportTicketRoute,
organizationSubscriptionRoute,