diff --git a/CHANGELOG.md b/CHANGELOG.md index f664ab5c5d..a69bd4bf8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,262 +1,266 @@ # CHANGELOG -## 3.361.0 - 2025-11-19 - -* `Aws\Route53` - Add dual-stack endpoint support for Route53 -* `Aws\CloudWatchRUM` - CloudWatch RUM now supports mobile application monitoring for Android and iOS platforms -* `Aws\DataZone` - Amazon DataZone now supports business metadata (readme and metadata forms) at the individual attribute (column) level, a new rule type for glossary terms, and the ability to update the owner of the root domain unit. -* `Aws\Lambda` - Added support for creating and invoking Tenant Isolated functions in AWS Lambda APIs. -* `Aws\Inspector2` - This release introduces BLOCKED_BY_ORGANIZATION_POLICY error code and IMAGE_ARCHIVED scanStatusReason. BLOCKED_BY_ORGANIZATION_POLICY error code is returned when an operation is blocked by an AWS Organizations policy. IMAGE_ARCHIVED scanStatusReason is returned when an Image is archived in ECR. -* `Aws\Signin` - AWS Sign-In manages authentication for AWS services. This service provides secure authentication flows for accessing AWS resources from the console and developer tools. This release adds the CreateOAuth2Token API, which can be used to fetch OAuth2 access tokens and refresh tokens from Sign-In. -* `Aws\EC2` - This launch adds support for two new features: Regional NAT Gateway and IPAM Policies. IPAM policies offers customers central control for public IPv4 assignments across AWS services. Regional NAT is a single NAT Gateway that automatically expands across AZs in a VPC to maintain high availability. -* `Aws\Billing` - Added name filtering support to ListBillingViews API through the new names parameter to efficiently filter billing views by name. -* `Aws\MediaConnect` - This release adds support for global routing in AWS Elemental MediaConnect. You can now use router inputs and router outputs to manage global video and audio routing workflows both within the AWS-Cloud and over the public internet. -* `Aws\CostExplorer` - Add support for COST_CATEGORY, TAG, and LINKED_ACCOUNT AWS managed cost anomaly detection monitors -* `Aws\IAM` - Added the EnableOutboundWebIdentityFederation, DisableOutboundWebIdentityFederation and GetOutboundWebIdentityFederationInfo APIs for the IAM outbound federation feature. -* `Aws\SecretsManager` - Adds support to create, update, retrieve, rotate, and delete managed external secrets. -* `Aws\PartnerCentralChannel` - Initial GA launch of Partner Central Channel -* `Aws\SFN` - Adds support to TestState for mocked results and exceptions, along with additional inspection data. -* `Aws\GuardDuty` - Add support for scanning and viewing scan results for backup resource types -* `Aws\FSx` - Adding File Server Resource Manager configuration to FSx Windows -* `Aws\EMR` - Add CloudWatch Logs integration for Spark driver, executor and step logs -* `Aws\NetworkFirewall` - Partner Managed Rulegroup feature support -* `Aws\S3` - Adds support for blocking SSE-C writes to general purpose buckets. -* `Aws\Invoicing` - Add support for adding Billing transfers in Invoice configuration -* `Aws\NetworkFlowMonitor` - Added new enum value (AWS::EKS::Cluster) for type field under MonitorLocalResource -* `Aws\Health` - Adds actionability and personas properties to Health events exposed through DescribeEvents, DescribeEventsForOrganization, DescribeEventDetails, and DescribeEventTypes APIs. Adds filtering by actionabilities and personas in EventFilter, OrganizationEventFilter, EventTypeFilter. -* `Aws\CloudTrail` - AWS CloudTrail now supports Insights for data events, expanding beyond management events to automatically detect unusual activity on data plane operations. -* `Aws\BedrockRuntime` - This release includes support for Search Results. -* `Aws\CostOptimizationHub` - Release ListEfficiencyMetrics API -* `Aws\APIGateway` - API Gateway now supports response streaming and new security policies for REST APIs and custom domain names. -* `Aws\CloudWatchLogs` - Adding support for ocsf version 1.5, add optional parameter MappingVersion -* `Aws\BillingConductor` - This release adds support for Billing Transfers, enabling management of billing transfers with billing groups on AWS Billing Conductor. -* `Aws\ApiGatewayV2` - Support for API Gateway portals and portal products. -* `Aws\SageMaker` - Added support for enhanced metrics for SageMaker AI Endpoints. This features provides Utilization Metrics at instance and container granularity and also provides easy configuration of metric publish frequency from 10 sec -> 5 mins -* `Aws\ECR` - Add support for ECR archival storage class and Inspector org policy for scanning -* `Aws\ECS` - Added support for Amazon ECS Managed Instances infrastructure optimization configuration. -* `Aws\ConnectCampaignsV2` - This release added support for ring timer configuration for campaign calls. -* `Aws\Backup` - Amazon GuardDuty Malware Protection now supports AWS Backup, extending malware detection capabilities to EC2, EBS, and S3 backups. -* `Aws\BCMPricingCalculator` - Add GroupSharingPreference, CostCategoryGroupSharingPreferenceArn, and CostCategoryGroupSharingPreferenceEffectiveDate to Bill Estimate. Add GroupSharingPreference and CostCategoryGroupSharingPreferenceArn to Bill Scenario. -* `Aws\MediaLive` - MediaLive is adding support for MediaConnect Router by supporting a new input type called MEDIACONNECT_ROUTER. This new input type will provide seamless encrypted transport between MediaConnect Router and your MediaLive channel. -* `Aws\DynamoDB` - Extended Global Secondary Index (GSI) composite keys to support up to 8 attributes. -* `Aws\STS` - IAM now supports outbound identity federation via the STS GetWebIdentityToken API, enabling AWS workloads to securely authenticate with external services using short-lived JSON Web Tokens. - -## 3.360.1 - 2025-11-18 - -* `Aws\AutoScaling` - This release adds the new LaunchInstances API, which can launch instances synchronously in an AutoScaling group. The API also returns instances info and launch error back immediately. -* `Aws\BedrockRuntime` - Amazon Bedrock Runtime Service Tier Support Launch -* `Aws\EC2` - AWS Site-to-Site VPN now supports VPN Concentrator, a new feature that enables customers to connect multiple low-bandwidth sites connections through a single attachment, simplifying multi-site connectivity for distributed enterprises. -* `Aws\ResourceGroupsTaggingAPI` - Add support for new ListRequiredTags API used to retrieve the required tags specified in a customer's effective tag policy. -* `Aws\IAM` - Added the AssociateDelegationRequest, GetDelegationRequest, AcceptDelegationRequest, RejectDelegatonRequest, ListDelegationRequests, UpdateDelegationRequest, SendDelegationToken and GetHumanReadableSummary APIs for the IAM temporary delegation feature. -* `Aws\Backup` - AWS Backup now supports a low-cost warm storage tier for Amazon S3 backup data. -* `Aws\Kafka` - Amazon MSK adds three new APIs, ListTopics, DescribeTopic, and DescribeTopicPartitions for viewing Kafka topics in your MSK clusters. -* `Aws\CloudFormation` - New CloudFormation DescribeEvents API with operation ID tracking and failure filtering capabilities to quickly identify root causes of deployment failures. Also, a DeploymentMode parameter for the CreateChangeSet API that enables creation of drift-aware change sets for safe drift management. -* `Aws\StorageGateway` - Adds support for European Sovereign Cloud ARNs in Storage Gateway API parameters. -* `Aws\WAFV2` - AssociateWebACL, UpdateWebACL and PutLoggingConfiguration will now throw WAFFeatureNotIncludedInPricingPlanException when the request contains a feature that is not included in the CloudFront pricing plan of the WebACL. -* `Aws\CloudWatchLogs` - CloudWatch Logs updates: Added capability to setup a recurring schedule for log insights queries. Logs introduced Scheduled Queries (managed through Create/Update/Get/Delete/List/History Scheduled Query APIs). For more information, see CloudWatch Logs API documentation. -* `Aws\Connect` - This release added support for ring timer configuration for campaign calls. - -## 3.360.0 - 2025-11-17 - -* `Aws\Glue` - Amazon Glue Releasing 2 the new API ListIntegrationResourceProperties and DeleteIntegrationResourceProperty along with minor improvement on existing API(s). -* `Aws\MediaPackageV2` - Add support for SCTE messages in Segment file output -* `Aws\LexModelsV2` - Adds support for LLM as Primary, allowing usage of LLMs as the default NLU system. -* `Aws\PCS` - Added support for the managed Slurm REST API endpoint -* `Aws\GuardDuty` - Add S3 On-Demand Object Scanning -* `Aws\Backup` - AWS Backup now supports specifying a logically air-gapped backup vault as a primary backup target in backup plans and on-demand backup jobs. -* `Aws\OpenSearchService` - This release adds index operation APIs to support Automatic Semantic Enrichment feature -* `Aws\MediaLive` - Adds configurations for spatial/temporal adaptive quantization in AV1 codec, and conversion to HLG output color space in H265 codec. -* `Aws\Route53Resolver` - Adding DICTIONARY_DGA to dns-threat-protection as a new enum type. Customers can now set rules for dictionary dga protection -* `Aws\AppStream` - Adding support for additional instances and extended storage -* `Aws\EC2` - This release introduces new APIs: DescribeInstanceSqlHaStates, DescribeInstanceSqlHaHistoryStates, EnableInstanceSqlHaStandbyDetections and DisableInstanceSqlHaStandbyDetections on Amazon EC2, allowing customers to enroll and monitor SQL Server licensing fee savings for their SQL HA EC2 instances. -* `Aws\DeviceFarm` - This release adds support for interacting with devices during a remote access session using the remoteDriverEndpoint interface -* `Aws\MWAAServerless` - Amazon MWAA now offers serverless deployment, eliminating operational overhead while optimizing costs. The service supports YAML and Python-based workflows, with 80+ AWS Operators. It provides isolated execution, IAM permissions, and automatic scaling with pay-per-use pricing. -* `Aws\DatabaseMigrationService` - This release introduces the SAP ASE(Sybase) Data Provider for AWS Data Migration Service (DMS). In addition, DMS Schema Conversion now supports this provider, enabling customers to migrate SAP ASE(Sybase) databases to Amazon RDS for PostgreSQL or Aurora PostgreSQL seamlessly. -* `Aws\Bedrock` - Automated Reasoning checks in Amazon Bedrock Guardrails now automatically generate Q&A tests for new Automated Reasoning policies. The GetAutomatedReasoningPolicyBuildWorkflowResultAssets API adds GENERATED_TEST_CASES asset type, allowing customers to retrieve tests generated by the build workflow. - -## 3.359.13 - 2025-11-14 - -* `Aws\imagebuilder` - EC2 Image Builder now supports invoking Lambda functions and executing Step Functions state machine through image workflows. -* `Aws\MediaLive` - Removed all the value constraint (min/max) for the shape definitions (e.g. integerMin0Max3600) on the C2j models to get rid of the need to request an exemption from the SDK team whenever a shape definition (e.g. integerMin0Max3600) is changed. -* `Aws\DataZone` - Adds support for granting read and write access to Amazon S3 general purpose buckets using CreateSubscriptionRequest and AcceptSubscriptionRequest APIs. Also adds search filters for SSOUser and SSOGroup to ListSubscriptions APIs and deprecates "sortBy" parameter for ListSubscriptions APIs. -* `Aws\EC2` - This release adds AvailabilityZoneId support for CreateInstanceConnectEndpoint, DescribeInstanceConnectEndpoints, and DeleteInstanceConnectEndpoint APIs. - -## 3.359.12 - 2025-11-13 - -* `Aws\EC2` - Added support for new accelerator types ("media") and accelerator names ("L4", "L40s", "GAUDI_HL_205", "INFERENTIA2", "TRAINIUM", "TRAINIUM2", "U30") in Attributes Based Instance Type Selection for launched instance types. -* `Aws\IoTWireless` - Integration of Device Location with Amazon Sidewalk network for Amazon Sidewalk enabled devices -* `Aws\ControlCatalog` - Added support for related control mappings with new RELATED_CONTROL mapping type in ListControlMappings API. -* `Aws\WorkSpacesWeb` - Support for managing web content filtering for defining, tracking and regulating type of content accessed with WorkSpaces Secure Browser as part of browser settings. -* `Aws\MediaConvert` - Lowers minimum duration for black video generator. Adds support for embedding and signing C2PA content credentials in DASH and CMAF HLS outputs. -* `Aws\RDS` - Updated endpoint and service metadata -* `Aws\CloudFormation` - CloudFormation now supports GetHookResult API with annotations to retrieve structured compliance check results and remediation guidance for each evaluated resource, replacing the previous single-message limitation with detailed validation outcomes. -* `Aws\ECR` - Add Amazon ECR FIPS PrivateLink endpoint support -* `Aws\ElasticLoadBalancingv2` - QUIC and TCP_QUIC protocol support for Network Load Balancer (NLB). This capability enables customers to forward QUIC traffic to their targets with ultra-low latency while maintaining session stickiness using QUIC Connection IDs. -* `Aws\SageMaker` - Added support for minor version upgrades and AWS Identity Center integration for SageMaker Hadron Partner Apps, enabling automated version management and IdC group-based access control. - -## 3.359.11 - 2025-11-12 - -* `Aws\Connect` - Updated Authentication Profile APIs to add support for automatic logout on user inactivity -* `Aws\ElasticLoadBalancingv2` - This release expands ALB Authentication to support JWT verification and adds support for a new JWT validation action in listener rule. -* `Aws\EC2` - Adds complete AMI ancestry tracing from immediate parent through each preceding generation back to the root AMI -* `Aws\DatabaseMigrationService` - Added support of SQL statements creation, metadata model discovery and selection rules transformation. -* `Aws\S3Tables` - Adds support for request metrics metrics APIs for S3 Tables -* `Aws\PrometheusService` - Add VPC source configuration support enabling Amazon Managed Service for Prometheus Collector to collect metrics from MSK clusters. -* `Aws\Redshift` - Added GetIdentityCenterAuthToken API to retrieve encrypted authentication tokens for Identity Center integrated applications. This API enables programmatic access to secure Identity Center tokens with proper error handling and parameter validation across supported SDK languages. -* `Aws\SageMaker` - Add support for trn2.3xlarge instance type for SageMaker Hyperpod - -## 3.359.10 - 2025-11-11 - -* `Aws\RTBFabric` - Added LogSettings and LinkAttribute fields to external links -* `Aws\SecurityIR` - Added support for configuring communication preferences as well as clearly displaying case comment author identities. -* `Aws\EC2` - AWS Site-to-Site VPN now supports VPN connections with up to 5 Gbps bandwidth per tunnel, a 4x improvement from existing limit of 1.25 Gbps. -* `Aws\MedicalImaging` - Added new fields in existing APIs. -* `Aws\Batch` - Documentation-only update: update API and doc descriptions per EKS ImageType default value switch from AL2 to AL2023. -* `Aws\BedrockDataAutomation` - Added support for Language Expansion feature for BDA Audio modality. - -## 3.359.9 - 2025-11-10 - -* `Aws\DSQL` - Cluster endpoint added to CreateCluster and GetCluster API responses -* `Aws\Invoicing` - Added new invoicing get-invoice-pdf API Operation -* `Aws\Braket` - Adds ExperimentalCapabilities field to CreateQuantumTask request and GetQuantumTask response objects. Enables use of experimental software capabilities when creating quantum tasks. -* `Aws\Kafka` - Amazon MSK now supports intelligent rebalancing for MSK Express brokers. -* `Aws\WAFV2` - AWS WAF now supports CLOUDWATCH_TELEMETRY_RULE_MANAGED as a LogScope option, enabling automated logging configuration through Amazon CloudWatch Logs for telemetry data collection and analysis. -* `Aws\STS` - Added GetDelegatedAccessToken API, which is not available for general use at this time. -* `Aws\IAM` - Added CreateDelegationRequest API, which is not available for general use at this time. -* `Aws\EC2` - Amazon EC2 Fleet customers can now filter instance types based on encryption-in-transit support using Attribute-Based Instance Type Selection (ABIS), eliminating the manual effort of identifying and selecting compatible instance types for security-sensitive workloads. -* `Aws\GuardDuty` - Include tags filed in CreatePublishingDestinationRequest and DescribePublishingDestinationResponse. -* `Aws\Backup` - AWS Backup supports backups of Amazon EKS clusters, including Kubernetes cluster state and persistent storage attached to the EKS cluster via a persistent volume claim (EBS volumes, EFS file systems, and S3 buckets). -* `Aws\ACMPCA` - Private Certificate Authority service now supports ML-DSA key algorithms. -* `Aws\DataZone` - Remove trackingServerName from DataZone Connection MLflowProperties -* `Aws\AppStream` - AWS Appstream support for IPv6 -* `Aws\VerifiedPermissions` - Amazon Verified Permissions / Features : Adds support for entity Cedar tags. - -## 3.359.8 - 2025-11-07 - -* `Aws\` - Removes `QLDB`, `QLDBSession`, `Robomaker`, `LookoutMetrics`, `LookoutVision`, `IoTFleetHub` and `Apptest` services, which have been deprecated. -* `Aws\KMS` - Added support for new ECC_NIST_EDWARDS25519 AWS KMS key spec -* `Aws\ControlTower` - Added Parent Identifier support to ListEnabledControls and GetEnabledControl API. Implemented RemediationType support for Landing Zone operations: CreateLandingZone, UpdateLandingZone and GetLandingZone APIs -* `Aws\VPCLattice` - Amazon VPC Lattice now supports custom domain name for resource configurations -* `Aws\OpenSearchService` - This release introduces the Default Application feature, allowing users to set, change, or unset a preferred OpenSearch UI application on a per-region basis for a streamlined and consistent user experience. -* `Aws\EC2` - Adds PrivateDnsPreference and PrivateDnsSpecifiedDomains to control private DNS resolution for resource and service network VPC endpoints and IpamScopeExternalAuthorityConfiguration to integrate Amazon VPC IPAM with a third-party IPAM service - -## 3.359.7 - 2025-11-06 - -* `Aws\Backup` - AWS Backup now supports customer-managed keys (CMK) for logically air-gapped vaults, enabling customers to maintain full control over their encryption key lifecycle. This feature helps organizations meet specific internal governance requirements or external regulatory compliance standards. -* `Aws\SSM` - Provides NoLongerSupportedException error message -* `Aws\QuickSight` - Support for New Data Prep Experience -* `Aws\IdentityStore` - IdentityStore API: added new KMSExceptionReason fields to the Exception object; added multiple new fields to the User APIs - UserStatus, Birthdate, Website and Photos; added multiple new metadata fields for User, Groups and Membership APIs - CreatedAt, CreatedBy, UpdatedAt and UpdatedBy. -* `Aws\EC2` - Add Amazon EC2 R8a instance types -* `Aws\S3Tables` - Adds support for tagging APIs for S3 Tables -* `Aws\AccessAnalyzer` - New field totalActiveErrors added to getFindingsStatistics response. -* `Aws\S3Vectors` - Amazon S3 Vectors provides cost-effective, elastic, and durable vector storage for queries based on semantic meaning and similarity. -* `Aws\SageMaker` - Added NodeProvisioningMode parameter to UpdateCluster API to determine how instance provisioning is handled during cluster operations; in Continuous mode. Added VpcId field in UpdateDomain request for SageMaker Unified Studio domains with no VPC to add a customer VPC. -* `Aws\GameLift` - Amazon GameLift Servers now supports game builds that use the Windows 2022 operating system. -* `Aws\Connect` - Added support for Conditional Questions in Evaluation Forms. Introduced Auto Evaluation capability for Evaluation Forms and Contact Evaluations. Added new API operations: SearchEvaluationForms and SearchContactEvaluations. - -## 3.359.6 - 2025-11-05 - -* `Aws\FSx` - Amazon FSx now enables secure management of Active Directory credentials through AWS Secrets Manager integration. Customers can use Secret ARNs instead of direct credentials when joining resources to Active Directory domains. -* `Aws\EC2` - This release adds AvailabilityZoneId support for DescribeFastSnapshotRestores, DisableFastSnapshotRestores, and EnableFastSnapshotRestores APIs. -* `Aws\GroundStation` - Introduce CreateDataflowEndpointGroupV2 action -* `Aws\CloudFront` - This release adds new and updated API operations. You can now use the IpAddressType field to specify either ipv4 or dualstack for your Anycast static IP list. You can also enable cross-account resource sharing to share your VPC origins with other AWS accounts -* `Aws\S3` - Launch IPv6 dual-stack support for S3 Express -* `Aws\DataZone` - Added support for Project Resource Tags -* `Aws\SageMaker` - Add new fields in SageMaker Hyperpod DescribeCluster API response: TargetStateCount, SoftwareUpdateStatus and ActiveSoftwareDeploymentConfig to provide AMI update progress visibility . - -## 3.359.5 - 2025-11-04 - -* `Aws\PinpointSMSVoiceV2` - This release adds support for the CarrierLookup API, which returns information about a destination phone number including if the number is valid, the carrier, and more. - -## 3.359.4 - 2025-11-03 - -* `Aws\Kinesis` - Adds support for MinimumThroughputBillingCommitment with new UpdateAccountSettings API. Adds support to configure warm throughput for on-demand streams in new UpdateStreamWarmThroughput API and existing CreateStream API and UpdateStreamMode API. -* `Aws\EC2` - Add Amazon EC2 trn2.3xlarge instance type. -* `Aws\BedrockAgentCoreControl` - Adds support for direct code deploy with CreateAgentRuntime and UpdateAgentRuntime -* `Aws\ECS` - Documentation-only update for LINEAR and CANARY deployment strategies. -* `Aws\Budgets` - Fix the AWS Budgets endpoint for the aws-eusc partition. - -## 3.359.3 - 2025-10-31 - -* `Aws\Omics` - Added WDL_LENIENT engine type that enables implicit typecasting of variable values to its compatible declared types -* `Aws\SavingsPlans` - Add dual-stack endpoint support for Savings Plans -* `Aws\PaymentCryptography` - Allow additional characters in the CertificateSubject for GetCertificateSigningRequest API. -* `Aws\SSMQuickSetup` - Update endpoint ruleset parameters casing -* `Aws\MarketplaceCatalog` - Update endpoint ruleset parameters casing -* `Aws\WAF` - Update endpoint ruleset parameters casing -* `Aws\Kinesis` - Update endpoint ruleset parameters casing -* `Aws\FSx` - Update endpoint ruleset parameters casing -* `Aws\Textract` - Update endpoint ruleset parameters casing -* `Aws\ResourceGroupsTaggingAPI` - Update endpoint ruleset parameters casing -* `Aws\Snowball` - Update endpoint ruleset parameters casing -* `Aws\Health` - Update endpoint ruleset parameters casing -* `Aws\ConnectCases` - Added two new case rule types: Parent Child Field Options (restricts child field options based on parent field value) and Hidden (controls child field visibility based on parent field value). Both enable dynamic field behavior within templates. -* `Aws\EMR` - Update endpoint ruleset parameters casing -* `Aws\EC2` - Amazon VPC IP Address Manager (IPAM) now supports automated prefix list management, allowing you to create rules that automatically populate customer-managed prefix lists with CIDRs from your IPAM pools or AWS resources based on tags, Regions, or other criteria. -* `Aws\Redshift` - Update endpoint ruleset parameters casing -* `Aws\MediaConvert` - Adds SlowPalPitchCorrection to audio pitch correction settings. Enables opacity for VideoOverlays. Adds REMUX_ALL option to enable multi-rendition passthrough to VideoSelector for allow listed accounts. -* `Aws\CloudWatchLogs` - Update endpoint ruleset parameters casing -* `Aws\Lambda` - Add Python3.14 (python3.14) and Java 25 (java25) support to AWS Lambda -* `Aws\SageMaker` - Allow update of platform identifier via UpdateNotebookInstance operation. -* `Aws\FMS` - Update endpoint ruleset parameters casing - -## 3.359.2 - 2025-10-30 - -* `Aws\ConnectCases` - Update endpoint ruleset parameters casing -* `Aws\CleanRooms` - Added support for advanced Spark configurations to optimize SQL performance -* `Aws\DevOpsGuru` - Update endpoint ruleset parameters casing -* `Aws\ComputeOptimizer` - Update endpoint ruleset parameters casing -* `Aws\AuditManager` - Update endpoint ruleset parameters casing -* `Aws\CloudDirectory` - Update endpoint ruleset parameters casing -* `Aws\AppSync` - Update endpoint ruleset parameters casing -* `Aws\PrometheusService` - Add Anomaly Detection APIs for Amazon Managed Prometheus -* `Aws\RTBFabric` - RTB Fabric documentation update. -* `Aws\Deadline` - Update endpoint ruleset parameters casing -* `Aws\Glue` - This release adds the capability to enable User Background Sessions for customers running Trusted Identity Propagation enabled Interactive Sessions on AWS Glue. -* `Aws\AppConfig` - Update endpoint ruleset parameters casing -* `Aws\Neptune` - Update endpoint ruleset parameters casing -* `Aws\ApplicationCostProfiler` - Update endpoint ruleset parameters casing -* `Aws\GeoPlaces` - Update endpoint ruleset parameters casing -* `Aws\Firehose` - Update endpoint ruleset parameters casing -* `Aws\FraudDetector` - Update endpoint ruleset parameters casing -* `Aws\ElastiCache` - Update endpoint ruleset parameters casing -* `Aws\CodeCommit` - Update endpoint ruleset parameters casing -* `Aws\EKSAuth` - Update endpoint ruleset parameters casing -* `Aws\BedrockAgentCoreControl` - Web-Bot-Auth support for AgentCore Browser tool to help reduce captcha challenges. -* `Aws\EMRServerless` - This release adds the capability to enable User Background Sessions for customers running Trusted Identity Propagation enabled Interactive Sessions on EMR Serverless Applications. -* `Aws\Schemas` - Update endpoint ruleset parameters casing -* `Aws\BedrockAgent` - Update endpoint ruleset parameters casing -* `Aws\Chime` - Update endpoint ruleset parameters casing -* `Aws\AppMesh` - Update endpoint ruleset parameters casing -* `Aws\LicenseManagerLinuxSubscriptions` - Update endpoint ruleset parameters casing -* `Aws\IoTManagedIntegrations` - Add a new GetManagedThingCertificate API to expose Iot ManagedIntegrations (MI) device certificate, and add "-" support for name, properties, actions and events in the CapabilityReportCapability object. -* `Aws\IoTEventsData` - Update endpoint ruleset parameters casing -* `Aws\APIGateway` - Update endpoint ruleset parameters casing -* `Aws\KMS` - Add cross account VPC endpoint service connectivity support to CustomKeyStore. -* `Aws\ECS` - Amazon ECS Service Connect now supports Envoy access logs, providing deeper observability into request-level traffic patterns and service interactions. -* `Aws\Artifact` - Update endpoint ruleset parameters casing -* `Aws\MarketplaceReporting` - Update endpoint ruleset parameters casing -* `Aws\Appflow` - Update endpoint ruleset parameters casing -* `Aws\STS` - Update endpoint ruleset parameters casing -* `Aws\GreengrassV2` - Update endpoint ruleset parameters casing -* `Aws\CodeCatalyst` - Update endpoint ruleset parameters casing -* `Aws\CloudSearch` - Update endpoint ruleset parameters casing -* `Aws\S3Outposts` - Update endpoint ruleset parameters casing -* `Aws\ServiceCatalog` - Update endpoint ruleset parameters casing -* `Aws\CloudControlApi` - Update endpoint ruleset parameters casing -* `Aws\KeyspacesStreams` - Update endpoint ruleset parameters casing -* `Aws\ServerlessApplicationRepository` - Update endpoint ruleset parameters casing -* `Aws\CognitoSync` - Update endpoint ruleset parameters casing -* `Aws\SageMakerRuntime` - Update endpoint ruleset parameters casing -* `Aws\SSO` - Update endpoint ruleset parameters casing -* `Aws\CloudWatch` - Update endpoint ruleset parameters casing -* `Aws\DocDB` - Adding FailoverState and TagList to GlobalCluster and SynchronizationStatus to GlobalClusterMember. -* `Aws\CodeDeploy` - Update endpoint ruleset parameters casing - -## 3.359.1 - 2025-10-29 - -* `Aws\BedrockRuntime` - Add support for system tool and web citation response. - +## 3.362.0 - 2025-11-19 + +* `Aws\Credentials` - Adds `LoginCredentialProvider`, which supports AWS Console sign-in credentials through the `aws login` CLI workflow. + +## 3.361.0 - 2025-11-19 + +* `Aws\Route53` - Add dual-stack endpoint support for Route53 +* `Aws\CloudWatchRUM` - CloudWatch RUM now supports mobile application monitoring for Android and iOS platforms +* `Aws\DataZone` - Amazon DataZone now supports business metadata (readme and metadata forms) at the individual attribute (column) level, a new rule type for glossary terms, and the ability to update the owner of the root domain unit. +* `Aws\Lambda` - Added support for creating and invoking Tenant Isolated functions in AWS Lambda APIs. +* `Aws\Inspector2` - This release introduces BLOCKED_BY_ORGANIZATION_POLICY error code and IMAGE_ARCHIVED scanStatusReason. BLOCKED_BY_ORGANIZATION_POLICY error code is returned when an operation is blocked by an AWS Organizations policy. IMAGE_ARCHIVED scanStatusReason is returned when an Image is archived in ECR. +* `Aws\Signin` - AWS Sign-In manages authentication for AWS services. This service provides secure authentication flows for accessing AWS resources from the console and developer tools. This release adds the CreateOAuth2Token API, which can be used to fetch OAuth2 access tokens and refresh tokens from Sign-In. +* `Aws\EC2` - This launch adds support for two new features: Regional NAT Gateway and IPAM Policies. IPAM policies offers customers central control for public IPv4 assignments across AWS services. Regional NAT is a single NAT Gateway that automatically expands across AZs in a VPC to maintain high availability. +* `Aws\Billing` - Added name filtering support to ListBillingViews API through the new names parameter to efficiently filter billing views by name. +* `Aws\MediaConnect` - This release adds support for global routing in AWS Elemental MediaConnect. You can now use router inputs and router outputs to manage global video and audio routing workflows both within the AWS-Cloud and over the public internet. +* `Aws\CostExplorer` - Add support for COST_CATEGORY, TAG, and LINKED_ACCOUNT AWS managed cost anomaly detection monitors +* `Aws\IAM` - Added the EnableOutboundWebIdentityFederation, DisableOutboundWebIdentityFederation and GetOutboundWebIdentityFederationInfo APIs for the IAM outbound federation feature. +* `Aws\SecretsManager` - Adds support to create, update, retrieve, rotate, and delete managed external secrets. +* `Aws\PartnerCentralChannel` - Initial GA launch of Partner Central Channel +* `Aws\SFN` - Adds support to TestState for mocked results and exceptions, along with additional inspection data. +* `Aws\GuardDuty` - Add support for scanning and viewing scan results for backup resource types +* `Aws\FSx` - Adding File Server Resource Manager configuration to FSx Windows +* `Aws\EMR` - Add CloudWatch Logs integration for Spark driver, executor and step logs +* `Aws\NetworkFirewall` - Partner Managed Rulegroup feature support +* `Aws\S3` - Adds support for blocking SSE-C writes to general purpose buckets. +* `Aws\Invoicing` - Add support for adding Billing transfers in Invoice configuration +* `Aws\NetworkFlowMonitor` - Added new enum value (AWS::EKS::Cluster) for type field under MonitorLocalResource +* `Aws\Health` - Adds actionability and personas properties to Health events exposed through DescribeEvents, DescribeEventsForOrganization, DescribeEventDetails, and DescribeEventTypes APIs. Adds filtering by actionabilities and personas in EventFilter, OrganizationEventFilter, EventTypeFilter. +* `Aws\CloudTrail` - AWS CloudTrail now supports Insights for data events, expanding beyond management events to automatically detect unusual activity on data plane operations. +* `Aws\BedrockRuntime` - This release includes support for Search Results. +* `Aws\CostOptimizationHub` - Release ListEfficiencyMetrics API +* `Aws\APIGateway` - API Gateway now supports response streaming and new security policies for REST APIs and custom domain names. +* `Aws\CloudWatchLogs` - Adding support for ocsf version 1.5, add optional parameter MappingVersion +* `Aws\BillingConductor` - This release adds support for Billing Transfers, enabling management of billing transfers with billing groups on AWS Billing Conductor. +* `Aws\ApiGatewayV2` - Support for API Gateway portals and portal products. +* `Aws\SageMaker` - Added support for enhanced metrics for SageMaker AI Endpoints. This features provides Utilization Metrics at instance and container granularity and also provides easy configuration of metric publish frequency from 10 sec -> 5 mins +* `Aws\ECR` - Add support for ECR archival storage class and Inspector org policy for scanning +* `Aws\ECS` - Added support for Amazon ECS Managed Instances infrastructure optimization configuration. +* `Aws\ConnectCampaignsV2` - This release added support for ring timer configuration for campaign calls. +* `Aws\Backup` - Amazon GuardDuty Malware Protection now supports AWS Backup, extending malware detection capabilities to EC2, EBS, and S3 backups. +* `Aws\BCMPricingCalculator` - Add GroupSharingPreference, CostCategoryGroupSharingPreferenceArn, and CostCategoryGroupSharingPreferenceEffectiveDate to Bill Estimate. Add GroupSharingPreference and CostCategoryGroupSharingPreferenceArn to Bill Scenario. +* `Aws\MediaLive` - MediaLive is adding support for MediaConnect Router by supporting a new input type called MEDIACONNECT_ROUTER. This new input type will provide seamless encrypted transport between MediaConnect Router and your MediaLive channel. +* `Aws\DynamoDB` - Extended Global Secondary Index (GSI) composite keys to support up to 8 attributes. +* `Aws\STS` - IAM now supports outbound identity federation via the STS GetWebIdentityToken API, enabling AWS workloads to securely authenticate with external services using short-lived JSON Web Tokens. + +## 3.360.1 - 2025-11-18 + +* `Aws\AutoScaling` - This release adds the new LaunchInstances API, which can launch instances synchronously in an AutoScaling group. The API also returns instances info and launch error back immediately. +* `Aws\BedrockRuntime` - Amazon Bedrock Runtime Service Tier Support Launch +* `Aws\EC2` - AWS Site-to-Site VPN now supports VPN Concentrator, a new feature that enables customers to connect multiple low-bandwidth sites connections through a single attachment, simplifying multi-site connectivity for distributed enterprises. +* `Aws\ResourceGroupsTaggingAPI` - Add support for new ListRequiredTags API used to retrieve the required tags specified in a customer's effective tag policy. +* `Aws\IAM` - Added the AssociateDelegationRequest, GetDelegationRequest, AcceptDelegationRequest, RejectDelegatonRequest, ListDelegationRequests, UpdateDelegationRequest, SendDelegationToken and GetHumanReadableSummary APIs for the IAM temporary delegation feature. +* `Aws\Backup` - AWS Backup now supports a low-cost warm storage tier for Amazon S3 backup data. +* `Aws\Kafka` - Amazon MSK adds three new APIs, ListTopics, DescribeTopic, and DescribeTopicPartitions for viewing Kafka topics in your MSK clusters. +* `Aws\CloudFormation` - New CloudFormation DescribeEvents API with operation ID tracking and failure filtering capabilities to quickly identify root causes of deployment failures. Also, a DeploymentMode parameter for the CreateChangeSet API that enables creation of drift-aware change sets for safe drift management. +* `Aws\StorageGateway` - Adds support for European Sovereign Cloud ARNs in Storage Gateway API parameters. +* `Aws\WAFV2` - AssociateWebACL, UpdateWebACL and PutLoggingConfiguration will now throw WAFFeatureNotIncludedInPricingPlanException when the request contains a feature that is not included in the CloudFront pricing plan of the WebACL. +* `Aws\CloudWatchLogs` - CloudWatch Logs updates: Added capability to setup a recurring schedule for log insights queries. Logs introduced Scheduled Queries (managed through Create/Update/Get/Delete/List/History Scheduled Query APIs). For more information, see CloudWatch Logs API documentation. +* `Aws\Connect` - This release added support for ring timer configuration for campaign calls. + +## 3.360.0 - 2025-11-17 + +* `Aws\Glue` - Amazon Glue Releasing 2 the new API ListIntegrationResourceProperties and DeleteIntegrationResourceProperty along with minor improvement on existing API(s). +* `Aws\MediaPackageV2` - Add support for SCTE messages in Segment file output +* `Aws\LexModelsV2` - Adds support for LLM as Primary, allowing usage of LLMs as the default NLU system. +* `Aws\PCS` - Added support for the managed Slurm REST API endpoint +* `Aws\GuardDuty` - Add S3 On-Demand Object Scanning +* `Aws\Backup` - AWS Backup now supports specifying a logically air-gapped backup vault as a primary backup target in backup plans and on-demand backup jobs. +* `Aws\OpenSearchService` - This release adds index operation APIs to support Automatic Semantic Enrichment feature +* `Aws\MediaLive` - Adds configurations for spatial/temporal adaptive quantization in AV1 codec, and conversion to HLG output color space in H265 codec. +* `Aws\Route53Resolver` - Adding DICTIONARY_DGA to dns-threat-protection as a new enum type. Customers can now set rules for dictionary dga protection +* `Aws\AppStream` - Adding support for additional instances and extended storage +* `Aws\EC2` - This release introduces new APIs: DescribeInstanceSqlHaStates, DescribeInstanceSqlHaHistoryStates, EnableInstanceSqlHaStandbyDetections and DisableInstanceSqlHaStandbyDetections on Amazon EC2, allowing customers to enroll and monitor SQL Server licensing fee savings for their SQL HA EC2 instances. +* `Aws\DeviceFarm` - This release adds support for interacting with devices during a remote access session using the remoteDriverEndpoint interface +* `Aws\MWAAServerless` - Amazon MWAA now offers serverless deployment, eliminating operational overhead while optimizing costs. The service supports YAML and Python-based workflows, with 80+ AWS Operators. It provides isolated execution, IAM permissions, and automatic scaling with pay-per-use pricing. +* `Aws\DatabaseMigrationService` - This release introduces the SAP ASE(Sybase) Data Provider for AWS Data Migration Service (DMS). In addition, DMS Schema Conversion now supports this provider, enabling customers to migrate SAP ASE(Sybase) databases to Amazon RDS for PostgreSQL or Aurora PostgreSQL seamlessly. +* `Aws\Bedrock` - Automated Reasoning checks in Amazon Bedrock Guardrails now automatically generate Q&A tests for new Automated Reasoning policies. The GetAutomatedReasoningPolicyBuildWorkflowResultAssets API adds GENERATED_TEST_CASES asset type, allowing customers to retrieve tests generated by the build workflow. + +## 3.359.13 - 2025-11-14 + +* `Aws\imagebuilder` - EC2 Image Builder now supports invoking Lambda functions and executing Step Functions state machine through image workflows. +* `Aws\MediaLive` - Removed all the value constraint (min/max) for the shape definitions (e.g. integerMin0Max3600) on the C2j models to get rid of the need to request an exemption from the SDK team whenever a shape definition (e.g. integerMin0Max3600) is changed. +* `Aws\DataZone` - Adds support for granting read and write access to Amazon S3 general purpose buckets using CreateSubscriptionRequest and AcceptSubscriptionRequest APIs. Also adds search filters for SSOUser and SSOGroup to ListSubscriptions APIs and deprecates "sortBy" parameter for ListSubscriptions APIs. +* `Aws\EC2` - This release adds AvailabilityZoneId support for CreateInstanceConnectEndpoint, DescribeInstanceConnectEndpoints, and DeleteInstanceConnectEndpoint APIs. + +## 3.359.12 - 2025-11-13 + +* `Aws\EC2` - Added support for new accelerator types ("media") and accelerator names ("L4", "L40s", "GAUDI_HL_205", "INFERENTIA2", "TRAINIUM", "TRAINIUM2", "U30") in Attributes Based Instance Type Selection for launched instance types. +* `Aws\IoTWireless` - Integration of Device Location with Amazon Sidewalk network for Amazon Sidewalk enabled devices +* `Aws\ControlCatalog` - Added support for related control mappings with new RELATED_CONTROL mapping type in ListControlMappings API. +* `Aws\WorkSpacesWeb` - Support for managing web content filtering for defining, tracking and regulating type of content accessed with WorkSpaces Secure Browser as part of browser settings. +* `Aws\MediaConvert` - Lowers minimum duration for black video generator. Adds support for embedding and signing C2PA content credentials in DASH and CMAF HLS outputs. +* `Aws\RDS` - Updated endpoint and service metadata +* `Aws\CloudFormation` - CloudFormation now supports GetHookResult API with annotations to retrieve structured compliance check results and remediation guidance for each evaluated resource, replacing the previous single-message limitation with detailed validation outcomes. +* `Aws\ECR` - Add Amazon ECR FIPS PrivateLink endpoint support +* `Aws\ElasticLoadBalancingv2` - QUIC and TCP_QUIC protocol support for Network Load Balancer (NLB). This capability enables customers to forward QUIC traffic to their targets with ultra-low latency while maintaining session stickiness using QUIC Connection IDs. +* `Aws\SageMaker` - Added support for minor version upgrades and AWS Identity Center integration for SageMaker Hadron Partner Apps, enabling automated version management and IdC group-based access control. + +## 3.359.11 - 2025-11-12 + +* `Aws\Connect` - Updated Authentication Profile APIs to add support for automatic logout on user inactivity +* `Aws\ElasticLoadBalancingv2` - This release expands ALB Authentication to support JWT verification and adds support for a new JWT validation action in listener rule. +* `Aws\EC2` - Adds complete AMI ancestry tracing from immediate parent through each preceding generation back to the root AMI +* `Aws\DatabaseMigrationService` - Added support of SQL statements creation, metadata model discovery and selection rules transformation. +* `Aws\S3Tables` - Adds support for request metrics metrics APIs for S3 Tables +* `Aws\PrometheusService` - Add VPC source configuration support enabling Amazon Managed Service for Prometheus Collector to collect metrics from MSK clusters. +* `Aws\Redshift` - Added GetIdentityCenterAuthToken API to retrieve encrypted authentication tokens for Identity Center integrated applications. This API enables programmatic access to secure Identity Center tokens with proper error handling and parameter validation across supported SDK languages. +* `Aws\SageMaker` - Add support for trn2.3xlarge instance type for SageMaker Hyperpod + +## 3.359.10 - 2025-11-11 + +* `Aws\RTBFabric` - Added LogSettings and LinkAttribute fields to external links +* `Aws\SecurityIR` - Added support for configuring communication preferences as well as clearly displaying case comment author identities. +* `Aws\EC2` - AWS Site-to-Site VPN now supports VPN connections with up to 5 Gbps bandwidth per tunnel, a 4x improvement from existing limit of 1.25 Gbps. +* `Aws\MedicalImaging` - Added new fields in existing APIs. +* `Aws\Batch` - Documentation-only update: update API and doc descriptions per EKS ImageType default value switch from AL2 to AL2023. +* `Aws\BedrockDataAutomation` - Added support for Language Expansion feature for BDA Audio modality. + +## 3.359.9 - 2025-11-10 + +* `Aws\DSQL` - Cluster endpoint added to CreateCluster and GetCluster API responses +* `Aws\Invoicing` - Added new invoicing get-invoice-pdf API Operation +* `Aws\Braket` - Adds ExperimentalCapabilities field to CreateQuantumTask request and GetQuantumTask response objects. Enables use of experimental software capabilities when creating quantum tasks. +* `Aws\Kafka` - Amazon MSK now supports intelligent rebalancing for MSK Express brokers. +* `Aws\WAFV2` - AWS WAF now supports CLOUDWATCH_TELEMETRY_RULE_MANAGED as a LogScope option, enabling automated logging configuration through Amazon CloudWatch Logs for telemetry data collection and analysis. +* `Aws\STS` - Added GetDelegatedAccessToken API, which is not available for general use at this time. +* `Aws\IAM` - Added CreateDelegationRequest API, which is not available for general use at this time. +* `Aws\EC2` - Amazon EC2 Fleet customers can now filter instance types based on encryption-in-transit support using Attribute-Based Instance Type Selection (ABIS), eliminating the manual effort of identifying and selecting compatible instance types for security-sensitive workloads. +* `Aws\GuardDuty` - Include tags filed in CreatePublishingDestinationRequest and DescribePublishingDestinationResponse. +* `Aws\Backup` - AWS Backup supports backups of Amazon EKS clusters, including Kubernetes cluster state and persistent storage attached to the EKS cluster via a persistent volume claim (EBS volumes, EFS file systems, and S3 buckets). +* `Aws\ACMPCA` - Private Certificate Authority service now supports ML-DSA key algorithms. +* `Aws\DataZone` - Remove trackingServerName from DataZone Connection MLflowProperties +* `Aws\AppStream` - AWS Appstream support for IPv6 +* `Aws\VerifiedPermissions` - Amazon Verified Permissions / Features : Adds support for entity Cedar tags. + +## 3.359.8 - 2025-11-07 + +* `Aws\` - Removes `QLDB`, `QLDBSession`, `Robomaker`, `LookoutMetrics`, `LookoutVision`, `IoTFleetHub` and `Apptest` services, which have been deprecated. +* `Aws\KMS` - Added support for new ECC_NIST_EDWARDS25519 AWS KMS key spec +* `Aws\ControlTower` - Added Parent Identifier support to ListEnabledControls and GetEnabledControl API. Implemented RemediationType support for Landing Zone operations: CreateLandingZone, UpdateLandingZone and GetLandingZone APIs +* `Aws\VPCLattice` - Amazon VPC Lattice now supports custom domain name for resource configurations +* `Aws\OpenSearchService` - This release introduces the Default Application feature, allowing users to set, change, or unset a preferred OpenSearch UI application on a per-region basis for a streamlined and consistent user experience. +* `Aws\EC2` - Adds PrivateDnsPreference and PrivateDnsSpecifiedDomains to control private DNS resolution for resource and service network VPC endpoints and IpamScopeExternalAuthorityConfiguration to integrate Amazon VPC IPAM with a third-party IPAM service + +## 3.359.7 - 2025-11-06 + +* `Aws\Backup` - AWS Backup now supports customer-managed keys (CMK) for logically air-gapped vaults, enabling customers to maintain full control over their encryption key lifecycle. This feature helps organizations meet specific internal governance requirements or external regulatory compliance standards. +* `Aws\SSM` - Provides NoLongerSupportedException error message +* `Aws\QuickSight` - Support for New Data Prep Experience +* `Aws\IdentityStore` - IdentityStore API: added new KMSExceptionReason fields to the Exception object; added multiple new fields to the User APIs - UserStatus, Birthdate, Website and Photos; added multiple new metadata fields for User, Groups and Membership APIs - CreatedAt, CreatedBy, UpdatedAt and UpdatedBy. +* `Aws\EC2` - Add Amazon EC2 R8a instance types +* `Aws\S3Tables` - Adds support for tagging APIs for S3 Tables +* `Aws\AccessAnalyzer` - New field totalActiveErrors added to getFindingsStatistics response. +* `Aws\S3Vectors` - Amazon S3 Vectors provides cost-effective, elastic, and durable vector storage for queries based on semantic meaning and similarity. +* `Aws\SageMaker` - Added NodeProvisioningMode parameter to UpdateCluster API to determine how instance provisioning is handled during cluster operations; in Continuous mode. Added VpcId field in UpdateDomain request for SageMaker Unified Studio domains with no VPC to add a customer VPC. +* `Aws\GameLift` - Amazon GameLift Servers now supports game builds that use the Windows 2022 operating system. +* `Aws\Connect` - Added support for Conditional Questions in Evaluation Forms. Introduced Auto Evaluation capability for Evaluation Forms and Contact Evaluations. Added new API operations: SearchEvaluationForms and SearchContactEvaluations. + +## 3.359.6 - 2025-11-05 + +* `Aws\FSx` - Amazon FSx now enables secure management of Active Directory credentials through AWS Secrets Manager integration. Customers can use Secret ARNs instead of direct credentials when joining resources to Active Directory domains. +* `Aws\EC2` - This release adds AvailabilityZoneId support for DescribeFastSnapshotRestores, DisableFastSnapshotRestores, and EnableFastSnapshotRestores APIs. +* `Aws\GroundStation` - Introduce CreateDataflowEndpointGroupV2 action +* `Aws\CloudFront` - This release adds new and updated API operations. You can now use the IpAddressType field to specify either ipv4 or dualstack for your Anycast static IP list. You can also enable cross-account resource sharing to share your VPC origins with other AWS accounts +* `Aws\S3` - Launch IPv6 dual-stack support for S3 Express +* `Aws\DataZone` - Added support for Project Resource Tags +* `Aws\SageMaker` - Add new fields in SageMaker Hyperpod DescribeCluster API response: TargetStateCount, SoftwareUpdateStatus and ActiveSoftwareDeploymentConfig to provide AMI update progress visibility . + +## 3.359.5 - 2025-11-04 + +* `Aws\PinpointSMSVoiceV2` - This release adds support for the CarrierLookup API, which returns information about a destination phone number including if the number is valid, the carrier, and more. + +## 3.359.4 - 2025-11-03 + +* `Aws\Kinesis` - Adds support for MinimumThroughputBillingCommitment with new UpdateAccountSettings API. Adds support to configure warm throughput for on-demand streams in new UpdateStreamWarmThroughput API and existing CreateStream API and UpdateStreamMode API. +* `Aws\EC2` - Add Amazon EC2 trn2.3xlarge instance type. +* `Aws\BedrockAgentCoreControl` - Adds support for direct code deploy with CreateAgentRuntime and UpdateAgentRuntime +* `Aws\ECS` - Documentation-only update for LINEAR and CANARY deployment strategies. +* `Aws\Budgets` - Fix the AWS Budgets endpoint for the aws-eusc partition. + +## 3.359.3 - 2025-10-31 + +* `Aws\Omics` - Added WDL_LENIENT engine type that enables implicit typecasting of variable values to its compatible declared types +* `Aws\SavingsPlans` - Add dual-stack endpoint support for Savings Plans +* `Aws\PaymentCryptography` - Allow additional characters in the CertificateSubject for GetCertificateSigningRequest API. +* `Aws\SSMQuickSetup` - Update endpoint ruleset parameters casing +* `Aws\MarketplaceCatalog` - Update endpoint ruleset parameters casing +* `Aws\WAF` - Update endpoint ruleset parameters casing +* `Aws\Kinesis` - Update endpoint ruleset parameters casing +* `Aws\FSx` - Update endpoint ruleset parameters casing +* `Aws\Textract` - Update endpoint ruleset parameters casing +* `Aws\ResourceGroupsTaggingAPI` - Update endpoint ruleset parameters casing +* `Aws\Snowball` - Update endpoint ruleset parameters casing +* `Aws\Health` - Update endpoint ruleset parameters casing +* `Aws\ConnectCases` - Added two new case rule types: Parent Child Field Options (restricts child field options based on parent field value) and Hidden (controls child field visibility based on parent field value). Both enable dynamic field behavior within templates. +* `Aws\EMR` - Update endpoint ruleset parameters casing +* `Aws\EC2` - Amazon VPC IP Address Manager (IPAM) now supports automated prefix list management, allowing you to create rules that automatically populate customer-managed prefix lists with CIDRs from your IPAM pools or AWS resources based on tags, Regions, or other criteria. +* `Aws\Redshift` - Update endpoint ruleset parameters casing +* `Aws\MediaConvert` - Adds SlowPalPitchCorrection to audio pitch correction settings. Enables opacity for VideoOverlays. Adds REMUX_ALL option to enable multi-rendition passthrough to VideoSelector for allow listed accounts. +* `Aws\CloudWatchLogs` - Update endpoint ruleset parameters casing +* `Aws\Lambda` - Add Python3.14 (python3.14) and Java 25 (java25) support to AWS Lambda +* `Aws\SageMaker` - Allow update of platform identifier via UpdateNotebookInstance operation. +* `Aws\FMS` - Update endpoint ruleset parameters casing + +## 3.359.2 - 2025-10-30 + +* `Aws\ConnectCases` - Update endpoint ruleset parameters casing +* `Aws\CleanRooms` - Added support for advanced Spark configurations to optimize SQL performance +* `Aws\DevOpsGuru` - Update endpoint ruleset parameters casing +* `Aws\ComputeOptimizer` - Update endpoint ruleset parameters casing +* `Aws\AuditManager` - Update endpoint ruleset parameters casing +* `Aws\CloudDirectory` - Update endpoint ruleset parameters casing +* `Aws\AppSync` - Update endpoint ruleset parameters casing +* `Aws\PrometheusService` - Add Anomaly Detection APIs for Amazon Managed Prometheus +* `Aws\RTBFabric` - RTB Fabric documentation update. +* `Aws\Deadline` - Update endpoint ruleset parameters casing +* `Aws\Glue` - This release adds the capability to enable User Background Sessions for customers running Trusted Identity Propagation enabled Interactive Sessions on AWS Glue. +* `Aws\AppConfig` - Update endpoint ruleset parameters casing +* `Aws\Neptune` - Update endpoint ruleset parameters casing +* `Aws\ApplicationCostProfiler` - Update endpoint ruleset parameters casing +* `Aws\GeoPlaces` - Update endpoint ruleset parameters casing +* `Aws\Firehose` - Update endpoint ruleset parameters casing +* `Aws\FraudDetector` - Update endpoint ruleset parameters casing +* `Aws\ElastiCache` - Update endpoint ruleset parameters casing +* `Aws\CodeCommit` - Update endpoint ruleset parameters casing +* `Aws\EKSAuth` - Update endpoint ruleset parameters casing +* `Aws\BedrockAgentCoreControl` - Web-Bot-Auth support for AgentCore Browser tool to help reduce captcha challenges. +* `Aws\EMRServerless` - This release adds the capability to enable User Background Sessions for customers running Trusted Identity Propagation enabled Interactive Sessions on EMR Serverless Applications. +* `Aws\Schemas` - Update endpoint ruleset parameters casing +* `Aws\BedrockAgent` - Update endpoint ruleset parameters casing +* `Aws\Chime` - Update endpoint ruleset parameters casing +* `Aws\AppMesh` - Update endpoint ruleset parameters casing +* `Aws\LicenseManagerLinuxSubscriptions` - Update endpoint ruleset parameters casing +* `Aws\IoTManagedIntegrations` - Add a new GetManagedThingCertificate API to expose Iot ManagedIntegrations (MI) device certificate, and add "-" support for name, properties, actions and events in the CapabilityReportCapability object. +* `Aws\IoTEventsData` - Update endpoint ruleset parameters casing +* `Aws\APIGateway` - Update endpoint ruleset parameters casing +* `Aws\KMS` - Add cross account VPC endpoint service connectivity support to CustomKeyStore. +* `Aws\ECS` - Amazon ECS Service Connect now supports Envoy access logs, providing deeper observability into request-level traffic patterns and service interactions. +* `Aws\Artifact` - Update endpoint ruleset parameters casing +* `Aws\MarketplaceReporting` - Update endpoint ruleset parameters casing +* `Aws\Appflow` - Update endpoint ruleset parameters casing +* `Aws\STS` - Update endpoint ruleset parameters casing +* `Aws\GreengrassV2` - Update endpoint ruleset parameters casing +* `Aws\CodeCatalyst` - Update endpoint ruleset parameters casing +* `Aws\CloudSearch` - Update endpoint ruleset parameters casing +* `Aws\S3Outposts` - Update endpoint ruleset parameters casing +* `Aws\ServiceCatalog` - Update endpoint ruleset parameters casing +* `Aws\CloudControlApi` - Update endpoint ruleset parameters casing +* `Aws\KeyspacesStreams` - Update endpoint ruleset parameters casing +* `Aws\ServerlessApplicationRepository` - Update endpoint ruleset parameters casing +* `Aws\CognitoSync` - Update endpoint ruleset parameters casing +* `Aws\SageMakerRuntime` - Update endpoint ruleset parameters casing +* `Aws\SSO` - Update endpoint ruleset parameters casing +* `Aws\CloudWatch` - Update endpoint ruleset parameters casing +* `Aws\DocDB` - Adding FailoverState and TagList to GlobalCluster and SynchronizationStatus to GlobalClusterMember. +* `Aws\CodeDeploy` - Update endpoint ruleset parameters casing + +## 3.359.1 - 2025-10-29 + +* `Aws\BedrockRuntime` - Add support for system tool and web citation response. + ## 3.359.0 - 2025-10-28 * `Aws\Credentials` - Fixes issue caused by #3203 with role assumption when `credential_source` is specified. diff --git a/src/ClientResolver.php b/src/ClientResolver.php index f16688c53e..d8751b1148 100644 --- a/src/ClientResolver.php +++ b/src/ClientResolver.php @@ -61,6 +61,8 @@ class ClientResolver __CLASS__, '_resolve_from_env_ini' ]; + private const ANONYMOUS_SIGNATURE = 'anonymous'; + private const DPOP_SIGNATURE = 'dpop'; /** @var array Map of types to a corresponding function */ private static $typeMap = [ @@ -668,7 +670,10 @@ public static function _apply_credentials($value, array &$args) $args['credentials'] = CredentialProvider::fromCredentials( new Credentials('', '') ); - $args['config']['signature_version'] = 'anonymous'; + if ($args['config']['signature_version'] !== self::DPOP_SIGNATURE) { + $args['config']['signature_version'] = self::ANONYMOUS_SIGNATURE; + } + $args['config']['configured_signature_version'] = true; } elseif ($value instanceof CacheInterface) { $args['credentials'] = CredentialProvider::defaultProvider($args); diff --git a/src/Credentials/CredentialProvider.php b/src/Credentials/CredentialProvider.php index 4cbd141e97..b0c54bc287 100644 --- a/src/Credentials/CredentialProvider.php +++ b/src/Credentials/CredentialProvider.php @@ -43,15 +43,16 @@ */ class CredentialProvider { - const ENV_ARN = 'AWS_ROLE_ARN'; - const ENV_KEY = 'AWS_ACCESS_KEY_ID'; - const ENV_PROFILE = 'AWS_PROFILE'; - const ENV_ROLE_SESSION_NAME = 'AWS_ROLE_SESSION_NAME'; - const ENV_SECRET = 'AWS_SECRET_ACCESS_KEY'; - const ENV_ACCOUNT_ID = 'AWS_ACCOUNT_ID'; - const ENV_SESSION = 'AWS_SESSION_TOKEN'; - const ENV_TOKEN_FILE = 'AWS_WEB_IDENTITY_TOKEN_FILE'; - const ENV_SHARED_CREDENTIALS_FILE = 'AWS_SHARED_CREDENTIALS_FILE'; + public const ENV_ARN = 'AWS_ROLE_ARN'; + public const ENV_KEY = 'AWS_ACCESS_KEY_ID'; + public const ENV_PROFILE = 'AWS_PROFILE'; + public const ENV_ROLE_SESSION_NAME = 'AWS_ROLE_SESSION_NAME'; + public const ENV_SECRET = 'AWS_SECRET_ACCESS_KEY'; + public const ENV_ACCOUNT_ID = 'AWS_ACCOUNT_ID'; + public const ENV_SESSION = 'AWS_SESSION_TOKEN'; + public const ENV_TOKEN_FILE = 'AWS_WEB_IDENTITY_TOKEN_FILE'; + public const ENV_SHARED_CREDENTIALS_FILE = 'AWS_SHARED_CREDENTIALS_FILE'; + public const ENV_CONFIG_FILE = 'AWS_CONFIG_FILE'; public const ENV_REGION = 'AWS_REGION'; public const FALLBACK_REGION = 'us-east-1'; public const REFRESH_WINDOW = 60; @@ -82,6 +83,7 @@ public static function defaultProvider(array $config = []) $cacheable = [ 'web_identity', 'sso', + 'login', 'process_credentials', 'process_config', 'ecs', @@ -94,24 +96,24 @@ public static function defaultProvider(array $config = []) 'env' => self::env(), 'web_identity' => self::assumeRoleWithWebIdentityCredentialProvider($config), ]; - if ( - !isset($config['use_aws_shared_config_files']) + if (!isset($config['use_aws_shared_config_files']) || $config['use_aws_shared_config_files'] !== false ) { $defaultChain['sso'] = self::sso( $profileName, - self::getHomeDir() . '/.aws/config', + self::getConfigFileName(), $config ); + $defaultChain['login'] = self::login($profileName, $config); $defaultChain['process_credentials'] = self::process(); $defaultChain['ini'] = self::ini(null, null, $config); $defaultChain['process_config'] = self::process( 'profile ' . $profileName, - self::getHomeDir() . '/.aws/config' + self::getConfigFileName() ); $defaultChain['ini_config'] = self::ini( 'profile '. $profileName, - self::getHomeDir() . '/.aws/config' + self::getConfigFileName() ); } @@ -339,11 +341,12 @@ public static function instanceProfile(array $config = []) * * @return callable */ - public static function sso($ssoProfileName = 'default', - $filename = null, - $config = [] + public static function sso( + $ssoProfileName = 'default', + $filename = null, + $config = [] ) { - $filename = $filename ?: (self::getHomeDir() . '/.aws/config'); + $filename = $filename ?? self::getConfigFileName(); return function () use ($ssoProfileName, $filename, $config) { if (!@is_readable($filename)) { @@ -489,17 +492,13 @@ public static function assumeRoleWithWebIdentityCredentialProvider(array $config */ public static function ini($profile = null, $filename = null, array $config = []) { - $filename = self::getFileName($filename); + $filename = self::getCredentialsFileName($filename); $profile = $profile ?: (getenv(self::ENV_PROFILE) ?: 'default'); return function () use ($profile, $filename, $config) { - $preferStaticCredentials = isset($config['preferStaticCredentials']) - ? $config['preferStaticCredentials'] - : false; - $disableAssumeRole = isset($config['disableAssumeRole']) - ? $config['disableAssumeRole'] - : false; - $stsClient = isset($config['stsClient']) ? $config['stsClient'] : null; + $preferStaticCredentials = $config['preferStaticCredentials'] ?? false; + $disableAssumeRole = $config['disableAssumeRole'] ?? false; + $stsClient = $config['stsClient'] ?? null; if (!@is_readable($filename)) { return self::reject("Cannot read credentials from $filename"); @@ -583,7 +582,7 @@ public static function ini($profile = null, $filename = null, array $config = [] */ public static function process($profile = null, $filename = null) { - $filename = self::getFileName($filename); + $filename = self::getCredentialsFileName($filename); $profile = $profile ?: (getenv(self::ENV_PROFILE) ?: 'default'); return function () use ($profile, $filename) { @@ -659,6 +658,41 @@ public static function process($profile = null, $filename = null) }; } + /** + * Login credential provider for AWS local development using console credentials + * + * @param string|null $profileName profile containing your console login session information + * @param array $config region used for refresh requests. + * pass `'region' => ` to configure a region, + * otherwise, provider construction falls back to AWS_REGION, + * then the profile specified for `login` + * + * @return callable + */ + public static function login( + ?string $profileName = null, + array $config = [], + ): callable + { + $resolvedProfile = $profileName ?? getenv(self::ENV_PROFILE) ?: 'default'; + + return static function () use ($resolvedProfile, $config) { + try { + $provider = new LoginCredentialProvider( + $resolvedProfile, + $config['region'] ?? null + ); + } catch (\Exception $e) { + return self::reject( + "Failed to initialize login credential provider for profile '{$resolvedProfile}': " + . $e->getMessage() + ); + } + + return $provider(); + }; + } + /** * Assumes role for profile that includes role_arn * @@ -672,13 +706,11 @@ private static function loadRoleProfile( $config = [] ) { $roleProfile = $profiles[$profileName]; - $roleArn = isset($roleProfile['role_arn']) ? $roleProfile['role_arn'] : ''; - $roleSessionName = isset($roleProfile['role_session_name']) - ? $roleProfile['role_session_name'] - : 'aws-sdk-php-' . round(microtime(true) * 1000); + $roleArn = $roleProfile['role_arn'] ?? ''; + $roleSessionName = $roleProfile['role_session_name'] + ?? 'aws-sdk-php-' . round(microtime(true) * 1000); - if ( - empty($roleProfile['source_profile']) + if (empty($roleProfile['source_profile']) == empty($roleProfile['credential_source']) ) { return self::reject("Either source_profile or credential_source must be set " . @@ -748,7 +780,7 @@ private static function loadRoleProfile( * * @return null|string */ - private static function getHomeDir() + public static function getHomeDir() { // On Linux/Unix-like systems, use the HOME environment variable if ($homeDir = getenv('HOME')) { @@ -765,7 +797,7 @@ private static function getHomeDir() /** * Gets profiles from specified $filename, or default ini files. */ - private static function loadProfiles($filename) + public static function loadProfiles($filename) { $profileData = \Aws\parse_ini_file($filename, true, INI_SCANNER_RAW); @@ -773,7 +805,7 @@ private static function loadProfiles($filename) if ($filename === self::getHomeDir() . '/.aws/credentials' && getenv('AWS_SDK_LOAD_NONDEFAULT_CONFIG') ) { - $configFilename = self::getHomeDir() . '/.aws/config'; + $configFilename = self::getConfigFileName(); $configProfileData = \Aws\parse_ini_file($configFilename, true, INI_SCANNER_RAW); foreach ($configProfileData as $name => $profile) { // standardize config profile names @@ -790,7 +822,8 @@ private static function loadProfiles($filename) /** * Gets profiles from ~/.aws/credentials and ~/.aws/config ini files */ - private static function loadDefaultProfiles() { + private static function loadDefaultProfiles() + { $profiles = []; $credFile = self::getHomeDir() . '/.aws/credentials'; $configFile = self::getHomeDir() . '/.aws/config'; @@ -861,15 +894,32 @@ private static function reject($msg) } /** + * Locates shared configuration file by first checking for AWS_CONFIG, + * then falling back to the default location. Returns the path of the + * resolved configuration file. + * + * @return string + */ + public static function getConfigFileName(): string + { + return getenv(self::ENV_CONFIG_FILE) ?: self::getHomeDir() . '/.aws/config'; + } + + /** + * Locates credentials file by first checking for AWS_SHARED_CREDENTIALS_FILE, + * then falling back to the default location. Returns the path of the + * resolved credentials file. + * * @param $filename * @return string */ - private static function getFileName($filename) + public static function getCredentialsFileName($filename): string { if (!isset($filename)) { $filename = getenv(self::ENV_SHARED_CREDENTIALS_FILE) ?: (self::getHomeDir() . '/.aws/credentials'); } + return $filename; } diff --git a/src/Credentials/CredentialSources.php b/src/Credentials/CredentialSources.php index 829aa919c1..de8a765dd2 100644 --- a/src/Credentials/CredentialSources.php +++ b/src/Credentials/CredentialSources.php @@ -19,4 +19,5 @@ final class CredentialSources const PROFILE_SSO = 'profile_sso'; const PROFILE_SSO_LEGACY = 'profile_sso_legacy'; const PROFILE_PROCESS = 'profile_process'; + const PROFILE_LOGIN = 'profile_login'; } diff --git a/src/Credentials/LoginCredentialProvider.php b/src/Credentials/LoginCredentialProvider.php new file mode 100644 index 0000000000..21c188dde1 --- /dev/null +++ b/src/Credentials/LoginCredentialProvider.php @@ -0,0 +1,527 @@ +profileName = $profileName; + $this->client = $this->createSigninClient($profileName, $region); + $this->tokenLocation = $this->resolveTokenLocation($profileName); + } + + /** + * Returns a promise that resolves to AWS credentials + * + * This method loads the cached token, refreshes it if necessary, + * and returns AWS credentials sourced from the access token. + * + * @return Promise\PromiseInterface A promise that resolves to a Credentials object + * @throws CredentialsException If re-authentication is required or credentials cannot be loaded + * @throws SigninException If the token refresh fails with a SigninException + */ + public function __invoke(): Promise\PromiseInterface + { + return Promise\Coroutine::of(function () { + $this->token ??= $this->loadToken(); + $credentials = $this->token[self::KEY_ACCESS_TOKEN]; + + if ($this->shouldRefresh($credentials)) { + try { + $credentials = yield from $this->refresh($credentials); + } catch (CredentialsException $e) { + // For specific re-authentication errors, re-throw + throw $e; + } catch (SigninException $e) { + // For SigninException not handled by refresh(), re-throw + throw new CredentialsException( + 'Unable to refresh login credentials: ' . $e->getAwsErrorMessage(), + $e->getCode(), + $e + ); + } catch (\Exception $e) { + // For other refresh failures, log and continue with existing token + trigger_error( + 'Continuing with existing token after refresh failure: ' . $e->getMessage(), + E_USER_NOTICE + ); + } + } + + yield $credentials; + }); + } + + /** + * Refreshes the access token using the refresh token and returns new credentials, + * or, if refreshed from another source, returns the externally refreshed credentials. + * + * @param Credentials $currentCredentials The current credentials to refresh + * + * @return \Generator Generator that yields and returns refreshed credentials + * @throws CredentialsException If re-authentication is required due to expired or changed credentials + * @throws SigninException If the token refresh fails with a SigninException + * @throws \Exception For unexpected errors during refresh + */ + private function refresh(Credentials $currentCredentials): \Generator + { + // Check for external refresh + if ($refreshedToken = $this->getExternalRefresh($currentCredentials)) { + $this->token = $refreshedToken; + + return $refreshedToken[self::KEY_ACCESS_TOKEN]; + } + + try { + $refreshed = (yield $this->client->createOAuth2TokenAsync([ + self::REQUEST_KEY_TOKEN_INPUT => [ + self::REQUEST_KEY_CLIENT_ID => $this->token[self::REQUEST_KEY_CLIENT_ID], + self::REQUEST_KEY_GRANT_TYPE => self::GRANT_TYPE, + self::REQUEST_KEY_REFRESH_TOKEN => $this->token[self::REQUEST_KEY_REFRESH_TOKEN] + ], + self::KEY_DPOP_KEY => $this->token[self::KEY_DPOP_KEY] + ]))->get(self::RESULT_TOKEN_OUTPUT); + + $newCredentials = self::createCredentials( + $refreshed[self::KEY_ACCESS_TOKEN], + time() + $refreshed[self::RESULT_EXPIRES_IN], + $currentCredentials->getAccountId() + ); + + $this->token[self::KEY_ACCESS_TOKEN] = $newCredentials; + $this->token[self::REQUEST_KEY_REFRESH_TOKEN] = $refreshed[self::REQUEST_KEY_REFRESH_TOKEN]; + } catch (\Exception $e) { + throw $this->handleRefreshException($e); + } + + try { + $this->writeToCache(); + } catch (\JsonException|\RuntimeException $e) { + trigger_error( + 'Failed to update credential cache during refresh: ' . $e->getMessage() + . '. Using refreshed credentials in memory.', + E_USER_NOTICE + ); + } + + return $newCredentials; + } + + /** + * Gets externally refreshed token if token has been refreshed from another source. + * If the new token does not need refreshing, returns it. + * + * @param Credentials $currentCredentials Current credentials to compare against + * @return array|null Returns the updated token array if externally refreshed, null otherwise + */ + private function getExternalRefresh(Credentials $currentCredentials): ?array + { + try { + $latestToken = $this->loadToken(); + $latestCredentials = $latestToken[self::KEY_ACCESS_TOKEN]; + + // Refresh token must be different + if ($latestToken[self::REQUEST_KEY_REFRESH_TOKEN] + === $this->token[self::REQUEST_KEY_REFRESH_TOKEN] + ) { + return null; + } + + // Expiration must be newer + if ($latestCredentials->getExpiration() + <= $currentCredentials->getExpiration() + ) { + return null; + } + + // New token should not need refresh itself + if ($this->shouldRefresh($latestCredentials)) { + return null; + } + + return $latestToken; + } catch (\Exception $e) { + return null; + } + } + + /** + * Writes the updated access token and refresh token to the cache file + * + * @throws \JsonException|\RuntimeException + */ + private function writeToCache(): void + { + $credentials = $this->token[self::KEY_ACCESS_TOKEN]; + $updates = [ + self::KEY_ACCESS_TOKEN => [ + self::KEY_ACCESS_KEY_ID => $credentials->getAccessKeyId(), + self::KEY_SECRET_ACCESS_KEY => $credentials->getSecretKey(), + self::KEY_SESSION_TOKEN => $credentials->getSecurityToken(), + self::KEY_ACCOUNT_ID => $credentials->getAccountId(), + self::KEY_EXPIRES_AT => gmdate('Y-m-d\TH:i:s\Z', $credentials->getExpiration()) + ], + self::REQUEST_KEY_REFRESH_TOKEN => $this->token[self::REQUEST_KEY_REFRESH_TOKEN] + ]; + + $existing = json_decode( + file_get_contents($this->tokenLocation), true, 512, JSON_THROW_ON_ERROR + ); + $merged = array_merge($existing, $updates); + + $result = file_put_contents( + $this->tokenLocation, + json_encode($merged, JSON_THROW_ON_ERROR), + LOCK_EX + ); + + if (!$result) { + throw new \RuntimeException('Failed to write cache file'); + } + } + + /** + * Loads and validates the token from the cache file + * + * @return array The loaded token data with Credentials object, DPoP key, and other fields + * @throws CredentialsException If the cache file is invalid, missing required keys, + * or DPoP key cannot be loaded + */ + private function loadToken(): array + { + try { + $cached = json_decode( + file_get_contents($this->tokenLocation), + true, 512, JSON_THROW_ON_ERROR + ); + } catch (\JsonException $e) { + throw new CredentialsException( + 'Invalid JSON in cache file: ' . $e->getMessage(), + 0, + $e + ); + } + + if (self::hasAllRequiredKeys($cached, self::REQUIRED_CACHE_KEYS) + && self::hasAllRequiredKeys( + $cached[self::KEY_ACCESS_TOKEN] ?? [], + self::REQUIRED_ACCESS_TOKEN_KEYS + ) + ) { + // Convert expiresAt to Unix timestamp + $expiresAt = strtotime($cached[self::KEY_ACCESS_TOKEN][self::KEY_EXPIRES_AT]); + if ($expiresAt === false) { + throw new CredentialsException( + 'Invalid expiration date format in cached token `' + . self::KEY_EXPIRES_AT . '` field.' . self::REAUTHENTICATE_MSG + ); + } + + $cached[self::KEY_ACCESS_TOKEN] = self::createCredentials( + $cached[self::KEY_ACCESS_TOKEN], + $expiresAt, + $cached[self::KEY_ACCESS_TOKEN][self::KEY_ACCOUNT_ID] + ); + + // Load DPoP key + $cached[self::KEY_DPOP_KEY] = openssl_pkey_get_private($cached[self::KEY_DPOP_KEY]); + if ($cached[self::KEY_DPOP_KEY] === false) { + $error = openssl_error_string(); + throw new CredentialsException( + 'Failed to load DPoP private key from cached token for profile ' + . "{$this->profileName}: " . ($error ? ": {$error}" : '.') + . self::REAUTHENTICATE_MSG + ); + } + + return $cached; + } + + throw new CredentialsException( + 'Missing required keys in cached token for profile ' + . $this->profileName . '.' . self::REAUTHENTICATE_MSG + ); + } + + /** + * @param Credentials $credentials The credentials to check for expiration + * + * @return bool True if the token expires within the refresh threshold + */ + private function shouldRefresh(Credentials $credentials): bool + { + return ($credentials->getExpiration() - time()) <= self::DEFAULT_REFRESH_THRESHOLD; + } + + /** + * Handles exceptions thrown during token refresh + * + * @param \Exception $e The exception thrown during refresh + * + * @return \Exception The exception to be thrown (either transformed or original) + */ + private function handleRefreshException(\Exception $e): \Exception + { + if ($e instanceof SigninException) { + trigger_error( + 'Failed to refresh login credentials: ' . $e->getAwsErrorMessage(), + E_USER_NOTICE + ); + + if ($e->getAwsErrorCode() === 'AccessDeniedException') { + $error = strtolower($e->get('error')); + switch ($error) { + case 'token_expired': + return new CredentialsException( + 'Your session has expired.' . self::REAUTHENTICATE_MSG, + 0, + $e + ); + case 'user_credentials_changed': + return new CredentialsException( + 'Unable to refresh credentials because of a change in your password. ' + . 'Please reauthenticate with your new password.', + 0, + $e + ); + case 'insufficient_permissions': + return new CredentialsException( + 'Unable to refresh credentials due to insufficient permissions. ' + . 'You may be missing permission for the `CreateOAuth2Token` action.', + 0, + $e + ); + } + } + + return $e; + } + + // For all other exceptions + trigger_error( + 'Unexpected error refreshing login credentials: ' . $e->getMessage(), + E_USER_NOTICE + ); + + return $e; + } + + /** + * Resolves the cache file location for the given profile + * + * @param string $profileName The profile name to resolve the token location for + * + * @return string The full path to the cache file + * @throws CredentialsException If the profile doesn't exist, lacks login_session, + * or cache file is not readable + */ + private function resolveTokenLocation(string $profileName): string + { + $configFile = CredentialProvider::getConfigFileName(); + if (!is_readable($configFile)) { + throw new CredentialsException( + 'Unable to load configuration file at ' . $configFile + . '. Please ensure the file exists at the specified location.' + ); + } + + // Ensure profile and session are set + $profiles = CredentialProvider::loadProfiles($configFile); + if ($profileName === self::PROFILE_DEFAULT) { + $profileData = $profiles[self::PROFILE_DEFAULT] ?? null; + } elseif (str_starts_with($profileName, self::PROFILE_SECONDARY)) { + $profileData = $profiles[$profileName] ?? null; + } else { + // Try without prefix first, then with prefix + $profileData = $profiles[$profileName] + ?? $profiles[self::PROFILE_SECONDARY . $profileName] + ?? null; + } + + if (!$profileData) { + throw new CredentialsException( + "Profile '{$profileName}' does not exist. " + . "Please ensure the specified profile is set at {$configFile}." + ); + } + + if (empty($session = $profileData[self::KEY_PROFILE_LOGIN_SESSION] ?? null)) { + throw new CredentialsException( + "Profile '{$profileName}' did not contain a " + . self::KEY_PROFILE_LOGIN_SESSION . " value. " + . 're-authentication using `aws login` may be needed.' + ); + } + + // Resolve location and ensure it exists + $cacheDirectory = getenv(self::ENV_CACHE_DIRECTORY) + ?: CredentialProvider::getHomeDir() . self::DEFAULT_CACHE_DIRECTORY; + $cacheFile = $cacheDirectory . DIRECTORY_SEPARATOR . hash('sha256', trim($session)) . '.json'; + if (!@is_readable($cacheFile)) { + throw new CredentialsException( + "Failed to load cached credentials for profile " + . "'{$profileName}'." . self::REAUTHENTICATE_MSG + ); + } + + return $cacheFile; + } + + /** + * Creates a SigninClient configured for DPoP authentication + * + * @param string $profile + * @param string|null $region The AWS region for the Signin service + * + * @return SigninClient A configured SigninClient instance with DPoP signature version + */ + private function createSigninClient(string $profile, ?string $region): SigninClient + { + $resolvedRegion = $region + ?? ConfigurationResolver::env(self::KEY_CLIENT_REGION) + ?? ConfigurationResolver::ini(self::KEY_CLIENT_REGION, 'string', $profile) + ?? ConfigurationResolver::ini( + self::KEY_CLIENT_REGION, + 'string', + self::PROFILE_SECONDARY . $profile + ); + + if (empty($resolvedRegion)) { + throw new CredentialsException( + 'Unable to determine region for the Sign-In service client ' + . ' used for refreshing Login credentials. You can provide a region in-code ' + . 'when constructing the provider, by setting the AWS_REGION environment variable' + . ', or by setting a `region` in your specified profile.' + ); + } + + return new SigninClient([ + self::KEY_CLIENT_REGION => $resolvedRegion, + self::KEY_CLIENT_SIGNATURE_VERSION => self::CLIENT_SIGNATURE_DPOP, + self::KEY_CLIENT_CREDENTIALS => false + ]); + } + + /** + * Creates a Credentials object from token data + * + * @param array $tokenData The token data containing access key, secret, and session token + * @param int $expiration Unix timestamp for credential expiration + * @param string $accountId The AWS account ID + * + * @return Credentials The created Credentials object + */ + private static function createCredentials( + array $tokenData, + int $expiration, + string $accountId + ): Credentials + { + return new Credentials( + $tokenData[self::KEY_ACCESS_KEY_ID], + $tokenData[self::KEY_SECRET_ACCESS_KEY], + $tokenData[self::KEY_SESSION_TOKEN], + $expiration, + $accountId, + CredentialSources::PROFILE_LOGIN + ); + } + + /** + * Checks if all required keys are present and non-empty in the data array + * + * @param array $data The data array to check + * @param array $requiredKeys The list of keys that must be present and non-empty + * + * @return bool True if all required keys are present and non-empty, false otherwise + */ + private static function hasAllRequiredKeys( + array $data, + array $requiredKeys + ): bool + { + foreach ($requiredKeys as $key) { + if (empty($data[$key])) { + return false; + } + } + + return true; + } +} diff --git a/src/MetricsBuilder.php b/src/MetricsBuilder.php index af9b79fa1c..5452c5355f 100644 --- a/src/MetricsBuilder.php +++ b/src/MetricsBuilder.php @@ -53,6 +53,7 @@ final class MetricsBuilder const CREDENTIALS_PROFILE_PROCESS = "v"; const CREDENTIALS_PROFILE_SSO = "r"; const CREDENTIALS_PROFILE_SSO_LEGACY = "t"; + const CREDENTIALS_PROFILE_LOGIN = "AC"; /** @var int */ private static $MAX_METRICS_SIZE = 1024; // 1KB or 1024 B @@ -249,6 +250,8 @@ private function appendCredentialsMetric( self::CREDENTIALS_PROFILE_SSO, CredentialSources::PROFILE_SSO_LEGACY => self::CREDENTIALS_PROFILE_SSO_LEGACY, + CredentialSources::PROFILE_LOGIN => + self::CREDENTIALS_PROFILE_LOGIN ]; if (isset($credentialsMetricMapping[$source])) { $this->append($credentialsMetricMapping[$source]); diff --git a/src/Middleware.php b/src/Middleware.php index cd8eca6523..fa5e4d803c 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -6,6 +6,7 @@ use Aws\Credentials\CredentialsInterface; use Aws\EndpointV2\EndpointProviderV2; use Aws\Exception\AwsException; +use Aws\Signature\DpopSignature; use Aws\Signature\S3ExpressSignature; use Aws\Token\TokenAuthorization; use Aws\Token\TokenInterface; @@ -154,44 +155,41 @@ public static function signer( RequestInterface $request ) use ($handler, $signatureFunction, $credProvider, $tokenProvider, $config) { $signer = $signatureFunction($command); - if ($signer instanceof TokenAuthorization) { - return $tokenProvider()->then( - function (TokenInterface $token) - use ($handler, $command, $signer, $request) { - $command->getMetricsBuilder()->identifyMetricByValueAndAppend( - 'token', - $token - ); - return $handler( - $command, - $signer->authorizeRequest($request, $token) - ); - } - ); + // Token authorization path + if ($signer instanceof TokenAuthorization) { + return $tokenProvider()->then(function (TokenInterface $token) use ($handler, $command, $signer, $request) { + $command->getMetricsBuilder()->identifyMetricByValueAndAppend('token', $token); + return $handler($command, $signer->authorizeRequest($request, $token)); + }); } - if ($signer instanceof S3ExpressSignature) { - $credentialPromise = $config['s3_express_identity_provider']($command); - } else { - $credentialPromise = $credProvider(); + // DPoP path + if ($signer instanceof DpopSignature) { + if (empty($key = $command['dpopKey']) + || !($key instanceof \OpenSSLAsymmetricKey) + ) { + throw new \RuntimeException( + 'A valid DPoP key must be present for DPoP signatures' + ); + } + + return $handler($command, $signer->signRequest($request, $key)); } - return $credentialPromise->then( - function (CredentialsInterface $creds) - use ($handler, $command, $signer, $request) { - // Capture credentials metric - $command->getMetricsBuilder()->identifyMetricByValueAndAppend( - 'credentials', - $creds - ); + // Credential signing path + $credentialPromise = ($signer instanceof S3ExpressSignature) + ? $config['s3_express_identity_provider']($command) + : $credProvider(); - return $handler( - $command, - $signer->signRequest($request, $creds) - ); - } - ); + return $credentialPromise->then(function (CredentialsInterface $creds) use ($handler, + $command, + $signer, + $request + ) { + $command->getMetricsBuilder()->identifyMetricByValueAndAppend('credentials', $creds); + return $handler($command, $signer->signRequest($request, $creds)); + }); }; }; } diff --git a/src/Script/Composer/Composer.php b/src/Script/Composer/Composer.php index 765d0c24c2..4f42f6c916 100644 --- a/src/Script/Composer/Composer.php +++ b/src/Script/Composer/Composer.php @@ -7,6 +7,14 @@ class Composer { + private static array $unsafeForDeletion = [ + 'Kms' => true, + 'S3' => true , + 'SSO' => true, + 'SSOOIDC' => true, + 'Sts' => true, + 'Signin' => true + ]; public static function removeUnusedServicesInDev(Event $event, ?Filesystem $filesystem = null) { @@ -26,9 +34,7 @@ private static function removeUnusedServicesWithConfig(Event $event, ?Filesystem $composer = $event->getComposer(); $extra = $composer->getPackage()->getExtra(); - $listedServices = isset($extra['aws/aws-sdk-php']) - ? $extra['aws/aws-sdk-php'] - : []; + $listedServices = $extra['aws/aws-sdk-php'] ?? []; if ($listedServices) { $serviceMapping = self::buildServiceMapping(); @@ -79,9 +85,9 @@ private static function removeServiceDirs( $listedServices, $vendorPath ) { - $unsafeForDeletion = ['Kms', 'S3', 'SSO', 'SSOOIDC', 'Sts']; + $unsafeForDeletion = self::$unsafeForDeletion; if (in_array('DynamoDbStreams', $listedServices)) { - $unsafeForDeletion[] = 'DynamoDb'; + $unsafeForDeletion['DynamoDb'] = true; } $clientPath = $vendorPath . '/aws/aws-sdk-php/src/'; @@ -90,7 +96,7 @@ private static function removeServiceDirs( foreach ($serviceMapping as $clientName => $modelName) { if (!in_array($clientName, $listedServices) && - !in_array($clientName, $unsafeForDeletion) + !isset($unsafeForDeletion[$clientName]) ) { $clientDir = $clientPath . $clientName; $modelDir = $modelPath . $modelName; diff --git a/src/Sdk.php b/src/Sdk.php index 069952ac46..d981e956d6 100644 --- a/src/Sdk.php +++ b/src/Sdk.php @@ -825,7 +825,7 @@ */ class Sdk { - const VERSION = '3.361.0'; + const VERSION = '3.362.0'; /** @var array Arguments for creating clients */ private $args; diff --git a/src/Signature/DpopSignature.php b/src/Signature/DpopSignature.php new file mode 100644 index 0000000000..0d3b9f96df --- /dev/null +++ b/src/Signature/DpopSignature.php @@ -0,0 +1,246 @@ + true]; + private const EXT_OPENSSL = 'openssl'; + private const CURVE_NAME = 'prime256v1'; + private const HEADER_DPOP = 'DPop'; + private const HEADER_TYP = 'dpop+jwt'; + private const HEADER_ALG = 'ES256'; + private const HEADER_JWK_KTY = 'EC'; + private const HEADER_JWK_CRV = 'P-256'; + private const PAYLOAD_HTM = 'POST'; + private const JWK_SCHEMA = [ + 'kty' => self::HEADER_JWK_KTY, + 'crv' => self::HEADER_JWK_CRV, + ]; + private const HEADER_SCHEMA = [ + 'typ' => self::HEADER_TYP, + 'alg' => self::HEADER_ALG, + 'jwk' => self::JWK_SCHEMA + ]; + private const PAYLOAD_SCHEMA = [ + 'htm' => self::PAYLOAD_HTM, + ]; + + /** + * Creates a new DpopSignature instance for the specified service + * + * @param string $serviceName The name of the AWS service (must be in the allow list) + * + * @throws \RuntimeException If the OpenSSL extension is not loaded + * @throws \InvalidArgumentException If the service is not in the allow list + */ + public function __construct(string $serviceName) + { + if (!extension_loaded(self::EXT_OPENSSL)) { + throw new \RuntimeException( + 'the `openssl` extension is required to generate DPop signatures. ' + . 'Please install or enable the `openssl` extension.' + ); + } + + if (!isset(self::ALLOW_LISTED_SERVICES[$serviceName])) { + throw new \InvalidArgumentException( + "The '{$serviceName}' service does not support DPop signatures. " + . 'Please configure a signature version this service supports.' + ); + } + } + + /** + * Signs an HTTP request with a DPoP header + * + * @param RequestInterface $request The HTTP request to sign + * @param \OpenSSLAsymmetricKey $key The private key for signing + * + * @return RequestInterface The request with the DPoP header added + * @throws \RuntimeException|\Exception If signature generation fails + */ + public function signRequest( + RequestInterface $request, + \OpenSSLAsymmetricKey $key, + ) { + $dpopHeaderValue = $this->generateDpopProof($key, $request->getUri()); + + return $request->withHeader(self::HEADER_DPOP, $dpopHeaderValue); + } + + /** + * Generates the DPoP JWT header value for the request + * + * @param \OpenSSLAsymmetricKey $key The private key for signing + * @param UriInterface $uri The URI of the request + * + * @return string The complete DPoP JWT token + * @throws \RuntimeException|\Exception If signature generation fails + */ + private function generateDpopProof( + \OpenSSLAsymmetricKey $key, + UriInterface $uri + ): string + { + $keyDetails = openssl_pkey_get_details($key); + if (($keyDetails['ec']['curve_name'] ?? '') !== self::CURVE_NAME) { + throw new \InvalidArgumentException( + 'DPoP signature keys must use P-256 curve. ' + . 'Please check your configuration and try again.' + ); + } + + ['x' => $x, 'y' => $y] = $keyDetails['ec']; + $header = $this->buildDpopHeader($x, $y); + $payload = $this->buildDpopPayload((string) $uri); + + $message = $this->base64url_encode(json_encode($header)) + . '.' . $this->base64url_encode(json_encode($payload)); + $signature = ''; + if (!openssl_sign($message, $signature, $key, OPENSSL_ALGO_SHA256)) { + $error = openssl_error_string(); + + throw new \RuntimeException( + 'Failed to generate signature.' . ($error ? ": $error" : '.') + ); + } + + $signature = $this->derToRaw($signature); + + return $message . '.' . $this->base64url_encode($signature); + } + + /** + * Builds the DPoP JWT header with the public key coordinates + * + * @param string $x The x-coordinate of the EC public key + * @param string $y The y-coordinate of the EC public key + * + * @return array The complete DPoP header with JWK + */ + private function buildDpopHeader(string $x, string $y): array + { + return array_merge_recursive(self::HEADER_SCHEMA, [ + 'jwk' => [ + 'x' => $this->base64url_encode($x), + 'y' => $this->base64url_encode($y) + ] + ]); + } + + /** + * Builds the DPoP JWT payload with request-specific claims + * + * @param string $uri The target URI for the HTTP request + * + * @return array The complete DPoP payload with jti, htm, htu, and iat claims + * @throws RandomException + */ + private function buildDpopPayload(string $uri): array + { + return array_merge(self::PAYLOAD_SCHEMA, [ + 'jti' => $this->uuidv4(), + 'htu' => $uri, + 'iat' => time() + ]); + } + + /** + * Generates a version 4 UUID + * + * @return string A UUID v4 string + * @throws RandomException + */ + private function uuidv4(): string + { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + /** + * Encodes data to Base64URL format (RFC 4648) + * + * @param string $data The data to encode + * + * @return string Base64URL encoded string (no padding, URL-safe characters) + */ + private function base64url_encode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Convert DER-encoded ECDSA signature to raw R||S format (64 bytes for ES256) + * + * @param string $derSignature DER-encoded signature from openssl_sign + * + * @return string Raw signature (64 bytes: 32 bytes R + 32 bytes S) + * @throws \Exception If the DER signature is invalid + */ + private function derToRaw(string $derSignature): string + { + $hex = bin2hex($derSignature); + $pos = 0; + + // Parse SEQUENCE tag (0x30) + if (substr($hex, $pos, 2) !== '30') { + throw new \Exception('Invalid DER signature format: missing SEQUENCE tag'); + } + $pos += 2; + + // Parse SEQUENCE length + $seqLen = hexdec(substr($hex, $pos, 2)); + $pos += 2; + + // Parse first INTEGER tag (0x02) for R + if (substr($hex, $pos, 2) !== '02') { + throw new \Exception('Invalid DER signature format: missing R INTEGER tag'); + } + $pos += 2; + + // Parse R length + $rLen = hexdec(substr($hex, $pos, 2)); + $pos += 2; + + // Extract R value + $r = substr($hex, $pos, $rLen * 2); + if (strlen($r) !== $rLen * 2) { + throw new \Exception('Invalid DER signature: R length mismatch'); + } + $pos += $rLen * 2; + + // Parse second INTEGER tag (0x02) for S + if (substr($hex, $pos, 2) !== '02') { + throw new \Exception('Invalid DER signature format: missing S INTEGER tag'); + } + $pos += 2; + + // Parse S length + $sLen = hexdec(substr($hex, $pos, 2)); + $pos += 2; + + // Extract S value + $s = substr($hex, $pos, $sLen * 2); + if (strlen($s) !== $sLen * 2) { + throw new \Exception('Invalid DER signature: S length mismatch'); + } + + // Remove leading zeros (if any) and pad to 32 bytes + $r = str_pad(ltrim($r, '0'), 64, '0', STR_PAD_LEFT); + $s = str_pad(ltrim($s, '0'), 64, '0', STR_PAD_LEFT); + + // Ensure exactly 32 bytes each + if (strlen($r) !== 64 || strlen($s) !== 64) { + throw new \Exception('Invalid signature component length'); + } + + return hex2bin($r . $s); + } +} diff --git a/src/Signature/SignatureProvider.php b/src/Signature/SignatureProvider.php index 5d496d763d..b701143c2f 100644 --- a/src/Signature/SignatureProvider.php +++ b/src/Signature/SignatureProvider.php @@ -65,6 +65,7 @@ public static function resolve(callable $provider, $version, $service, $region) $result = $provider($version, $service, $region); if ($result instanceof SignatureInterface || $result instanceof BearerTokenAuthorization + || $result instanceof DpopSignature ) { return $result; } @@ -139,6 +140,8 @@ public static function version() return new BearerTokenAuthorization(); case 'anonymous': return new AnonymousSignature(); + case 'dpop': + return new DpopSignature($service); default: return null; } diff --git a/tests/ClientResolverTest.php b/tests/ClientResolverTest.php index baaf5e9bfb..123cf11098 100644 --- a/tests/ClientResolverTest.php +++ b/tests/ClientResolverTest.php @@ -382,6 +382,22 @@ public function testCanCreateNullCredentials() $this->assertSame('anonymous', $conf['config']['signature_version']); } + public function testCanCreateNullCredentialsWithDpop() + { + $r = new ClientResolver(ClientResolver::getDefaultArguments()); + $conf = $r->resolve([ + 'service' => 'sqs', + 'region' => 'x', + 'credentials' => false, + 'signature_version' => 'dpop' + ], new HandlerList()); + $creds = call_user_func($conf['credentials'])->wait(); + $this->assertInstanceOf(Credentials::class, $creds); + // `'credentials' => false` results in a signature_version overwrite + // to 'anonymous' for any other value + $this->assertSame('dpop', $conf['config']['signature_version']); + } + public function testCanCreateCredentialsFromProvider() { $c = new Credentials('foo', 'bar'); diff --git a/tests/Credentials/CredentialProviderTest.php b/tests/Credentials/CredentialProviderTest.php index 8aae52e134..d2f37a8a17 100644 --- a/tests/Credentials/CredentialProviderTest.php +++ b/tests/Credentials/CredentialProviderTest.php @@ -8,11 +8,11 @@ use Aws\Credentials\CredentialSources; use Aws\Credentials\EcsCredentialProvider; use Aws\Credentials\InstanceProfileProvider; +use Aws\Exception\CredentialsException; use Aws\History; use Aws\LruArrayCache; use Aws\Result; use Aws\SSO\SSOClient; -use Aws\Sts\Exception\StsException; use Aws\Sts\StsClient; use Aws\Token\SsoTokenProvider; use Aws\Test\UsesServiceTrait; @@ -58,6 +58,7 @@ class CredentialProviderTest extends TestCase CredentialProvider::ENV_ARN, CredentialProvider::ENV_TOKEN_FILE, CredentialProvider::ENV_ROLE_SESSION_NAME, + CredentialProvider::ENV_REGION, 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI', 'AWS_CONTAINER_AUTHORIZATION_TOKEN', @@ -66,6 +67,7 @@ class CredentialProviderTest extends TestCase 'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_SHARED_CREDENTIALS_FILE', + 'AWS_CONFIG_FILE', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', ]; @@ -82,6 +84,7 @@ class CredentialProviderTest extends TestCase CredentialProvider::ENV_ARN, CredentialProvider::ENV_TOKEN_FILE, CredentialProvider::ENV_ROLE_SESSION_NAME, + CredentialProvider::ENV_REGION, 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI', 'AWS_CONTAINER_AUTHORIZATION_TOKEN', @@ -90,6 +93,7 @@ class CredentialProviderTest extends TestCase 'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_SHARED_CREDENTIALS_FILE', + 'AWS_CONFIG_FILE', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', ]; @@ -1558,7 +1562,6 @@ public function testGetsHomeDirectoryForWindowsUsers(): void putenv('HOMEPATH=\\Michael\\Home'); $ref = new \ReflectionClass(CredentialProvider::class); $meth = $ref->getMethod('getHomeDir'); - $meth->setAccessible(true); $this->assertSame('C:\\Michael\\Home', $meth->invoke(null)); } @@ -1685,6 +1688,8 @@ public function testCallsDefaultsCreds(): void public function testCachesCacheableInDefaultChain(): void { + $this->createAwsHome(); + $cacheable = [ 'web_identity', 'sso', @@ -1716,6 +1721,8 @@ public function testCachesCacheableInDefaultChain(): void public function testCachesAsPartOfDefaultChain(): void { + $this->createAwsHome(); + $instanceCredential = new Credentials( 'instance_foo', 'instance_bar', @@ -1733,9 +1740,6 @@ public function testCachesAsPartOfDefaultChain(): void $cache->set('aws_cached_instance_credentials', $instanceCredential); $cache->set('aws_cached_ecs_credentials', $ecsCredential); - // Start with a deliberately bad HOME so shared files aren't found - putenv('HOME=/does/not/exist'); - $credentials = call_user_func(CredentialProvider::defaultProvider([ 'credentials' => $cache, ]))->wait(); @@ -2336,4 +2340,525 @@ public function testCredentialsSourceFromSsoLegacy(): void $credentials->getSource() ); } + + public function testLoginResolvesRegionFromConfig(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + + $ini = << 'us-west-2'] + ); + + $this->assertIsCallable($provider); + + $provider()->wait(); + } + + public function testLoginResolvesRegionFromEnv(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + + $ini = <<assertIsCallable($provider); + + $provider()->wait(); + } + + public function testLogintResolvesRegionFromProfile(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + $ini = <<assertIsCallable($provider); + + $provider()->wait(); + } + + public function testLoginFailsWithMissingRegion(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Unable to determine region'); + + $awsDir = $this->createAwsHome(); + + // Create a profile without region + $ini = <<wait(); + } + + public function testLoginUsesDefaultProfileWhenNotSpecified(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('default'); + + $awsDir = $this->createAwsHome(); + + file_put_contents($awsDir . '/config', ''); + + // Test with no profile argument - should use 'default' + $provider = CredentialProvider::login( + null, + ['region' => 'us-east-1'] + ); + + $this->assertIsCallable($provider); + + $provider()->wait(); + } + + public function testLoginUsesProfileFromEnvWhenNotSpecified(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('envProfile'); + + $awsDir = $this->createAwsHome(); + + file_put_contents($awsDir . '/config', ''); + + putenv(CredentialProvider::ENV_PROFILE . '=envProfile'); + + $provider = CredentialProvider::login( + null, + ['region' => 'us-east-1'] + ); + + $this->assertIsCallable($provider); + + $provider()->wait(); + } + + public function testLoginHandlesClientCreationFailure(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Unable to determine region'); + + $awsDir = $this->createAwsHome(); + + $ini = << ''] + ); + + $provider()->wait(); + } + + public function testLoginHandlesInvalidProfileName(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('nonExistentProfile'); + + $awsDir = $this->createAwsHome(); + + // Create empty config file + file_put_contents($awsDir . '/config', ''); + + $provider = CredentialProvider::login( + 'nonExistentProfile', + ['region' => 'us-east-1'] + ); + + $provider()->wait(); + } + + public function testLoginSuccessfullyRetrievesCredentialsFromCache(): void + { + $awsDir = $this->createAwsHome(); + + // Create config with login_session + $ini = <<format('Y-m-d\TH:i:s\Z'); + $tokenData = json_encode([ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFDZHUzOG1Pzq+6F0mjMlOSp1syN9LRPBuHMoCFXTcXhoAoGCCqGSM49 +AwEHoUQDQgAE9qhj+KtcdHj1kVgwxWWWw++tqoh7H7UHs7oXh8jBbgF47rrYGC+t +djiIaHK3dBvvdE7MGj5HsepzLm3Kj91bqA== +-----END EC PRIVATE KEY-----' + ]); + + file_put_contents($tokenFile, $tokenData); + + $provider = CredentialProvider::login('default', ['region' => 'us-west-2']); + $credentials = $provider()->wait(); + + $this->assertEquals(CredentialSources::PROFILE_LOGIN, $credentials->getSource()); + $this->assertEquals('testKey', $credentials->getAccessKeyId()); + $this->assertEquals('testSecret', $credentials->getSecretKey()); + $this->assertEquals('testToken', $credentials->getSecurityToken()); + $this->assertEquals('123456789012', $credentials->getAccountId()); + } + + public function testLoginAddedToDefaultChain(): void + { + $awsDir = $this->createAwsHome(); + + // Create config with login_session + $ini = <<format('Y-m-d\TH:i:s\Z'); + $tokenData = json_encode([ + 'accessToken' => [ + 'accessKeyId' => 'loginKey', + 'secretAccessKey' => 'loginSecret', + 'sessionToken' => 'loginToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFDZHUzOG1Pzq+6F0mjMlOSp1syN9LRPBuHMoCFXTcXhoAoGCCqGSM49 +AwEHoUQDQgAE9qhj+KtcdHj1kVgwxWWWw++tqoh7H7UHs7oXh8jBbgF47rrYGC+t +djiIaHK3dBvvdE7MGj5HsepzLm3Kj91bqA== +-----END EC PRIVATE KEY-----' + ]); + + file_put_contents($tokenFile, $tokenData); + + $creds = call_user_func(CredentialProvider::defaultProvider())->wait(); + + $this->assertSame('loginKey', $creds->getAccessKeyId()); + $this->assertSame('loginSecret', $creds->getSecretKey()); + $this->assertSame('loginToken', $creds->getSecurityToken()); + } + + public function testLoginUsedFromCacheInDefaultChain(): void + { + $this->createAwsHome(); + + $cache = new LruArrayCache(); + $cachedCreds = new Credentials( + 'cachedLoginKey', + 'cachedLoginSecret', + 'cachedLoginToken', + PHP_INT_MAX + ); + $cache->set('aws_cached_login_credentials', $cachedCreds); + + $credentials = call_user_func(CredentialProvider::defaultProvider([ + 'credentials' => $cache, + ]))->wait(); + + $this->assertSame('cachedLoginKey', $credentials->getAccessKeyId()); + $this->assertSame('cachedLoginSecret', $credentials->getSecretKey()); + $this->assertSame('cachedLoginToken', $credentials->getSecurityToken()); + } + + /** + * @dataProvider loginInvalidCacheProvider + */ + public function testLoginWithInvalidCache( + string $cacheContent, + string $expectedMessage, + string $testDescription + ): void { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage($expectedMessage); + + $awsDir = $this->createAwsHome(); + + $ini = <<wait(); + } + + public function loginInvalidCacheProvider(): array + { + $validDpopKey = '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFDZHUzOG1Pzq+6F0mjMlOSp1syN9LRPBuHMoCFXTcXhoAoGCCqGSM49 +AwEHoUQDQgAE9qhj+KtcdHj1kVgwxWWWw++tqoh7H7UHs7oXh8jBbgF47rrYGC+t +djiIaHK3dBvvdE7MGj5HsepzLm3Kj91bqA== +-----END EC PRIVATE KEY-----'; + + return [ + 'invalid JSON' => [ + 'not valid json {', + 'Invalid JSON', + 'Cache file contains invalid JSON' + ], + 'missing refreshToken' => [ + json_encode([ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + // Missing refreshToken + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $validDpopKey + ]), + 'Missing required keys', + 'Cache file missing required refreshToken key' + ], + 'missing accessKeyId' => [ + json_encode([ + 'accessToken' => [ + // Missing accessKeyId + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $validDpopKey + ]), + 'Missing required keys', + 'Cache file missing required accessKeyId in accessToken' + ], + 'invalid DPoP key' => [ + json_encode([ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => 'invalid key data' + ]), + 'Failed to load DPoP private key', + 'Cache file contains invalid DPoP private key' + ], + ]; + } + + public function testLoginWithProfilePrefix(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + + // Use "profile myprofile" prefix format + $ini = <<assertIsCallable($provider); + + $provider()->wait(); + } + + public function testLoginMemoizes(): void + { + $awsDir = $this->createAwsHome(); + + $ini = <<format('Y-m-d\TH:i:s\Z'); + $tokenData = json_encode([ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFDZHUzOG1Pzq+6F0mjMlOSp1syN9LRPBuHMoCFXTcXhoAoGCCqGSM49 +AwEHoUQDQgAE9qhj+KtcdHj1kVgwxWWWw++tqoh7H7UHs7oXh8jBbgF47rrYGC+t +djiIaHK3dBvvdE7MGj5HsepzLm3Kj91bqA== +-----END EC PRIVATE KEY-----' + ]); + + file_put_contents($tokenFile, $tokenData); + + $called = 0; + $baseProvider = function () use (&$called) { + $called++; + return call_user_func(CredentialProvider::login('default',)); + }; + + $memoized = CredentialProvider::memoize($baseProvider); + + $creds1 = $memoized()->wait(); + $creds2 = $memoized()->wait(); + + $this->assertSame(1, $called); + $this->assertSame($creds1->getAccessKeyId(), $creds2->getAccessKeyId()); + $this->assertSame($creds1->getSecretKey(), $creds2->getSecretKey()); + } + + public function testLoginMemoizeCleansUpOnError(): void + { + $awsDir = $this->createAwsHome(); + + $ini = <<wait(false); + $memoized()->wait(false); + + $this->assertSame(2, $called); + } + + public function testLoginWithMissingConfigFile(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Unable to load configuration file'); + + $awsDir = $this->createAwsHome(); + // Don't create config file + + $provider = CredentialProvider::login('default', ['region' => 'us-west-2']); + $provider()->wait(); + } + + public function testLoginWithEmptyLoginSession(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('login_session'); + + $awsDir = $this->createAwsHome(); + + $ini = <<wait(); + } } diff --git a/tests/Credentials/LoginCredentialProviderTest.php b/tests/Credentials/LoginCredentialProviderTest.php new file mode 100644 index 0000000000..19ec52c0b0 --- /dev/null +++ b/tests/Credentials/LoginCredentialProviderTest.php @@ -0,0 +1,1722 @@ + */ + private array $originalEnv = []; + + /** @var list */ + private array $tempDirs = []; + + /** + * Environment variables to track + */ + private const ENV_VARS_TO_TRACK = [ + 'HOME', + 'HOMEDRIVE', + 'HOMEPATH', + 'AWS_PROFILE', + 'AWS_REGION', + 'AWS_DEFAULT_REGION', + 'AWS_LOGIN_CACHE_DIRECTORY', + 'AWS_CONFIG_FILE', + ]; + + protected function setUp(): void + { + parent::setUp(); + + // Snapshot all tracked env vars + foreach (self::ENV_VARS_TO_TRACK as $var) { + $this->originalEnv[$var] = getenv($var); + } + + // Clear AWS-specific env vars + putenv('AWS_PROFILE='); + putenv('AWS_REGION='); + putenv('AWS_DEFAULT_REGION='); + putenv('AWS_LOGIN_CACHE_DIRECTORY='); + putenv('AWS_CONFIG_FILE='); + unset($_SERVER['AWS_PROFILE'], + $_SERVER['AWS_REGION'], + $_SERVER['AWS_DEFAULT_REGION'], + $_SERVER['AWS_LOGIN_CACHE_DIRECTORY'], + $_SERVER['AWS_CONFIG_FILE'] + ); + } + + protected function tearDown(): void + { + // Restore all tracked env vars to their original values + foreach ($this->originalEnv as $key => $value) { + if ($value !== false) { + putenv("$key=$value"); + $_SERVER[$key] = $value; + } else { + putenv("$key="); + if (isset($_SERVER[$key])) { + unset($_SERVER[$key]); + } + } + } + $this->originalEnv = []; + + // Clean any temp dirs created during tests + foreach ($this->tempDirs as $dir) { + if (is_dir($dir)) { + $this->recursiveDelete($dir); + } + } + $this->tempDirs = []; + + parent::tearDown(); + } + + /** + * Create an isolated temp HOME with a `.aws` dir. + * Sets HOME to the temp base dir and returns the `.aws` path. + */ + private function createAwsHome(): string + { + $base = sys_get_temp_dir() . '/aws_test_' . uniqid('', true); + $awsDir = $base . '/.aws'; + mkdir($awsDir, 0777, true); + $this->tempDirs[] = $base; + putenv('HOME=' . $base); + $_SERVER['HOME'] = $base; + return $awsDir; + } + + private function recursiveDelete(string $dir): void + { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $dir, + \RecursiveDirectoryIterator::SKIP_DOTS + ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $file) { + $path = $file->getPathname(); + if ($file->isDir()) { + @rmdir($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } + + private function createValidTokenCache( + string $awsDir, + string $loginSession, + array $overrides = [] + ): string + { + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 3600); + $tokenData = array_merge([ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . +"MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . +"AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . +"rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . +"-----END EC PRIVATE KEY-----" + ], $overrides); + + file_put_contents($tokenFile, json_encode($tokenData)); + return $tokenFile; + } + + private function createConfigFile( + string $awsDir, + string $profileName, + string $loginSession + ): string + { + $configFile = $awsDir . '/config'; + $prefix = $profileName === 'default' ? '' : 'profile '; + $config = <<expectException(CredentialsException::class); + $this->expectExceptionMessage('Unable to load configuration file'); + + $awsDir = $this->createAwsHome(); + // Don't create config file + + new LoginCredentialProvider('default', 'us-west-2'); + } + + public function testConstructorFailsWithMissingProfile(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage("Profile 'nonexistent' does not exist"); + + $awsDir = $this->createAwsHome(); + $this->createConfigFile( + $awsDir, + 'default', + 'arn:aws:iam::123456789012:user/TestUser' + ); + + new LoginCredentialProvider('nonexistent', 'us-west-2'); + } + + public function testConstructorFailsWithMissingLoginSession(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('did not contain a login_session value'); + + $awsDir = $this->createAwsHome(); + $configFile = $awsDir . '/config'; + $config = <<expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + // Don't create cache file + + new LoginCredentialProvider('default', 'us-west-2'); + } + + public function testConstructorHandlesProfilePrefix(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'myprofile', $loginSession); + + new LoginCredentialProvider('myprofile', 'us-west-2'); + } + + public function testUsesCustomCacheDirectory(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load cached credentials'); + + $awsDir = $this->createAwsHome(); + $customCacheDir = sys_get_temp_dir() . '/custom_cache_' . uniqid('', true); + $this->tempDirs[] = $customCacheDir; + + putenv('AWS_LOGIN_CACHE_DIRECTORY=' . $customCacheDir); + + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + new LoginCredentialProvider('default', 'us-west-2'); + } + + public function testLoadCredentialsFromValidCache(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + $this->createValidTokenCache($awsDir, $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $credentials = $provider()->wait(); + + $this->assertEquals(CredentialSources::PROFILE_LOGIN, $credentials->getSource()); + $this->assertEquals('testKey', $credentials->getAccessKeyId()); + $this->assertEquals('testSecret', $credentials->getSecretKey()); + $this->assertEquals('testToken', $credentials->getSecurityToken()); + $this->assertEquals('123456789012', $credentials->getAccountId()); + } + + public function testLoadTokenFailsWithInvalidJson(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Invalid JSON in cache file'); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + file_put_contents($tokenFile, 'invalid json {'); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $provider()->wait(); + } + + /** + * @dataProvider missingCacheKeysProvider + */ + public function testLoadTokenFailsWithMissingOrEmptyCacheKeys( + array $tokenData, + string $expectedMessage + ): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage($expectedMessage); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + file_put_contents($tokenFile, json_encode($tokenData)); + $this->createConfigFile($awsDir, 'default', $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $provider()->wait(); + } + + public function missingCacheKeysProvider(): array + { + $validDpopKey = "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----"; + + return [ + 'missing refreshToken' => [ + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + // Missing refreshToken + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $validDpopKey + ], + 'Missing required keys in cached token' + ], + 'missing secretAccessKey in accessToken' => [ + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + // Missing secretAccessKey + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $validDpopKey + ], + 'Missing required keys in cached token' + ], + 'empty accessToken array' => [ + [ + 'accessToken' => [], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $validDpopKey + ], + 'Missing required keys in cached token' + ], + 'empty string sessionToken' => [ + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => '', // Empty string session token + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $validDpopKey + ], + 'Missing required keys in cached token' + ], + 'missing dpopKey' => [ + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + // Missing dpopKey + ], + 'Missing required keys in cached token' + ], + 'missing clientId' => [ + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + // Missing clientId + 'dpopKey' => $validDpopKey + ], + 'Missing required keys in cached token' + ] + ]; + } + + public function testLoadTokenFailsWithInvalidDpopKey(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Failed to load DPoP private key'); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => '2500-01-01T00:00:00Z' + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => 'invalid key data' + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + $this->createConfigFile($awsDir, 'default', $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $provider()->wait(); + } + + + public function testShouldRefreshReturnsTrueForExpiringCredentials(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create credentials expiring in 2 minutes (within 3 minute threshold) + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 120); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + $credentials = $provider()->wait(); + + // Should have refreshed + $this->assertEquals('refreshedKey', $credentials->getAccessKeyId()); + } + + public function testShouldRefreshReturnsFalseForFreshCredentials(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create credentials expiring in 10 minutes (outside 3 minute threshold) + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 600); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + $mockClient = $this->createMock(SigninClient::class); + // Should not call refresh + $mockClient->expects($this->never())->method('__call'); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + $credentials = $provider()->wait(); + + // Should use existing credentials + $this->assertEquals('testKey', $credentials->getAccessKeyId()); + } + + public function testRefreshUpdatesCredentialsCorrectly(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create expired credentials + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN PRIVATE KEY-----\n" . + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUCM6wO00sOZ3xv1I\n" . + "HUZQk6Owz3nW+TPqxFHKzsli6VOhRANCAASB46Rm/KSro0ieDm88ztr43WflZZN5\n" . + "ttLT1iSB4ORVJ7mRJ0MVrk7phe/nK6oLp925JsYfzdJyGqYJEGmD+TwC\n" . + "-----END PRIVATE KEY-----" + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $this->createConfigFile($awsDir, 'default', $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + $credentials = $provider()->wait(); + + $this->assertEquals('refreshedKey', $credentials->getAccessKeyId()); + $this->assertEquals('refreshedSecret', $credentials->getSecretKey()); + $this->assertEquals('refreshedToken', $credentials->getSecurityToken()); + } + + public function testContinuesWithExistingTokenAfterRefreshFailure(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create credentials expiring in 5 minutes (within refresh window) + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 300); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'existingKey', + 'secretAccessKey' => 'existingSecret', + 'sessionToken' => 'existingToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + // Use test client with mock handler that returns a network error + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + // Add a generic network error to the mock handler + $mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $exception = new AwsException( + 'Network error', + $mockCommand, + [ + 'code' => 'Network error', + ] + ); + $this->addMockResults($mockClient, [$exception]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + $credentials = @$provider()->wait(); + + // Should use existing credentials despite refresh failure + $this->assertEquals('existingKey', $credentials->getAccessKeyId()); + $this->assertEquals('existingSecret', $credentials->getSecretKey()); + $this->assertEquals('existingToken', $credentials->getSecurityToken()); + } + + public function testRefreshThrowsOnTokenExpiredError(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage( + 'Your session has expired. Please reauthenticate using `aws login`' + ); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create expired credentials to force refresh + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $exception = new SigninException( + 'Token expired', + $mockCommand, + [ + 'code' => 'AccessDeniedException', + 'body' => ['error' => 'token_expired'] + ] + ); + + $this->addMockResults($mockClient, [$exception]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + @$provider()->wait(); + } + + public function testRefreshThrowsOnUserCredentialsChangedError(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Unable to refresh credentials because of a change in your password'); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create expired credentials to force refresh + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $exception = new SigninException( + 'User credentials changed', + $mockCommand, + [ + 'code' => 'AccessDeniedException', + 'body' => ['error' => 'user_credentials_changed'] + ] + ); + + $this->addMockResults($mockClient, [$exception]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + @$provider()->wait(); + } + + public function testRefreshThrowsOnInsufficientPermissionsError(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage( + 'Unable to refresh credentials due to insufficient permissions' + ); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create expired credentials to force refresh + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $exception = new SigninException( + 'Insufficient permissions', + $mockCommand, + [ + 'code' => 'AccessDeniedException', + 'body' => ['error' => 'insufficient_permissions'] + ] + ); + + $this->addMockResults($mockClient, [$exception]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + @$provider()->wait(); + } + + public function testRefreshRethrowsOtherRefreshExceptions(): void + { + $this->expectException(CredentialsException::class); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + // Create expired credentials to force refresh + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $this->createValidTokenCache($awsDir, $loginSession, [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ] + ]); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $exception = new SigninException( + 'Some other error', + $mockCommand, + [ + 'code' => 'SomeOtherError', + 'body' => [] + ] + ); + + $this->addMockResults($mockClient, [$exception]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + @$provider()->wait(); + } + + public function testRefreshUpdatesInMemoryAndFileCache(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create expired credentials + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN PRIVATE KEY-----\n" . + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUCM6wO00sOZ3xv1I\n" . + "HUZQk6Owz3nW+TPqxFHKzsli6VOhRANCAASB46Rm/KSro0ieDm88ztr43WflZZN5\n" . + "ttLT1iSB4ORVJ7mRJ0MVrk7phe/nK6oLp925JsYfzdJyGqYJEGmD+TwC\n" . + "-----END PRIVATE KEY-----" + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + + $this->createConfigFile($awsDir, 'default', $loginSession); + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + 'accountId' => '123456789012' + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + $credentials = $provider()->wait(); + + // Should have refreshed + $this->assertEquals('refreshedKey', $credentials->getAccessKeyId()); + $this->assertEquals('refreshedSecret', $credentials->getSecretKey()); + $this->assertEquals('refreshedToken', $credentials->getSecurityToken()); + + // Verify cache file was updated + $updatedCache = json_decode(file_get_contents($tokenFile), true); + $this->assertEquals('newRefresh', $updatedCache['refreshToken']); + $this->assertEquals('refreshedKey', $updatedCache['accessToken']['accessKeyId']); + } + + public function testProviderCachesCredentialsInMemory(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create expired credentials + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----" + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + 'accountId' => '123456789012' + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $this->createConfigFile($awsDir, 'default', $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + // First call should trigger refresh + $credentials1 = $provider()->wait(); + $this->assertEquals('refreshedKey', $credentials1->getAccessKeyId()); + + // Second call should use in-memory cache, not call refresh again + $credentials2 = $provider()->wait(); + $this->assertEquals('refreshedKey', $credentials2->getAccessKeyId()); + $this->assertSame($credentials1, $credentials2); // Should be the same object + } + + + public function testLoadsCredentialsWithValidCacheAndAccountId(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Test various date formats + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 7200); + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----" + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + $this->createConfigFile($awsDir, 'default', $loginSession); + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $credentials = $provider()->wait(); + + // Verify expiration is parsed correctly + $this->assertGreaterThan(time(), $credentials->getExpiration()); + $this->assertLessThan(time() + 10000, $credentials->getExpiration()); + } + + public function testInvalidExpirationFormatThrowsException(): void + { + $this->expectException(CredentialsException::class); + + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create credentials with invalid expiration format + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => 'invalid-date-format' // This will cause strtotime to fail + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----" + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + + $this->createConfigFile($awsDir, 'default', $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + // This should throw due to invalid date format when loading the token + $provider()->wait(); + } + + public function testWriteToCacheMergesWithExistingCache(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create expired credentials with extra fields + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN PRIVATE KEY-----\n" . + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUCM6wO00sOZ3xv1I\n" . + "HUZQk6Owz3nW+TPqxFHKzsli6VOhRANCAASB46Rm/KSro0ieDm88ztr43WflZZN5\n" . + "ttLT1iSB4ORVJ7mRJ0MVrk7phe/nK6oLp925JsYfzdJyGqYJEGmD+TwC\n" . + "-----END PRIVATE KEY-----", + 'extraField' => 'shouldBePreserved' + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $this->createConfigFile($awsDir, 'default', $loginSession); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + $credentials = $provider()->wait(); + + // Check that file was updated and extra fields preserved + $updatedData = json_decode( + file_get_contents($tokenFile), + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertEquals('newRefresh', $updatedData['refreshToken']); + $this->assertEquals('refreshedKey', $updatedData['accessToken']['accessKeyId']); + $this->assertEquals('refreshedSecret', $updatedData['accessToken']['secretAccessKey']); + $this->assertEquals('refreshedToken', $updatedData['accessToken']['sessionToken']); + $this->assertEquals('shouldBePreserved', $updatedData['extraField']); // Extra fields should be preserved + } + + /** + * Test that a key with specifiedCurve parameters works correctly + */ + public function testLoadCredentialsWithSpecifiedCurveKey(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 3600); + + // Key with specifiedCurve parameters + $specifiedCurveKey = "-----BEGIN EC PRIVATE KEY-----\n" . + "MIIBUQIBAQQgW2JEXxOdj8dFip0hyS6SHr9dHciiZQyoTZoe6sox1KGggeMwgeAC\n" . + "AQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAAAAAAAAAAAAAA////////////////\n" . + "MEQEIP////8AAAABAAAAAAAAAAAAAAAA///////////////8BCBaxjXYqjqT57Pr\n" . + "vVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwRBBGsX0fLhLEJH+Lzm5WOkQPJ3A32BLesz\n" . + "oPShOUXYmMKWT+NC4v4af5uO5+tKfA+eFivOM1drMV7Oy7ZAaDe/UfUCIQD/////\n" . + "AAAAAP//////////vOb6racXnoTzucrC/GMlUQIBAaFEA0IABFqlTnAPfLQfFrmn\n" . + "uJFbpcMA89r5uhBzUe+KvRCCPpscjMats1NUCB64qslJ3QYEGuAx2BP2gOeQBUbl\n" . + "rSdm4F4=\n" . + "-----END EC PRIVATE KEY-----"; + + $tokenData = json_encode( + [ + 'accessToken' => [ + 'accessKeyId' => 'testKey', + 'secretAccessKey' => 'testSecret', + 'sessionToken' => 'testToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => $specifiedCurveKey + ], + JSON_THROW_ON_ERROR + ); + + file_put_contents($tokenFile, $tokenData); + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $credentials = $provider()->wait(); + + $this->assertEquals(CredentialSources::PROFILE_LOGIN, $credentials->getSource()); + $this->assertEquals('testKey', $credentials->getAccessKeyId()); + $this->assertEquals('testSecret', $credentials->getSecretKey()); + $this->assertEquals('testToken', $credentials->getSecurityToken()); + $this->assertEquals('123456789012', $credentials->getAccountId()); + } + + /** + * @dataProvider loginTestCasesProvider + */ + public function testLoginCredentialProviderFromTestCases( + string $documentation, + string $configContents, + array $cacheContents, + array $mockApiCalls, + array $outcomes + ): void + { + $awsDir = $this->createAwsHome(); + + // Write the config file + file_put_contents($awsDir . '/config', $configContents); + + // Set up cache files + if (!empty($cacheContents)) { + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + foreach ($cacheContents as $filename => $content) { + file_put_contents($cacheDir . '/' . $filename, json_encode($content)); + } + } else { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage( + "Failed to load cached credentials for profile 'signin'. " + . "Please reauthenticate using `aws login`." + ); + } + + $provider = new LoginCredentialProvider('signin', 'us-west-2'); + if (!empty($mockApiCalls)) { + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $mockResults = []; + foreach ($mockApiCalls as $call) { + if (isset($call['responseCode']) && $call['responseCode'] >= 400) { + // Create an error response + $mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $exception = new SigninException( + 'API Error', + $mockCommand, + ['code' => 'Error', 'statusCode' => $call['responseCode']] + ); + $mockResults[] = $exception; + } elseif (isset($call['response'])) { + $mockResults[] = new Result($call['response']); + } + } + + if (!empty($mockResults)) { + $this->addMockResults($mockClient, $mockResults); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + } + } + + foreach ($outcomes as $outcome) { + switch ($outcome['result']) { + case 'error': + $this->expectException(CredentialsException::class); + @$provider()->wait(); + + break; + + case 'credentials': + $credentials = $provider()->wait(); + + $this->assertEquals($outcome['accessKeyId'], $credentials->getAccessKeyId()); + $this->assertEquals($outcome['secretAccessKey'], $credentials->getSecretKey()); + $this->assertEquals($outcome['sessionToken'], $credentials->getSecurityToken()); + $this->assertEquals($outcome['accountId'], $credentials->getAccountId()); + $this->assertEquals(CredentialSources::PROFILE_LOGIN, $credentials->getSource()); + + break; + + case 'cacheContents': + foreach ($outcome as $filename => $expectedContent) { + if ($filename === 'result') { + continue; + } + + $actualPath = $awsDir . '/login/cache/' . $filename; + if (file_exists($actualPath)) { + $actualContent = json_decode(file_get_contents($actualPath), true); + if (isset($expectedContent['accessToken'])) { + $this->assertArrayHasKey('accessToken', $actualContent); + + $expectedToken = $expectedContent['accessToken']; + $actualToken = $actualContent['accessToken']; + + $this->assertEquals( + $expectedToken['accessKeyId'], + $actualToken['accessKeyId'] + ); + $this->assertEquals( + $expectedToken['secretAccessKey'], + $actualToken['secretAccessKey'] + ); + $this->assertEquals( + $expectedToken['sessionToken'], + $actualToken['sessionToken'] + ); + } + + if (isset($expectedContent['refreshToken'])) { + $this->assertEquals( + $expectedContent['refreshToken'], + $actualContent['refreshToken'] + ); + } + } + } + + break; + } + } + } + + /** + * Provider for test cases from JSON file + * + * @return \Generator + * @throws \JsonException + */ + public function loginTestCasesProvider(): \Generator + { + $testCasesFile = __DIR__ . '/fixtures/login/test-cases.json'; + + if (!file_exists($testCasesFile)) { + throw new \RuntimeException("Test cases file not found: {$testCasesFile}"); + } + + $testCases = json_decode( + file_get_contents($testCasesFile), + true, + 512, + JSON_THROW_ON_ERROR + ); + + foreach ($testCases as $index => $testCase) { + yield $testCase['documentation'] => [ + $testCase['documentation'], + $testCase['configContents'], + $testCase['cacheContents'] ?? [], + $testCase['mockApiCalls'] ?? [], + $testCase['outcomes'] + ]; + } + } + + /** + * @dataProvider externalRefreshProvider + */ + public function testExternalRefreshBehavior( + string $scenario, + int $currentExpiryMinutes, + int $diskExpiryMinutes, + bool $sameRefreshToken, + bool $shouldUseExternal + ): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create initial token that will expire and trigger refresh + $currentExpiration = gmdate('Y-m-d\TH:i:s\Z', time() + ($currentExpiryMinutes * 60)); + $initialTokenData = [ + 'accessToken' => [ + 'accessKeyId' => 'currentKey', + 'secretAccessKey' => 'currentSecret', + 'sessionToken' => 'currentToken', + 'accountId' => '123456789012', + 'expiresAt' => $currentExpiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'currentRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----" + ]; + + file_put_contents($tokenFile, json_encode($initialTokenData)); + + // Create provider and let it load the initial token + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $reflection = new \ReflectionClass($provider); + + // Set up mock client + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + // First call will trigger refresh due to 2-minute expiry + // Return credentials that also expire soon so second call triggers refresh + $firstRefreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'firstRefresh', + 'accessToken' => [ + 'accessKeyId' => 'firstKey', + 'secretAccessKey' => 'firstSecret', + 'sessionToken' => 'firstToken', + ], + 'expiresIn' => 120 // 2 minutes - will trigger refresh on second call + ] + ]); + + if (!$shouldUseExternal) { + // Second call should also use API for refresh + $secondRefreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'apiRefresh', + 'accessToken' => [ + 'accessKeyId' => 'apiKey', + 'secretAccessKey' => 'apiSecret', + 'sessionToken' => 'apiToken', + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$firstRefreshResult, $secondRefreshResult]); + } else { + // Only the first call needs a mock result + $this->addMockResults($mockClient, [$firstRefreshResult]); + } + + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + // Force load the initial token + $provider()->wait(); + + // Now modify the cache file to simulate external refresh + $diskExpiration = gmdate('Y-m-d\TH:i:s\Z', time() + ($diskExpiryMinutes * 60)); + $externalTokenData = array_merge($initialTokenData, [ + 'accessToken' => [ + 'accessKeyId' => 'externalKey', + 'secretAccessKey' => 'externalSecret', + 'sessionToken' => 'externalToken', + 'accountId' => '123456789012', + 'expiresAt' => $diskExpiration + ], + 'refreshToken' => $sameRefreshToken ? 'firstRefresh' : 'externalRefresh' + ]); + + file_put_contents($tokenFile, json_encode($externalTokenData)); + + // Now invoke again - this should trigger refresh logic + $credentials = $provider()->wait(); + + if ($shouldUseExternal) { + // Should have used the external token + $this->assertEquals('externalKey', $credentials->getAccessKeyId()); + $this->assertEquals('externalSecret', $credentials->getSecretKey()); + $this->assertEquals('externalToken', $credentials->getSecurityToken()); + } else { + // Should have used API refresh + $this->assertEquals('apiKey', $credentials->getAccessKeyId()); + $this->assertEquals('apiSecret', $credentials->getSecretKey()); + $this->assertEquals('apiToken', $credentials->getSecurityToken()); + } + } + + public function externalRefreshProvider(): array + { + return [ + 'external refresh detected - all conditions met' => [ + 'scenario' => 'valid external refresh', + 'currentExpiryMinutes' => 2, // Triggers refresh + 'diskExpiryMinutes' => 10, // Fresh token + 'sameRefreshToken' => false, + 'shouldUseExternal' => true + ], + 'same refresh token - no external refresh' => [ + 'scenario' => 'same refresh token', + 'currentExpiryMinutes' => 2, + 'diskExpiryMinutes' => 10, + 'sameRefreshToken' => true, + 'shouldUseExternal' => false + ], + 'older expiration - no external refresh' => [ + 'scenario' => 'older expiration', + 'currentExpiryMinutes' => 2, + 'diskExpiryMinutes' => 1, // Older than current + 'sameRefreshToken' => false, + 'shouldUseExternal' => false + ], + 'external token needs refresh - no external refresh' => [ + 'scenario' => 'external token needs refresh', + 'currentExpiryMinutes' => 2, + 'diskExpiryMinutes' => 2, // Also within 3-minute threshold + 'sameRefreshToken' => false, + 'shouldUseExternal' => false + ] + ]; + } + + public function testRefreshContinuesWhenDiskReadFails(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create expiring token + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 120); // 2 minutes + $tokenData = [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----" + ]; + + file_put_contents($tokenFile, json_encode($tokenData)); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + // Load token first (will trigger refresh due to 2-minute expiry) + $provider()->wait(); + + // Make file unreadable for the second refresh attempt + chmod($tokenFile, 0000); + + // Now call again - should use API refresh since disk read fails + $credentials = @$provider()->wait(); + + // Restore permissions before assertions + chmod($tokenFile, 0644); + + // Verify API was called (not external token) + $this->assertEquals('refreshedKey', $credentials->getAccessKeyId()); + $this->assertEquals('refreshedSecret', $credentials->getSecretKey()); + $this->assertEquals('refreshedToken', $credentials->getSecurityToken()); + } + + public function testRefreshSucceedsWhenCacheWriteFails(): void + { + $awsDir = $this->createAwsHome(); + $loginSession = 'arn:aws:iam::123456789012:user/TestUser'; + $this->createConfigFile($awsDir, 'default', $loginSession); + + $cacheDir = $awsDir . '/login/cache'; + mkdir($cacheDir, 0777, true); + + $sessionHash = hash('sha256', trim($loginSession)); + $tokenFile = $cacheDir . '/' . $sessionHash . '.json'; + + // Create expired credentials to force refresh + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() - 3600); + $tokenData = [ + 'accessToken' => [ + 'accessKeyId' => 'expiredKey', + 'secretAccessKey' => 'expiredSecret', + 'sessionToken' => 'expiredToken', + 'accountId' => '123456789012', + 'expiresAt' => $expiration + ], + 'tokenType' => 'aws_sigv4', + 'refreshToken' => 'testRefresh', + 'idToken' => 'testId', + 'clientId' => 'arn:aws:signin:::devtools/same-device', + 'dpopKey' => "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEID9l+ckeHBxlF47cg0h5qJnAErPvCm1brUY8i7b6qSJToAoGCCqGSM49\n" . + "AwEHoUQDQgAETcWLAT2yUAT3s0ePMBGu+gcmdDvepL86SZDBSmtFCuDxRpXxt5C4\n" . + "rGaUy8ujiVIkEvm6a1x/U1As+fGq4eqtVw==\n" . + "-----END EC PRIVATE KEY-----" + ]; + + file_put_contents($tokenFile, json_encode($tokenData)); + + // Set up mock client with successful refresh + $mockClient = $this->getTestClient( + 'Signin', + ['credentials' => false, 'signature_version' => 'dpop'] + ); + + $refreshResult = new Result([ + 'tokenOutput' => [ + 'refreshToken' => 'newRefresh', + 'accessToken' => [ + 'accessKeyId' => 'refreshedKey', + 'secretAccessKey' => 'refreshedSecret', + 'sessionToken' => 'refreshedToken', + ], + 'expiresIn' => 3600 + ] + ]); + + $this->addMockResults($mockClient, [$refreshResult]); + + $provider = new LoginCredentialProvider('default', 'us-west-2'); + + $reflection = new \ReflectionClass($provider); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($provider, $mockClient); + + // Make cache file read-only to prevent writes + chmod($tokenFile, 0444); + + // This should succeed despite write failure + $credentials = @$provider()->wait(); + + // refreshed credentials + $this->assertEquals('refreshedKey', $credentials->getAccessKeyId()); + $this->assertEquals('refreshedSecret', $credentials->getSecretKey()); + $this->assertEquals('refreshedToken', $credentials->getSecurityToken()); + $this->assertEquals('123456789012', $credentials->getAccountId()); + + // cache file was not updated + $cacheContent = json_decode(file_get_contents($tokenFile), true); + $this->assertEquals('expiredKey', $cacheContent['accessToken']['accessKeyId']); + $this->assertEquals('testRefresh', $cacheContent['refreshToken']); // Old refresh token + } +} diff --git a/tests/Credentials/fixtures/login/test-cases.json b/tests/Credentials/fixtures/login/test-cases.json new file mode 100644 index 0000000000..4a3e9cc3d4 --- /dev/null +++ b/tests/Credentials/fixtures/login/test-cases.json @@ -0,0 +1,231 @@ +[ + { + "documentation": "Success - Valid credentials are returned immediately", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "3025-09-14T04:05:45Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "credentials", + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "3025-09-14T04:05:45Z" + } + ] + }, + { + "documentation": "Failure - No cache file", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "documentation": "Failure - Missing accessToken", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token_456", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "documentation": "Failure - Missing refreshToken", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "documentation": "Failure - Missing clientId in cache", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "refreshToken": "valid_refresh_token_789", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "documentation": "Failure - Missing dpopKey", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token_101112", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "documentation": "Success - Expired token triggers successful refresh", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "OLDEXPIREDKEY", + "secretAccessKey": "oldExpiredSecretKey", + "sessionToken": "oldExpiredSessionToken", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "mockApiCalls": [ + { + "request": { + "tokenInput": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token", + "grantType": "refresh_token" + } + }, + "response": { + "tokenOutput": { + "accessToken": { + "accessKeyId": "NEWREFRESHEDKEY", + "secretAccessKey": "newRefreshedSecretKey", + "sessionToken": "newRefreshedSessionToken" + }, + "refreshToken": "new_refresh_token", + "expiresIn": 900 + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accessKeyId": "NEWREFRESHEDKEY", + "secretAccessKey": "newRefreshedSecretKey", + "sessionToken": "newRefreshedSessionToken", + "accountId": "012345678901", + "expiresAt": "2025-11-19T00:15:00Z" + }, + { + "result": "cacheContents", + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "NEWREFRESHEDKEY", + "secretAccessKey": "newRefreshedSecretKey", + "sessionToken": "newRefreshedSessionToken", + "accountId": "012345678901", + "expiresAt": "2025-11-19T00:15:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "new_refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + } + ] + }, + { + "documentation": "Failure - Expired token triggers failed refresh", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "OLDEXPIREDKEY", + "secretAccessKey": "oldExpiredSecretKey", + "sessionToken": "oldExpiredSessionToken", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "expired_refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "mockApiCalls": [ + { + "request": { + "tokenInput": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "expired_refresh_token", + "grantType": "refresh_token" + } + }, + "responseCode": 400 + } + ], + "outcomes": [ + { + "result": "error" + } + ] + } +] diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index 0fbbf149cd..ebedef02d4 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -14,6 +14,7 @@ use Aws\MockHandler; use Aws\Result; use Aws\ResultInterface; +use Aws\Signature\DpopSignature; use Aws\Signature\SignatureV4; use GuzzleHttp\Psr7; use GuzzleHttp\Psr7\Request; @@ -108,9 +109,27 @@ public function testAddsSigner() $this->assertTrue($req->hasHeader('Authorization')); } - public function TestOverridesAuthScheme() + public function testSignerRejectsDpopWithoutValidKey() { - + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'A valid DPoP key must be present for DPoP signatures' + ); + $list = new HandlerList(); + $mock = function ($command, $request) use (&$req) { + $req = $request; + return Promise\Create::promiseFor( + new Result(['@metadata' => ['statusCode' => 200]]) + ); + }; + $list->setHandler($mock); + // Equivalent to `'credentials' => false` + $creds = CredentialProvider::fromCredentials(new Credentials('', '')); + $signature = new DpopSignature('signin'); + $list->appendSign(Middleware::signer($creds, Aws\constantly($signature))); + $handler = $list->resolve(); + $handler(new Command('foo'), new Request('GET', 'http://exmaple.com')); + Promise\Utils::queue()->run(); } public function testBuildsRequests() diff --git a/tests/Script/ComposerTest.php b/tests/Script/ComposerTest.php index 78cd045341..9dd8cf436e 100644 --- a/tests/Script/ComposerTest.php +++ b/tests/Script/ComposerTest.php @@ -82,7 +82,7 @@ public function testRemoveServices($servicesToKeep) } $filesystem->mkdir( $clientPath . 'Api'); - $unsafeForDeletion = ['Kms', 'S3', 'SSO', 'SSOOIDC', 'Sts']; + $unsafeForDeletion = ['Kms', 'S3', 'SSO', 'SSOOIDC', 'Sts', 'Signin']; if (in_array('DynamoDbStreams', $servicesToKeep)) { $unsafeForDeletion[] = 'DynamoDb'; } diff --git a/tests/Signature/DpopSignatureTest.php b/tests/Signature/DpopSignatureTest.php new file mode 100644 index 0000000000..f2f3c7f201 --- /dev/null +++ b/tests/Signature/DpopSignatureTest.php @@ -0,0 +1,390 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + "The 'invalidservice' service does not support DPop signatures" + ); + + new DpopSignature('invalidservice'); + } + + public function testConstructorSucceedsForSigninService(): void + { + $dpop = new DpopSignature('signin'); + $this->assertInstanceOf(DpopSignature::class, $dpop); + } + + public function testSignRequestAddsDpopHeader(): void + { + $dpop = new DpopSignature('signin'); + $key = $this->getValidEcKey(); + + $request = new Request('POST', 'https://example.com/api'); + $signedRequest = $dpop->signRequest($request, $key); + + $this->assertTrue($signedRequest->hasHeader('DPop')); + $dpopHeader = $signedRequest->getHeader('DPop')[0]; + $this->assertNotEmpty($dpopHeader); + + // Verify JWT structure (3 parts separated by dots) + $parts = explode('.', $dpopHeader); + $this->assertCount(3, $parts); + } + + public function testDpopJwtHeaderStructure(): void + { + $dpop = new DpopSignature('signin'); + $key = $this->getValidEcKey(); + + $request = new Request('POST', 'https://example.com/api'); + $signedRequest = $dpop->signRequest($request, $key); + + $dpopHeader = $signedRequest->getHeader('DPop')[0]; + $parts = explode('.', $dpopHeader); + + // Decode header + $header = json_decode( + base64_decode(strtr($parts[0], '-_', '+/')), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $this->assertEquals('dpop+jwt', $header['typ']); + $this->assertEquals('ES256', $header['alg']); + $this->assertArrayHasKey('jwk', $header); + $this->assertEquals('EC', $header['jwk']['kty']); + $this->assertEquals('P-256', $header['jwk']['crv']); + $this->assertArrayHasKey('x', $header['jwk']); + $this->assertArrayHasKey('y', $header['jwk']); + } + + public function testDpopJwtPayloadStructure(): void + { + $dpop = new DpopSignature('signin'); + $key = $this->getValidEcKey(); + + $uri = 'https://example.com/api/endpoint'; + $request = new Request('POST', $uri); + $signedRequest = $dpop->signRequest($request, $key); + + $dpopHeader = $signedRequest->getHeader('DPop')[0]; + $parts = explode('.', $dpopHeader); + + // Decode payload + $payload = json_decode( + base64_decode(strtr($parts[1], '-_', '+/')), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $this->assertArrayHasKey('jti', $payload); + $this->assertEquals('POST', $payload['htm']); + $this->assertEquals($uri, $payload['htu']); + $this->assertArrayHasKey('iat', $payload); + + // Verify jti is a valid UUID v4 + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $payload['jti'] + ); + + // Verify iat is recent (within last minute) + $this->assertGreaterThan(time() - 60, $payload['iat']); + $this->assertLessThanOrEqual(time(), $payload['iat']); + } + + public function testDerToRawConvertsValidSignature(): void + { + $dpop = new DpopSignature('signin'); + $key = $this->getValidEcKey(); + + // Generate a real signature to test derToRaw + $message = 'test message'; + $signature = ''; + openssl_sign($message, $signature, $key, OPENSSL_ALGO_SHA256); + + // Use reflection to access private method + $reflection = new \ReflectionClass($dpop); + $method = $reflection->getMethod('derToRaw'); + + $raw = $method->invoke($dpop, $signature); + + // Raw signature for ES256 should be exactly 64 bytes + $this->assertEquals(64, strlen($raw)); + } + + /** + * Test that derToRaw properly handles DER signatures with various R and S lengths + */ + public function testDerToRawHandlesVariableLengthComponents(): void + { + $dpop = new DpopSignature('signin'); + + // Use reflection to access private method + $reflection = new \ReflectionClass($dpop); + $method = $reflection->getMethod('derToRaw'); + + // Test case 1: R and S both 32 bytes (no leading zeros) + $der1 = hex2bin( + '3044' . '0220' + . str_repeat('ab', 32) . '0220' + . str_repeat('cd', 32) + ); + $raw1 = $method->invoke($dpop, $der1); + $this->assertEquals(64, strlen($raw1)); + + // Test case 2: R with leading zero (33 bytes in DER) + $der2 = hex2bin( + '3045' . '0221' . '00' + . str_repeat('ff', 32) . '0220' + . str_repeat('aa', 32) + ); + $raw2 = $method->invoke($dpop, $der2); + $this->assertEquals(64, strlen($raw2)); + + // Test case 3: S with leading zero (33 bytes in DER) + $der3 = hex2bin( + '3045' . '0220' . str_repeat('bb', 32) + . '0221' . '00' + . str_repeat('ee', 32) + ); + $raw3 = $method->invoke($dpop, $der3); + $this->assertEquals(64, strlen($raw3)); + + // Test case 4: Both with leading zeros (33 bytes each in DER) + $der4 = hex2bin( + '3046' . '0221' . '00' + . str_repeat('dd', 32) . '0221' + . '00' . str_repeat('cc', 32) + ); + $raw4 = $method->invoke($dpop, $der4); + $this->assertEquals(64, strlen($raw4)); + } + + /** + * Test that derToRaw throws for invalid DER signatures + */ + public function testDerToRawThrowsForInvalidSignatures(): void + { + $dpop = new DpopSignature('signin'); + + // Use reflection to access private method + $reflection = new \ReflectionClass($dpop); + $method = $reflection->getMethod('derToRaw'); + + // Test case 1: Missing SEQUENCE tag + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DER signature format: missing SEQUENCE tag'); + $method->invoke( + $dpop, + hex2bin('31440220' . str_repeat('ab', 32) . '0220' . str_repeat('cd', 32)) + ); + } + + public function testDerToRawThrowsForMissingRIntegerTag(): void + { + $dpop = new DpopSignature('signin'); + + // Use reflection to access private method + $reflection = new \ReflectionClass($dpop); + $method = $reflection->getMethod('derToRaw'); + + // Missing R INTEGER tag + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DER signature format: missing R INTEGER tag'); + $method->invoke( + $dpop, + hex2bin('30440320' . str_repeat('ab', 32) . '0220' . str_repeat('cd', 32)) + ); + } + + public function testDerToRawThrowsForMissingSIntegerTag(): void + { + $dpop = new DpopSignature('signin'); + + // Use reflection to access private method + $reflection = new \ReflectionClass($dpop); + $method = $reflection->getMethod('derToRaw'); + + // Missing S INTEGER tag + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DER signature format: missing S INTEGER tag'); + $method->invoke( + $dpop, + hex2bin('30440220' . str_repeat('ab', 32) . '0320' . str_repeat('cd', 32)) + ); + } + + public function testDerToRawThrowsForLengthMismatch(): void + { + $dpop = new DpopSignature('signin'); + + // Use reflection to access private method + $reflection = new \ReflectionClass($dpop); + $method = $reflection->getMethod('derToRaw'); + + // R length says 32 bytes but only provide 31 (the rest is cut off so no S INTEGER tag found) + $this->expectException(\Exception::class); + // The parser will find the mismatch when it tries to extract R value + $this->expectExceptionMessage('Invalid DER signature'); + $method->invoke( + $dpop, + hex2bin('30430220' . str_repeat('ab', 31) . '0220' . str_repeat('cd', 32)) + ); + } + + /** + * Test that the signature can be verified with openssl + */ + public function testSignatureCanBeVerified(): void + { + $dpop = new DpopSignature('signin'); + $key = $this->getValidEcKey(); + + $request = new Request('POST', 'https://example.com/api'); + $signedRequest = $dpop->signRequest($request, $key); + + $dpopHeader = $signedRequest->getHeader('DPop')[0]; + $parts = explode('.', $dpopHeader); + + // Recreate the message + $message = $parts[0] . '.' . $parts[1]; + + // Decode the signature + $signature = base64_decode(strtr($parts[2], '-_', '+/')); + + // Convert raw signature back to DER for verification + $r = substr($signature, 0, 32); + $s = substr($signature, 32, 32); + + // Remove leading zeros from r and s for DER + $r = ltrim($r, "\x00"); + $s = ltrim($s, "\x00"); + + // Add leading zero if high bit is set (for DER encoding) + if (ord($r[0]) & 0x80) { + $r = "\x00" . $r; + } + if (ord($s[0]) & 0x80) { + $s = "\x00" . $s; + } + + // Build DER + $rDer = "\x02" . chr(strlen($r)) . $r; + $sDer = "\x02" . chr(strlen($s)) . $s; + $der = "\x30" . chr(strlen($rDer . $sDer)) . $rDer . $sDer; + + // Get public key from private key + $keyDetails = openssl_pkey_get_details($key); + $publicKey = openssl_pkey_get_public($keyDetails['key']); + + // Verify signature + $verified = openssl_verify( + $message, $der, $publicKey, OPENSSL_ALGO_SHA256 + ); + $this->assertEquals(1, $verified); + } + + /** + * Test that multiple signatures from the same key produce different JWTs (due to jti) + */ + public function testMultipleSignaturesProduceDifferentJwts(): void + { + $dpop = new DpopSignature('signin'); + $key = $this->getValidEcKey(); + + $request = new Request('POST', 'https://example.com/api'); + + $signedRequest1 = $dpop->signRequest($request, $key); + $signedRequest2 = $dpop->signRequest($request, $key); + + $dpopHeader1 = $signedRequest1->getHeader('DPop')[0]; + $dpopHeader2 = $signedRequest2->getHeader('DPop')[0]; + + // Headers should be different due to different jti values + $this->assertNotEquals($dpopHeader1, $dpopHeader2); + + // But header portions (containing JWK) should be the same + $parts1 = explode('.', $dpopHeader1); + $parts2 = explode('.', $dpopHeader2); + $this->assertEquals($parts1[0], $parts2[0]); + } + + /** + * Test that a key with specifiedCurve parameters works correctly + */ + public function testSignRequestWithSpecifiedCurveKey(): void + { + $dpop = new DpopSignature('signin'); + + // Key with specifiedCurve parameters + $pem = "-----BEGIN EC PRIVATE KEY-----\n" . + "MIIBUQIBAQQgW2JEXxOdj8dFip0hyS6SHr9dHciiZQyoTZoe6sox1KGggeMwgeAC\n" . + "AQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAAAAAAAAAAAAAA////////////////\n" . + "MEQEIP////8AAAABAAAAAAAAAAAAAAAA///////////////8BCBaxjXYqjqT57Pr\n" . + "vVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwRBBGsX0fLhLEJH+Lzm5WOkQPJ3A32BLesz\n" . + "oPShOUXYmMKWT+NC4v4af5uO5+tKfA+eFivOM1drMV7Oy7ZAaDe/UfUCIQD/////\n" . + "AAAAAP//////////vOb6racXnoTzucrC/GMlUQIBAaFEA0IABFqlTnAPfLQfFrmn\n" . + "uJFbpcMA89r5uhBzUe+KvRCCPpscjMats1NUCB64qslJ3QYEGuAx2BP2gOeQBUbl\n" . + "rSdm4F4=\n" . + "-----END EC PRIVATE KEY-----"; + + $key = openssl_pkey_get_private($pem); + $this->assertNotFalse($key, 'Failed to load EC key with specifiedCurve'); + + $request = new Request('POST', 'https://example.com/api'); + $signedRequest = $dpop->signRequest($request, $key); + + $this->assertTrue($signedRequest->hasHeader('DPop')); + $dpopHeader = $signedRequest->getHeader('DPop')[0]; + $this->assertNotEmpty($dpopHeader); + + // Verify JWT structure (3 parts separated by dots) + $parts = explode('.', $dpopHeader); + $this->assertCount(3, $parts); + + // Verify the header contains the JWK + $header = json_decode( + base64_decode(strtr($parts[0], '-_', '+/')), + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertEquals('dpop+jwt', $header['typ']); + $this->assertEquals('ES256', $header['alg']); + $this->assertArrayHasKey('jwk', $header); + $this->assertEquals('EC', $header['jwk']['kty']); + $this->assertEquals('P-256', $header['jwk']['crv']); + $this->assertArrayHasKey('x', $header['jwk']); + $this->assertArrayHasKey('y', $header['jwk']); + } +} diff --git a/tests/Signature/SignatureProviderTest.php b/tests/Signature/SignatureProviderTest.php index cbd945299b..5dc9c498b6 100644 --- a/tests/Signature/SignatureProviderTest.php +++ b/tests/Signature/SignatureProviderTest.php @@ -2,6 +2,7 @@ namespace Aws\Test\Signature; use Aws\Signature\AnonymousSignature; +use Aws\Signature\DpopSignature; use Aws\Signature\S3ExpressSignature; use Aws\Signature\S3SignatureV4; use Aws\Signature\SignatureInterface; @@ -30,7 +31,8 @@ public function versionProvider() ['v4-s3express', S3ExpressSignature::class, 's3express'], ['v4-unsigned-body', SignatureV4::class, 'foo'], ['anonymous', AnonymousSignature::class, 's3'], - ['s3v4', S3SignatureV4::class, 's3-outposts'] + ['s3v4', S3SignatureV4::class, 's3-outposts'], + ['dpop', DpopSignature::class, 'signin'], ]; }