diff --git a/packages/amplify-appsync-simulator/src/schema/directives/auth.ts b/packages/amplify-appsync-simulator/src/schema/directives/auth.ts index 342e0748ac8..4155df37f84 100644 --- a/packages/amplify-appsync-simulator/src/schema/directives/auth.ts +++ b/packages/amplify-appsync-simulator/src/schema/directives/auth.ts @@ -9,6 +9,7 @@ const AUTH_DIRECTIVES = { aws_api_key: 'directive @aws_api_key on FIELD_DEFINITION | OBJECT', aws_iam: 'directive @aws_iam on FIELD_DEFINITION | OBJECT', aws_oidc: 'directive @aws_oidc on FIELD_DEFINITION | OBJECT', + aws_lambda: 'directive @aws_lambda on FIELD_DEFINITION | OBJECT', aws_cognito_user_pools: 'directive @aws_cognito_user_pools(cognito_groups: [String!]) on FIELD_DEFINITION | OBJECT', aws_auth: 'directive @aws_auth(cognito_groups: [String!]!) on FIELD_DEFINITION', }; @@ -21,6 +22,7 @@ const AUTH_TYPE_TO_DIRECTIVE_MAP: { aws_auth: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, aws_cognito_user_pools: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, aws_oidc: AmplifyAppSyncSimulatorAuthenticationType.OPENID_CONNECT, + aws_lambda: AmplifyAppSyncSimulatorAuthenticationType.AWS_LAMBDA, }; export class AwsAuth extends AppSyncSimulatorDirectiveBase { diff --git a/packages/amplify-appsync-simulator/src/type-definition.ts b/packages/amplify-appsync-simulator/src/type-definition.ts index 93c15648a7b..a73591c3efb 100644 --- a/packages/amplify-appsync-simulator/src/type-definition.ts +++ b/packages/amplify-appsync-simulator/src/type-definition.ts @@ -78,6 +78,7 @@ export enum AmplifyAppSyncSimulatorAuthenticationType { AWS_IAM = 'AWS_IAM', AMAZON_COGNITO_USER_POOLS = 'AMAZON_COGNITO_USER_POOLS', OPENID_CONNECT = 'OPENID_CONNECT', + AWS_LAMBDA = 'AWS_LAMBDA' } export type AmplifyAppSyncAuthenticationProviderAPIConfig = { @@ -103,11 +104,20 @@ export type AmplifyAppSyncAuthenticationProviderOIDCConfig = { }; }; +export type AmplifyAppSyncAuthenticationProviderLambdaConfig = { + authenticationType: AmplifyAppSyncSimulatorAuthenticationType.AWS_LAMBDA; + lambdaAuthorizerConfig: { + AuthorizerUri: string; + AuthorizerResultTtlInSeconds?: number; + }; +}; + export type AmplifyAppSyncAuthenticationProviderConfig = | AmplifyAppSyncAuthenticationProviderAPIConfig | AmplifyAppSyncAuthenticationProviderIAMConfig | AmplifyAppSyncAuthenticationProviderCognitoConfig - | AmplifyAppSyncAuthenticationProviderOIDCConfig; + | AmplifyAppSyncAuthenticationProviderOIDCConfig + | AmplifyAppSyncAuthenticationProviderLambdaConfig; export type AmplifyAppSyncAPIConfig = { name: string; diff --git a/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-index.js b/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-index.js new file mode 100644 index 00000000000..20f0971452e --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-index.js @@ -0,0 +1,25 @@ +// This is sample code. Please update this to suite your schema + +exports.handler = async (event) => { + console.log(`event >`, JSON.stringify(event, null, 2)); + const { + authorizationToken, + requestContext: { apiId, accountId }, + } = event; + const response = { + isAuthorized: authorizationToken === 'custom-authorized', + resolverContext: { + userid: 'user-id', + info: 'contextual information A', + more_info: 'contextual information B', + }, + deniedFields: [ + `arn:aws:appsync:${process.env.AWS_REGION}:${accountId}:apis/${apiId}/types/Event/fields/comments`, + `Mutation.createEvent`, + ], + ttlOverride: 300, + }; + console.log(`response >`, JSON.stringify(response, null, 2)); + return response; +}; + diff --git a/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-package.json.ejs b/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-package.json.ejs new file mode 100644 index 00000000000..e2f9e8b41d5 --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-package.json.ejs @@ -0,0 +1,6 @@ +{ + "name": "<%= props.functionName %>", + "version": "1.0.0", + "description": "Lambda function generated by Amplify for AppSync Lambda authorizer", + "main": "index.js" +} diff --git a/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-template.json.ejs b/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-template.json.ejs new file mode 100644 index 00000000000..acd908246dc --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/graphql-lambda-authorizer/graphql-lambda-authorizer-template.json.ejs @@ -0,0 +1,208 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Lambda resource stack creation using Amplify CLI", + "Parameters": { + "env": { + "Type": "String" + }<%if (props.dependsOn && props.dependsOn.length > 0) { %>,<% } %> + <% if (props.dependsOn) { %> + <% for(var i=0; i < props.dependsOn.length; i++) { %> + <% for(var j=0; j < props.dependsOn[i].attributes.length; j++) { %> + "<%= props.dependsOn[i].category %><%= props.dependsOn[i].resourceName %><%= props.dependsOn[i].attributes[j] %>": { + "Type": "String", + "Default": "<%= props.dependsOn[i].category %><%= props.dependsOn[i].resourceName %><%= props.dependsOn[i].attributes[j] %>" + }<%if (i !== props.dependsOn.length - 1 || j !== props.dependsOn[i].attributes.length - 1) { %>,<% } %> + <% } %> + <% } %> + <% } %> + }, + "Conditions": { + "ShouldNotCreateEnvResources": { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + }, + "Resources": { + "LambdaFunction": { + "Type": "AWS::Lambda::Function", + "Metadata": { + "aws:asset:path": "./src", + "aws:asset:property": "Code" + }, + "Properties": { + "Handler": "index.handler", + "FunctionName": { + "Fn::If": [ + "ShouldNotCreateEnvResources", + "<%= props.functionName %>", + { + + "Fn::Join": [ + "", + [ + "<%= props.functionName %>", + "-", + { + "Ref": "env" + } + ] + ] + } + ] + }, + "Environment": { + "Variables" : { + "ENV": { + "Ref": "env" + }, + "REGION": { + "Ref": "AWS::Region" + } + <% if (props.resourceProperties && props.resourceProperties.length > 0) { %>,<%- props.resourceProperties%> <% } %> + } + }, + "Role": { "Fn::GetAtt" : ["LambdaExecutionRole", "Arn"] }, + "Runtime": "nodejs14.x", + "Timeout": 25 + } + }, + "LambdaExecutionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": { + "Fn::If": [ + "ShouldNotCreateEnvResources", + "<%=props.roleName %>", + { + + "Fn::Join": [ + "", + [ + "<%=props.roleName %>", + "-", + { + "Ref": "env" + } + ] + ] + } + ] + }, + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] + } + } + } + ,"lambdaexecutionpolicy": { + "DependsOn": ["LambdaExecutionRole"], + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyName": "lambda-execution-policy", + "Roles": [{ "Ref": "LambdaExecutionRole" }], + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action":["logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents"], + "Resource": { "Fn::Sub" : [ "arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:*", { "region": {"Ref": "AWS::Region"}, "account": {"Ref": "AWS::AccountId"}, "lambda": {"Ref": "LambdaFunction"}} ]} + }<% if (props.database && props.database.resourceName) { %>, + { + "Effect": "Allow", + "Action": ["dynamodb:GetItem","dynamodb:Query","dynamodb:Scan","dynamodb:PutItem","dynamodb:UpdateItem","dynamodb:DeleteItem"], + "Resource": [ + <% if (props.database && props.database.Arn) { %> + "<%= props.database.Arn %>", + { + "Fn::Join": [ + "/", + [ + "<%= props.database.Arn %>", + "index/*" + ] + ] + } + <% } else { %> + { "Ref": "storage<%= props.database.resourceName %>Arn" }, + { + "Fn::Join": [ + "/", + [ + { "Ref": "storage<%= props.database.resourceName %>Arn" }, + "index/*" + ] + ] + } + <% } %> + ] + } + <% } %> + ] + } + } + } + ,"PermissionForAppSyncToInvokeLambda": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "FunctionName": { + "Ref": "LambdaFunction" + }, + "Action": "lambda:InvokeFunction", + "Principal": "appsync.amazonaws.com" + } + } + <% if (props.categoryPolicies && props.categoryPolicies.length > 0 ) { %> + ,"AmplifyResourcesPolicy": { + "DependsOn": ["LambdaExecutionRole"], + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyName": "amplify-lambda-execution-policy", + "Roles": [{ "Ref": "LambdaExecutionRole" }], + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": <%- JSON.stringify(props.categoryPolicies) %> + } + } + } + <% } %> + }, + "Outputs": { + "Name": { + "Value": { + "Ref": "LambdaFunction" + } + }, + "Arn": { + "Value": {"Fn::GetAtt": ["LambdaFunction", "Arn"]} + }, + "Region": { + "Value": { + "Ref": "AWS::Region" + } + }, + "LambdaExecutionRole": { + "Value": { + "Ref": "LambdaExecutionRole" + } + } + } +} diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts index 379a66a4ee9..21780837768 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts @@ -3,7 +3,7 @@ import { dataStoreLearnMore } from '../sync-conflict-handler-assets/syncAssets'; import inquirer from 'inquirer'; import fs from 'fs-extra'; import path from 'path'; -import { rootAssetDir } from '../aws-constants'; +import { rootAssetDir, provider } from '../aws-constants'; import { collectDirectivesByTypeNames, readProjectConfiguration } from 'graphql-transformer-core'; import { category } from '../../../category-constants'; import { UpdateApiRequest } from '../../../../../amplify-headless-interface/lib/interface/api/update'; @@ -11,6 +11,7 @@ import { authConfigToAppSyncAuthType } from '../utils/auth-config-to-app-sync-au import { resolverConfigToConflictResolution } from '../utils/resolver-config-to-conflict-resolution-bi-di-mapper'; import _ from 'lodash'; import chalk from 'chalk'; +import uuid from 'uuid'; import { getAppSyncAuthConfig, checkIfAuthExists, authConfigHasApiKey } from '../utils/amplify-meta-utils'; import { ResourceAlreadyExistsError, @@ -30,6 +31,9 @@ const elasticContainerServiceName = 'ElasticContainer'; const providerName = 'awscloudformation'; const graphqlSchemaDir = path.join(rootAssetDir, 'graphql-schemas'); +// keep in sync with ServiceName in amplify-category-function, but probably it will not change +const FunctionServiceNameLambdaFunction = 'Lambda'; + const authProviderChoices = [ { name: 'API key', @@ -68,6 +72,7 @@ const conflictResolutionHanlderChoices = [ }, ]; +const blankSchemaFile = 'blank-schema.graphql'; const schemaTemplatesV1 = [ { name: 'Single object with fields (e.g., “Todo” with ID, name, description)', @@ -83,7 +88,7 @@ const schemaTemplatesV1 = [ }, { name: 'Blank Schema', - value: 'blank-schema.graphql', + value: blankSchemaFile, }, ]; @@ -102,7 +107,7 @@ const schemaTemplatesV2 = [ }, { name: 'Blank Schema', - value: 'blank-schema.graphql', + value: blankSchemaFile, }, ]; @@ -146,7 +151,7 @@ export const openConsole = async (context: $TSContext) => { url = `https://console.aws.amazon.com/appsync/home?region=${Region}#/${GraphQLAPIIdOutput}/v1/queries`; - const providerPlugin = await import(context.amplify.getProviderPlugins(context).awscloudformation); + const providerPlugin = await import(context.amplify.getProviderPlugins(context)[provider]); const { isAdminApp, region } = await providerPlugin.isAmplifyAdminApp(appId); if (isAdminApp) { if (region !== Region) { @@ -384,9 +389,9 @@ const updateApiInputWalkthrough = async (context, project, resolverConfig, model export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilename, serviceMetadata) => { const resourceName = resourceAlreadyExists(context); - const providerPlugin = await import(context.amplify.getProviderPlugins(context).awscloudformation); + const providerPlugin = await import(context.amplify.getProviderPlugins(context)[provider]); const transformerVersion = providerPlugin.getTransformerVersion(context); - + await addLambdaAuthorizerChoice(context); if (resourceName) { const errMessage = 'You already have an AppSync API in your project. Use the "amplify update api" command to update your existing AppSync API.'; @@ -414,7 +419,7 @@ export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilen const { templateSelection } = await inquirer.prompt(templateSelectionQuestion); const schemaFilePath = path.join(graphqlSchemaDir, templateSelection); - schemaContent += transformerVersion === 2 ? defineGlobalSandboxMode(context) : ''; + schemaContent += transformerVersion === 2 && templateSelection !== blankSchemaFile ? defineGlobalSandboxMode(context) : ''; schemaContent += fs.readFileSync(schemaFilePath, 'utf8'); return { @@ -432,6 +437,7 @@ export const updateWalkthrough = async (context): Promise => { let resource; let authConfig; const resources = allResources.filter(resource => resource.service === 'AppSync'); + await addLambdaAuthorizerChoice(context); // There can only be one appsync resource if (resources.length > 0) { @@ -652,10 +658,24 @@ async function askSyncFunctionQuestion(context) { return { newFunction, lambdaFunctionName }; } + +async function addLambdaAuthorizerChoice(context) { + const providerPlugin = await import(context.amplify.getProviderPlugins(context)[provider]); + const transformerVersion = providerPlugin.getTransformerVersion(context); + if (transformerVersion === 2 && !authProviderChoices.some(choice => choice.value == 'AWS_LAMBDA')) { + authProviderChoices.push({ + name: 'Lambda', + value: 'AWS_LAMBDA', + }); + } +} + async function askDefaultAuthQuestion(context) { + await addLambdaAuthorizerChoice(context); const currentAuthConfig = getAppSyncAuthConfig(context.amplify.getProjectMeta()); const currentDefaultAuth = currentAuthConfig && currentAuthConfig.defaultAuthentication ? currentAuthConfig.defaultAuthentication.authenticationType : undefined; + const defaultAuthTypeQuestion = { type: 'list', name: 'defaultAuthType', @@ -756,6 +776,16 @@ export async function askAuthQuestions(authType, context, printLeadText = false, return openIDConnectConfig; } + if (authType === 'AWS_LAMBDA') { + if (printLeadText) { + context.print.info('Lambda Authorizer configuration'); + } + + const lambdaConfig = await askLambdaQuestion(context); + + return lambdaConfig; + } + const errMessage = `Unknown authType: ${authType}`; context.print.error(errMessage); await context.usageData.emitError(new UnknownResourceTypeError(errMessage)); @@ -1006,3 +1036,148 @@ const getAuthTypes = authConfig => { return [...uniqueAuthTypes.keys()]; }; + +async function askLambdaQuestion(context) { + const existingFunctions = functionsExist(context); + const choices = [ + { + name: 'Create a new Lambda function', + value: 'newFunction', + }, + ]; + if (existingFunctions) { + choices.push({ + name: 'Use a Lambda function already added in the current Amplify project', + value: 'projectFunction', + }); + } + + let defaultFunctionType = 'newFunction'; + const lambdaAnswer = await inquirer.prompt({ + name: 'functionType', + type: 'list', + message: 'Choose a Lambda source', + choices, + default: defaultFunctionType, + }); + + const { lambdaFunction } = await askLambdaSource(context, lambdaAnswer.functionType); + const { ttlSeconds } = await inquirer.prompt({ + type: 'input', + name: 'ttlSeconds', + message: 'How long should the authorization response be cached in seconds?', + validate: validateTTL, + default: 300, + }); + + const lambdaAuthorizerConfig = { + lambdaFunction, + ttlSeconds, + } + + return { + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig, + }; +} + +function functionsExist(context: $TSContext): boolean { + const functionResources = context.amplify.getProjectDetails().amplifyMeta.function; + if (!functionResources) { + return false; + } + + const lambdaFunctions = []; + Object.keys(functionResources).forEach(resourceName => { + if (functionResources[resourceName].service === FunctionServiceNameLambdaFunction) { + lambdaFunctions.push(resourceName); + } + }); + + return lambdaFunctions.length !== 0; +} + +async function askLambdaSource(context: $TSContext, functionType: string) { + switch (functionType) { + case 'projectFunction': + return await askLambdaFromProject(context); + case 'newFunction': + return await newLambdaFunction(context); + default: + throw new Error(`Type ${functionType} not supported`); + } +} + +async function newLambdaFunction(context: $TSContext) { + const resourceName = await createLambdaAuthorizerFunction(context); + return { lambdaFunction: resourceName }; +} + +async function askLambdaFromProject(context: $TSContext) { + const functionResources = context.amplify.getProjectDetails().amplifyMeta.function; + const lambdaFunctions = []; + Object.keys(functionResources).forEach(resourceName => { + if (functionResources[resourceName].service === FunctionServiceNameLambdaFunction) { + lambdaFunctions.push(resourceName); + } + }); + + const answer = await inquirer.prompt({ + name: 'lambdaFunction', + type: 'list', + message: 'Choose one of the Lambda functions', + choices: lambdaFunctions, + default: lambdaFunctions[0], + }); + + await context.amplify.invokePluginMethod(context, 'function', undefined, 'addAppSyncInvokeMethodPermission', [ + answer.lambdaFunction, + ]); + + return { lambdaFunction: answer.lambdaFunction }; +} + +async function createLambdaAuthorizerFunction(context: $TSContext) { + const targetDir = context.amplify.pathManager.getBackendDirPath(); + const assetDir = path.normalize(path.join(rootAssetDir, 'graphql-lambda-authorizer')); + const [shortId] = uuid().split('-'); + + const functionName = `graphQlLambdaAuthorizer${shortId}`; + + const functionProps = { + functionName: `${functionName}`, + roleName: `${functionName}LambdaRole`, + }; + + const copyJobs = [ + { + dir: assetDir, + template: 'graphql-lambda-authorizer-index.js', + target: `${targetDir}/function/${functionName}/src/index.js`, + }, + { + dir: assetDir, + template: 'graphql-lambda-authorizer-package.json.ejs', + target: `${targetDir}/function/${functionName}/src/package.json`, + }, + { + dir: assetDir, + template: 'graphql-lambda-authorizer-template.json.ejs', + target: `${targetDir}/function/${functionName}/${functionName}-cloudformation-template.json`, + }, + ]; + + // copy over the files + await context.amplify.copyBatch(context, copyJobs, functionProps, true); + + const backendConfigs = { + service: FunctionServiceNameLambdaFunction, + providerPlugin: provider, + build: true, + }; + + await context.amplify.updateamplifyMetaAfterResourceAdd('function', functionName, backendConfigs); + context.print.success(`Successfully added ${functionName} function locally`); + + return functionName; +}; \ No newline at end of file diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/auth-config-to-app-sync-auth-type-bi-di-mapper.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/auth-config-to-app-sync-auth-type-bi-di-mapper.ts index c1a8dde916a..948b72a4957 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/auth-config-to-app-sync-auth-type-bi-di-mapper.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/auth-config-to-app-sync-auth-type-bi-di-mapper.ts @@ -3,6 +3,7 @@ import { AppSyncAPIKeyAuthType, AppSyncCognitoUserPoolsAuthType, AppSyncOpenIDConnectAuthType, + AppSyncLambdaAuthType, } from 'amplify-headless-interface'; import _ from 'lodash'; @@ -48,6 +49,11 @@ const authConfigToAppSyncAuthTypeMap: Record AppSyn openIDAuthTTL: authConfig.openIDConnectConfig.authTTL, openIDIatTTL: authConfig.openIDConnectConfig.iatTTL, }), + AWS_LAMBDA: authConfig => ({ + mode: 'AWS_LAMBDA', + lambdaFunction: authConfig.lambdaAuthorizerConfig.lambdaFunction, + ttlSeconds: authConfig.lambdaAuthorizerConfig.ttlSeconds, + }), }; const appSyncAuthTypeToAuthConfigMap: Record any> = { @@ -78,4 +84,11 @@ const appSyncAuthTypeToAuthConfigMap: Record ({ + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig: { + lambdaFunction: authType.lambdaFunction, + ttlSeconds: authType.ttlSeconds, + }, + }), }; diff --git a/packages/amplify-category-api/tsconfig.json b/packages/amplify-category-api/tsconfig.json index 31c85559bbb..0fc8b8735ce 100644 --- a/packages/amplify-category-api/tsconfig.json +++ b/packages/amplify-category-api/tsconfig.json @@ -11,6 +11,7 @@ "lib", "resources/awscloudformation/lambdas", "resources/awscloudformation/container-templates", + "resources/awscloudformation/graphql-lambda-authorizer", "src/__tests__" ], "references": [ diff --git a/packages/amplify-category-function/src/index.ts b/packages/amplify-category-function/src/index.ts index 63a6841431e..81f1da5a11d 100644 --- a/packages/amplify-category-function/src/index.ts +++ b/packages/amplify-category-function/src/index.ts @@ -27,7 +27,7 @@ export { lambdasWithApiDependency } from './provider-utils/awscloudformation/uti export { hashLayerResource } from './provider-utils/awscloudformation/utils/layerHelpers'; export { migrateLegacyLayer } from './provider-utils/awscloudformation/utils/layerMigrationUtils'; export { packageResource } from './provider-utils/awscloudformation/utils/package'; -export { updateDependentFunctionsCfn } from './provider-utils/awscloudformation/utils/updateDependentFunctionCfn'; +export { updateDependentFunctionsCfn, addAppSyncInvokeMethodPermission } from './provider-utils/awscloudformation/utils/updateDependentFunctionCfn'; export { loadFunctionParameters } from './provider-utils/awscloudformation/utils/loadFunctionParameters'; export async function add(context, providerName, service, parameters) { diff --git a/packages/amplify-category-function/src/provider-utils/awscloudformation/utils/updateDependentFunctionCfn.ts b/packages/amplify-category-function/src/provider-utils/awscloudformation/utils/updateDependentFunctionCfn.ts index 896f75a930e..8f3677b827a 100644 --- a/packages/amplify-category-function/src/provider-utils/awscloudformation/utils/updateDependentFunctionCfn.ts +++ b/packages/amplify-category-function/src/provider-utils/awscloudformation/utils/updateDependentFunctionCfn.ts @@ -1,4 +1,4 @@ -import { $TSContext, $TSObject, JSONUtilities } from 'amplify-cli-core'; +import { $TSAny, $TSContext, $TSObject, JSONUtilities, pathManager } from 'amplify-cli-core'; import { FunctionParameters } from 'amplify-function-plugin-interface'; import { getResourcesForCfn, generateEnvVariablesForCfn } from '../service-walkthroughs/execPermissionsWalkthrough'; import { updateCFNFileForResourcePermissions } from '../service-walkthroughs/lambda-walkthrough'; @@ -83,3 +83,26 @@ export async function updateDependentFunctionsCfn( context.amplify.updateamplifyMetaAfterResourceUpdate(categoryName, lambda.resourceName, 'dependsOn', lambda.dependsOn); } } + +export function addAppSyncInvokeMethodPermission( + functionName: string, +) { + const resourceDirPath = pathManager.getResourceDirectoryPath(undefined, categoryName, functionName); + const cfnFileName = `${functionName}-cloudformation-template.json`; + const cfnFilePath = path.join(resourceDirPath, cfnFileName); + const cfnContent = JSONUtilities.readJson<$TSAny>(cfnFilePath); + + if (!cfnContent?.Resources?.PermissionForAppSyncToInvokeLambda) { + cfnContent.Resources.PermissionForAppSyncToInvokeLambda = { + Type: 'AWS::Lambda::Permission', + Properties: { + FunctionName: { + Ref: "LambdaFunction" + }, + Action: "lambda:InvokeFunction", + Principal: "appsync.amazonaws.com", + }, + }; + } + JSONUtilities.writeJson(cfnFilePath, cfnContent); +} \ No newline at end of file diff --git a/packages/amplify-codegen-appsync-model-plugin/src/scalars/supported-directives.ts b/packages/amplify-codegen-appsync-model-plugin/src/scalars/supported-directives.ts index 262b075641c..0fde82582cd 100644 --- a/packages/amplify-codegen-appsync-model-plugin/src/scalars/supported-directives.ts +++ b/packages/amplify-codegen-appsync-model-plugin/src/scalars/supported-directives.ts @@ -41,7 +41,7 @@ export const directives = /* GraphQL */ ` directive @auth(rules: [AuthRule!]!) on OBJECT | FIELD_DEFINITION input AuthRule { - # Specifies the auth rule's strategy. Allowed values are 'owner', 'groups', 'public', 'private'. + # Specifies the auth rule's strategy. Allowed values are 'owner', 'groups', 'public', 'private', 'custom'. allow: AuthStrategy! # Legacy name for identityClaim @@ -90,6 +90,7 @@ export const directives = /* GraphQL */ ` groups private public + custom } enum AuthProvider { @@ -97,6 +98,7 @@ export const directives = /* GraphQL */ ` iam oidc userPools + function } enum ModelOperation { diff --git a/packages/amplify-codegen-appsync-model-plugin/src/utils/process-auth.ts b/packages/amplify-codegen-appsync-model-plugin/src/utils/process-auth.ts index 08e09ace75b..0b15cc76c6d 100644 --- a/packages/amplify-codegen-appsync-model-plugin/src/utils/process-auth.ts +++ b/packages/amplify-codegen-appsync-model-plugin/src/utils/process-auth.ts @@ -4,12 +4,14 @@ export enum AuthProvider { iam = 'iam', oidc = 'oidc', userPools = 'userPools', + function = 'function', } export enum AuthStrategy { owner = 'owner', groups = 'groups', private = 'private', public = 'public', + custom = 'custom', } export enum AuthModelOperation { diff --git a/packages/amplify-codegen-appsync-model-plugin/src/visitors/appsync-java-visitor.ts b/packages/amplify-codegen-appsync-model-plugin/src/visitors/appsync-java-visitor.ts index 57309ef5766..7d8f1d2f912 100644 --- a/packages/amplify-codegen-appsync-model-plugin/src/visitors/appsync-java-visitor.ts +++ b/packages/amplify-codegen-appsync-model-plugin/src/visitors/appsync-java-visitor.ts @@ -800,6 +800,9 @@ export class AppSyncModelJavaVisitor< case AuthStrategy.public: authRule.push('allow = AuthStrategy.PUBLIC'); break; + case AuthStrategy.custom: + authRule.push('allow = AuthStrategy.CUSTOM'); + break; case AuthStrategy.groups: authRule.push('allow = AuthStrategy.GROUPS'); authRule.push(`groupClaim = "${rule.groupClaim}"`); diff --git a/packages/amplify-e2e-core/src/categories/api.ts b/packages/amplify-e2e-core/src/categories/api.ts index 0dcb9c374d2..7b0df2bd681 100644 --- a/packages/amplify-e2e-core/src/categories/api.ts +++ b/packages/amplify-e2e-core/src/categories/api.ts @@ -129,6 +129,78 @@ export function addApiWithBlankSchemaAndConflictDetection(cwd: string) { }); } +/** + * Note: Lambda Authorizer is enabled only for Transformer V2 + */ +export function addApiWithAllAuthModesV2(cwd: string, opts: Partial = {}) { + const options = _.assign(defaultOptions, opts); + return new Promise((resolve, reject) => { + spawn(getCLIPath(), ['add', 'api'], { cwd, stripColors: true }) + .wait('Please select from one of the below mentioned services:') + .sendCarriageReturn() + .wait(/.*Here is the GraphQL API that we will create. Select a setting to edit or continue.*/) + .sendKeyUp(3) + .sendCarriageReturn() + .wait('Provide API name:') + .sendLine(options.apiName) + .wait(/.*Here is the GraphQL API that we will create. Select a setting to edit or continue.*/) + .sendKeyUp(2) + .sendCarriageReturn() + .wait(/.*Choose the default authorization type for the API.*/) + .sendCarriageReturn() + // API Key + .wait(/.*Enter a description for the API key.*/) + .sendLine('description') + .wait(/.*After how many days from now the API key should expire.*/) + .sendLine('300') + .wait(/.*Configure additional auth types.*/) + .sendConfirmYes() + .wait(/.*Choose the additional authorization types you want to configure for the API.*/) + .sendLine('a\r') // All items + // Cognito + .wait(/.*Do you want to use the default authentication and security configuration.*/) + .sendCarriageReturn() + .wait('How do you want users to be able to sign in?') + .sendCarriageReturn() + .wait('Do you want to configure advanced settings?') + .sendCarriageReturn() + // OIDC + .wait(/.*Enter a name for the OpenID Connect provider:.*/) + .sendLine('myoidcprovider') + .wait(/.*Enter the OpenID Connect provider domain \(Issuer URL\).*/) + .sendLine('https://facebook.com/') + .wait(/.*Enter the Client Id from your OpenID Client Connect application.*/) + .sendLine('clientId') + .wait(/.*Enter the number of milliseconds a token is valid after being issued to a user.*/) + .sendLine('1000') + .wait(/.*Enter the number of milliseconds a token is valid after being authenticated.*/) + .sendLine('2000') + // Lambda + .wait(/.*Choose a Lambda source*/) + .sendCarriageReturn() + .wait(/.*How long should the authorization response be cached in seconds.*/) + .sendLine('600') + .wait(/.*Here is the GraphQL API that we will create. Select a setting to edit or continue.*/) + .sendCarriageReturn() + // Schema selection + .wait('Choose a schema template:') + .sendKeyDown(2) + .sendCarriageReturn() + .wait('Do you want to edit the schema now?') + .sendConfirmNo() + .wait( + '"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud', + ) + .run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); + }); +} + export function updateApiSchema(cwd: string, projectName: string, schemaName: string, forceUpdate: boolean = false) { const testSchemaPath = getSchemaPath(schemaName); let schemaText = fs.readFileSync(testSchemaPath).toString(); diff --git a/packages/amplify-e2e-core/src/init/amplifyPush.ts b/packages/amplify-e2e-core/src/init/amplifyPush.ts index 8ec31b0a06c..c6be12e0290 100644 --- a/packages/amplify-e2e-core/src/init/amplifyPush.ts +++ b/packages/amplify-e2e-core/src/init/amplifyPush.ts @@ -36,6 +36,39 @@ export function amplifyPush(cwd: string, testingWithLatestCodebase: boolean = fa }); } +export function amplifyPushGraphQlWithCognitoPrompt(cwd: string, testingWithLatestCodebase: boolean = false): Promise { + return new Promise((resolve, reject) => { + //Test detailed status + spawn(getCLIPath(testingWithLatestCodebase), ['status', '-v'], { cwd, stripColors: true, noOutputTimeout: pushTimeoutMS }) + .wait(/.*/) + .run((err: Error) => { + if (err) { + reject(err); + } + }); + //Test amplify push + spawn(getCLIPath(testingWithLatestCodebase), ['push'], { cwd, stripColors: true, noOutputTimeout: pushTimeoutMS }) + .wait('Are you sure you want to continue?') + .sendConfirmYes() + .wait(/.*Do you want to use the default authentication and security configuration.*/) + .sendCarriageReturn() + .wait(/.*How do you want users to be able to sign in.*/) + .sendCarriageReturn() + .wait(/.*Do you want to configure advanced settings.*/) + .sendCarriageReturn() + .wait('Do you want to generate code for your newly created GraphQL API') + .sendLine('n') + .wait(/.*/) + .run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); + }); +} + export function amplifyPushForce(cwd: string, testingWithLatestCodebase: boolean = false): Promise { return new Promise((resolve, reject) => { spawn(getCLIPath(testingWithLatestCodebase), ['push', '--force'], { cwd, stripColors: true, noOutputTimeout: pushTimeoutMS }) diff --git a/packages/amplify-e2e-core/src/utils/feature-flags.ts b/packages/amplify-e2e-core/src/utils/feature-flags.ts index 2ad36415b76..dbc1ef61e49 100644 --- a/packages/amplify-e2e-core/src/utils/feature-flags.ts +++ b/packages/amplify-e2e-core/src/utils/feature-flags.ts @@ -25,7 +25,7 @@ export const saveFeatureFlagFile = (projectRoot: string, data: FeatureFlagData) * @param name feature flag name * @param value value for the feature flag */ -export const addFeatureFlag = (projectRoot: string, section: string, name: string, value: boolean): void => { +export const addFeatureFlag = (projectRoot: string, section: string, name: string, value: boolean | number): void => { const ff = loadFeatureFlags(projectRoot); _.set(ff, ['features', section, name], value); saveFeatureFlagFile(projectRoot, ff); diff --git a/packages/amplify-e2e-tests/schemas/cognito_simple_model.graphql b/packages/amplify-e2e-tests/schemas/cognito_simple_model.graphql new file mode 100644 index 00000000000..d7b78a08560 --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/cognito_simple_model.graphql @@ -0,0 +1,4 @@ +type Todo @model @auth(rules: [ { allow: owner, provider: userPools }]) { + id: ID! + content: String +} diff --git a/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-1-v2.graphql b/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-1-v2.graphql new file mode 100644 index 00000000000..ec59f78f476 --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-1-v2.graphql @@ -0,0 +1,12 @@ +type Note @model + @auth(rules: [ + { allow: custom }, + { allow: public, provider: iam} + ]) { + noteId: String! @primaryKey + note: String + @auth(rules: [ + { allow: public, provider: iam }, + { allow: custom, operations: [ create, update ]} + ]) +} \ No newline at end of file diff --git a/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-2-v2.graphql b/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-2-v2.graphql new file mode 100644 index 00000000000..7d4b5e6c59a --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-2-v2.graphql @@ -0,0 +1,12 @@ +type Note @model + @auth(rules: [ + { allow: custom }, + { allow: public, provider: iam} + ]) { + noteId: String! @primaryKey + note: String + @auth(rules: [ + { allow: public, provider: iam }, + { allow: custom, operations: [ read ]} + ]) +} \ No newline at end of file diff --git a/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-v2.graphql b/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-v2.graphql new file mode 100644 index 00000000000..f2aaad95514 --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/lambda-auth-field-auth-v2.graphql @@ -0,0 +1,4 @@ +type Note @model @auth(rules: [{ allow: custom }]) { + noteId: String! @primaryKey + note: String +} \ No newline at end of file diff --git a/packages/amplify-e2e-tests/src/__tests__/api_5.test.ts b/packages/amplify-e2e-tests/src/__tests__/api_5.test.ts index 0411b8760b4..34bb9651dbf 100644 --- a/packages/amplify-e2e-tests/src/__tests__/api_5.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/api_5.test.ts @@ -1,34 +1,29 @@ import { - amplifyPush, + addApiWithoutSchema, + addFeatureFlag, + addFunction, + addRestApi, + addSimpleDDB, + amplifyPushGraphQlWithCognitoPrompt, amplifyPushUpdate, + checkIfBucketExists, + createNewProjectDir, deleteProject, + deleteProjectDir, + getAppSyncApi, + getProjectMeta, initJSProjectWithProfile, listAttachedRolePolicies, listRolePolicies, + updateApiSchema, updateAuthAddAdminQueries, + addApiWithAllAuthModesV2, + amplifyPush, } from 'amplify-e2e-core'; +import { readdirSync, readFileSync, writeFileSync } from 'fs'; import * as path from 'path'; -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; -import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; -import gql from 'graphql-tag'; const providerName = 'awscloudformation'; -import { - addRestApi, - addFunction, - addSimpleDDB, - checkIfBucketExists, - createNewProjectDir, - deleteProjectDir, - getAppSyncApi, - getProjectMeta, - getLocalEnvInfo, - getTransformConfig, - enableAdminUI, -} from 'amplify-e2e-core'; -import { TRANSFORM_CURRENT_VERSION } from 'graphql-transformer-core'; -import _ from 'lodash'; - // to deal with bug in cognito-identity-js (global as any).fetch = require('node-fetch'); // to deal with subscriptions in node env @@ -178,4 +173,29 @@ describe('amplify add api (REST)', () => { writeFileSync(cfnTemplateFile, JSON.stringify(cfnTemplate)); await amplifyPushUpdate(projRoot); }); + + it('amplify push prompt for cognito configuration if auth mode is missing', async () => { + const envName = 'devtest'; + const projName = 'lambdaauthmode'; + await initJSProjectWithProfile(projRoot, { name: projName, envName }); + await addFeatureFlag(projRoot, 'graphqltransformer', 'useexperimentalpipelinedtransformer', true); + await addFeatureFlag(projRoot, 'graphqltransformer', 'transformerversion', 2); + await addApiWithoutSchema(projRoot); + await addFunction(projRoot, { functionTemplate: 'Hello World' }, 'nodejs'); + await updateApiSchema(projRoot, projName, 'cognito_simple_model.graphql'); + await amplifyPushGraphQlWithCognitoPrompt(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers.awscloudformation.Region; + const { output } = meta.api.lambdaauthmode; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + }); }); diff --git a/packages/amplify-e2e-tests/src/__tests__/api_lambda_auth.test.ts b/packages/amplify-e2e-tests/src/__tests__/api_lambda_auth.test.ts new file mode 100644 index 00000000000..abad0ec95a8 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/api_lambda_auth.test.ts @@ -0,0 +1,349 @@ +import { + addFeatureFlag, + checkIfBucketExists, + createNewProjectDir, + deleteProject, + deleteProjectDir, + getAppSyncApi, + getProjectMeta, + initJSProjectWithProfile, + updateApiSchema, + addApiWithAllAuthModesV2, + amplifyPush, +} from 'amplify-e2e-core'; +import gql from 'graphql-tag'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import { addEnvironment, checkoutEnvironment, listEnvironment } from '../environment/env'; +const providerName = 'awscloudformation'; + + +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); +// to deal with subscriptions in node env +(global as any).WebSocket = require('ws'); + +describe('amplify add api (GraphQL) - Lambda Authorizer', () => { + let projRoot: string; + beforeEach(async () => { + projRoot = await createNewProjectDir('rest-api'); + }); + + afterEach(async () => { + const meta = getProjectMeta(projRoot); + expect(meta.providers.awscloudformation).toBeDefined(); + const { + AuthRoleArn: authRoleArn, + UnauthRoleArn: unauthRoleArn, + DeploymentBucketName: bucketName, + Region: region, + StackId: stackId, + } = meta.providers.awscloudformation; + expect(authRoleArn).toBeDefined(); + expect(unauthRoleArn).toBeDefined(); + expect(region).toBeDefined(); + expect(stackId).toBeDefined(); + const bucketExists = await checkIfBucketExists(bucketName, region); + expect(bucketExists).toMatchObject({}); + + expect(meta.function).toBeDefined(); + let seenAtLeastOneFunc = false; + for (let key of Object.keys(meta.function)) { + const { service, build, lastBuildTimeStamp, lastPackageTimeStamp, distZipFilename, lastPushTimeStamp, lastPushDirHash } = + meta.function[key]; + expect(service).toBe('Lambda'); + expect(build).toBeTruthy(); + expect(lastBuildTimeStamp).toBeDefined(); + expect(lastPackageTimeStamp).toBeDefined(); + expect(distZipFilename).toBeDefined(); + expect(lastPushTimeStamp).toBeDefined(); + expect(lastPushDirHash).toBeDefined(); + seenAtLeastOneFunc = true; + } + expect(seenAtLeastOneFunc).toBeTruthy(); + + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('init a project and add the api with lambda auth multiple env', async () => { + const envName = 'devtest'; + const projName = 'lambdaauthenv'; + await initJSProjectWithProfile(projRoot, { name: projName, envName }); + await addFeatureFlag(projRoot, 'graphqltransformer', 'useexperimentalpipelinedtransformer', true); + await addFeatureFlag(projRoot, 'graphqltransformer', 'transformerversion', 2); + await addApiWithAllAuthModesV2(projRoot); + await updateApiSchema(projRoot, projName, 'lambda-auth-field-auth-v2.graphql'); + await amplifyPush(projRoot); + + let meta = getProjectMeta(projRoot); + let region = meta.providers.awscloudformation.Region; + let { output } = meta.api[projName]; + let { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + let { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + + await addEnvironment(projRoot, { envName: 'testenv' }); + await listEnvironment(projRoot, { numEnv: 2 }); + await checkoutEnvironment(projRoot, { envName: 'testenv' }); + await amplifyPush(projRoot); + + meta = getProjectMeta(projRoot); + region = meta.providers.awscloudformation.Region; + output = meta.api[projName]["output"]; + GraphQLAPIIdOutput = output["GraphQLAPIIdOutput"]; + GraphQLAPIEndpointOutput = output["GraphQLAPIEndpointOutput"]; + GraphQLAPIKeyOutput = output["GraphQLAPIKeyOutput"]; + graphqlApi = (await getAppSyncApi(GraphQLAPIIdOutput, region))["graphqlApi"]; + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + }); + + it('init a project and add the simple_model api include lambda auth', async () => { + const envName = 'devtest'; + const projName = 'lambdaauthmode'; + await initJSProjectWithProfile(projRoot, { name: projName, envName }); + await addFeatureFlag(projRoot, 'graphqltransformer', 'useexperimentalpipelinedtransformer', true); + await addFeatureFlag(projRoot, 'graphqltransformer', 'transformerversion', 2); + await addApiWithAllAuthModesV2(projRoot); + await updateApiSchema(projRoot, projName, 'lambda-auth-field-auth-v2.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers.awscloudformation.Region; + const { output } = meta.api.lambdaauthmode; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + + + const url = GraphQLAPIEndpointOutput as string; + const apiKey = GraphQLAPIKeyOutput as string; + + const appSyncClient = new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.AWS_LAMBDA, + token: 'custom-authorized', + }, + }); + + const createMutation = /* GraphQL */ ` + mutation CreateNote($input: CreateNoteInput!, $condition: ModelNoteConditionInput) { + createNote(input: $input, condition: $condition) { + noteId + note + createdAt + updatedAt + } + } + `; + const createInput = { + input: { + noteId: '1', + note: 'initial note', + }, + }; + const createResult: any = await appSyncClient.mutate({ + mutation: gql(createMutation), + fetchPolicy: 'no-cache', + variables: createInput, + }); + + const updateMutation = /* GraphQL */ ` + mutation UpdateNote($input: UpdateNoteInput!, $condition: ModelNoteConditionInput) { + updateNote(input: $input, condition: $condition) { + noteId + note + createdAt + updatedAt + } + } + `; + const createResultData = createResult.data as any; + const updateInput = { + input: { + noteId: createResultData.createNote.noteId, + note: 'note updated', + _version: createResultData.createNote._version, + }, + }; + + const updateResult: any = await appSyncClient.mutate({ + mutation: gql(updateMutation), + fetchPolicy: 'no-cache', + variables: updateInput, + }); + const updateResultData = updateResult.data as any; + + expect(updateResultData).toBeDefined(); + expect(updateResultData.updateNote).toBeDefined(); + expect(updateResultData.updateNote.noteId).toEqual(createResultData.createNote.noteId); + expect(updateResultData.updateNote.note).not.toEqual(createResultData.createNote.note); + expect(updateResultData.updateNote.note).toEqual(updateInput.input.note); + }); + + it('lambda auth must fail when missing read access on a field or invalid token', async () => { + const envName = 'devtest'; + const projName = 'lambdaauthmodeerr'; + await initJSProjectWithProfile(projRoot, { name: projName, envName }); + await addFeatureFlag(projRoot, 'graphqltransformer', 'useexperimentalpipelinedtransformer', true); + await addFeatureFlag(projRoot, 'graphqltransformer', 'transformerversion', 2); + await addApiWithAllAuthModesV2(projRoot); + await updateApiSchema(projRoot, projName, 'lambda-auth-field-auth-1-v2.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers.awscloudformation.Region; + const { output } = meta.api.lambdaauthmodeerr; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + + + const url = GraphQLAPIEndpointOutput as string; + const apiKey = GraphQLAPIKeyOutput as string; + + const appSyncClient = new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.AWS_LAMBDA, + token: 'custom-authorized', + }, + }); + + const createMutation = /* GraphQL */ ` + mutation CreateNote($input: CreateNoteInput!, $condition: ModelNoteConditionInput) { + createNote(input: $input, condition: $condition) { + noteId + } + } + `; + const createInput = { + input: { + noteId: '1', + note: 'initial note', + }, + }; + const createResult: any = await appSyncClient.mutate({ + mutation: gql(createMutation), + fetchPolicy: 'no-cache', + variables: createInput, + }); + + const listNotesQuery = /* GraphQL */ ` + query ListNotes { + listNotes { + items { + noteId + note + } + } + } + `; + + await expect(appSyncClient.query({ + query: gql(listNotesQuery), + fetchPolicy: 'no-cache', + })).rejects.toThrow(`GraphQL error: Not Authorized to access note on type String`); + + const appSyncInvalidClient = new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.AWS_LAMBDA, + token: 'invalid-token', + }, + }); + + await expect(appSyncInvalidClient.query({ + query: gql(listNotesQuery), + fetchPolicy: 'no-cache', + })).rejects.toThrow(`Network error: Response not successful: Received status code 401`); + }); + + it('lambda auth with no create access', async () => { + const envName = 'devtest'; + const projName = 'lambdaauth2'; + await initJSProjectWithProfile(projRoot, { name: projName, envName }); + await addFeatureFlag(projRoot, 'graphqltransformer', 'useexperimentalpipelinedtransformer', true); + await addFeatureFlag(projRoot, 'graphqltransformer', 'transformerversion', 2); + await addApiWithAllAuthModesV2(projRoot); + await updateApiSchema(projRoot, projName, 'lambda-auth-field-auth-2-v2.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers.awscloudformation.Region; + const { output } = meta.api.lambdaauth2; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + + const url = GraphQLAPIEndpointOutput as string; + const appSyncClient = new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.AWS_LAMBDA, + token: 'custom-authorized', + }, + }); + + const createMutation = /* GraphQL */ ` + mutation CreateNote($input: CreateNoteInput!, $condition: ModelNoteConditionInput) { + createNote(input: $input, condition: $condition) { + noteId + note + createdAt + updatedAt + } + } + `; + const createInput = { + input: { + noteId: '1', + note: 'initial note', + }, + }; + + await expect(appSyncClient.mutate({ + mutation: gql(createMutation), + fetchPolicy: 'no-cache', + variables: createInput, + })).rejects.toThrow(`GraphQL error: Unauthorized on [note]`); + }); +}); diff --git a/packages/amplify-graphql-auth-transformer/src/__tests__/custom-auth.test.ts b/packages/amplify-graphql-auth-transformer/src/__tests__/custom-auth.test.ts new file mode 100644 index 00000000000..dc5c378481e --- /dev/null +++ b/packages/amplify-graphql-auth-transformer/src/__tests__/custom-auth.test.ts @@ -0,0 +1,203 @@ +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { ResourceConstants } from 'graphql-transformer-common'; +import { AppSyncAuthConfiguration } from '@aws-amplify/graphql-transformer-interfaces'; + +test('happy case with lambda auth mode as default auth mode', () => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig: { + lambdaFunction: 'testfunction', + ttlSeconds: 600, + }, + }, + additionalAuthenticationProviders: [], + }; + const validSchema = ` + type Post @model @auth(rules: [{ allow: custom, provider: function }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const transformer = new GraphQLTransform({ + authConfig, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + authConfig, + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.rootStack!.Resources![ResourceConstants.RESOURCES.GraphQLAPILogicalID].Properties.AuthenticationType).toEqual('AWS_LAMBDA'); +}); + +test('happy case with lambda auth mode as additional auth mode', () => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [ + { + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig: { + lambdaFunction: 'testfunction', + ttlSeconds: 600, + }, + }, + ], + }; + const validSchema = ` + type Post @model @auth(rules: [{ allow: custom, provider: function }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const transformer = new GraphQLTransform({ + authConfig, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + authConfig, + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.rootStack!.Resources![ResourceConstants.RESOURCES.GraphQLAPILogicalID].Properties.AdditionalAuthenticationProviders[0].AuthenticationType).toEqual('AWS_LAMBDA'); +}); + +test('allow: custom defaults provider to function', () => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig: { + lambdaFunction: 'testfunction', + ttlSeconds: 600, + }, + }, + additionalAuthenticationProviders: [], + }; + const validSchema = ` + type Post @model @auth(rules: [{ allow: custom }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const transformer = new GraphQLTransform({ + authConfig, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + authConfig, + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.rootStack!.Resources![ResourceConstants.RESOURCES.GraphQLAPILogicalID].Properties.AuthenticationType).toEqual('AWS_LAMBDA'); +}); + +test('allow: custom error out when there is no lambda auth mode defined', () => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }; + const validSchema = ` + type Post @model @auth(rules: [{ allow: custom, provider: function }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const transformer = new GraphQLTransform({ + authConfig, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + authConfig, + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + expect(() => transformer.transform(validSchema)).toThrowError( + `@auth directive with 'function' provider found, but the project has no Lambda authentication provider configured.`, + ); +}); + +test('allow: custom and provider: iam error out for invalid combination', () => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig: { + lambdaFunction: 'testfunction', + ttlSeconds: 600, + }, + }, + additionalAuthenticationProviders: [], + }; + const validSchema = ` + type Post @model @auth(rules: [{ allow: custom, provider: iam }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const transformer = new GraphQLTransform({ + authConfig, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + authConfig, + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + expect(() => transformer.transform(validSchema)).toThrowError( + `@auth directive with 'custom' strategy only supports 'function' (default) provider, but found 'iam' assigned.`, + ); +}); + +test('allow: non-custom and provider: function error out for invalid combination', () => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AWS_LAMBDA', + lambdaAuthorizerConfig: { + lambdaFunction: 'testfunction', + ttlSeconds: 600, + }, + }, + additionalAuthenticationProviders: [], + }; + const validSchema = ` + type Post @model @auth(rules: [{ allow: public, provider: function }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const transformer = new GraphQLTransform({ + authConfig, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + authConfig, + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + expect(() => transformer.transform(validSchema)).toThrowError( + `@auth directive with 'public' strategy only supports 'apiKey' (default) and 'iam' providers, but found 'function' assigned.`, + ); +}); \ No newline at end of file diff --git a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts index ac275a9ea12..43ce050a331 100644 --- a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts +++ b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts @@ -770,6 +770,10 @@ Static group authorization should perform as expected.`, roleName = 'apiKey:public'; roleDefinition = { provider: rule.provider, strategy: rule.allow, static: true }; break; + case 'function': + roleName = 'function:custom'; + roleDefinition = { provider: rule.provider, strategy: rule.allow, static: true }; + break; case 'iam': roleName = `iam:${rule.allow}`; roleDefinition = { diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts index c7e9e4bfa2d..3e1fe630096 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts @@ -32,7 +32,7 @@ import { IS_AUTHORIZED_FLAG, API_KEY_AUTH_TYPE, } from '../utils'; -import { getOwnerClaim, generateStaticRoleExpression, apiKeyExpression, iamExpression, emptyPayload, getIdentityClaimExp } from './helpers'; +import { getOwnerClaim, generateStaticRoleExpression, apiKeyExpression, iamExpression, emptyPayload, lambdaExpression, getIdentityClaimExp } from './helpers'; // Field Read VTL Functions const generateDynamicAuthReadExpression = (roles: Array, fields: ReadonlyArray) => { @@ -89,11 +89,14 @@ export const generateAuthExpressionForField = ( roles: Array, fields: ReadonlyArray, ): string => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, iamRoles, apiKeyRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, iamRoles, apiKeyRoles, lambdaRoles } = splitRoles(roles); const totalAuthExpressions: Array = [set(ref(IS_AUTHORIZED_FLAG), bool(false))]; if (provider.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } + if (provider.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (provider.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, provider.hasAdminUIEnabled, provider.adminUserPoolID)); } diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/helpers.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/helpers.ts index 5ac4e417b94..720f3ff95eb 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/helpers.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/helpers.ts @@ -26,6 +26,7 @@ import { IS_AUTHORIZED_FLAG, ALLOWED_FIELDS, API_KEY_AUTH_TYPE, + LAMBDA_AUTH_TYPE, ADMIN_ROLE, IAM_AUTH_TYPE, MANAGE_ROLE, @@ -106,11 +107,19 @@ export const generateStaticRoleExpression = (roles: Array): Arra return staticRoleExpression; }; -export const apiKeyExpression = (roles: Array) => - iff( +export const apiKeyExpression = (roles: Array) => { + return iff( equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)), compoundExpression([...(roles.length > 0 ? [set(ref(IS_AUTHORIZED_FLAG), bool(true))] : [])]), ); +} + +export const lambdaExpression = (roles: Array) => { + return iff( + equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), + compoundExpression([...(roles.length > 0 ? [set(ref(IS_AUTHORIZED_FLAG), bool(true))] : [])]), + ); +} export const iamExpression = (roles: Array, adminuiEnabled: boolean = false, adminUserPoolID?: string) => { const expression = new Array(); diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.create.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.create.ts index 22dd6a95d22..9b65f6f8f68 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.create.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.create.ts @@ -32,6 +32,7 @@ import { import { ADMIN_ROLE, API_KEY_AUTH_TYPE, + LAMBDA_AUTH_TYPE, COGNITO_AUTH_TYPE, ConfiguredAuthProviders, IAM_AUTH_TYPE, @@ -98,6 +99,24 @@ const iamExpression = (roles: Array, hasAdminUIEnabled: boolean return iff(equals(ref('util.authType()'), str(IAM_AUTH_TYPE)), compoundExpression(expression)); }; +/** + * There is only one role for Lambda we can use the first index + * @param roles + * @returns Expression | null + */ + const lambdaExpression = (roles: Array) => { + const expression = new Array(); + if (roles.length === 0) { + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), ref('util.unauthorized()')); + } + if (roles[0].allowedFields!.length > 0) { + expression.push(set(ref(`${ALLOWED_FIELDS}`), raw(JSON.stringify(roles[0].allowedFields)))); + } else { + expression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true))); + } + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), compoundExpression(expression)); +}; + const generateStaticRoleExpression = (roles: Array): Array => { const staticRoleExpression: Array = new Array(); const privateRoleIdx = roles.findIndex(r => r.strategy === 'private'); @@ -222,6 +241,7 @@ export const generateAuthExpressionForCreate = ( oidcDynamicRoles, apiKeyRoles, iamRoles, + lambdaRoles, } = splitRoles(roles); const totalAuthExpressions: Array = [ setHasAuthExpression, @@ -235,6 +255,9 @@ export const generateAuthExpressionForCreate = ( if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasUserPools) { totalAuthExpressions.push( iff( diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts index c16ea3d9af6..49c2d09e121 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts @@ -22,6 +22,7 @@ import { ADMIN_ROLE, API_KEY_AUTH_TYPE, COGNITO_AUTH_TYPE, + LAMBDA_AUTH_TYPE, ConfiguredAuthProviders, fieldIsList, IAM_AUTH_TYPE, @@ -41,8 +42,7 @@ const apiKeyExpression = (roles: Array) => { const expression = new Array(); if (roles.length === 0) { return iff(equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)), ref('util.unauthorized()')); - } - if (roles.length > 0) { + } else { expression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true))); } return iff(equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)), compoundExpression(expression)); @@ -76,6 +76,21 @@ const iamExpression = (roles: Array, hasAdminUIEnabled: boolean return iff(equals(ref('util.authType()'), str(IAM_AUTH_TYPE)), compoundExpression(expression)); }; +/** + * There is only one role for Lambda we can use the first index + * @param roles + * @returns Expression | null + */ + const lambdaExpression = (roles: Array) => { + const expression = new Array(); + if (roles.length === 0) { + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), ref('util.unauthorized()')); + } else { + expression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true))); + } + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), compoundExpression(expression)); +}; + const generateStaticRoleExpression = (roles: Array): Array => { const staticRoleExpression: Array = new Array(); const privateRoleIdx = roles.findIndex(r => r.strategy === 'private'); @@ -157,7 +172,7 @@ export const geneateAuthExpressionForDelete = ( roles: Array, fields: ReadonlyArray, ) => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles, lambdaRoles } = splitRoles(roles); const totalAuthExpressions: Array = [setHasAuthExpression, set(ref(IS_AUTHORIZED_FLAG), bool(false))]; if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); @@ -165,6 +180,9 @@ export const geneateAuthExpressionForDelete = ( if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasUserPools) { totalAuthExpressions.push( iff( diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts index 7d4f20ec30d..75107b7ff58 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts @@ -26,6 +26,7 @@ import { ADMIN_ROLE, API_KEY_AUTH_TYPE, COGNITO_AUTH_TYPE, + LAMBDA_AUTH_TYPE, ConfiguredAuthProviders, IAM_AUTH_TYPE, MANAGE_ROLE, @@ -61,6 +62,27 @@ const apiKeyExpression = (roles: Array) => { return iff(equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)), compoundExpression(expression)); }; +/** + * There is only one role for Lambda we can use the first index + * @param roles + * @returns Expression | null + */ + const lambdaExpression = (roles: Array) => { + const expression = new Array(); + if (roles.length === 0) { + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), ref('util.unauthorized()')); + } + if (roles[0].allowedFields!.length > 0 || roles[0].nullAllowedFields!.length > 0) { + expression.push( + set(ref(`${ALLOWED_FIELDS}`), raw(JSON.stringify(roles[0].allowedFields))), + set(ref(`${NULL_ALLOWED_FIELDS}`), raw(JSON.stringify(roles[0].nullAllowedFields))), + ); + } else { + expression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true))); + } + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), compoundExpression(expression)); +}; + const iamExpression = (roles: Array, hasAdminUIEnabled: boolean = false, adminUserPoolID?: string) => { const expression = new Array(); // allow if using admin ui @@ -254,7 +276,7 @@ export const generateAuthExpressionForUpdate = ( roles: Array, fields: ReadonlyArray, ) => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles, lambdaRoles } = splitRoles(roles); const totalAuthExpressions: Array = [ setHasAuthExpression, responseCheckForErrors(), @@ -267,6 +289,9 @@ export const generateAuthExpressionForUpdate = ( if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts index 3b186108bd5..ff307694790 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts @@ -20,7 +20,7 @@ import { ifElse, nul, } from 'graphql-mapping-template'; -import { getIdentityClaimExp, getOwnerClaim, apiKeyExpression, iamExpression, emptyPayload, setHasAuthExpression } from './helpers'; +import { getIdentityClaimExp, getOwnerClaim, apiKeyExpression, iamExpression, lambdaExpression, emptyPayload, setHasAuthExpression } from './helpers'; import { COGNITO_AUTH_TYPE, OIDC_AUTH_TYPE, @@ -248,7 +248,7 @@ export const generateAuthExpressionForQueries = ( primaryFields: Array, isIndexQuery = false, ): string => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles, lambdaRoles } = splitRoles(roles); const getNonPrimaryFieldRoles = (roles: RoleDefinition[]) => roles.filter(roles => !primaryFields.includes(roles.entity)); const totalAuthExpressions: Array = [ setHasAuthExpression, @@ -258,6 +258,9 @@ export const generateAuthExpressionForQueries = ( if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } @@ -300,7 +303,7 @@ export const generateAuthExpressionForRelationQuery = ( fields: ReadonlyArray, primaryFieldMap: RelationalPrimaryMapConfig, ) => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles, lambdaRoles } = splitRoles(roles); const getNonPrimaryFieldRoles = (roles: RoleDefinition[]) => roles.filter(roles => !primaryFieldMap.has(roles.entity)); const totalAuthExpressions: Array = [ setHasAuthExpression, @@ -310,6 +313,9 @@ export const generateAuthExpressionForRelationQuery = ( if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts index efcc06ef9ff..cb1e820d642 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/search.ts @@ -25,6 +25,7 @@ import { getIdentityClaimExp, getOwnerClaim, emptyPayload, setHasAuthExpression, import { COGNITO_AUTH_TYPE, OIDC_AUTH_TYPE, + LAMBDA_AUTH_TYPE, RoleDefinition, splitRoles, ConfiguredAuthProviders, @@ -56,6 +57,21 @@ const apiKeyExpression = (roles: Array): Expression => { return iff(equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)), compoundExpression(expression)); }; +const lambdaExpression = (roles: Array): Expression => { + const expression = Array(); + if (roles.length === 0) { + expression.push(ref('util.unauthorized()')); + } else if (roles[0].allowedFields) { + expression.push( + set(ref(IS_AUTHORIZED_FLAG), bool(true)), + qref(methodCall(ref(`${allowedAggFieldsList}.addAll`), raw(JSON.stringify(roles[0].allowedFields)))), + ); + } else { + expression.push(set(ref(IS_AUTHORIZED_FLAG), bool(true)), set(ref(allowedAggFieldsList), ref(totalFields))); + } + return iff(equals(ref('util.authType()'), str(LAMBDA_AUTH_TYPE)), compoundExpression(expression)); +}; + const iamExpression = (roles: Array, adminuiEnabled: boolean = false, adminUserPoolID?: string) => { const expression = new Array(); // allow if using admin ui @@ -228,7 +244,7 @@ export const generateAuthExpressionForSearchQueries = ( fields: ReadonlyArray, allowedAggFields: Array, ): string => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles, lambdaRoles } = splitRoles(roles); const totalAuthExpressions: Array = [ setHasAuthExpression, set(ref(IS_AUTHORIZED_FLAG), bool(false)), @@ -238,6 +254,9 @@ export const generateAuthExpressionForSearchQueries = ( if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts index 8fcc6ab086f..0f739bcf00f 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts @@ -18,6 +18,7 @@ import { getOwnerClaim, apiKeyExpression, iamExpression, + lambdaExpression, emptyPayload, setHasAuthExpression, } from './helpers'; @@ -44,11 +45,14 @@ const dynamicRoleExpression = (roles: Array): Array }; export const generateAuthExpressionForSubscriptions = (providers: ConfiguredAuthProviders, roles: Array): string => { - const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, iamRoles, apiKeyRoles } = splitRoles(roles); + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, iamRoles, apiKeyRoles, lambdaRoles } = splitRoles(roles); const totalAuthExpressions: Array = [setHasAuthExpression, set(ref(IS_AUTHORIZED_FLAG), bool(false))]; if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } + if (providers.hasLambda) { + totalAuthExpressions.push(lambdaExpression(lambdaRoles)); + } if (providers.hasIAM) { totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); } diff --git a/packages/amplify-graphql-auth-transformer/src/utils/constants.ts b/packages/amplify-graphql-auth-transformer/src/utils/constants.ts index 85f62f075d4..392b70fda95 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/constants.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/constants.ts @@ -14,11 +14,13 @@ export const AUTH_PROVIDER_DIRECTIVE_MAP = new Map([ ['iam', 'aws_iam'], ['oidc', 'aws_oidc'], ['userPools', 'aws_cognito_user_pools'], + ['function', 'aws_lambda'], ]); // values for $util.authType() https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference.html export const COGNITO_AUTH_TYPE = 'User Pool Authorization'; export const OIDC_AUTH_TYPE = 'Open ID Connect Authorization'; export const IAM_AUTH_TYPE = 'IAM Authorization'; +export const LAMBDA_AUTH_TYPE = 'Lambda Authorization'; export const API_KEY_AUTH_TYPE = 'API Key Authorization'; // resolver refs export const IS_AUTHORIZED_FLAG = 'isAuthorized'; diff --git a/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts b/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts index abeefde7257..1254af08b68 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts @@ -1,6 +1,6 @@ import { AppSyncAuthConfiguration } from '@aws-amplify/graphql-transformer-interfaces'; -export type AuthStrategy = 'owner' | 'groups' | 'public' | 'private'; -export type AuthProvider = 'apiKey' | 'iam' | 'oidc' | 'userPools'; +export type AuthStrategy = 'owner' | 'groups' | 'public' | 'private' | 'custom'; +export type AuthProvider = 'apiKey' | 'iam' | 'oidc' | 'userPools' | 'function'; export type ModelQuery = 'get' | 'list'; export type ModelMutation = 'create' | 'update' | 'delete'; export type ModelOperation = 'create' | 'update' | 'delete' | 'read'; @@ -19,6 +19,7 @@ export interface RolesByProvider { oidcDynamicRoles: Array; iamRoles: Array; apiKeyRoles: Array; + lambdaRoles: Array; } export interface AuthRule { @@ -56,6 +57,7 @@ export interface ConfiguredAuthProviders { hasUserPools: boolean; hasOIDC: boolean; hasIAM: boolean; + hasLambda: boolean; hasAdminUIEnabled: boolean; adminUserPoolID?: string; } @@ -83,12 +85,14 @@ export const authDirectiveDefinition = ` groups private public + custom } enum AuthProvider { apiKey iam oidc userPools + function } enum ModelOperation { create diff --git a/packages/amplify-graphql-auth-transformer/src/utils/index.ts b/packages/amplify-graphql-auth-transformer/src/utils/index.ts index e5e7760884a..58467db0b70 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/index.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/index.ts @@ -18,6 +18,7 @@ export const splitRoles = (roles: Array): RolesByProvider => { oidcDynamicRoles: roles.filter(r => !r.static && r.provider === 'oidc'), iamRoles: roles.filter(r => r.provider === 'iam'), apiKeyRoles: roles.filter(r => r.provider === 'apiKey'), + lambdaRoles: roles.filter(r => r.provider === 'function'), }; }; /** @@ -40,6 +41,9 @@ export const ensureAuthRuleDefaults = (rules: AuthRule[]) => { case 'public': rule.provider = 'apiKey'; break; + case 'custom': + rule.provider = 'function'; + break; default: throw new Error(`Need to specify an allow to assigned a provider: ${rule}`); } @@ -88,6 +92,8 @@ export const getConfiguredAuthProviders = (config: AuthTransformerConfig): Confi return 'iam'; case 'OPENID_CONNECT': return 'oidc'; + case 'AWS_LAMBDA': + return 'function'; } }; const hasIAM = providers.some(p => p === 'AWS_IAM'); @@ -99,6 +105,7 @@ export const getConfiguredAuthProviders = (config: AuthTransformerConfig): Confi hasApiKey: providers.some(p => p === 'API_KEY'), hasUserPools: providers.some(p => p === 'AMAZON_COGNITO_USER_POOLS'), hasOIDC: providers.some(p => p === 'OPENID_CONNECT'), + hasLambda: providers.some(p => p === 'AWS_LAMBDA'), hasIAM, }; return configuredProviders; diff --git a/packages/amplify-graphql-auth-transformer/src/utils/validations.ts b/packages/amplify-graphql-auth-transformer/src/utils/validations.ts index d68646b90e9..c397658c2ca 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/validations.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/validations.ts @@ -50,6 +50,18 @@ found '${rule.provider}' assigned.`, } } + // + // Custom + // + if (rule.allow === 'custom') { + if (rule.provider !== null && rule.provider !== 'function') { + throw new InvalidDirectiveError( + `@auth directive with 'custom' strategy only supports 'function' (default) provider, but \ +found '${rule.provider}' assigned.`, + ); + } + } + // // Validate provider values against project configuration. // @@ -69,6 +81,10 @@ found '${rule.provider}' assigned.`, throw new InvalidDirectiveError( `@auth directive with 'iam' provider found, but the project has no IAM authentication provider configured.`, ); + } else if (rule.provider === 'function' && configuredAuthProviders.hasLambda === false) { + throw new InvalidDirectiveError( + `@auth directive with 'function' provider found, but the project has no Lambda authentication provider configured.`, + ); } }; diff --git a/packages/amplify-graphql-transformer-core/src/graphql-api.ts b/packages/amplify-graphql-transformer-core/src/graphql-api.ts index 21bcaf042b1..ca978d1fdb3 100644 --- a/packages/amplify-graphql-transformer-core/src/graphql-api.ts +++ b/packages/amplify-graphql-transformer-core/src/graphql-api.ts @@ -17,6 +17,7 @@ import { Grant, IGrantable, ManagedPolicy, Role, ServicePrincipal } from '@aws-c import { CfnResource, Construct, Duration, Stack } from '@aws-cdk/core'; import { TransformerSchema } from './cdk-compat/schema-asset'; import { DefaultTransformHost } from './transform-host'; +import * as cdk from '@aws-cdk/core'; export interface GraphqlApiProps { /** @@ -114,6 +115,7 @@ export type TransformerAPIProps = GraphqlApiProps & { readonly createApiKey?: boolean; readonly host?: TransformHostProvider; readonly sandboxModeEnabled?: boolean; + readonly environmentName?: string; }; export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { /** @@ -167,10 +169,15 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { */ public readonly sandboxModeEnabled?: boolean; + /** + * the amplify environment name + */ + public readonly environmentName?: string; + private schemaResource: CfnGraphQLSchema; private api: CfnGraphQLApi; private apiKeyResource?: CfnApiKey; - private authorizationConfig?: Required; + private authorizationConfig?: Required; constructor(scope: Construct, id: string, props: TransformerAPIProps) { super(scope, id); @@ -184,7 +191,7 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { const modes = [defaultMode, ...additionalModes]; this.modes = modes.map(mode => mode.authorizationType); - + this.environmentName = props.environmentName; this.validateAuthorizationProps(modes); this.api = new CfnGraphQLApi(this, 'Resource', { @@ -193,6 +200,7 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { logConfig: this.setupLogConfig(props.logConfig), openIdConnectConfig: this.setupOpenIdConnectConfig(defaultMode.openIdConnectConfig), userPoolConfig: this.setupUserPoolConfig(defaultMode.userPoolConfig), + lambdaAuthorizerConfig: this.setupLambdaConfig(defaultMode.lambdaAuthorizerConfig), additionalAuthenticationProviders: this.setupAdditionalAuthorizationModes(additionalModes), xrayEnabled: props.xrayEnabled, }); @@ -306,7 +314,9 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { } private setupLogConfig(config?: LogConfig) { - if (!config) return undefined; + if (!config) { + return undefined; + } const role = new Role(this, 'ApiLogsRole', { assumedBy: new ServicePrincipal('appsync.amazonaws.com'), managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSAppSyncPushToCloudWatchLogs')], @@ -319,7 +329,9 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { } private setupOpenIdConnectConfig(config?: OpenIdConnectConfig) { - if (!config) return undefined; + if (!config) { + return undefined; + } return { authTtl: config.tokenExpiryFromAuth, clientId: config.clientId, @@ -329,7 +341,9 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { } private setupUserPoolConfig(config?: UserPoolConfig) { - if (!config) return undefined; + if (!config) { + return undefined; + } return { userPoolId: config.userPool.userPoolId, awsRegion: config.userPool.stack.region, @@ -338,7 +352,22 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { }; } - private setupAdditionalAuthorizationModes(modes?: AuthorizationMode[]) { + private setupLambdaConfig(config?: any) { + if (!config) { + return undefined; + } + return { + authorizerUri: this.lambdaArnKey(config.lambdaFunction), + authorizerResultTtlInSeconds: config.ttlSeconds, + identityValidationExpression: "", + }; + } + + private lambdaArnKey(name: string) { + return `arn:${cdk.Aws.PARTITION}:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:function:${name}-${this.environmentName}`; + } + + private setupAdditionalAuthorizationModes(modes?: Array) { if (!modes || modes.length === 0) return undefined; return modes.reduce( (acc, mode) => [ @@ -347,6 +376,7 @@ export class GraphQLApi extends GraphqlApiBase implements GraphQLAPIProvider { authenticationType: mode.authorizationType, userPoolConfig: this.setupUserPoolConfig(mode.userPoolConfig), openIdConnectConfig: this.setupOpenIdConnectConfig(mode.openIdConnectConfig), + lambdaAuthorizerConfig: this.setupLambdaConfig(mode.lambdaAuthorizerConfig), }, ], [], diff --git a/packages/amplify-graphql-transformer-core/src/transformation/transform.ts b/packages/amplify-graphql-transformer-core/src/transformation/transform.ts index 0ec4d164fc1..ce1fe2dde41 100644 --- a/packages/amplify-graphql-transformer-core/src/transformation/transform.ts +++ b/packages/amplify-graphql-transformer-core/src/transformation/transform.ts @@ -140,6 +140,7 @@ export class GraphQLTransform { aws_api_key: true, aws_iam: true, aws_oidc: true, + aws_lambda: true, aws_cognito_user_pools: true, allow_public_data_access_with_api_key: true, deprecated: true, @@ -265,6 +266,7 @@ export class GraphQLTransform { authorizationConfig, host: this.options.host, sandboxModeEnabled: this.options.sandboxModeEnabled, + environmentName: envName.valueAsString, }); const authModes = [authorizationConfig.defaultAuthorization, ...(authorizationConfig.additionalAuthorizationModes || [])].map( mode => mode?.authorizationType, diff --git a/packages/amplify-graphql-transformer-core/src/transformation/validation.ts b/packages/amplify-graphql-transformer-core/src/transformation/validation.ts index a74afcd62bc..8d6bbd68434 100644 --- a/packages/amplify-graphql-transformer-core/src/transformation/validation.ts +++ b/packages/amplify-graphql-transformer-core/src/transformation/validation.ts @@ -159,7 +159,7 @@ export const validateAuthModes = (authConfig: AppSyncAuthConfiguration) => { for (let i = 0; i < authModes.length; i++) { const mode = authModes[i]; - if (mode !== 'API_KEY' && mode !== 'AMAZON_COGNITO_USER_POOLS' && mode !== 'AWS_IAM' && mode !== 'OPENID_CONNECT') { + if (mode !== 'API_KEY' && mode !== 'AMAZON_COGNITO_USER_POOLS' && mode !== 'AWS_IAM' && mode !== 'OPENID_CONNECT' && mode !== 'AWS_LAMBDA') { throw new Error(`Invalid auth mode ${mode}`); } } diff --git a/packages/amplify-graphql-transformer-core/src/transformer-context/output.ts b/packages/amplify-graphql-transformer-core/src/transformer-context/output.ts index 51491093881..d8429a657af 100644 --- a/packages/amplify-graphql-transformer-core/src/transformer-context/output.ts +++ b/packages/amplify-graphql-transformer-core/src/transformer-context/output.ts @@ -586,7 +586,7 @@ export class TransformerOutput implements TransformerContextOutputProvider { kind: 'Document', definitions: Object.values(this.nodeMap), }, - ['aws_subscribe', 'aws_auth', 'aws_api_key', 'aws_iam', 'aws_oidc', 'aws_cognito_user_pools', 'deprecated'], + ['aws_subscribe', 'aws_auth', 'aws_api_key', 'aws_iam', 'aws_oidc', 'aws_cognito_user_pools', 'aws_lambda', 'deprecated'], ); const SDL = print(astSansDirectives); return SDL; diff --git a/packages/amplify-graphql-transformer-core/src/utils/authType.ts b/packages/amplify-graphql-transformer-core/src/utils/authType.ts index 945da6dd5db..3a99fcd458b 100644 --- a/packages/amplify-graphql-transformer-core/src/utils/authType.ts +++ b/packages/amplify-graphql-transformer-core/src/utils/authType.ts @@ -1,14 +1,15 @@ -import { AuthorizationConfig, AuthorizationMode, AuthorizationType } from '@aws-cdk/aws-appsync'; +import { AuthorizationConfig, AuthorizationType } from '@aws-cdk/aws-appsync'; import { UserPool } from '@aws-cdk/aws-cognito'; import { Duration, Expiration } from '@aws-cdk/core'; import { StackManager } from '../transformer-context/stack-manager'; import { AppSyncAuthConfiguration, AppSyncAuthConfigurationEntry, AppSyncAuthMode } from '@aws-amplify/graphql-transformer-interfaces'; -const authTypeMap: Record = { +const authTypeMap: Record = { API_KEY: AuthorizationType.API_KEY, AMAZON_COGNITO_USER_POOLS: AuthorizationType.USER_POOL, AWS_IAM: AuthorizationType.IAM, OPENID_CONNECT: AuthorizationType.OIDC, + AWS_LAMBDA: "AWS_LAMBDA", }; export const IAM_AUTH_ROLE_PARAMETER = 'authRoleName'; @@ -21,7 +22,7 @@ export function adoptAuthModes(stack: StackManager, authConfig: AppSyncAuthConfi }; } -export function adoptAuthMode(stackManager: StackManager, entry: AppSyncAuthConfigurationEntry): AuthorizationMode { +export function adoptAuthMode(stackManager: StackManager, entry: AppSyncAuthConfigurationEntry): any { const authType = authTypeMap[entry.authenticationType]; switch (entry.authenticationType) { case AuthorizationType.API_KEY: @@ -59,6 +60,14 @@ export function adoptAuthMode(stackManager: StackManager, entry: AppSyncAuthConf tokenExpiryFromIssue: strToNumber(entry.openIDConnectConfig!.iatTTL), }, }; + case 'AWS_LAMBDA': + return { + authorizationType: authType, + lambdaAuthorizerConfig: { + lambdaFunction: entry.lambdaAuthorizerConfig!.lambdaFunction, + ttlSeconds: strToNumber(entry.lambdaAuthorizerConfig!.ttlSeconds), + }, + }; default: throw new Error('Invalid auth config'); } diff --git a/packages/amplify-graphql-transformer-core/src/utils/type-map-utils.ts b/packages/amplify-graphql-transformer-core/src/utils/type-map-utils.ts index 97dd607032f..89b3524a716 100644 --- a/packages/amplify-graphql-transformer-core/src/utils/type-map-utils.ts +++ b/packages/amplify-graphql-transformer-core/src/utils/type-map-utils.ts @@ -15,6 +15,9 @@ import { export function collectDirectives(sdl: string): DirectiveNode[] { + if (sdl.trim() === '') { + return []; + } const doc = parse(sdl); let directives: DirectiveNode[] = []; for (const def of doc.definitions) { @@ -61,6 +64,9 @@ export function collectDirectivesByTypeNames(sdl: string) { } export function collectDirectivesByType(sdl: string): Record { + if (sdl.trim() === '') { + return {}; + } const doc = parse(sdl); // defined types with directives list let types: Record = {}; diff --git a/packages/amplify-graphql-transformer-interfaces/src/graphql-api-provider.ts b/packages/amplify-graphql-transformer-interfaces/src/graphql-api-provider.ts index 6514c3e48a0..69cd8f78c71 100644 --- a/packages/amplify-graphql-transformer-interfaces/src/graphql-api-provider.ts +++ b/packages/amplify-graphql-transformer-interfaces/src/graphql-api-provider.ts @@ -3,7 +3,7 @@ import { Grant, IGrantable, IRole } from '@aws-cdk/aws-iam'; import { TransformHostProvider } from './transform-host-provider'; // Auth Config -export type AppSyncAuthMode = 'API_KEY' | 'AMAZON_COGNITO_USER_POOLS' | 'AWS_IAM' | 'OPENID_CONNECT'; +export type AppSyncAuthMode = 'API_KEY' | 'AMAZON_COGNITO_USER_POOLS' | 'AWS_IAM' | 'OPENID_CONNECT' | 'AWS_LAMBDA'; export type AppSyncAuthConfiguration = { defaultAuthentication: AppSyncAuthConfigurationEntry; additionalAuthenticationProviders: Array; @@ -13,7 +13,9 @@ export type AppSyncAuthConfigurationEntry = | AppSyncAuthConfigurationUserPoolEntry | AppSyncAuthConfigurationAPIKeyEntry | AppSyncAuthConfigurationIAMEntry - | AppSyncAuthConfigurationOIDCEntry; + | AppSyncAuthConfigurationOIDCEntry + | AppSyncAuthConfigurationLambdaEntry; + export type AppSyncAuthConfigurationAPIKeyEntry = { authenticationType: 'API_KEY'; apiKeyConfig?: ApiKeyConfig; @@ -31,6 +33,11 @@ export type AppSyncAuthConfigurationOIDCEntry = { openIDConnectConfig?: OpenIDConnectConfig; }; +export type AppSyncAuthConfigurationLambdaEntry = { + authenticationType: 'AWS_LAMBDA'; + lambdaAuthorizerConfig?: LambdaConfig; +}; + export interface ApiKeyConfig { description?: string; apiKeyExpirationDays: number; @@ -47,6 +54,11 @@ export interface OpenIDConnectConfig { authTTL?: number; } +export interface LambdaConfig { + lambdaFunction: string; + ttlSeconds?: number; +} + export interface AppSyncFunctionConfigurationProvider extends IConstruct { readonly arn: string; readonly functionId: string; diff --git a/packages/amplify-headless-interface/src/interface/api/add.ts b/packages/amplify-headless-interface/src/interface/api/add.ts index 081eaf07e57..593bbee4c6d 100644 --- a/packages/amplify-headless-interface/src/interface/api/add.ts +++ b/packages/amplify-headless-interface/src/interface/api/add.ts @@ -122,7 +122,8 @@ export type AppSyncAuthType = | AppSyncAPIKeyAuthType | AppSyncAWSIAMAuthType | AppSyncCognitoUserPoolsAuthType - | AppSyncOpenIDConnectAuthType; + | AppSyncOpenIDConnectAuthType + | AppSyncLambdaAuthType; /** * Specifies that the AppSync API should be secured using an API key. @@ -163,3 +164,12 @@ export interface AppSyncOpenIDConnectAuthType { openIDAuthTTL?: string; openIDIatTTL?: string; } + +/** + * Specifies that the AppSync API should be secured using Lambda. + */ + export interface AppSyncLambdaAuthType { + mode: 'AWS_LAMBDA'; + lambdaFunction: string; + ttlSeconds?: string; +} \ No newline at end of file diff --git a/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts b/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts index 01787185539..102ab59bf79 100644 --- a/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts +++ b/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts @@ -348,17 +348,6 @@ place .graphql files in a directory at ${schemaDirPath}`); return transformerOutput; } -async function addGraphQLAuthRequirement(context, authType) { - return await context.amplify.invokePluginMethod(context, 'api', undefined, 'addGraphQLAuthorizationMode', [ - context, - { - authType: authType, - printLeadText: true, - authSettings: undefined, - }, - ]); -} - function getProjectBucket(context) { const projectDetails = context.amplify.getProjectDetails(); const projectBucket = projectDetails.amplifyMeta.providers ? projectDetails.amplifyMeta.providers[providerName].DeploymentBucketName : ''; @@ -452,6 +441,12 @@ export type ProjectOptions = { }; export async function buildAPIProject(opts: ProjectOptions) { + const schema = opts.projectConfig.schema.toString(); + // Skip building the project if the schema is blank + if (!schema) { + return; + } + const builtProject = await _buildProject(opts); if (opts.projectDirectory && !opts.dryRun) { diff --git a/packages/amplify-provider-awscloudformation/src/push-resources.ts b/packages/amplify-provider-awscloudformation/src/push-resources.ts index 165776d8be3..de21d6353ba 100644 --- a/packages/amplify-provider-awscloudformation/src/push-resources.ts +++ b/packages/amplify-provider-awscloudformation/src/push-resources.ts @@ -390,7 +390,9 @@ export async function run(context: $TSContext, resourceDefinition: $TSObject, re //check for auth resources and remove deployment secret for push resources - .filter(resource => resource.category === 'auth' && resource.service === 'Cognito' && resource.providerPlugin === 'awscloudformation') + .filter( + resource => resource.category === 'auth' && resource.service === 'Cognito' && resource.providerPlugin === 'awscloudformation', + ) .map(({ category, resourceName }) => context.amplify.removeDeploymentSecrets(context, category, resourceName)); await adminModelgen(context, resources); @@ -1163,3 +1165,4 @@ function rollbackLambdaLayers(layerResources: $TSAny[]) { stateManager.setMeta(projectRoot, meta); } } + diff --git a/packages/amplify-provider-awscloudformation/src/transform-graphql-schema.ts b/packages/amplify-provider-awscloudformation/src/transform-graphql-schema.ts index c506f7937a9..f0ebbee0586 100644 --- a/packages/amplify-provider-awscloudformation/src/transform-graphql-schema.ts +++ b/packages/amplify-provider-awscloudformation/src/transform-graphql-schema.ts @@ -513,7 +513,6 @@ export async function transformGraphQLSchema(context, options) { featureFlags: ff, sanityCheckRules: sanityCheckRulesList, }; - const transformerOutput = await buildAPIProject(buildConfig); context.print.success(`GraphQL schema compiled successfully.\n\nEdit your schema at ${schemaFilePath} or \ @@ -526,17 +525,6 @@ place .graphql files in a directory at ${schemaDirPath}`); return transformerOutput; } -async function addGraphQLAuthRequirement(context, authType) { - return await context.amplify.invokePluginMethod(context, 'api', undefined, 'addGraphQLAuthorizationMode', [ - context, - { - authType: authType, - printLeadText: true, - authSettings: undefined, - }, - ]); -} - function getProjectBucket(context) { const projectDetails = context.amplify.getProjectDetails(); const projectBucket = projectDetails.amplifyMeta.providers ? projectDetails.amplifyMeta.providers[providerName].DeploymentBucketName : ''; diff --git a/packages/graphql-auth-transformer/src/AuthRule.ts b/packages/graphql-auth-transformer/src/AuthRule.ts index 2ea6854ed0a..17563baa644 100644 --- a/packages/graphql-auth-transformer/src/AuthRule.ts +++ b/packages/graphql-auth-transformer/src/AuthRule.ts @@ -1,5 +1,5 @@ -export type AuthStrategy = 'owner' | 'groups' | 'public' | 'private'; -export type AuthProvider = 'apiKey' | 'iam' | 'oidc' | 'userPools'; +export type AuthStrategy = 'owner' | 'groups' | 'public' | 'private' | 'custom'; +export type AuthProvider = 'apiKey' | 'iam' | 'oidc' | 'userPools' | 'function'; export type ModelQuery = 'get' | 'list'; export type ModelMutation = 'create' | 'update' | 'delete'; export type ModelOperation = 'create' | 'update' | 'delete' | 'read'; diff --git a/packages/graphql-auth-transformer/src/ModelAuthTransformer.ts b/packages/graphql-auth-transformer/src/ModelAuthTransformer.ts index 6e41670abd6..1d8ed98c631 100644 --- a/packages/graphql-auth-transformer/src/ModelAuthTransformer.ts +++ b/packages/graphql-auth-transformer/src/ModelAuthTransformer.ts @@ -84,7 +84,7 @@ import { OWNER_AUTH_STRATEGY, GROUPS_AUTH_STRATEGY, DEFAULT_OWNER_FIELD, AUTH_NO * attributes of the records using conditional expressions. This will likely * be via a new argument such as "groupsField". */ -export type AppSyncAuthMode = 'API_KEY' | 'AMAZON_COGNITO_USER_POOLS' | 'AWS_IAM' | 'OPENID_CONNECT'; +export type AppSyncAuthMode = 'API_KEY' | 'AMAZON_COGNITO_USER_POOLS' | 'AWS_IAM' | 'OPENID_CONNECT' | 'AWS_LAMBDA'; export type AppSyncAuthConfiguration = { defaultAuthentication: AppSyncAuthConfigurationEntry; additionalAuthenticationProviders: Array; @@ -94,6 +94,7 @@ export type AppSyncAuthConfigurationEntry = { apiKeyConfig?: ApiKeyConfig; userPoolConfig?: UserPoolConfig; openIDConnectConfig?: OpenIDConnectConfig; + lambdaAuthorizerConfig?: LambdaAuthorizerConfig; }; export type ApiKeyConfig = { description?: string; @@ -110,6 +111,10 @@ export type OpenIDConnectConfig = { iatTTL?: number; authTTL?: number; }; +export type LambdaAuthorizerConfig = { + lambdaFunction: string; + ttlSeconds?: number; +}; const validateAuthModes = (authConfig: AppSyncAuthConfiguration) => { let additionalAuthModes = []; @@ -123,7 +128,7 @@ const validateAuthModes = (authConfig: AppSyncAuthConfiguration) => { for (let i = 0; i < authModes.length; i++) { const mode = authModes[i]; - if (mode !== 'API_KEY' && mode !== 'AMAZON_COGNITO_USER_POOLS' && mode !== 'AWS_IAM' && mode !== 'OPENID_CONNECT') { + if (mode !== 'API_KEY' && mode !== 'AMAZON_COGNITO_USER_POOLS' && mode !== 'AWS_IAM' && mode !== 'OPENID_CONNECT' && mode !== 'AWS_LAMBDA') { throw new Error(`Invalid auth mode ${mode}`); } }