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:
committed by
GitHub
parent
4092d0c8ad
commit
eff46485c4
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user