diff --git a/README.md b/README.md index 498fd05..77bf55c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: +- v0.1.6: add `util.authType()`, derived from the identity of the request; a host installs the request through the new `setResolverContext` export - v0.1.5: add `rds` `beginsWith`, `between` and `size` conditions, table aliases and `sql` template `where` clauses; invalid input now raises AWS's validation error instead of a raw `TypeError`, and the wildcard conditions require a string - v0.1.4: fix `rds` query builders with empty `orderBy`, `where` and `values` inputs, `contains` wildcards and multiple conditions per column; `orderBy` `dir` is now restricted to `ASC`/`DESC` - v0.1.3: fix `rds` query builders with star columns, empty `where` objects and nullable `limit`/`offset` diff --git a/__tests__/__snapshots__/authType.test.js.snap b/__tests__/__snapshots__/authType.test.js.snap new file mode 100644 index 0000000..304d9be --- /dev/null +++ b/__tests__/__snapshots__/authType.test.js.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`util.authType an identity that names no single mode a subject, an issuer and claims beside a username fall back to an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType an identity that names no single mode a user pool identity missing its default auth strategy falls back to an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType an identity that names no single mode a user pool identity missing its source IP falls back to an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType an identity that names no single mode an IAM identity missing its user ARN falls back to an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType an identity that names no single mode an IAM identity whose source IP is null falls back to an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType an identity that names no single mode an identity holding only claims falls back to an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType the mode of an identity a null identity is authorized by an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType the mode of an identity a request carrying no identity is authorized by an API key 1`] = ` +{ + "authType": "API Key Authorization", +} +`; + +exports[`util.authType the mode of an identity a user pool identity is user pool authorized 1`] = ` +{ + "authType": "User Pool Authorization", +} +`; + +exports[`util.authType the mode of an identity a user pool identity without groups is user pool authorized 1`] = ` +{ + "authType": "User Pool Authorization", +} +`; + +exports[`util.authType the mode of an identity an IAM identity from a Cognito identity pool is IAM authorized 1`] = ` +{ + "authType": "IAM Authorization", +} +`; + +exports[`util.authType the mode of an identity an IAM identity is IAM authorized even though it also carries a username 1`] = ` +{ + "authType": "IAM Authorization", +} +`; + +exports[`util.authType the mode of an identity an identity carrying a resolver context comes from a Lambda authorizer 1`] = ` +{ + "authType": "Lambda Authorization", +} +`; + +exports[`util.authType the mode of an identity an identity holding a subject, an issuer and claims is OIDC authorized 1`] = ` +{ + "authType": "Open ID Connect Authorization", +} +`; + +exports[`util.authType the mode of an identity an identity with no keys comes from a Lambda authorizer 1`] = ` +{ + "authType": "Lambda Authorization", +} +`; diff --git a/__tests__/__snapshots__/index.test.js.snap b/__tests__/__snapshots__/index.test.js.snap index 54ac0ad..9234ca8 100644 --- a/__tests__/__snapshots__/index.test.js.snap +++ b/__tests__/__snapshots__/index.test.js.snap @@ -411,3 +411,5 @@ exports[`dynamodb helpers toStringSet 1`] = ` ], } `; + +exports[`general utilities authType 1`] = `"API Key Authorization"`; diff --git a/__tests__/authType.test.js b/__tests__/authType.test.js new file mode 100644 index 0000000..beefd2a --- /dev/null +++ b/__tests__/authType.test.js @@ -0,0 +1,158 @@ +// `util.authType()` names the authorization mode of the request. AppSync gives each mode its own +// `ctx.identity` shape, so the mode is read back from the identity; every shape below comes from +// the AppSync resolver context reference, together with the mixtures that sit between two modes. +import { checkResolverValid } from "./helpers"; +import { util, setResolverContext } from ".."; + +const RESOLVER = ` +export function request(ctx) { + return { authType: util.authType() }; +} + +export function response(ctx) {} +`; + +const authTypeFor = (identity) => checkResolverValid(RESOLVER, { identity }, "request"); + +const COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123"; +const COGNITO_CLAIMS = { + sub: "sub-1", + iss: COGNITO_ISSUER, + "cognito:username": "alice", + token_use: "id", +}; + +const LAMBDA_IDENTITY = { resolverContext: { userId: "alice", role: "admin" } }; + +const IAM_IDENTITY = { + accountId: "123456789012", + cognitoIdentityPoolId: null, + cognitoIdentityId: null, + sourceIp: ["1.2.3.4"], + username: "AKIAIOSFODNN7EXAMPLE", + userArn: "arn:aws:iam::123456789012:user/alice", + cognitoIdentityAuthType: null, + cognitoIdentityAuthProvider: null, +}; + +const IAM_IDENTITY_POOL_IDENTITY = { + ...IAM_IDENTITY, + cognitoIdentityPoolId: "us-east-1:00000000-0000-0000-0000-000000000000", + cognitoIdentityId: "us-east-1:11111111-1111-1111-1111-111111111111", + username: "AROAEXAMPLE:CognitoIdentityCredentials", + userArn: "arn:aws:sts::123456789012:assumed-role/authRole/CognitoIdentityCredentials", + cognitoIdentityAuthType: "authenticated", + cognitoIdentityAuthProvider: "cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123", +}; + +const USER_POOL_IDENTITY = { + sourceIp: ["1.2.3.4"], + username: "alice", + groups: ["admins"], + sub: "sub-1", + issuer: COGNITO_ISSUER, + claims: COGNITO_CLAIMS, + defaultAuthStrategy: "ALLOW", +}; + +const OIDC_IDENTITY = { + sub: "sub-1", + issuer: "https://accounts.example.com", + claims: { sub: "sub-1", iss: "https://accounts.example.com" }, +}; + +const withoutKey = (identity, key) => { + const { [key]: _removed, ...rest } = identity; + return rest; +}; + +describe("util.authType", () => { + describe("the mode of an identity", () => { + test("a request carrying no identity is authorized by an API key", async () => { + await checkResolverValid(RESOLVER, {}, "request"); + }); + + test("a null identity is authorized by an API key", async () => { + await authTypeFor(null); + }); + + test("an identity carrying a resolver context comes from a Lambda authorizer", async () => { + await authTypeFor(LAMBDA_IDENTITY); + }); + + // a Lambda authorizer may return no resolver context at all, which leaves an empty identity + test("an identity with no keys comes from a Lambda authorizer", async () => { + await authTypeFor({}); + }); + + test("an IAM identity is IAM authorized even though it also carries a username", async () => { + await authTypeFor(IAM_IDENTITY); + }); + + test("an IAM identity from a Cognito identity pool is IAM authorized", async () => { + await authTypeFor(IAM_IDENTITY_POOL_IDENTITY); + }); + + test("a user pool identity is user pool authorized", async () => { + await authTypeFor(USER_POOL_IDENTITY); + }); + + test("a user pool identity without groups is user pool authorized", async () => { + await authTypeFor(withoutKey(USER_POOL_IDENTITY, "groups")); + }); + + test("an identity holding a subject, an issuer and claims is OIDC authorized", async () => { + await authTypeFor(OIDC_IDENTITY); + }); + }); + + describe("an identity that names no single mode", () => { + test("an IAM identity missing its user ARN falls back to an API key", async () => { + await authTypeFor(withoutKey(IAM_IDENTITY, "userArn")); + }); + + test("an IAM identity whose source IP is null falls back to an API key", async () => { + await authTypeFor({ ...IAM_IDENTITY, sourceIp: null }); + }); + + test("a user pool identity missing its default auth strategy falls back to an API key", async () => { + await authTypeFor(withoutKey(USER_POOL_IDENTITY, "defaultAuthStrategy")); + }); + + test("a user pool identity missing its source IP falls back to an API key", async () => { + await authTypeFor(withoutKey(USER_POOL_IDENTITY, "sourceIp")); + }); + + test("an identity holding only claims falls back to an API key", async () => { + await authTypeFor({ claims: OIDC_IDENTITY.claims }); + }); + + // `username` belongs to the user pool identity, which makes this an incomplete user pool + // identity rather than an OIDC one + test("a subject, an issuer and claims beside a username fall back to an API key", async () => { + await authTypeFor({ ...OIDC_IDENTITY, username: "alice" }); + }); + }); + + // NOT compared against AWS: `setResolverContext` is the seam a host uses to hand the request to + // the utils, so these pin our own contract with the runtime rather than any AWS behaviour. They + // also show that the module a test imports is the module the resolver code imports. + describe("the request context a host installs", () => { + afterEach(() => setResolverContext(null)); + + test("reports an API key until a context is installed", () => { + expect(util.authType()).toBe("API Key Authorization"); + }); + + test("follows the context installed for the current request", () => { + setResolverContext({ identity: LAMBDA_IDENTITY }); + expect(util.authType()).toBe("Lambda Authorization"); + }); + + test("falls back to an API key once the context is cleared", () => { + setResolverContext({ identity: IAM_IDENTITY }); + setResolverContext(null); + expect(util.authType()).toBe("API Key Authorization"); + }); + }); +}); diff --git a/__tests__/helpers.js b/__tests__/helpers.js index bf96a02..a63f4a8 100644 --- a/__tests__/helpers.js +++ b/__tests__/helpers.js @@ -3,7 +3,7 @@ import { fileURLToPath, pathToFileURL } from "url"; import { dirname, resolve } from "path"; import { AppSyncClient, EvaluateCodeCommand } from "@aws-sdk/client-appsync"; -import { util } from ".."; +import { util, setResolverContext } from ".."; import * as ddb from "../dynamodb"; import * as rds from "../rds"; @@ -86,6 +86,9 @@ export const checkResolverValid = async (code, context, functionName) => { const fn = module[functionName]; transformContextForAppSync(context); + // mirrors the host installing the request before calling the resolver, so that utils reading + // the request (`util.authType()`) see what they see on AWS + setResolverContext(context); try { result = fn(context); } catch (e) { @@ -93,6 +96,8 @@ export const checkResolverValid = async (code, context, functionName) => { throw e; } result = {"message": e.message} + } finally { + setResolverContext(null); } } expect(result).toMatchSnapshot(); @@ -110,7 +115,14 @@ export const checkValid = async (s, context, postProcess) => { if (process.env.TEST_TARGET === "AWS_CLOUD") { result = await runOnAWS(s, context); } else { - result = eval(s); + // AWS evaluates the expression inside a resolver that receives `context`; give the utils the + // same view of the request, matching the default an absent context gets on the AWS branch + setResolverContext(context ?? {}); + try { + result = eval(s); + } finally { + setResolverContext(null); + } } if (postProcess) { result = postProcess(result); diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 6b2dcfd..260e6e2 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -14,6 +14,10 @@ describe("general utilities", () => { // cannot test on AWS due to random nature expect(util.autoId()).toBeTruthy(); }); + + test("authType", async () => { + await checkValid(`util.authType()`); + }); }); describe("time utilities", () => { diff --git a/index.js b/index.js index 70d1a5d..cf5c2d9 100644 --- a/index.js +++ b/index.js @@ -117,6 +117,76 @@ export const dynamodbUtils = { const FILTER_CONTAINS = "contains"; +// The strings `util.authType()` returns, see +// https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference.html +const AUTH_TYPE_API_KEY = "API Key Authorization"; +const AUTH_TYPE_IAM = "IAM Authorization"; +const AUTH_TYPE_USER_POOL = "User Pool Authorization"; +const AUTH_TYPE_OIDC = "Open ID Connect Authorization"; +const AUTH_TYPE_LAMBDA = "Lambda Authorization"; + +// AppSync populates `ctx.identity` with a shape that is specific to the authorization mode of the +// API, so the mode can be recovered from the identity alone. `keys` is the complete set of keys +// the mode's identity may carry and `required` the ones it always carries, both taken from the +// recordings described on `authTypeFromIdentity`. +const IDENTITY_SCHEMAS = [ + { + authType: AUTH_TYPE_IAM, + keys: ["accountId", "cognitoIdentityPoolId", "cognitoIdentityId", "sourceIp", "username", + "userArn", "cognitoIdentityAuthType", "cognitoIdentityAuthProvider"], + required: ["accountId", "sourceIp", "username", "userArn"], + }, + { + authType: AUTH_TYPE_USER_POOL, + keys: ["sourceIp", "username", "groups", "sub", "issuer", "claims", "defaultAuthStrategy"], + required: ["sourceIp", "username", "sub", "issuer", "claims", "defaultAuthStrategy"], + }, + { + authType: AUTH_TYPE_OIDC, + keys: ["sub", "issuer", "claims"], + required: ["sub", "issuer", "claims"], + }, + { + // `resolverContext` is what the authorizer returned, and an authorizer may return nothing at + // all, so an identity with no keys whatsoever is a Lambda identity to AWS + authType: AUTH_TYPE_LAMBDA, + keys: ["resolverContext"], + required: [], + }, +]; + +// Recorded from `EvaluateCode` against every identity shape in the AppSync resolver context +// reference plus the ambiguous mixtures: an identity belongs to a mode when all of its keys are +// keys of that mode and every key the mode requires is present and not null. Foreign keys rule a +// mode out rather than being ignored, so `{sub, issuer, claims, username}` is an incomplete user +// pool identity and not an OIDC one. The modes are mutually exclusive under those two rules, so +// the order below only makes the outcome deterministic. Anything unmatched, an absent identity +// included, is an API key request, which is the mode that populates no identity at all. +function authTypeFromIdentity(identity) { + if (identity === null || typeof identity !== "object" || Array.isArray(identity)) { + return AUTH_TYPE_API_KEY; + } + const keys = Object.keys(identity); + for (const schema of IDENTITY_SCHEMAS) { + const noForeignKeys = keys.every((key) => schema.keys.includes(key)); + const hasRequired = schema.required.every((key) => identity[key] !== null && identity[key] !== undefined); + if (noForeignKeys && hasRequired) { + return schema.authType; + } + } + return AUTH_TYPE_API_KEY; +} + +// The context of the request being resolved. Every function on `util` is otherwise pure, but +// `util.authType()` describes the request while having no access to the `ctx` the resolver +// receives, so the host installs the context here before handing control to the resolver. Shared +// by every request in the process, hence the host sets it immediately before the call. +let resolverContext = null; + +export function setResolverContext(ctx) { + resolverContext = ctx ?? null; +} + export const util = { autoId: function() { return uuidv4(); @@ -137,6 +207,9 @@ export const util = { unauthorized: function() { throw new AppSyncUserError("Unauthorized", "UnauthorizedException") }, + authType: function() { + return authTypeFromIdentity(resolverContext?.identity); + }, time: { nowFormatted: function(pattern) { // TODO: not completely correct, but close enough probably diff --git a/package.json b/package.json index 56c4a73..6d4743a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.5", + "version": "0.1.6", "description": "Implementation of the AppSync utils helpers", "type": "module", "main": "index.js",