Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Endpoint security, partition support, temporary-credential expiry, and documented credential behavior need correction.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an opt-in Amazon SES v2 email sender using SigV4 and AWS-provided credentials.
Changes:
- Implements SES MIME delivery, signing, HTTP transport, configuration, and error handling.
- Adds an AWS credential provider chain for common workload identities.
- Adds documentation and extensive unit/integration coverage.
File summaries
| File | Description |
|---|---|
services/src/main/java/org/keycloak/email/aws/AwsHttpRequest.java |
Defines immutable AWS HTTP requests. |
services/src/main/java/org/keycloak/email/aws/AwsHttpResponse.java |
Models bounded AWS HTTP responses. |
services/src/main/java/org/keycloak/email/aws/AwsHttpTransport.java |
Defines the transport contract. |
services/src/main/java/org/keycloak/email/aws/AwsSesConfig.java |
Resolves SES configuration. |
services/src/main/java/org/keycloak/email/aws/AwsSesConfigurationException.java |
Represents configuration failures. |
services/src/main/java/org/keycloak/email/aws/AwsSesEmailSenderProvider.java |
Implements the email sender SPI. |
services/src/main/java/org/keycloak/email/aws/AwsSesEmailSenderProviderFactory.java |
Registers and configures the provider. |
services/src/main/java/org/keycloak/email/aws/AwsV4Signer.java |
Implements SigV4 signing. |
services/src/main/java/org/keycloak/email/aws/CrlfNormalizingOutputStream.java |
Normalizes MIME line endings. |
services/src/main/java/org/keycloak/email/aws/KeycloakHttpTransport.java |
Uses Keycloak’s shared HTTP client. |
services/src/main/java/org/keycloak/email/aws/SesClient.java |
Calls SES v2 SendEmail. |
services/src/main/java/org/keycloak/email/aws/SesErrorResponse.java |
Parses SES error responses. |
services/src/main/java/org/keycloak/email/aws/SesRawMessage.java |
Composes raw MIME messages. |
services/src/main/java/org/keycloak/email/aws/credentials/AwsCredentials.java |
Models AWS credentials. |
services/src/main/java/org/keycloak/email/aws/credentials/AwsCredentialsException.java |
Represents credential failures. |
services/src/main/java/org/keycloak/email/aws/credentials/AwsCredentialsProvider.java |
Defines credential sources. |
services/src/main/java/org/keycloak/email/aws/credentials/AwsCredentialsProviderChain.java |
Resolves and caches credentials. |
services/src/main/java/org/keycloak/email/aws/credentials/AwsEnvironment.java |
Abstracts process configuration. |
services/src/main/java/org/keycloak/email/aws/credentials/ContainerCredentialsProvider.java |
Supports ECS/EKS credentials. |
services/src/main/java/org/keycloak/email/aws/credentials/EnvironmentVariableCredentialsProvider.java |
Reads environment credentials. |
services/src/main/java/org/keycloak/email/aws/credentials/InstanceMetadataCredentialsProvider.java |
Supports EC2 IMDSv2 credentials. |
services/src/main/java/org/keycloak/email/aws/credentials/ProfileCredentialsProvider.java |
Reads static AWS profiles. |
services/src/main/java/org/keycloak/email/aws/credentials/SystemPropertiesCredentialsProvider.java |
Reads JVM credentials. |
services/src/main/java/org/keycloak/email/aws/credentials/WebIdentityTokenCredentialsProvider.java |
Supports EKS web identity. |
services/src/main/resources/META-INF/services/org.keycloak.email.EmailSenderProviderFactory |
Registers the SES factory. |
services/src/test/java/org/keycloak/email/aws/AwsSesConfigTest.java |
Tests SES configuration. |
services/src/test/java/org/keycloak/email/aws/AwsSesEmailSenderProviderFactoryTest.java |
Tests factory activation behavior. |
services/src/test/java/org/keycloak/email/aws/AwsSesEmailSenderProviderTest.java |
Tests provider wiring. |
services/src/test/java/org/keycloak/email/aws/AwsV4SignerTest.java |
Tests SigV4 vectors. |
services/src/test/java/org/keycloak/email/aws/CrlfNormalizingOutputStreamTest.java |
Tests line normalization. |
services/src/test/java/org/keycloak/email/aws/FakeTransport.java |
Provides an HTTP test fake. |
services/src/test/java/org/keycloak/email/aws/KeycloakHttpTransportTest.java |
Tests actual HTTP behavior. |
services/src/test/java/org/keycloak/email/aws/SesClientTest.java |
Tests SES requests and responses. |
services/src/test/java/org/keycloak/email/aws/SesErrorResponseTest.java |
Tests error parsing. |
services/src/test/java/org/keycloak/email/aws/SesRawMessageTest.java |
Tests MIME parity. |
services/src/test/java/org/keycloak/email/aws/TestEnvironment.java |
Provides isolated environment state. |
services/src/test/java/org/keycloak/email/aws/credentials/AwsCredentialsProviderChainTest.java |
Tests chain precedence and caching. |
services/src/test/java/org/keycloak/email/aws/credentials/ContainerCredentialsProviderTest.java |
Tests container credentials. |
services/src/test/java/org/keycloak/email/aws/credentials/InstanceMetadataCredentialsProviderTest.java |
Tests IMDSv2 behavior. |
services/src/test/java/org/keycloak/email/aws/credentials/ProfileCredentialsProviderTest.java |
Tests profile parsing. |
services/src/test/java/org/keycloak/email/aws/credentials/StaticCredentialsProvidersTest.java |
Tests static credential sources. |
services/src/test/java/org/keycloak/email/aws/credentials/WebIdentityTokenCredentialsProviderTest.java |
Tests STS web identity. |
tests/base/src/test/java/org/keycloak/tests/providers/AwsSesEmailSenderTest.java |
Adds server-level integration coverage. |
docs/documentation/server_admin/topics/realms/email.adoc |
Documents setup and credentials. |
docs/documentation/release_notes/topics/26_8_0.adoc |
Announces the SES provider. |
Review details
Suppressed comments (1)
services/src/main/java/org/keycloak/email/aws/AwsSesConfig.java:137
- This accepts any URI scheme with a host, such as
ftp://host, even though the transport only supports HTTP(S); boot succeeds and every send then fails with an unsupported protocol. Reject schemes other thanhttpandhttpsduring configuration parsing.
8000
URI parsed = new URI(base);
if (parsed.getScheme() == null || parsed.getHost() == null) {
throw new AwsSesConfigurationException("'" + endpoint + "' is not a usable Amazon SES endpoint URL:"
+ " it must be absolute, for example https://email.eu-central-1.amazonaws.com");
- Files reviewed: 45/45 changed files
- Comments generated: 11
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
6dfc0e8 to
c9cbac5
Compare
|
Thanks — I went through each comment and verified it against the code before acting. Nine were right and are fixed in the amended commit; two I could not reproduce and have left alone, with the evidence below. Fixed
Not changed — I could not reproduce these two Both comments assume So The suite is now 244 unit tests plus the integration test; all green, and the container run against 26.0.7 and 26.7.3 was repeated after these changes. |
There was a problem hiding this comment.
🟡 Changes recommended
Response parsing, temporary credential caching, endpoint handling, and documentation contain unresolved correctness issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
services/src/main/java/org/keycloak/email/aws/SesErrorResponse.java:59
- A whitespace-only error body is non-empty but
readTreereturnsnull; callingfirstTextthen masks the SES status and request ID with aNullPointerException. Skip field extraction when no JSON node was parsed.
JsonNode body = JsonSerialization.mapper.readTree(response.body());
if (errorCode == null) {
errorCode = sanitizeErrorType(firstText(body, "__type", "code", "Code"));
}
message = firstText(body, "message", "Message");
- Files reviewed: 46/46 changed files
- Comments generated: 7
- Review effort level: Balanced
| JsonNode document; | ||
| try { | ||
| document = JsonSerialization.mapper.readTree(response.body()); | ||
| } catch (IOException e) { | ||
| // The cause is dropped on purpose: Jackson quotes the offending source in its message, | ||
| // and that source is the credential document itself. | ||
| throw new AwsCredentialsException("EC2 instance metadata service returned an unreadable credential" | ||
| + " document for role " + role); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
c9cbac5 to
27a6eda
Compare
|
Second pass. Five of the seven are real and are fixed; the other two repeat a premise I cannot reproduce. Fixed
Not changed — same premise as last round, and I still cannot reproduce it Four comments (
I have left them as they are rather than adding four null checks for a value that cannot be null, but if you would rather not have the code depend on that Jackson behaviour, say so and I will add them. 249 unit tests plus the integration test, all green; the container run against 26.0.7 and 26.7.3 was repeated after these changes. |
There was a problem hiding this comment.
🟡 Changes recommended
Endpoint validation permits unsafe plaintext targets, and several empty HTTP responses cause unchecked null dereferences.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
services/src/main/java/org/keycloak/email/aws/credentials/ContainerCredentialsProvider.java:230
- A successful credential endpoint response with an empty body causes
readTreeto returnnull, followed by aNullPointerExceptionintext. Convert this malformed response into anAwsCredentialsExceptionso the configured source fails predictably.
body = JsonSerialization.mapper.readTree(response.body());
} catch (IOException e) {
throw new AwsCredentialsException("Container credential endpoint " + host
+ " returned a body that is not JSON", e);
services/src/main/java/org/keycloak/email/aws/credentials/InstanceMetadataCredentialsProvider.java:245
- An empty or whitespace-only 200 credential document is parsed as Java
null, after whichtext(document, ...)throwsNullPointerException. Reject the empty document withAwsCredentialsExceptioninstead of leaking an unchecked failure from the credential chain.
document = JsonSerialization.mapper.readTree(response.body());
} catch (IOException e) {
// The cause is dropped on purpose: Jackson quotes the offending source in its message,
// and that source is the credential document itself.
throw new AwsCredentialsException("EC2 instance metadata service returned an unreadable credential"
+ " document for role " + role);
- Files reviewed: 46/46 changed files
- Comments generated: 4
- Review effort level: Balanced
| private static final List<String> ALLOWED_LINK_LOCAL_HOSTS = | ||
| List.of("169.254.170.2", "169.254.170.23", "fd00:ec2::23"); | ||
|
|
||
| private static final Pattern LOOPBACK_IPV4 = Pattern.compile("127(?:\\.\\d{1,3}){3}"); |
27a6eda to
f537163
Compare
|
Third pass. One of the two new findings is real and fixed; the other turned out not to be reachable, and I have pinned that rather than leave it to chance. The four null-dereference comments are the same premise as the previous two rounds. Fixed — the IPv6 range was too wide
Investigated — the invalid-octet case, and what I found You were right that the two guards disagreed: the instance-metadata one validates octets, the container one matched The exploit does not hold, though, and I would rather say so than claim a fix I did not make: Still not changed — The four remaining comments assume
I am happy to add explicit null guards anyway if you would prefer the code not to rest on that behaviour; it is four lines and I have no strong feeling about it. What I did not want to do was add them while describing it as a bug fix. 254 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run after these changes. |
There was a problem hiding this comment.
🟡 Changes recommended
Credential proxying can expose bearer tokens, while credential-chain compatibility and error redaction also need correction.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 47/47 changed files
- Comments generated: 5
- Review effort level: Balanced
| httpRequest.setHeader(header.getKey(), header.getValue()); | ||
| } | ||
|
|
||
| try (CloseableHttpResponse response = httpClient.execute(httpRequest)) { |
f537163 to
58527f4
Compare
|
Fourth pass — all five acted on. The proxy one was the good catch of this round and it changed the design. Credential endpoints no longer share the server's HTTP client You are right, and the second half of the comment is the part that matters: under a wildcard proxy the address checks in front of the container and metadata calls are not just bypassed, they are meaningless, because the host being validated stops being the host being spoken to. Documenting a So the two sources that answer on this machine now go through a Still no new dependency — it is the JDK client, and the response-size bound is shared with the Apache
10BC8
one rather than written twice. The credential chain now matches the SDK on system properties Also correct, and it was the same omission three times: Parse failure no longer carries Jackson's cause Right — the instance-metadata provider already dropped it for exactly this reason and the container one did not. Jackson quotes the offending source in its message, and here that source is the credential document. 264 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run. |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated 3 comments.
58527f4 to
3d47090
Compare
|
Rebased on current The three comments from this round were all real and are fixed.
Both are genuine deviations from the signing spec, and both are the kind that only surface once someone signs a URI this provider does not build today.
AWS's published
Correct, and it contradicted the interface's own contract rather than merely being undocumented. It now keeps one client per connect-timeout value, created on demand. In practice the map holds a single entry — both callers ask for a second — but the contract holds, and it is cheaper than building a client, and its threads, on every credential lookup. Dead constants in Right, and worth more than tidiness: leaving a validation pattern behind after the logic moved to 267 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run after the rebase. |
|
One follow-up on the I have added the null guards. Not because I found the failure — I could not reproduce it, and the probe below still holds — but because the code should not rest on a library behaviour, and a reader landing on this thread should not have to relitigate it. So the four sites take their documented paths today, and the guards are unreachable with the parser the server ships. Each one says so in a comment rather than pretending to be a bug fix, and each lands in the same place the corresponding malformed-body path already goes — the SES message-id error, the unstructured-error branch, and the two "unreadable credential document" failures. I have not written tests for them: a test would have to make the parser return null, which this Jackson will not do, and a test that cannot fail is worse than none. The reachable behaviour — empty, whitespace-only and non-JSON bodies — is already covered. 267 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run. |
3d47090 to
9f03772
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated 5 comments.
Suppressed comments (1)
services/src/test/java/org/keycloak/email/aws/DirectHttpTransportTest.java:1
- This assertion relies on referential equality (
sameInstance) for the proxy selector, which can be brittle across JDK implementations (theOptionalmay not return the same singleton instance even if behavior is identical). Consider asserting behaviorally (e.g., proxy is present and configured for no-proxy semantics, or that requests are not routed via a proxy) rather than depending on identity equality.
| * header the caller set, must not follow redirects and must not retry a request whose bytes already | ||
| * reached the wire: the first two would break the SigV4 signature, the third would send a | ||
| * transactional email twice, because SES {@code SendEmail} has no idempotency token. |
| public EmailSenderProvider create(KeycloakSession session) { | ||
| if (config == null) { | ||
| throw configurationError; | ||
| } | ||
| HttpClientProvider httpClientProvider = session.getProvider(HttpClientProvider.class); | ||
| return new AwsSesEmailSenderProvider(config, client, | ||
| new KeycloakHttpTransport(httpClientProvider.getHttpClient()), clock); | ||
| } |
| String code = text(document, "Code"); | ||
| if (!"Success".equals(code)) { | ||
| throw new AwsCredentialsException("EC2 instance metadata service reported Code=" + code | ||
| + " for role " + role); | ||
| } | ||
| String accessKeyId = text(document, "AccessKeyId"); | ||
| String secretAccessKey = text(document, "SecretAccessKey"); |
There was a problem hiding this comment.
🟡 Changes recommended
Credential response reads can hang indefinitely, and detailed SES errors can leak recipient addresses into server logs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 50/50 changed files
- Comments generated: 2
- Review effort level: Balanced
| HttpResponse<java.io.InputStream> response = | ||
| clientFor(request).send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); | ||
| return new AwsHttpResponse(response.statusCode(), headersOf(response), | ||
| HttpBodies.readBounded(response.body())); |
| if (!response.isSuccessful()) { | ||
| SesErrorResponse error = SesErrorResponse.parse(response); | ||
| logger.warnf("Amazon SES refused an email: %s", error.describeForLog()); | ||
| throw new EmailException(error.describe()); |
9f03772 to
72c8bc0
Compare
|
Four of the five are real and fixed. One I checked and could not reproduce. Fixed
Not reproduced The AsciiDoc table is not malformed: it opens with 267 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run. One thing worth flagging for whoever picks this up: the last two Copilot passes both reported |
72c8bc0 to
4efed6d
Compare
|
Both real. The first one is more serious than the comment suggests, because the remedy it proposes does not work. The credential body read could hang — and switching body handlers does not fix it I wrote the suggested fix first, then tested it. All three waited for the peer; none returned at 500 ms, and the exception came only because the stub eventually closed. A peer that simply never closes would hold the caller forever. So the transport now sends asynchronously and waits on the future with an explicit deadline, cancelling the exchange when it expires — the calling thread is freed whatever the peer does, which is the property that matters when that thread is inside a Keycloak transaction. The byte cap stays. A regression test asserts the caller returns in well under the three seconds the stub stalls for; before the change it took the full three. AWS's rejection text reached the server log with the recipient in it Correct, and my earlier reasoning was wrong: I had kept the text in the exception and sanitised only the provider's own WARN, on the assumption that the message stayed in the administrator's response. It does not — Rather than dropping the text — "Email address is not verified" is exactly what makes the failure actionable — the address is now redacted out of it, so the message keeps the diagnosis and loses the identity. The error code and the AWS request id are untouched. Test asserts the address is absent and everything else survives. 269 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run. |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated 3 comments.
| * did not cover, for instance — AWS answers 403 with nothing to explain why. | ||
| */ | ||
| private static String hostHeader(URI uri) { | ||
| return uri.getPort() == -1 ? uri.getHost() : uri.getHost() + ":" + uri.getPort(); |
| DocumentBuilder builder = factory.newDocumentBuilder(); | ||
| builder.setErrorHandler(STRICT_ERROR_HANDLER); | ||
| return builder.parse(new ByteArrayInputStream(xml)); | ||
| } catch (ParserConfigurationException | SAXException | IOException e) { |
|
One of the two holds; the other does not. Fixed — the XML factory could throw out of the contract Correct. No test: making it fire needs a different JAXP on the classpath, and a test that cannot fail is worse than none. Not reproduced — the IPv6 Host header is already correct
Adding brackets around that would produce 270 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run. Since it keeps coming up in the summaries: the last three passes all reported |
4efed6d to
68ef177
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated 4 comments.
| ByteArrayOutputStream body = new ByteArrayOutputStream(); | ||
| CompletableFuture<HttpResponse<Void>> exchange = clientFor(request).sendAsync(builder.build(), | ||
| 10BC8 | responseInfo -> HttpResponse.BodySubscribers.ofByteArrayConsumer( | |
| chunk -> chunk.ifPresent(bytes -> append(body, bytes)))); |
| if (body.size() + chunk.length > HttpBodies.MAX_RESPONSE_BYTES) { | ||
| // Unchecked because the consumer cannot declare one; unwrapped by the caller. | ||
| throw new UncheckedIOException( | ||
| new IOException("Response body exceeded " + HttpBodies.MAX_RESPONSE_BYTES + " bytes")); | ||
| } | ||
| body.write(chunk, 0, chunk.length); |
| * produces a 403 on every request.</li> | ||
| * <li>Only the headers passed in are signed. Whatever the HTTP client adds afterwards (Host when | ||
| * unset, Content-Length, User-Agent, Accept-Encoding, Connection) must stay out of the signature, | ||
| * so the caller sets every signed header explicitly and this class refuses the hop-by-hop ones.</li> |
| List<AwsCredentialsProvider> providers = new ArrayList<>(); | ||
| providers.add(new SystemPropertiesCredentialsProvider(environment)); | ||
| providers.add(new EnvironmentVariableCredentialsProvider(environment)); | ||
| providers.add(new WebIdentityTokenCredentialsProvider(environment)); | ||
| providers.add(new ProfileCredentialsProvider(environment)); | ||
| providers.add(new ContainerCredentialsProvider(environment)); | ||
| providers.add(new InstanceMetadataCredentialsProvider(environment)); | ||
| return new AwsCredentialsProviderChain(Collections.unmodifiableList(providers)); |
Keycloak's only route to Amazon SES is its SMTP interface, which requires a username and password derived from a long-lived IAM user secret and stored in the realm. A server running on AWS cannot use the identity its platform already gives it, and the credential cannot be rotated by AWS. The new provider (SPI id aws-ses) signs SES v2 SendEmail requests with SigV4 and resolves credentials from the standard AWS chain. It adds no dependency: SigV4, the credential chain, JSON and MIME are implemented against HttpClientProvider, JsonSerialization and jakarta.mail. The message is composed exactly as DefaultEmailSenderProvider composes it, from the same realm settings, and sent as Content.Raw, so switching transport changes nothing a recipient can observe. It is inert unless selected with --spi-email-sender--provider=aws-ses, and order() stays 0 so it cannot win default-provider resolution by accident. The endpoint option makes the integration test hermetic: AwsSesEmailSenderTest points it at the injected HTTP server, sends a real execute-actions email through a real server, and asserts the signed request and the decoded MIME, with no AWS account and no network. SigV4 is verified against AWS's published test vectors. Closes keycloak#52360 Signed-off-by: lorexoff <rocca.lorenzo04@gmail.com>
68ef177 to
040aaad
Compare
|
Two of the four hold, both cosmetic; the thread-safety pair does not. Fixed
Not reproduced — the body accumulator is not racy The premise is that Every method used here is I would rather not add a lock that documents a race which cannot occur; if you would prefer the accumulation to be visibly single-threaded anyway, say so and I will make it explicit rather than synchronised. 270 unit tests plus the integration test, green; container matrix on 26.0.7 and 26.7.3 re-run. |
|
I see that there is a lot of feedback in the PR from Copilot that makes it difficult to discuss. I'll comment on the parent issue. |
|
I'm afraid we can't accept this PR; we simply can't support and maintain proprietary mechanisms for sending email. |
Closes #52360
Discussion: #52359
What
Adds an
EmailSenderProviderwith the idaws-sesthat delivers Keycloak's transactional email through the Amazon SES v2 API, signed with AWS SigV4, instead of through SES's SMTP interface. It does nothing unless selected with--spi-email-sender--provider=aws-ses.Why
SES SMTP requires a username and password derived from a long-lived IAM user's secret access key. It cannot be an assumed role, AWS does not rotate it, and it lives in
realm.smtpServer.password. A Keycloak on EKS, ECS or EC2 cannot use the identity its platform already gives it.email.adocdocuments the dead end today: "XOAUTH2 is not supported by the AWS-SMTP service. The AWS-service requires the use of a password."How
HttpClientProvider(Apache HttpClient),JsonSerialization(Jackson) andjakarta.mail. No AWS SDK, no new Maven module, no pom changes — one package underservices/src/main/java/org/keycloak/email/aws/and one appended line inMETA-INF/services/org.keycloak.email.EmailSenderProviderFactory, the same shape as Sending Mails via SMTP and XOAUTH2 authentication mechanism #36665.DefaultEmailSenderProviderbuilds it — samemultipart/alternative, same header order, same RFC 2047 subject encoding,Reply-Todefaulting toFrom, the same IDN conversionconvertEmailperforms — and sent asContent.Raw, so a realm that switches transport changes nothing a recipient can observe.FromEmailAddressand the MIMEFromheader are derived from a singleInternetAddress, so the parameter and the header cannot disagree.One difference from the SMTP path is deliberate:
MimeMessage.writeToemits bare LF inside 7bit parts, which the mail implementation's own CRLF stream hides on the SMTP socket and nothing hides on an API call, so line endings are normalised before the message is base64-encoded.from,fromDisplayName,replyTo,replyToDisplayName,timeout,connectionTimeout.envelopeFrommaps toFeedbackForwardingEmailAddress; that mapping is partial — SES fixes the SMTP envelope sender to the identity's MAIL FROM domain — and is documented and warned about rather than presented as lossless.kc.sh show-configprints unregistered SPI options unmasked. The container and instance-metadata lookups are constrained to the documented AWS endpoints, and IMDSv1 is not implemented.SendEmailhas no idempotency token, so a retried request that reached AWS would put a second activation email in a user's inbox. Note that the shared HTTP client installsDefaultHttpRequestRetryHandlerregardless, and turns it into a resend-after-send whenspi-connections-http-client-default-max-retriesis set; since that cannot be opted out of per request, the factory logs a warning when it sees it.order()stays 0, so the provider cannot win default-provider resolution by accident. A misconfiguration is fatal at boot only when this provider is the one the server was told to use — a Keycloak that cannot send an activation link should not start pretending it can — and is silent otherwise.Testing
services/src/test/java/org/keycloak/email/aws/: SigV4 against AWS's published signing test vectors, MIME parity with the SMTP sender, the request body, error mapping, and every credential source. JUnit 5 + Hamcrest, no mocking framework — the three collaborators that touch the outside world (HTTP, the process environment, the clock) are interfaces with hand-written fakes.AwsSesEmailSenderTestintests/base(org.keycloak.tests.providers, already registered in Base3, socheck-base-suites.shneeds no change) runs a real server with the sender selected, points the provider'sendpointoption at the@InjectHttpServerstub, triggers an admin execute-actions email, and asserts on the bytes that left the server: the SigV4 credential scope, and the base64-decoded MIME's structure,To,Fromand line endings. A second test answers400withx-amzn-ErrorType: MessageRejectedand asserts the failure reaches the administrator. No AWS account, no credentials, no network.quay.io/keycloak/keycloakcontainers for 26.0.7 and 26.7.3 to confirm the compatibility decision below, including a negative control (with the option removed the run fails, so the test cannot pass for the wrong reason).Compatibility
validate(Map)is declared without@Override: it does not exist on 26.0.x, 26.1.x or 26.3.0–26.3.2 and is abstract on newer releases, so this is what lets one implementation work across the range. Verified on both ends: on 26.7.3 a realm update with a malformedfromreturns 400 with this provider's own message, i.e. the server really does dispatch into it; on 26.0.7 the same update returns 204.Nothing existing changes behaviour: the default sender stays
default, and the only edit to an existing file is the appended service-registration line.Documentation
server_admin/topics/realms/email.adoc: a section inside the existingifeval::[{project_community}==true]guard, next to the XOAUTH2 vendor notes, carrying the same "provided as-is" disclaimer.release_notes/topics/26_8_0.adoc.Notes for the reviewer
While writing the integration test I hit what looks like a pre-existing bug in the test framework:
HttpServerUtil.sendResponsesets the response headers after callingexchange.sendResponseHeaders(...), so they are never sent. Its only current caller does not check them, which is why it has gone unnoticed. I have sent a separate one-line fix; this PR carries its own smallrespond()helper so the two are independent.Disclosure per CONTRIBUTING: AI agents were used in researching and writing this change.