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
27 changes: 27 additions & 0 deletions .github/workflows/saml2-idp-initiated.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: SAML 2.0 IdP-initiated (Spring Boot)

on:
push:
branches: [main]
paths:
- "saml2/idp-initiated/**"
- ".github/workflows/saml2-idp-initiated.yml"
pull_request:
branches: [main]
paths:
- "saml2/idp-initiated/**"
- ".github/workflows/saml2-idp-initiated.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
ci:
uses: ./.github/workflows/_gradle-ci.yml
with:
working-directory: saml2/idp-initiated
32 changes: 32 additions & 0 deletions .github/workflows/spring-boot-keycloak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Spring Boot + Angular (Keycloak)

on:
push:
branches: [main]
paths:
- "frameworks/spring-boot-keycloak/**"
- ".github/workflows/spring-boot-keycloak.yml"
pull_request:
branches: [main]
paths:
- "frameworks/spring-boot-keycloak/**"
- ".github/workflows/spring-boot-keycloak.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
api:
uses: ./.github/workflows/_gradle-ci.yml
with:
working-directory: frameworks/spring-boot-keycloak

angularclient:
uses: ./.github/workflows/_node-ci.yml
with:
working-directory: frameworks/spring-boot-keycloak/angularclient
3 changes: 3 additions & 0 deletions frameworks/spring-boot-keycloak/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
4 changes: 0 additions & 4 deletions frameworks/spring-boot-keycloak/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ build/
!**/src/main/**/build/
!**/src/test/**/build/

### STS ###
.apt_generated
.classpath
.factorypath
Expand All @@ -17,7 +16,6 @@ bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
Expand All @@ -26,12 +24,10 @@ out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
142 changes: 118 additions & 24 deletions frameworks/spring-boot-keycloak/README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,133 @@
# SpringBoot Keycloak SPA application
# Phase Two Spring Boot example: resource server + Angular client

This project was generated with [Spring Initializr](https://start.spring.io/).
A Spring Boot API secured with Keycloak, and an Angular single-page app that logs users in and calls it with their access token. Follow along with the tutorial [Securing an Angular and Spring Boot Application with Keycloak](https://phasetwo.io/blog/secure-spring-boot/).

Follow along with the [tutorial](https://phasetwo.io/blog/secure-spring-boot/) from Phase Two.
## How it works

# Get Started
```text
Angular client (localhost:4200) Keycloak (localhost:8888)
┌──────────────────────────────┐ 1. log in ┌──────────────────────────┐
│ angular-oauth2-oidc │ ───────────────▶ │ realm demo-realm │
│ code flow + PKCE │ ◀─────────────── │ client demo-spa │
└──────────────────────────────┘ access token └──────────────────────────┘
│ ▲
│ 2. GET /api/test/user │ 3. signing keys
│ Authorization: Bearer <access token> │
▼ │
┌────────────────────────────────────────────────────────────────────────────┐
│ Spring Boot API (localhost:8080): OAuth 2.0 resource server │
└────────────────────────────────────────────────────────────────────────────┘
```

## Start Keycloak Server
1. The [Angular client](./angularclient) logs the user in with the authorization code flow and PKCE. Tokens stay in the browser's session storage and are refreshed before they expire.
2. It sends the access token as a bearer token, only on requests to the API.
3. The API validates the token (signature, issuer, expiry) with the realm's public keys, then maps the realm roles from the `realm_access.roles` claim to Spring Security roles: the Keycloak role `user` becomes `ROLE_user`.

To skip this section, use [Phase Two](https://phasetwo.io/dashboard/) to setup a free Keycloak hosted in the cloud. Instructions below assume using the local instance, but values can easily be changed to match the cloud instance.
| Endpoint | Who can call it | Response |
| ------------------------- | ------------------------------------------ | --------------------------------------------------------------------------- |
| `GET /api/test/anonymous` | everyone | `{"message":"Hello Anonymous"}` |
| `GET /api/test/user` | an access token with the realm role `user` | `{"message":"Hello Secured with user role.","user":"<preferred_username>"}` |

1. Start Keycloak server using `docker-compose up -d`
1. Go to `http://localhost:8888/auth` and login using username `admin` and password `admin`
1. Create a realm `demo-realm`
1. Create a public client `demo-spa` and add redirectUri `http://localhost:4200/*` and Web Origins `http://localhost:4200`
1. Create a realm role `user`
1. Create a `test` user and assign the `user` role to it. Add password credentials to the `test` user.
Without a token, `/api/test/user` answers `401 Unauthorized`; with a token that lacks the `user` role, `403 Forbidden`. Any other path is denied, except `/.well-known/oauth-protected-resource`, where Spring Security publishes the API's [OAuth 2.0 protected resource metadata](https://datatracker.ietf.org/doc/html/rfc9728).

## Start Angular Application
| File | What it does |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| [`SecurityConfig.java`](./src/main/java/com/example/springbootkeycloak/config/SecurityConfig.java) | Stateless resource server, the paths that need a token, CORS for the Angular client |
| [`JwtClaimsConverter.java`](./src/main/java/com/example/springbootkeycloak/config/JwtClaimsConverter.java) | Realm roles to `ROLE_*` authorities, `preferred_username` as the user name |
| [`TestController.java`](./src/main/java/com/example/springbootkeycloak/web/TestController.java) | The two endpoints; `@PreAuthorize("hasRole('user')")` guards `/api/test/user` |
| [`application.yaml`](./src/main/resources/application.yaml) | Keycloak issuer and allowed CORS origins |
| [`keycloak/demo-realm-realm.json`](./keycloak/demo-realm-realm.json) | The realm Keycloak imports at startup |

1. Open another terminal. Go to `./angularclient` folder. Run `npm i` and start the Angular app using `npm run start`.
1. Open the browser to `http://localhost:4200/`. After login the `accessToken` should be present in the `localStorage` of the browser. View this by opening a Web console and typing `localStorage` to view its value.
1. Use access token to access secured endpoints:
## Requirements

## Start Spring Application
- Java 17 or newer to run Gradle. The build compiles with a Java 21 toolchain, which Gradle downloads if you do not have one.
- Node.js 24 and pnpm, for the Angular client.
- Docker with the Compose plugin.

1. Run `./gradlew bootRun` to start the SpringBoot application in a terminal.
1. Directly hit the Spring application's API interface. Pull the `accessToken` from `localStorage`.
## Run it

1. Start Keycloak on port 8888, from this folder:

```sh
docker compose up -d --wait
```

Keycloak imports [`keycloak/demo-realm-realm.json`](./keycloak/demo-realm-realm.json) (the file name follows Keycloak's `<realm>-realm.json` export convention):

| What | Value |
| ------------- | ------------------------------------------------------------------------------------------- |
| Issuer | `http://localhost:8888/auth/realms/demo-realm` |
| Admin console | <http://localhost:8888/auth/admin>, `admin` / `admin` |
| Users | `test` / `test` has the realm role `user`; `noaccess` / `noaccess` does not |
| Client | `demo-spa`: public, standard flow with PKCE (S256), redirect URIs `http://localhost:4200/*` |

Nothing is persisted: `docker compose down` followed by `docker compose up -d --wait` gives you a fresh realm.

2. Start the API on port 8080:

```sh
./gradlew bootRun
```

3. Start the Angular client on port 4200, in another terminal:

```sh
cd angularclient
pnpm install
pnpm start
```

Open <http://localhost:4200>. Call both endpoints while logged out, then log in as `test` and call `/api/test/user` again. Log out, log in as `noaccess` and the same call returns `403 Forbidden`. The protected page (`/protected`) shows how an Angular route guard sends users to Keycloak before they can open a page.

## Call the API with curl

The public endpoint needs no token:

```sh
curl http://localhost:8080/api/test/anonymous
```
curl --location 'http://localhost:8080/api/test/anonymous' \
--header 'Authorization: Bearer {{$access_token}}'
```

The `user` endpoint needs an access token. The `demo-spa` client only allows the browser login flow, so take the token from the Angular client: log in, open the browser's developer console and run `sessionStorage.getItem('access_token')`. Then:

```sh
ACCESS_TOKEN='paste the access token here'

curl -i http://localhost:8080/api/test/user
curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8080/api/test/user
```
curl --location 'http://localhost:8080/api/test/user' \
--header 'Authorization: Bearer {{$access_token}}'

The first call returns `401`, the second `{"message":"Hello Secured with user role.","user":"test"}` (or `403` with the `noaccess` user's token). Access tokens expire after 5 minutes.

## Configuration

The API reads these settings from [`application.yaml`](./src/main/resources/application.yaml); the environment variables override them.

| Property | Environment variable | Default | Description |
| ------------------------------------------------------ | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `spring.security.oauth2.resourceserver.jwt.issuer-uri` | `KEYCLOAK_ISSUER_URI` | `http://localhost:8888/auth/realms/demo-realm` | URL of the Keycloak realm. On the first request the API reads the realm's signing keys from it. Tokens must have this exact `iss`. |
| `app.cors.allowed-origins` | `CORS_ALLOWED_ORIGINS` | `http://localhost:4200` | Comma-separated origins allowed to call the API from a browser |
| `server.port` | `SERVER_PORT` | `8080` | Port of the API |

For example: `KEYCLOAK_ISSUER_URI=https://keycloak.example.com/auth/realms/myrealm ./gradlew bootRun`.

The Angular client's settings (issuer, client ID and API URL) are in [`angularclient/src/environments/environment.ts`](./angularclient/src/environments/environment.ts).

## Use your own Keycloak realm

1. Create a realm role `user`.
2. Create an OpenID Connect client, for example `demo-spa`, with client authentication off and only the standard flow enabled. Set `http://localhost:4200/*` as valid redirect URI, and `+` as valid post logout redirect URI and as web origin. In the client's advanced settings, set the PKCE method to `S256`.
3. Create a user with a password, an email, a first name and a last name, and assign it the `user` role.
4. Point the API at the realm with `KEYCLOAK_ISSUER_URI`, and the Angular client with `issuer` and `clientId` in `environment.ts`.

## Build and test

```sh
./gradlew build
```

It compiles the API and runs its tests, none of which need Keycloak:

- [`TestControllerTests`](./src/test/java/com/example/springbootkeycloak/web/TestControllerTests.java) calls the endpoints through the security filter chain: public endpoint, `401` without a token, `403` without the `user` role, `200` with it, CORS for the Angular client.
- [`JwtClaimsConverterTests`](./src/test/java/com/example/springbootkeycloak/config/JwtClaimsConverterTests.java) checks the role mapping.

`./gradlew bootJar` builds `build/libs/spring-boot-keycloak-0.0.1-SNAPSHOT.jar`, which runs with `java -jar`. For the Angular client, see [its README](./angularclient/README.md).
17 changes: 0 additions & 17 deletions frameworks/spring-boot-keycloak/angularclient/.browserslistrc

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Editor configuration, see https://editorconfig.org
root = true

[*]
Expand All @@ -10,6 +9,7 @@ trim_trailing_whitespace = true

[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false

[*.md]
max_line_length = off
Expand Down
28 changes: 7 additions & 21 deletions frameworks/spring-boot-keycloak/angularclient/.gitignore
Original file line number Diff line number Diff line change
@@ -1,46 +1,32 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.

# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out

# dependencies
/node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log

# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json

# IDEs and editors
/.idea
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace

# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*

# misc
/.sass-cache
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
__screenshots__/

# System Files
.DS_Store
Thumbs.db
1 change: 1 addition & 0 deletions frameworks/spring-boot-keycloak/angularclient/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
5 changes: 5 additions & 0 deletions frameworks/spring-boot-keycloak/angularclient/.postcssrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm-lock.yaml
14 changes: 14 additions & 0 deletions frameworks/spring-boot-keycloak/angularclient/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"printWidth": 100,
"singleQuote": true,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/styles.css",
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
28 changes: 14 additions & 14 deletions frameworks/spring-boot-keycloak/angularclient/README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 11.2.4.
# Angular client

# NodeJS
The Angular single-page app of the [Spring Boot example](../README.md). It logs users in with Keycloak using the authorization code flow with PKCE, through [angular-oauth2-oidc](https://github.com/manfredsteyer/angular-oauth2-oidc), and calls the Spring Boot API with the access token.

This project runs using Node.js version v16 or newer.
- [`src/environments/environment.ts`](./src/environments/environment.ts): Keycloak issuer, client ID and API URL.
- [`src/app/app.config.ts`](./src/app/app.config.ts): configures angular-oauth2-oidc, sends the access token only to the API, and finishes the login when Keycloak redirects back.
- [`src/app/auth/auth.guard.ts`](./src/app/auth/auth.guard.ts): `authGuard` starts the login before opening a route that needs it (`/protected`).
- [`src/app/home/`](./src/app/home): login status, the Log in / Log out buttons, the API calls and the decoded tokens.

# npm package manager
Tokens are kept in session storage and refreshed before they expire. Logging out also ends the Keycloak session.

This project is build using npm package manager version 8.5.1
## Run it

## Install dependencies
Start Keycloak and the API first, as described in the [example's README](../README.md). Then:

Run `npm install` to add all dependencies
```sh
pnpm install
pnpm start
```

## Development server

Run `npm run ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.

## Build

Run `npm run ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
Open <http://localhost:4200>. `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` and `pnpm format` are also available.
Loading
Loading