Hydra redirected back with an OAuth error instead of a code:
+${error}<#if errorDescription??>: ${errorDescription}#if>
+ Back to start
+<#else>
+ Hydra redirected back with an authorization code:
+${code!""}
+
+ Exchange it for tokens. The client fields are pre-filled for the seeded demo client; change + them if you started the flow with a different client.
+ + +#if> + + diff --git a/reference-app/src/main/resources/templates/demo.ftlh b/reference-app/src/main/resources/templates/demo.ftlh index 31dfb4a..f8eafb8 100644 --- a/reference-app/src/main/resources/templates/demo.ftlh +++ b/reference-app/src/main/resources/templates/demo.ftlh @@ -1,52 +1,51 @@ -This app implements the login, consent, and logout screens for an
+Ory Hydra OAuth 2.0 server. Start a flow below, log in
+with foo@bar.com / password, and you will end on a page that exchanges
+the authorization code for real tokens.
${client.id()}
curl -X POST \
- -H "Content-Type: application/x-www-form-urlencoded" \
- -H "authorization: basic $(echo -n "${client.id()}:omit-for-random-secret-1" | base64)" \
- -d "grant_type=authorization_code" \
- -d "code=CODE_GOES_HERE" \
- -d "redirect_uri=${redirectUri?url('ISO-8859-1')}" \
- -d "client_id=${client.id()}" \
- http://127.0.0.1:4444/oauth2/token
-
- ${link.redirectUri()}, which this app does not serve; copy the
+ code query parameter from the browser afterwards.
+ #if>
+ No OAuth2 clients are registered yet — expand the section below to create one.
#list> -Started via ./gradlew bootTestRun? A demo client is already registered and listed
+above. To create another client (the commands assume a Hydra container named hydra,
+as in the README's "Running With Your Own Ory Hydra" instructions), run these commands and
+reload this page.
-hydra_client=$(docker-compose -f ./reference-app/src/test/resources/docker-compose.yml exec hydra \
+hydra_client=$(docker exec hydra \
hydra create client \
--endpoint http://127.0.0.1:4445 \
--grant-type authorization_code,refresh_token \
@@ -54,13 +53,12 @@ hydra_client=$(docker-compose -f ./reference-app/src/test/resources/docker-compo
--format json \
--secret omit-for-random-secret-1 \
--scope openid --scope offline \
- --redirect-uri http://127.0.0.1:5555/callback
+ --redirect-uri http://localhost:8080/callback
)
# Put the client ID and client secret values into env variables for later use.
hydra_client_id=$(echo $hydra_client | jq -r '.client_id')
hydra_client_secret=$(echo $hydra_client | jq -r '.client_secret')
-hydra_client_redirect_uri_0=$(echo $hydra_client | jq -r '.redirect_uris[0]')
# Update the client to avoid some issues with the Java SDK.
curl -X PATCH \
@@ -68,5 +66,7 @@ curl -X PATCH \
--data-binary @patch_client_body.json
-
The raw response from Hydra's token endpoint:
+${tokenResponse}
+
+<#if idTokenClaims??>
+ The payload of the id_token JWT (signature not verified here — that is the client's job,
+ against /.well-known/jwks.json):
${idTokenClaims}
+#if>
+
+Back to start
+
+
diff --git a/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java b/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java
index 8f0caf4..7f8df49 100644
--- a/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java
+++ b/reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java
@@ -112,6 +112,8 @@ void startTestEnvironment() {
// two different endpoints: the app's HydraAdminClient speaks the admin API, while the
// test's public proxy forwards browsers to the public authorize endpoint.
properties.setBasePath(oryHydraContainer.adminBaseUriString());
+ // The landing page and demo callback build public-endpoint URLs from this property.
+ properties.setPublicBasePath(oryHydraContainer.publicBaseUriString());
forwardingController.hydraPublicBaseUri = oryHydraContainer.publicBaseUriString();
}
@@ -573,6 +575,59 @@ public void cancellingLogoutKeepsTheSessionAlive() {
assertThat(page.url()).doesNotContain("/login");
}
+ @Test
+ public void quickStartFromLandingPageExchangesTokensInBrowser() {
+ val screenshotPathProducer =
+ ScreenshotPathProducer.builder()
+ .testName("quickStartFromLandingPageExchangesTokensInBrowser")
+ .build();
+
+ // Re-register this test's client to redirect to the app's own demo callback page, the
+ // configuration the landing page's quick start is built around.
+ val appCallback = "http://localhost:" + springBootAppPort + "/callback";
+ oryHydraContainer.createOrReplaceClient(
+ client ->
+ client
+ .clientId(clientId)
+ .clientSecret(clientSecret)
+ .redirectUris(appCallback)
+ .grantTypes("authorization_code", "refresh_token")
+ .responseTypes("code", "id_token")
+ .scope("offline_access", "openid", "offline", "profile"));
+
+ val page = browser.newPage();
+
+ page.navigate("http://localhost:" + springBootAppPort + "/");
+ page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("landing-page"));
+
+ page.locator("a[data-testid='start-" + clientId + "']").click();
+
+ page.waitForLoadState();
+ page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("login"));
+
+ page.locator("input[name=loginEmail]").fill("foo@bar.com");
+ page.locator("input[name=loginPassword]").fill("password");
+ page.locator("input[name=submit]").click();
+
+ page.waitForLoadState();
+ page.locator("input[id=accept]").click();
+
+ page.waitForLoadState();
+ page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("callback-page"));
+ assertThat(page.url()).contains("/callback?code=");
+
+ // The form pre-fills the seeded demo client's values; this test's client is unique, so
+ // overwrite them.
+ page.locator("input[id=clientId]").fill(clientId);
+ page.locator("input[id=clientSecret]").fill(clientSecret);
+ page.locator("input[id=exchange]").click();
+
+ page.waitForLoadState();
+ page.screenshot(screenshotPathProducer.screenshotOptionsForStepName("tokens"));
+ assertThat(page.content()).contains("access_token");
+ assertThat(page.content()).contains("exampleCustomClaimKey");
+ }
+
/**
* Runs one full authorization-code flow with login remember-me checked, ending at the client
* callback with the code exchanged.
diff --git a/reference-app/src/test/java/com/ardetrick/oryhydrareference/TestOryHydraReferenceApplication.java b/reference-app/src/test/java/com/ardetrick/oryhydrareference/TestOryHydraReferenceApplication.java
new file mode 100644
index 0000000..6a56af9
--- /dev/null
+++ b/reference-app/src/test/java/com/ardetrick/oryhydrareference/TestOryHydraReferenceApplication.java
@@ -0,0 +1,62 @@
+package com.ardetrick.oryhydrareference;
+
+import com.ardetrick.testcontainers.OryHydraContainer;
+import java.util.List;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Development-time entry point: starts the app together with a real Ory Hydra container and a
+ * seeded demo client, so running locally is one command with nothing to install but Docker.
+ *
+ * Run {@code ./gradlew bootTestRun}, then open http://localhost:8080/demo and log in with {@code + * foo@bar.com} / {@code password}. + */ +public class TestOryHydraReferenceApplication { + + public static void main(String[] args) { + SpringApplication.from(OryHydraReferenceApplication::main) + .with(LocalHydraConfiguration.class) + .run(args); + } + + @TestConfiguration(proxyBeanMethods = false) + static class LocalHydraConfiguration { + + // Hydra's host ports are pinned to the image defaults here, so Hydra's dev-mode issuer + // (http://localhost:4444), the app's default admin base path (http://localhost:4445), and + // the hardcoded links on /demo all line up with no configuration. Unlike the functional + // tests, a dev-time run has no parallelism to worry about, so fixed ports are fine. The + // demo client's secret matches the one the /demo page's curl snippet assumes. + @Bean(destroyMethod = "stop") + OryHydraContainer oryHydraContainer() { + OryHydraContainer hydra = + OryHydraContainer.builder() + .urlsLogin("http://localhost:8080/login") + .urlsConsent("http://localhost:8080/consent") + .urlsLogout("http://localhost:8080/logout") + // Without an explicit issuer, Hydra derives it from its listen address + // (0.0.0.0:4444), and browsers refuse to follow the issuer-based redirects the + // flow makes mid-way (Safari outright blocks 0.0.0.0). + .urlsSelfIssuer("http://localhost:4444") + .client( + client -> + client + .clientId("demo-client") + .clientSecret("omit-for-random-secret-1") + // The app's own demo callback page, so the flow completes in the + // browser end to end. + .redirectUris("http://localhost:8080/callback") + .grantTypes("authorization_code", "refresh_token") + .responseTypes("code", "id_token") + .scope("openid", "offline", "offline_access", "profile") + .put("client_name", "Demo Client")) + .build(); + hydra.setPortBindings(List.of("4444:4444", "4445:4445")); + hydra.start(); + return hydra; + } + } +}