diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1d08bc9c6e..c4f5c4a841 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -78,7 +78,7 @@ services: - dynamodb-local restart: always ports: - - "8002:8002" + - "8002:8001" environment: - DYNAMO_ENDPOINT=http://dynamodb-local:8000 - AWS_REGION=eu-south-1 diff --git a/package.json b/package.json index 9abb632b87..e7ac4acb3b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "start:delegation": "turbo start --filter pagopa-interop-delegation-process", "start:delegation-readmodel-writer": "turbo start --filter pagopa-interop-delegation-readmodel-writer", "start:datalake-data-export": "turbo start --filter pagopa-interop-datalake-data-export", + "start:authorization-server": "turbo start --filter pagopa-interop-authorization-server", "test": "turbo test", "build": "turbo build", "check": "turbo check", diff --git a/packages/api-clients/open-api/authorizationServerApi.yml b/packages/api-clients/open-api/authorizationServerApi.yml new file mode 100644 index 0000000000..3fcbf37b1e --- /dev/null +++ b/packages/api-clients/open-api/authorizationServerApi.yml @@ -0,0 +1,215 @@ +openapi: 3.0.3 +info: + title: Interoperability Authorization Server Micro Service + description: Provides endpoints to request an interoperability token + version: "0.1.0" + contact: + name: API Support + url: "http://www.example.com/support" + email: support@example.com + termsOfService: "http://swagger.io/terms/" + x-api-id: an x-api-id + x-summary: an x-summary +servers: + - url: "/authorization-server" + description: Interoperability Authorization Server +tags: + - name: auth + description: Get security information + externalDocs: + description: Find out more + url: http://swagger.io + - name: health + description: Verify service status + externalDocs: + description: Find out more + url: http://swagger.io +paths: + "/token.oauth2": + post: + tags: + - auth + summary: Create a new access token + description: Return the generated access token + operationId: createToken + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/AccessTokenRequest" + responses: + "200": + description: The Access token + headers: + Cache-Control: + schema: + type: string + default: no-cache, no-store + description: no-cache, no-store + "X-Rate-Limit-Limit": + schema: + type: integer + description: Max allowed requests within time interval + "X-Rate-Limit-Remaining": + schema: + type: integer + description: Remaining requests within time interval + "X-Rate-Limit-Interval": + schema: + type: integer + description: Time interval in milliseconds. Allowed requests will be constantly replenished during the interval. At the end of the interval the max allowed requests will be available + content: + application/json: + schema: + $ref: "#/components/schemas/ClientCredentialsResponse" + "400": + description: Bad request + x-noqa: RFC6749 + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + "401": + description: Unauthorized + x-noqa: RFC6749 + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + headers: + "X-Rate-Limit-Limit": + schema: + type: integer + description: Max allowed requests within time interval + "X-Rate-Limit-Remaining": + schema: + type: integer + description: Remaining requests within time interval + "X-Rate-Limit-Interval": + schema: + type: integer + description: Time interval in milliseconds. Allowed requests will be constantly replenished during the interval. At the end of the interval the max allowed requests will be available + /status: + get: + security: [] + summary: Returns the application status + description: Returns the application status + operationId: get_status + tags: + - health + responses: + "200": + description: This is the valid status from the server. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" +components: + schemas: + AccessTokenRequest: + type: object + required: + - client_assertion + - client_assertion_type + - grant_type + properties: + client_id: + type: string + example: e58035ce-c753-4f72-b613-46f8a17b71cc + client_assertion: + type: string + format: jws + client_assertion_type: + type: string + example: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + grant_type: + type: string + enum: + - client_credentials + TokenType: + type: string + description: Represents the token type + enum: + - Bearer + ClientCredentialsResponse: + type: object + required: + - access_token + - token_type + - expires_in + properties: + access_token: + type: string + format: jws + token_type: + $ref: "#/components/schemas/TokenType" + expires_in: + type: integer + format: int32 + maximum: 600 + Problem: + properties: + type: + description: URI reference of type definition + type: string + status: + description: The HTTP status code generated by the origin server for this occurrence of the problem. + example: 400 + exclusiveMaximum: true + format: int32 + maximum: 600 + minimum: 100 + type: integer + title: + description: A short, summary of the problem type. Written in english and readable + example: Service Unavailable + maxLength: 64 + pattern: "^[ -~]{0,64}$" + type: string + correlationId: + description: Unique identifier of the request + example: "53af4f2d-0c87-41ef-a645-b726a821852b" + maxLength: 64 + type: string + detail: + description: A human readable explanation of the problem. + example: Request took too long to complete. + maxLength: 4096 + pattern: "^.{0,1024}$" + type: string + errors: + type: array + minItems: 0 + items: + $ref: "#/components/schemas/ProblemError" + additionalProperties: false + required: + - type + - status + - title + - errors + ProblemError: + properties: + code: + description: Internal code of the error + example: 123-4567 + minLength: 8 + maxLength: 8 + pattern: "^[0-9]{3}-[0-9]{4}$" + type: string + detail: + description: A human readable explanation specific to this occurrence of the problem. + example: Parameter not valid + maxLength: 4096 + pattern: "^.{0,1024}$" + type: string + required: + - code + - detail diff --git a/packages/api-clients/src/index.ts b/packages/api-clients/src/index.ts index 2a5174bbba..cc32f1e16f 100644 --- a/packages/api-clients/src/index.ts +++ b/packages/api-clients/src/index.ts @@ -11,3 +11,4 @@ export * as apiGatewayApi from "./apiGatewayApi.js"; export * as notifierApi from "./generated/notifierApi.js"; export * as delegationApi from "./generated/delegationApi.js"; export * from "./selfcareClients.js"; +export * as authorizationServerApi from "./generated/authorizationServerApi.js"; diff --git a/packages/authorization-platformstate-writer/test/sample.integration.test.ts b/packages/authorization-platformstate-writer/test/sample.integration.test.ts deleted file mode 100644 index a4e96fa400..0000000000 --- a/packages/authorization-platformstate-writer/test/sample.integration.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/no-floating-promises */ -import { fail } from "assert"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { - buildDynamoDBTables, - deleteDynamoDBTables, -} from "pagopa-interop-commons-test"; -import { config } from "./utils.js"; - -describe("integration tests V2 events", async () => { - if (!config) { - fail(); - } - const dynamoDBClient = new DynamoDBClient({ - endpoint: `http://localhost:${config.tokenGenerationReadModelDbPort}`, - }); - beforeEach(async () => { - await buildDynamoDBTables(dynamoDBClient); - }); - afterEach(async () => { - await deleteDynamoDBTables(dynamoDBClient); - }); - const mockDate = new Date(); - beforeAll(() => { - vi.useFakeTimers(); - vi.setSystemTime(mockDate); - }); - afterAll(() => { - vi.useRealTimers(); - }); - - it("sample", () => { - expect(1).toBe(1); - }); -}); diff --git a/packages/authorization-server/.env b/packages/authorization-server/.env new file mode 100644 index 0000000000..eec5e7bf87 --- /dev/null +++ b/packages/authorization-server/.env @@ -0,0 +1,40 @@ +HOST=0.0.0.0 +PORT=3300 +LOG_LEVEL=info + +AWS_CONFIG_FILE=aws.config.local +TOKEN_GENERATION_READMODEL_TABLE_NAME_PLATFORM="platform-states" +TOKEN_GENERATION_READMODEL_TABLE_NAME_TOKEN_GENERATION="token-generation-states" + +AWS_REGION="eu-south-1" + +CLIENT_ASSERTION_AUDIENCE="test.interop.pagopa.it" + +GENERATED_INTEROP_TOKEN_ALGORITHM="RS256" +GENERATED_INTEROP_TOKEN_KID="ffcc9b5b-4612-49b1-9374-9d203a3834f2" +GENERATED_INTEROP_TOKEN_ISSUER="test" +GENERATED_INTEROP_TOKEN_M2M_AUDIENCE="test.interop.pagopa.it" +GENERATED_INTEROP_TOKEN_M2M_DURATION_SECONDS=60 +TOKEN_AUDITING_TOPIC="authorization-server.generated-jwt" + +RATE_LIMITER_BURST_PERCENTAGE="0" +RATE_LIMITER_MAX_REQUESTS="10" +RATE_LIMITER_RATE_INTERVAL_MILLIS="1000" +RATE_LIMITER_REDIS_HOST="localhost" +RATE_LIMITER_REDIS_PORT="6379" +RATE_LIMITER_TIMEOUT_MILLIS="300" + +PRODUCER_KAFKA_CLIENT_ID="authorization-server" +PRODUCER_KAFKA_BROKERS="localhost:9092" +PRODUCER_KAFKA_DISABLE_AWS_IAM_AUTH="true" + +S3_BUCKET=interop-local-bucket +S3_CUSTOM_SERVER=true +S3_SERVER_HOST=http://localhost +S3_SERVER_PORT=9000 + +KAFKA_CLIENT_ID="authorization-server" +KAFKA_GROUP_ID="authorization-server-group" +KAFKA_BROKERS="localhost:9092" +KAFKA_DISABLE_AWS_IAM_AUTH="true" + diff --git a/packages/authorization-server/Dockerfile b/packages/authorization-server/Dockerfile new file mode 100644 index 0000000000..6c1c4d89ea --- /dev/null +++ b/packages/authorization-server/Dockerfile @@ -0,0 +1,51 @@ +FROM node:20.14.0-slim@sha256:5e8ac65a0231d76a388683d07ca36a9769ab019a85d85169fe28e206f7a3208e as build + +RUN corepack enable + +WORKDIR /app +COPY package.json /app/ +COPY pnpm-lock.yaml /app/ +COPY pnpm-workspace.yaml /app/ +COPY .npmrc /app/ + +COPY ./packages/authorization-server/package.json /app/packages/authorization-server/package.json +COPY ./packages/commons/package.json /app/packages/commons/package.json +COPY ./packages/models/package.json /app/packages/models/package.json +COPY ./packages/client-assertion-validation/package.json /app/packages/client-assertion-validation/package.json +COPY ./packages/kafka-iam-auth/package.json /app/packages/kafka-iam-auth/package.json +COPY ./packages/api-clients/package.json /app/packages/api-clients/package.json + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +COPY tsconfig.json /app/ +COPY turbo.json /app/ +COPY ./packages/authorization-server /app/packages/authorization-server +COPY ./packages/commons /app/packages/commons +COPY ./packages/models /app/packages/models +COPY ./packages/client-assertion-validation /app/packages/client-assertion-validation +COPY ./packages/kafka-iam-auth /app/packages/kafka-iam-auth +COPY ./packages/api-clients /app/packages/api-clients + +RUN pnpm build && \ + rm -rf /app/node_modules/.modules.yaml && \ + rm -rf /app/node_modules/.cache && \ + mkdir /out && \ + cp -a --parents -t /out \ + node_modules packages/authorization-server/node_modules \ + package*.json packages/authorization-server/package*.json \ + packages/commons/ \ + packages/models/ \ + packages/client-assertion-validation/ \ + packages/kafka-iam-auth/ \ + packages/api-clients \ + packages/authorization-server/dist && \ + find /out -exec touch -h --date=@0 {} \; + +FROM node:20.14.0-slim@sha256:5e8ac65a0231d76a388683d07ca36a9769ab019a85d85169fe28e206f7a3208e as final + +COPY --from=build /out /app + +WORKDIR /app/packages/authorization-server +EXPOSE 3300 + +CMD [ "node", "." ] diff --git a/packages/authorization-server/aws.config.local b/packages/authorization-server/aws.config.local new file mode 100644 index 0000000000..042982b52d --- /dev/null +++ b/packages/authorization-server/aws.config.local @@ -0,0 +1,12 @@ +[default] +aws_access_key_id=testawskey +aws_secret_access_key=testawssecret +region=eu-south-1 +services=local + +[services local] +dynamodb= + endpoint_url=http://localhost:8085 + +kms= + endpoint_url=http://localhost:4566 diff --git a/packages/authorization-server/package.json b/packages/authorization-server/package.json new file mode 100644 index 0000000000..0fd0589788 --- /dev/null +++ b/packages/authorization-server/package.json @@ -0,0 +1,53 @@ +{ + "name": "pagopa-interop-authorization-server", + "version": "1.0.0", + "description": "PagoPA Interoperability service for authorization", + "main": "dist", + "type": "module", + "scripts": { + "test": "vitest", + "lint": "eslint . --ext .ts,.tsx", + "lint:autofix": "eslint . --ext .ts,.tsx --fix", + "format:check": "prettier --check src", + "format:write": "prettier --write src", + "start": "tsx -r 'dotenv-flow/config' --watch ./src/index.ts", + "build": "tsc", + "check": "tsc --project tsconfig.check.json" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "devDependencies": { + "@pagopa/eslint-config": "3.0.0", + "@protobuf-ts/runtime": "2.9.4", + "@types/express": "4.17.21", + "@types/node": "20.14.6", + "@types/uuid": "9.0.8", + "pagopa-interop-commons-test": "workspace:*", + "prettier": "2.8.8", + "tsx": "4.19.1", + "typescript": "5.4.5", + "vitest": "1.6.0", + "uuid": "10.0.0", + "jose": "5.9.4" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "3.637.0", + "@aws-sdk/client-kms": "3.600.0", + "@aws-sdk/util-dynamodb": "3.637.0", + "@zodios/core": "10.9.6", + "@zodios/express": "10.6.1", + "axios": "1.7.4", + "connection-string": "4.4.0", + "dotenv-flow": "4.1.0", + "express": "4.20.0", + "kafka-iam-auth": "workspace:*", + "openapi-zod-client": "1.18.1", + "pagopa-interop-api-clients": "workspace:*", + "pagopa-interop-client-assertion-validation": "workspace:*", + "pagopa-interop-commons": "workspace:*", + "pagopa-interop-models": "workspace:*", + "ts-pattern": "5.2.0", + "zod": "3.23.8" + } +} diff --git a/packages/authorization-server/src/app.ts b/packages/authorization-server/src/app.ts new file mode 100644 index 0000000000..31e2d6bca3 --- /dev/null +++ b/packages/authorization-server/src/app.ts @@ -0,0 +1,24 @@ +import { + contextMiddleware, + loggerMiddleware, + zodiosCtx, +} from "pagopa-interop-commons"; +import express from "express"; +import healthRouter from "./routers/HealthRouter.js"; +import authorizationServerRouter from "./routers/AuthorizationServerRouter.js"; + +const serviceName = "authorization-server"; + +const app = zodiosCtx.app(); + +// Disable the "X-Powered-By: Express" HTTP header for security reasons. +// See https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#recommendation_16 +app.disable("x-powered-by"); + +app.use(healthRouter); +app.use(contextMiddleware(serviceName, false)); +app.use(express.urlencoded({ extended: true })); +app.use(loggerMiddleware(serviceName)); +app.use(authorizationServerRouter(zodiosCtx)); + +export default app; diff --git a/packages/authorization-server/src/config/config.ts b/packages/authorization-server/src/config/config.ts new file mode 100644 index 0000000000..8e81b097a3 --- /dev/null +++ b/packages/authorization-server/src/config/config.ts @@ -0,0 +1,43 @@ +import { + FileManagerConfig, + KafkaProducerConfig, + RedisRateLimiterConfig, + S3Config, + AuthorizationServerTokenGenerationConfig, + HTTPServerConfig, + LoggerConfig, +} from "pagopa-interop-commons"; +import { z } from "zod"; + +const AuthorizationServerConfig = HTTPServerConfig.and(LoggerConfig) + .and(RedisRateLimiterConfig) + .and(KafkaProducerConfig) + .and(FileManagerConfig) + .and(S3Config) + .and( + z + .object({ + TOKEN_AUDITING_TOPIC: z.string(), + }) + .transform((c) => ({ + tokenAuditingTopic: c.TOKEN_AUDITING_TOPIC, + })) + ) + .and(AuthorizationServerTokenGenerationConfig) + .and( + z + .object({ + TOKEN_GENERATION_READMODEL_TABLE_NAME_TOKEN_GENERATION: z.string(), + }) + .transform((c) => ({ + tokenGenerationStatesTable: + c.TOKEN_GENERATION_READMODEL_TABLE_NAME_TOKEN_GENERATION, + })) + ); + +export type AuthorizationServerConfig = z.infer< + typeof AuthorizationServerConfig +>; + +export const config: AuthorizationServerConfig = + AuthorizationServerConfig.parse(process.env); diff --git a/packages/authorization-server/src/index.ts b/packages/authorization-server/src/index.ts new file mode 100644 index 0000000000..160d2c4711 --- /dev/null +++ b/packages/authorization-server/src/index.ts @@ -0,0 +1,7 @@ +import { genericLogger } from "pagopa-interop-commons"; +import { config } from "./config/config.js"; +import app from "./app.js"; + +app.listen(config.port, config.host, () => { + genericLogger.info(`listening on ${config.host}:${config.port}`); +}); diff --git a/packages/authorization-server/src/model/domain/errors.ts b/packages/authorization-server/src/model/domain/errors.ts new file mode 100644 index 0000000000..f263765cb1 --- /dev/null +++ b/packages/authorization-server/src/model/domain/errors.ts @@ -0,0 +1,124 @@ +import { + ApiError, + ClientId, + ClientKindTokenStates, + makeApiProblemBuilder, + TokenGenerationStatesClientKidPK, + TokenGenerationStatesClientKidPurposePK, +} from "pagopa-interop-models"; + +export const errorCodes = { + clientAssertionRequestValidationFailed: "0001", + clientAssertionValidationFailed: "0002", + clientAssertionSignatureValidationFailed: "0003", + kafkaAuditingFailed: "0004", + fallbackAuditFailed: "0005", + tokenGenerationStatesEntryNotFound: "0006", + invalidTokenClientKidPurposeEntry: "0007", + keyTypeMismatch: "0008", + unexpectedTokenGenerationStatesEntry: "0009", + platformStateValidationFailed: "0010", +}; + +export type ErrorCodes = keyof typeof errorCodes; + +export const makeApiProblem = makeApiProblemBuilder(errorCodes); + +export function clientAssertionRequestValidationFailed( + clientId: string | undefined +): ApiError { + return new ApiError({ + detail: `Client assertion request validation failed for request by client ${clientId}`, + code: "clientAssertionRequestValidationFailed", + title: "Client assertion request validation failed", + }); +} + +export function clientAssertionValidationFailed( + clientId: string | undefined +): ApiError { + return new ApiError({ + detail: `Client assertion validation failed for clientId: ${clientId}`, + code: "clientAssertionValidationFailed", + title: "Client assertion validation failed", + }); +} + +export function clientAssertionSignatureValidationFailed( + clientId: string | undefined +): ApiError { + return new ApiError({ + detail: `Client assertion signature validation failed for client ${clientId}`, + code: "clientAssertionSignatureValidationFailed", + title: "Client assertion signature validation failed", + }); +} + +export function kafkaAuditingFailed(): ApiError { + return new ApiError({ + detail: "Kafka auditing failed ", + code: "kafkaAuditingFailed", + title: "Kafka auditing failed", + }); +} + +export function fallbackAuditFailed(clientId: ClientId): ApiError { + return new ApiError({ + detail: `Fallback audit failed for client ${clientId}`, + code: "fallbackAuditFailed", + title: "Fallback audit failed", + }); +} + +export function tokenGenerationStatesEntryNotFound( + pk: TokenGenerationStatesClientKidPurposePK | TokenGenerationStatesClientKidPK +): ApiError { + return new ApiError({ + detail: `Entry with PK ${pk} not found in token-generation-states table`, + code: "tokenGenerationStatesEntryNotFound", + title: "token-generation-states entry not found", + }); +} + +export function invalidTokenClientKidPurposeEntry( + pk: TokenGenerationStatesClientKidPurposePK | TokenGenerationStatesClientKidPK +): ApiError { + return new ApiError({ + detail: `Missing data in client-kid-purpose entry from token-generation-states table. Primary key: ${pk}`, + code: "invalidTokenClientKidPurposeEntry", + title: "Invalid token client-kid-purpose entry", + }); +} + +export function keyTypeMismatch( + pk: + | TokenGenerationStatesClientKidPurposePK + | TokenGenerationStatesClientKidPK, + clientKind: ClientKindTokenStates +): ApiError { + return new ApiError({ + detail: `Token-generation entry ${pk} can't have client kind: ${clientKind}`, + code: "keyTypeMismatch", + title: "Key type mismatch", + }); +} + +export function unexpectedTokenGenerationStatesEntry( + pk: TokenGenerationStatesClientKidPK | TokenGenerationStatesClientKidPurposePK +): ApiError { + return new ApiError({ + detail: `Unexpected token-generation-states entry, primary key: ${pk}`, + code: "unexpectedTokenGenerationStatesEntry", + title: "Unexpected token-generation-states entry", + }); +} + +export function platformStateValidationFailed( + details: string[] +): ApiError { + return new ApiError({ + detail: `Platform state validation failed - reasons: ${details}`, + code: "platformStateValidationFailed", + title: "Platform state validation failed", + }); +} diff --git a/packages/authorization-server/src/model/domain/models.ts b/packages/authorization-server/src/model/domain/models.ts new file mode 100644 index 0000000000..09fefba9f0 --- /dev/null +++ b/packages/authorization-server/src/model/domain/models.ts @@ -0,0 +1,5 @@ +export interface InteropTokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} diff --git a/packages/authorization-server/src/routers/AuthorizationServerRouter.ts b/packages/authorization-server/src/routers/AuthorizationServerRouter.ts new file mode 100644 index 0000000000..ec49766192 --- /dev/null +++ b/packages/authorization-server/src/routers/AuthorizationServerRouter.ts @@ -0,0 +1,104 @@ +import { + ExpressContext, + fromAppContext, + initFileManager, + initRedisRateLimiter, + InteropTokenGenerator, + rateLimiterHeadersFromStatus, + ZodiosContext, + zodiosValidationErrorToApiProblem, +} from "pagopa-interop-commons"; +import { tooManyRequestsError } from "pagopa-interop-models"; +import { authorizationServerApi } from "pagopa-interop-api-clients"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { initProducer } from "kafka-iam-auth"; +import { ZodiosEndpointDefinitions } from "@zodios/core"; +import { ZodiosRouter } from "@zodios/express"; +import { makeApiProblem } from "../model/domain/errors.js"; +import { authorizationServerErrorMapper } from "../utilities/errorMappers.js"; +import { tokenServiceBuilder } from "../services/tokenService.js"; +import { config } from "../config/config.js"; + +const dynamoDBClient = new DynamoDBClient({}); +const redisRateLimiter = await initRedisRateLimiter({ + limiterGroup: "AUTHSERVER", + maxRequests: config.rateLimiterMaxRequests, + rateInterval: config.rateLimiterRateInterval, + burstPercentage: config.rateLimiterBurstPercentage, + redisHost: config.rateLimiterRedisHost, + redisPort: config.rateLimiterRedisPort, + timeout: config.rateLimiterTimeout, +}); +const producer = await initProducer(config, config.tokenAuditingTopic); +const fileManager = initFileManager(config); + +const tokenGenerator = new InteropTokenGenerator({ + generatedInteropTokenKid: config.generatedInteropTokenKid, + generatedInteropTokenIssuer: config.generatedInteropTokenIssuer, + generatedInteropTokenM2MAudience: config.generatedInteropTokenM2MAudience, + generatedInteropTokenM2MDurationSeconds: + config.generatedInteropTokenM2MDurationSeconds, +}); + +const tokenService = tokenServiceBuilder({ + tokenGenerator, + dynamoDBClient, + redisRateLimiter, + producer, + fileManager, +}); + +const authorizationServerRouter = ( + ctx: ZodiosContext +): ZodiosRouter => { + const authorizationServerRouter = ctx.router( + authorizationServerApi.authApi.api, + { + validationErrorHandler: zodiosValidationErrorToApiProblem, + } + ); + authorizationServerRouter.post("/token.oauth2", async (req, res) => { + const ctx = fromAppContext(req.ctx); + + try { + const tokenResult = await tokenService.generateToken( + req.body, + ctx.correlationId, + ctx.logger + ); + + const headers = rateLimiterHeadersFromStatus( + tokenResult.rateLimiterStatus + ); + res.set(headers); + + if (tokenResult.limitReached) { + const errorRes = makeApiProblem( + tooManyRequestsError(tokenResult.rateLimitedTenantId), + authorizationServerErrorMapper, + ctx.logger, + ctx.correlationId + ); + + return res.status(errorRes.status).send(errorRes); + } + + return res.status(200).send({ + access_token: tokenResult.token.serialized, + token_type: "Bearer", + expires_in: tokenResult.token.payload.exp, + }); + } catch (err) { + const errorRes = makeApiProblem( + err, + authorizationServerErrorMapper, + ctx.logger, + ctx.correlationId + ); + return res.status(errorRes.status).send(errorRes); + } + }); + return authorizationServerRouter; +}; + +export default authorizationServerRouter; diff --git a/packages/authorization-server/src/routers/HealthRouter.ts b/packages/authorization-server/src/routers/HealthRouter.ts new file mode 100644 index 0000000000..6658f9ab36 --- /dev/null +++ b/packages/authorization-server/src/routers/HealthRouter.ts @@ -0,0 +1,8 @@ +import { zodiosRouter } from "@zodios/express"; +import { authorizationServerApi } from "pagopa-interop-api-clients"; + +const healthRouter = zodiosRouter(authorizationServerApi.healthApi.api); + +healthRouter.get("/status", async (_, res) => res.status(200).send()); + +export default healthRouter; diff --git a/packages/authorization-server/src/services/tokenService.ts b/packages/authorization-server/src/services/tokenService.ts new file mode 100644 index 0000000000..e582016a03 --- /dev/null +++ b/packages/authorization-server/src/services/tokenService.ts @@ -0,0 +1,412 @@ +import { + validateClientKindAndPlatformState, + validateRequestParameters, + verifyClientAssertion, + verifyClientAssertionSignature, +} from "pagopa-interop-client-assertion-validation"; +import { authorizationServerApi } from "pagopa-interop-api-clients"; +import { + clientKidPrefix, + clientKidPurposePrefix, + clientKindTokenStates, + DescriptorId, + EServiceId, + generateId, + genericInternalError, + makeTokenGenerationStatesClientKidPK, + makeTokenGenerationStatesClientKidPurposePK, + TenantId, + TokenGenerationStatesClientEntry, + TokenGenerationStatesClientKidPK, + TokenGenerationStatesClientKidPurposePK, + TokenGenerationStatesClientPurposeEntry, + TokenGenerationStatesGenericEntry, + unsafeBrandId, + GeneratedTokenAuditDetails, + GSIPKEServiceIdDescriptorId, + ClientAssertion, + FullTokenGenerationStatesClientPurposeEntry, +} from "pagopa-interop-models"; +import { + DynamoDBClient, + GetItemCommand, + GetItemCommandOutput, + GetItemInput, +} from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { match } from "ts-pattern"; +import { + FileManager, + formatDateyyyyMMdd, + formatTimehhmmss, + InteropApiToken, + InteropConsumerToken, + InteropTokenGenerator, + Logger, + RateLimiter, + RateLimiterStatus, +} from "pagopa-interop-commons"; +import { initProducer } from "kafka-iam-auth"; +import { config } from "../config/config.js"; +import { + clientAssertionRequestValidationFailed, + clientAssertionSignatureValidationFailed, + clientAssertionValidationFailed, + fallbackAuditFailed, + invalidTokenClientKidPurposeEntry, + kafkaAuditingFailed, + tokenGenerationStatesEntryNotFound, + keyTypeMismatch, + unexpectedTokenGenerationStatesEntry, + platformStateValidationFailed, +} from "../model/domain/errors.js"; + +export type GenerateTokenReturnType = + | { + limitReached: true; + token: undefined; + rateLimitedTenantId: TenantId; + rateLimiterStatus: Omit; + } + | { + limitReached: false; + token: InteropConsumerToken | InteropApiToken; + rateLimiterStatus: Omit; + }; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function tokenServiceBuilder({ + tokenGenerator, + dynamoDBClient, + redisRateLimiter, + producer, + fileManager, +}: { + tokenGenerator: InteropTokenGenerator; + dynamoDBClient: DynamoDBClient; + redisRateLimiter: RateLimiter; + producer: Awaited>; + fileManager: FileManager; +}) { + return { + async generateToken( + request: authorizationServerApi.AccessTokenRequest, + correlationId: string, + logger: Logger + ): Promise { + const { errors: parametersErrors } = validateRequestParameters({ + client_assertion: request.client_assertion, + client_assertion_type: request.client_assertion_type, + grant_type: request.grant_type, + client_id: request.client_id, + }); + + if (parametersErrors) { + throw clientAssertionRequestValidationFailed(request.client_id); + } + + const { data: jwt, errors: clientAssertionErrors } = + verifyClientAssertion(request.client_assertion, request.client_id); + + if (clientAssertionErrors) { + // TODO double check if errors have to be logged or put inside the error below (check the same for parameters errors) + logger.warn(clientAssertionErrors.map((error) => error.detail)); + throw clientAssertionValidationFailed(request.client_id); + } + + const clientId = jwt.payload.sub; + const kid = jwt.header.kid; + const purposeId = jwt.payload.purposeId; + + const pk = purposeId + ? makeTokenGenerationStatesClientKidPurposePK({ + clientId, + kid, + purposeId, + }) + : makeTokenGenerationStatesClientKidPK({ clientId, kid }); + + const key = await retrieveKey(dynamoDBClient, pk); + + const { errors: clientAssertionSignatureErrors } = + await verifyClientAssertionSignature( + request.client_assertion, + key, + jwt.header.alg + ); + + if (clientAssertionSignatureErrors) { + throw clientAssertionSignatureValidationFailed(request.client_id); + } + + const { errors: platformStateErrors } = + validateClientKindAndPlatformState(key, jwt); + if (platformStateErrors) { + throw platformStateValidationFailed( + platformStateErrors.map((error) => error.detail) + ); + } + + const { limitReached, ...rateLimiterStatus } = + await redisRateLimiter.rateLimitByOrganization(key.consumerId, logger); + if (limitReached) { + return { + limitReached: true, + token: undefined, + rateLimitedTenantId: key.consumerId, + rateLimiterStatus, + }; + } + + return await match(key.clientKind) + .with(clientKindTokenStates.consumer, async () => { + const parsedKey = + FullTokenGenerationStatesClientPurposeEntry.safeParse(key); + if (parsedKey.success) { + const token = await tokenGenerator.generateInteropConsumerToken({ + sub: jwt.payload.sub, + audience: parsedKey.data.descriptorAudience, + purposeId: parsedKey.data.GSIPK_purposeId, + tokenDurationInSeconds: parsedKey.data.descriptorVoucherLifespan, + digest: jwt.payload.digest, + }); + + await publishAudit({ + producer, + generatedToken: token, + key: parsedKey.data, + clientAssertion: jwt, + correlationId, + fileManager, + logger, + }); + + return { + limitReached: false as const, + token, + rateLimiterStatus, + }; + } + throw invalidTokenClientKidPurposeEntry(key.PK); + }) + .with(clientKindTokenStates.api, async () => { + const token = await tokenGenerator.generateInteropApiToken({ + sub: jwt.payload.sub, + consumerId: key.consumerId, + }); + + return { + limitReached: false as const, + token, + rateLimiterStatus, + }; + }) + .exhaustive(); + }, + }; +} + +export const retrieveKey = async ( + dynamoDBClient: DynamoDBClient, + pk: TokenGenerationStatesClientKidPurposePK | TokenGenerationStatesClientKidPK +): Promise< + TokenGenerationStatesClientEntry | TokenGenerationStatesClientPurposeEntry +> => { + const input: GetItemInput = { + Key: { + PK: { S: pk }, + }, + TableName: config.tokenGenerationStatesTable, + }; + + const command = new GetItemCommand(input); + const data: GetItemCommandOutput = await dynamoDBClient.send(command); + + if (!data.Item) { + throw tokenGenerationStatesEntryNotFound(pk); + } else { + const unmarshalled = unmarshall(data.Item); + const tokenGenerationEntry = + TokenGenerationStatesGenericEntry.safeParse(unmarshalled); + + if (!tokenGenerationEntry.success) { + throw genericInternalError( + `Unable to parse token generation entry item: result ${JSON.stringify( + tokenGenerationEntry + )} - data ${JSON.stringify(data)} ` + ); + } + + return match(tokenGenerationEntry.data) + .when( + (entry) => + entry.clientKind === clientKindTokenStates.consumer && + entry.PK.startsWith(clientKidPurposePrefix), + () => { + const clientKidPurposeEntry = + FullTokenGenerationStatesClientPurposeEntry.safeParse( + tokenGenerationEntry.data + ); + if (!clientKidPurposeEntry.success) { + throw invalidTokenClientKidPurposeEntry( + tokenGenerationEntry.data.PK + ); + } + + return clientKidPurposeEntry.data; + } + ) + .when( + (entry) => + entry.clientKind === clientKindTokenStates.consumer && + entry.PK.startsWith(clientKidPrefix), + (entry) => { + throw keyTypeMismatch(entry.PK, entry.clientKind); + } + ) + .when( + (entry) => + entry.clientKind === clientKindTokenStates.api && + entry.PK.startsWith(clientKidPurposePrefix), + (entry) => { + throw keyTypeMismatch(entry.PK, entry.clientKind); + } + ) + .when( + (entry) => + entry.clientKind === clientKindTokenStates.api && + entry.PK.startsWith(clientKidPrefix), + () => tokenGenerationEntry.data as TokenGenerationStatesClientEntry + ) + .otherwise(() => { + throw unexpectedTokenGenerationStatesEntry( + tokenGenerationEntry.data.PK + ); + }); + } +}; + +export const publishAudit = async ({ + producer, + generatedToken, + key, + clientAssertion, + correlationId, + fileManager, + logger, +}: { + producer: Awaited>; + generatedToken: InteropConsumerToken | InteropApiToken; + key: FullTokenGenerationStatesClientPurposeEntry; + clientAssertion: ClientAssertion; + correlationId: string; + fileManager: FileManager; + logger: Logger; +}): Promise => { + const messageBody: GeneratedTokenAuditDetails = { + jwtId: generatedToken.payload.jti, + correlationId, + issuedAt: generatedToken.payload.iat, + clientId: clientAssertion.payload.sub, + organizationId: key.consumerId, + agreementId: key.agreementId, + eserviceId: deconstructGSIPK_eserviceId_descriptorId( + key.GSIPK_eserviceId_descriptorId + ).eserviceId, + descriptorId: deconstructGSIPK_eserviceId_descriptorId( + key.GSIPK_eserviceId_descriptorId + ).descriptorId, + purposeId: key.GSIPK_purposeId, + purposeVersionId: unsafeBrandId(key.purposeVersionId), + algorithm: generatedToken.header.alg, + keyId: generatedToken.header.kid, + audience: generatedToken.payload.aud.join(","), + subject: generatedToken.payload.sub, + notBefore: generatedToken.payload.nbf, + expirationTime: generatedToken.payload.exp, + issuer: generatedToken.payload.iss, + clientAssertion: { + algorithm: clientAssertion.header.alg, + audience: [clientAssertion.payload.aud].flat().join(","), + expirationTime: clientAssertion.payload.exp, + issuedAt: clientAssertion.payload.iat, + issuer: clientAssertion.payload.iss, + jwtId: clientAssertion.payload.jti, + keyId: clientAssertion.header.kid, + subject: clientAssertion.payload.sub, + }, + }; + + try { + const res = await producer.send({ + messages: [ + { + key: generatedToken.payload.jti, + value: JSON.stringify(messageBody), + }, + ], + }); + if (res.length === 0 || res[0].errorCode !== 0) { + throw kafkaAuditingFailed(); + } + } catch (e) { + logger.info("main auditing flow failed, going through fallback"); + await fallbackAudit(messageBody, fileManager, logger); + } +}; + +export const fallbackAudit = async ( + messageBody: GeneratedTokenAuditDetails, + fileManager: FileManager, + logger: Logger +): Promise => { + const date = new Date(); + const ymdDate = formatDateyyyyMMdd(date); + const hmsTime = formatTimehhmmss(date); + + const fileName = `${ymdDate}_${hmsTime}_${generateId()}.ndjson`; + const filePath = `token-details/${ymdDate}`; + + try { + await fileManager.storeBytes( + { + bucket: config.s3Bucket, + path: filePath, + name: fileName, + content: Buffer.from(JSON.stringify(messageBody)), + }, + logger + ); + logger.info("auditing succeeded through fallback"); + } catch { + throw fallbackAuditFailed(messageBody.clientId); + } +}; + +const deconstructGSIPK_eserviceId_descriptorId = ( + gsi: GSIPKEServiceIdDescriptorId +): { eserviceId: EServiceId; descriptorId: DescriptorId } => { + const substrings = gsi.split("#"); + const eserviceId = substrings[0]; + const descriptorId = substrings[1]; + const parsedEserviceId = EServiceId.safeParse(eserviceId); + + if (!parsedEserviceId.success) { + throw genericInternalError( + `Unable to parse extract eserviceId from GSIPKEServiceIdDescriptorId: ${GSIPKEServiceIdDescriptorId}` + ); + } + + const parsedDescriptorId = DescriptorId.safeParse(descriptorId); + + if (!parsedDescriptorId.success) { + throw genericInternalError( + `Unable to parse extract descriptorId from GSIPKEServiceIdDescriptorId: ${GSIPKEServiceIdDescriptorId}` + ); + } + + return { + eserviceId: parsedEserviceId.data, + descriptorId: parsedDescriptorId.data, + }; +}; diff --git a/packages/authorization-server/src/utilities/errorMappers.ts b/packages/authorization-server/src/utilities/errorMappers.ts new file mode 100644 index 0000000000..f97666fb03 --- /dev/null +++ b/packages/authorization-server/src/utilities/errorMappers.ts @@ -0,0 +1,23 @@ +import { constants } from "http2"; +import { ApiError, CommonErrorCodes } from "pagopa-interop-models"; +import { match } from "ts-pattern"; +import { ErrorCodes as LocalErrorCodes } from "../model/domain/errors.js"; + +type ErrorCodes = LocalErrorCodes | CommonErrorCodes; + +const { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_BAD_REQUEST } = + constants; + +export const authorizationServerErrorMapper = ( + error: ApiError +): number => + match(error.code) + .with( + "tokenGenerationStatesEntryNotFound", + "clientAssertionRequestValidationFailed", + "clientAssertionSignatureValidationFailed", + "clientAssertionValidationFailed", + "platformStateValidationFailed", + () => HTTP_STATUS_BAD_REQUEST + ) + .otherwise(() => HTTP_STATUS_INTERNAL_SERVER_ERROR); diff --git a/packages/authorization-server/test/.eslintrc.json b/packages/authorization-server/test/.eslintrc.json new file mode 100644 index 0000000000..6135a5ce08 --- /dev/null +++ b/packages/authorization-server/test/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["../../../.eslintrc.cjs"], + "rules": { + "functional/immutable-data": "off", + "sonarjs/no-identical-functions": "off" + } +} diff --git a/packages/authorization-server/test/authorizationServer.integration.test.ts b/packages/authorization-server/test/authorizationServer.integration.test.ts new file mode 100644 index 0000000000..158b500d10 --- /dev/null +++ b/packages/authorization-server/test/authorizationServer.integration.test.ts @@ -0,0 +1,808 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import crypto from "crypto"; +import { fail } from "assert"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildDynamoDBTables, + deleteDynamoDBTables, + getMockTokenStatesClientPurposeEntry, + getMockPurpose, + getMockPurposeVersion, + getMockTokenStatesClientEntry, + writeTokenStateEntry, + writeTokenStateClientEntry, + getMockClientAssertion, +} from "pagopa-interop-commons-test"; +import { + AgreementId, + ClientId, + clientKindTokenStates, + EServiceId, + GeneratedTokenAuditDetails, + generateId, + itemState, + makeGSIPKKid, + makeTokenGenerationStatesClientKidPK, + makeTokenGenerationStatesClientKidPurposePK, + Purpose, + PurposeId, + purposeVersionState, + TokenGenerationStatesClientEntry, + TokenGenerationStatesClientPurposeEntry, + unsafeBrandId, +} from "pagopa-interop-models"; +import { formatDateyyyyMMdd, genericLogger } from "pagopa-interop-commons"; +import { authorizationServerApi } from "pagopa-interop-api-clients"; +import { config } from "../src/config/config.js"; +import { + clientAssertionRequestValidationFailed, + clientAssertionSignatureValidationFailed, + clientAssertionValidationFailed, + fallbackAuditFailed, + invalidTokenClientKidPurposeEntry, + keyTypeMismatch, + platformStateValidationFailed, + tokenGenerationStatesEntryNotFound, +} from "../src/model/domain/errors.js"; +import { inactiveEService } from "../../client-assertion-validation/dist/errors.js"; +import { + configTokenGenerationStates, + dynamoDBClient, + fileManager, + getMockAccessTokenRequest, + mockKMSClient, + mockProducer, + tokenService, +} from "./utils.js"; + +describe("authorization server tests", () => { + if (!configTokenGenerationStates) { + fail(); + } + beforeEach(async () => { + await buildDynamoDBTables(dynamoDBClient); + mockKMSClient.send.mockImplementation(async () => ({ + Signature: "mock signature", + })); + }); + afterEach(async () => { + await deleteDynamoDBTables(dynamoDBClient); + vi.restoreAllMocks(); + }); + + it("should throw clientAssertionRequestValidationFailed", async () => { + const { jws } = await getMockClientAssertion(); + + const clientId = generateId(); + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion_type: "wrong-client-assertion-type", + client_assertion: jws, + client_id: clientId, + }; + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError(clientAssertionRequestValidationFailed(clientId)); + }); + + it("should throw clientAssertionValidationFailed", async () => { + const { jws } = await getMockClientAssertion({ + standardClaimsOverride: { iat: undefined }, + }); + + const clientId = generateId(); + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError(clientAssertionValidationFailed(clientId)); + }); + + it("should throw tokenGenerationStatesEntryNotFound", async () => { + const purposeId = generateId(); + const clientId = generateId(); + const { jws, clientAssertion } = await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const entryPK = makeTokenGenerationStatesClientKidPurposePK({ + clientId, + kid: clientAssertion.header.kid!, + purposeId, + }); + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError(tokenGenerationStatesEntryNotFound(entryPK)); + }); + + it("should throw invalidTokenClientKidPurposeEntry", async () => { + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion } = await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientPurposeEntry: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + agreementId: undefined, + }; + + await writeTokenStateEntry(tokenClientPurposeEntry, dynamoDBClient); + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + invalidTokenClientKidPurposeEntry(tokenClientPurposeEntry.PK) + ); + }); + + it("should throw keyTypeMismatch - clientKid entry with consumer kind", async () => { + const clientId = generateId(); + const { jws, clientAssertion } = await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPK = makeTokenGenerationStatesClientKidPK({ + clientId, + kid: clientAssertion.header.kid!, + }); + + const tokenClientKidEntry: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(tokenClientKidPK), + clientKind: clientKindTokenStates.consumer, + }; + + await writeTokenStateClientEntry(tokenClientKidEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + keyTypeMismatch(tokenClientKidEntry.PK, clientKindTokenStates.consumer) + ); + }); + + it("should throw keyTypeMismatch - clientKidPurpose entry with api kind", async () => { + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion } = await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + clientKind: clientKindTokenStates.api, + }; + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + keyTypeMismatch(tokenClientKidPurposeEntry.PK, clientKindTokenStates.api) + ); + }); + + it("should throw clientAssertionSignatureValidationFailed", async () => { + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion } = await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const splitJws = jws.split("."); + const jwsWithWrongSignature = `${splitJws[0]}.${splitJws[1]}.wrong-singature`; + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jwsWithWrongSignature, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK); + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + clientAssertionSignatureValidationFailed(request.client_id) + ); + }); + + it("should throw platformStateValidationFailed", async () => { + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + descriptorState: itemState.inactive, + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + platformStateValidationFailed([inactiveEService().detail]) + ); + }); + + it("should block the request because of the rate limiter", async () => { + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + // eslint-disable-next-line functional/no-let + for (let i = 0; i < config.rateLimiterMaxRequests; i++) { + const response = await tokenService.generateToken( + request, + generateId(), + genericLogger + ); + expect(response.limitReached).toBe(false); + expect(response.rateLimiterStatus.remainingRequests).toBe( + config.rateLimiterMaxRequests - i - 1 + ); + } + + const responseAfterLimitExceeded = await tokenService.generateToken( + request, + generateId(), + genericLogger + ); + + expect(responseAfterLimitExceeded).toEqual({ + limitReached: true, + rateLimitedTenantId: tokenClientKidPurposeEntry.consumerId, + token: undefined, + rateLimiterStatus: { + maxRequests: config.rateLimiterMaxRequests, + rateInterval: config.rateLimiterRateInterval, + remainingRequests: 0, + }, + }); + }); + + it("should throw error during token signing - consumer key", async () => { + const uuid = crypto.randomUUID(); + const uuidSpy = vi.spyOn(crypto, "randomUUID"); + uuidSpy.mockReturnValue(uuid); + + mockKMSClient.send.mockImplementationOnce(() => + Promise.resolve({ signature: undefined }) + ); + + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + clientKind: clientKindTokenStates.consumer, + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + Error("JWT Signature failed. Empty signature returned") + ); + }); + + it("should throw tokenSigningFailed - api key", async () => { + const uuid = crypto.randomUUID(); + const uuidSpy = vi.spyOn(crypto, "randomUUID"); + uuidSpy.mockReturnValue(uuid); + + mockKMSClient.send.mockImplementationOnce(() => + Promise.resolve({ signature: undefined }) + ); + + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPK = makeTokenGenerationStatesClientKidPK({ + clientId, + kid: clientAssertion.header.kid!, + }); + + const tokenClientKidEntry: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(tokenClientKidPK), + clientKind: clientKindTokenStates.api, + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateClientEntry(tokenClientKidEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError( + Error("JWT Signature failed. Empty signature returned") + ); + }); + + it("should throw fallbackAuditFailed - consumer key - kafka audit failed and fallback audit failed", async () => { + const uuid = crypto.randomUUID(); + const uuidSpy = vi.spyOn(crypto, "randomUUID"); + uuidSpy.mockReturnValue(uuid); + + mockProducer.send.mockImplementationOnce(async () => Promise.reject()); + vi.spyOn(fileManager, "storeBytes").mockImplementationOnce(() => + Promise.reject() + ); + + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + + expect( + tokenService.generateToken(request, generateId(), genericLogger) + ).rejects.toThrowError(fallbackAuditFailed(clientId)); + }); + + it("should succeed - consumer key - kafka audit failed and fallback audit succeeded", async () => { + mockProducer.send.mockImplementation(async () => Promise.reject()); + + const purposeId = generateId(); + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + customClaims: { purposeId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId, + } + ); + + const tokenClientKidPurposeEntry: TokenGenerationStatesClientPurposeEntry = + { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateEntry(tokenClientKidPurposeEntry, dynamoDBClient); + + const fileListBeforeAudit = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileListBeforeAudit).toHaveLength(0); + + const uuid = crypto.randomUUID(); + const uuidSpy = vi.spyOn(crypto, "randomUUID"); + uuidSpy.mockReturnValue(uuid); + + const correlationId = generateId(); + const response = await tokenService.generateToken( + request, + correlationId, + genericLogger + ); + + const date = new Date(); + const ymdDate = formatDateyyyyMMdd(date); + + const fileListAfterAudit = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileListAfterAudit).toHaveLength(1); + const file = fileListAfterAudit[0]; + const split = file.split("_"); + expect(split[0]).toEqual(`token-details/${ymdDate}/${ymdDate}`); + + const fileContent = await fileManager.get( + config.s3Bucket, + file, + genericLogger + ); + + const decodedFileContent = new TextDecoder().decode(fileContent); + const parsedDecodedFileContent = JSON.parse(decodedFileContent); + + const expectedMessageBody: GeneratedTokenAuditDetails = { + jwtId: generateId(), + correlationId, + issuedAt: parsedDecodedFileContent.issuedAt, + clientId, + organizationId: tokenClientKidPurposeEntry.consumerId, + agreementId: unsafeBrandId( + tokenClientKidPurposeEntry.agreementId! + ), + eserviceId: unsafeBrandId( + tokenClientKidPurposeEntry.GSIPK_eserviceId_descriptorId!.split("#")[0] + ), + descriptorId: unsafeBrandId( + tokenClientKidPurposeEntry.GSIPK_eserviceId_descriptorId!.split("#")[1] + ), + purposeId: tokenClientKidPurposeEntry.GSIPK_purposeId!, + purposeVersionId: tokenClientKidPurposeEntry.purposeVersionId!, + algorithm: "RS256", + keyId: config.generatedInteropTokenKid, + audience: tokenClientKidPurposeEntry.descriptorAudience!.join(","), + subject: clientId, + notBefore: parsedDecodedFileContent.notBefore, + expirationTime: parsedDecodedFileContent.expirationTime, + issuer: config.generatedInteropTokenIssuer, + clientAssertion: { + algorithm: clientAssertion.header.alg, + audience: [clientAssertion.payload.aud].flat().join(","), + expirationTime: clientAssertion.payload.exp!, + issuedAt: clientAssertion.payload.iat!, + issuer: clientAssertion.payload.iss!, + jwtId: clientAssertion.payload.jti!, + keyId: clientAssertion.header.kid!, + subject: unsafeBrandId(clientAssertion.payload.sub!), + }, + }; + expect(parsedDecodedFileContent).toEqual(expectedMessageBody); + expect(response.limitReached).toBe(false); + expect(response.token).toBeDefined(); + expect(response.rateLimiterStatus).toEqual({ + maxRequests: config.rateLimiterMaxRequests, + rateInterval: config.rateLimiterRateInterval, + remainingRequests: config.rateLimiterMaxRequests - 1, + }); + }); + + it("should succeed - consumer key - kafka audit succeeded", async () => { + mockProducer.send.mockImplementationOnce(async () => [ + { topic: config.tokenAuditingTopic, partition: 0, errorCode: 0 }, + ]); + mockKMSClient.send.mockImplementationOnce(async () => ({ + Signature: "mock signature", + })); + + vi.spyOn(mockProducer, "send"); + vi.spyOn(fileManager, "storeBytes"); + + const purpose: Purpose = { + ...getMockPurpose(), + versions: [getMockPurposeVersion(purposeVersionState.active)], + }; + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { + sub: clientId, + }, + customClaims: { purposeId: purpose.id }, + }); + + const tokenClientKidPurposePK = makeTokenGenerationStatesClientKidPurposePK( + { + clientId, + kid: clientAssertion.header.kid!, + purposeId: purpose.id, + } + ); + const tokenClientPurposeEntry: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + consumerId: purpose.consumerId, + GSIPK_purposeId: purpose.id, + purposeState: itemState.active, + purposeVersionId: purpose.versions[0].id, + agreementState: itemState.active, + descriptorState: itemState.active, + GSIPK_clientId: clientId, + GSIPK_kid: makeGSIPKKid(clientAssertion.header.kid!), + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateEntry(tokenClientPurposeEntry, dynamoDBClient); + + const request = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const uuid = crypto.randomUUID(); + const uuidSpy = vi.spyOn(crypto, "randomUUID"); + uuidSpy.mockReturnValue(uuid); + + const correlationId = generateId(); + const result = await tokenService.generateToken( + request, + correlationId, + genericLogger + ); + + expect(result.token).toBeDefined(); + + expect(result.limitReached).toBe(false); + expect(result.token).toBeDefined(); + expect(result.rateLimiterStatus).toEqual({ + maxRequests: config.rateLimiterMaxRequests, + rateInterval: config.rateLimiterRateInterval, + remainingRequests: config.rateLimiterMaxRequests - 1, + }); + + const fileList = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileList).toHaveLength(0); + expect(fileManager.storeBytes).not.toHaveBeenCalled(); + + const actualMessageSent = mockProducer.send.mock.calls[0][0] + .messages[0] as { key: string; value: string }; + + const parsedAuditSent = JSON.parse(actualMessageSent.value); + + const expectedMessageBody: GeneratedTokenAuditDetails = { + jwtId: generateId(), + correlationId, + issuedAt: parsedAuditSent.issuedAt, + clientId, + organizationId: tokenClientPurposeEntry.consumerId, + agreementId: unsafeBrandId( + tokenClientPurposeEntry.agreementId! + ), + eserviceId: unsafeBrandId( + tokenClientPurposeEntry.GSIPK_eserviceId_descriptorId!.split("#")[0] + ), + descriptorId: unsafeBrandId( + tokenClientPurposeEntry.GSIPK_eserviceId_descriptorId!.split("#")[1] + ), + purposeId: tokenClientPurposeEntry.GSIPK_purposeId!, + purposeVersionId: tokenClientPurposeEntry.purposeVersionId!, + algorithm: "RS256", + keyId: config.generatedInteropTokenKid, + audience: tokenClientPurposeEntry.descriptorAudience!.join(","), + subject: clientId, + notBefore: parsedAuditSent.notBefore, + expirationTime: parsedAuditSent.expirationTime, + issuer: config.generatedInteropTokenIssuer, + clientAssertion: { + algorithm: clientAssertion.header.alg, + audience: [clientAssertion.payload.aud].flat().join(","), + expirationTime: clientAssertion.payload.exp!, + issuedAt: clientAssertion.payload.iat!, + issuer: clientAssertion.payload.iss!, + jwtId: clientAssertion.payload.jti!, + keyId: clientAssertion.header.kid!, + subject: unsafeBrandId(clientAssertion.payload.sub!), + }, + }; + + expect(parsedAuditSent).toEqual(expectedMessageBody); + }); + + it("should succeed - api key - no audit", async () => { + vi.spyOn(fileManager, "storeBytes"); + + const clientId = generateId(); + + const { jws, clientAssertion, publicKeyEncodedPem } = + await getMockClientAssertion({ + standardClaimsOverride: { sub: clientId }, + }); + + const request: authorizationServerApi.AccessTokenRequest = { + ...(await getMockAccessTokenRequest()), + client_assertion: jws, + client_id: clientId, + }; + + const tokenClientKidK = makeTokenGenerationStatesClientKidPK({ + clientId, + kid: clientAssertion.header.kid!, + }); + + const tokenClientKidEntry: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(tokenClientKidK), + clientKind: clientKindTokenStates.api, + publicKey: publicKeyEncodedPem, + }; + + await writeTokenStateClientEntry(tokenClientKidEntry, dynamoDBClient); + + const fileListBefore = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileListBefore).toHaveLength(0); + + const response = await tokenService.generateToken( + request, + generateId(), + genericLogger + ); + + const fileListAfter = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileListAfter).toHaveLength(0); + expect(fileManager.storeBytes).not.toHaveBeenCalled(); + + expect(response.limitReached).toBe(false); + expect(response.token).toBeDefined(); + expect(response.rateLimiterStatus).toEqual({ + maxRequests: config.rateLimiterMaxRequests, + rateInterval: config.rateLimiterRateInterval, + remainingRequests: config.rateLimiterMaxRequests - 1, + }); + }); +}); diff --git a/packages/authorization-server/test/authorizationServer.unit.test.ts b/packages/authorization-server/test/authorizationServer.unit.test.ts new file mode 100644 index 0000000000..ddfd05fc33 --- /dev/null +++ b/packages/authorization-server/test/authorizationServer.unit.test.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { + buildDynamoDBTables, + deleteDynamoDBTables, + getMockTokenStatesClientEntry, + getMockTokenStatesClientPurposeEntry, + writeTokenStateClientEntry, + writeTokenStateEntry, +} from "pagopa-interop-commons-test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ClientId, + clientKindTokenStates, + generateId, + makeTokenGenerationStatesClientKidPK, + makeTokenGenerationStatesClientKidPurposePK, + PurposeId, + TokenGenerationStatesClientEntry, + TokenGenerationStatesClientPurposeEntry, +} from "pagopa-interop-models"; +import {} from "pagopa-interop-client-assertion-validation"; +import { genericLogger } from "pagopa-interop-commons"; +import { fallbackAudit, retrieveKey } from "../src/services/tokenService.js"; +import { + fallbackAuditFailed, + invalidTokenClientKidPurposeEntry, + keyTypeMismatch, + tokenGenerationStatesEntryNotFound, +} from "../src/model/domain/errors.js"; +import { config } from "../src/config/config.js"; +import { + dynamoDBClient, + fileManager, + getMockAuditMessage, + mockKMSClient, + mockProducer, +} from "./utils.js"; + +describe("unit tests", () => { + beforeEach(async () => { + await buildDynamoDBTables(dynamoDBClient); + mockKMSClient.send.mockImplementation(async () => ({ + Signature: "mock signature", + })); + }); + afterEach(async () => { + await deleteDynamoDBTables(dynamoDBClient); + vi.restoreAllMocks(); + }); + + describe("retrieveKey", () => { + it("should throw tokenGenerationStatesEntryNotFound if the clientKidPurpose entry doesn't exist in token-generation-states", async () => { + const clientId1 = generateId(); + const kid = "kid"; + const purposeId1 = generateId(); + const clientId2 = generateId(); + const purposeId2 = generateId(); + + const tokenClientKidPurposePK1 = + makeTokenGenerationStatesClientKidPurposePK({ + clientId: clientId1, + kid, + purposeId: purposeId1, + }); + + const tokenClientKidPurposePK2 = + makeTokenGenerationStatesClientKidPurposePK({ + clientId: clientId2, + kid, + purposeId: purposeId2, + }); + + const tokenClientPurposeEntry1: TokenGenerationStatesClientPurposeEntry = + getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK1); + + await writeTokenStateEntry(tokenClientPurposeEntry1, dynamoDBClient); + + expect( + retrieveKey(dynamoDBClient, tokenClientKidPurposePK2) + ).rejects.toThrowError( + tokenGenerationStatesEntryNotFound(tokenClientKidPurposePK2) + ); + }); + + it("should throw tokenGenerationStatesEntryNotFound if the clientKid entry doesn't exist in token-generation-states", async () => { + const clientId1 = generateId(); + const kid = "kid"; + const clientId2 = generateId(); + + const tokenClientKidPK1 = makeTokenGenerationStatesClientKidPK({ + clientId: clientId1, + kid, + }); + + const tokenClientKidPK2 = makeTokenGenerationStatesClientKidPK({ + clientId: clientId2, + kid, + }); + + const tokenClientEntry1: TokenGenerationStatesClientEntry = + getMockTokenStatesClientEntry(tokenClientKidPK1); + + await writeTokenStateClientEntry(tokenClientEntry1, dynamoDBClient); + + expect( + retrieveKey(dynamoDBClient, tokenClientKidPK2) + ).rejects.toThrowError( + tokenGenerationStatesEntryNotFound(tokenClientKidPK2) + ); + }); + + it("should throw invalidTokenClientKidPurposeEntry - clientKidPurpose entry - consumer key - missing info", async () => { + const clientId = generateId(); + const kid = "kid"; + const purposeId = generateId(); + + const tokenClientKidPurposePK = + makeTokenGenerationStatesClientKidPurposePK({ + clientId, + kid, + purposeId, + }); + + const tokenClientPurposeEntry: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + agreementId: undefined, + }; + + await writeTokenStateEntry(tokenClientPurposeEntry, dynamoDBClient); + expect( + retrieveKey(dynamoDBClient, tokenClientKidPurposePK) + ).rejects.toThrowError( + invalidTokenClientKidPurposeEntry(tokenClientPurposeEntry.PK) + ); + }); + + it("should succeed - clientKidPurpose entry - consumer key", async () => { + const clientId = generateId(); + const kid = "kid"; + const purposeId = generateId(); + + const tokenClientKidPurposePK = + makeTokenGenerationStatesClientKidPurposePK({ + clientId, + kid, + purposeId, + }); + + const tokenClientPurposeEntry: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + clientKind: clientKindTokenStates.consumer, + }; + + await writeTokenStateEntry(tokenClientPurposeEntry, dynamoDBClient); + const key = await retrieveKey(dynamoDBClient, tokenClientKidPurposePK); + + expect(key).toEqual(tokenClientPurposeEntry); + }); + + it("should throw keyTypeMismatch - clientKid entry with consumer key", async () => { + const clientId = generateId(); + const kid = "kid"; + + const tokenClientKidPK = makeTokenGenerationStatesClientKidPK({ + clientId, + kid, + }); + + const tokenClientEntry: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(tokenClientKidPK), + clientKind: clientKindTokenStates.consumer, + }; + + await writeTokenStateClientEntry(tokenClientEntry, dynamoDBClient); + expect( + retrieveKey(dynamoDBClient, tokenClientKidPK) + ).rejects.toThrowError( + keyTypeMismatch(tokenClientEntry.PK, clientKindTokenStates.consumer) + ); + }); + + it("should throw keyTypeMismatch - clientKidPurpose entry with api key", async () => { + const clientId = generateId(); + const kid = "kid"; + const purposeId = generateId(); + + const tokenClientKidPurposePK = + makeTokenGenerationStatesClientKidPurposePK({ + clientId, + kid, + purposeId, + }); + + const tokenClientPurposeEntry: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(tokenClientKidPurposePK), + clientKind: clientKindTokenStates.api, + }; + + await writeTokenStateEntry(tokenClientPurposeEntry, dynamoDBClient); + expect( + retrieveKey(dynamoDBClient, tokenClientKidPurposePK) + ).rejects.toThrowError( + keyTypeMismatch(tokenClientPurposeEntry.PK, clientKindTokenStates.api) + ); + }); + + it("should succeed - clientKid entry - api key", async () => { + const clientId = generateId(); + const kid = "kid"; + + const tokenClientKidPK = makeTokenGenerationStatesClientKidPK({ + clientId, + kid, + }); + + const tokenClientEntry: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(tokenClientKidPK), + clientKind: clientKindTokenStates.api, + }; + + await writeTokenStateClientEntry(tokenClientEntry, dynamoDBClient); + const key = await retrieveKey(dynamoDBClient, tokenClientKidPK); + + expect(key).toEqual(tokenClientEntry); + }); + }); + + describe("fallbackAudit", () => { + it("should write the audit message to the file storage", async () => { + const mockAuditMessage = getMockAuditMessage(); + + const fileListBeforeAudit = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileListBeforeAudit).toHaveLength(0); + + await fallbackAudit(mockAuditMessage, fileManager, genericLogger); + + const fileListAfterAudit = await fileManager.listFiles( + config.s3Bucket, + genericLogger + ); + expect(fileListAfterAudit).toHaveLength(1); + + const fileContent = await fileManager.get( + config.s3Bucket, + fileListAfterAudit[0], + genericLogger + ); + + const expectedFileContent = JSON.stringify(mockAuditMessage); + + const decodedFileContent = new TextDecoder().decode(fileContent); + expect(decodedFileContent).toEqual(expectedFileContent); + }); + + it("should throw fallbackAuditFailed in case of unsuccessful file write operation", async () => { + const mockAuditMessage = getMockAuditMessage(); + + mockProducer.send.mockImplementationOnce(async () => Promise.reject()); + vi.spyOn(fileManager, "storeBytes").mockImplementationOnce(() => + Promise.reject() + ); + + expect( + fallbackAudit(mockAuditMessage, fileManager, genericLogger) + ).rejects.toThrowError(fallbackAuditFailed(mockAuditMessage.clientId)); + }); + }); +}); diff --git a/packages/authorization-server/test/tsconfig.json b/packages/authorization-server/test/tsconfig.json new file mode 100644 index 0000000000..379a994d81 --- /dev/null +++ b/packages/authorization-server/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["."] +} diff --git a/packages/authorization-server/test/utils.ts b/packages/authorization-server/test/utils.ts new file mode 100644 index 0000000000..ecbdd4daf7 --- /dev/null +++ b/packages/authorization-server/test/utils.ts @@ -0,0 +1,127 @@ +import { + getMockClientAssertion, + setupTestContainersVitest, +} from "pagopa-interop-commons-test"; +import { + AgreementId, + ClientId, + DescriptorId, + EServiceId, + GeneratedTokenAuditDetails, + generateId, + PurposeId, + PurposeVersionId, + TenantId, +} from "pagopa-interop-models"; +import { afterEach, inject, vi } from "vitest"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { KMSClient } from "@aws-sdk/client-kms"; +import { initProducer } from "kafka-iam-auth"; +import { authorizationServerApi } from "pagopa-interop-api-clients"; +import { dateToSeconds, InteropTokenGenerator } from "pagopa-interop-commons"; +import { tokenServiceBuilder } from "../src/services/tokenService.js"; +import { config } from "../src/config/config.js"; + +export const configTokenGenerationStates = inject( + "tokenGenerationReadModelConfig" +); + +export const { cleanup, fileManager, redisRateLimiter } = + await setupTestContainersVitest( + undefined, + undefined, + inject("fileManagerConfig"), + undefined, + inject("redisRateLimiterConfig") + ); + +afterEach(cleanup); + +if (configTokenGenerationStates === undefined) { + throw new Error("configTokenGenerationStates is undefined"); +} + +export const dynamoDBClient = new DynamoDBClient({ + endpoint: `http://localhost:${configTokenGenerationStates.tokenGenerationReadModelDbPort}`, +}); + +export const mockProducer = { + send: vi.fn(), +}; +export const mockKMSClient = { + send: vi.fn(), +}; + +const tokenGenerator = new InteropTokenGenerator( + { + generatedInteropTokenKid: config.generatedInteropTokenKid, + generatedInteropTokenIssuer: config.generatedInteropTokenIssuer, + generatedInteropTokenM2MAudience: config.generatedInteropTokenM2MAudience, + generatedInteropTokenM2MDurationSeconds: + config.generatedInteropTokenM2MDurationSeconds, + }, + mockKMSClient as unknown as KMSClient +); + +export const tokenService = tokenServiceBuilder({ + tokenGenerator, + dynamoDBClient, + redisRateLimiter, + producer: mockProducer as unknown as Awaited>, + fileManager, +}); + +export const getMockAccessTokenRequest = + async (): Promise => { + const { jws } = await getMockClientAssertion(); + return { + client_id: generateId(), + client_assertion_type: + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: jws, + grant_type: "client_credentials", + }; + }; + +export const getMockAuditMessage = (): GeneratedTokenAuditDetails => { + const correlationId = generateId(); + const eserviceId = generateId(); + const descriptorId = generateId(); + const agreementId = generateId(); + const clientId = generateId(); + const purposeId = generateId(); + const kid = "kid"; + const purposeVersionId = generateId(); + const consumerId = generateId(); + const clientAssertionJti = generateId(); + + return { + correlationId, + eserviceId, + descriptorId, + agreementId, + subject: clientId, + audience: "pagopa.it", + purposeId, + algorithm: "RS256", + clientId, + keyId: kid, + purposeVersionId, + jwtId: generateId(), + issuedAt: dateToSeconds(new Date()), + issuer: "interop jwt issuer", + expirationTime: dateToSeconds(new Date()), + organizationId: consumerId, + notBefore: 0, + clientAssertion: { + subject: clientId, + audience: "pagopa.it", + algorithm: "RS256", + keyId: kid, + jwtId: clientAssertionJti, + issuedAt: dateToSeconds(new Date()), + issuer: consumerId, + expirationTime: dateToSeconds(new Date()), + }, + }; +}; diff --git a/packages/authorization-server/test/vitestGlobalSetup.ts b/packages/authorization-server/test/vitestGlobalSetup.ts new file mode 100644 index 0000000000..32972b5c32 --- /dev/null +++ b/packages/authorization-server/test/vitestGlobalSetup.ts @@ -0,0 +1,3 @@ +import { setupTestContainersVitestGlobal } from "pagopa-interop-commons-test"; + +export default setupTestContainersVitestGlobal(); diff --git a/packages/authorization-server/tsconfig.check.json b/packages/authorization-server/tsconfig.check.json new file mode 100644 index 0000000000..a19f84bcb7 --- /dev/null +++ b/packages/authorization-server/tsconfig.check.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + }, + "include": ["src", "test"] +} diff --git a/packages/authorization-server/tsconfig.json b/packages/authorization-server/tsconfig.json new file mode 100644 index 0000000000..039e0b4d16 --- /dev/null +++ b/packages/authorization-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/authorization-server/vitest.config.ts b/packages/authorization-server/vitest.config.ts new file mode 100644 index 0000000000..d1a4f22cc5 --- /dev/null +++ b/packages/authorization-server/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["./test/vitestGlobalSetup.ts"], + testTimeout: 60000, + hookTimeout: 60000, + fileParallelism: false, + pool: "forks" + }, +}); diff --git a/packages/backend-for-frontend/.env b/packages/backend-for-frontend/.env index 33fe161b6b..4457ca94c7 100644 --- a/packages/backend-for-frontend/.env +++ b/packages/backend-for-frontend/.env @@ -80,4 +80,4 @@ IMPORT_ESERVICE_PATH="local/eservices-import" PRESIGNED_URL_GET_DURATION_MINUTES=5000 PRESIGNED_URL_PUT_DURATION_MINUTES= 5000 -CLIENT_ASSERTION_AUDIENCE="auth.refactor.dev.interop.pagopa.it/client-assertion" +CLIENT_ASSERTION_AUDIENCE="dev.interop.pagopa.it" diff --git a/packages/backend-for-frontend/src/services/toolService.ts b/packages/backend-for-frontend/src/services/toolService.ts index d286488c7a..d6e851a0ea 100644 --- a/packages/backend-for-frontend/src/services/toolService.ts +++ b/packages/backend-for-frontend/src/services/toolService.ts @@ -2,9 +2,6 @@ import { isAxiosError } from "axios"; import { - ApiKey, - ClientAssertion, - ConsumerKey, FailedValidation, SuccessfulValidation, validateClientKindAndPlatformState, @@ -15,11 +12,21 @@ import { import { AgreementId, ApiError, + ClientAssertion, ClientId, + DescriptorId, EServiceId, + GSIPKKid, ItemState, + makeGSIPKClientIdPurposeId, + makeGSIPKConsumerIdEServiceId, + makeGSIPKEServiceIdDescriptorId, + makeTokenGenerationStatesClientKidPK, + makeTokenGenerationStatesClientKidPurposePK, PurposeId, TenantId, + TokenGenerationStatesClientEntry, + TokenGenerationStatesClientPurposeEntry, unsafeBrandId, } from "pagopa-interop-models"; import { WithLogger } from "pagopa-interop-commons"; @@ -96,7 +103,11 @@ export function toolsServiceBuilder(clients: PagoPAInteropBeClients) { : undefined; const { errors: clientAssertionSignatureErrors } = - await verifyClientAssertionSignature(clientAssertion, key); + await verifyClientAssertionSignature( + clientAssertion, + key, + jwt.header.alg + ); if (clientAssertionSignatureErrors) { return handleValidationResults( { @@ -209,7 +220,9 @@ async function retrieveKeyAndEservice( ctx: WithLogger ): Promise< | SuccessfulValidation<{ - key: ApiKey | ConsumerKey; + key: + | TokenGenerationStatesClientEntry + | TokenGenerationStatesClientPurposeEntry; eservice?: catalogApi.EService; descriptor?: catalogApi.EServiceDescriptor; }> @@ -241,26 +254,29 @@ async function retrieveKeyAndEservice( assertIsConsumer(ctx.authData.organizationId, keyWithClient); - const { encodedPem, algorithm } = - await authorizationClient.client.getClientKeyById({ - headers: ctx.headers, - params: { - clientId: keyWithClient.client.id, - keyId: jwt.header.kid, - }, - }); + const { encodedPem } = await authorizationClient.client.getClientKeyById({ + headers: ctx.headers, + params: { + clientId: keyWithClient.client.id, + keyId: jwt.header.kid, + }, + }); if (keyWithClient.client.kind === authorizationApi.ClientKind.enum.API) { return { errors: undefined, data: { key: { + PK: makeTokenGenerationStatesClientKidPK({ + clientId: unsafeBrandId(keyWithClient.client.id), + kid: jwt.header.kid, + }), clientKind: authorizationApi.ClientKind.enum.API, - kid: jwt.header.kid, - algorithm, + GSIPK_kid: unsafeBrandId(jwt.header.kid), publicKey: encodedPem, - clientId: unsafeBrandId(keyWithClient.client.id), + GSIPK_clientId: unsafeBrandId(keyWithClient.client.id), consumerId: unsafeBrandId(keyWithClient.client.consumerId), + updatedAt: new Date().toISOString(), }, }, }; @@ -311,18 +327,36 @@ async function retrieveKeyAndEservice( errors: undefined, data: { key: { + PK: makeTokenGenerationStatesClientKidPurposePK({ + clientId: unsafeBrandId(keyWithClient.client.id), + kid: jwt.header.kid, + purposeId, + }), clientKind: authorizationApi.ClientKind.enum.CONSUMER, - clientId: unsafeBrandId(keyWithClient.client.id), - kid: jwt.header.kid, - algorithm, + GSIPK_clientId: unsafeBrandId(keyWithClient.client.id), + GSIPK_kid: unsafeBrandId(jwt.header.kid), publicKey: encodedPem, - purposeId, + GSIPK_purposeId: purposeId, consumerId: unsafeBrandId(keyWithClient.client.consumerId), agreementId: unsafeBrandId(agreement.id), - eServiceId: unsafeBrandId(agreement.eserviceId), + GSIPK_eserviceId_descriptorId: makeGSIPKEServiceIdDescriptorId({ + eserviceId: unsafeBrandId(agreement.eserviceId), + descriptorId: unsafeBrandId(agreement.descriptorId), + }), + GSIPK_consumerId_eserviceId: makeGSIPKConsumerIdEServiceId({ + eserviceId: unsafeBrandId(agreement.eserviceId), + consumerId: unsafeBrandId(keyWithClient.client.consumerId), + }), + GSIPK_clientId_purposeId: makeGSIPKClientIdPurposeId({ + clientId: unsafeBrandId(keyWithClient.client.id), + purposeId, + }), agreementState: agreementStateToItemState(agreement.state), - purposeState: retrievePurposeItemState(purpose), + purposeState: purposeToItemState(purpose), descriptorState: descriptorStateToItemState(descriptor.state), + descriptorAudience: descriptor.audience, + descriptorVoucherLifespan: descriptor.voucherLifespan, + updatedAt: new Date().toISOString(), }, eservice, descriptor, @@ -374,7 +408,7 @@ async function retrieveDescriptor( return descriptor; } -function retrievePurposeItemState(purpose: purposeApi.Purpose): ItemState { +function purposeToItemState(purpose: purposeApi.Purpose): ItemState { const purposeVersion = [...purpose.versions] .sort( (a, b) => @@ -416,10 +450,10 @@ const agreementStateToItemState = ( : ItemState.Enum.INACTIVE; const descriptorStateToItemState = ( - state: catalogApi.EServiceDescriptorState + descriptorState: catalogApi.EServiceDescriptorState ): ItemState => - state === catalogApi.EServiceDescriptorState.Enum.PUBLISHED || - state === catalogApi.EServiceDescriptorState.Enum.DEPRECATED + descriptorState === catalogApi.EServiceDescriptorState.Enum.PUBLISHED || + descriptorState === catalogApi.EServiceDescriptorState.Enum.DEPRECATED ? ItemState.Enum.ACTIVE : ItemState.Enum.INACTIVE; diff --git a/packages/client-assertion-validation/src/errors.ts b/packages/client-assertion-validation/src/errors.ts index 1ba5d7589b..1f1617a1f7 100644 --- a/packages/client-assertion-validation/src/errors.ts +++ b/packages/client-assertion-validation/src/errors.ts @@ -1,55 +1,45 @@ import { ApiError } from "pagopa-interop-models"; export const errorCodes = { - clientAssertionValidationFailure: "0001", - unexpectedClientAssertionSignatureVerificationError: "0002", - invalidAssertionType: "0003", - invalidGrantType: "0004", - invalidAudienceFormat: "0005", - invalidAudience: "0006", - audienceNotFound: "0007", - invalidClientAssertionFormat: "0008", - unexpectedClientAssertionPayload: "0009", - jtiNotFound: "00010", - issuedAtNotFound: "0011", - expNotFound: "0012", - issuerNotFound: "0013", - subjectNotFound: "0014", - invalidSubject: "0015", - invalidPurposeIdClaimFormat: "0016", - kidNotFound: "0017", - clientAssertionSignatureVerificationError: "0018", - tokenExpiredError: "0019", - jsonWebTokenError: "0020", - notBeforeError: "0021", - inactivePurpose: "0022", - inactiveAgreement: "0023", - inactiveEService: "0024", - invalidClientIdFormat: "0025", - invalidSubjectFormat: "0026", - digestClaimNotFound: "0027", - invalidHashLength: "0028", - invalidHashAlgorithm: "0029", - algorithmNotFound: "0030", - algorithmNotAllowed: "0031", - purposeIdNotProvided: "0032", - invalidKidFormat: "0033", - clientAssertionInvalidClaims: "0034", - invalidSignature: "0035", + unexpectedClientAssertionSignatureVerificationError: "0001", + invalidAssertionType: "0002", + invalidGrantType: "0003", + invalidAudienceFormat: "0004", + invalidAudience: "0005", + audienceNotFound: "0006", + invalidClientAssertionFormat: "0007", + unexpectedClientAssertionPayload: "0008", + jtiNotFound: "0009", + issuedAtNotFound: "0010", + expNotFound: "0011", + issuerNotFound: "0012", + subjectNotFound: "0013", + invalidSubject: "0014", + invalidPurposeIdClaimFormat: "0015", + kidNotFound: "0016", + clientAssertionSignatureVerificationError: "0017", + tokenExpiredError: "0018", + jsonWebTokenError: "0019", + notBeforeError: "0020", + inactivePurpose: "0021", + inactiveAgreement: "0022", + inactiveEService: "0023", + invalidClientIdFormat: "0024", + invalidSubjectFormat: "0025", + digestClaimNotFound: "0026", + invalidHashLength: "0027", + invalidHashAlgorithm: "0028", + algorithmNotFound: "0029", + algorithmNotAllowed: "0030", + purposeIdNotProvided: "0031", + invalidKidFormat: "0032", + clientAssertionInvalidClaims: "0033", + invalidSignature: "0034", + missingPlatformStates: "0035", }; export type ErrorCodes = keyof typeof errorCodes; -export function clientAssertionValidationFailure( - details: string -): ApiError { - return new ApiError({ - detail: `Client assertion validation failed: ${details}`, - code: "clientAssertionValidationFailure", - title: "Client assertion validation failed", - }); -} - export function unexpectedClientAssertionSignatureVerificationError( message: string ): ApiError { @@ -336,3 +326,11 @@ export function invalidSignature(): ApiError { title: "Invalid signature", }); } + +export function missingPlatformStates(): ApiError { + return new ApiError({ + detail: "Platform states not available for the entry", + code: "missingPlatformStates", + title: "Missing platform states", + }); +} diff --git a/packages/client-assertion-validation/src/types.ts b/packages/client-assertion-validation/src/types.ts index 38a8729a83..4d9020e990 100644 --- a/packages/client-assertion-validation/src/types.ts +++ b/packages/client-assertion-validation/src/types.ts @@ -1,82 +1,8 @@ -import { - AgreementId, - ApiError, - ClientId, - clientKindTokenStates, - EServiceId, - ItemState, - PurposeId, - TenantId, -} from "pagopa-interop-models"; +import { ApiError } from "pagopa-interop-models"; import { z } from "zod"; import { ErrorCodes } from "./errors.js"; -export const ClientAssertionDigest = z - .object({ - alg: z.string(), - value: z.string(), - }) - .strict(); -export type ClientAssertionDigest = z.infer; - -export const ClientAssertionHeader = z - .object({ - kid: z.string(), - alg: z.string(), - typ: z.string().optional(), - }) - .strict(); -export type ClientAssertionHeader = z.infer; - -export const ClientAssertionPayload = z - .object({ - sub: ClientId, - jti: z.string(), - iat: z.number(), - iss: z.string(), - aud: z.array(z.string()).or(z.string()), - exp: z.number(), - digest: ClientAssertionDigest.optional(), - purposeId: PurposeId.optional(), - }) - .strict(); -export type ClientAssertionPayload = z.infer; - -export const ClientAssertion = z - .object({ - header: ClientAssertionHeader, - payload: ClientAssertionPayload, - }) - .strict(); -export type ClientAssertion = z.infer; - export const Base64Encoded = z.string().base64().min(1); -export const Key = z - .object({ - clientId: ClientId, - consumerId: TenantId, - kid: z.string(), - publicKey: Base64Encoded, - algorithm: z.string(), - }) - .strict(); -export type Key = z.infer; - -export const ConsumerKey = Key.extend({ - clientKind: z.literal(clientKindTokenStates.consumer), - purposeId: PurposeId, - purposeState: ItemState, - agreementId: AgreementId, - agreementState: ItemState, - eServiceId: EServiceId, - descriptorState: ItemState, -}).strict(); -export type ConsumerKey = z.infer; - -export const ApiKey = Key.extend({ - clientKind: z.literal(clientKindTokenStates.api), -}).strict(); -export type ApiKey = z.infer; export type ValidationResult = | SuccessfulValidation diff --git a/packages/client-assertion-validation/src/utils.ts b/packages/client-assertion-validation/src/utils.ts index 6bfa958313..44f16d1692 100644 --- a/packages/client-assertion-validation/src/utils.ts +++ b/packages/client-assertion-validation/src/utils.ts @@ -3,11 +3,11 @@ import { ClientId, itemState, PurposeId, + TokenGenerationStatesClientPurposeEntry, unsafeBrandId, + ClientAssertionDigest, } from "pagopa-interop-models"; import { - ClientAssertionDigest, - ConsumerKey, FailedValidation, ValidationResult, SuccessfulValidation, @@ -190,8 +190,8 @@ export const validateDigest = ( }; export const validatePlatformState = ( - key: ConsumerKey -): ValidationResult => { + key: TokenGenerationStatesClientPurposeEntry +): ValidationResult => { const agreementError = key.agreementState !== itemState.active ? inactiveAgreement() : undefined; diff --git a/packages/client-assertion-validation/src/validation.ts b/packages/client-assertion-validation/src/validation.ts index 6db2e9f4ac..27041566c5 100644 --- a/packages/client-assertion-validation/src/validation.ts +++ b/packages/client-assertion-validation/src/validation.ts @@ -1,5 +1,13 @@ import { match } from "ts-pattern"; -import { clientKindTokenStates } from "pagopa-interop-models"; +import { + clientKidPurposePrefix, + clientKindTokenStates, + TokenGenerationStatesClientEntry, + TokenGenerationStatesClientPurposeEntry, + ClientAssertion, + ClientAssertionHeader, + ClientAssertionPayload, +} from "pagopa-interop-models"; import * as jose from "jose"; import { JOSEError, @@ -29,14 +37,8 @@ import { ALLOWED_ALGORITHM, } from "./utils.js"; import { - ApiKey, Base64Encoded, - ClientAssertion, - ClientAssertionHeader, - ClientAssertionPayload, ClientAssertionValidationRequest, - ConsumerKey, - Key, ValidationResult, } from "./types.js"; import { @@ -53,6 +55,7 @@ import { clientAssertionInvalidClaims, algorithmNotAllowed, clientAssertionSignatureVerificationError, + missingPlatformStates, } from "./errors.js"; export const validateRequestParameters = ( @@ -184,11 +187,14 @@ export const verifyClientAssertion = ( export const verifyClientAssertionSignature = async ( clientAssertionJws: string, - key: Key + key: + | TokenGenerationStatesClientPurposeEntry + | TokenGenerationStatesClientEntry, + clientAssertionAlgorithm: string ): Promise> => { try { - if (key.algorithm !== ALLOWED_ALGORITHM) { - return failedValidation([algorithmNotAllowed(key.algorithm)]); + if (clientAssertionAlgorithm !== ALLOWED_ALGORITHM) { + return failedValidation([algorithmNotAllowed(clientAssertionAlgorithm)]); } if (!Base64Encoded.safeParse(key.publicKey).success) { @@ -211,7 +217,7 @@ export const verifyClientAssertionSignature = async ( const publicKey = createPublicKey(key.publicKey); const result = await jose.jwtVerify(clientAssertionJws, publicKey, { - algorithms: [key.algorithm], + algorithms: [clientAssertionAlgorithm], }); return successfulValidation(result.payload); @@ -241,7 +247,9 @@ export const verifyClientAssertionSignature = async ( }; export const validateClientKindAndPlatformState = ( - key: ApiKey | ConsumerKey, + key: + | TokenGenerationStatesClientEntry + | TokenGenerationStatesClientPurposeEntry, jwt: ClientAssertion ): ValidationResult => match(key) @@ -249,14 +257,18 @@ export const validateClientKindAndPlatformState = ( successfulValidation(jwt) ) .with({ clientKind: clientKindTokenStates.consumer }, (key) => { - const { errors: platformStateErrors } = validatePlatformState(key); - const purposeIdError = jwt.payload.purposeId - ? undefined - : purposeIdNotProvided(); + if (key.PK.startsWith(clientKidPurposePrefix)) { + const parsed = key as TokenGenerationStatesClientPurposeEntry; + const { errors: platformStateErrors } = validatePlatformState(parsed); + const purposeIdError = jwt.payload.purposeId + ? undefined + : purposeIdNotProvided(); - if (!platformStateErrors && !purposeIdError) { - return successfulValidation(jwt); + if (!platformStateErrors && !purposeIdError) { + return successfulValidation(jwt); + } + return failedValidation([platformStateErrors, purposeIdError]); } - return failedValidation([platformStateErrors, purposeIdError]); + return failedValidation([missingPlatformStates()]); }) .exhaustive(); diff --git a/packages/client-assertion-validation/test/utils.ts b/packages/client-assertion-validation/test/utils.ts index 2ad7728e8f..4f35335375 100644 --- a/packages/client-assertion-validation/test/utils.ts +++ b/packages/client-assertion-validation/test/utils.ts @@ -1,19 +1,7 @@ import crypto from "crypto"; -import { - ClientId, - clientKindTokenStates, - generateId, - itemState, - PurposeId, - TenantId, -} from "pagopa-interop-models"; -import * as jose from "jose"; -import { - ApiKey, - ClientAssertionValidationRequest, - ConsumerKey, - Key, -} from ".././src/types.js"; +import { ClientId, generateId } from "pagopa-interop-models"; +import { getMockClientAssertion } from "pagopa-interop-commons-test"; +import { ClientAssertionValidationRequest } from ".././src/types.js"; import { EXPECTED_CLIENT_ASSERTION_TYPE, EXPECTED_CLIENT_CREDENTIALS_GRANT_TYPE, @@ -21,119 +9,6 @@ import { export const value64chars = crypto.randomBytes(32).toString("hex"); -export const getMockClientAssertion = async (props?: { - standardClaimsOverride?: Partial; - customClaims?: { [k: string]: unknown }; - customHeader?: { [k: string]: unknown }; -}): Promise<{ - jws: string; - publicKeyEncodedPem: string; -}> => { - const { keySet, publicKeyEncodedPem } = generateKeySet(); - - const clientId = generateId(); - const defaultPayload: jose.JWTPayload = { - iss: clientId, - sub: clientId, - aud: ["test.interop.pagopa.it", "dev.interop.pagopa.it"], - exp: 60, - jti: generateId(), - iat: 5, - }; - - const actualPayload: jose.JWTPayload = { - ...defaultPayload, - ...props?.standardClaimsOverride, - ...props?.customClaims, - }; - - const headers: jose.JWTHeaderParameters = { - alg: "RS256", - kid: "kid", - ...props?.customHeader, - }; - - const jws = await signClientAssertion({ - payload: actualPayload, - headers, - keySet, - }); - - return { - jws, - publicKeyEncodedPem, - }; -}; - -export const generateKeySet = (): { - keySet: crypto.KeyPairKeyObjectResult; - publicKeyEncodedPem: string; -} => { - const keySet: crypto.KeyPairKeyObjectResult = crypto.generateKeyPairSync( - "rsa", - { - modulusLength: 2048, - } - ); - - const pemPublicKey = keySet.publicKey - .export({ - type: "spki", - format: "pem", - }) - .toString(); - - const publicKeyEncodedPem = Buffer.from(pemPublicKey).toString("base64"); - return { - keySet, - publicKeyEncodedPem, - }; -}; - -const signClientAssertion = async ({ - payload, - headers, - keySet, -}: { - payload: jose.JWTPayload; - headers: jose.JWTHeaderParameters; - keySet: crypto.KeyPairKeyObjectResult; -}): Promise => { - const pemPrivateKey = keySet.privateKey.export({ - type: "pkcs8", - format: "pem", - }); - - const privateKey = crypto.createPrivateKey(pemPrivateKey); - return await new jose.SignJWT(payload) - .setProtectedHeader(headers) - .sign(privateKey); -}; - -export const getMockKey = (): Key => ({ - clientId: generateId(), - consumerId: generateId(), - kid: "kid", - publicKey: generateKeySet().publicKeyEncodedPem, - algorithm: "RS256", -}); - -export const getMockConsumerKey = (): ConsumerKey => ({ - ...getMockKey(), - purposeId: generateId(), - clientKind: clientKindTokenStates.consumer, - purposeState: itemState.active, - agreementId: generateId(), - agreementState: itemState.active, - eServiceId: generateId(), - descriptorState: itemState.active, -}); - -export const getMockApiKey = (): ApiKey => ({ - ...getMockKey(), - clientKind: clientKindTokenStates.api, -}); - export const getMockAccessTokenRequest = async (): Promise => ({ client_id: generateId(), diff --git a/packages/client-assertion-validation/test/validation.test.ts b/packages/client-assertion-validation/test/validation.test.ts index d698ffe1d0..22d1ced539 100644 --- a/packages/client-assertion-validation/test/validation.test.ts +++ b/packages/client-assertion-validation/test/validation.test.ts @@ -3,11 +3,21 @@ import { fail } from "assert"; import { describe, expect, it } from "vitest"; import { ClientId, + clientKindTokenStates, generateId, itemState, PurposeId, + TokenGenerationStatesClientEntry, + TokenGenerationStatesClientPurposeEntry, } from "pagopa-interop-models"; import * as jsonwebtoken from "jsonwebtoken"; +import { + generateKeySet, + getMockClientAssertion, + getMockTokenStatesClientEntry, + getMockTokenStatesClientPurposeEntry, +} from "pagopa-interop-commons-test"; +import { dateToSeconds } from "pagopa-interop-commons"; import { validateClientKindAndPlatformState, validateRequestParameters, @@ -46,21 +56,10 @@ import { clientAssertionInvalidClaims, invalidAudienceFormat, unexpectedClientAssertionSignatureVerificationError, + missingPlatformStates, } from "../src/errors.js"; -import { - ClientAssertionValidationRequest, - ConsumerKey, - Key, -} from "../src/types.js"; -import { - generateKeySet, - getMockAccessTokenRequest, - getMockApiKey, - getMockClientAssertion, - getMockConsumerKey, - getMockKey, - value64chars, -} from "./utils.js"; +import { ClientAssertionValidationRequest } from "../src/types.js"; +import { getMockAccessTokenRequest, value64chars } from "./utils.js"; describe("validation test", async () => { describe("validateRequestParameters", async () => { @@ -488,26 +487,35 @@ describe("validation test", async () => { const { jws, publicKeyEncodedPem } = await getMockClientAssertion({ standardClaimsOverride: { - iat: new Date().getTime() / 1000, - exp: threeHourLater.getTime() / 1000, + iat: dateToSeconds(new Date()), + exp: dateToSeconds(threeHourLater), }, }); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; - const { errors } = await verifyClientAssertionSignature(jws, mockKey); + const { errors } = await verifyClientAssertionSignature( + jws, + mockKey, + "RS256" + ); expect(errors).toBeUndefined(); }); it("unexpectedClientAssertionSignatureVerificationError - base64 key expected", async () => { const { jws, publicKeyEncodedPem } = await getMockClientAssertion(); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: Buffer.from(publicKeyEncodedPem, "base64").toString("utf8"), }; - const { errors } = await verifyClientAssertionSignature(jws, mockKey); + + const { errors } = await verifyClientAssertionSignature( + jws, + mockKey, + "RS256" + ); expect(errors).toHaveLength(1); expect(errors![0]).toEqual( unexpectedClientAssertionSignatureVerificationError( @@ -527,16 +535,20 @@ describe("validation test", async () => { alg: notAllowedAlg, }, standardClaimsOverride: { - iat: new Date().getTime() / 1000, - exp: threeHourLater.getTime() / 1000, + iat: dateToSeconds(new Date()), + exp: dateToSeconds(threeHourLater), }, }); - const mockKey: Key = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, - algorithm: notAllowedAlg, }; - const { errors } = await verifyClientAssertionSignature(jws, mockKey); + + const { errors } = await verifyClientAssertionSignature( + jws, + mockKey, + notAllowedAlg + ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); expect(errors![0]).toEqual(algorithmNotAllowed(notAllowedAlg)); @@ -551,29 +563,35 @@ describe("validation test", async () => { const { jws, publicKeyEncodedPem } = await getMockClientAssertion({ standardClaimsOverride: { - iat: sixHoursAgo.getTime() / 1000, - exp: threeHourAgo.getTime() / 1000, + iat: dateToSeconds(sixHoursAgo), + exp: dateToSeconds(threeHourAgo), }, }); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; - const { errors } = await verifyClientAssertionSignature(jws, mockKey); + const { errors } = await verifyClientAssertionSignature( + jws, + mockKey, + "RS256" + ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); expect(errors![0]).toEqual(tokenExpiredError()); }); it("jsonWebTokenError", async () => { const { publicKeyEncodedPem } = generateKeySet(); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; + const { errors } = await verifyClientAssertionSignature( "not-a-valid-jws", - mockKey + mockKey, + "RS256" ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); @@ -582,17 +600,19 @@ describe("validation test", async () => { it("invalidSignature", async () => { const { publicKeyEncodedPem } = generateKeySet(); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; + const { jws } = await getMockClientAssertion(); const subStrings = jws.split("."); const clientAssertionWithWrongSignature = `${subStrings[0]}.${subStrings[1]}.wrong-signature`; const { errors } = await verifyClientAssertionSignature( clientAssertionWithWrongSignature, - mockKey + mockKey, + "RS256" ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); @@ -600,13 +620,15 @@ describe("validation test", async () => { }); it("jsonWebTokenError - malformed jwt", async () => { const { publicKeyEncodedPem } = generateKeySet(); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; + const { errors } = await verifyClientAssertionSignature( "too.many.substrings.in.client.assertion", - mockKey + mockKey, + "RS256" ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); @@ -617,8 +639,8 @@ describe("validation test", async () => { const { jws: clientAssertion1, publicKeyEncodedPem } = await getMockClientAssertion(); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; @@ -629,7 +651,8 @@ describe("validation test", async () => { const clientAssertionWithWrongSignature = `${subStrings1[0]}.${subStrings1[1]}.${subStrings2[2]}`; const { errors } = await verifyClientAssertionSignature( clientAssertionWithWrongSignature, - mockKey + mockKey, + "RS256" ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); @@ -648,17 +671,21 @@ describe("validation test", async () => { const { jws, publicKeyEncodedPem } = await getMockClientAssertion({ standardClaimsOverride: { - iat: threeHoursAgo.getTime() / 1000, - exp: sixHoursLater.getTime() / 1000, - nbf: threeHoursLater.getTime() / 1000, + iat: dateToSeconds(threeHoursAgo), + exp: dateToSeconds(sixHoursLater), + nbf: dateToSeconds(threeHoursLater), }, }); - const mockKey = { - ...getMockKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), publicKey: publicKeyEncodedPem, }; - const { errors } = await verifyClientAssertionSignature(jws, mockKey); + const { errors } = await verifyClientAssertionSignature( + jws, + mockKey, + "RS256" + ); expect(errors).toBeDefined(); expect(errors).toHaveLength(1); expect(errors![0]).toEqual(notBeforeError()); @@ -671,11 +698,8 @@ describe("validation test", async () => { describe("validatePlatformState", async () => { it("success", async () => { - const mockKey: ConsumerKey = { - ...getMockConsumerKey(), - agreementState: itemState.active, - descriptorState: itemState.active, - purposeState: itemState.active, + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), }; validatePlatformState(mockKey); const { errors } = validatePlatformState(mockKey); @@ -683,8 +707,8 @@ describe("validation test", async () => { }); it("inactiveAgreement", async () => { - const mockKey: ConsumerKey = { - ...getMockConsumerKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), agreementState: itemState.inactive, }; validatePlatformState(mockKey); @@ -695,9 +719,11 @@ describe("validation test", async () => { expect(errors![0]).toEqual(inactiveAgreement()); }); it("inactiveEservice", async () => { - const mockKey: ConsumerKey = { - ...getMockConsumerKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), descriptorState: itemState.inactive, + descriptorAudience: ["test.interop.pagopa.it"], + descriptorVoucherLifespan: 60, }; validatePlatformState(mockKey); const { errors } = validatePlatformState(mockKey); @@ -707,10 +733,11 @@ describe("validation test", async () => { expect(errors![0]).toEqual(inactiveEService()); }); it("inactivePurpose", async () => { - const mockKey: ConsumerKey = { - ...getMockConsumerKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), purposeState: itemState.inactive, }; + validatePlatformState(mockKey); const { errors } = validatePlatformState(mockKey); @@ -719,8 +746,8 @@ describe("validation test", async () => { expect(errors![0]).toEqual(inactivePurpose()); }); it("inactiveAgreement and inactiveEservice and inactivePurpose", async () => { - const mockKey: ConsumerKey = { - ...getMockConsumerKey(), + const mockKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), agreementState: itemState.inactive, descriptorState: itemState.inactive, purposeState: itemState.inactive, @@ -739,8 +766,8 @@ describe("validation test", async () => { }); describe("validateClientKindAndPlatformState", async () => { - it("success (consumerKey with consumer client kind; valid platform states)", async () => { - const mockConsumerKey = getMockConsumerKey(); + it("success (clientKidPurpose entry with consumer client kind; valid platform states)", async () => { + const mockConsumerKey = getMockTokenStatesClientPurposeEntry(); const { data: mockClientAssertion } = verifyClientAssertion( ( await getMockClientAssertion({ @@ -760,8 +787,8 @@ describe("validation test", async () => { }); it("inactiveEService (consumerKey with consumer client kind; invalid platform states)", async () => { - const mockConsumerKey: ConsumerKey = { - ...getMockConsumerKey(), + const mockConsumerKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), descriptorState: itemState.inactive, }; const { data: mockClientAssertion } = verifyClientAssertion( @@ -784,8 +811,11 @@ describe("validation test", async () => { expect(errors![0]).toEqual(inactiveEService()); }); - it("success (apiKey with api client kind)", async () => { - const mockApiKey = getMockApiKey(); + it("success (clientEntry with api client kind)", async () => { + const mockApiKey: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(), + clientKind: clientKindTokenStates.api, + }; const { data: mockClientAssertion } = verifyClientAssertion( (await getMockClientAssertion()).jws, undefined @@ -800,8 +830,29 @@ describe("validation test", async () => { expect(errors).toBeUndefined(); }); + it("missingPlatformStates (clientEntry with consumer client kind)", async () => { + const mockApiKey: TokenGenerationStatesClientEntry = { + ...getMockTokenStatesClientEntry(), + clientKind: clientKindTokenStates.consumer, + }; + const { data: mockClientAssertion } = verifyClientAssertion( + (await getMockClientAssertion()).jws, + undefined + ); + if (!mockClientAssertion) { + fail(); + } + const { errors } = validateClientKindAndPlatformState( + mockApiKey, + mockClientAssertion + ); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors![0]).toEqual(missingPlatformStates()); + }); + it("purposeIdNotProvided for Client Kind Consumer", async () => { - const mockConsumerKey = getMockConsumerKey(); + const mockConsumerKey = getMockTokenStatesClientPurposeEntry(); const { data: mockClientAssertion } = verifyClientAssertion( ( await getMockClientAssertion({ @@ -823,8 +874,8 @@ describe("validation test", async () => { }); it("purposeIdNotProvided and platformStateError", async () => { - const mockConsumerKey: ConsumerKey = { - ...getMockConsumerKey(), + const mockConsumerKey: TokenGenerationStatesClientPurposeEntry = { + ...getMockTokenStatesClientPurposeEntry(), agreementState: itemState.inactive, }; const { data: mockClientAssertion } = verifyClientAssertion( diff --git a/packages/commons-test/package.json b/packages/commons-test/package.json index 55e91be360..d474e20cba 100644 --- a/packages/commons-test/package.json +++ b/packages/commons-test/package.json @@ -31,6 +31,7 @@ "aws-sdk-client-mock": "4.0.1", "axios": "1.7.4", "dotenv-flow": "4.1.0", + "jose": "5.9.4", "jsonwebtoken": "9.0.2", "pagopa-interop-commons": "workspace:*", "pagopa-interop-models": "workspace:*", diff --git a/packages/commons-test/src/testUtils.ts b/packages/commons-test/src/testUtils.ts index dbb0143273..2d8a8d7692 100644 --- a/packages/commons-test/src/testUtils.ts +++ b/packages/commons-test/src/testUtils.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { fail } from "assert"; import { generateMock } from "@anatine/zod-mock"; import { @@ -67,9 +68,11 @@ import { PlatformStatesClientPK, PlatformStatesClientEntry, makePlatformStatesClientPK, + unsafeBrandId, } from "pagopa-interop-models"; -import { AuthData } from "pagopa-interop-commons"; +import { AuthData, dateToSeconds } from "pagopa-interop-commons"; import { z } from "zod"; +import * as jose from "jose"; import { match } from "ts-pattern"; export function expectPastTimestamp(timestamp: bigint): boolean { @@ -419,7 +422,9 @@ export const getMockDelegationDocument = ( export const getMockTokenStatesClientPurposeEntry = ( tokenStateEntryPK?: TokenGenerationStatesClientKidPurposePK ): TokenGenerationStatesClientPurposeEntry => { - const clientId = generateId(); + const clientId = tokenStateEntryPK + ? unsafeBrandId(tokenStateEntryPK.split("#")[1]) + : generateId(); const purposeId = generateId(); const consumerId = generateId(); const eserviceId = generateId(); @@ -436,7 +441,7 @@ export const getMockTokenStatesClientPurposeEntry = ( kid, purposeId, }), - descriptorState: itemState.inactive, + descriptorState: itemState.active, descriptorAudience: ["pagopa.it/test1", "pagopa.it/test2"], descriptorVoucherLifespan: 60, updatedAt: new Date().toISOString(), @@ -457,7 +462,7 @@ export const getMockTokenStatesClientPurposeEntry = ( descriptorId, }), GSIPK_purposeId: purposeId, - purposeState: itemState.inactive, + purposeState: itemState.active, GSIPK_clientId_purposeId: makeGSIPKClientIdPurposeId({ clientId, purposeId, @@ -486,7 +491,10 @@ export const getMockAgreementEntry = ( export const getMockTokenStatesClientEntry = ( tokenStateEntryPK?: TokenGenerationStatesClientKidPK ): TokenGenerationStatesClientEntry => { - const clientId = generateId(); + const clientId = tokenStateEntryPK + ? unsafeBrandId(tokenStateEntryPK.split("#")[1]) + : generateId(); + const consumerId = generateId(); const kid = `kid ${Math.random()}`; @@ -517,3 +525,103 @@ export const getMockPlatformStatesClientEntry = ( clientConsumerId: generateId(), clientPurposesIds: [], }); + +export const getMockClientAssertion = async (props?: { + standardClaimsOverride?: Partial; + customClaims?: { [k: string]: unknown }; + customHeader?: { [k: string]: unknown }; +}): Promise<{ + jws: string; + clientAssertion: { + payload: jose.JWTPayload; + header: jose.JWTHeaderParameters; + }; + publicKeyEncodedPem: string; +}> => { + const { keySet, publicKeyEncodedPem } = generateKeySet(); + + const threeHourLater = new Date(); + threeHourLater.setHours(threeHourLater.getHours() + 3); + + const clientId = generateId(); + const defaultPayload: jose.JWTPayload = { + iss: clientId, + sub: clientId, + aud: ["test.interop.pagopa.it", "dev.interop.pagopa.it"], + exp: dateToSeconds(threeHourLater), + jti: generateId(), + iat: dateToSeconds(new Date()), + }; + + const actualPayload: jose.JWTPayload = { + ...defaultPayload, + ...props?.standardClaimsOverride, + ...props?.customClaims, + }; + + const headers: jose.JWTHeaderParameters = { + alg: "RS256", + kid: "kid", + ...props?.customHeader, + }; + + const jws = await signClientAssertion({ + payload: actualPayload, + headers, + keySet, + }); + + return { + jws, + clientAssertion: { + payload: actualPayload, + header: headers, + }, + publicKeyEncodedPem, + }; +}; + +export const generateKeySet = (): { + keySet: crypto.KeyPairKeyObjectResult; + publicKeyEncodedPem: string; +} => { + const keySet: crypto.KeyPairKeyObjectResult = crypto.generateKeyPairSync( + "rsa", + { + modulusLength: 2048, + } + ); + + const pemPublicKey = keySet.publicKey + .export({ + type: "spki", + format: "pem", + }) + .toString(); + + const publicKeyEncodedPem = Buffer.from(pemPublicKey).toString("base64"); + return { + keySet, + publicKeyEncodedPem, + }; +}; + +const signClientAssertion = async ({ + payload, + headers, + keySet, +}: { + payload: jose.JWTPayload; + headers: jose.JWTHeaderParameters; + keySet: crypto.KeyPairKeyObjectResult; +}): Promise => { + const pemPrivateKey = keySet.privateKey.export({ + type: "pkcs8", + format: "pem", + }); + + const privateKey = crypto.createPrivateKey(pemPrivateKey); + return await new jose.SignJWT(payload) + .setProtectedHeader(headers) + .sign(privateKey); +}; diff --git a/packages/commons-test/src/tokenGenerationReadmodelUtils.ts b/packages/commons-test/src/tokenGenerationReadmodelUtils.ts index 18f5e7df98..ca231469ad 100644 --- a/packages/commons-test/src/tokenGenerationReadmodelUtils.ts +++ b/packages/commons-test/src/tokenGenerationReadmodelUtils.ts @@ -20,10 +20,46 @@ import { PlatformStatesPurposeEntry, PlatformStatesAgreementEntry, TokenGenerationStatesGenericEntry, + TokenGenerationStatesClientEntry, } from "pagopa-interop-models"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import { z } from "zod"; +export const writeTokenStateClientEntry = async ( + tokenStateEntry: TokenGenerationStatesClientEntry, + dynamoDBClient: DynamoDBClient +): Promise => { + const input: PutItemInput = { + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: { + S: tokenStateEntry.PK, + }, + updatedAt: { + S: tokenStateEntry.updatedAt, + }, + consumerId: { + S: tokenStateEntry.consumerId, + }, + clientKind: { + S: tokenStateEntry.clientKind, + }, + publicKey: { + S: tokenStateEntry.publicKey, + }, + GSIPK_clientId: { + S: tokenStateEntry.GSIPK_clientId, + }, + GSIPK_kid: { + S: tokenStateEntry.GSIPK_kid, + }, + }, + TableName: "token-generation-states", + }; + const command = new PutItemCommand(input); + await dynamoDBClient.send(command); +}; + export const writeTokenStateEntry = async ( tokenStateEntry: TokenGenerationStatesClientPurposeEntry, dynamoDBClient: DynamoDBClient diff --git a/packages/commons/src/config/authorizationServerTokenGenerationConfig.ts b/packages/commons/src/config/authorizationServerTokenGenerationConfig.ts new file mode 100644 index 0000000000..405d6999eb --- /dev/null +++ b/packages/commons/src/config/authorizationServerTokenGenerationConfig.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const AuthorizationServerTokenGenerationConfig = z + .object({ + GENERATED_INTEROP_TOKEN_KID: z.string(), + GENERATED_INTEROP_TOKEN_ISSUER: z.string(), + GENERATED_INTEROP_TOKEN_M2M_AUDIENCE: z.string(), + GENERATED_INTEROP_TOKEN_M2M_DURATION_SECONDS: z.string(), + }) + .transform((c) => ({ + generatedInteropTokenKid: c.GENERATED_INTEROP_TOKEN_KID, + generatedInteropTokenIssuer: c.GENERATED_INTEROP_TOKEN_ISSUER, + generatedInteropTokenM2MAudience: c.GENERATED_INTEROP_TOKEN_M2M_AUDIENCE, + generatedInteropTokenM2MDurationSeconds: parseInt( + c.GENERATED_INTEROP_TOKEN_M2M_DURATION_SECONDS, + 10 + ), + })); + +export type AuthorizationServerTokenGenerationConfig = z.infer< + typeof AuthorizationServerTokenGenerationConfig +>; diff --git a/packages/commons/src/config/index.ts b/packages/commons/src/config/index.ts index 7cba6068cc..6993d3b5d9 100644 --- a/packages/commons/src/config/index.ts +++ b/packages/commons/src/config/index.ts @@ -15,3 +15,4 @@ export * from "./sessionTokenGenerationConfig.js"; export * from "./redisRateLimiterConfig.js"; export * from "./pecEmailManagerConfig.js"; export * from "./selfcareConfig.js"; +export * from "./authorizationServerTokenGenerationConfig.js"; diff --git a/packages/commons/src/interop-token/interopTokenService.ts b/packages/commons/src/interop-token/interopTokenService.ts index b8088b4767..f4fec60d4f 100644 --- a/packages/commons/src/interop-token/interopTokenService.ts +++ b/packages/commons/src/interop-token/interopTokenService.ts @@ -1,12 +1,28 @@ import crypto from "crypto"; import { KMSClient, SignCommand, SignCommandInput } from "@aws-sdk/client-kms"; +import { + ClientId, + generateId, + PurposeId, + TenantId, + ClientAssertionDigest, +} from "pagopa-interop-models"; import { SessionTokenGenerationConfig } from "../config/sessionTokenGenerationConfig.js"; import { TokenGenerationConfig } from "../config/tokenGenerationConfig.js"; +import { AuthorizationServerTokenGenerationConfig } from "../config/authorizationServerTokenGenerationConfig.js"; +import { dateToSeconds } from "../utils/date.js"; import { CustomClaims, + GENERATED_INTEROP_TOKEN_M2M_ROLE, + InteropApiToken, + InteropConsumerToken, + InteropJwtApiPayload, + InteropJwtConsumerPayload, InteropJwtHeader, InteropJwtPayload, InteropToken, + ORGANIZATION_ID_CLAIM, + ROLE_CLAIM, SessionClaims, SessionJwtPayload, SessionToken, @@ -22,14 +38,26 @@ export class InteropTokenGenerator { private kmsClient: KMSClient; constructor( - private config: TokenGenerationConfig & - Partial + private config: Partial & + Partial & + Partial, + kmsClient?: KMSClient ) { - this.kmsClient = new KMSClient(); + this.kmsClient = kmsClient || new KMSClient(); } public async generateInternalToken(): Promise { - const currentTimestamp = Math.floor(Date.now() / 1000); + const currentTimestamp = dateToSeconds(new Date()); + + if ( + !this.config.kid || + !this.config.issuer || + !this.config.audience || + !this.config.subject || + !this.config.secondsDuration + ) { + throw Error("TokenGenerationConfig not provided or incomplete"); + } const header: InteropJwtHeader = { alg: JWT_HEADER_ALG, @@ -49,11 +77,11 @@ export class InteropTokenGenerator { [JWT_ROLE_CLAIM]: JWT_INTERNAL_ROLE, }; - const serializedToken = await this.createAndSignToken( + const serializedToken = await this.createAndSignToken({ header, payload, - this.config.kid - ); + keyId: this.config.kid, + }); return { header, @@ -75,7 +103,7 @@ export class InteropTokenGenerator { throw Error("SessionTokenGenerationConfig not provided or incomplete"); } - const currentTimestamp = Math.floor(Date.now() / 1000); + const currentTimestamp = dateToSeconds(new Date()); const header: InteropJwtHeader = { alg: JWT_HEADER_ALG, @@ -96,11 +124,122 @@ export class InteropTokenGenerator { ...claims, }; - const serializedToken = await this.createAndSignToken( + const serializedToken = await this.createAndSignToken({ + header, + payload, + keyId: this.config.generatedKid, + }); + + return { + header, + payload, + serialized: serializedToken, + }; + } + + public async generateInteropApiToken({ + sub, + consumerId, + }: { + sub: ClientId; + consumerId: TenantId; + }): Promise { + if ( + !this.config.generatedInteropTokenKid || + !this.config.generatedInteropTokenIssuer || + !this.config.generatedInteropTokenM2MAudience || + !this.config.generatedInteropTokenM2MDurationSeconds + ) { + throw Error( + "AuthorizationServerTokenGenerationConfig not provided or incomplete" + ); + } + + const currentTimestamp = Date.now(); + + const header: InteropJwtHeader = { + alg: "RS256", + use: "sig", + typ: "at+jwt", + kid: this.config.generatedInteropTokenKid, + }; + + const payload: InteropJwtApiPayload = { + jti: generateId(), + iss: this.config.generatedInteropTokenIssuer, + aud: [this.config.generatedInteropTokenM2MAudience], + sub, + iat: currentTimestamp, + nbf: currentTimestamp, + exp: + currentTimestamp + + this.config.generatedInteropTokenM2MDurationSeconds * 1000, + [ORGANIZATION_ID_CLAIM]: consumerId, + [ROLE_CLAIM]: GENERATED_INTEROP_TOKEN_M2M_ROLE, + }; + + const serializedToken = await this.createAndSignToken({ + header, + payload, + keyId: this.config.generatedInteropTokenKid, + }); + + return { + header, + payload, + serialized: serializedToken, + }; + } + + public async generateInteropConsumerToken({ + sub, + audience, + purposeId, + tokenDurationInSeconds, + digest, + }: { + sub: ClientId; + audience: string[]; + purposeId: PurposeId; + tokenDurationInSeconds: number; + digest: ClientAssertionDigest | undefined; + }): Promise { + if ( + !this.config.generatedInteropTokenKid || + !this.config.generatedInteropTokenIssuer || + !this.config.generatedInteropTokenM2MAudience + ) { + throw Error( + "AuthorizationServerTokenGenerationConfig not provided or incomplete" + ); + } + + const currentTimestamp = Date.now(); + + const header: InteropJwtHeader = { + alg: "RS256", + use: "sig", + typ: "at+jwt", + kid: this.config.generatedInteropTokenKid, + }; + + const payload: InteropJwtConsumerPayload = { + jti: generateId(), + iss: this.config.generatedInteropTokenIssuer, + aud: audience, + sub, + iat: currentTimestamp, + nbf: currentTimestamp, + exp: currentTimestamp + tokenDurationInSeconds, + purposeId, + ...(digest ? { digest } : {}), + }; + + const serializedToken = await this.createAndSignToken({ header, payload, - this.config.generatedKid - ); + keyId: this.config.generatedInteropTokenKid, + }); return { header, @@ -109,11 +248,15 @@ export class InteropTokenGenerator { }; } - private async createAndSignToken( - header: InteropJwtHeader, - payload: InteropJwtPayload | SessionJwtPayload, - keyId: string - ): Promise { + private async createAndSignToken({ + header, + payload, + keyId, + }: { + header: InteropJwtHeader; + payload: InteropJwtPayload | SessionJwtPayload | InteropJwtConsumerPayload; + keyId: string; + }): Promise { const serializedToken = `${b64UrlEncode( JSON.stringify(header) )}.${b64UrlEncode(JSON.stringify(payload))}`; diff --git a/packages/commons/src/interop-token/models.ts b/packages/commons/src/interop-token/models.ts index 45c16a577e..9054a5d483 100644 --- a/packages/commons/src/interop-token/models.ts +++ b/packages/commons/src/interop-token/models.ts @@ -1,3 +1,4 @@ +import { ClientAssertionDigest } from "pagopa-interop-models"; import { z } from "zod"; export const ORGANIZATION = "organization"; @@ -11,6 +12,9 @@ export const ORGANIZATION_EXTERNAL_ID_CLAIM = "externalId"; export const ORGANIZATION_EXTERNAL_ID_ORIGIN_CLAIM = "origin"; export const ORGANIZATION_EXTERNAL_ID_VALUE_CLAIM = "value"; export const USER_ROLES = "user-roles"; +const PURPOSE_ID_CLAIM = "purposeId"; +export const GENERATED_INTEROP_TOKEN_M2M_ROLE = "m2m"; +export const ROLE_CLAIM = "role"; export interface InteropJwtHeader { alg: string; @@ -28,6 +32,18 @@ export type InteropJwtCommonPayload = { exp: number; }; +export type InteropJwtConsumerPayload = InteropJwtCommonPayload & { + sub: string; + [PURPOSE_ID_CLAIM]: string; + digest?: ClientAssertionDigest; +}; + +export type InteropJwtApiPayload = InteropJwtCommonPayload & { + sub: string; + [ORGANIZATION_ID_CLAIM]: string; + [ROLE_CLAIM]: string; +}; + export type InteropJwtPayload = InteropJwtCommonPayload & { sub: string; role: string; @@ -39,6 +55,18 @@ export type InteropToken = { serialized: string; }; +export type InteropConsumerToken = { + header: InteropJwtHeader; + payload: InteropJwtConsumerPayload; + serialized: string; +}; + +export type InteropApiToken = { + header: InteropJwtHeader; + payload: InteropJwtApiPayload; + serialized: string; +}; + const Organization = z.object({ id: z.string(), name: z.string(), diff --git a/packages/commons/src/utils/date.ts b/packages/commons/src/utils/date.ts index 7d26258daf..8e4dcace25 100644 --- a/packages/commons/src/utils/date.ts +++ b/packages/commons/src/utils/date.ts @@ -13,6 +13,14 @@ export function formatDateyyyyMMddThhmmss(date: Date): string { return format(date, "yyyy-MM-dd'T'hh:mm:ss"); } +export function formatDateyyyyMMdd(date: Date): string { + return format(date, "yyyyMMdd"); +} + +export function formatTimehhmmss(date: Date): string { + return format(date, "hhmmss"); +} + export function dateAtRomeZone(date: Date): string { return formatInTimeZone(date, "Europe/Rome", "dd/MM/yyyy"); } @@ -20,3 +28,7 @@ export function dateAtRomeZone(date: Date): string { export function timeAtRomeZone(date: Date): string { return formatInTimeZone(date, "Europe/Rome", "HH:mm:ss"); } + +export function dateToSeconds(date: Date): number { + return Math.floor(date.getTime() / 1000); +} diff --git a/packages/models/src/brandedIds.ts b/packages/models/src/brandedIds.ts index 3da409a6e9..201c9c74dd 100644 --- a/packages/models/src/brandedIds.ts +++ b/packages/models/src/brandedIds.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "crypto"; +import crypto from "crypto"; import { z } from "zod"; export const CorrelationId = z.string().brand("CorrelationId"); @@ -177,7 +177,7 @@ type IDS = // it infers the type of the ID based on how is used the result // the 'as' is used to cast the uuid string to the inferred type export function generateId(): T { - return randomUUID() as T; + return crypto.randomUUID() as T; } // This function is used to get a branded ID from a string diff --git a/packages/models/src/client-assertion/clientAssertionValidation.ts b/packages/models/src/client-assertion/clientAssertionValidation.ts new file mode 100644 index 0000000000..1a12ce3ca8 --- /dev/null +++ b/packages/models/src/client-assertion/clientAssertionValidation.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { ClientId, PurposeId } from "../brandedIds.js"; + +export const ClientAssertionDigest = z + .object({ + alg: z.string(), + value: z.string(), + }) + .strict(); +export type ClientAssertionDigest = z.infer; + +export const ClientAssertionHeader = z + .object({ + kid: z.string(), + alg: z.string(), + typ: z.string().optional(), + }) + .strict(); +export type ClientAssertionHeader = z.infer; + +export const ClientAssertionPayload = z + .object({ + sub: ClientId, + jti: z.string(), + iat: z.number(), + iss: z.string(), + aud: z.array(z.string()).or(z.string()), + exp: z.number(), + digest: ClientAssertionDigest.optional(), + purposeId: PurposeId.optional(), + }) + .strict(); +export type ClientAssertionPayload = z.infer; + +export const ClientAssertion = z + .object({ + header: ClientAssertionHeader, + payload: ClientAssertionPayload, + }) + .strict(); +export type ClientAssertion = z.infer; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 8e7aeee6e8..efba64d4d7 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -62,6 +62,8 @@ export * from "./user/user.js"; export * from "./token-generation-readmodel/platform-states-entry.js"; export * from "./token-generation-readmodel/token-generation-states-entry.js"; export * from "./token-generation-readmodel/commons.js"; +export * from "./token-generation-audit/audit.js"; +export * from "./client-assertion/clientAssertionValidation.js"; // Protobuf export * from "./protobuf/protobuf.js"; diff --git a/packages/models/src/token-generation-audit/audit.ts b/packages/models/src/token-generation-audit/audit.ts new file mode 100644 index 0000000000..b3376dc26d --- /dev/null +++ b/packages/models/src/token-generation-audit/audit.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { + AgreementId, + ClientId, + DescriptorId, + EServiceId, + PurposeId, + PurposeVersionId, + TenantId, +} from "../brandedIds.js"; + +export const ClientAssertionAuditDetails = z.object({ + jwtId: z.string(), + issuedAt: z.number(), + algorithm: z.string(), + keyId: z.string(), + issuer: z.string(), + subject: ClientId, + audience: z.string(), + expirationTime: z.number(), +}); +export type ClientAssertionAuditDetails = z.infer< + typeof ClientAssertionAuditDetails +>; + +export const GeneratedTokenAuditDetails = z.object({ + jwtId: z.string(), + correlationId: z.string(), + issuedAt: z.number(), + clientId: ClientId, + organizationId: TenantId, + agreementId: AgreementId, + eserviceId: EServiceId, + descriptorId: DescriptorId, + purposeId: PurposeId, + purposeVersionId: PurposeVersionId, + algorithm: z.string(), + keyId: z.string(), + audience: z.string(), + subject: z.string(), + notBefore: z.number(), + expirationTime: z.number(), + issuer: z.string(), + clientAssertion: ClientAssertionAuditDetails, +}); +export type GeneratedTokenAuditDetails = z.infer< + typeof GeneratedTokenAuditDetails +>; diff --git a/packages/models/src/token-generation-readmodel/token-generation-states-entry.ts b/packages/models/src/token-generation-readmodel/token-generation-states-entry.ts index c36a56cf9a..1f82f59bf6 100644 --- a/packages/models/src/token-generation-readmodel/token-generation-states-entry.ts +++ b/packages/models/src/token-generation-readmodel/token-generation-states-entry.ts @@ -46,6 +46,12 @@ export type TokenGenerationStatesClientPurposeEntry = z.infer< typeof TokenGenerationStatesClientPurposeEntry >; +export const FullTokenGenerationStatesClientPurposeEntry = + TokenGenerationStatesClientPurposeEntry.required(); +export type FullTokenGenerationStatesClientPurposeEntry = z.infer< + typeof FullTokenGenerationStatesClientPurposeEntry +>; + export const TokenGenerationStatesClientEntry = TokenGenerationStatesBaseEntry.extend({ PK: TokenGenerationStatesClientKidPK, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1885d59312..81acb9ffd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,6 +808,97 @@ importers: specifier: 1.6.0 version: 1.6.0(@types/node@20.14.6) + packages/authorization-server: + dependencies: + '@aws-sdk/client-dynamodb': + specifier: 3.637.0 + version: 3.637.0 + '@aws-sdk/client-kms': + specifier: 3.600.0 + version: 3.600.0 + '@aws-sdk/util-dynamodb': + specifier: 3.637.0 + version: 3.637.0(@aws-sdk/client-dynamodb@3.637.0) + '@zodios/core': + specifier: 10.9.6 + version: 10.9.6(axios@1.7.4)(zod@3.23.8) + '@zodios/express': + specifier: 10.6.1 + version: 10.6.1(@zodios/core@10.9.6(axios@1.7.4)(zod@3.23.8))(express@4.20.0)(zod@3.23.8) + axios: + specifier: 1.7.4 + version: 1.7.4 + connection-string: + specifier: 4.4.0 + version: 4.4.0 + dotenv-flow: + specifier: 4.1.0 + version: 4.1.0 + express: + specifier: 4.20.0 + version: 4.20.0 + kafka-iam-auth: + specifier: workspace:* + version: link:../kafka-iam-auth + openapi-zod-client: + specifier: 1.18.1 + version: 1.18.1 + pagopa-interop-api-clients: + specifier: workspace:* + version: link:../api-clients + pagopa-interop-client-assertion-validation: + specifier: workspace:* + version: link:../client-assertion-validation + pagopa-interop-commons: + specifier: workspace:* + version: link:../commons + pagopa-interop-models: + specifier: workspace:* + version: link:../models + ts-pattern: + specifier: 5.2.0 + version: 5.2.0 + zod: + specifier: 3.23.8 + version: 3.23.8 + devDependencies: + '@pagopa/eslint-config': + specifier: 3.0.0 + version: 3.0.0(tsutils@3.21.0(typescript@5.4.5))(typescript@5.4.5) + '@protobuf-ts/runtime': + specifier: 2.9.4 + version: 2.9.4 + '@types/express': + specifier: 4.17.21 + version: 4.17.21 + '@types/node': + specifier: 20.14.6 + version: 20.14.6 + '@types/uuid': + specifier: 9.0.8 + version: 9.0.8 + jose: + specifier: 5.9.4 + version: 5.9.4 + pagopa-interop-commons-test: + specifier: workspace:* + version: link:../commons-test + prettier: + specifier: 2.8.8 + version: 2.8.8 + tsx: + specifier: 4.19.1 + version: 4.19.1 + typescript: + specifier: 5.4.5 + version: 5.4.5 + uuid: + specifier: 10.0.0 + version: 10.0.0 + vitest: + specifier: 1.6.0 + version: 1.6.0(@types/node@20.14.6) + packages/authorization-updater: dependencies: '@protobuf-ts/runtime': @@ -1482,6 +1573,9 @@ importers: dotenv-flow: specifier: 4.1.0 version: 4.1.0 + jose: + specifier: 5.9.4 + version: 5.9.4 jsonwebtoken: specifier: 9.0.2 version: 9.0.2 @@ -4949,6 +5043,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -7699,6 +7796,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -8093,8 +8194,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.600.0(@aws-sdk/client-sts@3.600.0) - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sso-oidc': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/core': 3.598.0 '@aws-sdk/credential-provider-node': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0) '@aws-sdk/middleware-endpoint-discovery': 3.598.0 @@ -8240,8 +8341,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.600.0(@aws-sdk/client-sts@3.600.0) - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sso-oidc': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/core': 3.598.0 '@aws-sdk/credential-provider-node': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0) '@aws-sdk/middleware-host-header': 3.598.0 @@ -8347,8 +8448,8 @@ snapshots: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.600.0(@aws-sdk/client-sts@3.600.0) - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sso-oidc': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/core': 3.598.0 '@aws-sdk/credential-provider-node': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0) '@aws-sdk/middleware-bucket-endpoint': 3.598.0 @@ -8455,8 +8556,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.600.0(@aws-sdk/client-sts@3.600.0) - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sso-oidc': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/core': 3.598.0 '@aws-sdk/credential-provider-node': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0) '@aws-sdk/middleware-host-header': 3.598.0 @@ -8499,11 +8600,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.600.0(@aws-sdk/client-sts@3.600.0)': + '@aws-sdk/client-sso-oidc@3.600.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/core': 3.598.0 '@aws-sdk/credential-provider-node': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0) '@aws-sdk/middleware-host-header': 3.598.0 @@ -8542,7 +8643,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.6.3 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.609.0(@aws-sdk/client-sts@3.609.0)': @@ -9020,11 +9120,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.600.0': + '@aws-sdk/client-sts@3.600.0(@aws-sdk/client-sso-oidc@3.600.0)': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.600.0(@aws-sdk/client-sts@3.600.0) + '@aws-sdk/client-sso-oidc': 3.600.0 '@aws-sdk/core': 3.598.0 '@aws-sdk/credential-provider-node': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0) '@aws-sdk/middleware-host-header': 3.598.0 @@ -9063,6 +9163,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.6.3 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.609.0': @@ -9393,7 +9494,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.598.0(@aws-sdk/client-sso-oidc@3.600.0)(@aws-sdk/client-sts@3.600.0)': dependencies: - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/credential-provider-env': 3.598.0 '@aws-sdk/credential-provider-http': 3.598.0 '@aws-sdk/credential-provider-process': 3.598.0 @@ -9710,7 +9811,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.598.0(@aws-sdk/client-sts@3.600.0)': dependencies: - '@aws-sdk/client-sts': 3.600.0 + '@aws-sdk/client-sts': 3.600.0(@aws-sdk/client-sso-oidc@3.600.0) '@aws-sdk/types': 3.598.0 '@smithy/property-provider': 3.1.7 '@smithy/types': 3.5.0 @@ -10138,7 +10239,7 @@ snapshots: '@aws-sdk/token-providers@3.598.0(@aws-sdk/client-sso-oidc@3.600.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.600.0(@aws-sdk/client-sts@3.600.0) + '@aws-sdk/client-sso-oidc': 3.600.0 '@aws-sdk/types': 3.598.0 '@smithy/property-provider': 3.1.7 '@smithy/shared-ini-file-loader': 3.1.8 @@ -11993,6 +12094,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/uuid@9.0.8': {} + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -15196,6 +15299,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@9.0.1: {} vary@1.1.2: {}