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

Co-authored-by: Iha Shin <me@xiniha.dev>
This commit is contained in:
Laurin 2026-02-06 08:53:07 +01:00 committed by GitHub
parent ef246a17fe
commit 1f38d9064c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 909 additions and 497 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 { 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,
},
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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