diff --git a/.gitignore b/.gitignore index ec3b35b..77f1375 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ out/ .vscode/ ### MacOS ### -.DS_Store \ No newline at end of file +.DS_Store +# Local agent scratch/worktrees +.claude/ diff --git a/README.md b/README.md index 94393a3..64a7edc 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Whatever you choose, do not write the oauth endpoints yourself! - Java 21 - required by the SpringBoot plugin at build time - Gradle tool chain is used to compile and run tests -- Docker (only required for running tests) +- Docker (required for running tests and for `./gradlew bootTestRun`) - jq (used for some demos, not a hard dependency) ## Technologies Used @@ -56,6 +56,52 @@ Whatever you choose, do not write the oauth endpoints yourself! ## Running +### Running Locally + +One command starts the app together with a real Ory Hydra container and a pre-registered demo +client (Docker must be running): + +``` +./gradlew bootTestRun +``` + +Then open http://localhost:8080 and click "Start the OAuth flow". The credentials are +`foo@bar.com` / `password`; the flow ends on a page that exchanges the authorization code for +real tokens. Hydra runs on its default ports (public 4444, admin 4445), the same +single-container setup the functional tests use via +[testcontainers-ory-hydra](https://github.com/ardetrick/testcontainers-ory-hydra) — see +`TestOryHydraReferenceApplication` for how the container is configured and the demo client is +seeded. Ports 4444, 4445, and 8080 must be free. + +### Running With Your Own Ory Hydra + +To run the app against a Hydra you manage yourself, configure your Hydra with the app's +endpoints as the login, consent, and logout URLs (`URLS_LOGIN=http://localhost:8080/login`, +and likewise `/consent` and `/logout`), then start the app with `./gradlew bootRun`. If your +Hydra's admin API is not at the default `http://localhost:4445`, point the app at it with the +`reference-app.hydra.base-path` property. This path starts with whatever OAuth2 clients your +Hydra already has — the landing page at http://localhost:8080 lists them and includes +instructions for creating one. To see each step of the flow and token exchange as raw terminal +commands, see +[walking the authorization code flow by hand](docs/manual-token-exchange.md). + +
+Example: a minimal disposable Hydra via docker run + +``` +docker run --rm --name hydra \ + -p 4444:4444 -p 4445:4445 \ + -e DSN="sqlite:///tmp/db.sqlite?_fk=true" \ + -e SECRETS_SYSTEM=local-dev-secret \ + -e URLS_LOGIN=http://localhost:8080/login \ + -e URLS_CONSENT=http://localhost:8080/consent \ + -e URLS_LOGOUT=http://localhost:8080/logout \ + --entrypoint sh oryd/hydra:v25.4.0 \ + -c "hydra migrate sql -e --yes && hydra serve all --dev" +``` + +
+ ### Running Functional Tests The functional tests for this project run along all other tests with the standard gradle command. @@ -68,6 +114,9 @@ The functional tests are unique because there is practically no mocking. This ma setup, but it allows us to reproduce scenarios in a context very similar to what would be seen in production, all the way from interacting with the UI back to Ory Hydra. +
+How the test rig works + 1. Using `@SpringBootTest`, the application is started on a random port. Note that the application also configures two extra controllers to help facilitate testing. 2. A Playwright browser instance is created (shared by all tests). @@ -92,99 +141,7 @@ Since the token flow of OAuth is inherently UI driven, it is imperative that the with this the `Playwright` framework is used. It allows us to use a headless driver to load the UI and use HTML selectors to interact with the loaded page just like a human would. -### Running With Local Ory Hydra - -Running the application and Ory Hydra locally is a useful way to manually interact with the application. Use the -following commands to start Hydra, start the reference application, create a client, exchange a token. - -Note: All steps require Docker and some require `jq`. All commands should run relative to the root of this repo. - -#### Start Hydra And The Reference App - -``` -# Start Hydra with an in-container SQLite database, running the migrations first — -# the same single-container setup the functional tests get from testcontainers-ory-hydra. -docker run --rm --name hydra \ - -p 4444:4444 -p 4445:4445 \ - -e DSN="sqlite:///tmp/db.sqlite?_fk=true" \ - -e SECRETS_SYSTEM=local-dev-secret \ - -e URLS_LOGIN=http://localhost:8080/login \ - -e URLS_CONSENT=http://localhost:8080/consent \ - --entrypoint sh oryd/hydra:v25.4.0 \ - -c "hydra migrate sql -e --yes && hydra serve all --dev" - -# At this point Hydra urls such as http://localhost:4445/admin/clients should be up and running. - -# Open a new terminal... - -# Start the Spring app. -./gradlew bootRun - -# At this point the reference app pages such as localhost:8080/index.html should load. -``` - -#### Demo App - -Once both the app and Hydra are started, visit http://localhost:8080/demo for instructions on how interactively demo -the application. This is a more user-friendly approach than the subsequent testing instructions. - -#### Create A New Client And Test Via Terminal - -There may be existing clients you can use, visit to see http://localhost:4445/admin/clients. -Otherwise, follow these instructions to create a client and test it by going through the oauth flow. - -``` -# Create a client. Uses the Hydra container to access the Hydra CLI. -hydra_client=$(docker exec hydra \ - hydra create client \ - --endpoint http://127.0.0.1:4445 \ - --grant-type authorization_code,refresh_token \ - --response-type code,id_token \ - --format json \ - --secret omit-for-random-secret-1 \ - --scope openid --scope offline \ - --redirect-uri http://127.0.0.1:5555/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 \ - http://localhost:4445/admin/clients/$hydra_client_id \ - --data-binary @patch_client_body.json - -# Open a new terminal. - -# Build the endpoint to initiate the OAuth flow -oauth_endpoint="http://localhost:4444/oauth2/auth?\ -client_id=${hydra_client_id}&\ -response_type=code&\ -redirect_uri=${hydra_client_redirect_uri_0}&\ -scope=openid+offline&\ -state=123456789" - -# Print the endpoint. -echo $oauth_endpoint - -# Click on the printed endpoint (or paste it into a browser). -# Complete the OAuth flow (by default the hard coded credentials are username: foo@bar.com password: password). - -# replace '...' with the `code` query param in the call back. -code=... - -# Exchange the authorization code for an access token -curl -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -H "authorization: basic $(echo -n "${hydra_client_id}:${hydra_client_secret}" | base64)" \ - -d "grant_type=authorization_code" \ - -d "code=${code}" \ - -d "redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback" \ - -d "client_id=${hydra_client_id}" \ - http://127.0.0.1:4444/oauth2/token -``` +
## OAuth 2.0 Authorization Code Grant Flow diff --git a/docs/manual-token-exchange.md b/docs/manual-token-exchange.md new file mode 100644 index 0000000..d0a0af5 --- /dev/null +++ b/docs/manual-token-exchange.md @@ -0,0 +1,64 @@ +# Walking The Authorization Code Flow By Hand + +The app's [landing page](http://localhost:8080) automates most of this, but doing the +exchange manually with curl shows exactly what crosses the wire at each step of the +authorization code grant. This walkthrough assumes a Hydra container named `hydra` (see +"Running With Your Own Ory Hydra" in the [README](../README.md)), the app running via +`./gradlew bootRun`, and `jq` installed. Run the commands from the repository root. + +``` +# Create a client. Uses the Hydra container to access the Hydra CLI. +hydra_client=$(docker exec hydra \ + hydra create client \ + --endpoint http://127.0.0.1:4445 \ + --grant-type authorization_code,refresh_token \ + --response-type code,id_token \ + --format json \ + --secret omit-for-random-secret-1 \ + --scope openid --scope offline \ + --redirect-uri http://127.0.0.1:5555/callback +) + +# Put the client ID, client secret, and redirect URI 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 deserialization issues in the Ory Java SDK the app uses +# to list clients on /demo. +curl -X PATCH \ + http://localhost:4445/admin/clients/$hydra_client_id \ + --data-binary @patch_client_body.json + +# Build the endpoint that initiates the OAuth flow. +oauth_endpoint="http://localhost:4444/oauth2/auth?\ +client_id=${hydra_client_id}&\ +response_type=code&\ +redirect_uri=${hydra_client_redirect_uri_0}&\ +scope=openid+offline&\ +state=123456789" + +# Print the endpoint. +echo $oauth_endpoint + +# Paste the printed endpoint into a browser and complete the OAuth flow (the hard coded +# credentials are username: foo@bar.com password: password). Nothing listens on the +# redirect URI, so the final redirect shows a connection error — that's expected. Copy the +# `code` query parameter out of the browser's address bar. +code=... + +# Exchange the authorization code for the token response. Note the client authenticates +# with HTTP basic auth (client_id:client_secret, base64-encoded). +curl -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "authorization: basic $(echo -n "${hydra_client_id}:${hydra_client_secret}" | base64)" \ + -d "grant_type=authorization_code" \ + -d "code=${code}" \ + -d "redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback" \ + -d "client_id=${hydra_client_id}" \ + http://127.0.0.1:4444/oauth2/token +``` + +The response contains the access token, a refresh token (because the `offline` scope was +granted), and an `id_token` JWT (because `openid` was granted). The README's OpenID Connect +section shows an example of decoding and verifying the id_token. diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoCallbackController.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoCallbackController.java new file mode 100644 index 0000000..98d58c6 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoCallbackController.java @@ -0,0 +1,53 @@ +package com.ardetrick.oryhydrareference.demo; + +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +/** + * A demo-only OAuth2 client callback. In a real deployment the callback belongs to the client + * application, not the login/consent provider — this page exists so the demo flow can complete in + * the browser: it shows the authorization code (or error) Hydra redirected back with and exchanges + * the code for tokens at the click of a button. + */ +@RequiredArgsConstructor +@Controller +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class DemoCallbackController { + + @NonNull DemoTokenExchangeService demoTokenExchangeService; + + @GetMapping("/callback") + public ModelAndView callback( + @RequestParam(required = false) String code, + @RequestParam(required = false) String error, + @RequestParam(name = "error_description", required = false) String errorDescription) { + // The token exchange must present the exact redirect_uri used in the authorization request, + // which for this page is its own URL. + val redirectUri = + ServletUriComponentsBuilder.fromCurrentContextPath().path("/callback").toUriString(); + + return new ModelAndView("callback") + .addObject("code", code) + .addObject("error", error) + .addObject("errorDescription", errorDescription) + .addObject("redirectUri", redirectUri); + } + + @PostMapping("/callback/exchange") + public ModelAndView exchange(final ExchangeForm exchangeForm) { + val result = demoTokenExchangeService.exchange(exchangeForm); + + return new ModelAndView("tokens") + .addObject("tokenResponse", result.prettyTokenResponse()) + .addObject("idTokenClaims", result.prettyIdTokenClaims()); + } +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoController.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoController.java index 6dcc76c..0fc2226 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoController.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoController.java @@ -1,44 +1,75 @@ package com.ardetrick.oryhydrareference.demo; import com.ardetrick.oryhydrareference.hydra.HydraAdminClient; +import java.util.UUID; import lombok.AccessLevel; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; import lombok.val; -import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.servlet.view.RedirectView; +import org.springframework.web.util.UriComponentsBuilder; /** - * This controller is for demonstration purposes only to make it easier to run through the OAuth2 - * flow and interact with Hydra clients. It is not required nor expected to be used for any - * production use cases. + * The landing page: lists registered OAuth2 clients with ready-to-click links that start a real + * authorization code flow. Demonstration purposes only — a production Hydra deployment has no page + * like this. */ -@Slf4j @RequiredArgsConstructor @Controller -@RequestMapping("/demo") @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class DemoController { @NonNull HydraAdminClient hydraAdminClient; + @NonNull HydraAdminClient.Properties properties; + + @GetMapping("/") + public ModelAndView landingPage() { + // The app's own demo callback page; flows that redirect here complete in the browser. + val appCallbackUri = + ServletUriComponentsBuilder.fromCurrentContextPath().path("/callback").toUriString(); - @GetMapping(produces = {MediaType.TEXT_HTML_VALUE}) - @ResponseBody - public ModelAndView test() { val clients = hydraAdminClient.listOAuth2Clients().stream() .map( client -> new MvcClient( - client.getClientName(), client.getClientId(), client.getRedirectUris())) + client.getClientName(), + client.getClientId(), + client.getRedirectUris().stream() + .map( + redirectUri -> + new MvcClient.StartLink( + redirectUri, + authorizeUrl( + client.getClientId(), client.getScope(), redirectUri), + redirectUri.equals(appCallbackUri))) + .toList())) .toList(); return new ModelAndView("demo").addObject("clients", clients); } + + /** The landing page used to live here; redirect so old links and muscle memory keep working. */ + @GetMapping("/demo") + public RedirectView demo() { + return new RedirectView("/", true); + } + + private String authorizeUrl(String clientId, String scope, String redirectUri) { + return UriComponentsBuilder.fromUriString(properties.getPublicBasePath()) + .path("/oauth2/auth") + .queryParam("client_id", clientId) + .queryParam("response_type", "code") + .queryParam("redirect_uri", redirectUri) + .queryParam("scope", scope == null || scope.isBlank() ? "openid offline" : scope) + .queryParam("state", UUID.randomUUID().toString()) + .encode() + .build() + .toUriString(); + } } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoTokenExchangeService.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoTokenExchangeService.java new file mode 100644 index 0000000..dfd419b --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/DemoTokenExchangeService.java @@ -0,0 +1,86 @@ +package com.ardetrick.oryhydrareference.demo; + +import com.ardetrick.oryhydrareference.hydra.HydraAdminClient; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.springframework.stereotype.Service; + +/** + * Performs the authorization code exchange (RFC 6749 section 4.1.3) for the demo callback page. In + * a real deployment this request is made by the client application's backend; it lives here only so + * the demo flow can finish in the browser. The raw response is returned pretty-printed so the page + * can show exactly what the token endpoint said. + */ +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class DemoTokenExchangeService { + + static ObjectMapper objectMapper = new ObjectMapper(); + + @NonNull HydraAdminClient.Properties properties; + + public Result exchange(@NonNull final ExchangeForm exchangeForm) { + val form = + "grant_type=authorization_code" + + "&code=" + + URLEncoder.encode(exchangeForm.code(), StandardCharsets.UTF_8) + + "&redirect_uri=" + + URLEncoder.encode(exchangeForm.redirectUri(), StandardCharsets.UTF_8) + + "&client_id=" + + URLEncoder.encode(exchangeForm.clientId(), StandardCharsets.UTF_8); + val basicAuth = + Base64.getEncoder() + .encodeToString( + (exchangeForm.clientId() + ":" + exchangeForm.clientSecret()) + .getBytes(StandardCharsets.UTF_8)); + val request = + HttpRequest.newBuilder(URI.create(properties.getPublicBasePath() + "/oauth2/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "Basic " + basicAuth) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + + try { + val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + val json = objectMapper.readTree(response.body()); + val pretty = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(json); + val idTokenClaims = + json.hasNonNull("id_token") ? decodeJwtPayload(json.get("id_token").asText()) : null; + return new Result(pretty, idTokenClaims); + } catch (IOException e) { + throw new RuntimeException("Token exchange failed", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Token exchange interrupted", e); + } + } + + // Split-and-decode is enough to *display* claims; verifying the signature against the JWKS is a + // client responsibility deliberately not demoed here. + private static String decodeJwtPayload(String jwt) throws JsonProcessingException { + val parts = jwt.split("\\."); + if (parts.length < 2) { + return null; + } + val payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + return objectMapper + .writerWithDefaultPrettyPrinter() + .writeValueAsString(objectMapper.readTree(payload)); + } + + public record Result(String prettyTokenResponse, String prettyIdTokenClaims) {} +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/ExchangeForm.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/ExchangeForm.java new file mode 100644 index 0000000..a004228 --- /dev/null +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/ExchangeForm.java @@ -0,0 +1,4 @@ +package com.ardetrick.oryhydrareference.demo; + +/** The Java object representing the data submitted by the exchange form in callback.ftlh. */ +public record ExchangeForm(String code, String redirectUri, String clientId, String clientSecret) {} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/MvcClient.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/MvcClient.java index 6ee3379..d002d73 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/MvcClient.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/demo/MvcClient.java @@ -2,4 +2,12 @@ import java.util.List; -public record MvcClient(String name, String id, List redirectUris) {} +public record MvcClient(String name, String id, List startLinks) { + + /** + * One clickable flow entry point per registered redirect URI. {@code servedByThisApp} is true + * when the redirect URI is this app's own demo callback page, meaning the flow completes in the + * browser end to end. + */ + public record StartLink(String redirectUri, String url, boolean servedByThisApp) {} +} diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java index 96b8d1a..b211d73 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java @@ -157,5 +157,8 @@ public void rejectLogoutRequest(@NonNull String logoutChallenge) { public static class Properties { String basePath = "http://localhost:4445"; + + // Hydra's public API, used by the demo pages to build authorize links and exchange codes. + String publicBasePath = "http://localhost:4444"; } } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/login/LoginModelAndViewMapper.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/login/LoginModelAndViewMapper.java index 764db63..ea52fc8 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/login/LoginModelAndViewMapper.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/login/LoginModelAndViewMapper.java @@ -32,7 +32,7 @@ case LoginAcceptedFollowRedirect(String redirectUrl) -> yield loginModelAndView; } - case LoginRequestNotFound ignored -> new ModelAndView("/home"); + case LoginRequestNotFound ignored -> ModelAndViewUtils.redirectToDifferentContext("/"); }; } } diff --git a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java index 0954aa9..3bacbdb 100644 --- a/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java +++ b/reference-app/src/main/java/com/ardetrick/oryhydrareference/logout/LogoutModelAndViewMapper.java @@ -20,8 +20,8 @@ public static ModelAndView map(@NonNull final LogoutResult result) { .addObject("subject", r.subject()); case LogoutAcceptedFollowRedirect r -> ModelAndViewUtils.redirectToDifferentContext(r.redirectUrl()); - case LogoutCancelledReturnHome ignored -> new ModelAndView("/home"); - case LogoutRequestNotFound ignored -> new ModelAndView("/home"); + case LogoutCancelledReturnHome ignored -> ModelAndViewUtils.redirectToDifferentContext("/"); + case LogoutRequestNotFound ignored -> ModelAndViewUtils.redirectToDifferentContext("/"); }; } } diff --git a/reference-app/src/main/resources/templates/callback.ftlh b/reference-app/src/main/resources/templates/callback.ftlh new file mode 100644 index 0000000..9f1666d --- /dev/null +++ b/reference-app/src/main/resources/templates/callback.ftlh @@ -0,0 +1,34 @@ + + + + + Callback + + +

Callback

+ +<#if error??> +

Hydra redirected back with an OAuth error instead of a code:

+
${error}<#if errorDescription??>: ${errorDescription}
+ 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.

+ +
+ + + + +
+ +
+ + +
+ + + 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 @@ -

Demo

+ + + + + Ory Hydra Reference App + + +

Ory Hydra Reference App

-This page serves as a short cut to kick start the demonstration process for different existing clients. +

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.

-
    -
  1. Create a client if there are no viable ones listed below.
  2. -
  3. Find the client and redirect uri you are interested in testing (see list below).
  4. -
  5. Copy the partially completed curl command for token exchange (this will be used later). Note it assumes the default client secret, change as needed.
  6. -
  7. Follow the link to and complete the OAuth flow.
  8. -
  9. Get the "code" value from the query param.
  10. -
  11. Place the code in the partially completed curl command.
  12. -
  13. Observe the response! Consider inspecting the JWT with jwt.io
  14. -
- -

All Clients

+

Start a flow

<#list clients as client> -
- Client Name: ${client.name()}
- Client ID: ${client.id()}
- OAuth start links by redirect URI: +
+ <#if client.name()?has_content>${client.name()} — ${client.id()}
    - <#list client.redirectUris() as redirectUri> -
  • - <#assign oauthUrl="http://localhost:4444/oauth2/auth?client_id=${client.id()}&response_type=code&redirect_uri=${redirectUri}&scope=openid+offline&state=${.now?long?c}"> - ${redirectUri}
    - ${oauthUrl} -
    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
    -
    -
  • + <#list client.startLinks() as link> +
  • + <#if link.servedByThisApp()> + Start the OAuth flow + <#else> + Start the OAuth flow — redirects to + ${link.redirectUri()}, which this app does not serve; copy the + code query parameter from the browser afterwards. + +
-
+
+<#else> +

No OAuth2 clients are registered yet — expand the section below to create one.

-

Create A Client

+
+Create another client
-To create a client, run these commands. Afterwards, reload this page to see the new client. +

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
 
 
-
+
+ + diff --git a/reference-app/src/main/resources/templates/home.ftlh b/reference-app/src/main/resources/templates/home.ftlh deleted file mode 100644 index 2fd89ce..0000000 --- a/reference-app/src/main/resources/templates/home.ftlh +++ /dev/null @@ -1 +0,0 @@ -Home diff --git a/reference-app/src/main/resources/templates/tokens.ftlh b/reference-app/src/main/resources/templates/tokens.ftlh new file mode 100644 index 0000000..2d725bb --- /dev/null +++ b/reference-app/src/main/resources/templates/tokens.ftlh @@ -0,0 +1,22 @@ + + + + + Token Response + + +

Token Response

+ +

The raw response from Hydra's token endpoint:

+
${tokenResponse}
+ +<#if idTokenClaims??> +

Decoded id_token claims

+

The payload of the id_token JWT (signature not verified here — that is the client's job, + against /.well-known/jwks.json):

+
${idTokenClaims}
+ + +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; + } + } +}