From 95aa58958fff46b80e4a181e98d8441ccab1ef8e Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Mon, 3 Jun 2024 17:24:28 +0300 Subject: [PATCH] feat: authenticate user as function author (#2316) * feat: check if user is fn author w/ exec engine * WIP auth redirect * finalized --- packages/frontend-2/.env.example | 2 - .../automate/function/CreateDialog.vue | 11 ++-- .../frontend-2/lib/common/helpers/route.ts | 2 +- packages/frontend-2/nuxt.config.ts | 4 +- .../automate/clients/executionEngine.ts | 2 +- .../automate/graph/resolvers/automate.ts | 2 +- packages/server/modules/automate/index.ts | 2 + .../modules/automate/rest/authGithubApp.ts | 43 +++++++++++++++ .../automate/services/executionEngine.ts | 4 +- .../automate/services/functionManagement.ts | 52 +++++++++++++++++++ .../modules/core/helpers/routeHelper.ts | 4 ++ 11 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 packages/server/modules/automate/rest/authGithubApp.ts diff --git a/packages/frontend-2/.env.example b/packages/frontend-2/.env.example index aba0b489c..d28478896 100644 --- a/packages/frontend-2/.env.example +++ b/packages/frontend-2/.env.example @@ -38,8 +38,6 @@ NUXT_PUBLIC_DEBUG_CORE_WEB_VITALS=false # Enable Speckle Automate functionality NUXT_PUBLIC_ENABLE_AUTOMATE_MODULE=false -NUXT_PUBLIC_AUTOMATE_GH_CLIENT_ID=Iv1.79a1df48749f11b4 -AUTOMATE_GH_CLIENT_SECRET=5bb28a6397204edf259f3d40cf36afc6a95c3998 # Survicate NUXT_PUBLIC_SURVICATE_WORKSPACE_KEY= diff --git a/packages/frontend-2/components/automate/function/CreateDialog.vue b/packages/frontend-2/components/automate/function/CreateDialog.vue index 788ebf69a..f628984db 100644 --- a/packages/frontend-2/components/automate/function/CreateDialog.vue +++ b/packages/frontend-2/components/automate/function/CreateDialog.vue @@ -50,6 +50,7 @@ import type { CreatableFunctionTemplate, FunctionDetailsFormValues } from '~/lib/automate/helpers/functions' +import { automateGithubAppAuthorizationRoute } from '~/lib/common/helpers/route' import { useEnumSteps, useEnumStepsWidgetSetup } from '~/lib/form/composables/steps' import { useForm } from 'vee-validate' import { useCreateAutomateFunction } from '~/lib/automate/composables/management' @@ -152,8 +153,8 @@ const title = computed(() => { }) const authorizeGithubUrl = computed(() => { - // TODO: - return new URL('/', apiBaseUrl).toString() + const redirectUrl = new URL(automateGithubAppAuthorizationRoute, apiBaseUrl) + return redirectUrl.toString() }) const buttons = computed((): LayoutDialogButton[] => { @@ -174,7 +175,6 @@ const buttons = computed((): LayoutDialogButton[] => { text: 'Authorize', props: { fullWidth: true, - disabled: true, to: authorizeGithubUrl.value, external: true } @@ -187,7 +187,7 @@ const buttons = computed((): LayoutDialogButton[] => { text: 'Next', props: { iconRight: ChevronRightIcon, - disabled: !selectedTemplate.value + disabled: !selectedTemplate.value || true // TODO: Remove once fns work }, onClick: () => step.value++ } @@ -200,7 +200,8 @@ const buttons = computed((): LayoutDialogButton[] => { props: { color: 'secondary', iconLeft: ChevronLeftIcon, - textColor: 'primary' + textColor: 'primary', + disabled: true // TODO: Remove once fns work }, onClick: () => step.value-- }, diff --git a/packages/frontend-2/lib/common/helpers/route.ts b/packages/frontend-2/lib/common/helpers/route.ts index 03335cee5..419aede6c 100644 --- a/packages/frontend-2/lib/common/helpers/route.ts +++ b/packages/frontend-2/lib/common/helpers/route.ts @@ -55,7 +55,7 @@ export const projectWebhooksRoute = (projectId: string) => export const threadRedirectRoute = (projectId: string, threadId: string) => `/projects/${projectId}/threads/${threadId}` -export const automateGithubAppAuthorizationCallback = '/api/auth/automate-github-app' +export const automateGithubAppAuthorizationRoute = '/api/automate/auth/githubapp' export const automationFunctionsRoute = '/functions' diff --git a/packages/frontend-2/nuxt.config.ts b/packages/frontend-2/nuxt.config.ts index 8b4c907de..0d6f863f5 100644 --- a/packages/frontend-2/nuxt.config.ts +++ b/packages/frontend-2/nuxt.config.ts @@ -46,7 +46,6 @@ export default defineNuxtConfig({ ], runtimeConfig: { redisUrl: '', - automateGhClientSecret: '', public: { ...featureFlags, apiOrigin: 'UNDEFINED', @@ -73,8 +72,7 @@ export default defineNuxtConfig({ datadogSite: '', datadogService: '', datadogEnv: '', - enableDirectPreviews: true, - automateGhClientId: 'Iv1.79a1df48749f11b4' + enableDirectPreviews: true } }, diff --git a/packages/server/modules/automate/clients/executionEngine.ts b/packages/server/modules/automate/clients/executionEngine.ts index c45af3f07..a775be6b4 100644 --- a/packages/server/modules/automate/clients/executionEngine.ts +++ b/packages/server/modules/automate/clients/executionEngine.ts @@ -384,7 +384,7 @@ export const getFunctions = async (params: { } type UserGithubAuthStateResponse = { - userHasAuthorizedGithubApp: boolean + userHasAuthorizedGitHubApp: boolean } export const getUserGithubAuthState = async (params: { diff --git a/packages/server/modules/automate/graph/resolvers/automate.ts b/packages/server/modules/automate/graph/resolvers/automate.ts index 5cba4ce79..9180c5a77 100644 --- a/packages/server/modules/automate/graph/resolvers/automate.ts +++ b/packages/server/modules/automate/graph/resolvers/automate.ts @@ -613,7 +613,7 @@ export = (FF_AUTOMATE_MODULE_ENABLED let hasAutomateGithubApp = false try { const authState = await getUserGithubAuthState({ userId }) - hasAutomateGithubApp = authState.userHasAuthorizedGithubApp + hasAutomateGithubApp = authState.userHasAuthorizedGitHubApp } catch (e) { if (e instanceof ExecutionEngineFailedResponseError) { if (e.response.statusMessage === 'FunctionCreatorDoesNotExist') { diff --git a/packages/server/modules/automate/index.ts b/packages/server/modules/automate/index.ts index d75308227..aa37a0222 100644 --- a/packages/server/modules/automate/index.ts +++ b/packages/server/modules/automate/index.ts @@ -26,6 +26,7 @@ import { setupAutomationUpdateSubscriptions, setupStatusUpdateSubscriptions } from '@/modules/automate/services/subscriptions' +import authGithubAppRest from '@/modules/automate/rest/authGithubApp' const { FF_AUTOMATE_MODULE_ENABLED } = Environment.getFeatureFlags() let quitListeners: Optional<() => void> = undefined @@ -95,6 +96,7 @@ const automateModule: SpeckleModule = { await initScopes() logStreamRest(app) + authGithubAppRest(app) if (isInitial) { quitListeners = initializeEventListeners() diff --git a/packages/server/modules/automate/rest/authGithubApp.ts b/packages/server/modules/automate/rest/authGithubApp.ts new file mode 100644 index 000000000..e8a334f89 --- /dev/null +++ b/packages/server/modules/automate/rest/authGithubApp.ts @@ -0,0 +1,43 @@ +import { createStoredAuthCode } from '@/modules/automate/services/executionEngine' +import { + handleAutomateFunctionCreatorAuthCallback, + startAutomateFunctionCreatorAuth +} from '@/modules/automate/services/functionManagement' +import { getGenericRedis } from '@/modules/core' +import { corsMiddleware } from '@/modules/core/configs/cors' +import { validateScope, validateServerRole } from '@/modules/shared/authz' +import { authMiddlewareCreator } from '@/modules/shared/middleware' +import { Roles, Scopes } from '@speckle/shared' +import { Application } from 'express' + +export default (app: Application) => { + app.get( + '/api/automate/auth/githubapp', + corsMiddleware(), + authMiddlewareCreator([ + validateServerRole({ requiredRole: Roles.Server.Guest }), + validateScope({ requiredScope: Scopes.AutomateFunctions.Write }) + ]), + async (req, res) => { + const startAuth = startAutomateFunctionCreatorAuth({ + createStoredAuthCode: createStoredAuthCode({ + redis: getGenericRedis() + }) + }) + await startAuth({ req, res }) + } + ) + + app.get( + '/api/automate/ghAuthComplete', + corsMiddleware(), + authMiddlewareCreator([ + validateServerRole({ requiredRole: Roles.Server.Guest }), + validateScope({ requiredScope: Scopes.AutomateFunctions.Write }) + ]), + async (req, res) => { + const handleCallback = handleAutomateFunctionCreatorAuthCallback() + await handleCallback({ req, res }) + } + ) +} diff --git a/packages/server/modules/automate/services/executionEngine.ts b/packages/server/modules/automate/services/executionEngine.ts index 08c55e2e4..94fddf783 100644 --- a/packages/server/modules/automate/services/executionEngine.ts +++ b/packages/server/modules/automate/services/executionEngine.ts @@ -6,8 +6,8 @@ export const createStoredAuthCode = (deps: { redis: Redis }) => async () => { const { redis } = deps const codeId = cryptoRandomString({ length: 10 }) const authCode = cryptoRandomString({ length: 20 }) - // prob hashing and salting it would be better, but they expire in 2 mins... - await redis.set(codeId, authCode, 'EX', 120) + // prob hashing and salting it would be better, but they expire in 5 mins... + await redis.set(codeId, authCode, 'EX', 60 * 5) return `${codeId}${authCode}` } diff --git a/packages/server/modules/automate/services/functionManagement.ts b/packages/server/modules/automate/services/functionManagement.ts index 4322ac22a..65da68548 100644 --- a/packages/server/modules/automate/services/functionManagement.ts +++ b/packages/server/modules/automate/services/functionManagement.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import { CreateFunctionBody, createFunction, @@ -30,6 +31,11 @@ import { FunctionReleaseSchemaType, FunctionSchemaType } from '@/modules/automate/helpers/executionEngine' +import { Request, Response } from 'express' +import { UnauthorizedError } from '@/modules/shared/errors' +import { createStoredAuthCode } from '@/modules/automate/services/executionEngine' +import { getServerOrigin, speckleAutomateUrl } from '@/modules/shared/helpers/envHelper' +import { getFunctionsMarketplaceUrl } from '@/modules/core/helpers/routeHelper' const repoUrlToBasicGitRepositoryMetadata = ( url: string @@ -167,3 +173,49 @@ export const updateFunction = return convertFunctionToGraphQLReturn(apiResult) } + +export type StartAutomateFunctionCreatorAuthDeps = { + createStoredAuthCode: ReturnType +} + +export const startAutomateFunctionCreatorAuth = + (deps: StartAutomateFunctionCreatorAuthDeps) => + async (params: { req: Request; res: Response }) => { + const { createStoredAuthCode } = deps + const { req, res } = params + + const userId = req.context.userId + if (!userId) { + throw new UnauthorizedError() + } + + const authCode = await createStoredAuthCode() + const redirectUrl = new URL( + '/api/v2/functions/auth/githubapp/authorize', + speckleAutomateUrl() + ) + redirectUrl.searchParams.set('speckleUserId', userId) + redirectUrl.searchParams.set( + 'speckleServerOrigin', + new URL(getServerOrigin()).origin + ) + redirectUrl.searchParams.set('speckleServerAuthenticationCode', authCode) + + return res.redirect(redirectUrl.toString()) + } + +export const handleAutomateFunctionCreatorAuthCallback = + () => async (params: { req: Request; res: Response }) => { + const { req, res } = params + const { + ghAuth = 'unknown', + ghAuthDesc = 'GitHub Authentication unexpectedly failed' + } = req.query as Record + + const isSuccess = ghAuth === 'success' + const redirectUrl = getFunctionsMarketplaceUrl() + redirectUrl.searchParams.set('ghAuth', isSuccess ? 'success' : ghAuth) + redirectUrl.searchParams.set('ghAuthDesc', isSuccess ? '' : ghAuthDesc) + + return res.redirect(redirectUrl.toString()) + } diff --git a/packages/server/modules/core/helpers/routeHelper.ts b/packages/server/modules/core/helpers/routeHelper.ts index 8b859bff2..8a62e5882 100644 --- a/packages/server/modules/core/helpers/routeHelper.ts +++ b/packages/server/modules/core/helpers/routeHelper.ts @@ -49,3 +49,7 @@ export function getStreamCollaboratorsRoute(streamId: string): string { export function buildAbsoluteFrontendUrlFromPath(route: string): string { return new URL(route, getFrontendOrigin()).toString() } + +export function getFunctionsMarketplaceUrl() { + return new URL('/functions', getFrontendOrigin()) +}