Skip to content

Commit df3f0e8

Browse files
committed
feat: one-command local run via bootTestRun
1 parent b9c7aff commit df3f0e8

17 files changed

Lines changed: 532 additions & 152 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,6 @@ out/
3737
.vscode/
3838

3939
### MacOS ###
40-
.DS_Store
40+
.DS_Store
41+
# Local agent scratch/worktrees
42+
.claude/

README.md

Lines changed: 51 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Whatever you choose, do not write the oauth endpoints yourself!
3939
- Java 21
4040
- required by the SpringBoot plugin at build time
4141
- Gradle tool chain is used to compile and run tests
42-
- Docker (only required for running tests)
42+
- Docker (required for running tests and for `./gradlew bootTestRun`)
4343
- jq (used for some demos, not a hard dependency)
4444

4545
## Technologies Used
@@ -56,6 +56,52 @@ Whatever you choose, do not write the oauth endpoints yourself!
5656

5757
## Running
5858

59+
### Running Locally
60+
61+
One command starts the app together with a real Ory Hydra container and a pre-registered demo
62+
client (Docker must be running):
63+
64+
```
65+
./gradlew bootTestRun
66+
```
67+
68+
Then open http://localhost:8080 and click "Start the OAuth flow". The credentials are
69+
`foo@bar.com` / `password`; the flow ends on a page that exchanges the authorization code for
70+
real tokens. Hydra runs on its default ports (public 4444, admin 4445), the same
71+
single-container setup the functional tests use via
72+
[testcontainers-ory-hydra](https://github.com/ardetrick/testcontainers-ory-hydra) — see
73+
`TestOryHydraReferenceApplication` for how the container is configured and the demo client is
74+
seeded. Ports 4444, 4445, and 8080 must be free.
75+
76+
### Running With Your Own Ory Hydra
77+
78+
To run the app against a Hydra you manage yourself, configure your Hydra with the app's
79+
endpoints as the login, consent, and logout URLs (`URLS_LOGIN=http://localhost:8080/login`,
80+
and likewise `/consent` and `/logout`), then start the app with `./gradlew bootRun`. If your
81+
Hydra's admin API is not at the default `http://localhost:4445`, point the app at it with the
82+
`reference-app.hydra.base-path` property. This path starts with whatever OAuth2 clients your
83+
Hydra already has — the landing page at http://localhost:8080 lists them and includes
84+
instructions for creating one. To see each step of the flow and token exchange as raw terminal
85+
commands, see
86+
[walking the authorization code flow by hand](docs/manual-token-exchange.md).
87+
88+
<details>
89+
<summary>Example: a minimal disposable Hydra via docker run</summary>
90+
91+
```
92+
docker run --rm --name hydra \
93+
-p 4444:4444 -p 4445:4445 \
94+
-e DSN="sqlite:///tmp/db.sqlite?_fk=true" \
95+
-e SECRETS_SYSTEM=local-dev-secret \
96+
-e URLS_LOGIN=http://localhost:8080/login \
97+
-e URLS_CONSENT=http://localhost:8080/consent \
98+
-e URLS_LOGOUT=http://localhost:8080/logout \
99+
--entrypoint sh oryd/hydra:v25.4.0 \
100+
-c "hydra migrate sql -e --yes && hydra serve all --dev"
101+
```
102+
103+
</details>
104+
59105
### Running Functional Tests
60106

61107
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
68114
setup, but it allows us to reproduce scenarios in a context very similar to what would be seen in production, all the
69115
way from interacting with the UI back to Ory Hydra.
70116

117+
<details>
118+
<summary>How the test rig works</summary>
119+
71120
1. Using `@SpringBootTest`, the application is started on a random port. Note that the application also configures two
72121
extra controllers to help facilitate testing.
73122
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
92141
with this the `Playwright` framework is used. It allows us to use a headless driver to load the UI and use HTML
93142
selectors to interact with the loaded page just like a human would.
94143

95-
### Running With Local Ory Hydra
96-
97-
Running the application and Ory Hydra locally is a useful way to manually interact with the application. Use the
98-
following commands to start Hydra, start the reference application, create a client, exchange a token.
99-
100-
Note: All steps require Docker and some require `jq`. All commands should run relative to the root of this repo.
101-
102-
#### Start Hydra And The Reference App
103-
104-
```
105-
# Start Hydra with an in-container SQLite database, running the migrations first —
106-
# the same single-container setup the functional tests get from testcontainers-ory-hydra.
107-
docker run --rm --name hydra \
108-
-p 4444:4444 -p 4445:4445 \
109-
-e DSN="sqlite:///tmp/db.sqlite?_fk=true" \
110-
-e SECRETS_SYSTEM=local-dev-secret \
111-
-e URLS_LOGIN=http://localhost:8080/login \
112-
-e URLS_CONSENT=http://localhost:8080/consent \
113-
--entrypoint sh oryd/hydra:v25.4.0 \
114-
-c "hydra migrate sql -e --yes && hydra serve all --dev"
115-
116-
# At this point Hydra urls such as http://localhost:4445/admin/clients should be up and running.
117-
118-
# Open a new terminal...
119-
120-
# Start the Spring app.
121-
./gradlew bootRun
122-
123-
# At this point the reference app pages such as localhost:8080/index.html should load.
124-
```
125-
126-
#### Demo App
127-
128-
Once both the app and Hydra are started, visit http://localhost:8080/demo for instructions on how interactively demo
129-
the application. This is a more user-friendly approach than the subsequent testing instructions.
130-
131-
#### Create A New Client And Test Via Terminal
132-
133-
There may be existing clients you can use, visit to see http://localhost:4445/admin/clients.
134-
Otherwise, follow these instructions to create a client and test it by going through the oauth flow.
135-
136-
```
137-
# Create a client. Uses the Hydra container to access the Hydra CLI.
138-
hydra_client=$(docker exec hydra \
139-
hydra create client \
140-
--endpoint http://127.0.0.1:4445 \
141-
--grant-type authorization_code,refresh_token \
142-
--response-type code,id_token \
143-
--format json \
144-
--secret omit-for-random-secret-1 \
145-
--scope openid --scope offline \
146-
--redirect-uri http://127.0.0.1:5555/callback
147-
)
148-
149-
# Put the client ID and client secret values into env variables for later use.
150-
hydra_client_id=$(echo $hydra_client | jq -r '.client_id')
151-
hydra_client_secret=$(echo $hydra_client | jq -r '.client_secret')
152-
hydra_client_redirect_uri_0=$(echo $hydra_client | jq -r '.redirect_uris[0]')
153-
154-
# Update the client to avoid some issues with the Java SDK.
155-
curl -X PATCH \
156-
http://localhost:4445/admin/clients/$hydra_client_id \
157-
--data-binary @patch_client_body.json
158-
159-
# Open a new terminal.
160-
161-
# Build the endpoint to initiate the OAuth flow
162-
oauth_endpoint="http://localhost:4444/oauth2/auth?\
163-
client_id=${hydra_client_id}&\
164-
response_type=code&\
165-
redirect_uri=${hydra_client_redirect_uri_0}&\
166-
scope=openid+offline&\
167-
state=123456789"
168-
169-
# Print the endpoint.
170-
echo $oauth_endpoint
171-
172-
# Click on the printed endpoint (or paste it into a browser).
173-
# Complete the OAuth flow (by default the hard coded credentials are username: foo@bar.com password: password).
174-
175-
# replace '...' with the `code` query param in the call back.
176-
code=...
177-
178-
# Exchange the authorization code for an access token
179-
curl -X POST \
180-
-H "Content-Type: application/x-www-form-urlencoded" \
181-
-H "authorization: basic $(echo -n "${hydra_client_id}:${hydra_client_secret}" | base64)" \
182-
-d "grant_type=authorization_code" \
183-
-d "code=${code}" \
184-
-d "redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback" \
185-
-d "client_id=${hydra_client_id}" \
186-
http://127.0.0.1:4444/oauth2/token
187-
```
144+
</details>
188145

189146
## OAuth 2.0 Authorization Code Grant Flow
190147

docs/manual-token-exchange.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Walking The Authorization Code Flow By Hand
2+
3+
The app's [landing page](http://localhost:8080) automates most of this, but doing the
4+
exchange manually with curl shows exactly what crosses the wire at each step of the
5+
authorization code grant. This walkthrough assumes a Hydra container named `hydra` (see
6+
"Running With Your Own Ory Hydra" in the [README](../README.md)), the app running via
7+
`./gradlew bootRun`, and `jq` installed. Run the commands from the repository root.
8+
9+
```
10+
# Create a client. Uses the Hydra container to access the Hydra CLI.
11+
hydra_client=$(docker exec hydra \
12+
hydra create client \
13+
--endpoint http://127.0.0.1:4445 \
14+
--grant-type authorization_code,refresh_token \
15+
--response-type code,id_token \
16+
--format json \
17+
--secret omit-for-random-secret-1 \
18+
--scope openid --scope offline \
19+
--redirect-uri http://127.0.0.1:5555/callback
20+
)
21+
22+
# Put the client ID, client secret, and redirect URI into env variables for later use.
23+
hydra_client_id=$(echo $hydra_client | jq -r '.client_id')
24+
hydra_client_secret=$(echo $hydra_client | jq -r '.client_secret')
25+
hydra_client_redirect_uri_0=$(echo $hydra_client | jq -r '.redirect_uris[0]')
26+
27+
# Update the client to avoid some deserialization issues in the Ory Java SDK the app uses
28+
# to list clients on /demo.
29+
curl -X PATCH \
30+
http://localhost:4445/admin/clients/$hydra_client_id \
31+
--data-binary @patch_client_body.json
32+
33+
# Build the endpoint that initiates the OAuth flow.
34+
oauth_endpoint="http://localhost:4444/oauth2/auth?\
35+
client_id=${hydra_client_id}&\
36+
response_type=code&\
37+
redirect_uri=${hydra_client_redirect_uri_0}&\
38+
scope=openid+offline&\
39+
state=123456789"
40+
41+
# Print the endpoint.
42+
echo $oauth_endpoint
43+
44+
# Paste the printed endpoint into a browser and complete the OAuth flow (the hard coded
45+
# credentials are username: foo@bar.com password: password). Nothing listens on the
46+
# redirect URI, so the final redirect shows a connection error — that's expected. Copy the
47+
# `code` query parameter out of the browser's address bar.
48+
code=...
49+
50+
# Exchange the authorization code for the token response. Note the client authenticates
51+
# with HTTP basic auth (client_id:client_secret, base64-encoded).
52+
curl -X POST \
53+
-H "Content-Type: application/x-www-form-urlencoded" \
54+
-H "authorization: basic $(echo -n "${hydra_client_id}:${hydra_client_secret}" | base64)" \
55+
-d "grant_type=authorization_code" \
56+
-d "code=${code}" \
57+
-d "redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcallback" \
58+
-d "client_id=${hydra_client_id}" \
59+
http://127.0.0.1:4444/oauth2/token
60+
```
61+
62+
The response contains the access token, a refresh token (because the `offline` scope was
63+
granted), and an `id_token` JWT (because `openid` was granted). The README's OpenID Connect
64+
section shows an example of decoding and verifying the id_token.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.ardetrick.oryhydrareference.demo;
2+
3+
import lombok.AccessLevel;
4+
import lombok.NonNull;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.experimental.FieldDefaults;
7+
import lombok.val;
8+
import org.springframework.stereotype.Controller;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.servlet.ModelAndView;
13+
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
14+
15+
/**
16+
* A demo-only OAuth2 client callback. In a real deployment the callback belongs to the client
17+
* application, not the login/consent provider — this page exists so the demo flow can complete in
18+
* the browser: it shows the authorization code (or error) Hydra redirected back with and exchanges
19+
* the code for tokens at the click of a button.
20+
*/
21+
@RequiredArgsConstructor
22+
@Controller
23+
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
24+
public class DemoCallbackController {
25+
26+
@NonNull DemoTokenExchangeService demoTokenExchangeService;
27+
28+
@GetMapping("/callback")
29+
public ModelAndView callback(
30+
@RequestParam(required = false) String code,
31+
@RequestParam(required = false) String error,
32+
@RequestParam(name = "error_description", required = false) String errorDescription) {
33+
// The token exchange must present the exact redirect_uri used in the authorization request,
34+
// which for this page is its own URL.
35+
val redirectUri =
36+
ServletUriComponentsBuilder.fromCurrentContextPath().path("/callback").toUriString();
37+
38+
return new ModelAndView("callback")
39+
.addObject("code", code)
40+
.addObject("error", error)
41+
.addObject("errorDescription", errorDescription)
42+
.addObject("redirectUri", redirectUri);
43+
}
44+
45+
@PostMapping("/callback/exchange")
46+
public ModelAndView exchange(final ExchangeForm exchangeForm) {
47+
val result = demoTokenExchangeService.exchange(exchangeForm);
48+
49+
return new ModelAndView("tokens")
50+
.addObject("tokenResponse", result.prettyTokenResponse())
51+
.addObject("idTokenClaims", result.prettyIdTokenClaims());
52+
}
53+
}
Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,75 @@
11
package com.ardetrick.oryhydrareference.demo;
22

33
import com.ardetrick.oryhydrareference.hydra.HydraAdminClient;
4+
import java.util.UUID;
45
import lombok.AccessLevel;
56
import lombok.NonNull;
67
import lombok.RequiredArgsConstructor;
78
import lombok.experimental.FieldDefaults;
8-
import lombok.extern.slf4j.Slf4j;
99
import lombok.val;
10-
import org.springframework.http.MediaType;
1110
import org.springframework.stereotype.Controller;
1211
import org.springframework.web.bind.annotation.GetMapping;
13-
import org.springframework.web.bind.annotation.RequestMapping;
14-
import org.springframework.web.bind.annotation.ResponseBody;
1512
import org.springframework.web.servlet.ModelAndView;
13+
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
14+
import org.springframework.web.servlet.view.RedirectView;
15+
import org.springframework.web.util.UriComponentsBuilder;
1616

1717
/**
18-
* This controller is for demonstration purposes only to make it easier to run through the OAuth2
19-
* flow and interact with Hydra clients. It is not required nor expected to be used for any
20-
* production use cases.
18+
* The landing page: lists registered OAuth2 clients with ready-to-click links that start a real
19+
* authorization code flow. Demonstration purposes only — a production Hydra deployment has no page
20+
* like this.
2121
*/
22-
@Slf4j
2322
@RequiredArgsConstructor
2423
@Controller
25-
@RequestMapping("/demo")
2624
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
2725
public class DemoController {
2826

2927
@NonNull HydraAdminClient hydraAdminClient;
28+
@NonNull HydraAdminClient.Properties properties;
29+
30+
@GetMapping("/")
31+
public ModelAndView landingPage() {
32+
// The app's own demo callback page; flows that redirect here complete in the browser.
33+
val appCallbackUri =
34+
ServletUriComponentsBuilder.fromCurrentContextPath().path("/callback").toUriString();
3035

31-
@GetMapping(produces = {MediaType.TEXT_HTML_VALUE})
32-
@ResponseBody
33-
public ModelAndView test() {
3436
val clients =
3537
hydraAdminClient.listOAuth2Clients().stream()
3638
.map(
3739
client ->
3840
new MvcClient(
39-
client.getClientName(), client.getClientId(), client.getRedirectUris()))
41+
client.getClientName(),
42+
client.getClientId(),
43+
client.getRedirectUris().stream()
44+
.map(
45+
redirectUri ->
46+
new MvcClient.StartLink(
47+
redirectUri,
48+
authorizeUrl(
49+
client.getClientId(), client.getScope(), redirectUri),
50+
redirectUri.equals(appCallbackUri)))
51+
.toList()))
4052
.toList();
4153

4254
return new ModelAndView("demo").addObject("clients", clients);
4355
}
56+
57+
/** The landing page used to live here; redirect so old links and muscle memory keep working. */
58+
@GetMapping("/demo")
59+
public RedirectView demo() {
60+
return new RedirectView("/", true);
61+
}
62+
63+
private String authorizeUrl(String clientId, String scope, String redirectUri) {
64+
return UriComponentsBuilder.fromUriString(properties.getPublicBasePath())
65+
.path("/oauth2/auth")
66+
.queryParam("client_id", clientId)
67+
.queryParam("response_type", "code")
68+
.queryParam("redirect_uri", redirectUri)
69+
.queryParam("scope", scope == null || scope.isBlank() ? "openid offline" : scope)
70+
.queryParam("state", UUID.randomUUID().toString())
71+
.encode()
72+
.build()
73+
.toUriString();
74+
}
4475
}

0 commit comments

Comments
 (0)