Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ out/
.vscode/

### MacOS ###
.DS_Store
.DS_Store
# Local agent scratch/worktrees
.claude/
145 changes: 51 additions & 94 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

<details>
<summary>Example: a minimal disposable Hydra via docker run</summary>

```
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"
```

</details>

### Running Functional Tests

The functional tests for this project run along all other tests with the standard gradle command.
Expand All @@ -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.

<details>
<summary>How the test rig works</summary>

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).
Expand All @@ -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
```
</details>

## OAuth 2.0 Authorization Code Grant Flow

Expand Down
64 changes: 64 additions & 0 deletions docs/manual-token-exchange.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading