From 54495ad40dfacaaf8e8520136649e2b3eb445c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Thu, 25 Jun 2026 17:20:58 +0100 Subject: [PATCH 1/6] comment --- mpapi-next-gen/docs/dynamo-tables.md | 2 ++ .../src/common/subscriptionEvent.ts | 3 ++ mpapi-next-gen/src/common/userSubscription.ts | 36 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 mpapi-next-gen/docs/dynamo-tables.md create mode 100644 mpapi-next-gen/src/common/userSubscription.ts diff --git a/mpapi-next-gen/docs/dynamo-tables.md b/mpapi-next-gen/docs/dynamo-tables.md new file mode 100644 index 00000000..ab187715 --- /dev/null +++ b/mpapi-next-gen/docs/dynamo-tables.md @@ -0,0 +1,2 @@ + +mobile-purchases-PROD-user-subscriptions diff --git a/mpapi-next-gen/src/common/subscriptionEvent.ts b/mpapi-next-gen/src/common/subscriptionEvent.ts index ebcf018d..340105f2 100644 --- a/mpapi-next-gen/src/common/subscriptionEvent.ts +++ b/mpapi-next-gen/src/common/subscriptionEvent.ts @@ -1,5 +1,8 @@ import { App, Stage } from '../common/appIdentity'; +// This class abstract the records in the dynamo table +// mobile-purchases-PROD-subscription-events-v2 + export class SubscriptionEvent { subscriptionId: string; timestampAndType: string; diff --git a/mpapi-next-gen/src/common/userSubscription.ts b/mpapi-next-gen/src/common/userSubscription.ts new file mode 100644 index 00000000..12f2e771 --- /dev/null +++ b/mpapi-next-gen/src/common/userSubscription.ts @@ -0,0 +1,36 @@ +import { DynamoDbTable } from '@aws/dynamodb-data-mapper'; +import { App, Stage } from '../common/appIdentity'; + +// This class abstract the records in the dynamo table +// mobile-purchases-PROD-user-subscriptions + +export class UserSubscription { + userId: string; + subscriptionId: string; + creationTimestamp: string; + + constructor( + userId: string, + subscriptionId: string, + creationTimestamp: string, + ) { + this.userId = userId; + this.subscriptionId = subscriptionId; + this.creationTimestamp = creationTimestamp; + } + + get [DynamoDbTable]() { + return `${App}-${Stage}-user-subscriptions`; + } +} + +// Note: +// UserSubscriptionEmpty is a convenience class for when you need to create +// an empty UserSubscription object. But the type should be used in place +// of UserSubscription. + +export class UserSubscriptionEmpty extends UserSubscription { + constructor() { + super('', '', ''); + } +} From f306d132147ddb1925ff5ec68e8b2af78cb69d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Fri, 26 Jun 2026 08:55:47 +0100 Subject: [PATCH 2/6] introduce ssmParameters --- mpapi-next-gen/src/common/ssmParameters.ts | 33 ++++++++++++++++++++++ mpapi-next-gen/src/lambdas/googleOauth2.ts | 27 +++++------------- 2 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 mpapi-next-gen/src/common/ssmParameters.ts diff --git a/mpapi-next-gen/src/common/ssmParameters.ts b/mpapi-next-gen/src/common/ssmParameters.ts new file mode 100644 index 00000000..4dae3e59 --- /dev/null +++ b/mpapi-next-gen/src/common/ssmParameters.ts @@ -0,0 +1,33 @@ +import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'; + +const ssmClient = new SSMClient({ + region: process.env.AWS_REGION || 'us-east-1', +}); + +export async function getParameterValue( + parameterName: string, +): Promise { + /* + the input value `parameterName` is expected to be the full name in AWS + for instance: + `/mobile-purchases/${Stage}/google-oauth-lambda/google.serviceAccountJson` + */ + + try { + const command = new GetParameterCommand({ + Name: parameterName, + WithDecryption: true, + }); + + const response = await ssmClient.send(command); + + if (!response.Parameter?.Value) { + throw new Error('[df35dc74] no credentials found in SSM'); + } + + return response.Parameter.Value; + } catch (error) { + console.error('[5446b6c6] error retrieving credentials from SSM:', error); + throw error; + } +} diff --git a/mpapi-next-gen/src/lambdas/googleOauth2.ts b/mpapi-next-gen/src/lambdas/googleOauth2.ts index 5f5982d6..3d74d266 100644 --- a/mpapi-next-gen/src/lambdas/googleOauth2.ts +++ b/mpapi-next-gen/src/lambdas/googleOauth2.ts @@ -1,17 +1,13 @@ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { APIGatewayProxyEvent } from 'aws-lambda'; import { Stage } from '../common/appIdentity'; -import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'; import { JWT } from 'google-auth-library'; +import { getParameterValue } from '../common/ssmParameters'; const s3Client = new S3Client({ region: process.env.AWS_REGION || 'us-east-1', }); -const ssmClient = new SSMClient({ - region: process.env.AWS_REGION || 'us-east-1', -}); - interface GoogleServiceAccountCredentials { type: string; project_id: string; @@ -27,23 +23,14 @@ interface GoogleServiceAccountCredentials { async function getGoogleCredentials(): Promise { const parameterName = `/mobile-purchases/${Stage}/google-oauth-lambda/google.serviceAccountJson`; - try { - const command = new GetParameterCommand({ - Name: parameterName, - WithDecryption: true, - }); - - const response = await ssmClient.send(command); - - if (!response.Parameter?.Value) { - throw new Error('No credentials found in SSM'); - } - - // Parse the JSON string from SSM - return JSON.parse(response.Parameter.Value); + const value = await getParameterValue(parameterName); + return JSON.parse(value); } catch (error) { - console.error('Error retrieving credentials from SSM:', error); + console.error( + '[de62945b] Error retrieving google credentials from SSM:', + error, + ); throw error; } } From fd2c3ba1dd2cc5eada403fc35bb8000dfd1f2f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Fri, 26 Jun 2026 10:04:56 +0100 Subject: [PATCH 3/6] use @aws-sdk/client-cloudwatch --- mpapi-next-gen/package.json | 1 + mpapi-next-gen/yarn.lock | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/mpapi-next-gen/package.json b/mpapi-next-gen/package.json index cd46c7ff..4e432111 100644 --- a/mpapi-next-gen/package.json +++ b/mpapi-next-gen/package.json @@ -16,6 +16,7 @@ "validate": "yarn lint:check && yarn format:check" }, "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.1075.0", "@aws-sdk/client-dynamodb": "^3.1073.0", "@aws-sdk/client-s3": "^3.1071.0", "@aws-sdk/client-sqs": "^3.1073.0", diff --git a/mpapi-next-gen/yarn.lock b/mpapi-next-gen/yarn.lock index 38b73cd7..6cbd1b45 100644 --- a/mpapi-next-gen/yarn.lock +++ b/mpapi-next-gen/yarn.lock @@ -84,6 +84,23 @@ "@smithy/types" "^4.14.3" tslib "^2.6.2" +"@aws-sdk/client-cloudwatch@^3.1075.0": + version "3.1075.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-cloudwatch/-/client-cloudwatch-3.1075.0.tgz#cf3a0a067cc61356ae5daf4c11b1c2ed189915ec" + integrity sha512-Kuc9K+YBw8y43izN5Yz7eE3m3WZ1ea6ynULA94qn0dCd8lX6PKwlG3q7UEKfUr8iDvh02Dz6AdYrGYJFWD8M0g== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.23" + "@aws-sdk/credential-provider-node" "^3.972.58" + "@aws-sdk/types" "^3.973.13" + "@smithy/core" "^3.24.6" + "@smithy/fetch-http-handler" "^5.4.6" + "@smithy/middleware-compression" "^4.4.6" + "@smithy/node-http-handler" "^4.7.6" + "@smithy/types" "^4.14.3" + tslib "^2.6.2" + "@aws-sdk/client-dynamodb@^3.1073.0": version "3.1075.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1075.0.tgz#4a7b10266175da3ef89ec8e019d2478677db417c" @@ -895,6 +912,16 @@ dependencies: tslib "^2.6.2" +"@smithy/middleware-compression@^4.4.6": + version "4.5.2" + resolved "https://registry.yarnpkg.com/@smithy/middleware-compression/-/middleware-compression-4.5.2.tgz#e730ce1ea3b049da99b2f819ddd9603b36725b8e" + integrity sha512-gub8H0OcmWMjuAp94S7IgXV3WaD2psuaiiL2XQtvkrjT7CKJT/cW7v37bNncdiAvUci5a+IiBGn7ugj1qZ7lfw== + dependencies: + "@smithy/core" "^3.26.0" + "@smithy/types" "^4.15.0" + fflate "0.8.1" + tslib "^2.6.2" + "@smithy/node-http-handler@^4.7.6": version "4.8.2" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz#bd7fc4d60220d4629111784e3c5c1d76fa55499f" @@ -2126,6 +2153,11 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" +fflate@0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.1.tgz#1ed92270674d2ad3c73f077cd0acf26486dae6c9" + integrity sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" From 4d783f2b02e037fdf39694ce11feb07d6a78168d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Fri, 26 Jun 2026 10:21:13 +0100 Subject: [PATCH 4/6] @aws-sdk/lib-dynamodb --- mpapi-next-gen/package.json | 1 + mpapi-next-gen/yarn.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/mpapi-next-gen/package.json b/mpapi-next-gen/package.json index 4e432111..0dbed06b 100644 --- a/mpapi-next-gen/package.json +++ b/mpapi-next-gen/package.json @@ -21,6 +21,7 @@ "@aws-sdk/client-s3": "^3.1071.0", "@aws-sdk/client-sqs": "^3.1073.0", "@aws-sdk/client-ssm": "^3.1071.0", + "@aws-sdk/lib-dynamodb": "^3.1075.0", "@aws-sdk/util-dynamodb": "^3.996.5", "google-auth-library": "^10.7.0" }, diff --git a/mpapi-next-gen/yarn.lock b/mpapi-next-gen/yarn.lock index 6cbd1b45..afb6f266 100644 --- a/mpapi-next-gen/yarn.lock +++ b/mpapi-next-gen/yarn.lock @@ -312,6 +312,17 @@ mnemonist "0.38.3" tslib "^2.6.2" +"@aws-sdk/lib-dynamodb@^3.1075.0": + version "3.1075.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1075.0.tgz#273588fc97cd707cc72947f81b0b371d68763565" + integrity sha512-3OxmMDgk8wvK6QOaVrQuOq9t6xlgS5047aDPxAZxTSwGBcEMc6FtiamtoK3kfClfOt7k3t+NWIo0bIfhWQ4Pzg== + dependencies: + "@aws-sdk/core" "^3.974.23" + "@aws-sdk/util-dynamodb" "^3.996.5" + "@smithy/core" "^3.24.6" + "@smithy/types" "^4.14.3" + tslib "^2.6.2" + "@aws-sdk/middleware-endpoint-discovery@^3.972.19": version "3.972.19" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.19.tgz#affa05f6ac297a8eff7eeae5650fd08baf16b5f4" From 42a855c4dd18d73e34418515befa89494e384275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Fri, 26 Jun 2026 11:05:59 +0100 Subject: [PATCH 5/6] expand userSubscription --- mpapi-next-gen/src/common/userSubscription.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mpapi-next-gen/src/common/userSubscription.ts b/mpapi-next-gen/src/common/userSubscription.ts index 12f2e771..530a8ffc 100644 --- a/mpapi-next-gen/src/common/userSubscription.ts +++ b/mpapi-next-gen/src/common/userSubscription.ts @@ -19,9 +19,13 @@ export class UserSubscription { this.creationTimestamp = creationTimestamp; } - get [DynamoDbTable]() { + static getTableName(): string { return `${App}-${Stage}-user-subscriptions`; } + + get [DynamoDbTable]() { + return UserSubscription.getTableName(); + } } // Note: From c4e070b2908fd1e62523c83e72e8d865c194776d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Fri, 26 Jun 2026 11:12:40 +0100 Subject: [PATCH 6/6] introduce new common code --- mpapi-next-gen/src/common/apiGatewayHttp.ts | 31 ++++++ mpapi-next-gen/src/common/guIdentityApi.ts | 106 ++++++++++++++++++++ mpapi-next-gen/src/common/platform.ts | 11 ++ mpapi-next-gen/src/common/softOptIns.ts | 15 +++ 4 files changed, 163 insertions(+) create mode 100644 mpapi-next-gen/src/common/apiGatewayHttp.ts create mode 100644 mpapi-next-gen/src/common/guIdentityApi.ts create mode 100644 mpapi-next-gen/src/common/platform.ts create mode 100644 mpapi-next-gen/src/common/softOptIns.ts diff --git a/mpapi-next-gen/src/common/apiGatewayHttp.ts b/mpapi-next-gen/src/common/apiGatewayHttp.ts new file mode 100644 index 00000000..b709f235 --- /dev/null +++ b/mpapi-next-gen/src/common/apiGatewayHttp.ts @@ -0,0 +1,31 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; + +export type QueryParameters = Record; + +export type PathParameters = Record; + +export type HttpRequestHeaders = Record; + +export const HTTPResponses: Record = { + OK: { statusCode: 200, body: '{"status": 200, "message": "OK"}' }, + INVALID_REQUEST: { + statusCode: 400, + body: '{"status": 400, "message": "INVALID_REQUEST"}', + }, + UNAUTHORISED: { + statusCode: 401, + body: '{"status": 401, "message": "UNAUTHORISED"}', + }, + FORBIDDEN: { + statusCode: 403, + body: '{"status": 403, "message": "FORBIDDEN"}', + }, + NOT_FOUND: { + statusCode: 404, + body: '{"status": 404, "message": "NOT_FOUND"}', + }, + INTERNAL_ERROR: { + statusCode: 500, + body: '{"status": 500, "message": "INTERNAL_SERVER_ERROR"}', + }, +}; diff --git a/mpapi-next-gen/src/common/guIdentityApi.ts b/mpapi-next-gen/src/common/guIdentityApi.ts new file mode 100644 index 00000000..43ac1f50 --- /dev/null +++ b/mpapi-next-gen/src/common/guIdentityApi.ts @@ -0,0 +1,106 @@ +import type { HttpRequestHeaders } from './apiGatewayHttp'; +import { Stage } from './appIdentity'; +import { getParameterValueUsingAppStageStackConvention } from './ssmParameters'; +import OktaJwtVerifier from '@okta/jwt-verifier'; + +interface OktaJwtVerifierClaims { + scp: [string]; + legacy_identity_id: string; +} + +interface OktaStageParameters { + issuer: string; + expectedAud: string; + scope: string; +} + +export interface UserIdResolution { + status: + | 'incorrect-token' + | 'incorrect-scope' + | 'missing-identity-id' + | 'success'; + userId: null | string; +} + +export async function getIdentityApiKey(): Promise { + return await getParameterValueUsingAppStageStackConvention( + 'mp-soft-opt-in-identity-api-key', + ); +} + +export async function getMembershipAccountId(): Promise { + return await getParameterValueUsingAppStageStackConvention( + 'membershipAccountId', + ); +} + +export async function getIdentityUrl(): Promise { + return await getParameterValueUsingAppStageStackConvention( + 'mp-soft-opt-in-identity-user-consent-domain-url', + ); +} + +export function getAuthToken(headers: HttpRequestHeaders): string | undefined { + return (headers['Authorization'] ?? headers['authorization'])?.replace( + 'Bearer ', + '', + ); +} + +function getOktaStageParameters(stage: string): OktaStageParameters { + if (stage === 'PROD') { + return { + issuer: 'https://profile.theguardian.com/oauth2/aus3xgj525jYQRowl417', + expectedAud: 'https://profile.theguardian.com/', + scope: 'guardian.mobile-purchases-api.update.self', + }; + } else { + return { + issuer: + 'https://profile.code.dev-theguardian.com/oauth2/aus3v9gla95Toj0EE0x7', + expectedAud: 'https://profile.code.dev-theguardian.com/', + scope: 'guardian.mobile-purchases-api.update.self', + }; + } +} + +export async function getUserId( + headers: HttpRequestHeaders, +): Promise { + const oktaparams = getOktaStageParameters(Stage); + + const issuer = oktaparams.issuer; + const expectedAud = oktaparams.expectedAud; + const scope = oktaparams.scope; + + const oktaJwtVerifier = new OktaJwtVerifier({ + issuer: issuer, + }); + + const accessTokenString = getAuthToken(headers); + + try { + return await oktaJwtVerifier + .verifyAccessToken(accessTokenString || '', expectedAud) + .then((payload) => { + const claims = payload.claims as unknown as OktaJwtVerifierClaims; + + if (claims.scp.includes(scope)) { + if (claims.legacy_identity_id) { + return { + status: 'success', + userId: claims.legacy_identity_id, + }; + } else { + return { status: 'missing-identity-id', userId: null }; + } + } else { + return { status: 'incorrect-scope', userId: null }; + } + }); + } catch (error) { + console.log(`error: ${error}`); + return { status: 'incorrect-token', userId: null }; + } +} diff --git a/mpapi-next-gen/src/common/platform.ts b/mpapi-next-gen/src/common/platform.ts new file mode 100644 index 00000000..7afc4965 --- /dev/null +++ b/mpapi-next-gen/src/common/platform.ts @@ -0,0 +1,11 @@ +export enum Platform { + Ios = 'ios', + Android = 'android', + DailyEdition = 'newsstand', + IosEdition = 'ios-edition', + AndroidEdition = 'android-edition', + IosPuzzles = 'ios-puzzles', + AndroidPuzzles = 'android-puzzles', + IosFeast = 'ios-feast', + AndroidFeast = 'android-feast', +} diff --git a/mpapi-next-gen/src/common/softOptIns.ts b/mpapi-next-gen/src/common/softOptIns.ts new file mode 100644 index 00000000..cb3c9690 --- /dev/null +++ b/mpapi-next-gen/src/common/softOptIns.ts @@ -0,0 +1,15 @@ +import { Platform } from './platform'; + +export type SoftOptInEventProductName = 'InAppPurchase' | 'FeastInAppPurchase'; + +export const mapPlatformToSoftOptInProductName = ( + platform: string | undefined, +): SoftOptInEventProductName => { + switch (platform) { + case Platform.IosFeast: + case Platform.AndroidFeast: + return 'FeastInAppPurchase'; + default: + return 'InAppPurchase'; + } +};