Skip to content

feat(kotlin-oidc): landing pages for the JVM sign-in flow - #674

Open
mattcreaser wants to merge 13 commits into
mainfrom
mattcreaser/kotlin-oidc-landing-pages
Open

mattcreaser wants to merge 13 commits into
mainfrom
mattcreaser/kotlin-oidc-landing-pages

Conversation

@mattcreaser

Copy link
Copy Markdown
Contributor

Problem

After an OIDC sign-in on the JVM target, the loopback server served a single line of unstyled HTML on 127.0.0.1. A failed sign-in rendered the success page, because error in the callback was treated the same as code. Apps had no way to brand or localize what the user saw.

Issue #, if available:

Changes

signIn now accepts an OidcSignInOptions. Its platformOptions is an expect class with a per-target shape; on JVM it carries a successPage and an errorPage, each an OidcLandingPage:

  • BuiltIn (default) — new styled, self-contained pages: inline CSS and SVG, light and dark, no network fetches. The failure page shows the provider's error and error_description, HTML-escaped, and uses its own glyph and tab icon.
  • Redirect(url) — a 302 to the app's own page. Validated at construction: http/https only, and https unless the host is loopback.
  • Html(document) — the app's own document, served verbatim, for apps with nothing to host.

Also in the handler:

  • On success the callback query is stripped, so the authorization code and state are not included in the redirect. On failure only error and error_description are forwarded.
  • The response is written before the sign-in wait completes, so server teardown cannot close the connection first.
  • A request that is not the relay redirect gets a 404 with no body.

The Android and iOS actuals are empty, since their in-app browser dismisses itself.

Validation

  • New OidcLandingPageTest (URL validation) and OidcLoopbackPagesTest (document structure, escaping, tab icon matches the page glyph), plus 8 cases in JvmLoopbackSessionTest driving each arm against a live loopback server.
  • ./gradlew build, the runtime JVM / Android-host / iOS-simulator suites, plugin and codegen tests, the e2e build, and both example apps.
  • Manually on desktop: sign-in shows the built-in page by default, and a configured Redirect lands the browser on the external page.

Checklist

  • PR description included
  • Tests are changed or added
  • Relevant documentation is changed or added (and PR referenced)

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@mattcreaser
mattcreaser requested a review from a team as a code owner October 1, 2026 19:42
@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 52157a9

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Hand-rolled string parsing split the scheme and host by hand, including a helper to
step over userinfo and IPv6 brackets. URI does both by the spec, and rejects malformed
input that the manual split accepted.
Replaces a hand-written escaper with io.ktor.util.escapeHTML, which covers the same
five characters. Ktor is already a dependency of this module.
Replaces hand-rolled query splitting, percent-encoding, and query-string appending with
parseQueryString and URLBuilder. URLBuilder also places an appended query before an
existing fragment, which the manual version got wrong; a test now covers that.
The loopback handler inferred success from a `code` in the callback query, so a
redirect that later failed state or CSRF validation, or whose exchange was
rejected, still showed the success page while signIn threw.

OidcRedirectSession gains reportOutcome, which signIn calls once it knows. The
JVM session hands the callback over first and then waits for that outcome before
answering the browser, so nothing that goes wrong while responding can strand a
callback that already arrived, and the handler no longer has to guess the outcome
from the query keys. Android and iOS do not wait on anything, so they no-op.
- Reject an out-of-range port, which URI parses without range-checking and which
  cannot be followed or appended to later.
- Treat every spelling of a loopback address as loopback, so 127.0.0.2 and an
  expanded ::1 are accepted over http, rather than only two literals.
- Say the host is unusable, and point at punycode, instead of claiming a URL with
  an underscore or a non-ASCII host has no host at all.
- Drop xmlns from the inline SVG, which the HTML parser does not need, and assert
  the pages fetch nothing by looking for src=, url(, @import and href="http
  rather than for particular hostnames.
- Document signIn, and correct the README: a clean URL is only guaranteed for
  Redirect. BuiltIn and Html answer the callback itself, so its query stays in the
  address bar and supplied markup can read the code.
…ncher

FakeLauncher records what it receives, so a test can tell a forwarded argument
from a dropped one: because the interface defaults options, omitting it at the
call site would compile and silently discard every app's configured pages.

Also covers the outcome signIn reports, including the cases the handler used to
get wrong on its own — a callback carrying a code that fails state validation,
and one carrying both code and error.
@mattcreaser mattcreaser added the kotlin Issues or Pull Requests related to Kotlin code label Oct 5, 2026
…dc-landing-pages

# Conflicts:
#	native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/oidc/OidcClient.kt
#	native/kotlin/runtime/src/commonTest/kotlin/com/aws/blocks/kotlin/oidc/OidcClientTest.kt
Comment thread native/kotlin/README.md
## Signing In with OIDC

```kotlin
val auth = AuthApi()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't compile after the merge with main. Since #704, generated API classes take a BlocksClient and are reached through Blocks, so AuthApi() has no no-arg constructor. The quick start above uses blocks.authApi, and OidcE2ETest uses e2eBlocks().oidcAuthApi. This should probably be:

val blocks = Blocks()
val client = blocks.authApi.getClient()
val user = client.signIn("google")

return if (idpError != null) {
OidcSignInOutcome.Failed(idpError, resultParams["error_description"])
} else {
val code = if (failure is OidcExchangeException) "exchange_failed" else "invalid_callback"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This maps everything except OidcExchangeException to invalid_callback, but the try at L104 also covers step 6. Several failures reach the browser as error=invalid_callback even though the callback was fine:

  • a network failure on /auth/exchange, such as a ConnectException or OkHttp's read timeout
  • a parse failure on the response body
  • the BlocksException from ensureOpen()

catch (failure: Throwable) also catches CancellationException. If the caller's scope is cancelled mid-exchange, for example when a Compose screen leaves composition, the page says invalid_callback with "StandaloneCoroutine was cancelled". In each case the description is the raw exception message, which is shown on the built-in page and appended to a Redirect URL, so it ends up in the app's access logs.

An app whose Redirect error page branches on error would tell the user their sign-in link was bad when the backend was unreachable. Classifying by step would fix it:

  • invalid_callback only for steps 4–5
  • exchange_failed for anything thrown from step 6
  • its own code (e.g. cancelled) for CancellationException

Using failure.message only for OidcExceptions, with a fixed description otherwise, would keep third-party exception text out of the page. No test drives exchange_failed through signIn. signIn closes the session when the exchange fails could also assert reportedOutcome.

this.outcome.complete(outcome)
// The handler is still holding the browser's connection open; stop(0) in close()
// would drop it mid-response.
responded.await(RESPOND_TIMEOUT_SECONDS, TimeUnit.SECONDS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reportOutcome isn't suspend, so this wait (up to 10 s) runs on whatever thread signIn is on. On Compose Desktop that's usually Dispatchers.Main, the Swing event thread. The wait is normally a few milliseconds, but it can last the full 10 s if the write stalls, and coroutine cancellation can't interrupt it. The tests all wrap the call in withContext(Dispatchers.IO), which signIn doesn't do. The interface is @InternalBlocksApi, so it costs little to make reportOutcome suspend and replace the latch with a CompletableDeferred<Unit> awaited under withTimeoutOrNull. The Android and iOS no-ops stay as they are.


// Long enough for a token exchange, short enough that a caller that stopped running
// does not hold the browser indefinitely.
const val OUTCOME_TIMEOUT_SECONDS = 30L

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there's no test for the fallback when no outcome arrives, and there can't be one without waiting 30 s. Making this a constructor parameter like timeout would let a test cover "no outcome → failure page". The fallback can also mislead the other way: if the exchange outlasts it, the browser shows a bare failure page while signIn goes on to succeed. That's worth a line in outcomeForDisplay's KDoc.

This branch had an error being deployed

1 failed and 1 active (outdated) deployments
e2e — 52157a9c Deployed Oct 22, 2026 by mattcreaser via Kotlin E2E (JVM, sandbox) #3764
publish — 1a4f4dcd Deployed Oct 5, 2026 by mattcreaser via Kotlin E2E (JVM, sandbox) #2795
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kotlin Issues or Pull Requests related to Kotlin code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants