feat(server): generalized automate auth code handshake mechanism (#2360)

* feat(server): generalized automate auth code handshake mechanism

* amend API requests to match with changes in https://github.com/specklesystems/speckle-automate/pull/758

* fix test

* integration fix

* deleting auth code on verification

---------

Co-authored-by: Iain Sproat <68657+iainsproat@users.noreply.github.com>
This commit is contained in:
Kristaps Fabians Geikins
2024-06-17 17:12:17 +03:00
committed by GitHub
parent 4092d0c8ad
commit eff46485c4
12 changed files with 177 additions and 69 deletions
@@ -190,6 +190,12 @@ export type AuthStrategy = {
url: Scalars['String'];
};
export type AutomateAuthCodePayloadTest = {
action: Scalars['String'];
code: Scalars['String'];
userId: Scalars['String'];
};
export type AutomateFunction = {
__typename?: 'AutomateFunction';
automationCount: Scalars['Int'];
@@ -2360,7 +2366,7 @@ export type QueryAutomateFunctionsArgs = {
export type QueryAutomateValidateAuthCodeArgs = {
code: Scalars['String'];
payload: AutomateAuthCodePayloadTest;
};
@@ -320,6 +320,12 @@ extend type Project {
automation(id: String!): Automation! @hasStreamRole(role: STREAM_OWNER)
}
input AutomateAuthCodePayloadTest {
code: String!
userId: String!
action: String!
}
extend type Query {
automateFunctions(
filter: AutomateFunctionsFilter
@@ -334,7 +340,7 @@ extend type Query {
"""
Part of the automation/function creation handshake mechanism
"""
automateValidateAuthCode(code: String!): Boolean!
automateValidateAuthCode(payload: AutomateAuthCodePayloadTest!): Boolean!
}
extend type Mutation {
@@ -1,27 +1,31 @@
import { automateLogger } from '@/logging/logging'
import {
ExecutionEngineBadResponseBodyError,
ExecutionEngineErrorResponse,
type ExecutionEngineErrorResponse,
ExecutionEngineFailedResponseError,
ExecutionEngineNetworkError
} from '@/modules/automate/errors/executionEngine'
import { AutomateInvalidTriggerError } from '@/modules/automate/errors/management'
import {
import type {
FunctionReleaseSchemaType,
FunctionSchemaType,
FunctionWithVersionsSchemaType
} from '@/modules/automate/helpers/executionEngine'
import {
AutomationFunctionRunRecord,
BaseTriggerManifest,
type AutomationFunctionRunRecord,
type BaseTriggerManifest,
VersionCreationTriggerType,
isVersionCreatedTriggerManifest
} from '@/modules/automate/helpers/types'
import type {
AuthCodePayload,
AuthCodePayloadWithOrigin
} from '@/modules/automate/services/authCode'
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
import { getServerOrigin, speckleAutomateUrl } from '@/modules/shared/helpers/envHelper'
import {
Nullable,
SourceAppName,
type Nullable,
type SourceAppName,
isNonNullable,
isNullOrUndefined,
retry,
@@ -129,7 +133,7 @@ const invokeRequest = async (params: {
export const createAutomation = async (params: {
speckleServerUrl?: string
authCode: string
authCode: AuthCodePayload
}) => {
const { speckleServerUrl = getServerOrigin(), authCode } = params
@@ -141,8 +145,10 @@ export const createAutomation = async (params: {
url,
method: 'post',
body: {
speckleServerOrigin,
speckleServerAuthenticationCode: authCode
speckleServerAuthenticationPayload: {
...authCode,
origin: speckleServerOrigin
}
},
retry: false
})
@@ -236,9 +242,7 @@ export enum ExecutionEngineFunctionTemplateId {
}
export type CreateFunctionBody = {
speckleServerOrigin: string
speckleUserId: string
authenticationCode: string
speckleServerAuthenticationPayload: AuthCodePayloadWithOrigin
template: ExecutionEngineFunctionTemplateId
functionName: string
description: string
@@ -276,6 +280,7 @@ export const createFunction = async ({
}
export type UpdateFunctionBody = {
//TODO add speckleServerAuthenticationPayload
functionName?: string
description?: string
supportedSourceApps?: SourceAppName[]
@@ -423,21 +428,20 @@ export const getUserGithubAuthState = async (params: {
export const getUserGithubOrganizations = async (params: {
speckleServerUrl?: string
userId: string
authCode: string
authCode: AuthCodePayload
}) => {
const {
speckleServerUrl = getServerOrigin(),
userId: speckleUserId,
authCode: speckleServerAuthenticationCode
authCode: speckleServerAuthenticationPayload
} = params
const speckleServerOrigin = new URL(speckleServerUrl).origin
const url = getApiUrl(`/api/v2/functions/auth/githubapp/organizations`, {
query: {
speckleServerOrigin,
speckleUserId,
speckleServerAuthenticationCode
speckleServerAuthenticationPayload: JSON.stringify({
...speckleServerAuthenticationPayload,
origin: speckleServerOrigin
})
}
})
@@ -36,6 +36,7 @@ import {
updateAutomation
} from '@/modules/automate/services/automationManagement'
import {
AuthCodePayloadAction,
createStoredAuthCode,
validateStoredAuthCode
} from '@/modules/automate/services/authCode'
@@ -440,11 +441,8 @@ export = (FF_AUTOMATE_MODULE_ENABLED
},
ProjectAutomationMutations: {
async create(parent, { input }, ctx) {
const testAutomateAuthCode = process.env['TEST_AUTOMATE_AUTHENTICATION_CODE']
const create = createAutomation({
createAuthCode: testAutomateAuthCode
? async () => testAutomateAuthCode
: createStoredAuthCode({ redis: getGenericRedis() }),
createAuthCode: createStoredAuthCode({ redis: getGenericRedis() }),
automateCreateAutomation: clientCreateAutomation,
storeAutomation,
storeAutomationToken
@@ -545,11 +543,14 @@ export = (FF_AUTOMATE_MODULE_ENABLED
}
},
Query: {
async automateValidateAuthCode(_parent, { code }) {
async automateValidateAuthCode(_parent, args) {
const validate = validateStoredAuthCode({
redis: getGenericRedis()
})
return await validate(code)
return await validate({
...args.payload,
action: args.payload.action as AuthCodePayloadAction
})
},
async automateFunction(_parent, { id }, ctx) {
const fn = await ctx.loaders.automationsApi.getFunction.load(id)
@@ -615,14 +616,16 @@ export = (FF_AUTOMATE_MODULE_ENABLED
return hasAutomateGithubApp
},
availableGithubOrgs: async (parent, _args, ctx) => {
const authCode = await createStoredAuthCode({ redis: getGenericRedis() })()
const userId = parent.userId
const authCode = await createStoredAuthCode({ redis: getGenericRedis() })({
userId,
action: AuthCodePayloadAction.GetAvailableGithubOrganizations
})
let orgs: string[] = []
try {
orgs = (
await getUserGithubOrganizations({
userId,
authCode
})
).availableGitHubOrganisations
@@ -1,25 +1,70 @@
import { automateLogger } from '@/logging/logging'
import { AutomateAuthCodeHandshakeError } from '@/modules/automate/errors/management'
import cryptoRandomString from 'crypto-random-string'
import Redis from 'ioredis'
import { get, has, isObjectLike } from 'lodash'
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 5 mins...
await redis.set(codeId, authCode, 'EX', 60 * 5)
return `${codeId}${authCode}`
export enum AuthCodePayloadAction {
CreateAutomation = 'createAutomation',
CreateFunction = 'createFunction',
BecomeFunctionAuthor = 'becomeFunctionAuthor',
GetAvailableGithubOrganizations = 'getAvailableGithubOrganizations'
}
export const validateStoredAuthCode =
(deps: { redis: Redis }) => async (code: string) => {
const { redis } = deps
const codeId = code.slice(0, 10)
const authCode = code.slice(10)
const storedAuthCode = await redis.get(codeId)
export type AuthCodePayload = {
code: string
userId: string
action: AuthCodePayloadAction
}
if (!storedAuthCode || authCode !== storedAuthCode) {
throw new AutomateAuthCodeHandshakeError('Invalid automate auth code')
export type AuthCodePayloadWithOrigin = AuthCodePayload & { origin: string }
const isPayload = (payload: unknown): payload is AuthCodePayload =>
!!(
payload &&
isObjectLike(payload) &&
has(payload, 'code') &&
has(payload, 'userId') &&
has(payload, 'action') &&
Object.values(AuthCodePayloadAction).includes(get(payload, 'action'))
)
export const createStoredAuthCode =
(deps: { redis: Redis }) => async (params: Omit<AuthCodePayload, 'code'>) => {
const { redis } = deps
const payload: AuthCodePayload = {
...params,
code: cryptoRandomString({ length: 20 })
}
await redis.set(payload.code, JSON.stringify(payload), 'EX', 60 * 5)
return payload
}
export const validateStoredAuthCode =
(deps: { redis: Redis }) => async (payload: AuthCodePayload) => {
const { redis } = deps
const potentialPayloadString = await redis.get(payload.code)
const potentialPayload: unknown = potentialPayloadString
? JSON.parse(potentialPayloadString)
: null
const formattedPayload = isPayload(potentialPayload) ? potentialPayload : null
if (
!formattedPayload ||
formattedPayload.code !== formattedPayload.code ||
formattedPayload.userId !== formattedPayload.userId ||
formattedPayload.action !== formattedPayload.action
) {
throw new AutomateAuthCodeHandshakeError('Invalid automate auth payload')
}
try {
await redis.del(payload.code)
} catch (e) {
automateLogger.error(e, 'Auth code deletion unexpectedly failed')
}
return true
@@ -19,7 +19,10 @@ import {
} from '@/modules/automate/clients/executionEngine'
import { validateStreamAccess } from '@/modules/core/services/streams/streamAccessService'
import { Automate, Roles, removeNullOrUndefinedKeys } from '@speckle/shared'
import { createStoredAuthCode } from '@/modules/automate/services/authCode'
import {
AuthCodePayloadAction,
createStoredAuthCode
} from '@/modules/automate/services/authCode'
import {
ProjectAutomationCreateInput,
ProjectAutomationRevisionCreateInput,
@@ -89,7 +92,10 @@ export const createAutomation =
userResourceAccessRules
)
const authCode = await createAuthCode()
const authCode = await createAuthCode({
userId,
action: AuthCodePayloadAction.CreateAutomation
})
// trigger automation creation on automate
const { automationId: executionEngineAutomationId, token } =
@@ -34,7 +34,10 @@ import {
} from '@/modules/automate/helpers/executionEngine'
import { Request, Response } from 'express'
import { UnauthorizedError } from '@/modules/shared/errors'
import { createStoredAuthCode } from '@/modules/automate/services/authCode'
import {
AuthCodePayloadAction,
createStoredAuthCode
} from '@/modules/automate/services/authCode'
import { getServerOrigin, speckleAutomateUrl } from '@/modules/shared/helpers/envHelper'
import { getFunctionsMarketplaceUrl } from '@/modules/core/helpers/routeHelper'
@@ -124,12 +127,16 @@ export const createFunctionFromTemplate =
throw new AutomateFunctionCreationError('Speckle user not found')
}
const authCode = await createStoredAuthCode()
const authCode = await createStoredAuthCode({
userId: user.id,
action: AuthCodePayloadAction.CreateFunction
})
const body: CreateFunctionBody = {
...input,
speckleServerOrigin: new URL(getServerOrigin()).origin,
speckleUserId: user.id,
authenticationCode: authCode,
speckleServerAuthenticationPayload: {
...authCode,
origin: new URL(getServerOrigin()).origin
},
functionName: input.name,
template: mapGqlTemplateIdToExecEngineTemplateId(input.template),
supportedSourceApps: input.supportedSourceApps as SourceAppName[],
@@ -219,17 +226,18 @@ export const startAutomateFunctionCreatorAuth =
throw new UnauthorizedError()
}
const authCode = await createStoredAuthCode()
const authCode = await createStoredAuthCode({
userId,
action: AuthCodePayloadAction.BecomeFunctionAuthor
})
const redirectUrl = new URL(
'/api/v2/functions/auth/githubapp/authorize',
speckleAutomateUrl()
)
redirectUrl.searchParams.set('speckleUserId', userId)
redirectUrl.searchParams.set(
'speckleServerOrigin',
new URL(getServerOrigin()).origin
'speckleServerAuthenticationPayload',
JSON.stringify({ ...authCode, origin: new URL(getServerOrigin()).origin })
)
redirectUrl.searchParams.set('speckleServerAuthenticationCode', authCode)
return res.redirect(redirectUrl.toString())
}
@@ -7,7 +7,10 @@ import {
updateAutomation as updateDbAutomation
} from '@/modules/automate/repositories/automations'
import { updateAutomation } from '@/modules/automate/services/automationManagement'
import { createStoredAuthCode } from '@/modules/automate/services/authCode'
import {
AuthCodePayloadAction,
createStoredAuthCode
} from '@/modules/automate/services/authCode'
import { getGenericRedis } from '@/modules/core'
import { ProjectAutomationRevisionCreateInput } from '@/modules/core/graph/generated/graphql'
import { BranchRecord } from '@/modules/core/helpers/types'
@@ -482,10 +485,14 @@ const buildAutomationUpdate = () => {
it('fails if code is invalid', async () => {
const res = await apollo.execute(AutomateValidateAuthCodeDocument, {
code: 'invalid'
payload: {
code: 'invalid',
userId: 'a',
action: 'aty'
}
})
expect(res).to.haveGraphQLErrors('Invalid automate auth code')
expect(res).to.haveGraphQLErrors('Invalid automate auth payload')
expect(res.data?.automateValidateAuthCode).to.not.be.ok
})
@@ -493,10 +500,13 @@ const buildAutomationUpdate = () => {
const storeCode = createStoredAuthCode({
redis: getGenericRedis()
})
const code = await storeCode()
const code = await storeCode({
userId: me.id,
action: AuthCodePayloadAction.BecomeFunctionAuthor
})
const res = await apollo.execute(AutomateValidateAuthCodeDocument, {
code
payload: code
})
expect(res).to.not.haveGraphQLErrors()
@@ -199,6 +199,12 @@ export type AuthStrategy = {
url: Scalars['String'];
};
export type AutomateAuthCodePayloadTest = {
action: Scalars['String'];
code: Scalars['String'];
userId: Scalars['String'];
};
export type AutomateFunction = {
__typename?: 'AutomateFunction';
automationCount: Scalars['Int'];
@@ -2374,7 +2380,7 @@ export type QueryAutomateFunctionsArgs = {
export type QueryAutomateValidateAuthCodeArgs = {
code: Scalars['String'];
payload: AutomateAuthCodePayloadTest;
};
@@ -3604,6 +3610,7 @@ export type ResolversTypes = {
AppTokenCreateInput: AppTokenCreateInput;
AppUpdateInput: AppUpdateInput;
AuthStrategy: ResolverTypeWrapper<AuthStrategy>;
AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest;
AutomateFunction: ResolverTypeWrapper<AutomateFunctionGraphQLReturn>;
AutomateFunctionCollection: ResolverTypeWrapper<Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversTypes['AutomateFunction']> }>;
AutomateFunctionRelease: ResolverTypeWrapper<AutomateFunctionReleaseGraphQLReturn>;
@@ -3830,6 +3837,7 @@ export type ResolversParentTypes = {
AppTokenCreateInput: AppTokenCreateInput;
AppUpdateInput: AppUpdateInput;
AuthStrategy: AuthStrategy;
AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest;
AutomateFunction: AutomateFunctionGraphQLReturn;
AutomateFunctionCollection: Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateFunction']> };
AutomateFunctionRelease: AutomateFunctionReleaseGraphQLReturn;
@@ -4889,7 +4897,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
authenticatedAsApp?: Resolver<Maybe<ResolversTypes['ServerAppListItem']>, ParentType, ContextType>;
automateFunction?: Resolver<ResolversTypes['AutomateFunction'], ParentType, ContextType, RequireFields<QueryAutomateFunctionArgs, 'id'>>;
automateFunctions?: Resolver<ResolversTypes['AutomateFunctionCollection'], ParentType, ContextType, Partial<QueryAutomateFunctionsArgs>>;
automateValidateAuthCode?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryAutomateValidateAuthCodeArgs, 'code'>>;
automateValidateAuthCode?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryAutomateValidateAuthCodeArgs, 'payload'>>;
comment?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType, RequireFields<QueryCommentArgs, 'id' | 'streamId'>>;
comments?: Resolver<Maybe<ResolversTypes['CommentCollection']>, ParentType, ContextType, RequireFields<QueryCommentsArgs, 'archived' | 'limit' | 'streamId'>>;
discoverableStreams?: Resolver<Maybe<ResolversTypes['StreamCollection']>, ParentType, ContextType, RequireFields<QueryDiscoverableStreamsArgs, 'limit'>>;
@@ -188,6 +188,12 @@ export type AuthStrategy = {
url: Scalars['String'];
};
export type AutomateAuthCodePayloadTest = {
action: Scalars['String'];
code: Scalars['String'];
userId: Scalars['String'];
};
export type AutomateFunction = {
__typename?: 'AutomateFunction';
automationCount: Scalars['Int'];
@@ -2363,7 +2369,7 @@ export type QueryAutomateFunctionsArgs = {
export type QueryAutomateValidateAuthCodeArgs = {
code: Scalars['String'];
payload: AutomateAuthCodePayloadTest;
};
+2 -2
View File
@@ -122,7 +122,7 @@ export const getAutomateFunctionsQuery = gql`
`
export const automateValidateAuthCodeQuery = gql`
query AutomateValidateAuthCode($code: String!) {
automateValidateAuthCode(code: $code)
query AutomateValidateAuthCode($payload: AutomateAuthCodePayloadTest!) {
automateValidateAuthCode(payload: $payload)
}
`
File diff suppressed because one or more lines are too long