diff --git a/cypress/e2e/app.cy.ts b/cypress/e2e/app.cy.ts index 4ac44217a..4d0439d5b 100644 --- a/cypress/e2e/app.cy.ts +++ b/cypress/e2e/app.cy.ts @@ -14,7 +14,7 @@ describe('basic user flow', () => { it('should redirect anon to auth', () => { cy.visit('/'); - cy.url().should('include', '/auth?redirectToPath=%2F'); + cy.url().should('include', '/auth?redirectToPath='); }); it('should sign up', () => { @@ -34,7 +34,7 @@ describe('basic user flow', () => { // Logout cy.get('[data-cy="user-menu-trigger"]').click(); cy.get('[data-cy="user-menu-logout"]').click(); - cy.url().should('include', '/auth?redirectToPath=%2F'); + cy.url().should('include', '/auth?redirectToPath='); }); }); diff --git a/deployment/index.ts b/deployment/index.ts index 4d1eea6a4..20cf98826 100644 --- a/deployment/index.ts +++ b/deployment/index.ts @@ -223,8 +223,6 @@ const app = deployApp({ dbMigrations, image: docker.factory.getImageId('app', imagesTag), docker, - supertokens, - emails, zendesk, billing, github: githubApp, diff --git a/deployment/services/app.ts b/deployment/services/app.ts index 59da7c129..a0d735a21 100644 --- a/deployment/services/app.ts +++ b/deployment/services/app.ts @@ -1,33 +1,23 @@ import * as pulumi from '@pulumi/pulumi'; import { serviceLocalEndpoint } from '../utils/local-endpoint'; -import { ServiceSecret } from '../utils/secrets'; import { ServiceDeployment } from '../utils/service-deployment'; import { StripeBilling } from './billing'; import { DbMigrations } from './db-migrations'; import { Docker } from './docker'; -import { Emails } from './emails'; import { Environment } from './environment'; import { GitHubApp } from './github'; import { GraphQL } from './graphql'; import { Sentry } from './sentry'; import { SlackApp } from './slack-app'; -import { Supertokens } from './supertokens'; import { Zendesk } from './zendesk'; export type App = ReturnType; -class AppOAuthSecret extends ServiceSecret<{ - clientId: string | pulumi.Output; - clientSecret: string | pulumi.Output; -}> {} - export function deployApp({ graphql, dbMigrations, image, - supertokens, docker, - emails, zendesk, github, slackApp, @@ -40,8 +30,6 @@ export function deployApp({ graphql: GraphQL; dbMigrations: DbMigrations; docker: Docker; - supertokens: Supertokens; - emails: Emails; zendesk: Zendesk; github: GitHubApp; slackApp: SlackApp; @@ -51,16 +39,6 @@ export function deployApp({ const appConfig = new pulumi.Config('app'); const appEnv = appConfig.requireObject>('env'); - const oauthConfig = new pulumi.Config('oauth'); - const githubOAuthSecret = new AppOAuthSecret('oauth-github', { - clientId: oauthConfig.requireSecret('githubClient'), - clientSecret: oauthConfig.requireSecret('githubSecret'), - }); - const googleOAuthSecret = new AppOAuthSecret('oauth-google', { - clientId: oauthConfig.requireSecret('googleClient'), - clientSecret: oauthConfig.requireSecret('googleSecret'), - }); - return new ServiceDeployment( 'app', { @@ -80,7 +58,8 @@ export function deployApp({ env: { ...environment.envVars, SENTRY: sentry.enabled ? '1' : '0', - GRAPHQL_ENDPOINT: serviceLocalEndpoint(graphql.service).apply(s => `${s}/graphql`), + GRAPHQL_PUBLIC_ENDPOINT: `https://${environment.appDns}/graphql`, + GRAPHQL_PUBLIC_ORIGIN: `https://${environment.appDns}`, SERVER_ENDPOINT: serviceLocalEndpoint(graphql.service), APP_BASE_URL: `https://${environment.appDns}/`, INTEGRATION_SLACK: '1', @@ -89,8 +68,6 @@ export function deployApp({ DOCS_URL: 'https://the-guild.dev/graphql/hive/docs', GRAPHQL_PERSISTED_OPERATIONS: '1', ZENDESK_SUPPORT: zendesk.enabled ? '1' : '0', - SUPERTOKENS_CONNECTION_URI: supertokens.localEndpoint, - EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service), AUTH_GITHUB: '1', AUTH_GOOGLE: '1', AUTH_REQUIRE_EMAIL_VERIFICATION: '1', @@ -104,11 +81,6 @@ export function deployApp({ .withSecret('INTEGRATION_SLACK_CLIENT_ID', slackApp.secret, 'clientId') .withSecret('INTEGRATION_SLACK_CLIENT_SECRET', slackApp.secret, 'clientSecret') .withSecret('STRIPE_PUBLIC_KEY', billing.secret, 'stripePublicKey') - .withSecret('SUPERTOKENS_API_KEY', supertokens.secret, 'apiKey') - .withSecret('AUTH_GITHUB_CLIENT_ID', githubOAuthSecret, 'clientId') - .withSecret('AUTH_GITHUB_CLIENT_SECRET', githubOAuthSecret, 'clientSecret') - .withSecret('AUTH_GOOGLE_CLIENT_ID', googleOAuthSecret, 'clientId') - .withSecret('AUTH_GOOGLE_CLIENT_SECRET', googleOAuthSecret, 'clientSecret') .withConditionalSecret(sentry.enabled, 'SENTRY_DSN', sentry.secret, 'dsn') .deploy(); } diff --git a/deployment/services/graphql.ts b/deployment/services/graphql.ts index 39cdbdecf..ae1b5c85f 100644 --- a/deployment/services/graphql.ts +++ b/deployment/services/graphql.ts @@ -1,5 +1,6 @@ import * as pulumi from '@pulumi/pulumi'; import { serviceLocalEndpoint } from '../utils/local-endpoint'; +import { ServiceSecret } from '../utils/secrets'; import { ServiceDeployment } from '../utils/service-deployment'; import { StripeBillingService } from './billing'; import { CDN } from './cf-cdn'; @@ -25,6 +26,11 @@ import { Zendesk } from './zendesk'; export type GraphQL = ReturnType; +class AppOAuthSecret extends ServiceSecret<{ + clientId: string | pulumi.Output; + clientSecret: string | pulumi.Output; +}> {} + export function deployGraphQL({ clickhouse, image, @@ -75,6 +81,16 @@ export function deployGraphQL({ const apiConfig = new pulumi.Config('api'); const apiEnv = apiConfig.requireObject>('env'); + const oauthConfig = new pulumi.Config('oauth'); + const githubOAuthSecret = new AppOAuthSecret('oauth-github', { + clientId: oauthConfig.requireSecret('githubClient'), + clientSecret: oauthConfig.requireSecret('githubSecret'), + }); + const googleOAuthSecret = new AppOAuthSecret('oauth-google', { + clientId: oauthConfig.requireSecret('googleClient'), + clientSecret: oauthConfig.requireSecret('googleSecret'), + }); + return ( new ServiceDeployment( 'graphql-api', @@ -108,6 +124,7 @@ export function deployGraphQL({ EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service), USAGE_ESTIMATOR_ENDPOINT: serviceLocalEndpoint(usageEstimator.service), WEB_APP_URL: `https://${environment.appDns}`, + GRAPHQL_PUBLIC_ORIGIN: `https://${environment.appDns}`, CDN_CF: '1', HIVE: '1', HIVE_REPORTING: '1', @@ -115,9 +132,13 @@ export function deployGraphQL({ HIVE_REPORTING_ENDPOINT: 'http://0.0.0.0:4000/graphql', ZENDESK_SUPPORT: zendesk.enabled ? '1' : '0', INTEGRATION_GITHUB: '1', - SUPERTOKENS_CONNECTION_URI: supertokens.localEndpoint, - AUTH_ORGANIZATION_OIDC: '1', GRAPHQL_PERSISTED_OPERATIONS_PATH: './persisted-operations.json', + // Auth + SUPERTOKENS_CONNECTION_URI: supertokens.localEndpoint, + AUTH_GITHUB: '1', + AUTH_GOOGLE: '1', + AUTH_ORGANIZATION_OIDC: '1', + AUTH_REQUIRE_EMAIL_VERIFICATION: '1', }, exposesMetrics: true, port: 4000, @@ -160,8 +181,12 @@ export function deployGraphQL({ .withSecret('S3_SECRET_ACCESS_KEY', s3.secret, 'secretAccessKey') .withSecret('S3_BUCKET_NAME', s3.secret, 'bucket') .withSecret('S3_ENDPOINT', s3.secret, 'endpoint') - // Supertokens + // Auth .withSecret('SUPERTOKENS_API_KEY', supertokens.secret, 'apiKey') + .withSecret('AUTH_GITHUB_CLIENT_ID', githubOAuthSecret, 'clientId') + .withSecret('AUTH_GITHUB_CLIENT_SECRET', githubOAuthSecret, 'clientSecret') + .withSecret('AUTH_GOOGLE_CLIENT_ID', googleOAuthSecret, 'clientId') + .withSecret('AUTH_GOOGLE_CLIENT_SECRET', googleOAuthSecret, 'clientSecret') // Zendesk .withConditionalSecret(zendesk.enabled, 'ZENDESK_SUBDOMAIN', zendesk.secret, 'subdomain') .withConditionalSecret(zendesk.enabled, 'ZENDESK_USERNAME', zendesk.secret, 'username') diff --git a/deployment/services/proxy.ts b/deployment/services/proxy.ts index 7e4510153..59b7ae974 100644 --- a/deployment/services/proxy.ts +++ b/deployment/services/proxy.ts @@ -66,6 +66,14 @@ export function deployProxy({ timeoutInSeconds: 60, retriable: true, }, + { + name: 'auth', + path: '/auth-api', + customRewrite: '/auth-api', + service: graphql.service, + timeoutInSeconds: 60, + retriable: true, + }, { name: 'usage', path: '/usage', diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 3b7de4be2..5c6d0ded8 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -243,8 +243,6 @@ services: ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}' WEB_APP_URL: '${HIVE_APP_BASE_URL}' PORT: 3001 - SUPERTOKENS_CONNECTION_URI: http://supertokens:3567 - SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}' S3_ENDPOINT: 'http://s3:9000' S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER} S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} @@ -253,8 +251,13 @@ services: CDN_AUTH_PRIVATE_KEY: ${CDN_AUTH_PRIVATE_KEY} CDN_API: '1' CDN_API_BASE_URL: 'http://localhost:8082' - AUTH_ORGANIZATION_OIDC: '1' LOG_LEVEL: '${LOG_LEVEL:-info}' + # Auth + AUTH_ORGANIZATION_OIDC: '1' + AUTH_REQUIRE_EMAIL_VERIFICATION: '0' + SUPERTOKENS_CONNECTION_URI: http://supertokens:3567 + SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}' + GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082 policy: image: '${DOCKER_REGISTRY}policy${DOCKER_TAG}' @@ -390,11 +393,8 @@ services: PORT: 3000 NODE_ENV: production APP_BASE_URL: '${HIVE_APP_BASE_URL}' - SUPERTOKENS_CONNECTION_URI: http://supertokens:3567 - SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}' - EMAILS_ENDPOINT: http://emails:3011 - GRAPHQL_ENDPOINT: http://server:3001/graphql - SERVER_ENDPOINT: http://server:3001 + GRAPHQL_PUBLIC_ENDPOINT: http://localhost:8082/graphql + GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082 AUTH_REQUIRE_EMAIL_VERIFICATION: '0' AUTH_ORGANIZATION_OIDC: '1' LOG_LEVEL: '${LOG_LEVEL:-info}' diff --git a/integration-tests/docker-compose.integration.yaml b/integration-tests/docker-compose.integration.yaml index cb35e5ef0..fd46ca7a8 100644 --- a/integration-tests/docker-compose.integration.yaml +++ b/integration-tests/docker-compose.integration.yaml @@ -169,12 +169,18 @@ services: GITHUB_APP_PRIVATE_KEY: 5f938d51a065476c4dc1b04aeba13afb FEEDBACK_SLACK_TOKEN: '' FEEDBACK_SLACK_CHANNEL: '#hive' - AUTH_ORGANIZATION_OIDC: '1' S3_PUBLIC_URL: 'http://localhost:8083' USAGE_ESTIMATOR_ENDPOINT: '${USAGE_ESTIMATOR_ENDPOINT}' RATE_LIMIT_ENDPOINT: '${RATE_LIMIT_ENDPOINT}' EMAIL_PROVIDER: '${EMAIL_PROVIDER}' LOG_LEVEL: debug + # Auth + WEB_APP_URL: '${HIVE_APP_BASE_URL}' + AUTH_ORGANIZATION_OIDC: '1' + AUTH_REQUIRE_EMAIL_VERIFICATION: '0' + SUPERTOKENS_CONNECTION_URI: http://supertokens:3567 + SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}' + GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082 broker: image: vectorized/redpanda:latest diff --git a/packages/services/api/src/modules/auth/providers/auth-manager.ts b/packages/services/api/src/modules/auth/providers/auth-manager.ts index 68a506676..395bd5496 100644 --- a/packages/services/api/src/modules/auth/providers/auth-manager.ts +++ b/packages/services/api/src/modules/auth/providers/auth-manager.ts @@ -188,7 +188,7 @@ export class AuthManager { getCurrentUser: () => Promise<(User & { isAdmin: boolean }) | never> = share(async () => { if (!this.session) { - throw new AccessError('Authorization token is missing'); + throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED'); } const user = await this.storage.getUserBySuperTokenId({ diff --git a/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts b/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts index a74ce47ab..857ac9bf4 100644 --- a/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts +++ b/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts @@ -55,11 +55,16 @@ export class GitHubIntegrationManager { installationId: string; }, ): Promise { - this.logger.debug('Registering GitHub integration (organization=%s)', input.organization); + this.logger.debug( + 'Registering GitHub integration (organization=%s, installationId:%s)', + input.organization, + input.installationId, + ); await this.authManager.ensureOrganizationAccess({ ...input, scope: OrganizationAccessScope.INTEGRATIONS, }); + this.logger.debug('Updating organization'); await this.storage.addGitHubIntegration({ organization: input.organization, installationId: input.installationId, @@ -72,6 +77,7 @@ export class GitHubIntegrationManager { ...input, scope: OrganizationAccessScope.INTEGRATIONS, }); + this.logger.debug('Updating organization'); await this.storage.deleteGitHubIntegration({ organization: input.organization, }); @@ -80,7 +86,8 @@ export class GitHubIntegrationManager { async isAvailable(selector: OrganizationSelector): Promise { this.logger.debug('Checking GitHub integration (organization=%s)', selector.organization); - if (this.isEnabled()) { + if (!this.isEnabled()) { + this.logger.debug('GitHub integration is disabled.'); return false; } diff --git a/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts b/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts index 5214acb83..317ef25e7 100644 --- a/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts +++ b/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts @@ -42,6 +42,7 @@ export class SlackIntegrationManager { ...input, scope: OrganizationAccessScope.INTEGRATIONS, }); + this.logger.debug('Updating organization'); await this.storage.addSlackIntegration({ organization: input.organization, token: this.crypto.encrypt(input.token), @@ -54,6 +55,7 @@ export class SlackIntegrationManager { ...input, scope: OrganizationAccessScope.INTEGRATIONS, }); + this.logger.debug('Updating organization'); await this.storage.deleteSlackIntegration({ organization: input.organization, }); diff --git a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts index 7e5b4e166..f538c4970 100644 --- a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts +++ b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts @@ -278,6 +278,31 @@ export class OIDCIntegrationsProvider { organizationId: integration.linkedOrganizationId, } as const; } + + async getOIDCIntegrationById(args: { oidcIntegrationId: string }) { + if (this.isEnabled() === false) { + return { + type: 'error', + message: 'OIDC integrations are disabled.', + } as const; + } + + const integration = await this.storage.getOIDCIntegrationById({ + oidcIntegrationId: args.oidcIntegrationId, + }); + + if (integration === null) { + return { + type: 'error', + message: 'Integration not found.', + } as const; + } + + return { + type: 'ok', + organizationId: integration.linkedOrganizationId, + } as const; + } } const OIDCIntegrationClientIdModel = zod diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 407c832c9..763cf0e5f 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -8,6 +8,7 @@ export default gql` organizationTransferRequest( selector: OrganizationTransferRequestSelector! ): OrganizationTransfer + myDefaultOrganization(previouslyVisitedOrganizationId: ID): OrganizationPayload } extend type Mutation { diff --git a/packages/services/api/src/modules/organization/resolvers.ts b/packages/services/api/src/modules/organization/resolvers.ts index 368474502..d5c21e698 100644 --- a/packages/services/api/src/modules/organization/resolvers.ts +++ b/packages/services/api/src/modules/organization/resolvers.ts @@ -9,6 +9,7 @@ import { } from '../auth/providers/organization-access'; import { isProjectScope, ProjectAccessScope } from '../auth/providers/project-access'; import { isTargetScope, TargetAccessScope } from '../auth/providers/target-access'; +import { OIDCIntegrationsProvider } from '../oidc-integrations/providers/oidc-integrations.provider'; import { InMemoryRateLimiter } from '../rate-limit/providers/in-memory-rate-limiter'; import { IdTranslator } from '../shared/providers/id-translator'; import { Logger } from '../shared/providers/logger'; @@ -54,6 +55,71 @@ export const resolvers: OrganizationModule.Resolvers = { async organizations(_, __, { injector }) { return injector.get(OrganizationManager).getOrganizations(); }, + async myDefaultOrganization(_, { previouslyVisitedOrganizationId }, { injector }) { + const user = await injector.get(AuthManager).getCurrentUser(); + 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({ + organization: oidcIntegration.organizationId, + }); + + return { + selector: { + organization: org.cleanId, + }, + organization: org, + }; + } + + return null; + } + + // This is the organization that got stored as an cookie + // We make sure it actually exists before directing to it. + if (previouslyVisitedOrganizationId) { + const orgId = await injector.get(IdTranslator).translateOrganizationId({ + organization: previouslyVisitedOrganizationId, + }); + + const org = await organizationManager.getOrganization({ + organization: orgId, + }); + + if (org) { + return { + selector: { + organization: org.cleanId, + }, + organization: org, + }; + } + } + + if (user?.id) { + const allOrganizations = await organizationManager.getOrganizations(); + + if (allOrganizations.length > 0) { + const firstOrg = allOrganizations[0]; + + return { + selector: { + organization: firstOrg.cleanId, + }, + organization: firstOrg, + }; + } + } + + return null; + }, async organizationByInviteCode(_, { code }, { injector }) { const organization = await injector.get(OrganizationManager).getOrganizationByInviteCode({ code, diff --git a/packages/services/api/src/shared/errors.ts b/packages/services/api/src/shared/errors.ts index 23d49ab0c..211293c68 100644 --- a/packages/services/api/src/shared/errors.ts +++ b/packages/services/api/src/shared/errors.ts @@ -27,7 +27,11 @@ export function isGraphQLError(error: unknown): error is GraphQLError { export const HiveError = GraphQLError; export class AccessError extends HiveError { - constructor(reason: string) { - super(`No access (reason: "${reason}")`); + constructor(reason: string, code: string = 'UNAUTHORISED') { + super(`No access (reason: "${reason}")`, { + extensions: { + code, + }, + }); } } diff --git a/packages/services/external-composition/federation-2/package.json b/packages/services/external-composition/federation-2/package.json index fe5f50783..e796bff04 100644 --- a/packages/services/external-composition/federation-2/package.json +++ b/packages/services/external-composition/federation-2/package.json @@ -4,7 +4,7 @@ "license": "MIT", "private": true, "scripts": { - "build": "BUILD=1 tsx ../../../../scripts/runify.ts", + "build": "tsx ../../../../scripts/runify.ts", "dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/index.ts" }, "devDependencies": { diff --git a/packages/services/server/.env.template b/packages/services/server/.env.template index c3037575d..a99e4112d 100644 --- a/packages/services/server/.env.template +++ b/packages/services/server/.env.template @@ -57,3 +57,23 @@ SUPERTOKENS_API_KEY=bubatzbieber6942096420 # Organization level Open ID Connect Authentication AUTH_ORGANIZATION_OIDC=0 +## Enable GitHub login +AUTH_GITHUB="" +AUTH_GITHUB_CLIENT_ID="" +AUTH_GITHUB_CLIENT_SECRET="" + +## Enable Google login +AUTH_GOOGLE="" +AUTH_GOOGLE_CLIENT_ID="" +AUTH_GOOGLE_CLIENT_SECRET="" + +WEB_APP_URL="http://localhost:3000" +GRAPHQL_PUBLIC_ORIGIN="http://localhost:3001" + +## Enable Okta login +AUTH_OKTA=0 +AUTH_OKTA_HIDDEN=0 +AUTH_OKTA_ENDPOINT="" +AUTH_OKTA_CLIENT_ID="" +AUTH_OKTA_CLIENT_SECRET="" + diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 987c84252..6526f8e39 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -9,6 +9,7 @@ The GraphQL API for GraphQL Hive. | `PORT` | **Yes** | The port this service is running on. | `4013` | | `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` | | `WEB_APP_URL` | **Yes** | The url of the web app. | `http://127.0.0.1:3000` | +| `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The origin of the GraphQL server. | `http://127.0.0.1:4013` | | `RATE_LIMIT_ENDPOINT` | **Yes** | The endpoint of the rate limiting service. | `http://127.0.0.1:4012` | | `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` | | `TOKENS_ENDPOINT` | **Yes** | The endpoint of the tokens service. | `http://127.0.0.1:6001` | @@ -40,10 +41,22 @@ The GraphQL API for GraphQL Hive. | `CDN_API_BASE_URL` | No (Yes if `CDN_API` is set to `1`) | The public base url of the API service. | `http://localhost:8082` | | `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` | +| `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` | +| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | +| `AUTH_GOOGLE` | No | Whether login via Google should be allowed | `1` (enabled) or `0` (disabled) | +| `AUTH_GOOGLE_CLIENT_ID` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client ID. | `g6aff8102efda5e1d12e` | +| `AUTH_GOOGLE_CLIENT_SECRET` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | +| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) | +| `AUTH_OKTA` | No | Whether login via Okta should be allowed | `1` (enabled) or `0` (disabled) | +| `AUTH_OKTA_CLIENT_ENDPOINT` | No (**Yes** if `AUTH_OKTA` is set) | The Okta endpoint. | `https://dev-1234567.okta.com` | +| `AUTH_OKTA_HIDDEN` | No | Whether the Okta login button should be hidden. (Default: `0`) | `1` (enabled) or `0` (disabled) | +| `AUTH_OKTA_CLIENT_ID` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client ID. | `g6aff8102efda5e1d12e` | +| `AUTH_OKTA_CLIENT_SECRET` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | +| `AUTH_REQUIRE_EMAIL_VERIFICATION` | No | Whether verifying the email address is mandatory. | `1` (enabled) or `0` (disabled) | | `INTEGRATION_GITHUB` | No | Whether the GitHub integration is enabled | `1` (enabled) or `0` (disabled) | | `INTEGRATION_GITHUB_GITHUB_APP_ID` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app id. | `123` | | `INTEGRATION_GITHUB_GITHUB_APP_PRIVATE_KEY` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app private key. | `letmein1` | -| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) | | `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | | `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` (enabled) or `0` (disabled) | | `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` | diff --git a/packages/services/server/package.json b/packages/services/server/package.json index 55051ea00..4c7d23e0d 100644 --- a/packages/services/server/package.json +++ b/packages/services/server/package.json @@ -20,6 +20,8 @@ "@escape.tech/graphql-armor-max-depth": "2.2.0", "@escape.tech/graphql-armor-max-directives": "2.1.0", "@escape.tech/graphql-armor-max-tokens": "2.3.0", + "@fastify/cors": "9.0.1", + "@fastify/formbody": "7.4.0", "@graphql-hive/client": "workspace:*", "@graphql-yoga/plugin-persisted-operations": "3.2.0", "@graphql-yoga/plugin-response-cache": "3.4.0", @@ -30,6 +32,7 @@ "@sentry/integrations": "7.108.0", "@sentry/node": "7.108.0", "@swc/core": "1.4.8", + "@trpc/client": "10.45.2", "@trpc/server": "10.45.2", "@whatwg-node/server": "0.9.32", "dotenv": "16.4.5", @@ -41,6 +44,8 @@ "pino-pretty": "11.0.0", "prom-client": "15.1.0", "reflect-metadata": "0.2.1", + "supertokens-js-override": "0.0.4", + "supertokens-node": "15.2.1", "tslib": "2.6.2", "zod": "3.22.4" }, diff --git a/packages/services/server/src/api.ts b/packages/services/server/src/api.ts index 159549e2f..ff1c5f30a 100644 --- a/packages/services/server/src/api.ts +++ b/packages/services/server/src/api.ts @@ -2,23 +2,19 @@ import { CryptoProvider } from 'packages/services/api/src/modules/shared/provide import { z } from 'zod'; import type { Storage } from '@hive/api'; import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@hive/api'; -import { FastifyRequest, handleTRPCError } from '@hive/service-common'; import type { inferAsyncReturnType } from '@trpc/server'; import { initTRPC } from '@trpc/server'; export async function createContext({ storage, crypto, - req, }: { storage: Storage; crypto: CryptoProvider; - req: FastifyRequest; }) { return { storage, crypto, - req, }; } @@ -32,10 +28,9 @@ const oidcDefaultScopes = [ ]; const t = initTRPC.context().create(); -const procedure = t.procedure.use(handleTRPCError); export const internalApiRouter = t.router({ - ensureUser: procedure + ensureUser: t.procedure .input( z .object({ @@ -58,64 +53,8 @@ export const internalApiRouter = t.router({ return result; }), - getDefaultOrgForUser: procedure - .input( - z.object({ - superTokensUserId: z.string(), - lastOrgId: z.union([z.string(), z.null()]), - }), - ) - .query(async ({ input, ctx }) => { - const user = await ctx.storage.getUserBySuperTokenId({ - superTokensUserId: input.superTokensUserId, - }); - // For an OIDC Integration User we want to return the linked organization - if (user?.oidcIntegrationId) { - const oidcIntegration = await ctx.storage.getOIDCIntegrationById({ - oidcIntegrationId: user.oidcIntegrationId, - }); - if (oidcIntegration) { - const org = await ctx.storage.getOrganization({ - organization: oidcIntegration.linkedOrganizationId, - }); - - return { - id: org.id, - cleanId: org.cleanId, - }; - } - } - - // This is the organizaton that got stored as an cookie - // We make sure it actually exists before directing to it. - if (input.lastOrgId) { - const org = await ctx.storage.getOrganizationByCleanId({ cleanId: input.lastOrgId }); - - if (org) { - return { - id: org.id, - cleanId: org.cleanId, - }; - } - } - - if (user?.id) { - const allAllOraganizations = await ctx.storage.getOrganizations({ user: user.id }); - - if (allAllOraganizations.length > 0) { - const someOrg = allAllOraganizations[0]; - - return { - id: someOrg.id, - cleanId: someOrg.cleanId, - }; - } - } - - return null; - }), - getOIDCIntegrationById: procedure + getOIDCIntegrationById: t.procedure .input( z.object({ oidcIntegrationId: z.string().min(1), @@ -145,4 +84,6 @@ export const internalApiRouter = t.router({ }), }); +export const createInternalApiCaller = t.createCallerFactory(internalApiRouter); + export type InternalApi = typeof internalApiRouter; diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 54f55d07d..83cbffda2 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -22,7 +22,13 @@ const EnvironmentModel = zod.object({ ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), ENCRYPTION_SECRET: emptyString(zod.string()), - WEB_APP_URL: emptyString(zod.string().url().optional()), + WEB_APP_URL: zod.string().url(), + GRAPHQL_PUBLIC_ORIGIN: zod + .string({ + required_error: + 'GRAPHQL_PUBLIC_ORIGIN is required (see: https://github.com/kamilkisiela/graphql-hive/pull/4288#issue-2195509699)', + }) + .url(), RATE_LIMIT_ENDPOINT: emptyString(zod.string().url().optional()), SCHEMA_POLICY_ENDPOINT: emptyString(zod.string().url().optional()), TOKENS_ENDPOINT: zod.string().url(), @@ -34,6 +40,9 @@ const EnvironmentModel = zod.object({ SCHEMA_ENDPOINT: zod.string().url(), AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), GRAPHQL_PERSISTED_OPERATIONS_PATH: emptyString(zod.string().optional()), + AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString( + zod.union([zod.literal('1'), zod.literal('0')]).optional(), + ), }); const SentryModel = zod.union([ @@ -146,6 +155,45 @@ const S3Model = zod.object({ S3_PUBLIC_URL: emptyString(zod.string().url().optional()), }); +const AuthGitHubConfigSchema = zod.union([ + zod.object({ + AUTH_GITHUB: zod.union([zod.void(), zod.literal('0'), zod.literal('')]), + }), + zod.object({ + AUTH_GITHUB: zod.literal('1'), + AUTH_GITHUB_CLIENT_ID: zod.string(), + AUTH_GITHUB_CLIENT_SECRET: zod.string(), + }), +]); + +const AuthGoogleConfigSchema = zod.union([ + zod.object({ + AUTH_GOOGLE: zod.union([zod.void(), zod.literal('0'), zod.literal('')]), + }), + zod.object({ + AUTH_GOOGLE: zod.literal('1'), + AUTH_GOOGLE_CLIENT_ID: zod.string(), + AUTH_GOOGLE_CLIENT_SECRET: zod.string(), + }), +]); + +const AuthOktaConfigSchema = zod.union([ + zod.object({ + AUTH_OKTA: zod.union([zod.void(), zod.literal('0')]), + }), + zod.object({ + AUTH_OKTA: zod.literal('1'), + AUTH_OKTA_HIDDEN: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), + AUTH_OKTA_ENDPOINT: zod.string().url(), + AUTH_OKTA_CLIENT_ID: zod.string(), + AUTH_OKTA_CLIENT_SECRET: zod.string(), + }), +]); + +const AuthOktaMultiTenantSchema = zod.object({ + AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), +}); + const LogModel = zod.object({ LOG_LEVEL: emptyString( zod @@ -165,35 +213,28 @@ const LogModel = zod.object({ ), }); +// eslint-disable-next-line no-process-env +const processEnv = process.env; + const configs = { - // eslint-disable-next-line no-process-env - base: EnvironmentModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - sentry: SentryModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - postgres: PostgresModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - clickhouse: ClickHouseModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - redis: RedisModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - supertokens: SuperTokensModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - github: GitHubModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - cdnCf: CdnCFModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - cdnApi: CdnApiModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - prometheus: PrometheusModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - hive: HiveModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - s3: S3Model.safeParse(process.env), - // eslint-disable-next-line no-process-env - log: LogModel.safeParse(process.env), - // eslint-disable-next-line no-process-env - zendeskSupport: ZendeskSupportModel.safeParse(process.env), + base: EnvironmentModel.safeParse(processEnv), + sentry: SentryModel.safeParse(processEnv), + postgres: PostgresModel.safeParse(processEnv), + clickhouse: ClickHouseModel.safeParse(processEnv), + redis: RedisModel.safeParse(processEnv), + supertokens: SuperTokensModel.safeParse(processEnv), + authGithub: AuthGitHubConfigSchema.safeParse(processEnv), + authGoogle: AuthGoogleConfigSchema.safeParse(processEnv), + authOkta: AuthOktaConfigSchema.safeParse(processEnv), + authOktaMultiTenant: AuthOktaMultiTenantSchema.safeParse(processEnv), + github: GitHubModel.safeParse(processEnv), + cdnCf: CdnCFModel.safeParse(processEnv), + cdnApi: CdnApiModel.safeParse(processEnv), + prometheus: PrometheusModel.safeParse(processEnv), + hive: HiveModel.safeParse(processEnv), + s3: S3Model.safeParse(processEnv), + log: LogModel.safeParse(processEnv), + zendeskSupport: ZendeskSupportModel.safeParse(processEnv), }; const environmentErrors: Array = []; @@ -223,6 +264,10 @@ const sentry = extractConfig(configs.sentry); const clickhouse = extractConfig(configs.clickhouse); const redis = extractConfig(configs.redis); const supertokens = extractConfig(configs.supertokens); +const authGithub = extractConfig(configs.authGithub); +const authGoogle = extractConfig(configs.authGoogle); +const authOkta = extractConfig(configs.authOkta); +const authOktaMultiTenant = extractConfig(configs.authOktaMultiTenant); const github = extractConfig(configs.github); const prometheus = extractConfig(configs.prometheus); const log = extractConfig(configs.log); @@ -254,11 +299,9 @@ export const env = { release: base.RELEASE ?? 'local', encryptionSecret: base.ENCRYPTION_SECRET, hiveServices: { - webApp: base.WEB_APP_URL - ? { - url: base.WEB_APP_URL, - } - : null, + webApp: { + url: base.WEB_APP_URL, + }, tokens: { endpoint: base.TOKENS_ENDPOINT, }, @@ -311,6 +354,33 @@ export const env = { connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI, apiKey: supertokens.SUPERTOKENS_API_KEY, }, + auth: { + github: + authGithub.AUTH_GITHUB === '1' + ? { + clientId: authGithub.AUTH_GITHUB_CLIENT_ID, + clientSecret: authGithub.AUTH_GITHUB_CLIENT_SECRET, + } + : null, + google: + authGoogle.AUTH_GOOGLE === '1' + ? { + clientId: authGoogle.AUTH_GOOGLE_CLIENT_ID, + clientSecret: authGoogle.AUTH_GOOGLE_CLIENT_SECRET, + } + : null, + okta: + authOkta.AUTH_OKTA === '1' + ? { + endpoint: authOkta.AUTH_OKTA_ENDPOINT, + hidden: authOkta.AUTH_OKTA_HIDDEN === '1', + clientId: authOkta.AUTH_OKTA_CLIENT_ID, + clientSecret: authOkta.AUTH_OKTA_CLIENT_SECRET, + } + : null, + organizationOIDC: authOktaMultiTenant.AUTH_ORGANIZATION_OIDC === '1', + requireEmailVerification: base.AUTH_REQUIRE_EMAIL_VERIFICATION === '1', + }, github: github.INTEGRATION_GITHUB === '1' ? { @@ -357,6 +427,7 @@ export const env = { hive: hiveConfig, graphql: { persistedOperationsPath: base.GRAPHQL_PERSISTED_OPERATIONS_PATH ?? null, + origin: base.GRAPHQL_PUBLIC_ORIGIN, }, zendeskSupport: zendeskSupport.ZENDESK_SUPPORT === '1' diff --git a/packages/services/server/src/graphql-handler.ts b/packages/services/server/src/graphql-handler.ts index 8219dae2c..d6a72303c 100644 --- a/packages/services/server/src/graphql-handler.ts +++ b/packages/services/server/src/graphql-handler.ts @@ -12,7 +12,6 @@ import { } from 'graphql'; import { createYoga, Plugin, useErrorHandler } from 'graphql-yoga'; import hyperid from 'hyperid'; -import zod from 'zod'; import { isGraphQLError } from '@envelop/core'; import { useGenericAuth } from '@envelop/generic-auth'; import { useGraphQlJit } from '@envelop/graphql-jit'; @@ -21,11 +20,12 @@ import { useSentry } from '@envelop/sentry'; import { useYogaHive } from '@graphql-hive/client'; import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'; import { useResponseCache } from '@graphql-yoga/plugin-response-cache'; -import { HiveError, Registry, RegistryContext } from '@hive/api'; +import { Registry, RegistryContext } from '@hive/api'; import { cleanRequestId } from '@hive/service-common'; import { runWithAsyncContext } from '@sentry/node'; import { asyncStorage } from './async-storage'; import type { HiveConfig } from './environment'; +import { resolveUser, type SupertokensSession } from './supertokens'; import { useArmor } from './use-armor'; import { extractUserId, useSentryUser } from './use-sentry-user'; @@ -37,23 +37,6 @@ function hashSessionId(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex'); } -const SuperTokenAccessTokenModel = zod.object({ - version: zod.literal('1'), - superTokensUserId: zod.string(), - /** - * Supertokens for some reason omits externalUserId from the access token payload if it is null. - */ - externalUserId: zod.optional(zod.union([zod.string(), zod.null()])), - email: zod.string(), -}); - -const SuperTokenSessionVerifyModel = zod.object({ - status: zod.literal('OK'), - session: zod.object({ - userDataInJWT: SuperTokenAccessTokenModel, - }), -}); - export interface GraphQLHandlerOptions { graphiqlEndpoint: string; registry: Registry; @@ -69,12 +52,10 @@ export interface GraphQLHandlerOptions { persistedOperations: Record | null; } -export type SuperTokenSessionPayload = zod.TypeOf; - interface Context extends RegistryContext { req: FastifyRequest; reply: FastifyReply; - session: SuperTokenSessionPayload | null; + session: SupertokensSession | null; } const NoIntrospection: ValidationRule = (context: ValidationContext) => ({ @@ -204,27 +185,8 @@ export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMeth useGenericAuth({ mode: 'resolve-only', contextFieldName: 'session', - resolveUserFn: async (ctx: Context) => { - if (ctx.headers['authorization']) { - let authHeader = ctx.headers['authorization']; - authHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader; - - const authHeaderParts = authHeader.split(' '); - if (authHeaderParts.length === 2 && authHeaderParts[0] === 'Bearer') { - const accessToken = authHeaderParts[1]; - // The token issued by Hive is always 32 characters long. - // Everything longer should be treated as a supertokens token (JWT). - if (accessToken.length > 32) { - return await verifySuperTokensSession( - options.supertokens.connectionUri, - options.supertokens.apiKey, - accessToken, - ); - } - } - } - - return null; + async resolveUserFn(ctx: Context) { + return resolveUser(ctx); }, }), useYogaHive({ @@ -358,52 +320,6 @@ export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMeth }; }; -/** - * Verify whether a SuperTokens access token session is valid. - * https://app.swaggerhub.com/apis/supertokens/CDI/2.20#/Session%20Recipe/verifySession - */ -async function verifySuperTokensSession( - connectionUri: string, - apiKey: string, - accessToken: string, -): Promise { - const response = await fetch(connectionUri + '/appid-public/public/recipe/session/verify', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'api-key': apiKey, - rid: 'session', - 'cdi-version': '4.0', - }, - body: JSON.stringify({ - accessToken, - enableAntiCsrf: false, - doAntiCsrfCheck: false, - checkDatabase: true, - }), - }); - const body = await response.text(); - if (response.status !== 200) { - console.error( - `SuperTokens session verification failed with status ${response.status}.\n` + body, - ); - throw new Error(`SuperTokens instance returned an unexpected error.`); - } - - const result = SuperTokenSessionVerifyModel.safeParse(JSON.parse(body)); - - if (result.success === false) { - console.error(`SuperTokens session verification failed.\n` + body); - throw new HiveError(`Invalid token provided`); - } - - // ensure externalUserId is a string or null - return { - ...result.data.session.userDataInJWT, - externalUserId: result.data.session.userDataInJWT.externalUserId ?? null, - }; -} - function isOperationDefinitionNode(def: DefinitionNode): def is OperationDefinitionNode { return def.kind === Kind.OPERATION_DEFINITION; } diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 0f7ad6e42..2fb623208 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -2,8 +2,16 @@ import * as fs from 'fs'; import got from 'got'; import { DocumentNode, GraphQLError, stripIgnoredCharacters } from 'graphql'; +import supertokens from 'supertokens-node'; +import { + errorHandler as supertokensErrorHandler, + plugin as supertokensFastifyPlugin, +} from 'supertokens-node/framework/fastify/index.js'; +import cors from '@fastify/cors'; +import type { FastifyCorsOptionsDelegateCallback } from '@fastify/cors'; import 'reflect-metadata'; import { hostname } from 'os'; +import formDataPlugin from '@fastify/formbody'; import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api'; import { createArtifactRequestHandler } from '@hive/cdn-script/artifact-handler'; import { ArtifactStorageReader } from '@hive/cdn-script/artifact-storage-reader'; @@ -36,6 +44,7 @@ import { asyncStorage } from './async-storage'; import { env } from './environment'; import { graphqlHandler } from './graphql-handler'; import { clickHouseElapsedDuration, clickHouseReadDuration } from './metrics'; +import { initSupertokens } from './supertokens'; export async function main() { init({ @@ -69,6 +78,7 @@ export async function main() { const server = await createServer({ name: 'graphql-api', tracing: true, + cors: false, log: { level: env.log.level, requests: env.log.requests, @@ -93,6 +103,31 @@ export async function main() { }, ); + server.setErrorHandler(supertokensErrorHandler()); + await server.register(cors, (_: unknown): FastifyCorsOptionsDelegateCallback => { + return (req, callback) => { + if (req.headers.origin?.startsWith(env.hiveServices.webApp.url)) { + // We need to treat requests from the web app a bit differently than others. + // The web app requires to define the `Access-Control-Allow-Origin` header (not *). + callback(null, { + origin: env.hiveServices.webApp.url, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'graphql-client-version', + 'graphql-client-name', + 'x-request-id', + ...supertokens.getAllCORSHeaders(), + ], + }); + return; + } + + callback(null, {}); + }; + }); + const storage = await createPostgreSQLStorage(createConnectionString(env.postgres), 10); let dbPurgeTaskRunner: null | ReturnType = null; @@ -292,9 +327,7 @@ export async function main() { schemaConfig: env.hiveServices.webApp ? { schemaPublishLink(input) { - let url = `${env.hiveServices.webApp!.url}/${input.organization.cleanId}/${ - input.project.cleanId - }/${input.target.cleanId}`; + let url = `${env.hiveServices.webApp.url}/${input.organization.cleanId}/${input.project.cleanId}/${input.target.cleanId}`; if (input.version) { url += `/history/${input.version.id}`; @@ -303,9 +336,7 @@ export async function main() { return url; }, schemaCheckLink(input) { - return `${env.hiveServices.webApp!.url}/${input.organization.cleanId}/${ - input.project.cleanId - }/${input.target.cleanId}/checks/${input.schemaCheckId}`; + return `${env.hiveServices.webApp.url}/${input.organization.cleanId}/${input.project.cleanId}/${input.target.cleanId}/checks/${input.schemaCheckId}`; }, } : {}, @@ -359,17 +390,26 @@ export async function main() { const crypto = new CryptoProvider(env.encryptionSecret); + initSupertokens({ + storage, + crypto, + logger: server.log, + }); + + await server.register(formDataPlugin); + await server.register(supertokensFastifyPlugin); + await registerTRPC(server, { router: internalApiRouter, - createContext({ req }) { - return createContext({ storage, crypto, req }); + createContext() { + return createContext({ storage, crypto }); }, }); server.route({ method: ['GET', 'HEAD'], url: '/_health', - async handler(req, res) { + async handler(_, res) { res.status(200).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void }, }); diff --git a/packages/web/app/src/config/supertokens/backend.ts b/packages/services/server/src/supertokens.ts similarity index 65% rename from packages/web/app/src/config/supertokens/backend.ts rename to packages/services/server/src/supertokens.ts index 65c7394d4..96dd10ac7 100644 --- a/packages/web/app/src/config/supertokens/backend.ts +++ b/packages/services/server/src/supertokens.ts @@ -1,27 +1,50 @@ -import { OverrideableBuilder } from 'supertokens-js-override/lib/build'; -import EmailVerification from 'supertokens-node/recipe/emailverification'; -import SessionNode from 'supertokens-node/recipe/session'; +import type { FastifyBaseLogger, FastifyReply, FastifyRequest } from 'fastify'; +import { CryptoProvider } from 'packages/services/api/src/modules/shared/providers/crypto'; +import { OverrideableBuilder } from 'supertokens-js-override/lib/build/index.js'; +import supertokens from 'supertokens-node'; +import EmailVerification from 'supertokens-node/recipe/emailverification/index.js'; +import SessionNode from 'supertokens-node/recipe/session/index.js'; import type { ProviderInput } from 'supertokens-node/recipe/thirdparty/types'; -import ThirdPartyEmailPasswordNode from 'supertokens-node/recipe/thirdpartyemailpassword'; -import { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-node/recipe/thirdpartyemailpassword/types'; -import { TypeInput } from 'supertokens-node/types'; -import { env } from '@/env/backend'; -import { appInfo } from '@/lib/supertokens/app-info'; +import ThirdPartyEmailPasswordNode from 'supertokens-node/recipe/thirdpartyemailpassword/index.js'; +import type { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-node/recipe/thirdpartyemailpassword/types'; +import type { TypeInput } from 'supertokens-node/types'; +import zod from 'zod'; +import { HiveError, type Storage } from '@hive/api'; +import type { EmailsApi } from '@hive/emails'; +import { captureException } from '@sentry/node'; +import { createTRPCProxyClient, httpLink } from '@trpc/client'; +import { createInternalApiCaller } from './api'; +import { env } from './environment'; import { createOIDCSuperTokensProvider, getOIDCSuperTokensOverrides, -} from '@/lib/supertokens/third-party-email-password-node-oidc-provider'; -import { createThirdPartyEmailPasswordNodeOktaProvider } from '@/lib/supertokens/third-party-email-password-node-okta-provider'; -import type { EmailsApi } from '@hive/emails'; -import type { InternalApi } from '@hive/server'; -import { createTRPCProxyClient, CreateTRPCProxyClient, httpLink } from '@trpc/client'; +} from './supertokens/oidc-provider'; +import { createThirdPartyEmailPasswordNodeOktaProvider } from './supertokens/okta-provider'; -export const backendConfig = (): TypeInput => { +const SuperTokenAccessTokenModel = zod.object({ + version: zod.literal('1'), + superTokensUserId: zod.string(), + /** + * Supertokens for some reason omits externalUserId from the access token payload if it is null. + */ + externalUserId: zod.optional(zod.union([zod.string(), zod.null()])), + email: zod.string(), +}); + +export type SupertokensSession = zod.TypeOf; + +export const backendConfig = (requirements: { + storage: Storage; + crypto: CryptoProvider; + logger: FastifyBaseLogger; +}): TypeInput => { + const { logger } = requirements; const emailsService = createTRPCProxyClient({ - links: [httpLink({ url: `${env.emailsEndpoint}/trpc` })], + links: [httpLink({ url: `${env.hiveServices.emails?.endpoint}/trpc` })], }); - const internalApi = createTRPCProxyClient({ - links: [httpLink({ url: `${env.serverEndpoint}/trpc` })], + const internalApi = createInternalApiCaller({ + storage: requirements.storage, + crypto: requirements.crypto, }); const providers: ProviderInput[] = []; @@ -70,12 +93,24 @@ export const backendConfig = (): TypeInput => { ); } + logger.info('SuperTokens providers: %s', providers.map(p => p.config.thirdPartyId).join(', ')); + logger.info('SuperTokens websiteDomain: %s', env.hiveServices.webApp.url); + logger.info('SuperTokens apiDomain: %s', env.graphql.origin); + return { + framework: 'fastify', supertokens: { - connectionURI: env.supertokens.connectionUri, + connectionURI: env.supertokens.connectionURI, apiKey: env.supertokens.apiKey, }, - appInfo: appInfo(), + appInfo: { + // learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo + appName: 'GraphQL Hive', + apiDomain: env.graphql.origin, + websiteDomain: env.hiveServices.webApp.url, + apiBasePath: '/auth-api', + websiteBasePath: '/auth', + }, recipeList: [ ThirdPartyEmailPasswordNode.init({ providers, @@ -166,7 +201,7 @@ export const backendConfig = (): TypeInput => { }; const getEnsureUserOverrides = ( - internalApi: CreateTRPCProxyClient, + internalApi: ReturnType, ): ThirdPartEmailPasswordTypeInput['override'] => ({ apis: originalImplementation => ({ ...originalImplementation, @@ -178,7 +213,7 @@ const getEnsureUserOverrides = ( const response = await originalImplementation.emailPasswordSignUpPOST(input); if (response.status === 'OK') { - await internalApi.ensureUser.mutate({ + await internalApi.ensureUser({ superTokensUserId: response.user.id, email: response.user.email, oidcIntegrationId: null, @@ -195,7 +230,7 @@ const getEnsureUserOverrides = ( const response = await originalImplementation.emailPasswordSignInPOST(input); if (response.status === 'OK') { - await internalApi.ensureUser.mutate({ + await internalApi.ensureUser({ superTokensUserId: response.user.id, email: response.user.email, oidcIntegrationId: null, @@ -211,7 +246,6 @@ const getEnsureUserOverrides = ( function extractOidcId(args: typeof input) { if (input.provider.id === 'oidc') { - // eslint-disable-next-line prefer-destructuring const oidcId: unknown = args.userContext['oidcId']; if (typeof oidcId === 'string') { return oidcId; @@ -223,7 +257,7 @@ const getEnsureUserOverrides = ( const response = await originalImplementation.thirdPartySignInUpPOST(input); if (response.status === 'OK') { - await internalApi.ensureUser.mutate({ + await internalApi.ensureUser({ superTokensUserId: response.user.id, email: response.user.email, oidcIntegrationId: extractOidcId(input), @@ -291,3 +325,64 @@ const composeSuperTokensOverrides = ( return impl; }, }); + +export function initSupertokens(requirements: { + storage: Storage; + crypto: CryptoProvider; + logger: FastifyBaseLogger; +}) { + supertokens.init(backendConfig(requirements)); +} + +export async function resolveUser(ctx: { req: FastifyRequest; reply: FastifyReply }) { + let session: SessionNode.SessionContainer | undefined; + + try { + session = await SessionNode.getSession(ctx.req, ctx.reply, { + sessionRequired: false, + antiCsrfCheck: false, + checkDatabase: true, + }); + } catch (error) { + if (SessionNode.Error.isErrorFromSuperTokens(error)) { + // Check whether the email is already verified. + // If it is not then we need to redirect to the email verification page - which will trigger the email sending. + if (error.type === SessionNode.Error.INVALID_CLAIMS) { + throw new HiveError('Your account is not verified. Please verify your email address.', { + extensions: { + code: 'VERIFY_EMAIL', + }, + }); + } else if ( + error.type === SessionNode.Error.TRY_REFRESH_TOKEN || + error.type === SessionNode.Error.UNAUTHORISED + ) { + throw new HiveError('Invalid session', { + extensions: { + code: 'NEEDS_REFRESH', + }, + }); + } + } + + ctx.req.log.error(error, 'Error while resolving user'); + captureException(error); + + throw error; + } + + const payload = session?.getAccessTokenPayload(); + + if (!payload) { + return null; + } + + const result = SuperTokenAccessTokenModel.safeParse(payload); + + if (result.success === false) { + console.error(`SuperTokens session verification failed.\n` + JSON.stringify(payload)); + throw new HiveError(`Invalid token provided`); + } + + return result.data; +} diff --git a/packages/web/app/src/lib/supertokens/third-party-email-password-node-oidc-provider.ts b/packages/services/server/src/supertokens/oidc-provider.ts similarity index 73% rename from packages/web/app/src/lib/supertokens/third-party-email-password-node-oidc-provider.ts rename to packages/services/server/src/supertokens/oidc-provider.ts index cb8dff4b6..b4dd962ed 100644 --- a/packages/web/app/src/lib/supertokens/third-party-email-password-node-oidc-provider.ts +++ b/packages/services/server/src/supertokens/oidc-provider.ts @@ -1,13 +1,13 @@ -import type { ExpressRequest } from 'supertokens-node/lib/build/framework/express/framework'; +import type { FastifyRequest } from 'supertokens-node/lib/build/framework/fastify/framework'; import type { ProviderInput } from 'supertokens-node/recipe/thirdparty/types'; import type { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-node/recipe/thirdpartyemailpassword/types'; import zod from 'zod'; -import { getLogger } from '@/server-logger'; -import type { InternalApi } from '@hive/server'; -import type { CreateTRPCProxyClient } from '@trpc/client'; +import { createInternalApiCaller } from '../api'; const couldNotResolveOidcIntegrationSymbol = Symbol('could_not_resolve_oidc_integration'); +type InternalApiCaller = ReturnType; + export const getOIDCSuperTokensOverrides = (): ThirdPartEmailPasswordTypeInput['override'] => ({ apis(originalImplementation) { return { @@ -27,18 +27,17 @@ export const getOIDCSuperTokensOverrides = (): ThirdPartEmailPasswordTypeInput[' }); export const createOIDCSuperTokensProvider = (args: { - internalApi: CreateTRPCProxyClient; + internalApi: InternalApiCaller; }): ProviderInput => ({ config: { thirdPartyId: 'oidc', }, override(originalImplementation) { - const logger = getLogger(); return { ...originalImplementation, async getConfigForClientType(input) { - logger.info('resolve config for OIDC provider.'); + console.info('resolve config for OIDC provider.'); const config = await getOIDCConfigFromInput(args.internalApi, input); if (!config) { // In the next step the override `authorisationUrlGET` from `getOIDCSuperTokensOverrides` is called. @@ -66,7 +65,7 @@ export const createOIDCSuperTokensProvider = (args: { }, async getAuthorisationRedirectURL(input) { - logger.info('resolve config for OIDC provider.'); + console.info('resolve config for OIDC provider.'); const oidcConfig = await getOIDCConfigFromInput(args.internalApi, input); if (!oidcConfig) { @@ -88,7 +87,7 @@ export const createOIDCSuperTokensProvider = (args: { }, async getUserInfo(input) { - logger.info('retrieve profile info from OIDC provider'); + console.info('retrieve profile info from OIDC provider'); const config = await getOIDCConfigFromInput(args.internalApi, input); if (!config) { // This case should never be reached (guarded by getConfigForClientType). @@ -96,7 +95,7 @@ export const createOIDCSuperTokensProvider = (args: { throw new Error('Could not find OIDC integration.'); } - logger.info('fetch info for OIDC provider (oidcId=%s)', config.id); + console.info('fetch info for OIDC provider (oidcId=%s)', config.id); const tokenResponse = OIDCTokenSchema.parse(input.oAuthTokens); const rawData: unknown = await fetch(config.userinfoEndpoint, { @@ -107,16 +106,16 @@ export const createOIDCSuperTokensProvider = (args: { }, }).then(res => res.json()); - logger.info('retrieved profile info for provider (oidcId=%s)', config.id); + console.info('retrieved profile info for provider (oidcId=%s)', config.id); const dataParseResult = OIDCProfileInfoSchema.safeParse(rawData); if (!dataParseResult.success) { - logger.error('Could not parse profile info for OIDC provider (oidcId=%s)', config.id); - logger.error('Raw data: %s', JSON.stringify(rawData)); - logger.error('Error: %s', JSON.stringify(dataParseResult.error)); + console.error('Could not parse profile info for OIDC provider (oidcId=%s)', config.id); + console.error('Raw data: %s', JSON.stringify(rawData)); + console.error('Error: %s', JSON.stringify(dataParseResult.error)); for (const issue of dataParseResult.error.issues) { - logger.debug('Issue: %s', JSON.stringify(issue)); + console.debug('Issue: %s', JSON.stringify(issue)); } throw new Error('Could not parse profile info.'); } @@ -159,45 +158,40 @@ const OIDCProfileInfoSchema = zod.object({ const OIDCTokenSchema = zod.object({ access_token: zod.string() }); const getOIDCIdFromInput = (input: { userContext: any }): string => { - const expressRequest = input.userContext._default.request as ExpressRequest; - const originalUrl = 'http://localhost' + expressRequest.getOriginalURL(); + const fastifyRequest = input.userContext._default.request as FastifyRequest; + const originalUrl = 'http://localhost' + fastifyRequest.getOriginalURL(); const oidcId = new URL(originalUrl).searchParams.get('oidc_id'); if (typeof oidcId !== 'string') { - const logger = getLogger(); - logger.error('Invalid OIDC ID sent from client: %s', oidcId); + console.error('Invalid OIDC ID sent from client: %s', oidcId); throw new Error('Invalid OIDC ID sent from client.'); } return oidcId; }; -const configCache = new WeakMap(); +const configCache = new WeakMap(); /** * Get cached OIDC config from the supertokens input. */ -async function getOIDCConfigFromInput( - internalApi: CreateTRPCProxyClient, - input: { userContext: any }, -) { - const expressRequest = input.userContext._default.request as ExpressRequest; - if (configCache.has(expressRequest)) { - return configCache.get(expressRequest) ?? null; +async function getOIDCConfigFromInput(internalApi: InternalApiCaller, input: { userContext: any }) { + const fastifyRequest = input.userContext._default.request as FastifyRequest; + if (configCache.has(fastifyRequest)) { + return configCache.get(fastifyRequest) ?? null; } const oidcId = getOIDCIdFromInput(input); const config = await fetchOIDCConfig(internalApi, oidcId); - configCache.set(expressRequest, config); + configCache.set(fastifyRequest, config); if (!config) { - const logger = getLogger(); - logger.error('Could not find OIDC integration (oidcId: %s)', oidcId); + console.error('Could not find OIDC integration (oidcId: %s)', oidcId); } return config; } const fetchOIDCConfig = async ( - internalApi: CreateTRPCProxyClient, + internalApi: InternalApiCaller, oidcIntegrationId: string, ): Promise<{ id: string; @@ -207,10 +201,10 @@ const fetchOIDCConfig = async ( userinfoEndpoint: string; authorizationEndpoint: string; } | null> => { - const result = await internalApi.getOIDCIntegrationById.query({ oidcIntegrationId }); + const result = await internalApi.getOIDCIntegrationById({ oidcIntegrationId }); if (result === null) { - const logger = getLogger(); - logger.error('OIDC integration not found. (oidcId=%s)', oidcIntegrationId); + // TODO: replace console.error with req.log.error + console.error('OIDC integration not found. (oidcId=%s)', oidcIntegrationId); return null; } return result; diff --git a/packages/web/app/src/lib/supertokens/third-party-email-password-node-okta-provider.ts b/packages/services/server/src/supertokens/okta-provider.ts similarity index 94% rename from packages/web/app/src/lib/supertokens/third-party-email-password-node-okta-provider.ts rename to packages/services/server/src/supertokens/okta-provider.ts index 64a3bb09d..3d6030606 100644 --- a/packages/web/app/src/lib/supertokens/third-party-email-password-node-okta-provider.ts +++ b/packages/services/server/src/supertokens/okta-provider.ts @@ -1,6 +1,6 @@ import type { ProviderInput } from 'supertokens-node/recipe/thirdparty/types'; import zod from 'zod'; -import { env } from '@/env/backend'; +import { env } from '../environment'; type OktaConfig = Exclude<(typeof env)['auth']['okta'], null>; @@ -58,7 +58,7 @@ const OktaProfileModel = zod.object({ }), }); -const fetchOktaProfile = async (config: OktaConfig, accessToken: string) => { +async function fetchOktaProfile(config: OktaConfig, accessToken: string) { const response = await fetch(`${config.endpoint}/api/v1/users/me`, { method: 'GET', headers: { @@ -74,4 +74,4 @@ const fetchOktaProfile = async (config: OktaConfig, accessToken: string) => { const json = await response.json(); return OktaProfileModel.parse(json); -}; +} diff --git a/packages/services/service-common/src/fastify.ts b/packages/services/service-common/src/fastify.ts index 4dde00304..7a001048a 100644 --- a/packages/services/service-common/src/fastify.ts +++ b/packages/services/service-common/src/fastify.ts @@ -13,6 +13,7 @@ export async function createServer(options: { requests: boolean; level: string; }; + cors?: boolean; bodyLimit?: number; }) { const server = fastify({ @@ -48,7 +49,9 @@ export async function createServer(options: { await useRequestLogging(server); } - await server.register(cors); + if (options.cors !== false) { + await server.register(cors); + } return server; } diff --git a/packages/web/app/.env.template b/packages/web/app/.env.template index 3521e3028..f8743637b 100644 --- a/packages/web/app/.env.template +++ b/packages/web/app/.env.template @@ -1,31 +1,21 @@ -GRAPHQL_ENDPOINT="http://localhost:3001/graphql" APP_BASE_URL="http://localhost:3000" -SERVER_ENDPOINT="http://localhost:3001" ENVIRONMENT="development" -# Supertokens - -SUPERTOKENS_CONNECTION_URI="http://localhost:3567" -SUPERTOKENS_API_KEY="bubatzbieber6942096420" +# Public GraphQL endpoint +GRAPHQL_PUBLIC_ENDPOINT="http://localhost:3001/graphql" +GRAPHQL_PUBLIC_ORIGIN="http://localhost:3001" # Auth Provider ## Enable GitHub login AUTH_GITHUB="" -AUTH_GITHUB_CLIENT_ID="" -AUTH_GITHUB_CLIENT_SECRET="" ## Enable Google login AUTH_GOOGLE="" -AUTH_GOOGLE_CLIENT_ID="" -AUTH_GOOGLE_CLIENT_SECRET="" ## Enable Okta login AUTH_OKTA=0 AUTH_OKTA_HIDDEN=0 -AUTH_OKTA_ENDPOINT="" -AUTH_OKTA_CLIENT_ID="" -AUTH_OKTA_CLIENT_SECRET="" ## Organization level Open ID Connect Authentication AUTH_ORGANIZATION_OIDC=0 @@ -41,7 +31,3 @@ INTEGRATION_GITHUB_APP_NAME="" # Stripe STRIPE_PUBLIC_KEY="" - -# Emails Service - -EMAILS_ENDPOINT=http://localhost:6260 diff --git a/packages/web/app/.gitignore b/packages/web/app/.gitignore index 8cc166d6e..fa2cc38ac 100644 --- a/packages/web/app/.gitignore +++ b/packages/web/app/.gitignore @@ -30,6 +30,9 @@ npm-debug.log* .env.test.local .env.production.local +# next-env-runtime +public/__ENV.js + # vercel .vercel diff --git a/packages/web/app/README.md b/packages/web/app/README.md index ccdd232bb..4683ddb2a 100644 --- a/packages/web/app/README.md +++ b/packages/web/app/README.md @@ -9,27 +9,17 @@ The following environment variables configure the application. | Name | Required | Description | Example Value | | --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | `APP_BASE_URL` | **Yes** | The base url of the app, | `https://app.graphql-hive.com` | -| `SERVER_ENDPOINT` | **Yes** | The endpoint of the Hive server. | `http://127.0.0.1:4000` | -| `GRAPHQL_ENDPOINT` | **Yes** | The endpoint of the Hive GraphQL API. | `http://127.0.0.1:4000/graphql` | -| `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` | -| `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` | +| `GRAPHQL_PUBLIC_ENDPOINT` | **Yes** | The public endpoint of the Hive GraphQL API. | `http://127.0.0.1:4000/graphql` | +| `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The http address origin of the Hive GraphQL server. | `http://127.0.0.1:4000/` | | `INTEGRATION_SLACK` | No | Whether the Slack integration is enabled or disabled. | `1` (enabled) or `0` (disabled) | | `INTEGRATION_SLACK_SLACK_CLIENT_ID` | No (**Yes** if `INTEGRATION_SLACK` is set) | The Slack client ID. | `g6aff8102efda5e1d12e` | | `INTEGRATION_SLACK_SLACK_CLIENT_SECRET` | No (**Yes** if `INTEGRATION_SLACK` is set) | The Slack client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | | `INTEGRATION_GITHUB_APP_NAME` | No | The GitHub application name. | `graphql-hive-self-hosted` | | `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` | -| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | | `AUTH_GOOGLE` | No | Whether login via Google should be allowed | `1` (enabled) or `0` (disabled) | -| `AUTH_GOOGLE_CLIENT_ID` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client ID. | `g6aff8102efda5e1d12e` | -| `AUTH_GOOGLE_CLIENT_SECRET` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | | `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) | | `AUTH_OKTA` | No | Whether login via Okta should be allowed | `1` (enabled) or `0` (disabled) | -| `AUTH_OKTA_CLIENT_ENDPOINT` | No (**Yes** if `AUTH_OKTA` is set) | The Okta endpoint. | `https://dev-1234567.okta.com` | | `AUTH_OKTA_HIDDEN` | No | Whether the Okta login button should be hidden. (Default: `0`) | `1` (enabled) or `0` (disabled) | -| `AUTH_OKTA_CLIENT_ID` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client ID. | `g6aff8102efda5e1d12e` | -| `AUTH_OKTA_CLIENT_SECRET` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | | `AUTH_REQUIRE_EMAIL_VERIFICATION` | No | Whether verifying the email address is mandatory. | `1` (enabled) or `0` (disabled) | | `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | | `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` | @@ -37,7 +27,7 @@ The following environment variables configure the application. | `DOCS_URL` | No | The URL of the Hive Docs | `https://the-guild.dev/graphql/hive/docs` | | `NODE_ENV` | No | The `NODE_ENV` value. | `production` | | `GA_TRACKING_ID` | No | The token for Google Analytics in order to track user actions. | `g6aff8102efda5e1d12e` | -| `GRAPHQL_PERSISTED_OPERATIONS` | No | Send persisted oepration hashes instead of documents to the | +| `GRAPHQL_PERSISTED_OPERATIONS` | No | Send persisted operation hashes instead of documents to the | | server. | `1` (enabled) or `0` (disabled) | ## Hive Hosted Configuration diff --git a/packages/web/app/environment.ts b/packages/web/app/environment.ts index 40a7c8dfa..a424b73df 100644 --- a/packages/web/app/environment.ts +++ b/packages/web/app/environment.ts @@ -1,250 +1,33 @@ -import zod from 'zod'; -import * as Sentry from '@sentry/nextjs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { ALLOWED_ENVIRONMENT_VARIABLES } from './src/env/frontend-public-variables'; -console.log('🌲 Loading environment variables...'); +// +// Runtime environment in Next.js +// -// Weird hacky way of getting the Sentry.Integrations object -// When the nextjs config is loaded by Next CLI Sentry has `Integrations` property. -// When nextjs starts and the `environment.js` is loaded, the Sentry object doesn't have the `Integrations` property, it' under `Sentry.default` property. -// Dealing with esm/cjs/default exports is a pain, we all feel that pain... -const Integrations = - 'default' in Sentry - ? ((Sentry as any).default as typeof Sentry).Integrations - : Sentry.Integrations; +configureRuntimeEnv(ALLOWED_ENVIRONMENT_VARIABLES); -// treat an empty string `''` as `undefined` -const emptyString = (input: T) => { - return zod.preprocess((value: unknown) => { - if (value === '') return undefined; - return value; - }, input); -}; - -const BaseSchema = zod.object({ - NODE_ENV: zod.string(), - ENVIRONMENT: zod.string(), - APP_BASE_URL: zod.string().url(), - GRAPHQL_ENDPOINT: zod.string().url(), - SERVER_ENDPOINT: zod.string().url(), - EMAILS_ENDPOINT: zod.string().url(), - SUPERTOKENS_CONNECTION_URI: zod.string().url(), - SUPERTOKENS_API_KEY: zod.string(), - INTEGRATION_GITHUB_APP_NAME: emptyString(zod.string().optional()), - GA_TRACKING_ID: emptyString(zod.string().optional()), - DOCS_URL: emptyString(zod.string().url().optional()), - STRIPE_PUBLIC_KEY: emptyString(zod.string().optional()), - RELEASE: emptyString(zod.string().optional()), - AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString( - zod.union([zod.literal('1'), zod.literal('0')]).optional(), - ), - GRAPHQL_PERSISTED_OPERATIONS: emptyString( - zod.union([zod.literal('1'), zod.literal('0')]).optional(), - ), - ZENDESK_SUPPORT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - -const IntegrationSlackSchema = zod.union([ - zod.object({ - INTEGRATION_SLACK: emptyString(zod.literal('0').optional()), - }), - zod.object({ - INTEGRATION_SLACK: zod.literal('1'), - INTEGRATION_SLACK_CLIENT_ID: zod.string(), - INTEGRATION_SLACK_CLIENT_SECRET: zod.string(), - }), -]); - -const AuthGitHubConfigSchema = zod.union([ - zod.object({ - AUTH_GITHUB: zod.union([zod.void(), zod.literal('0'), zod.literal('')]), - }), - zod.object({ - AUTH_GITHUB: zod.literal('1'), - AUTH_GITHUB_CLIENT_ID: zod.string(), - AUTH_GITHUB_CLIENT_SECRET: zod.string(), - }), -]); - -const AuthGoogleConfigSchema = zod.union([ - zod.object({ - AUTH_GOOGLE: zod.union([zod.void(), zod.literal('0'), zod.literal('')]), - }), - zod.object({ - AUTH_GOOGLE: zod.literal('1'), - AUTH_GOOGLE_CLIENT_ID: zod.string(), - AUTH_GOOGLE_CLIENT_SECRET: zod.string(), - }), -]); - -const AuthOktaConfigSchema = zod.union([ - zod.object({ - AUTH_OKTA: zod.union([zod.void(), zod.literal('0')]), - }), - zod.object({ - AUTH_OKTA: zod.literal('1'), - AUTH_OKTA_HIDDEN: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), - AUTH_OKTA_ENDPOINT: zod.string().url(), - AUTH_OKTA_CLIENT_ID: zod.string(), - AUTH_OKTA_CLIENT_SECRET: zod.string(), - }), -]); - -const AuthOktaMultiTenantSchema = zod.object({ - AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - -const SentryConfigSchema = zod.union([ - zod.object({ - SENTRY: zod.union([zod.void(), zod.literal('0')]), - }), - zod.object({ - SENTRY: zod.literal('1'), - SENTRY_DSN: zod.string(), - }), -]); - -const MigrationsSchema = zod.object({ - MEMBER_ROLES_DEADLINE: emptyString( - zod - .date({ - coerce: true, - }) - .optional(), - ), -}); - -const configs = { +// Writes the environment variables to public/__ENV.js file and make them accessible under `window.__ENV` +function configureRuntimeEnv(publicEnvVars: readonly string[]) { + const envObject: Record = {}; // eslint-disable-next-line no-process-env - base: BaseSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - integrationSlack: IntegrationSlackSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - sentry: SentryConfigSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - authGithub: AuthGitHubConfigSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - authGoogle: AuthGoogleConfigSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - authOkta: AuthOktaConfigSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - authOktaMultiTenant: AuthOktaMultiTenantSchema.safeParse(process.env), - // eslint-disable-next-line no-process-env - migrations: MigrationsSchema.safeParse(process.env), -}; + const processEnv = process.env; -const environmentErrors: Array = []; - -for (const config of Object.values(configs)) { - if (config.success === false) { - environmentErrors.push(JSON.stringify(config.error.format(), null, 4)); + for (const key in processEnv) { + if (publicEnvVars.includes(key)) { + envObject[key] = processEnv[key]; + } } -} -if (environmentErrors.length) { - const fullError = environmentErrors.join('\n'); - console.error('❌ Invalid environment variables:', fullError); - process.exit(1); -} + const base = fs.realpathSync(process.cwd()); + const file = `${base}/public/__ENV.js`; + const content = `window.__ENV = ${JSON.stringify(envObject)};`; + const dirname = path.dirname(file); -function extractConfig(config: zod.SafeParseReturnType): Output { - if (!config.success) { - throw new Error('Something went wrong.'); + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }); } - return config.data; + + fs.writeFileSync(file, content); } - -const base = extractConfig(configs.base); -const integrationSlack = extractConfig(configs.integrationSlack); -const sentry = extractConfig(configs.sentry); -const authGithub = extractConfig(configs.authGithub); -const authGoogle = extractConfig(configs.authGoogle); -const authOkta = extractConfig(configs.authOkta); -const authOktaMultiTenant = extractConfig(configs.authOktaMultiTenant); -const migrations = extractConfig(configs.migrations); - -const config = { - release: base.RELEASE ?? 'local', - nodeEnv: base.NODE_ENV, - environment: base.ENVIRONMENT, - appBaseUrl: base.APP_BASE_URL.replace(/\/$/, ''), - graphqlEndpoint: base.GRAPHQL_ENDPOINT, - serverEndpoint: base.SERVER_ENDPOINT, - emailsEndpoint: base.EMAILS_ENDPOINT, - supertokens: { - connectionUri: base.SUPERTOKENS_CONNECTION_URI, - apiKey: base.SUPERTOKENS_API_KEY, - }, - slack: - integrationSlack.INTEGRATION_SLACK === '1' - ? { - clientId: integrationSlack.INTEGRATION_SLACK_CLIENT_ID, - clientSecret: integrationSlack.INTEGRATION_SLACK_CLIENT_SECRET, - } - : null, - github: base.INTEGRATION_GITHUB_APP_NAME ? { appName: base.INTEGRATION_GITHUB_APP_NAME } : null, - analytics: { - googleAnalyticsTrackingId: base.GA_TRACKING_ID, - }, - docsUrl: base.DOCS_URL, - auth: { - github: - authGithub.AUTH_GITHUB === '1' - ? { - clientId: authGithub.AUTH_GITHUB_CLIENT_ID, - clientSecret: authGithub.AUTH_GITHUB_CLIENT_SECRET, - } - : null, - google: - authGoogle.AUTH_GOOGLE === '1' - ? { - clientId: authGoogle.AUTH_GOOGLE_CLIENT_ID, - clientSecret: authGoogle.AUTH_GOOGLE_CLIENT_SECRET, - } - : null, - okta: - authOkta.AUTH_OKTA === '1' - ? { - endpoint: authOkta.AUTH_OKTA_ENDPOINT, - hidden: authOkta.AUTH_OKTA_HIDDEN === '1', - clientId: authOkta.AUTH_OKTA_CLIENT_ID, - clientSecret: authOkta.AUTH_OKTA_CLIENT_SECRET, - } - : null, - organizationOIDC: authOktaMultiTenant.AUTH_ORGANIZATION_OIDC === '1', - requireEmailVerification: base.AUTH_REQUIRE_EMAIL_VERIFICATION === '1', - }, - sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, - stripePublicKey: base.STRIPE_PUBLIC_KEY ?? null, - graphql: { - persistedOperations: base.GRAPHQL_PERSISTED_OPERATIONS === '1', - }, - zendeskSupport: base.ZENDESK_SUPPORT === '1', - migrations: { - member_roles_deadline: migrations.MEMBER_ROLES_DEADLINE ?? null, - }, -} as const; - -declare global { - // eslint-disable-next-line no-var - var __backend_env: typeof config; -} - -globalThis['__backend_env'] = config; - -// TODO: I don't like this here, but it seems like it makes most sense here :) -Sentry.init({ - serverName: 'app', - dist: 'app', - enabled: !!config.sentry, - enableTracing: false, - tracesSampleRate: 1, - dsn: config.sentry?.dsn, - release: config.release, - environment: config.environment, - integrations: [ - // HTTP integration is only available on the server - new Integrations.Http({ - tracing: false, - }), - ], -}); diff --git a/packages/web/app/next.config.mts b/packages/web/app/next.config.mts index c1243cd22..1fbf5f958 100644 --- a/packages/web/app/next.config.mts +++ b/packages/web/app/next.config.mts @@ -1,10 +1,5 @@ import bundleAnalyzer from '@next/bundle-analyzer'; - -// For the dev server we want to make sure that the correct environment variables are set :) -// during build we don't need environment variables! -if (globalThis.process.env.BUILD !== '1') { - await import('./environment'); -} +import './environment'; const withBundleAnalyzer = bundleAnalyzer({ enabled: globalThis.process.env.ANALYZE === '1', diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 8f9a80214..75b31cc2e 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -4,12 +4,12 @@ "private": true, "scripts": { "analyze": "pnpm build:config && ANALYZE=1 next build", - "build": "pnpm generate-changelog && pnpm build:config && BUILD=1 next build && tsx ../../../scripts/runify.ts", + "build": "pnpm generate-changelog && pnpm build:config && next build && tsx ../../../scripts/runify.ts", "build-storybook": "storybook build", "build:config": "tsup-node --no-splitting --out-dir . --loader \".mts=ts\" --format esm --target node21 next.config.mts", "dev": "pnpm generate-changelog && pnpm build:config && next dev | pino-pretty", "generate-changelog": "node ../../../scripts/generate-changelog.js", - "postbuild": "pnpm --filter @hive/app deploy --prod --no-optional deploy-tmp/web && rimraf dist/node_modules && mv deploy-tmp/web/node_modules dist && rimraf deploy-tmp/web", + "postbuild": "rimraf deploy-tmp/web && pnpm --filter @hive/app deploy --prod --no-optional deploy-tmp/web && rimraf dist/node_modules && mv deploy-tmp/web/node_modules dist && rimraf deploy-tmp/web", "postinstall": "pnpm generate-changelog", "start": "node dist/index.js", "storybook": "storybook dev -p 6006", @@ -53,6 +53,7 @@ "@trpc/client": "10.45.2", "@trpc/server": "10.45.2", "@urql/core": "4.1.4", + "@urql/exchange-auth": "2.1.6", "@urql/exchange-graphcache": "6.3.3", "@urql/exchange-persisted": "4.1.0", "class-variance-authority": "0.7.0", @@ -93,8 +94,6 @@ "regenerator-runtime": "0.14.1", "snarkdown": "2.0.0", "supertokens-auth-react": "0.35.6", - "supertokens-js-override": "0.0.4", - "supertokens-node": "15.2.1", "supertokens-web-js": "0.8.0", "tailwind-merge": "2.2.2", "tslib": "2.6.2", diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/checks.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/checks.tsx index b62a6f2e5..d60a59d28 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/checks.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/checks.tsx @@ -37,7 +37,6 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { CriticalityLevel } from '@/gql/graphql'; import { ProjectType } from '@/graphql'; import { useRouteSelector } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; import { CheckIcon, @@ -166,7 +165,7 @@ const Navigation = (props: { {edge.node.githubRepository && edge.node.meta ? ( ; } @@ -173,9 +175,6 @@ function ExplorerPageContent() { const latestSchemaVersion = currentTarget?.latestSchemaVersion; const latestValidSchemaVersion = currentTarget?.latestValidSchemaVersion; - /* to avoid janky behaviour we keep track if the version has a successful explorer once, and in that case always show the filter bar. */ - const isFilterVisible = useRef(false); - if (latestValidSchemaVersion?.explorer) { isFilterVisible.current = true; } @@ -280,6 +279,4 @@ function ExplorerPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(ExplorerPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/[typename].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/[typename].tsx index daff958db..11413ee6d 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/[typename].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/[typename].tsx @@ -19,7 +19,6 @@ import { MetaTitle } from '@/components/v2'; import { noSchemaVersion } from '@/components/v2/empty-list'; import { FragmentType, graphql, useFragment } from '@/gql'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; -import { withSessionProtection } from '@/lib/supertokens/guard'; export const TypeRenderFragment = graphql(` fragment TypeRenderFragment on GraphQLNamedType { @@ -256,6 +255,4 @@ function TypeExplorerPage() { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(TypeExplorerPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/unused.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/unused.tsx index db4f1e04b..aa6e37620 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/unused.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer/unused.tsx @@ -14,7 +14,6 @@ import { EmptyList, noSchemaVersion } from '@/components/v2/empty-list'; import { FragmentType, graphql, useFragment } from '@/gql'; import { useRouteSelector } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; import { TypeRenderer, TypeRenderFragment } from './[typename]'; @@ -382,6 +381,4 @@ function UnusedSchemaExplorerPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(UnusedSchemaExplorerPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx index a71844203..37836211a 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx @@ -19,7 +19,6 @@ import { DiffIcon } from '@/components/v2/icon'; import { FragmentType, graphql, useFragment } from '@/gql'; import { CriticalityLevel, ProjectType } from '@/graphql'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; import { CheckCircledIcon, @@ -164,7 +163,7 @@ function ListPage({ {version.githubMetadata ? ( , @@ -361,6 +360,4 @@ function SchemaPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(SchemaPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights.tsx index 58e859e87..1ce77b509 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights.tsx @@ -17,7 +17,6 @@ import { EmptyList, MetaTitle } from '@/components/v2'; import { graphql } from '@/gql'; import { useRouteSelector } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { withSessionProtection } from '@/lib/supertokens/guard'; function OperationsView({ organizationCleanId, @@ -187,6 +186,4 @@ function InsightsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(InsightsPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx index 40c8f6788..63f6fbfb9 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx @@ -17,7 +17,6 @@ import { EmptyList, Link, MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; import { useRouteSelector } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const GraphQLOperationBody_OperationFragment = graphql(` fragment GraphQLOperationBody_OperationFragment on Operation { @@ -265,6 +264,4 @@ function OperationInsightsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(OperationInsightsPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx index e65a450c0..3cd3b946b 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx @@ -18,7 +18,6 @@ import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; import { formatNumber, formatThroughput, toDecimal, useRouteSelector } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { useChartStyles } from '@/utils'; const ClientView_ClientStatsQuery = graphql(` @@ -442,6 +441,4 @@ function ClientInsightsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(ClientInsightsPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx index a2198dbb6..87908fa70 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx @@ -26,7 +26,6 @@ import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; import { formatNumber, formatThroughput, toDecimal, useRouteSelector } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { useChartStyles } from '@/utils'; const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` @@ -496,6 +495,4 @@ function SchemaCoordinatePage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(SchemaCoordinatePage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/laboratory.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/laboratory.tsx index 45e4b67ac..c260b2cd3 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/laboratory.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/laboratory.tsx @@ -28,7 +28,6 @@ import { graphql } from '@/gql'; import { TargetAccessScope } from '@/gql/graphql'; import { canAccessTarget } from '@/lib/access/target'; import { useClipboard, useNotifications, useRouteSelector, useToggle } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; import { Button as GraphiQLButton, @@ -824,6 +823,7 @@ function LaboratoryPageContent() { (actualSelectedApiEndpoint === 'linkedApi' ? query.data?.target?.graphqlEndpointUrl : undefined) ?? mockEndpoint, + fetch, }); const result = await fetcher(params, opts); @@ -1052,8 +1052,6 @@ function LaboratoryPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(LaboratoryPage); function useApiTabValueState(graphqlEndpointUrl: string | null) { diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/settings.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/settings.tsx index 78a99b5fb..15caf905d 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/settings.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/settings.tsx @@ -42,7 +42,6 @@ import { ProjectType } from '@/gql/graphql'; import { canAccessTarget, TargetAccessScope } from '@/lib/access/target'; import { subDays } from '@/lib/date-time'; import { useRouteSelector, useToggle } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const SetTargetValidationMutation = graphql(` mutation Settings_SetTargetValidation($input: SetTargetValidationInput!) { @@ -1060,6 +1059,4 @@ function SettingsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(SettingsPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/index.tsx b/packages/web/app/pages/[organizationId]/[projectId]/index.tsx index 58ccce444..04365160b 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/index.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/index.tsx @@ -21,7 +21,6 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { subDays } from '@/lib/date-time'; import { useFormattedNumber } from '@/lib/hooks'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn, pluralize } from '@/lib/utils'; function floorDate(date: Date): Date { @@ -383,6 +382,4 @@ function ProjectsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(ProjectsPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/view/alerts.tsx b/packages/web/app/pages/[organizationId]/[projectId]/view/alerts.tsx index 8b77a845f..2fc2fd293 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/view/alerts.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/view/alerts.tsx @@ -30,7 +30,6 @@ import { DocsLink, MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; import { ProjectAccessScope, useProjectAccess } from '@/lib/access/project'; import { useRouteSelector, useToggle } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; function Channels(props: { channels: FragmentType[]; @@ -265,6 +264,4 @@ function AlertsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(AlertsPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/view/policy.tsx b/packages/web/app/pages/[organizationId]/[projectId]/view/policy.tsx index 066ae1b30..6646789f4 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/view/policy.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/view/policy.tsx @@ -12,7 +12,6 @@ import { ProjectAccessScope } from '@/gql/graphql'; import { RegistryModel } from '@/graphql'; import { useProjectAccess } from '@/lib/access/project'; import { useRouteSelector } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const ProjectPolicyPageQuery = graphql(` query ProjectPolicyPageQuery($organizationId: ID!, $projectId: ID!) { @@ -206,6 +205,4 @@ function ProjectPolicyPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(ProjectPolicyPage); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/view/settings.tsx b/packages/web/app/pages/[organizationId]/[projectId]/view/settings.tsx index b4654ad8a..ea64cda7e 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/view/settings.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/view/settings.tsx @@ -28,7 +28,6 @@ import { ProjectType } from '@/graphql'; import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project'; import { getDocsUrl } from '@/lib/docs-url'; import { useNotifications, useRouteSelector, useToggle } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const GithubIntegration_GithubIntegrationDetailsQuery = graphql(` query getGitHubIntegrationDetails($selector: OrganizationSelectorInput!) { @@ -107,7 +106,7 @@ function GitHubIntegration(props: {
- +
@@ -123,7 +122,7 @@ function GitHubIntegration(props: {
- +
@@ -414,6 +413,4 @@ function SettingsPage() { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(SettingsPage); diff --git a/packages/web/app/pages/[organizationId]/index.tsx b/packages/web/app/pages/[organizationId]/index.tsx index 699791bbb..e0e2b9d5e 100644 --- a/packages/web/app/pages/[organizationId]/index.tsx +++ b/packages/web/app/pages/[organizationId]/index.tsx @@ -19,11 +19,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { Activities, Card, EmptyList, MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; import { ProjectType } from '@/gql/graphql'; -import { writeLastVisitedOrganization } from '@/lib/cookies'; import { subDays } from '@/lib/date-time'; import { useFormattedNumber } from '@/lib/hooks'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { pluralize } from '@/lib/utils'; function floorDate(date: Date): Date { @@ -403,9 +401,4 @@ function OrganizationPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(async ({ req, res, resolvedUrl }) => { - writeLastVisitedOrganization(req, res, resolvedUrl.substring(1)); - return { props: {} }; -}); - export default authenticated(OrganizationPage); diff --git a/packages/web/app/pages/[organizationId]/view/manage-subscription.tsx b/packages/web/app/pages/[organizationId]/view/manage-subscription.tsx index b7fd8fcae..f015f5a29 100644 --- a/packages/web/app/pages/[organizationId]/view/manage-subscription.tsx +++ b/packages/web/app/pages/[organizationId]/view/manage-subscription.tsx @@ -16,7 +16,6 @@ import { BillingPlanType } from '@/gql/graphql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key'; import { useRouteSelector } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; const ManageSubscriptionInner_OrganizationFragment = graphql(` @@ -168,6 +167,16 @@ function Inner(props: { [setOperationsRateLimit], ); + const isFetching = + updateOrgRateLimitMutationState.fetching || + downgradeToHobbyMutationState.fetching || + upgradeToProMutationState.fetching; + + const billingPlans = useFragment( + ManageSubscriptionInner_BillingPlansFragment, + props.billingPlans, + ); + useEffect(() => { if (query.data?.billingPlans?.length) { if (organization.plan === plan) { @@ -182,15 +191,6 @@ function Inner(props: { } }, [organization.plan, organization.rateLimit.operations, plan, query.data?.billingPlans]); - if (!canAccess) { - return null; - } - - const isFetching = - updateOrgRateLimitMutationState.fetching || - downgradeToHobbyMutationState.fetching || - upgradeToProMutationState.fetching; - const upgrade = useCallback(async () => { if (isFetching) { return; @@ -262,6 +262,10 @@ function Inner(props: { }); }, [organization.cleanId, operationsRateLimit, updateOrgRateLimitMutation, isFetching]); + if (!canAccess) { + return null; + } + const renderActions = () => { if (plan === organization.plan) { if (organization.rateLimit.operations !== operationsRateLimit * 1_000_000) { @@ -312,11 +316,6 @@ function Inner(props: { downgradeToHobbyMutationState.error || updateOrgRateLimitMutationState.error; - const billingPlans = useFragment( - ManageSubscriptionInner_BillingPlansFragment, - props.billingPlans, - ); - // TODO: this is also not safe as billingPlans might be an empty list. const selectedPlan = billingPlans.find(v => v.planType === plan) ?? billingPlans[0]; @@ -441,6 +440,20 @@ const ManageSubscriptionPageQuery = graphql(` function ManageSubscriptionPageContent() { const router = useRouteSelector(); + + /** + * If Stripe is not enabled we redirect the user to the organization. + */ + if (!getIsStripeEnabled()) { + void router.push({ + pathname: '/[organizationId]', + query: { + organizationId: router.organizationId, + }, + }); + return null; + } + const [query] = useQuery({ query: ManageSubscriptionPageQuery, variables: { @@ -523,22 +536,4 @@ function ManageSubscriptionPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(async context => { - /** - * If Stripe is not enabled we redirect the user to the organization. - */ - const isStripeEnabled = getIsStripeEnabled(); - if (!isStripeEnabled) { - const parts = String(context.resolvedUrl).split('/'); - parts.pop(); - return { - redirect: { - destination: parts.join('/'), - permanent: false, - }, - }; - } - return { props: {} }; -}); - export default authenticated(ManageSubscriptionPage); diff --git a/packages/web/app/pages/[organizationId]/view/members.tsx b/packages/web/app/pages/[organizationId]/view/members.tsx index d16e0038a..95f9c95fd 100644 --- a/packages/web/app/pages/[organizationId]/view/members.tsx +++ b/packages/web/app/pages/[organizationId]/view/members.tsx @@ -13,7 +13,6 @@ import { MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; const OrganizationMembersPage_OrganizationFragment = graphql(` @@ -225,6 +224,4 @@ function OrganizationMembersPage() { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(OrganizationMembersPage); diff --git a/packages/web/app/pages/[organizationId]/view/policy.tsx b/packages/web/app/pages/[organizationId]/view/policy.tsx index 770932b8b..b97b25207 100644 --- a/packages/web/app/pages/[organizationId]/view/policy.tsx +++ b/packages/web/app/pages/[organizationId]/view/policy.tsx @@ -13,7 +13,6 @@ import { OrganizationAccessScope } from '@/gql/graphql'; import { RegistryModel } from '@/graphql'; import { useOrganizationAccess } from '@/lib/access/organization'; import { useRouteSelector } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const OrganizationPolicyPageQuery = graphql(` query OrganizationPolicyPageQuery($selector: OrganizationSelectorInput!) { @@ -223,6 +222,4 @@ function OrganizationPolicyPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(OrganizationPolicyPage); diff --git a/packages/web/app/pages/[organizationId]/view/settings.tsx b/packages/web/app/pages/[organizationId]/view/settings.tsx index 205cd7bd8..04c5a3b9e 100644 --- a/packages/web/app/pages/[organizationId]/view/settings.tsx +++ b/packages/web/app/pages/[organizationId]/view/settings.tsx @@ -31,7 +31,6 @@ import { useOrganizationAccess, } from '@/lib/access/organization'; import { useRouteSelector, useToggle } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const Integrations_CheckIntegrationsQuery = graphql(` query Integrations_CheckIntegrationsQuery($selector: OrganizationSelectorInput!) { @@ -479,6 +478,4 @@ function OrganizationSettingsPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(OrganizationSettingsPage); diff --git a/packages/web/app/pages/[organizationId]/view/subscription.tsx b/packages/web/app/pages/[organizationId]/view/subscription.tsx index 46a51fbe6..f87e9fa0e 100644 --- a/packages/web/app/pages/[organizationId]/view/subscription.tsx +++ b/packages/web/app/pages/[organizationId]/view/subscription.tsx @@ -16,7 +16,6 @@ import { graphql, useFragment } from '@/gql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key'; import { useRouteSelector } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; const DateFormatter = Intl.DateTimeFormat('en-US', { month: 'short', @@ -75,6 +74,17 @@ const SubscriptionPageQuery = graphql(` function SubscriptionPageContent() { const router = useRouteSelector(); + + if (!getIsStripeEnabled()) { + void router.push({ + pathname: '/[organizationId]', + query: { + organizationId: router.organizationId, + }, + }); + return null; + } + const [query] = useQuery({ query: SubscriptionPageQuery, variables: { @@ -200,22 +210,4 @@ function SubscriptionPage(): ReactElement { ); } -export const getServerSideProps = withSessionProtection(async context => { - /** - * If Stripe is not enabled we redirect the user to the organization. - */ - const isStripeEnabled = getIsStripeEnabled(); - if (!isStripeEnabled) { - const parts = String(context.resolvedUrl).split('/'); - parts.pop(); - return { - redirect: { - destination: parts.join('/'), - permanent: false, - }, - }; - } - return { props: {} }; -}); - export default authenticated(SubscriptionPage); diff --git a/packages/web/app/pages/[organizationId]/view/support.tsx b/packages/web/app/pages/[organizationId]/view/support.tsx index 8bebe8b40..28bc066b9 100644 --- a/packages/web/app/pages/[organizationId]/view/support.tsx +++ b/packages/web/app/pages/[organizationId]/view/support.tsx @@ -45,7 +45,6 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { SupportTicketPriority, SupportTicketStatus } from '@/gql/graphql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { useNotifications, useRouteSelector, useToggle } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -457,6 +456,4 @@ function SupportPage() { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(SupportPage); diff --git a/packages/web/app/pages/[organizationId]/view/support/[ticketId].tsx b/packages/web/app/pages/[organizationId]/view/support/[ticketId].tsx index aec7d7e3e..df63ee2dc 100644 --- a/packages/web/app/pages/[organizationId]/view/support/[ticketId].tsx +++ b/packages/web/app/pages/[organizationId]/view/support/[ticketId].tsx @@ -19,7 +19,6 @@ import { MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { useNotifications, useRouteSelector } from '@/lib/hooks'; -import { withSessionProtection } from '@/lib/supertokens/guard'; import { cn } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -366,6 +365,4 @@ function SupportTicketPage() { ); } -export const getServerSideProps = withSessionProtection(); - export default authenticated(SupportTicketPage); diff --git a/packages/web/app/pages/_document.tsx b/packages/web/app/pages/_document.tsx index d60209806..aae6fb29a 100644 --- a/packages/web/app/pages/_document.tsx +++ b/packages/web/app/pages/_document.tsx @@ -1,25 +1,17 @@ import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document'; import 'regenerator-runtime/runtime'; -// don't remove this import ; it will break the built app ; but not the dev app :) -import '@/config/frontend-env'; export default class MyDocument extends Document<{ ids: Array; css: string; - frontendEnv: (typeof import('@/config/frontend-env'))['env']; }> { static async getInitialProps(ctx: DocumentContext) { - if (globalThis.process.env.BUILD !== '1') { - await import('../environment'); - } - const { env: frontendEnv } = await import('@/config/frontend-env'); const initialProps = await Document.getInitialProps(ctx); const page = await ctx.renderPage(); return { ...initialProps, ...page, - frontendEnv, }; } @@ -37,14 +29,8 @@ export default class MyDocument extends Document<{ -