From 43643e09a012c20c7d430efb2f94c86b37917ddd Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 11:48:34 -0300 Subject: [PATCH 1/8] Add shared CI workflows, local Keycloak and contributing guide --- .editorconfig | 15 ++ .github/dependabot.yml | 14 ++ .github/workflows/_gradle-ci.yml | 31 +++ .github/workflows/_node-ci.yml | 41 ++++ .github/workflows/_vercel-deploy.yml | 63 +++++ .gitignore | 25 +- .nvmrc | 1 + CONTRIBUTING.md | 62 +++++ README.md | 50 ++-- frameworks/nuxt/README.md | 8 + frameworks/reactjs/README.md | 7 + keycloak/README.md | 55 +++++ keycloak/docker-compose.yml | 39 +++ keycloak/realms/p2examples.json | 270 +++++++++++++++++++++ keycloak/seed/seed-orgs.mjs | 102 ++++++++ tools/e2e-smoke/.gitignore | 3 + tools/e2e-smoke/.nvmrc | 1 + tools/e2e-smoke/README.md | 17 ++ tools/e2e-smoke/package.json | 17 ++ tools/e2e-smoke/playwright.config.ts | 14 ++ tools/e2e-smoke/pnpm-lock.yaml | 57 +++++ tools/e2e-smoke/tests/login-logout.spec.ts | 23 ++ tools/e2e-smoke/tsconfig.json | 12 + 23 files changed, 907 insertions(+), 20 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/_gradle-ci.yml create mode 100644 .github/workflows/_node-ci.yml create mode 100644 .github/workflows/_vercel-deploy.yml create mode 100644 .nvmrc create mode 100644 CONTRIBUTING.md create mode 100644 frameworks/nuxt/README.md create mode 100644 frameworks/reactjs/README.md create mode 100644 keycloak/README.md create mode 100644 keycloak/docker-compose.yml create mode 100644 keycloak/realms/p2examples.json create mode 100644 keycloak/seed/seed-orgs.mjs create mode 100644 tools/e2e-smoke/.gitignore create mode 100644 tools/e2e-smoke/.nvmrc create mode 100644 tools/e2e-smoke/README.md create mode 100644 tools/e2e-smoke/package.json create mode 100644 tools/e2e-smoke/playwright.config.ts create mode 100644 tools/e2e-smoke/pnpm-lock.yaml create mode 100644 tools/e2e-smoke/tests/login-logout.spec.ts create mode 100644 tools/e2e-smoke/tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5870c34 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,java,gradle}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..62a48a6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: docker-compose + directory: /keycloak + schedule: + interval: monthly diff --git a/.github/workflows/_gradle-ci.yml b/.github/workflows/_gradle-ci.yml new file mode 100644 index 0000000..774af0c --- /dev/null +++ b/.github/workflows/_gradle-ci.yml @@ -0,0 +1,31 @@ +name: _gradle-ci + +on: + workflow_call: + inputs: + working-directory: + type: string + required: true + +permissions: + contents: read + +jobs: + build: + name: Gradle build + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "21" + + - uses: gradle/actions/setup-gradle@v6 + + - run: ./gradlew build diff --git a/.github/workflows/_node-ci.yml b/.github/workflows/_node-ci.yml new file mode 100644 index 0000000..b2a348e --- /dev/null +++ b/.github/workflows/_node-ci.yml @@ -0,0 +1,41 @@ +name: _node-ci + +on: + workflow_call: + inputs: + working-directory: + type: string + required: true + +permissions: + contents: read + +jobs: + ci: + name: Build and check + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + env: + NX_DAEMON: "false" + NX_NO_CLOUD: "true" + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + with: + package_json_file: ${{ inputs.working-directory }}/package.json + + - uses: actions/setup-node@v7 + with: + node-version-file: ${{ inputs.working-directory }}/.nvmrc + cache: pnpm + cache-dependency-path: ${{ inputs.working-directory }}/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + - run: pnpm run --if-present typecheck + - run: pnpm run --if-present lint + - run: pnpm run --if-present test + - run: pnpm run build diff --git a/.github/workflows/_vercel-deploy.yml b/.github/workflows/_vercel-deploy.yml new file mode 100644 index 0000000..73a4ba9 --- /dev/null +++ b/.github/workflows/_vercel-deploy.yml @@ -0,0 +1,63 @@ +name: _vercel-deploy + +on: + workflow_call: + inputs: + working-directory: + type: string + required: true + secrets: + VERCEL_ORG_ID: + required: false + VERCEL_PROJECT_ID: + required: false + VERCEL_TOKEN: + required: false + +permissions: + contents: read + +jobs: + deploy: + name: Deploy to Vercel + if: >- + github.repository == 'p2-inc/examples' && + github.actor != 'dependabot[bot]' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + DEPLOY_TARGET: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') && 'production' || 'preview' }} + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + with: + package_json_file: ${{ inputs.working-directory }}/package.json + + - uses: actions/setup-node@v7 + with: + node-version-file: ${{ inputs.working-directory }}/.nvmrc + cache: pnpm + cache-dependency-path: ${{ inputs.working-directory }}/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: npm install --global vercel@59 + + - name: Pull Vercel project settings + run: vercel pull --yes --environment="$DEPLOY_TARGET" --token="$VERCEL_TOKEN" + + - name: Build + run: vercel build ${{ env.DEPLOY_TARGET == 'production' && '--prod' || '' }} --token="$VERCEL_TOKEN" + + - name: Deploy + run: | + url=$(vercel deploy --prebuilt ${{ env.DEPLOY_TARGET == 'production' && '--prod' || '' }} --token="$VERCEL_TOKEN") + echo "Deployed to Vercel ($DEPLOY_TARGET): $url" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index fd2505d..cd7641c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,21 +4,38 @@ node_modules .pnp .pnp.js +.pnpm-store # testing coverage # production build +dist +.next +.nuxt +.output +.svelte-kit +.react-router +.angular +.nx/cache +.nx/workspace-data +*.tsbuildinfo +.vercel +__pycache__ +*.py[cod] +.venv +*.sqlite3 +.gradle # misc .DS_Store +.env .env.local -.env.development.local -.env.test.local -.env.production.local +.env.*.local .env.production npm-debug.log* yarn-debug.log* -yarn-error.log* \ No newline at end of file +yarn-error.log* +pnpm-debug.log* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dc30f84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +Examples are read, copied and pasted more than they are run, so they favour clarity over cleverness and follow the same conventions as Phase Two's own apps. + +## Structure + +- One standalone folder per example. Nothing is shared between examples at build time: each has its own lockfile, config and assets, so copying the folder is enough to run it. +- Keep folder paths stable. The tutorials on [phasetwo.io](https://phasetwo.io/blog) link to them. +- Each example has its own workflow in `.github/workflows`, which calls the shared `_node-ci.yml`, `_vercel-deploy.yml` or `_gradle-ci.yml`. Its `paths` filter lists the example folder and the workflow file itself. + +## JavaScript and TypeScript examples + +| Topic | Convention | +| --------------- | ----------------------------------------------------------------------------------------------------------- | +| Runtime | Node.js 24: `.nvmrc` and `"engines": { "node": "24.x" }` | +| Package manager | pnpm, pinned with `"packageManager"`. Only `pnpm-lock.yaml` is committed. | +| Language | TypeScript, `strict`, the framework's own tsconfig | +| Scripts | `dev`, `build`, `preview` or `start`, `typecheck`, `lint`, `format`, and `test` when there are tests | +| Styling | Tailwind CSS 4, configured in CSS (`@import "tailwindcss"` and an `@theme` block). No `tailwind.config.js`. | +| Lint and format | ESLint flat config from the framework's preset, Prettier with `prettier-plugin-tailwindcss` | +| Configuration | Public settings in env files; secrets only in the environment, never in git | + +Environment variables: + +- Vite SPAs commit a `.env` with the public settings of the hosted demo realm (`VITE_OIDC_ISSUER_URI`, `VITE_OIDC_CLIENT_ID`) and a `.env.local.sample` for the local Keycloak. Copy it to `.env.local`, which is ignored by git. +- Server-side examples commit only `.env.example`, pointing at the local Keycloak. + +Local ports, so examples match the Keycloak clients in [`keycloak/realms/p2examples.json`](./keycloak/realms/p2examples.json): + +| Port | Examples | +| ----------- | -------------------------------------------------------------------------- | +| 3000 | React, Vue, Nuxt, Next.js, React Router (Remix), SvelteKit | +| 4200 / 4201 | Angular, Spring Boot's Angular client, multitenant zoo / aquarium | +| 8000 | Django | +| 8080 | the local Keycloak, or the Spring Boot API (with its own Keycloak on 8888) | +| 8081 | SAML service provider | + +## Authentication + +- Use the authorization code flow with PKCE. Never ship a client secret in browser code. +- Log out through Keycloak so the SSO session ends, not only the local session. +- Keep tokens out of the browser in server-side examples; show decoded claims instead. +- Let the library refresh tokens; no `setInterval` refresh loops. + +## Look and feel + +All examples share the same page, so they are easy to compare and one smoke test covers them all: + +- the Phase Two background (`home-bg.webp`, `home-bg-mobile.webp`) and logo, and the `p2blue`, `p2gray`, `p2grad` and `p2dark` colours; +- a header linking to phasetwo.io and to the example's own folder on GitHub; +- the status line "Your current status is:" followed by "Not authenticated." or "Authenticated", with "Log in" and "Log out" buttons; +- decoded "Access token (decoded)" and "ID token (decoded)" panels; +- the Docs, Github, Blog and Contact footer. + +Icons are small inline SVG components in each example, so they inherit the text colour. No icon library. + +## Before opening a pull request + +- `pnpm install --frozen-lockfile`, `pnpm typecheck`, `pnpm lint`, `pnpm test` and `pnpm build` pass on Node.js 24 (or `./gradlew build`, or `python manage.py test`). +- Logging in and out works against the local Keycloak. For the JavaScript examples, run [`tools/e2e-smoke`](./tools/e2e-smoke). +- No secrets are committed. +- The pull request description has a **Docs drift** section listing the phasetwo.io tutorials and docs pages whose code snippets no longer match the example. diff --git a/README.md b/README.md index 4a94919..9d5a555 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,45 @@ # Phase Two Framework Examples -This is a repo for code examples showing how to integrate Keycloak with various frameworks. +This is a repo for code examples showing how to integrate Keycloak with various frameworks. Every example is a standalone project: copy its folder and it runs on its own. ## Frameworks -| Framework | Code | Live | Tutorial | -| ---------------------- | :-----------------------------------------: | :----------------------------------------------------------: | :---------------------------------------------------------------------------: | -| React (oidc-client-ts) | [🧑‍💻📁](./frameworks/reactjs/oidc-client-ts) | [👩‍💻🚀](https://phasetwo-react-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-reactjs/) | -| React (oidc-spa) | [🧑‍💻📁](./frameworks/reactjs/oidc-spa) | [👩‍💻🚀](https://phasetwo-react-oidcspa-example.vercel.app/) | [👩‍🏫](https://phasetwo.io/blog/keycloak-oidc-spa-phasetwo) | -| Next.js | [🧑‍💻📁](./frameworks/nextjs/) | [👩‍💻🚀](https://phasetwo-nextjs-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nextjs/) | -| Remix | [🧑‍💻📁](./frameworks/remix/) | [👩‍💻🚀](https://phasetwo-remix-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-management-and-sso-for-remix/) | -| Vue | [🧑‍💻📁](./frameworks/vue/) | [👩‍💻🚀](https://phasetwo-vue-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-vue/) | -| Nuxt (keycloak-js) | [🧑‍💻📁](./frameworks/nuxt/keycloak-js/) | [👩‍💻🚀](https://phasetwo-nuxt-keycloakjs-example.vercel.app/) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nuxt/) | -| Nuxt (oidc-client-ts) | [🧑‍💻📁](./frameworks/nuxt/oidc-client-ts/) | [👩‍💻🚀](https://phasetwo-nuxt-oidc-example.vercel.app/) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nuxt/) | -| Sveltekit | [🧑‍💻📁](./frameworks/sveltekit/) | [👩‍💻🚀](https://phasetwo-sveltekit-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-management-and-sso-for-sveltekit/) | -| Angular | [🧑‍💻📁](./frameworks/angular/) | [👩‍💻🚀](https://phasetwo-angular-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-management-and-sso-for-angular/) | -| Django | [🧑‍💻📁](./frameworks/django/) | 👩‍💻⚒️ | [👩‍🏫](https://phasetwo.io/blog/secure-django/) | -| SpringBoot + Angular | [🧑‍💻📁](./frameworks/spring-boot-keycloak/) | 👩‍💻⚒️ | [👩‍🏫](https://phasetwo.io/blog/secure-spring-boot/) | +| Framework | Code | Live | Tutorial | Local port | +| ------------------------------------ | :-------------------------------------------: | :----------------------------------------------------------: | :-----------------------------------------------------------------------------------------: | :---------: | +| React (oidc-client-ts) | [🧑‍💻📁](./frameworks/reactjs/oidc-client-ts) | [👩‍💻🚀](https://phasetwo-react-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-reactjs/) | 3000 | +| React (oidc-spa) | [🧑‍💻📁](./frameworks/reactjs/oidc-spa) | [👩‍💻🚀](https://phasetwo-react-oidcspa-example.vercel.app/) | [👩‍🏫](https://phasetwo.io/blog/keycloak-oidc-spa-phasetwo) | 3000 | +| React (oidc-spa tutorial starter) | [🧑‍💻📁](./frameworks/reactjs/oidc-spa-starter) | — | [👩‍🏫](https://phasetwo.io/blog/keycloak-oidc-spa-phasetwo) | 3000 | +| Next.js (NextAuth.js) | [🧑‍💻📁](./frameworks/nextjs/) | [👩‍💻🚀](https://phasetwo-nextjs-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nextjs/) | 3000 | +| Remix (remix-auth) | [🧑‍💻📁](./frameworks/remix/) | [👩‍💻🚀](https://phasetwo-remix-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-management-and-sso-for-remix/) | 3000 | +| Vue (oidc-client-ts) | [🧑‍💻📁](./frameworks/vue/) | [👩‍💻🚀](https://phasetwo-vue-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-vue/) | 3000 | +| Nuxt (keycloak-js) | [🧑‍💻📁](./frameworks/nuxt/keycloak-js/) | [👩‍💻🚀](https://phasetwo-nuxt-keycloakjs-example.vercel.app/) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nuxt/) | 3000 | +| Nuxt (oidc-client-ts) | [🧑‍💻📁](./frameworks/nuxt/oidc-client-ts/) | [👩‍💻🚀](https://phasetwo-nuxt-oidc-example.vercel.app/) | [👩‍🏫](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nuxt/) | 3000 | +| SvelteKit (Auth.js) | [🧑‍💻📁](./frameworks/sveltekit/) | [👩‍💻🚀](https://phasetwo-sveltekit-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-management-and-sso-for-sveltekit/) | 3000 | +| Angular (angular-oauth2-oidc) | [🧑‍💻📁](./frameworks/angular/) | [👩‍💻🚀](https://phasetwo-angular-example.vercel.app) | [👩‍🏫](https://phasetwo.io/blog/instant-user-management-and-sso-for-angular/) | 4200 | +| Django (mozilla-django-oidc) | [🧑‍💻📁](./frameworks/django/) | 👩‍💻⚒️ | [👩‍🏫](https://phasetwo.io/blog/secure-django/) | 8000 | +| Spring Boot + Angular | [🧑‍💻📁](./frameworks/spring-boot-keycloak/) | 👩‍💻⚒️ | [👩‍🏫](https://phasetwo.io/blog/secure-spring-boot/) | 8080 / 4200 | +| SAML IdP-initiated SSO (Spring Boot) | [🧑‍💻📁](./saml2/idp-initiated/) | 👩‍💻⚒️ | [👩‍🏫](https://phasetwo.io/blog/keycloak-saml-identity-provider-idp-initiated-flow-with-okta) | 8081 | ## Multi-tenant -[Demo Apps](./multitenant/README.md) to be used as a starting point for a multi-tenant setup. Uses `nx` to manage the apps and `oidc-spa`. +[Demo Apps](./multitenant/README.md) to be used as a starting point for a multi-tenant setup with [Phase Two Organizations](https://phasetwo.io/docs/organizations/). Uses `nx` to manage the apps and `oidc-spa`. Tutorial: [Implement Multi-Tenancy Applications with Keycloak Organizations](https://phasetwo.io/blog/multi-tenancy-with-keycloak-organizations). + +## Running an example + +Each example's README has its exact steps. They all need: + +- [Node.js 24](https://nodejs.org/) (see `.nvmrc`) and [pnpm](https://pnpm.io/), for the JavaScript examples. With Corepack (`corepack enable`), the pnpm version pinned in each `package.json` is used automatically. +- Python 3.13+ for Django, and a JDK for the Spring Boot examples (Gradle downloads JDK 21 if needed). +- A Keycloak to log in against, either: + - **the hosted demo realm** `https://app.phasetwo.io/auth/realms/p2examples`, which the live demos use, or + - **a local Phase Two Keycloak**: `docker compose -f keycloak/docker-compose.yml up -d --wait` starts one with a pre-configured `p2examples` realm and demo users. See [keycloak/README.md](./keycloak/README.md). Each example has a sample env file for it. + +To use your own [Phase Two](https://phasetwo.io) or Keycloak instance, create the client described in the example's README and point the example at your realm. ## Github Actions -This repo contains actions that deploy all frameworks. Feel free to use, disable, or remove as desired. +Every example has a workflow in [.github/workflows](./.github/workflows) that builds it on pull requests. The examples with a live demo are also deployed to Vercel: a preview deployment for pull requests and a production deployment for `main`. Deployments need the `VERCEL_ORG_ID`, `VERCEL_DEPLOYMENT_TOKEN` and `VERCEL__PROJECT_ID` secrets, and are skipped for forks and Dependabot pull requests. Feel free to use, disable, or remove as desired. + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the conventions the examples follow. diff --git a/frameworks/nuxt/README.md b/frameworks/nuxt/README.md new file mode 100644 index 0000000..0ccbabe --- /dev/null +++ b/frameworks/nuxt/README.md @@ -0,0 +1,8 @@ +# Nuxt examples + +| Example | Library | Live demo | +| ---------------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | +| [keycloak-js](./keycloak-js) | [keycloak-js](https://github.com/keycloak/keycloak-js) | https://phasetwo-nuxt-keycloakjs-example.vercel.app | +| [oidc-client-ts](./oidc-client-ts) | [oidc-client-ts](https://github.com/authts/oidc-client-ts) | https://phasetwo-nuxt-oidc-example.vercel.app | + +Both follow the tutorial [Securing Nuxt Apps with Keycloak](https://phasetwo.io/blog/instant-user-managemenet-and-sso-for-nuxt/). diff --git a/frameworks/reactjs/README.md b/frameworks/reactjs/README.md new file mode 100644 index 0000000..ed5d9fa --- /dev/null +++ b/frameworks/reactjs/README.md @@ -0,0 +1,7 @@ +# React examples + +| Example | Library | Live demo | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| [oidc-client-ts](./oidc-client-ts) | [react-oidc-context](https://github.com/authts/react-oidc-context) and [oidc-client-ts](https://github.com/authts/oidc-client-ts) | https://phasetwo-react-example.vercel.app | +| [oidc-spa](./oidc-spa) | [oidc-spa](https://www.oidc-spa.dev/) | https://phasetwo-react-oidcspa-example.vercel.app | +| [oidc-spa-starter](./oidc-spa-starter) | none yet: the starting point of the [oidc-spa tutorial](https://phasetwo.io/blog/keycloak-oidc-spa-phasetwo) | — | diff --git a/keycloak/README.md b/keycloak/README.md new file mode 100644 index 0000000..485a2f1 --- /dev/null +++ b/keycloak/README.md @@ -0,0 +1,55 @@ +# Local Phase Two Keycloak + +A throwaway [Phase Two Keycloak](https://github.com/p2-inc/phasetwo-containers) with a `p2examples` realm, so every example in this repo can be run end to end on your machine. The realm uses the same client IDs as the hosted demo realm (`https://app.phasetwo.io/auth/realms/p2examples`), so switching an example between the two only changes its issuer URL (and, for server-side examples, the client secret). + +## Start and stop + +Run these from the repository root. You need Docker with the Compose plugin; the standalone `docker-compose` command works the same way. + +```sh +docker compose -f keycloak/docker-compose.yml up -d --wait # Keycloak + realm +docker compose -f keycloak/docker-compose.yml --profile orgs up -d --wait # ...plus demo organizations (for /multitenant) +docker compose -f keycloak/docker-compose.yml down # stop; everything is reset +``` + +| What | Value | +| --------------------------------------- | ------------------------------------------------------- | +| Issuer (use in the examples' env files) | `http://localhost:8080/auth/realms/p2examples` | +| Admin console | http://localhost:8080/auth/admin — `admin` / `admin` | +| Demo users | `demo` / `demo`, `jane` / `jane`, `jacques` / `jacques` | + +Nothing is persisted: `down` followed by `up` gives you a fresh realm, re-imported from [`realms/p2examples.json`](./realms/p2examples.json). + +## Clients + +Every client allows its local redirect URIs, `+` as web origin (CORS for the redirect URIs' origins) and `+` as post-logout redirect URIs. Public clients require PKCE (S256). + +| Client ID | Type | Local redirect URIs | Used by | +| ----------------------------- | --------------- | ---------------------------------------------------- | -------------------------------- | +| `reactjs-example` | public | `http://localhost:3000/*` | `frameworks/reactjs/*` | +| `vue-example` | public | `http://localhost:3000/*` | `frameworks/vue` | +| `nuxt-example` | public | `http://localhost:3000/*` | `frameworks/nuxt/keycloak-js` | +| `nuxt-oidc-client-ts-example` | public | `http://localhost:3000/*` | `frameworks/nuxt/oidc-client-ts` | +| `angular` | public | `http://localhost:4200/*` | `frameworks/angular` | +| `zoo` | public | `http://localhost:4200/*` | `multitenant/apps/zoo` | +| `aquarium` | public | `http://localhost:4201/*` | `multitenant/apps/aquarium` | +| `nextjs` | confidential | `http://localhost:3000/*` | `frameworks/nextjs` | +| `remix` | confidential | `http://localhost:3000/*` | `frameworks/remix` | +| `sveltekit` | confidential | `http://localhost:3000/*` | `frameworks/sveltekit` | +| `django` | confidential | `http://localhost:8000/*`, `http://127.0.0.1:8000/*` | `frameworks/django` | +| `seed-cli` | service account | — | `seed/seed-orgs.mjs` | + +Confidential clients use the secret `-local-dev-secret` (for example `nextjs-local-dev-secret`). **These secrets, and the demo passwords, exist only for this local container.** Never reuse them anywhere else. + +The Spring Boot and SAML examples keep their own Keycloak setup (`frameworks/spring-boot-keycloak/docker-compose.yml` on port 8888, `saml2/idp-initiated`), because their tutorials use different realms. + +## Organizations (for `/multitenant`) + +The `orgs` profile runs [`seed/seed-orgs.mjs`](./seed/seed-orgs.mjs) once Keycloak is healthy. It uses the Phase Two Organizations API to create the setup from [Implement Multi-Tenancy Applications with Keycloak Organizations](https://phasetwo.io/blog/multi-tenancy-with-keycloak-organizations): + +| Organization | `jane` | `jacques` | +| ------------ | ----------------- | ---------- | +| `california` | `zoo` | `aquarium` | +| `newyork` | `zoo`, `aquarium` | `aquarium` | + +Both users are members of both organizations; the roles decide which app (zoo or aquarium) they can use in each tenant. The script can also run on the host: `KEYCLOAK_URL=http://localhost:8080/auth node keycloak/seed/seed-orgs.mjs`. diff --git a/keycloak/docker-compose.yml b/keycloak/docker-compose.yml new file mode 100644 index 0000000..9bb514a --- /dev/null +++ b/keycloak/docker-compose.yml @@ -0,0 +1,39 @@ +name: p2-examples-keycloak + +services: + keycloak: + image: quay.io/phasetwo/phasetwo-keycloak:26.6 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HTTP_RELATIVE_PATH: /auth + KC_HEALTH_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - ./realms:/opt/keycloak/data/import:ro + healthcheck: + test: + [ + "CMD", + "bash", + "-c", + "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && grep -q UP <&3", + ] + interval: 5s + timeout: 5s + retries: 60 + start_period: 20s + + seed-orgs: + profiles: ["orgs"] + image: node:24-alpine + depends_on: + keycloak: + condition: service_healthy + environment: + KEYCLOAK_URL: http://keycloak:8080/auth + volumes: + - ./seed:/seed:ro + command: ["node", "/seed/seed-orgs.mjs"] diff --git a/keycloak/realms/p2examples.json b/keycloak/realms/p2examples.json new file mode 100644 index 0000000..019874f --- /dev/null +++ b/keycloak/realms/p2examples.json @@ -0,0 +1,270 @@ +{ + "realm": "p2examples", + "displayName": "Phase Two Examples (local)", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "clients": [ + { + "clientId": "reactjs-example", + "name": "React examples (oidc-client-ts, oidc-spa)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "vue-example", + "name": "Vue example (oidc-client-ts)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "nuxt-example", + "name": "Nuxt example (keycloak-js)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "nuxt-oidc-client-ts-example", + "name": "Nuxt example (oidc-client-ts)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "angular", + "name": "Angular example (angular-oauth2-oidc)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:4200", + "baseUrl": "/", + "redirectUris": ["http://localhost:4200/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "zoo", + "name": "Multitenant example: zoo app (oidc-spa)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:4200", + "baseUrl": "/", + "redirectUris": ["http://localhost:4200/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "aquarium", + "name": "Multitenant example: aquarium app (oidc-spa)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:4201", + "baseUrl": "/", + "redirectUris": ["http://localhost:4201/*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "nextjs", + "name": "Next.js example (next-auth)", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "nextjs-local-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "remix", + "name": "React Router example, formerly Remix (remix-auth)", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "remix-local-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "sveltekit", + "name": "SvelteKit example (Auth.js)", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "sveltekit-local-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:3000", + "baseUrl": "/", + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["+"], + "attributes": { + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "django", + "name": "Django example (mozilla-django-oidc)", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "django-local-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:8000", + "baseUrl": "/", + "redirectUris": ["http://localhost:8000/*", "http://127.0.0.1:8000/*"], + "webOrigins": ["+"], + "attributes": { + "post.logout.redirect.uris": "+" + } + }, + { + "clientId": "seed-cli", + "name": "Organization seed for the multitenant example", + "enabled": true, + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "seed-cli-local-dev-secret", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": true + } + ], + "users": [ + { + "username": "demo", + "email": "demo@example.com", + "firstName": "Demo", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "demo", + "temporary": false + } + ] + }, + { + "username": "jane", + "email": "jane@example.com", + "firstName": "Jane", + "lastName": "Goodall", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "jane", + "temporary": false + } + ] + }, + { + "username": "jacques", + "email": "jacques@example.com", + "firstName": "Jacques", + "lastName": "Cousteau", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "jacques", + "temporary": false + } + ] + }, + { + "username": "service-account-seed-cli", + "enabled": true, + "serviceAccountClientId": "seed-cli", + "clientRoles": { + "realm-management": ["realm-admin"] + } + } + ] +} diff --git a/keycloak/seed/seed-orgs.mjs b/keycloak/seed/seed-orgs.mjs new file mode 100644 index 0000000..74bba2c --- /dev/null +++ b/keycloak/seed/seed-orgs.mjs @@ -0,0 +1,102 @@ +const KEYCLOAK_URL = process.env.KEYCLOAK_URL ?? "http://localhost:8080/auth"; +const REALM = "p2examples"; +const CLIENT_ID = "seed-cli"; +const CLIENT_SECRET = "seed-cli-local-dev-secret"; + +const organizations = [ + { + name: "california", + displayName: "California", + roles: ["zoo", "aquarium"], + members: { jane: ["zoo"], jacques: ["aquarium"] }, + }, + { + name: "newyork", + displayName: "New York", + roles: ["zoo", "aquarium"], + members: { jane: ["zoo", "aquarium"], jacques: ["aquarium"] }, + }, +]; + +const base = `${KEYCLOAK_URL}/realms/${REALM}`; + +async function getToken() { + const response = await fetch(`${base}/protocol/openid-connect/token`, { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new Error( + `Token request failed: ${response.status} ${await response.text()}`, + ); + } + return (await response.json()).access_token; +} + +const token = await getToken(); + +async function api(method, url, body) { + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok && response.status !== 409) { + throw new Error( + `${method} ${url} failed: ${response.status} ${await response.text()}`, + ); + } + return response; +} + +async function findUserId(username) { + const response = await api( + "GET", + `${KEYCLOAK_URL}/admin/realms/${REALM}/users?exact=true&username=${encodeURIComponent(username)}`, + ); + const [user] = await response.json(); + if (!user) throw new Error(`User "${username}" not found in realm ${REALM}`); + return user.id; +} + +async function findOrCreateOrganization({ name, displayName }) { + const search = await api( + "GET", + `${base}/orgs?search=${encodeURIComponent(name)}`, + ); + const existing = (await search.json()).find((org) => org.name === name); + if (existing) return existing.id; + + const created = await api("POST", `${base}/orgs`, { name, displayName }); + const location = created.headers.get("Location"); + if (!location) + throw new Error( + `Organization "${name}" was created without a Location header`, + ); + return location.split("/").pop(); +} + +for (const org of organizations) { + const orgId = await findOrCreateOrganization(org); + + for (const role of org.roles) { + await api("POST", `${base}/orgs/${orgId}/roles`, { name: role }); + } + + for (const [username, roles] of Object.entries(org.members)) { + const userId = await findUserId(username); + await api("PUT", `${base}/orgs/${orgId}/members/${userId}`); + for (const role of roles) { + await api("PUT", `${base}/orgs/${orgId}/roles/${role}/users/${userId}`); + } + } + + console.log(`Seeded organization "${org.name}" (${orgId})`); +} diff --git a/tools/e2e-smoke/.gitignore b/tools/e2e-smoke/.gitignore new file mode 100644 index 0000000..4569578 --- /dev/null +++ b/tools/e2e-smoke/.gitignore @@ -0,0 +1,3 @@ +node_modules +test-results +playwright-report diff --git a/tools/e2e-smoke/.nvmrc b/tools/e2e-smoke/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/tools/e2e-smoke/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/tools/e2e-smoke/README.md b/tools/e2e-smoke/README.md new file mode 100644 index 0000000..022eb9a --- /dev/null +++ b/tools/e2e-smoke/README.md @@ -0,0 +1,17 @@ +# e2e smoke test + +A single Playwright test that logs in and out of any example through Keycloak. It relies on the shared UI of the examples: the "Not authenticated." / "Authenticated" status, the "Log in" / "Log out" buttons and a decoded token panel. + +```sh +pnpm install +pnpm install-browser # first time only: downloads Chromium + +# with the local Keycloak running and an example started on its dev port +APP_URL=http://localhost:3000 KC_USERNAME=demo KC_PASSWORD=demo pnpm test +``` + +| Variable | Default | +| ------------- | ----------------------- | +| `APP_URL` | `http://localhost:3000` | +| `KC_USERNAME` | `demo` | +| `KC_PASSWORD` | `demo` | diff --git a/tools/e2e-smoke/package.json b/tools/e2e-smoke/package.json new file mode 100644 index 0000000..d6fb831 --- /dev/null +++ b/tools/e2e-smoke/package.json @@ -0,0 +1,17 @@ +{ + "name": "e2e-smoke", + "private": true, + "type": "module", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": "24.x" + }, + "scripts": { + "test": "playwright test", + "install-browser": "playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/node": "^24.13.3" + } +} diff --git a/tools/e2e-smoke/playwright.config.ts b/tools/e2e-smoke/playwright.config.ts new file mode 100644 index 0000000..03e0ff5 --- /dev/null +++ b/tools/e2e-smoke/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 60_000, + expect: { timeout: 15_000 }, + retries: 0, + reporter: [["list"]], + use: { + baseURL: process.env.APP_URL ?? "http://localhost:3000", + trace: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/tools/e2e-smoke/pnpm-lock.yaml b/tools/e2e-smoke/pnpm-lock.yaml new file mode 100644 index 0000000..8f4a124 --- /dev/null +++ b/tools/e2e-smoke/pnpm-lock.yaml @@ -0,0 +1,57 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 + '@types/node': + specifier: ^24.13.3 + version: 24.13.6 + +packages: + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@types/node@24.13.6': + resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + +snapshots: + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@types/node@24.13.6': + dependencies: + undici-types: 7.18.2 + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + undici-types@7.18.2: {} diff --git a/tools/e2e-smoke/tests/login-logout.spec.ts b/tools/e2e-smoke/tests/login-logout.spec.ts new file mode 100644 index 0000000..290370c --- /dev/null +++ b/tools/e2e-smoke/tests/login-logout.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +const username = process.env.KC_USERNAME ?? "demo"; +const password = process.env.KC_PASSWORD ?? "demo"; + +test("logs in and out through Keycloak", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Not authenticated.")).toBeVisible(); + + await page.getByRole("button", { name: "Log in" }).click(); + + await page.locator("#username").fill(username); + await page.locator("#password").fill(password); + await page.locator("#kc-login").click(); + + await expect(page.getByText("Authenticated", { exact: true })).toBeVisible(); + await expect( + page.getByLabel(/token \(decoded\)|claims/i).first(), + ).toHaveValue(/"iss"/); + + await page.getByRole("button", { name: "Log out" }).click(); + await expect(page.getByText("Not authenticated.")).toBeVisible(); +}); diff --git a/tools/e2e-smoke/tsconfig.json b/tools/e2e-smoke/tsconfig.json new file mode 100644 index 0000000..45bb481 --- /dev/null +++ b/tools/e2e-smoke/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests"] +} From b62541b8febf5a682a044c64b35cddb225f75c33 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:02:28 -0300 Subject: [PATCH 2/8] Upgrade SAML example to Spring Boot 4.1, Java 21 and Gradle 9.7.1 --- saml2/idp-initiated/build.gradle | 42 ++-- .../gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 47505 bytes .../gradle/wrapper/gradle-wrapper.properties | 4 +- saml2/idp-initiated/gradlew | 18 +- saml2/idp-initiated/gradlew.bat | 36 ++-- saml2/idp-initiated/settings.gradle | 4 + .../IdpInitiatedApplication.java | 6 +- .../idp_initiated/SecurityConfiguration.java | 22 +++ .../controllers/RootController.java | 15 +- .../src/main/resources/application.yaml | 18 +- .../src/main/resources/templates/index.html | 186 ++++++++++++++---- .../IdpInitiatedApplicationTests.java | 82 +++++++- .../src/test/resources/application.yaml | 17 ++ .../test/resources/test-realm-descriptor.xml | 25 +++ 14 files changed, 356 insertions(+), 119 deletions(-) create mode 100644 saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/SecurityConfiguration.java create mode 100644 saml2/idp-initiated/src/test/resources/application.yaml create mode 100644 saml2/idp-initiated/src/test/resources/test-realm-descriptor.xml diff --git a/saml2/idp-initiated/build.gradle b/saml2/idp-initiated/build.gradle index bfbcad0..884c25b 100644 --- a/saml2/idp-initiated/build.gradle +++ b/saml2/idp-initiated/build.gradle @@ -1,38 +1,36 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.3' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '4.1.1' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.saml2' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } repositories { - mavenCentral() - maven { url "https://build.shibboleth.net/nexus/content/repositories/releases/" } + mavenCentral() + maven { url = 'https://build.shibboleth.net/maven/releases' } } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' - constraints { - implementation "org.opensaml:opensaml-saml-api:5.1.3" - implementation "org.opensaml:opensaml-saml-impl:5.1.3" - } - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.security:spring-security-saml2-service-provider' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security-saml2' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + testImplementation 'org.springframework.boot:spring-boot-starter-security-saml2-test' + testImplementation 'org.springframework.boot:spring-boot-starter-security-test' + testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() -} \ No newline at end of file + useJUnitPlatform() +} diff --git a/saml2/idp-initiated/gradle/wrapper/gradle-wrapper.jar b/saml2/idp-initiated/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 100644 GIT binary patch delta 39486 zcmX7vQ+Qon*M`&Bwr!h@ZQHhu-Dn4m+1O~DG`5W$+qTi9N&dXwf3mK1u#VPzo`HLe z*|Pwicn@A@j|^a%nt~5yMTOk)C%sO)Z9*o zFwOf0t|AWuYd15YmkI_3)&`b5i$$2ch%5|HQj{MMLCtZr*EbiU^|@`OX;O4AAJUOb zC@jm>yXn4CnrrIFs*{Z-#r`Lo3yJap21w!kCTYFX_x5WfAgBuf)=PK6?z8G}bc@l0 z;}))T3zrT@;*zmyK%jay8P2UP(=HuaD8imz2z!WrSW)iKM-#;gh3u0H)H*1r5ts&G zR6E|IhMm&|s$l{CN1ICIz-+m%f)K*LD?={YHUSS!I9 z@fJGi_W~n=#ceEoV7}ql|AiE%oPKn}V{jt9qk!y5gK-{+N0j;4_C=!nq-WC6erIY=9*xw$-- z9#yPwfKBZ~qb^GX8Xf;l%HIlyDVDQ)-O34#bO8g&Xei>%NUBJJrwedNRsK$uZDgU) z;(Og57B@d2-5sN`PGJ!KdIEKrduj@CGB7bMWN$Y#;GxxUCA1`Ql4L#hz{O{NpR_Y5 zLQx>zHcgeGXc~y|NBwNABFB=-!PT5~-{6(n2jp@yL-p@-$T??F42x)QZ0=}c!cj3#zGJTdHPq8gsJMZ+nSCQ1hb-3#w};k?&7Q13AxP`%$K zD~URO?=aW9&RgJ4=3FCbTCoz~d&FyCpK`EyXhXc#PIR_mZ^akz{KIEp>Y(l{4?)Z* z0Dy4f)g?;R2E|$m4JCOlGeCc6%e)?D^$IVj&?9F*eB8|LhrYe`n7Z79iNE1~gfYCe zz6$qvMY;mV56Gesui4l|XNiC$zl=U&cKdNA9Rqk@9}~;@S(WXviKKC*BSMtIV|txq z3Kbq9;YNzc%+R=>aQ;&v)zY^bH*hcruxyC`Irf#7Dp?7G2w?c19);%9m<{C$$X}{k zLz+u0NfLB=e`%oS;j7m~W^PSf6xKHKV-!eE9(8qudJ8>lz?DQ)Q=RaX&!y=~LM%!eEXADK#c zICMBvV!1y+iyPW~*pf<>%U5GU`?tcD8>_|8RIkRo<}G)m#L%X`{zRpH|C5lgn8qtNY`8j@ZGBz0{{9Ar z1pNrta_Np#r@M`zC4-bOOnBtEF%SqG)O%B8gWwm^oyy2 zT;(6OnV!uH#zhigSx`#UeF+(xG4#kiqO7iW(y_o!_j^%Th8f(;1O09I)ql9Pg*1*; z=D8g0)9558RJOV&JZxj&os>tdV#}og@ z^PkLOtLQ#u!NI_?l7lITlDA2zl4KNLXLCmBCOZn&`mDOBM>JYMNHfXSe zJiRy-yCo(4VXnZoRfD9|DJLs$3NL=oQsJWQH;9FLR&%Qb=joTCg+Cp$;G#<`Ig?jM zxo_F-dHz@K(}B+)hYVopw+I8q%DMkC-^A%$9o2tXTFT9Gr^FQMHnlssXtyXd0jyG? zRvk@YuV6E)+@u#P#MV9Vxui4Jc7PP@a$SW>K8A~3xXG4mi45`ROVfdO+cvn+c*Jn) z1?3i_u`#hRadZHwUfcuAN>ls2W+zyn>qLzO12+o+c_p5o*HXn|t1k0Nsm@+A#l>LE zN}Yq)dR)f_ZPO8z2b)6JEAvtpz){iJwZl@^tBrxugTtdkb5qv9LIvK`^e(KsFst1^ zc6VBZH$b?bC*6DZZh%2m0yG~=5h+EsUycYjg6qeWjJxL4n`P=)ev=@)ryH7;deLBi zZbh~2goj+RAU9Y;2+CckeSp_WL^YQQBFe*ZR)%1IWgA6B(U6ybIj(TgQyxO;Zzl)qjx)#D6FUENsl4U#?Y;SO-)uh5voKbI?}D zw6*Y)5v331JqZ(|2t%*|w0MNvPmRk2pV7QqZrgUumBY<0L}w5zZ5h|&ovPYrt~VDaS-!!?9$XjMV zHHP{1_3RQUg5*Dikx(Cc=s8ie_Hy}`5ivsE)p5Emt4yKYi{A_XUJXxPCCzlm^U$wj;R`>E~=HyK>k1O3D;tu(NO(zN;*oV+j0O8*W-$ ziFKT;aoV3oJ~HBQT^_+%P{a>k`vC zJbErzuRQnbum6R}vGji?)NsL4w1fZy1HpiSsmMb>CNE=QC+AX7C1>VQ0aCSey)hT@ zH)&2)WYx)Yz|f)S^jT$5qM}7$L&GAYPbqB@R&c}RmSxLiJT>(-b5LP0k9Ds-CAv0b zcahKTwiZtrEt#$Z@A|yoAzpV-T$m!lhp#m>Be;o>d{1iBj{~gp-Y=bbAOFte1;Fn> z2aLMh4Mqv@lSIaqXSh1q0cLA#tT;0xt*Q)+cr>(ICM~Atc&FAkb}9yJT@^f>3tQ zyy5w1*0?J0k(DLxS1pvLXgq`K4fogio2D%6z0;oKu@OV5sar2m=@mVioo%-}sa{ot zaK(K<^ecElf1F%^j%hiUqiYpbH*qlq-VZu$*>-kxn+Gr&>$^|G0HgU#PMEupJYkNL z3g&cFv(}*5DTE9CVN(6Ahx{*Fr4>+kCh~p&i4Z3Oza?qwI|=O4a>*eGXz=@EfK&IsBzc{{e=Kllxpi-?iT8kMAQCx%Ys z@YtErC?tpB4d|s*$E7ANn*W3o@)!cUs=Mugc2)x5E$eCc;FfGTlm_B_<s3A547(2b^ZM{DO~oS4-w!?}2e>n>0&B<sW7fjSNa zwQIA;&!=fO*e9Zy8<8RcJm#$H*{u*Kn~gNk6lm3vUNGIeETti7y!edPcRdrtRYO;l zRt*`R&UpfW9jetWDIpfR|Hmx7Cf^undNDLbnVmVKo@F7!OsTqf^#h+TfZ+)tavj%B zZ|yuWGlDf;t0w})f(ng$W>~pVqSd73Ot!x68c|1tXdZ9U)oyK?{yS-@i8gbHXsFR( zNOPpEFw=bwZKi{EN}{^5d3`3n0}~Be*82%As_Oz!P}NE>K+bSwy3%fg{csv5j`^NK z)0qEGT#yTx%{gA45|NNb+NYI@v`gz~sZio`4uf1EOtaq@Tk!*B6X49*W9q7EXrPW! zH3`Fwob;czq~$t7OO{AxENrrsgczmkkSE0u`_2|eF7aNRJ*h5AP^pyJ<)0D3{_FG( ze$WGS>S|-TEs6#g#K0*4ReN3S_~U|v(_MQ`$n9^z2Dorw$af2%vwIH@wT>R0dpw2v zL5td~2040@*{yNxV~#oZY(!Szu}}3 z2)u}#OuuCWrtjkkN`GW>vh_TL5R>jhbA<@8Xj-J1}AYX)BEL4C(s_#Hjm3WZ#6lT4X<={Ns?# zZD|uPL{3TnpihH;A>iW6IRj#^1z#?C9g5Nmc^-mF1BCspKH3*RdWGJ ze14X;7&dmqq}?BZ^K}5ZOtFAUE46Y?AkOYx4_uMGFB?Y;ht3 zEq!_Kepgc@f=UV*xT=!C&oKAZW>iFf zOcJBh!y$j1`hinW_9iSYPEIEgU4l`ZTx(*?m>5Tc?{103WIO~R9{a3$B7379WmC=4 zkJfriL8VZl{_z{-ihj@3uSx)bUub}A!<8!-YH2;Ig;hI(9bJL)pkcgcE;v)3ow?8H zm$Cb~@C+wN=XP*$vHbn1lBzQ?8Yb=d+Ba28Hs-e_D|zD`PJ0kLzXoqwW(Z z1@9qh1mjteg8HRi(^X!G;aI{V>PzW&x#vD0OAfU%PfPb`RRWb%>+*`~x@8vsaxWg+ zHVBG8{zcL}Zo&L_P3sAez|fD-bbv5yZt-tzB3@oI{>pYRu(RT%_8@35Fl*$mMn^Om zIiC`clYFf_FM>PL)6&N;N3)KJ1t`F3d>e}Sp}>ky_G%a63QckCbm^$Ec0T7tIV`jH zxsM?0LEU|Waud(~ohj#=cnC=Ts%ClS`OV;~?p(em5&p)QKYnBDe66`3x^;9Emyp(7 zl||H5k(ImthlqGBx2i3d%4QQMZYMZDeq9hyp0VD)0r%>Z&*I&R5m~WyzGSdfxtcg4 zF3-qS3KdwA0 z&d~VYAYH!FR{l2WE%?UTXhqay;!28TZCAZ#9p0A~Glo?|d4U))ihLm9)(i(2eiQ{* z3;hv6s3r3`n&$QL%XEzQr>Jc*nPUn!S2;+7cGPy%%mK0Jm&s_yzd(J|(+GkwZkpI5 zf$oWOEtV8i9Sj)W|Y$daHMn<4Og^uJhxmyvmVcw zceZacX_aqf+H|c_hr6<3{&BFs1+VY`pJ?wc21q=t)qDI-cC8_ms!5(eSLGUix0m(G z5z9vp(>+HuwkJa}aU^e6f}u|N2(;^<@&IZkU?NvL79z%Mzu&ea!Y7xr05}xCSNIim z5uWC^DJk0;v+P&)u2UIRh=sfLYhLZYz<)ss+T}B#U(u;hm>5C)yQy-w{b1@cdGWoB zKspLI!EjD4QtglEe_$>2i{G38q7Rs?p{tNt3W6i#RqQ|5B;-IJMMX_hDByHRng2c%;9vc6f$j7IbqFAjfQ|SE50*>sI0ii z%0;*Sy~VIy*U2+8>S|?ltpB3)!S4$3nkj(NWwHY77`@@2Biz~ud@w})=J7fkr;k{Tc1M-9JsVCBj5X~or&I?-vksyA_P&v4EMEI`=?6k2Q%ZDvR*;>8mwGa6?9N|jU6TP18%79U zVG}Dmi6SYwCg@~|2|~>NMuMt|a4A=1eFOR24N5dkTvA0|>k^K6gS7kcXYe#wdIUlG zM4(^i0YxQ)(H2JOmqRj?SCuqFzs&QAh;6qjuHMYjA#u53N>}&rRB9PR*=? z*O-+gDik1zrZ4mh0#QxZL~acdTCua-Z*@+-jhqvJIrNf;mW_=3*%I;&_YPe~BgHQMS#!nnLVJaOob#$+qoB6C zpg1aXIFk=4?aFa*>2o+B2oo_|Wb?{Z0M&Dmhb!VO6YJ5(B=U{PJlW0CUT>RVh5Orx z81zEw4=wY``nHm8`A{~CwJnjey(mir=&59@`IL=1N%fZMqncyh4X!6fZNX!9RJXwG z`0^%k+cqnBIN*?Y&}0_3_ta6)v&xAnyeyD6om$-V+S|pTVITqUAjLH5YtyFn5?Nr7 zF_k4*0khDoCZytmwu-3MT1*-n4rS0=7&5uSz*;QsY|vMy@q5}Fb-t04`BO5`uYiG4 z2GtD@y0)rrrcN+I@3AQa%V1)MzJ?>^BUYGLrCgJDslf#Zjc)(c4tk0jz1fzBJtN5qU zWD`2SIJP?(*}J>MIEpYd>F^8a%kjv0aJMF%(BI|G~6%y>i$2@b!89*C@O=0`L`kfmi)!@q2cxi^a=zzS`18V zZX`Y%ai^zuL=(tS)|-q?3Il*ngYmxsjH_2HJY19_|GaPD{aVJ~mjHj5AAu`oH+TO6 zHUk<7uaH%IwIlg z?x+p_rUCO!+en(WbB|i$&;4aPT0AR+Jfd9S_oL>6@fMbx@9nD4bBJ@&Vo@i*`pkTv zy`UQEvO^IMB8hKcVCXg_(g#{)paJ@a(|J-n>|%c}HwB=fs;zvy-F@8I1wPYmn0Pg1 zvP6w45vqf7`7@qNKEti4eL!UrvtxeV1VJ@ z!lf_%Wgu>-_DIxJewM4=;y|pEtM4W#u3`YM?_#Xtmi|Ts2-H4#3mUjxEFp4wD=c4{ zN*Xw)VmQ#@926V+RivlOH*n4AkOdAS_BtpREa%%T$Wt4131Ked=!5k8=W!YnF1C=Z zUFiXkiF32LQFF$%)n0>1yK`?2$L+Pc)Y}~!i{)( zfx)F52-?sJ5b0{mj`$xuUv{|4T$3VYS}tDr5fp5x!ondfC`BMF>X)ZegC%H*+V+Xp zfnlH+5R`mye3u13*P1Jajt51D_&^a{1rD_=<}4vf@+$5eL*^L01NE$3yy_B0AaUae z@6XIOZlV|fuhs85d32|pV?OTwyV&Wc2<@*yiD-VnR$M>MX09jhcq)OSXOgAVbW^L*G$oD zVp4s;ir*)OIL8PsGqHK2N6ak=rWMHMl8T^+CzPOd75uB^YY@7<7LfoTU{o8FrpjX~ zxg3|L=Jszkd(0d@9Ps&(ok7K5H8*GC2Cfde6huXkD5>}@55CN{RdHe=CkV?ptioa< zK#@J6{HH%dObs*ZufWg>`Ty__6D&EJ0yEhNl{lF+4>~!H6$KEesqc-UiTQyno52ZL zK!+15IR{JKRjdl%+_)h|t7e-lhrp#0Q^rv?1{zcNl>yRXhJS?#z#tCX9ct?QK{u{< z@`vU7Uxa@|de0f0CED#;G{+Zzr(2#sp1%8k{JU;R2aDTf0Kz<98(;NcWF4JVu{N!U zDC9(Wj%ZXG%pw7<&eAd%^)pF3m=ODjL|NoFWGko&rui2eFbIC_0x8?f7&2ZbO-D@w5KU~p2M#b7Gp%TiD$EGA9r5e*oxaAUf>TfSMKS~FvWB< zJ7L3hL?4rT%N2E!JLH}1HcMo&4vXc1}CTZ<(Mp2DsVtURg(7CDPzPrAuw&i25lTVE#7VzBJ46XLb-y$ zh{Kvi6blZ5(th!~dsn#8IR09^>pEwswAhuhmIk{-d}~RN?Gr$h>tZMu85LXAK}A^Ty4rvNjij+0)%;u^@&45;?cuVFpy7H?8WKQDEcWn{fDB+}@j zfG$CoeUNcg*O<2eyRNm(Zs&pOS_qDta9sXkTXXz1QZj>{gd~usTMvo2mRECBb$EW- z<{VT`@jvx7O)JbWWTnm{WRfs&6Os^}3jeap^!>)-QG&I$5LMo16u(IAFdaaZbSl0y z12i!zc-t6ZH3b#NB8(O9TXB$C19%h| z{HpU6#!oa*uJk;0>K$@~?vVvP zHW^Iwh50hdY#ehQ(`$x2I=h{>EZ8wE8g3+q?tX8Z1o1?h(!{EGrXAge{euNzdaoi00ksykiOE?(cw0A5-}Pn(%Z}3<{nytacBlW5_p{W| z0Bq6x1ps{?d8D7j_N~8xE$=9KDg8$TMqt`TMML9X`7EVz(_9R`y+V9 zKLo_XGV%|xmjTHD4RJ4aLFzm%1;)Mc>nsZD7O?|@$a8zt@PMGkbG|`xxfzoMlX#E! zXg;7cFG#jh01>dGlh&`~wR|&lkX!<22NVxB?|u`S5&Mgy7{gSsET10jf@tGxEGE!Q zXQSoM;{DAwYqFA;SHy z3O7Q93X)2d^p*6bM>&l$c) zIq#3xBtej@F=dFxLjK&sPn3=1*4(W%VZ-7Xiz=KcY;L}qfMmx-kU5|Dt_4rqu;tJB zc>4ydiK^R4pysGh3m zp_M;-3rT1|N1_~0-Yu#wb; zbJ;m#_h8rS9jHVgk0-O5$3iOTve$$T2O4RaZq^2N%_NHu+$!-}pj16)O(?GBc*p+= z8oB^=0FQKwFdY#pTpTjd?pAm@#uP_3Dh@>ffTpM`P{P!#r$x7~2?b-9 zpS*p#h5G6U;qC-;BRGp zDt3v~*3oR1Z|+)7&z$mHbeC6lX6AGqFoUV7d^aJ(n`897_cujjSu)H?WI+5hMK;ze z-&_a7LoP)&j$h=%&$kn0DP*?M8>E_Kw~SpXU_5JPu265PP)xEoy_Os82XR~*ag^&d zF@HzrY+z^4L+8tgcUS?k^2sWMG8)_h8go<{(TA8yKkaWxz2UuUgxBZ9d-Bu-0QD** zbAtZ#@&Xvj*y;^h={yHa>!~8)9`b<0!jR9YK_epBU#5WF-mbvnwDOnUzygWEqGCVv znV&u3&$lMeSj`CF`UHaLr5O-4kxs{1VPW_0${zeL_^|OWjZyYjX8NS~os`b=rv4{E z!9y8B29${#;wVwN-}rK(hvyP?0Q`gq0Ukf%i8%8j%Zh}U?V0KO&dL%tA&#Q{@+XO# z+A(4E8>1s|^yGi<^bd2;D>jZ3L@qzKyJ?x7Olpb zLee?_eEXSeaT15V9I^6yV6G;)NPz+H;Z4H*OdJXCD)}Y6J*kdhL1s$UiP@YdI9Kh0ZwCC64 z&khpBSWPM`9>Cop(6g7qBEJ#{$+x-GmJ}-o*32O|wjQ7<*TTl^Cy$^rUa0D@s*;y~ z?(@`J7sRN+L1F$}kxC_9p0_TjAW-i~vMFvtB0uLeJZ~2WD?n4RN7P<@?!Beu4LNP66__)GS_G|E`L57*4Mx)~hRCT*S~m6j)WDr?o;<3PwFPp&c3n)tJ37fRz&1;ukRJ4+M^1viQg5BFDM%sX&DXV!o=}}I1^2vO)Xaf$rZKwg z1W9xh@Xm_c*v~%DUV=_ti!8|0)ts=L03S+>pP}odr==m9UBHA&{Oo)3%1(`E#VHtS ztzSnZa6;F8XvISW(8Xu$V# z&BuD19jRkXbfk2r1rigMQtJ#-=>|icO6)fLag#g#8trbzPfG?W)RKyqkbj^=G86zY zbQYucd1VU-(OIh$8LJFJBg6i(H_TmOjgxv1Sn4?l7#|Y8TK@ayhs=VFlj?)5>h+Hk zV3b_TPk|!W{JTSch_8ajHR?!pAb+7{*VB&EHu9A|)_af~NgS!UbFA(W&1$>8iVUqzHf(i* zcUuekklH8_ud*!d4RA5X^EeH*Zz8>b;vPkQ&}cc+id0yJ-dR z&pmg?vBDbs`|DqPcy(UV`Kr&1x$T6!$L*pCef1TumJj>8b36maNt0p#h^-2zjLfdi zu9Y*EZM+Me4wy91PGI{_R4B#TLM0G2^%#La0@U!~OsPq!lT)!>l^=WM0q*G#)s01{ zZBupJ!}ivMsEEXLgY&fbd%B@p?GGBGSa146OZbH?xs55$?4A8Eo?EBn)GHLjO@asg zFx#vRI${GEWYr=~&%h(4|4SWE`C+D9eA{e_M`U5^i1zsi@a@0q96{gY9Pz6<=Y~y2 z6SfDaDk@`&q6SU{AuFwjy6=H{u7$Kmo{gt&1k^Hw+%21zEercao{#_$T zDvf_;*p?V$KMhevGZ*5f}&ssvSkyA2jJv%q%SruiLEQV4WR;Iy}M_O3z zOzQ9KTpoxwxkPtq_GCC+6vyCH4!-n)#K{1hoJ4vd52$t)&(aBP@ao6dbA^~#5E+`sFQ|AaLwx$ z1=nBy*HP(!m-*&>H$2ZG8Js-#>FhLfr0I$ch z*{6v_G)bX}SchVY!Xxr(w8cprPQ!Yx`N-obMw+%E&l*fVx{(w!1ET%2RWmx{7>@(Y zu2Fr8@L3-txx_I_d<00$?$=v-bWx^VE8cBb4zV700hoD*;d@31oogk{b- zE9?fWYz{j_BwjD(U<;dfKl`>m7c<+1$RL$cSc;6*mdfcyJ#ZJ8S+EL&n_2X83P)Zq zj;*zjX#~7{0A84~3b7X<4}};K;znD`6qwq~6%@HH&6sSmzKE;3W&`-# zVT@9?8GFVPJRh*)WM5ddTI&=^Ex_ogI$fc8Z1u(6w1ahig%`+lB;rI^hFgZhRLuxL@$|~%MLj9-$+8&I%=gL zRu*y1v_c4kUorz@Kh-tiE*I({4M_6#ucP8}BRc5e^CA4t>tNnaCM#^qqq};&$2gL& zzjac!0x}AW=vK{3OAOthKUlR-5;PJs_IZE!r}i%qhqmiZDjg>So{T!hu+H|PhpMGm zkkQka+4LK~3)cRY8TYX%C>!YO%x*;z#bU~O@{H8 z>PR}xn_^N_>a1Rk=Q@Q_4DNw;`b^E-#r+Zk7vl$DF$8d>;Iw>Qd9 zjMaQ$(bk;{PoO72o-EBJWH?tKt8aBnhd^jm^AEi9P6`RlEp@0MPQ81vjefKp!W>Lv zfavj9K(_@01;4i)>Fz_w&x$oPuC?Ke7&Kwba*lGMKm0T3vV2X_`MIgu^or72YA~}aF0tC{*CS;azO|Kw8_h;6qGeyu9s5DGZtpD zNTp>k@mPnD^i5!=R5jp4pjvV>qcFam)VGa707K@y){L*S>vyN_SmGYY!z5DsjCx@*V{p)%+ za8`+g^(C4Lpn7jtOPS2&f5`tG{fGpCIlbXULnlCr)IO#rjHUc*eIPoXrL)GWHl_B) zlc;-;2eLtI1|FaNkr4_H^L?a_z8bHyHicw2jY2dsmp|$wPe+Qu55g7;orUz>5ZMn8 zHGk&XrUi}p6KOxi2M0`QJKQI|g zFc0|Y@K`ft89=V)jyMF!Oti$SNF@Wf6)yo~>f4t62{QK7slz0BNT zVxiBE`+J-p%RY2BmfT+oc&wFC(!l>K-A(vPWNal{gT$9pBAO%_?OPdBD0b^2AxSDN zrxb0g_5zR=XAeJn@g<~8f$__A#r@6me)6NK`Ey1%8-HN1sR62tefIjP9Y*NW9ZxKu zeY8}og2ORIMBtAru6}e3yfforTf?qxT3i3^q0p(v&M3d-vyrb3+6v)3S2%Ev_n!-w z)|~HG{$|$63BR9AN%H0P7fgZD+CJ0hzz9CndIJG;!3DgjAjIcWc+5htKFY3sIpAf*+imI!Ec9V9;U^We-o~ILefz=Uuh-b(dnK%@jPp~UisUO8VNNN z+JpRGp~f!x77h5y=1|~Yg&IS0TpDaLvMgM(mn18|(0N`9HwZauf7pFJg76y9GE=h&8eYh zErTV0&JRqO&o`480c+5u#a%4e75aVXd4ij=rV8wQtk7fFQzc~fwngbTQ;B9^D0u`- zmT)IUn4U~!I^C34?m?!W-FBd_*`(m}!}}Ny$STP7?4Q2ulmwoUFgQsD@2}D;ag?ub z#17d-WYTiWO##f$_@ilUUr;T9=AV3jqVd+^9()#_GWLQ6ITb+Y#>4#_lJ6$L6L6W% zts&8qg%t%M92&)~|Bx4?(YZ!gGf4r2J!?Ae4>feJcOBYnGcNxWPxLM%m6QovjV*A6 zSJtPTcCIPgj2k?S#F69&A1}a|Pk4d*_-GOVW$CBsCb&-gxi5@Yj*&a1h~~JI!;{qn zop~#`WUX=vB{CG_vIL1?G#hA}lqj;;YzoIz4W)_HD6D!;fLq*%GHO*M@am*QE6=r*1=`Ri8U#!dQv_I6>I9G4xzgAwEJ^e|Bzn8jY z`gg1wsSp=Rwcd8#K~x-eioNO>t&ThpPalvQK7-%XAHrWZJfQx6dFxq5 z9H6WG-&Ud%f&yM?Nb62_O!>T+K2{<)vxuRJ2pnwvZJ&V^^W}+0=~hO_pv)f0?W{GI zULo}`jp9GmvkdT3ChAvKZ@y!J^Y`h0FMm%Fe*FAp>eJT!Jw337#q~bQg(*;iC;c&K z^FhvXeK+{B?RSzjT$21Q>=l9}*x_YkV&@^_dk{Q&Ee z?3!JM;7bcDAK#RM%ZAIfKFvFeg;VM_W5mZONnm-R&O*MJdI4z-8Su%gu`qepL=^JR z#=$t2%sP&5Gumg06HYkTXW{7 z&`7Q=68O-rzeA;*BmI`LQ$tLFhFP`_98}O|FshgE1GfN>Odr>o!_Hx}L7e>9GOk|C z;6OIeRiPYLG>-po`jgepbVoQK z99zOReT@F8XCQG|N9m}NSCy){TYA|V_iwi}fs97t*)&LPC_H{t>IRGAZ-z6j5_c#w zPjG2>QT5x|f_2Ox-QZ(T|E6E^M(-t0Q)j8IP}nGopqNI%a5za2J{fYUN(mXcUM1V> z|E^s}pR|`))GT4|nACrf)D+?fW$$P|uVptM(Nx{s zr1kZx@9~!7Z|!AGB5Lou@hN=#`FWrB%=Z~^Ix-W8+{rFRc<|&pMT{#^An<%W0_YoElPP*hV91+Bg4e#=lKS)U4*)(Z$Cz zm)Pt^BwONY-M`gJSD)*jqnLU>Fr*P3Y2KVnqe;%~>2zYyQ}9<6gB%vo;-}KaR-(dc zh^DS}vHf5@I$g(kE5Q8Ft$E&4`N@YmwFw&BGEE zXM8<;rl5eX=6%*X?D6{%>Pm?h(@~3Yk75xDI1J) zPB{c&gAA&tFduG%SChON^mimS%M`iA4uVBe-JER>MQc1BoB_&J<$#C~cz0xkj)s2e zxck^N`bN=v^YlDaQuC9Ja)6)ed^5`c-MbOeX1gmkpjmNNV0qu1ZnM4u!k*p*OfNUY z)7`5liE54i!2|4yIDz9aQ=0Z(Jv~M}_L}!XMe^yK)G`1nw;J7N178T~->DTrDCw3J zMW_hN>4mS}{WtMnxIr*BorxYE3{M8%0CrR%9;}CDRmflc3RzvjeaspZ2b`E~VZ}KS+dRwJ=UPPE7OMP02h--u>76N13YKDPbRqZbRGe2xH>h| zF-fYG{boqE4=z1p_e<{)&4Q+YhL8^qe%v@N97_^ud4IQx5I_dOKc?+l0G8DCjLT%h z2-m}%tHRV0CnkFur5gDa o4R-k2&gx9U+8QzJ!pri<9Mjh8TD3dmI04r9hRjY} z03j~QHAAt?M zz`Hi9;Qpai7$IUZ-d{q$MnK?(b$`9U0#@_9y_H?v;ht}+ncXDNKE;=xU@$g^cJ^zuo7IYaqKwPZXI3kEs)dcJU&@=dp>~+v(*Nk0G`$(Q|7!sD!s_AR0(yhX zG{n=toUocPVVZ=tVXJkBhGVu&G{HduPPEJ3Pzw`MR%#dhM{8F7>XcX_?xm9S4A7(z zvZqF)uUBmN^Js-_AF%Ha5rU7AhJw1jn=k$0;{25=yx4mkk9t4C`<|DdRg?Ih;I9cN zKT*i0+jRia?Zu$Pg-$Y;btUgk*4ca@{phxNV^TieG9ex3@_!MuJ&mDaEReb~GRYb^ z!Drr$K9Qd(YF;Ste?gHZpV^nC1{@~1Tudo(^r}{1JShq9J> zqbRbS=4ks{mkzkYe>~tjEg|=^YT<6CLKVq&tgH2pRtt9F~4Ydp+5dJ@EQag5EX24f(Na3e^&CWVbwoJ6}2lJiO* zC?32H`7#GG`;ef+rBDkp0QkI9h_BMgK5vSW$7(>=j>K|qHOoCQ9B^LoT-9P5rxoCI zVSy;n!vm`mMNM)h1rs=IjElxIZ#bnRBj5DZM?S{OCg)bl0W|6VCFpS{O|A2*m+?HH zsTVb>l<}`hv#CJMpT+5uXGEv=>vU2wU7)9#74EDu%o~KW?w@{e0u*gjMBNNjMy}cv zwvbr&ByoxZ3k{!5nLoYyz>9*SVm7GGvGiunx|@blo;Xi??Yyg;rp=3V=4;s4pfYZb zFrAnjX}C;WAJtEZp)FgqYlExU-;)!9Yq4u2X9&P_tGn~OqRB-X!m?C?vs8Voby>Mo zCbc_iXE@p2m`uN|01;bB|9CrWzRp~zldy6lW;t{Tu|eu}u1ag-qF7i^B*O( zMAg6%F_>+1F4p9;V<)qat@GdO4J%}8Of4$z9%If0xhSOGIo`6$6T% z&zz~jsE6uE8X_n+X<`p$Tx0!>rP@SiRhA$+;5u}&>lCyh0i9Lr>f=o?8kFtp2<_N8 zTs$tyz*uj5jjr^0@j*Hw^@<*#^Tf~QbUl>~-4bjq3}}LHr#{BOo2=Xd%Gj z&Qp{!Ne=)907Xsm0I(HOx1#`L^X2*V<#ia`;UCi0sZ>hQjqC_A7zzIv=J9DU1ef)T z_e`->7cYgkjwCL{7gjM*6IbfEN>+E3U~y_&=6efMRm!=waTR3Kzz`yy>Q~~W?iI|{ z^RLvxk-IbAxILBt&`BRdqs_f2b@JC*&s~{f+fQ?2z)D>=2|%R$*Q z!zm_-_u}Xg7qiM5>*23tSCEB>7>|f#6#PGj&NO`5#W!;eV$3(#&^D}xEGV~}L9fHV zj`myX0Z-iIPC({OSG{$4nH16#cAKsx?7D4!9 zub76^KWCO3xG9Mv9<+8iuSBk*-H{w`O-K41mIv!;d^X2h8U{Jm?cF`*#*1uzqa^8u z^4~vHJ@1lRhc0wE&~iE;?I_@6d3Do2Vg)?{ARKkC_mEP{Y>_=e*Jr#avB)0qR=eBZ zZV>pX&uo=gd)%8=cHIB9yi~>LGtj*pz1+NPf58*teazs7@IJHaCyo=@6t%B>Z1;jw zS?mT)oMNxNvYmKvfqX{JI6SaFDwTT!*QP^qz4J_9QEUeqi4`!k&b)fE_%d?N{&?$f55&l~neep5l}MTK6KHdtalE$&6O%o}YwY~LJsY*4pZ zb@_q4;Fv?bdoSJN*c!Oa1zQowXV;_q%ANg9;IT%(_lo>Q(_HjR_v5$4wB7&-pc_47 z5VlS^glqWrAE{Tt>9ypGN^5Z-fNpnv|0M<1Tc7xhE1)KE1hH{M!j19F)~e4`+TyzB zs{FEg(=7PYn)HLhMO1H2)ZR`2J*DtS{6fv9S{xN5|WvO+8)@uwtg!Nh23emc3!-l9Tq9$n-nDXk0uMZ_NY!U7)^` zWH-K&ZVZ0rK|ZuQ9ChEpP>cu-SYUqAyrRO_HqeL`czltSu0^Yq%nAA+d$FH4>s74r z5@fmbR}t*C;|QyUCH$JUnrP7aT+j&gd8tVkt{`1yK|kV7E$n&ejXd`Opr4cK;-;cD z8U_G`@2ZcB_63&h@V|uwzYFH)m4;NipS$i}AMO7w{dh|Mdq&rN{qxyJKF!aY&<)l) z)wNtyUKwiDnKlRG(xo{>=f%K$iWJ0iy=b7M0h0_M|5{;n#B}_KPS?LVht$!!JF}>_ zGjte7FRT;*6C2>5e5l(jTaa4Dz}Be-jg#b~f`0DLC~c^J@ZxLpd6LlwN{8b`(uiAO zGgav0Cavi(v$M#Aj7ASP1=8 z>b{y|t+HaRAqDESA<6P%H`W;mI}GGK4E75j2Hyq%yqGDQrgoWg&fzby8`qaNAsqO# z?%$+Vr>%j~XW#nY`oTb-orD_kpSalr&(uYwHN+%{H`x(NBy5)m?{6~%^D6O>hM$M) zsb-uH&1JSs$C+tw9wWvt*}FqAfd|SVk_J3sW+A~-bY122B=#ne4NP9p&V-mt8+yS9 zW{dv-Yr_QnEdud(SEiv&u8AbB@|i2u@dso4mf|otsRbq$19*!%Ln?w#Qu;M#8Q(Ic zp1ewUx*r?}KyRJYQ;#>Bu9la(x+ zl<6xg6IaF`WF*T(X2x<0`BD(sB5a6?@0Hf3`;+^0J=t=d(*xobC>-o+YC5%seIDB3 z{mXs1{m`WfZ9-WeM=TqHs$TFAI^2>FTo#Pfle=y++wNT)oov}7WkRSDpjKxa!rdGI zJc1(2UF}|>RAJe3vneG3lL?iDL=A4S)O2>1{m8Dc>R*@slZSefhox7)r&7OsjfZN9&SVOMP zRqF+-W;$SwSGkO1Mfvw7x5O!}@Cz%|fZ~nFy9jM6A)ARtg^pUi8;5F_(*~6x3e$pH zP2_-ah>2N5d=620j74T%sOP&dBd0OcCAL zjQOh(y;(kkL7BEZ<9uJDP1plk{XiPMD_AlFK7)F#ufSEu4Z7{3eVk=TZZAYQeGX31 z*!Tam5qK93L8Cv&J?;O^+5d;#)zp3gNcat%%x!J{zd8G#=suYN2Do5c7^jOtW+9xv znkfUR6dm?{3xQzBnO%dI^+nc+jiAtsVgL4eL2u-se!}=@zH>gm@1AFlF@UuC<$6bK zo@0B)NYCJxM5c@A4%E(y@0Rr~GdL@-1Sdf``o%i6^d}#}0S{y@%8BWnY!GJ?Ea{4$X1xW z;(Yx5pFr`t@c)}gIPbV{ocyQ@Q2vwK@vE@`rqtcNlpdSDv(v^l#-@Pu1ptFY>G6v$wmL$E28`U%ouN1RSofU8i5Oyk9quWw@Wfd>v_u0zv<2HMqY5 zlF+mz>|(HBm0zg*H_;x^_-WTLqAA8|(eFi?V#m+z|Kw9aC$V`Z1+uMFKs=Ku%n1e6 zXoit2zJ+(=*(#mh2ZKkU5jwBl&91^>>t+vK%Lo&7)~ZI4r&7_=*s{0mUN}-$yZAym zgd~SUE;a3jN|Y|{@kH_1lcLIFKMy4Vx_SR>wNATTvTUy<+DZ9G$$6yJtC~kr)MXDx zX{VI+6@qc~p&xqpn=f&F7{23C_))&LqK*RjX&A23>h;?lp_G97f5w`v5H(dC6oCz!*20 z9Hi;b9Lpui6rs?s_n|dgi9|~v#ZpX&(qwy2o5g80o*dTlXLc3sKMl+;Y<6}$2WwOj z(ew>sIvPlbS*FVKmzy+osihWDk5pG*)UxK14`Vi>8BViGvSFS3iOcj|^C^pG4O%qV zronv;t^QZ_B9l>x)>r~)Q6q#*HS04QcxDG3c66C+J{h(Gx=SJ0<#Ds0vq1ktck5co zU_+k3iV_9fT~XO>j>MocD`xd%eajvZrtr$&QcuKC~6tdpXWYVx4s9G@YISp?^y(q>P-wk=OEw%LNjN z3o?x|Jc1Tyw7{~AqQVL-1qGO>0yA3{6wJG=px7{a-&Utq$<;{=Y0@VL*4ZvtoaagM zR%^5DDf0544`P~9EtBC3vZole`LIy-Txj`))3v11vp|#T$2v}c3#(S~AT_QYIhFZI zgk@!~k(xAnFXNFBeFO!O;Rwn{d#7(_Nj@;TH~mvs?UrmQ=O3bAc2y{{8kUAK%IF&- z^6FujI!E}vg#GkBvDABGmhwRX9~T4>F{qI?8M!r*eW2=N1{I=T*4xV~*{RL+&2zI< z)+z7H0%MQH$;v8p1`9SWbF+_TnT`buJ$-}9VaEnD_7t!yEKQWFY z^$NmuufLJuolJt!xp z-De1fx;pD;FahHx+t-JSU>)J}JBJ(HXTs}82`nOq4H{pf%9w>aidIrJ(d|zNbY4S;2s1!G4oXx_qVwmkHrtxpWJwYvhqMzj>7WBdd>l>TX$K6@q0rtM?= zK+O9pR7eAG#!JMX^5yWb#pDOt+CS|lH*0U^u!JK+eze>gK zL2skk(KqynBz@5($%d1&nQ(XNm6`0-?7TBY??Ya{FoK23spIUg@6ML}+uqn- zs(in2kF{x&$uFN2X@JPlmPk?!Ie4F*UjpP-@u4NRxVi)R>_sMOsVg^7O`*3{yICz2(PtqQ{vBTSdH~6d8+dY*ZtPzBBbSOHq6YkL;kYvao zm4+%hLkxI^{f(_J2HER6QaiIyz>5_nRk@q%; zxtTw|KXGmuypF_+edm~J16iR2zCOm5WBK!4=5#StVMS^V3Z*x$=SB9yOf~Q_keyQ( zH2wDt8^3Y3%|iFC3ac8cQhDkUBu!W^GEtEjpBbz%+9>9bFHu4+4=4&a+XEXEh`hi@ z{?Q>2>#Xn)jvR|EkP`S;K4b!zrlv$=kQw7AR%ZEstP#xWk9N?HLW*%lsi3Qd%^dIu zXK`hklXS4ma)i>-ZiBhY6&K1p5|!Iv#$tHVIE%o}#4cZ^+&E!Mr;dT-hHtOq* zOFhQ_+NY$azLK>yxina>HG1l*hQt|dh$$t~-6`XjKG*|f7~^LTQ855y29GJxC0Dtq z9Y_hvhMrlCGhhd+LwKqMYdR`7XvNG%q6s+o)7P)VE-$?mr&#>QYn@sIlUXbt#GC{R zkNxtS;4BCKJUoOhy#6X>Ul!K@spXl3*)P04 zc$0t7`e4(vkBsR>Ha-S)PZ%$MDFuP>QG4!CD=w0A5M;7?P$TJFiBY?t(S5nUyIH4$ zbP(LA6W492A=@D_YUkZ3LmgQG=+NZ56NYzl>YO`Nx++jv8en87v^bEYy`vTuBcE!n z3+IA+;(B4vvpKM!oUESJKI@&^J1_OBfF6s(R>85x>d)a@ywvk-iez0F16RIKhk%6f z*QYEgM({ceGFHiLQjkp;8DGz=ilqt~HSCWuz}ePuKHwoXlIU$5j*DmVi^0W?y;*g= zfb|Vter>UE9#93A*5(t2cW4$JO^XzLEaC_e!|2fzt1g3_WYSC_mqR9qs`&72y}<2Y zv5_9uVVNjaNKEtg=Zy+C{*(aMBfX!AD>qhEJ_>ib;4L;2kKhPYiVRvad0pRF>KM*rrIaj6ORxh&wFAEm>y?m- z)FIO=rS*p~E0635_?T_j1MZc>YzxOqKR4E(GVx0011_`sH$C|@NJ!;!BS}oOM2;P( z;q>01BOqC5JL%Lot}KFhGLJpHLQlGz%1}bd>rotL)g{vO9PO=}{Sk6?VtbMtcHd%R z1_ovMHLmsQ-iTNKCF>#mWwI{SryqX0b)uGLc&4>Jk~NKkYpjO67MD$0^kM=b~Hnz6;hf$pT{q4@7Nwm$HB9pS>WNTL$W%0yQU=0bls!dpl! zwjmZtBz6d8M#-c%ahI?y?B&Nq-+rk5s+Gy#{vh%&(}cftW36kn)tyWniRL|$4>E2p^l+xIwOa%QvK}W2xCuu=oLd`nl$KmArbz+X!AH}k!2Ge%7YFq)C-D; z2#K9Bvl7n4pUPKlC@Z9Agrzea(@W*lHmaS9B3UI1dmuuDkFuq`Mhx#ASHPZce%A^+ zd2JYkQP%UIT`KISCC%4SFq25%nCXH+?*VeqhU{(t_3W>gXg zs5j@O4ofFyIDf4-k*0Y-vZp@+?3@t_C}&kl3OJ%3AARah`TVB>MMLUH86vIyvYb1# zFiL*S+KZQ1rbJiE;96j9in2k4uCYAf1O>%pMiY22A|{m%gV(ARhh0zBM%_?X*aDIQ zhX|lCA{laDx4Sl2ewO?XFs z>t0E;Dsa9nv`UOL9mN2CL;eWbnoH~hE*Wg7JaC--e3zUhL#CW9L!#HUoI~tAT^c%Ne)`;~H><(7cDLn8l&lGG(KbwTUhl4BC0Xr9Th_NShxT#_$VQnoA*$ig51spDySpgZVt4oW>;5gpD znRn;s7ezaSXwI8ob&(^qM*zlgn>Phk96|`IX7p#B4;KEc=o9H$t6k*VT*s$rcKUJ<6O6e%^JCIXq(~s#)TJfdywN;cGudrK&*D*AH z`1R0V6CZYf-DYfrcOO7o&?UIYH+BVQI5e%~M%iHrV>x;PFPd(H*XvcI?;KY3MX2xm z7HaO6#j8@YKn`g)&9FHg+JrOX_om+-@cF%9-hPkdZ8N~Ii2^XYU&hPtW7*WbZvMw3 z5(69)>SgAMqo5bi)LjtSt->WF8!j{!HYpb3ge`;L*A+YYVETa*dlMVDkE{c<)u#aM z3_DYX#h)g2Nt}6x1FnxL<*RzP{i~81=;4@V7V3C>Z|o=2s_!%T^pWjnR&y7uFO*{ z!rRL&6I{G6>@(G*4S9ie8R7p#XDvj@Y{?lc>ayt%2lwsh0YxNK)tK# z>o;fc=~C}BJlw5eme1O-4KH7>4zEzp@@b3t+b7azkxEHV${e5b8{2iV9a5``J!+?7 zg=TD6css%#n`em)rH>Pf+Q;%*G?S$GW~*Kwj+&sTUEK-Vif=_=hoj@{11l$7Mcaym z)#1F zQW$_gkzF?Z45&dhc}RnckS7OeJAV1WlV}Ph5{J-qPts5ugUMMrz-VFr&SoJ`1eJd) z^9*2i-{0TAKf(jeFre6VY4O%KY!vXy8ye9$6JjW=uPH}b8)q~&2NOLH2KpJe^~Roo zo+_T45!{1t%nGzKRm~MGeHt2`kTO@SJg#becb}vZpNxgPVEyPE=~XRXIY0^Zx*M|o z{hW}&xYc#b=L_@|0z{8TD_T^!SghtqZU}a6LwTr%iPUd8 z3bt;CxQ)Ue7QZ(Hc}7WC2!Z4{)9VR3Z!;*Wa(O+F07kYLYd(<0L7EGhQHG0 zbJ7)3LjH$H6H@=tcr@vnYs7cdgL};|%hk50O43`2{0~q|us^4d)Xh%2n;};(4nO8 zI-dTIjZS*f0;)Wq_0|vFpYJ<4zv3$gWpGI45#;Fh1e^U>WaL_{$O*}s1^2;Jkr?Vf zTxp0TMMRNHSg5Hhb#2!gwLuRlBHJy^GiC{}nWC$(Yx!+g#v!PSoM?lZD`%^U2)C}? zzuPlMPrH5aNjx66W;n}%c%DIv+CA<2*_ zq6x$Wb#5dml4R*3@d0GRiY6P8h0;<8qVv*o`poU*S-9f#i15OD=FF{LPOb}LMvatY zrdkYU)tY>*5SEJPu18Z3L%nZDO;eW3bby?Q)Ukz($>RK@Bee}9jYO$S zo7Jpv0Nn6YvUl0&NoktJsZ=3zoZAKgm*Ui8TvuGO8%bS<@p|v035#KD!WgFP)s}T} zD!Qxk%61Vs8$%mJ!aMS^G32v1~I5Thr4?bylP@iU$OAf zml=0P^yShU z0HuR%QT1SpDJSW*S#T0#?;$zIc-8@GWg~`bA^p3evoT)9-3&a7dFrGx0Z4r^hf{jGXl0&{(*h~STVX7;OFVGiXIQn z2p!IpX%wny({f(p>0DRFrQ~QE$s(dRfJ}e}PooT~Dw?EiWYJ%BrawnTur$E}dJIfZ z_%}<=S!dGNfw!=yM5~CB89Z5b0d&Sp2B*^fSt)Dg%4kjUkPH5b;EUN&Ly*F+8Fo{~ z8u)&FOhFJT9VhjQ6ci_N89F5)tP6*;VJ8g01{qYeJk}Hd`h#WRO$Q-F#pAPHfZn_m zB=VRPLAsO?QJoM${BKa6WFrHUc8*%}Zwk&MJ%R){xFO^KU`GH+dRPqjG<7w$rOO-| z6j55CGsZ$^`vPU}(t4R{^;Tcw8^AmoqVHVg!~T9b-#sSejbhSBb_5}HO^vI|>CYvy zw$?zSyR+W}OR6}8$d{7NyZw_5fSFk1tdeh2p%XO^xQ#j7`F6!OLaL4qNk!N1+{KMy z5g+psLcc3b3`XAQ&>aHalY%T+gD>k<$Vf;)Tm(b`JY|KLZ76Lyu}tMEtT3=7KOPi~ z1~HOUf{_d#gX@km%G?^9`F-EQPxZmZ+>#SuT@gV7drE4~oGv}YWJtRS2n~h`!Zz%X zz9kfEgmGAUlNHMAyEkFI95?+7;c1=|Qs0P=7cVXW1}O_}Ddnq#n`Mk+i8$*iti#4c zo5|Y)U)^M_RA?~UCqP3veU@u9`~C|8`KWt((2NyyZxX7ov8{SuTGe&R$;%ISGBFEu z{wtGrCokjVsQ+*QG`hWFeQ1YI3jcN%^0WZyuZt5yydMs{ zl(S2Cf>$Ol;e1<@U1jn`^sbVFuF+rr&k{QsmTajEowr7#HpF>TyXjnXIvauCHqZ0A z^T!9V1A7y-vm8YUoN=Je7Vq3jY2r4F&lp7gl+_Aw(5~~^ce9&PPHaTl?1)pIhWOBfiM&;YsO zCUjKR&LtvZPrbSaR{5qX5hMq@mXL@L1|p6p|0MyRzw?1>J3YT!2Nf(kfa?ECmLx6N zDQTIzN)wq>5YaoW#Eq(!G;H>j`B!xwIqr`ZTq^hpHcX{X5bgK z=Aq=sq5k5vn%ykF#WIRjBnXnlBO0HF^^`*WMD;lZ#}>uFo_zR&UL2jl&E&>vd-z8N zk@3NvXs2vu$N)@rzff{#Cq+}B5v3(KbHI1zGH`LF2{Y4fj)D$VF>sa0+e1!ULlEY+ zfr!*u5lD!U%sp7Ddtkn>{{5X$6_Q;L_saZIsr?Q+2S7>G`;FepHe{V0JEI_vz549PCGB?L&Ng+-DWkbSe5wNl36GAgcJ z{C#vi>8StmT%e=UG?+!)IV&A$PoQ*E+`#E83x%73Ci6`G!t|rmznVE9mkFX8Lj4l- z&`JKLt{*YKumVXlgy;ea|0VAc8`t%4jV!KHhz}eyrG_Flxf&NP)`U^F6BRg)4{BdR zEXYYTrVfxg{-{C14gG-b+RIBhEpcG(t#C9#)Pr&RY(u-#5eB0T?cm{JA@MWQWK~QS z)(I$#?H`9sqkCU)RD0#my-s0Ql>Y=vYo%84kVwBFwI?$HM_+0kJVNqX4=CPFd86!J_%(Z`Hq6-YOM>uUA%E zbmt}5F2;eH<|Tcl4_$|K&<>*$YmUE$5*hFVPC1G~)Yv#!nkadfC z7~zU87-y{?gb=FgN98E^GyZM$DW=T!5fPRzn(K)&c#pM`42m|hoOerh&5+dABm?kQ?` zaV?lwNGjw{Tqu@9*zCKo(d#Ky45O{LXm^(xhWz|*nSfG)fo5sUE3L_4f^t!v4B*@* zJgR~NDvK80wP79gsAjm_PF-2&`fv8WcGu0wNROeu{=k`f#yC8iaYRb>BIhhmXRD7Z zZvAE40`l3fpj{Dtk%6lDkTh%r*)zd&GMP@=t|cuDPPkDGt{dx$SpiGWqj*E+?!DHK zoud_e2X1I!yxPb?)}>KkjAdaM7hqU$l*4MS?S9ntSTf_13wAA>8kk$XYsu3Y@m`VT zh5J_+m07AqB{J%Dtt)@{>-$8u1O&C7;#)y%CtU_Us*}5Su1yH;fkhIMGc5(Xi+fO? zL}-gv{TJoh7Ea8S{q3XFu?tB9Q1~0TP==nKk}dVpJFst`Z|9LZ`_EqQ4LHT5WMpUj zG`52K?K!CDORh>3FLo6grB;KI;>j^&j=0^62{{H>x=dLM&GGJ(ixDtb&+9q}eY7jp zM~y+dN>ReWjV3`h;jSWG*LQ~Vu?2R@ZU(#Yj_JD=yiHv@-s8K!0d`Nh5mTs3W~8OA zNv6(16#wP`0BK?_mH8Rb074{yvdwcQ4HtB8AbGrq3kNc9;j&H1Q_9e%9Jbt%^L#*mp-uuLMJIfiBc;*P zb$+O$HhINnFANDg(_2gDYoTLRm7W;r7y$@653v}&70iHSay0qq)G3=a)qmi)X4RW zl}O~3y5X65CzMKYEM6oj*nTh7i59q19_{IPgn!9{?0z2F!mp1*yAxfQ5BEU|5gE&|5eKpU zuw|s3)T$k(`G<1bFvLadat?2aaa+QKr}1eQye`5@esFyihg|LSm}+2@N~SUB&+V1> z`J$Sh<|<=|AOpXu^M^~l5{gO4)S*>kiT~^PO&TY=K&Cm-0LY8T@t0>MjyBSYOdsi} ztAud9WiIJlnN|}_&e&=hdgxZ9>JIO_Z?qU6*hWdb_a0?0!iRMw>H4TtvZsFG=xuJ6 z63O1_6Mu{c@!TH%n8SlFa9; za`VH`@xlD<2Y7oSU$H8dOL+@t(E+Xw|C@&em4S**gD~iVx+<`yrX}s49inZF0;-e(Ar{S zoymBbx#QsC!sE;8A%(L*IPdrM0B5+o>cZCC;FT_Koq4_w@Gtg= zXRT?d1ali@F(>@6-cKg%3{L* zlz-|VIjW3z6&xGE=Eq)i4ss5eG17!5D=n6|2@DUZmHLc>NTfu3M&w|3KF}oU`dN0q z>hCIJ0TTN+TX6LnBtJ~>aUod4ivQ~Cai`=bTjSN4z^sgOqAIzxA#Iu-Jn#<2?r4e= zsN5K@NsKmSudH>G=nr4dd;xm7@|q`6fOcKrsdB}yI0m&4#IhesBF9ehWh-nV4ony1 z&?i=$F?6j}KISpqP4pdqtV~Ps?-jz?hA=@Lz=tmm#|j@L!GC!{>;F;DQTs{Pv|P1Z zs`6RBmb3wBRY>jccqXH(T~?@Yz64Gorv&wI838PRA=$AQv{Qc*03}i7;E9y>n9gQ2 zC8VSi-+5->9Cp!zI?anDc|hDH*@O`rz6CBrdXMI_r-0p8N&u^uEd|*j#I(Yf{##=vPcEO1FtwEA$}Qov0ysQ?EF0oaj48JKL;xcyCNO z{bNqsU@=5UZhOw%fR8pgWGn@bknvuu7a>gBwD`Ru zmZ#w|0(0!Xgx4pOBj@V_Pr4910yLjGXC*Q#2!zg!EIGlaAQn+ZY+n(mRl(>O(hDfYoPzL+o15~->%6&*EE3?jDLFaWp@mFeGwg8FDZF$dIrcF^BnfXxM5 z%uPZe2IUCBCnHgaNhP1c;)naRYLj`E5F8@{&(+&0VH>XuV!q<@WyuC7u8i`zcIfvx z!zRQo0*<;6idiIt>-1dr5py56b1+&nx7d#;#QD9QfX2pCe$MyO=_ngEz3h-c{S+QxV>w!pXeX}})7=pvG zW|zv%&)2Or@7j(zF=FoM_HTn0rLl=obp7q9Ta(wZ(ed2=4AyY~ zOxqdEvL)`?!A+a4P1+vfpRa&#R}^A8QaFDF_{T>E0Sh4 zwL8X}U^Ygt@`3lQ8U1Om4>p$0&{_DMncqDBC3$;jtx+^=Ck?t71tzO$uZZ*h%wH8^ zKkwaoW66H0+$$t(lMFBf>?TL1tM1>Yy6S&d^pTJGG}_yE#1DHoJn*g)KbR&sNsiiB zI!~76_1Fhx@`kiWfcSK5rxnBmY2v!dbj=;Ou6fPwl8=<=_&p)MUdqN!*v`zEW;i%$ zwu%a&*UQdc#x!$URmtE_w}zx=cuoItG&|cN@g~G!a(GaUbxhg@=s4O$55L=W6Z&_p zLKnQ~$RVs_LI~^f(mVt?J}8fVh^5>@VKS)C{+qS5y>JHmaUym(;@_;3Q$hww1?R`=!c|3}y)j$P zLayc_h8au~MPV`m1b%a}fR}N}s7pMsww0J|H8S%W0WE{H8K@cVy=V z4~x>*Yj--0@t~S%dH9UdUCr%;47zo~g2{ zM}HJ8Bs^q|^zMMZPyK1W62YKkB^#2JEfgM~ANF6cvn8zEMnuu5$l20x@UY><1+R$d zK-%g;h)i@v-knBvf+2qY*l`@QS@ZUl<4VoMZGP-#OdDwsN6a>!L*xQYk2VNG4z2@B zu>CNAq-NY+dsKY_9!e{e%*PA<=w$<^hGW`tz-M3!2BmfC_#$WL@ej2lPJHTt>jN@6$qeiWf$=Fux-v-#O$8 zGid~;4*7Eyw1DFEUpR!F3y z4>CekvbZnR3uknM#nk+j-}2S(!yzRa@D^(Tm-G%}sKohd#nEQP-lOB|V&D2#x8UGX za7h1G8P|8M5|A%&R7)}ZUoHs1b_u9CjMqAn7&zndjwbDiRs>5sXy=1|BYp>8By&$_ z>^s`dNI@^~46$riB`wY?CctFkWC-(UJ$T=EYf7Q12?9g zcIXQH!0kMUl`(N*NR5?G%`^xj1 zd)m0@X_;->QRCD7PN^H3@4uoO=_0G3%>qyMem$)_Iq%VX8v3)gx@XLRgehLFghR0- zl$Dn-%BvrAwMSRZFFblb?ntWI5ZJ4{v)5?t(31O?*A>o@DF{2jy0Fi?A=2FxLt2jD zB!0=v$y0-qP>blP@MgDA&B;^BL$M9*;fAF$9l|Fh-T~SpG!;cuZ8Y96G^!Z6S#%#_ zcCMpSrWyL6rWzRh`s1?NYw5?mcN(&le0QpfT0|6#=!ajkn7h%g4N`c$iqC8#&B$Sh zZSG=|H$mxKVL?7%T!%t2jX=LpckIU9 zOBrd_z6lASVvVC3@z=KO&g(BY*i>1TnuPu!wv?ndZlf%lvU4^{j!V9t`J`VvMUci7Y7si_T)8NcwRqA5BTd3bD2Q^GN`mkxXm zw&rqv|3V&KI!saT3!RPEctK#T4<6c zovCjOja@$`g~-9@7Ju!>#>X`QBG{LTr~H1{7vrbw8^yY#-S*Pf_3B^luGbr$UtEiBgc9nFeWqep4d0F zTZ7$qCfl)9JTc4s*N_G_r1fkTmI7TS;_BIFSozwmPw9nH1jx&TH6Nshh=`~e7!B#X zbN@(w5mPOBr`j?Ey055PL#AkBikUmWRhp22oBn3BEXxMA9uvXp!a~qPUzfjsM}mD> zWXM_5EU<6Z5UeU@%bSKAh_5m=DE70T*8GWg21;Lb(Ot{c@>L^lh`5Osd;c$u@t*0& z=DIy8NO@nj#J-EXQ8Cb`8)g^QNdB$<5Ld;V;Lp);kS{sSQC_$|wf$zr9oMn%OX7x? z$s^<(&!**C+y$iIreg|e-t18;USOUOS2Xd-Q0k-D_&>aZEG2ZD0>f;xQkC&Pi+?-& z*Sb^hL6TRCKB-|c35zJ|%RQ}rzdBVjd`Fj)z3M1_t|pRr^31xNJ>on;i7xXeNT(o% z&TPp*GNkwt?Ru`7cK8zM5DWq#KSoT-lSgtuwiO7(2}9=Iv^`@^cou0S?xb#qX|62B z%Fh4`zbA7M#!vcNU39dZ&;_qh-*a66khIm+kFwds@lFl zNOlwJ;RSUCva4yR?i($YCplKmrB<@BQ`k<$qgr`d%`W}FB;P%6%i2YLO`lcO(9}?0 z()8etY1sI5&v>Wr$bBr{5=X0$L5g=n2B*^pL8a>Y?RZTAr0u5{+@t>)m>KLM(19up|tfY~oS-JcM$DyZtj(`Lvr~WR~drIa%sA<${C{3BpXx zNRg$#MXZoxCF>n^z=_XbrU~@!MIk@>s0nKVu(%y0J$dXqT&lYXsqWL)>MP8HVWHf? zWFfsANEj7Z5sWB(GDbKXf)-A1WLH0J175}@c@=#ZDS}}^IgziMe%K$Fqi(Y;XF_P# zvc5FDCv!M76AFh~OhShE zoenk^lefXKNgk1>XlaIai`iIaS`NCpmo22+2eqTd--YCh2BNBxSqCwGNpA_m{Alk* z!vZB`w1xDx1*avWP`M`ENE2}?onJ0r?w=}*hQEOH(an91up^MW`V_!{SJ$(0Id^@L z>FJoE$OFzk;(!e$C1B`-i%?cuDgZ;&;|65g#R!83N$qVujh$7#&N5q3G$q#gsD4+u zBlrUo21kCcJ$brIo4gLb@@~9d*nAt>ZdS)sMC9v*uZ@;X&PJ=}YX_JukBCtW3=KFa z6b7$*(Tm6q$+7%-HfPh9*8bI*c{00V=o~vM2J=jN!Czi0l`sAMvOV zijXFon+0GdyW@PytIF%CB_&B}g!+;(O-2VEe5VYqiwqE38FaVrP5YV9&SELS@vW8G zC)PLl8}c7P?&)ezLZ&UonTMiyhFS)cc`xAA6iAk?+ps8R`S$_s95`;kP^NQT!p))HC<01-1&kt0*f;Vk543A7y=?4xycvcfs)dytAc)B}j zo8y=j;!0B|V>y4rWI|kbyb= zkhXG4%cxfZQ73AkDW()O!aIXS(4?8KbJ>6Dc~!qKN=S8@AtC>c%t8>BTGLJEp-cRr z4SmJoP^j3GNGKwpHbQw4n0jES3NUkwiYf1czt#gUzme~dz*u5=E6wXtEru~mhlAa! z|8jz(z3|;c-4kK@q?X@Yv)vCR295PvQBZ?N>sth#L++A%Ba%W@l?;52y67ir3w2{! zGuMmv>+q4Dkc>7?#68>5={=#-Q_Y$el9=$Oui3+MsOot=uHX7{xQ>xe(}OjnAkKd= z)LAxsz(k?5`!r~Qe~{C|oBgnREoO@*(sQFxOfPksrLW^*avi$ElIlIS>VmBh{2n!D zI3XL<5~A&w8h>g`8?(HMqoL*#GRPd~(jNDib{7JxkL1jCYcdmD(5<42dQ0=1pkJQP z&&UiXc4)ko@x^+o?}iLJ=U2O*)A8M^N)u=SMSVo$vlC+vH?HK7wf5PK)jU;=tRZbF zT1`6?7Rn_Qi*6oWuJ!`;$_pB5F$As3Yh&wDau7aO&1YH{?@KA7athnT5}V%Z*%R|8 zoZ(*T)_-W9dSw>jxI$&k=_HxAg&#V{%BhyHFhVLnVgdWscTeZC<7dTT)?hq8ThqKI z<}@~2l}T)T*GlZ&a6El1ibI|lg=hYT_xaOK?|tDt6rE`mdzv}ZwJA*cWiXchAlzQk z)}t5_;_k+k(p-)G+VAA?Q==rsr)ccc-AUP@#aO?C$zk3WHnm5!mzEJ3pWFG_R`Z2y zdM@;?=(s1irM`DvT_bn>^6Ff ziu)3#i)ax4=I+5R)i1PreZCn7Y0eqW4vvtE-+KftbXHh~ii)XYgEsFsC5$P4!T{sX z>>}Zua%-nSm#yI*?zohD)06%tPR3$-Jj)*No@t5p^p0)xPW({g-BnV;c9$pCZWm=SH2RrplWikE1{y6P zqx`9JFmLYVpI2!*36APy|2rv5g5@ z^2V}@HU6EX3u}E+E^Ipbj@VpOsgm#^#`_eBW2aOD*tgAXDA&n!)l0`nnwp9;g;sjD z{J0kLAN%SDoeqs|6Aoi3p`{e()elQpAkqtMB43w;+S?{Iu{7PGwS{8gZ{b9_bTAgJ zsEH>iWM&e_V&XU?5;fUI%){Ram+3Uj!hQ`c(aMPj_!SV6zD;86YFi*sgVl0%9ukt| zOG6o?of!Z%{R36A1^x{-)vD*SQLlWr=Rb^W_;e>uSAzsz!|zPro1BT886@UGdWf+H zDlP}%R&Kfb4+|JA66YL%P5x5A|4#au<38{Nfb^H?C*+*~#NJMIoXiiW#IuV`*1XoKMts9P^Kl-#ru8tZ}8tRh$QZAe8qFQfb6idR_m8S(cu+fG`d{5>33Yq0f)gZZvUj39O>G6{(_BwtZC>j}~ zikpSm1{}FKF_{V4Q?bM;MOqB;H8jO{Y;gsXQtSy;Lmk%L@39$5)+xOIEZr{8oE-M< zqxM${ls6^F@U_bE)=@S_HJfh^Id&7c=q@fC)#GU|#<`IcEyuaB`^tIHMSs-pUmdP? z7g1~EybOed2tG{Q6_p;52h>XV+T#t8d!9Ox$b8|I8Y>YhiM2f?+`%y+puMw6 zo+Ys&JD@Q2ZdA@s66J@(kN7?>TRhz%jS9|=$krC6Un%1 zhG)4)7@2k%mFCbF>cH{A`C-4`D;z_$piK3y6wCT(PUdHZ2G6X9_&g=b*q?NVJr4Sr zG8G5O&M#fq6!#&TGs0t&ss4z%8xTS=9oE>B#Bww#ZlHj6@Z0!n)5isK_hVhmnKthF zU-B9n(PawGlw??$lz!n|O{e0zDX~f`^;qRs zV|-^M?ORJH!JZwEfhE;Is)h_a)jgU3o8`m}Vl(T_v4Bm~ClKiIQr)dRBSv0pHQ^~R z;pmQX-+MrZn}j*lO_F~UcF0y)CHBkvPWjbLpF>h9$q6oDp!;O$$$py!uG|#Xd6~iE zY_2UhT2uwNFtICd#cHU_U~P@Jt(|jL_>YY$Jcf5)YOxkeMEv9YRV>q=&>zHNQJtoH z5G~qw$aBzF%bieDIU}Bgd)oCLdJ`BsGCYm6=aud^@-DDM6)ss= z@?aaKYv5g{%4kKtnDxXmWMt zfuqQ}QaHq`mXX=6skd?<>-qHXC5xd>gb(u}Mq(+4dXpC!a{K#o*9WEM&q#cm14NH3-1p&Qa@ z(&D+d?Y8{HV}dMnqJK;<;6?f#lT^ABR(@JJ%cElDsz@YDFy1-k1&k>@Ofj4%jXU?@5tU`9F0YN*t|FT0;5)N zX8d=~GE`@+NYArV(B__P&>oG9Z&36p_D-9l%$B%wprG2+gOqs1c_icCfFkOENKReY3f3)%}hmBHA?f$yK7GK8GqT-(;26UeFG%k|6pF**&^K` z1QCMJ(b0i@0W_d8cn*{$UGkpIV4qGRaHJ0Ep8gC>8S0Tj0Cd|lfj2kk9&r4BFQCUP zo&|;;06jH8bp%ZR9jW9s@Nb$1sKhed0weJdpbs7q0*wC1d?f>bslYQD?=4UV5ERLx zgDMbEAV9TOL@&*OXhUcqkjO0%n-~EyX7Yi*Nl@Dk0X4D&bay{7P)a60*lwyk zK>$b@0HnMfiJ1lg;e6l&9~u-cHUkhVV2X1)(g+;@F++Rls1W5s*vkXW@fd&wZ>2eKaI z^Uph?SQP;R8;B6#jfxNm#C{80HbHi}>rGCG)PHYR0aD*Cqq`RXf}vn9Fao?8lLdjO zZ$;$y0YG8!-{Wb(Jw|m4@J~jRE4qLl{P!RbknZ*+NSlU$mI~OwHyt<-h~rkI!SrkB y|A&njz&FqG|E`BkHX?9-Ausr!zkW=&0?+3nAj~2L99p2kY_UNgypPvEbpHV_P~-Lh delta 35650 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 /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -206,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/saml2/idp-initiated/gradlew.bat b/saml2/idp-initiated/gradlew.bat index 9d21a21..a51ec4f 100644 --- a/saml2/idp-initiated/gradlew.bat +++ b/saml2/idp-initiated/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,30 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/saml2/idp-initiated/settings.gradle b/saml2/idp-initiated/settings.gradle index 5bb4d1d..08b0929 100644 --- a/saml2/idp-initiated/settings.gradle +++ b/saml2/idp-initiated/settings.gradle @@ -1 +1,5 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + rootProject.name = 'idp-initiated' diff --git a/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/IdpInitiatedApplication.java b/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/IdpInitiatedApplication.java index fbd4e29..a5f19e3 100644 --- a/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/IdpInitiatedApplication.java +++ b/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/IdpInitiatedApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class IdpInitiatedApplication { - public static void main(String[] args) { - SpringApplication.run(IdpInitiatedApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(IdpInitiatedApplication.class, args); + } } diff --git a/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/SecurityConfiguration.java b/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/SecurityConfiguration.java new file mode 100644 index 0000000..eafc824 --- /dev/null +++ b/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/SecurityConfiguration.java @@ -0,0 +1,22 @@ +package com.saml2.idp_initiated; + +import static org.springframework.security.config.Customizer.withDefaults; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration(proxyBeanMethods = false) +public class SecurityConfiguration { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) { + http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated()) + .saml2Login(withDefaults()) + .saml2Logout(withDefaults()) + .saml2Metadata(withDefaults()); + return http.build(); + } + +} diff --git a/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/controllers/RootController.java b/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/controllers/RootController.java index 151a481..d115d63 100644 --- a/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/controllers/RootController.java +++ b/saml2/idp-initiated/src/main/java/com/saml2/idp_initiated/controllers/RootController.java @@ -1,7 +1,7 @@ package com.saml2.idp_initiated.controllers; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -10,10 +10,13 @@ public class RootController { @GetMapping("/") - public String root(Model model, @AuthenticationPrincipal Saml2AuthenticatedPrincipal principal) { - String name = principal.getName(); - model.addAttribute("name", name); - + public String index(Saml2AssertionAuthentication authentication, Model model) { + Saml2ResponseAssertionAccessor assertion = authentication.getCredentials(); + model.addAttribute("registrationId", authentication.getRelyingPartyRegistrationId()); + model.addAttribute("nameId", assertion.getNameId()); + model.addAttribute("sessionIndexes", assertion.getSessionIndexes()); + model.addAttribute("attributes", assertion.getAttributes()); return "index"; } + } diff --git a/saml2/idp-initiated/src/main/resources/application.yaml b/saml2/idp-initiated/src/main/resources/application.yaml index 51f4aba..8539c8c 100644 --- a/saml2/idp-initiated/src/main/resources/application.yaml +++ b/saml2/idp-initiated/src/main/resources/application.yaml @@ -7,11 +7,15 @@ spring: saml2: relyingparty: registration: - okta-app: + keycloak: entity-id: "{baseUrl}/saml2/metadata" - acs.location: "{baseUrl}/login/saml2/sso" - signing.credentials: - - private-key-location: classpath:credentials/private.key # you need replace this with private key from keycloak saml client in order to validate signature - certificate-location: classpath:credentials/cert.crt # you need replace this with cert from keycloak saml client in order to validate signature - idp-entity-id: http://localhost:8080/realms/test-realm - assertingparty.metadata-uri: http://localhost:8080/realms/test-realm/protocol/saml/descriptor \ No newline at end of file + acs: + location: "{baseUrl}/login/saml2/sso" + singlelogout: + url: "{baseUrl}/logout/saml2/slo" + signing: + credentials: + - private-key-location: classpath:credentials/private.key + certificate-location: classpath:credentials/cert.crt + assertingparty: + metadata-uri: http://localhost:8080/realms/test-realm/protocol/saml/descriptor diff --git a/saml2/idp-initiated/src/main/resources/templates/index.html b/saml2/idp-initiated/src/main/resources/templates/index.html index 25d2e89..258630d 100644 --- a/saml2/idp-initiated/src/main/resources/templates/index.html +++ b/saml2/idp-initiated/src/main/resources/templates/index.html @@ -1,46 +1,148 @@ - - - - - + + + + + + Phase Two SAML 2.0 IdP-initiated example - SAML Login - - - -
- -
-
-

Authenticated Page

-
-
-

You are successfully logged in as

-
-
-
- -
-
+ + +
+ Phase Two +

Spring Boot · SAML 2.0 service provider · IdP-initiated SSO

+ + Source code on GitHub + +
+
+

Your current status is:

+

Authenticated

+

Logged in as user

+
+ +
+
+

SAML assertion (decoded)

+ + + + + + + + + + + + + + + + + + + +
Relying party registrationkeycloak
NameIDuser
Session indexindex
attributevalue
+
-
- + + diff --git a/saml2/idp-initiated/src/test/java/com/saml2/idp_initiated/IdpInitiatedApplicationTests.java b/saml2/idp-initiated/src/test/java/com/saml2/idp_initiated/IdpInitiatedApplicationTests.java index 44151d0..fc888c8 100644 --- a/saml2/idp-initiated/src/test/java/com/saml2/idp_initiated/IdpInitiatedApplicationTests.java +++ b/saml2/idp-initiated/src/test/java/com/saml2/idp_initiated/IdpInitiatedApplicationTests.java @@ -1,13 +1,89 @@ package com.saml2.idp_initiated; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; + +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion; +import org.springframework.test.web.servlet.MockMvc; @SpringBootTest +@AutoConfigureMockMvc class IdpInitiatedApplicationTests { - @Test - void contextLoads() { - } + private static final Map METADATA_NAMESPACE = Map.of("md", "urn:oasis:names:tc:SAML:2.0:metadata"); + + @Autowired + private MockMvc mvc; + + @Test + void publishesServiceProviderMetadata() throws Exception { + mvc.perform(get("/saml2/metadata")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/samlmetadata+xml")) + .andExpect(xpath("/md:EntityDescriptor/@entityID", METADATA_NAMESPACE) + .string("http://localhost/saml2/metadata")) + .andExpect(xpath("//md:AssertionConsumerService/@Location", METADATA_NAMESPACE) + .string("http://localhost/login/saml2/sso")) + .andExpect(xpath("//md:SingleLogoutService/@Location", METADATA_NAMESPACE) + .string("http://localhost/logout/saml2/slo")); + } + + @Test + void publishesMetadataUnderTheRegistrationId() throws Exception { + mvc.perform(get("/saml2/metadata/keycloak")) + .andExpect(status().isOk()) + .andExpect(xpath("/md:EntityDescriptor/@entityID", METADATA_NAMESPACE) + .string("http://localhost/saml2/metadata")); + } + + @Test + void redirectsAnonymousUsersToTheSamlLogin() throws Exception { + mvc.perform(get("/")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/saml2/authenticate?registrationId=keycloak")); + } + + @Test + void sendsAuthenticationRequestsToKeycloak() throws Exception { + mvc.perform(get("/saml2/authenticate").param("registrationId", "keycloak")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("action=\"http://localhost:8080/realms/test-realm/protocol/saml\""))) + .andExpect(content().string(containsString("name=\"SAMLRequest\""))); + } + + @Test + void showsTheSamlAssertionToAuthenticatedUsers() throws Exception { + Map> attributes = Map.of("email", List.of("test@example.com")); + Saml2ResponseAssertion assertion = Saml2ResponseAssertion.withResponseValue("") + .nameId("test") + .sessionIndexes(List.of("session-1")) + .attributes(attributes) + .build(); + AuthenticatedPrincipal principal = () -> "test"; + Saml2AssertionAuthentication user = new Saml2AssertionAuthentication(principal, assertion, + AuthorityUtils.createAuthorityList("ROLE_USER"), "keycloak"); + + mvc.perform(get("/").with(authentication(user))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Authenticated"))) + .andExpect(content().string(containsString("Logged in as test"))) + .andExpect(content().string(containsString("test@example.com"))) + .andExpect(content().string(containsString("session-1"))); + } } diff --git a/saml2/idp-initiated/src/test/resources/application.yaml b/saml2/idp-initiated/src/test/resources/application.yaml new file mode 100644 index 0000000..b8ab4ce --- /dev/null +++ b/saml2/idp-initiated/src/test/resources/application.yaml @@ -0,0 +1,17 @@ +spring: + application: + name: idp-initiated + security: + saml2: + relyingparty: + registration: + keycloak: + entity-id: "{baseUrl}/saml2/metadata" + acs: + location: "{baseUrl}/login/saml2/sso" + singlelogout: + url: "{baseUrl}/logout/saml2/slo" + assertingparty: + metadata-uri: classpath:test-realm-descriptor.xml + singlesignon: + sign-request: false diff --git a/saml2/idp-initiated/src/test/resources/test-realm-descriptor.xml b/saml2/idp-initiated/src/test/resources/test-realm-descriptor.xml new file mode 100644 index 0000000..289b119 --- /dev/null +++ b/saml2/idp-initiated/src/test/resources/test-realm-descriptor.xml @@ -0,0 +1,25 @@ + + + + + v3hvsG_POeQyr85avHMkzDo0yB-yXI7yvIqcSjMW_pA + + MIIDCzCCAfOgAwIBAgIUJCnFcTe4jwKouJrlKtkHENJ1OykwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UEAwwKdGVzdC1yZWFsbTAeFw0yNjA5MjQxODQ3MzBaFw0zNjA5MjExODQ3MzBaMBUxEzARBgNVBAMMCnRlc3QtcmVhbG0wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2zMh2sZ1CuOWQyki3ZWHnrXRt9gwKd9vGe494hC/1cojbWv/rVdsDu0FIfGi4VhewmeTEvAe/B/OoUiZO4payMZfMAIAYedwa+j2u3dsj/T0lT4+xWkfI28IQYVqwR1xOdTs5k/CDPvAFtiFHM350Qz8SNqb+iTdcdqzB4qRyAbTAhBLzncQgUirvwdEcl7HegHRwTZoyWtNy9O4BPoIwhs700Kq60ExH06d+R7XdZ+PHawuyOw2ZUrARddNVJqLH/HVxavmloYanEXJz9Kp/Y1UGAWXGEc7ZPdcbJbHbPn60nSwVbkETjMJ4U/mHHphR9P0f7hs8pJGuUACdT50pAgMBAAGjUzBRMB0GA1UdDgQWBBTNnwsYMxDfcJp5yHYJZvMa8i9wWDAfBgNVHSMEGDAWgBTNnwsYMxDfcJp5yHYJZvMa8i9wWDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAKsWrhzhDjmFJj2HCkpjj1pJYUC9Egb1eX8PyVK8KUwxV+273aFcVfnTKMIQzPo804Nvf2HsjEfJnKSURFGxDNiokQ9ykxryuCbLLjbg9+5FHAnq+4u4+GdsSFNFHWHcBXTLua1pKPXxLc4LcCppsQLCFz8nvhaEEKiNn7WmAkEAAotSBRf4LyfTs1o+qO3/AF4+kVYcVce3lytMDzZl9KBfhJ3dP9M0BtWKIs2Xnvn0alnhBlA7bdCxruSWBkJNqm9BWbyzVbIEXtQkbcJM3HwqguIw66Am83vKi6h+WmSStwsGom+bacXgyLg1aFIXEJQ3QPkVuZE84t5D6Iojyy + + + + + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + + From ca724d5b98af3097db8b3fd2388b3cc44689b3e4 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:02:38 -0300 Subject: [PATCH 3/8] Generate the SP signing key locally instead of committing it --- saml2/idp-initiated/.gitattributes | 1 + saml2/idp-initiated/.gitignore | 5 +--- .../scripts/generate-sp-credentials.sh | 22 +++++++++++++++ .../src/main/resources/application.yaml | 4 +-- .../src/main/resources/credentials/cert.crt | 18 ------------- .../main/resources/credentials/private.key | 27 ------------------- 6 files changed, 26 insertions(+), 51 deletions(-) create mode 100755 saml2/idp-initiated/scripts/generate-sp-credentials.sh delete mode 100644 saml2/idp-initiated/src/main/resources/credentials/cert.crt delete mode 100644 saml2/idp-initiated/src/main/resources/credentials/private.key diff --git a/saml2/idp-initiated/.gitattributes b/saml2/idp-initiated/.gitattributes index 8af972c..6e40691 100644 --- a/saml2/idp-initiated/.gitattributes +++ b/saml2/idp-initiated/.gitattributes @@ -1,3 +1,4 @@ /gradlew text eol=lf +*.sh text eol=lf *.bat text eol=crlf *.jar binary diff --git a/saml2/idp-initiated/.gitignore b/saml2/idp-initiated/.gitignore index c2065bc..b4615e8 100644 --- a/saml2/idp-initiated/.gitignore +++ b/saml2/idp-initiated/.gitignore @@ -1,11 +1,11 @@ HELP.md +/credentials/ .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ -### STS ### .apt_generated .classpath .factorypath @@ -17,7 +17,6 @@ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ -### IntelliJ IDEA ### .idea *.iws *.iml @@ -26,12 +25,10 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ -### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ -### VS Code ### .vscode/ diff --git a/saml2/idp-initiated/scripts/generate-sp-credentials.sh b/saml2/idp-initiated/scripts/generate-sp-credentials.sh new file mode 100755 index 0000000..7c804f1 --- /dev/null +++ b/saml2/idp-initiated/scripts/generate-sp-credentials.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +entity_id="http://localhost:8081/saml2/metadata" +key_file="credentials/private.key" +cert_file="credentials/cert.crt" + +if [[ -f "$key_file" && -f "$cert_file" ]]; then + echo "$key_file and $cert_file already exist. Delete them to generate a new pair." + exit 0 +fi + +mkdir -p credentials +MSYS_NO_PATHCONV=1 openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 \ + -subj "/CN=${entity_id//\//\\/}" \ + -keyout "$key_file" \ + -out "$cert_file" +chmod 600 "$key_file" + +echo "Created $key_file and $cert_file for $entity_id" diff --git a/saml2/idp-initiated/src/main/resources/application.yaml b/saml2/idp-initiated/src/main/resources/application.yaml index 8539c8c..9db54b0 100644 --- a/saml2/idp-initiated/src/main/resources/application.yaml +++ b/saml2/idp-initiated/src/main/resources/application.yaml @@ -15,7 +15,7 @@ spring: url: "{baseUrl}/logout/saml2/slo" signing: credentials: - - private-key-location: classpath:credentials/private.key - certificate-location: classpath:credentials/cert.crt + - private-key-location: file:credentials/private.key + certificate-location: file:credentials/cert.crt assertingparty: metadata-uri: http://localhost:8080/realms/test-realm/protocol/saml/descriptor diff --git a/saml2/idp-initiated/src/main/resources/credentials/cert.crt b/saml2/idp-initiated/src/main/resources/credentials/cert.crt deleted file mode 100644 index f905981..0000000 --- a/saml2/idp-initiated/src/main/resources/credentials/cert.crt +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC1zCCAb8CBgGVPT9ESDANBgkqhkiG9w0BAQsFADAvMS0wKwYDVQQDDCRodHRw -Oi8vbG9jYWxob3N0OjgwODEvc2FtbDIvbWV0YWRhdGEwHhcNMjUwMjI1MTMxMzMx -WhcNMzUwMjI1MTMxNTExWjAvMS0wKwYDVQQDDCRodHRwOi8vbG9jYWxob3N0Ojgw -ODEvc2FtbDIvbWV0YWRhdGEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB -AQCqBFrHFZf94TZ7miZSmrB2mzPSZTof500+YjJfSfs4hOXzj3a9gqo4/0tiw5q4 -dFMfLisrXM58qyq1ifTvUoVe8IsvCUVT8varEF91Wrua3+8xJcMwO1NhUfDVQE2j -8YM4WpfSYYnpUx9RBOuYEGBacZbOjOKbRbdqiRiq3T0xi48mhZV7g+etx+N4FrSb -KifQEfp9R01XxCxLGChATHeIfu4x1B+sQKS1alSiz7rxL1etqyv+yI5TcA9Ew0PS -NwZWV43k+zHAn+4AsSZw3Q2szZv/9BXrdCrdrGgmbwUbngPFHU5wYcYKsv+PdY66 -z+O9uUWQhVbiz6xvJgWSQukvAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAAo6g+Kk -INIH6JIyJ2W0E2rsvhQSDgUwOB0CBUOqCiHnru8OqILLhrk53dh4SV8cPCwU7GHs -7AhLU3PtSZxqxEwAC5GR5d32jhNCt06P9Sb1DSnnQsGBORM9QUIkqe9tndBMiNlE -QZIOOYXx7fATdi5c8JAT0OFVmxMzdY7ZWTKm0iNi+KWpgqL7uP7E7FyGFMz0MKLO -QlUlmJSsVnnLf018PAnugfCJlQbRHofVweICFzXvI46q74UcUxFlObETtZTsXmD/ -14ltvqTVoYmOtd2q3xqahFp4x4VnKn3mjlaV643XmXLvqOOVPqjFkitQGn5259DS -ShKL8ajM4pV3M0o= ------END CERTIFICATE----- diff --git a/saml2/idp-initiated/src/main/resources/credentials/private.key b/saml2/idp-initiated/src/main/resources/credentials/private.key deleted file mode 100644 index 79670b1..0000000 --- a/saml2/idp-initiated/src/main/resources/credentials/private.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpQIBAAKCAQEAqgRaxxWX/eE2e5omUpqwdpsz0mU6H+dNPmIyX0n7OITl8492 -vYKqOP9LYsOauHRTHy4rK1zOfKsqtYn071KFXvCLLwlFU/L2qxBfdVq7mt/vMSXD -MDtTYVHw1UBNo/GDOFqX0mGJ6VMfUQTrmBBgWnGWzozim0W3aokYqt09MYuPJoWV -e4PnrcfjeBa0myon0BH6fUdNV8QsSxgoQEx3iH7uMdQfrECktWpUos+68S9Xrasr -/siOU3APRMND0jcGVleN5PsxwJ/uALEmcN0NrM2b//QV63Qq3axoJm8FG54DxR1O -cGHGCrL/j3WOus/jvblFkIVW4s+sbyYFkkLpLwIDAQABAoIBABOWbdj6+WhkDpFd -+KVSshrwFZ/bTr+HOASEbrZAwUNJOG7/q9f4mmD3tLk7vPMcX8k7Wfjkd9uwIrsI -mmIKOVhvUTHJmSWrW9TojX7pRaz5uMaKSbsgUqJnPN5syqUlOY+ADHbOCwQJMhX1 -Aami1V8GMDA61g66kP+irOdeluLCt8MbdJMXZN0VoCbgfkgNPH3AmYAWK5xOG7/q -/fiCc1IAP+t0m4MohVn/HiQ54agi5pcpx2Vq66RTg1J8vH0Qk5ZD3QIzmECusAff -kX33EeOIqdjgpjW6NpJQkESh5swdlPC6eDR6tQvF6Yw8/O67A7M/O08jgbUXUbnz -PmKvPjUCgYEA3Uf5KKqFlqxmKFS34B6Mvxgyvq9857MPm0XP+LQUh3ByNrL+dBHK -15yzgVgMohYkInhktVEneclcQEJ1e8xv3mAAbRZGSZqTISZWHRb0KIuI1hkysUk6 -RZl6Nux8CYkgd+Sko7vWC5/rPavMIBgjV51MjbRSZgUbvAU+HnMfpgUCgYEAxLFL -faG2wuj8yaZBg4Uf23QKuacvFpyPilLQieM9C90jJcxs56Pid2r8XCNYwgYz9DoY -HQbSmLW1ObmgW54yI2hL/35ZJPcCP56T4gUtz94z5HLM4ko6bYv37HXWLskDiYbh -XiqW1MvACHl4VPZAcYlHQFC8omxkLwb0fLaNpKMCgYEAzCD+F2afD60AHIpyiZyF -CC/heAjZN8D2T63qOaqeGYdAxXb/xsE3FwnmihZYqRm1wdrARIeV3AJGSL1GNasV -M9Xp7rgSpQmkWntMC0/GcrStyZu7KNK9n8ECfjJQBlfqjM9uleDMhzLCRsT57E14 -JPXlzf8f2B80Q5GuiVZ4RqUCgYEAq6pJRpaTGb2akqvl1dGkIjY+/hVpxYRSytjI -ZO/M4cPY3y5xhiHEj5DyO8AlwQEE6wyg6wR/XQSJ7J2T1SJWhAyM96ngR6DFd50B -WGxItXkrNSD0rDM4l09nzBp0GlUaHzd3DmaBSauiKENLwGtDh1PWuc09NU0AiwK4 -8veo250CgYEA279Kq3IkFD1CSEBXEiFvYY4MWxkgNdP6WNkgcH1Moo8+MxuA63fA -/uGuhwdtkD1Nceg/dAJyA30Aq7fDyUaTIK7IRyxtwl/0Xa+fL4sRI0fjPQMn594/ -3rjT0+5Cg4b03a/LbAdCZRg6QGuKdH/23HPFOwRMAv/h2j9P199dZnQ= ------END RSA PRIVATE KEY----- From c7dd99a6546b46a26bed50067182eab6ce0c2fb9 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:02:43 -0300 Subject: [PATCH 4/8] Add Keycloak compose file and sanitized test realm for the SAML example --- saml2/idp-initiated/docker-compose.yml | 26 + saml2/idp-initiated/keycloak/saml-client.json | 65 - .../keycloak/test-realm-export.json | 2224 ----------------- saml2/idp-initiated/keycloak/test-realm.json | 118 + 4 files changed, 144 insertions(+), 2289 deletions(-) create mode 100644 saml2/idp-initiated/docker-compose.yml delete mode 100644 saml2/idp-initiated/keycloak/saml-client.json delete mode 100644 saml2/idp-initiated/keycloak/test-realm-export.json create mode 100644 saml2/idp-initiated/keycloak/test-realm.json diff --git a/saml2/idp-initiated/docker-compose.yml b/saml2/idp-initiated/docker-compose.yml new file mode 100644 index 0000000..1ddda52 --- /dev/null +++ b/saml2/idp-initiated/docker-compose.yml @@ -0,0 +1,26 @@ +name: saml2-idp-initiated + +services: + keycloak: + image: quay.io/phasetwo/phasetwo-keycloak:26.6 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - ./keycloak:/opt/keycloak/data/import:ro + healthcheck: + test: + [ + "CMD", + "bash", + "-c", + "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && grep -q UP <&3", + ] + interval: 5s + timeout: 5s + retries: 60 + start_period: 20s diff --git a/saml2/idp-initiated/keycloak/saml-client.json b/saml2/idp-initiated/keycloak/saml-client.json deleted file mode 100644 index 823cf09..0000000 --- a/saml2/idp-initiated/keycloak/saml-client.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "clientId": "http://localhost:8081/saml2/metadata", - "name": "", - "description": "", - "rootUrl": "", - "adminUrl": "", - "baseUrl": "", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [ - "http://localhost:8081/*" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": true, - "protocol": "saml", - "attributes": { - "saml.assertion.signature": "false", - "saml.force.post.binding": "true", - "saml_single_logout_service_url_post": "http://localhost:8081/logout/saml2/slo", - "saml.encrypt": "false", - "post.logout.redirect.uris": "http://localhost:8081/*", - "saml_assertion_consumer_url_post": "http://localhost:8081/login/saml2/sso", - "saml.server.signature": "true", - "saml_idp_initiated_sso_url_name": "okta-client", - "saml.server.signature.keyinfo.ext": "false", - "saml.signing.certificate": "MIICpTCCAY0CBgGVPDMgIzANBgkqhkiG9w0BAQsFADAWMRQwEgYDVQQDDAtva3RhLWNsaWVudDAeFw0yNTAyMjUwODIwMzhaFw0zNTAyMjUwODIyMThaMBYxFDASBgNVBAMMC29rdGEtY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3QlnZM0WoaDWTszywlkz4FF9EnhAPLpQKqRMazLRg2p+h2KjidvMquDc8VQOsj6aNqgn+NmZXx4xolZ3s+0bjourGGeueHUS7qQqXDygWZ90Q6GYpgPVFW51LSTAwGZ6svObJW6MwN9f8Siff8+nkAtvMcxcpQwO11U7rl8Wt98xcixTcYzm341hWBPETfzDOLSrb2YZu3uRhds51NBXIEwsrPfqSM9NB5O7ELkFRVfvfwK13ACci6BxOi6vz4CQLuLkSvcd8o2FH+vOU8IGJpELp2EmELZ0wWL1STD7sBbGGpVnaGj0m5i63qn4jym12ARyHiENhvvtjUJEPY7E2QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB0BncWx4ANYcCWW3h+UXGageM5qV73fy/vg9IFeyduW7/wtcZLlCH5iBdfws8H3fsfinWHqWT5rwUIkjgTbGC7LkjB60MBqziaGeUn6KxHKvzWHGruFNHBmBYcaXZbC3YvxjRtBJsKPJ0dyVZWMRPJw8k/4z4o5Chxfj72dsGpCboAnrOjHwJKNIbVXtEXhyO8iNmVbjiMgPufOhCveoD2rsSQlFJhkx500KpkZY6T8quXiRFsasreRYT9hegsJYQGycvbaQSdHM2kz7fLuf6WIFrL+c7JRnW9FMvfik1cqSuCDcuSdE8LZ7u/52cvURX+NxZbgW0QZO1EhV8mnGTJ", - "realm_client": "false", - "saml.artifact.binding.identifier": "ug3P4zN09U1DuSnNZh4ltkTvjwA=", - "saml.artifact.binding": "false", - "saml.signature.algorithm": "RSA_SHA256", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "saml.authnstatement": "true", - "display.on.consent.screen": "false", - "saml_name_id_format": "username", - "saml.signing.private.key": "MIIEpQIBAAKCAQEA3QlnZM0WoaDWTszywlkz4FF9EnhAPLpQKqRMazLRg2p+h2KjidvMquDc8VQOsj6aNqgn+NmZXx4xolZ3s+0bjourGGeueHUS7qQqXDygWZ90Q6GYpgPVFW51LSTAwGZ6svObJW6MwN9f8Siff8+nkAtvMcxcpQwO11U7rl8Wt98xcixTcYzm341hWBPETfzDOLSrb2YZu3uRhds51NBXIEwsrPfqSM9NB5O7ELkFRVfvfwK13ACci6BxOi6vz4CQLuLkSvcd8o2FH+vOU8IGJpELp2EmELZ0wWL1STD7sBbGGpVnaGj0m5i63qn4jym12ARyHiENhvvtjUJEPY7E2QIDAQABAoIBAAP9TtZPDNLlPyT8Nj/7gfARLHWcQcWO09JXNZFkQQuTDR5WsY60DsdBSH+bFapSLrCgm5sWW1owBnOQhbfTxmDt5KxLt8aRD2LLCAAy8G55riyn0qRhcpPA0wDZ9lgqqVmIQ9M6wfPbvESzJ+DTz8mN6Ud/8aahd3Wbwlhg+z0dk7UDIapnlC5lCaNuantQi2zClAzOu4mUnGAxXMgl0rMaZfVZRXPjH2L5+wRxd/TEyZUZ/A41YpyBuip7Eh4f+7surjoEnVmnqM3WNBgdLaFmXK0FSUY0wmP1CRRFtZr6izWgf1jhPrHgxySjx+zBLjE18d9tV8zhEAqpgR81z7ECgYEA9Byw9GZp12p7uOHNbGzQ7TkLsY+W7uF5hHYPhk51FF7SYcwhjXo5MVAPAVMU4PxRLBkysOJoJN6ZVVppvFMbEHStont70uYS8osXwSkRGreRc/VwOKGSh2qgxzEo5xlbeC9Z12utshltJGwU023+FhC0v6TKTkNzoL9nF5KQp4kCgYEA580JK5TWKG7Fva2V+nGSsVST8W5zhOxtBui771z/SZbK2zEUhMGxZjnHcFz8HKi9iWTFIyatong6jYgfEcv2uNqb+Ax+KYazckESSaRekoV7GVSCWm5nFAEnFDEgzlij8baY7VGTcvXaiHqpJmufsR2pOUBYnl+rfFda7+pIjtECgYEAx/RgdjEVbWBJq606CSAxGr9pdq45Yb7LPNc/7DGD8YWiKn181lhRVLVRNTKKnafQDaEuRnmN+PVk/+cpSRuBxb7tI3Smfp/60TVh5dTvr8TBngxjOeAPGGEdBUydRn8cz5tpqe0HwCTXo8fsXytvvz877uba17Skl/1wQnzJhJECgYEAwMujnfD8MsL3iyzOunAuL3oGVt+kC2HNyNpYPd1eo+lcrfTjeQvVLg5OmGFue7PJeXYsFXF9J83R/ZfDJ706Z0PESAq0AHv90sDWohOpaTrGSSomBWsgJt5Sj5PREm9dZJSYG4MnHY3pBmznNYJ8Xwcpkem4C3nNGu1ZQITfdpECgYEAhin1SLP/wlMc/Xmu3SdRJTlzvsftY0UQw/oPfJtg2J+TJwCQT5/zrFaJFklYSHQv3ZYOvz6WOfWSDHJm0Y1qyBZv/9QpAGddSh9zSpUFTPwki/uI5dQiCNlt5Hr4zJhNnw7ieYm1EA4cWk8n3pRac4+j8Qx6lmvmyShESdp7S2Q=", - "saml.allow.ecp.flow": "false", - "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#", - "saml.onetimeuse.condition": "false", - "saml.server.signature.keyinfo.xmlSigKeyInfoKeyNameTransformer": "NONE" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "saml_organization", - "role_list" - ], - "optionalClientScopes": [], - "access": { - "view": true, - "configure": true, - "manage": true - } -} \ No newline at end of file diff --git a/saml2/idp-initiated/keycloak/test-realm-export.json b/saml2/idp-initiated/keycloak/test-realm-export.json deleted file mode 100644 index 09c7305..0000000 --- a/saml2/idp-initiated/keycloak/test-realm-export.json +++ /dev/null @@ -1,2224 +0,0 @@ -{ - "realm": "test-realm", - "notBefore": 0, - "defaultSignatureAlgorithm": "RS256", - "revokeRefreshToken": false, - "refreshTokenMaxReuse": 0, - "accessTokenLifespan": 300, - "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "ssoSessionIdleTimeoutRememberMe": 0, - "ssoSessionMaxLifespanRememberMe": 0, - "offlineSessionIdleTimeout": 2592000, - "offlineSessionMaxLifespanEnabled": false, - "offlineSessionMaxLifespan": 5184000, - "clientSessionIdleTimeout": 0, - "clientSessionMaxLifespan": 0, - "clientOfflineSessionIdleTimeout": 0, - "clientOfflineSessionMaxLifespan": 0, - "accessCodeLifespan": 60, - "accessCodeLifespanUserAction": 300, - "accessCodeLifespanLogin": 1800, - "actionTokenGeneratedByAdminLifespan": 43200, - "actionTokenGeneratedByUserLifespan": 300, - "oauth2DeviceCodeLifespan": 600, - "oauth2DevicePollingInterval": 5, - "enabled": true, - "sslRequired": "external", - "registrationAllowed": false, - "registrationEmailAsUsername": false, - "rememberMe": false, - "verifyEmail": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "bruteForceProtected": false, - "permanentLockout": false, - "maxTemporaryLockouts": 0, - "bruteForceStrategy": "MULTIPLE", - "maxFailureWaitSeconds": 900, - "minimumQuickLoginWaitSeconds": 60, - "waitIncrementSeconds": 60, - "quickLoginCheckMilliSeconds": 1000, - "maxDeltaTimeSeconds": 43200, - "failureFactor": 30, - "defaultRole": { - "id": "c5ffa4d0-7f09-41ff-a54d-cf198c3a1549", - "name": "default-roles-test-realm", - "description": "${role_default-roles}", - "composite": true, - "clientRole": false, - "containerId": "1e2d347c-43e2-4841-b199-b6a649d9bf2e" - }, - "requiredCredentials": [ - "password" - ], - "otpPolicyType": "totp", - "otpPolicyAlgorithm": "HmacSHA1", - "otpPolicyInitialCounter": 0, - "otpPolicyDigits": 6, - "otpPolicyLookAheadWindow": 1, - "otpPolicyPeriod": 30, - "otpPolicyCodeReusable": false, - "otpSupportedApplications": [ - "totpAppFreeOTPName", - "totpAppGoogleName", - "totpAppMicrosoftAuthenticatorName" - ], - "localizationTexts": {}, - "webAuthnPolicyRpEntityName": "keycloak", - "webAuthnPolicySignatureAlgorithms": [ - "ES256", - "RS256" - ], - "webAuthnPolicyRpId": "", - "webAuthnPolicyAttestationConveyancePreference": "not specified", - "webAuthnPolicyAuthenticatorAttachment": "not specified", - "webAuthnPolicyRequireResidentKey": "not specified", - "webAuthnPolicyUserVerificationRequirement": "not specified", - "webAuthnPolicyCreateTimeout": 0, - "webAuthnPolicyAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyAcceptableAaguids": [], - "webAuthnPolicyExtraOrigins": [], - "webAuthnPolicyPasswordlessRpEntityName": "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms": [ - "ES256", - "RS256" - ], - "webAuthnPolicyPasswordlessRpId": "", - "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", - "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", - "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", - "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", - "webAuthnPolicyPasswordlessCreateTimeout": 0, - "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyPasswordlessAcceptableAaguids": [], - "webAuthnPolicyPasswordlessExtraOrigins": [], - "scopeMappings": [ - { - "clientScope": "offline_access", - "roles": [ - "offline_access" - ] - } - ], - "clientScopes": [ - { - "id": "9716d4a9-70ab-49e9-9215-33e5bfbea06c", - "name": "microprofile-jwt", - "description": "Microprofile - JWT built-in scope", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "dcc6602b-5594-4670-8977-9d769736a181", - "name": "upn", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "upn", - "jsonType.label": "String" - } - }, - { - "id": "27d367a5-2f00-45e4-848d-cef298727e75", - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "multivalued": "true", - "user.attribute": "foo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "c59c0a2f-f92c-4d65-9371-d5aa829f7edd", - "name": "offline_access", - "description": "OpenID Connect built-in scope: offline_access", - "protocol": "openid-connect", - "attributes": { - "consent.screen.text": "${offlineAccessScopeConsentText}", - "display.on.consent.screen": "true" - } - }, - { - "id": "8a1d42c1-1365-4ef2-b217-0f6c4a97e50b", - "name": "basic", - "description": "OpenID Connect scope for add all basic claims to the token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "b3f9f3fe-f1d6-4971-911f-0d5ae54b1116", - "name": "auth_time", - "protocol": "openid-connect", - "protocolMapper": "oidc-usersessionmodel-note-mapper", - "consentRequired": false, - "config": { - "user.session.note": "AUTH_TIME", - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "auth_time", - "jsonType.label": "long" - } - }, - { - "id": "f5bc781e-b973-40be-9049-be08173cf23f", - "name": "sub", - "protocol": "openid-connect", - "protocolMapper": "oidc-sub-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true" - } - } - ] - }, - { - "id": "da6b69cc-7354-4cfc-b866-6d1fd8e436d8", - "name": "roles", - "description": "OpenID Connect scope for add user roles to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "consent.screen.text": "${rolesScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "886dc446-7d82-42c5-8f8d-9a438dfae14f", - "name": "realm roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "realm_access.roles", - "jsonType.label": "String", - "multivalued": "true" - } - }, - { - "id": "487b5b11-1b0e-4ac5-81a8-b18483e9734b", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true" - } - }, - { - "id": "7e4ea93b-22e1-4113-9b4a-ce0d1258ce56", - "name": "client roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "resource_access.${client_id}.roles", - "jsonType.label": "String", - "multivalued": "true" - } - } - ] - }, - { - "id": "c418037b-5e27-4716-b5dc-d052db99d7c7", - "name": "phone", - "description": "OpenID Connect built-in scope: phone", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${phoneScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "76669311-e8a7-479a-9a6d-e1d356d46a94", - "name": "phone number", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "phoneNumber", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number", - "jsonType.label": "String" - } - }, - { - "id": "13fb945e-1cdb-474b-ba1f-a9e028838ce2", - "name": "phone number verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "phoneNumberVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number_verified", - "jsonType.label": "boolean" - } - } - ] - }, - { - "id": "dbaa2fa5-1c56-4fe5-b2fc-7d1fcf907c36", - "name": "role_list", - "description": "SAML role list", - "protocol": "saml", - "attributes": { - "consent.screen.text": "${samlRoleListScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "7ce2f2ab-b2eb-4d6e-9cc2-876ae85bf701", - "name": "role list", - "protocol": "saml", - "protocolMapper": "saml-role-list-mapper", - "consentRequired": false, - "config": { - "single": "false", - "attribute.nameformat": "Basic", - "attribute.name": "Role" - } - } - ] - }, - { - "id": "fd67ee33-e066-4381-98f6-39f6be15c59f", - "name": "web-origins", - "description": "OpenID Connect scope for add allowed web origins to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "consent.screen.text": "", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "a2cd0e49-3d42-4306-96f7-74ac636857bb", - "name": "allowed web origins", - "protocol": "openid-connect", - "protocolMapper": "oidc-allowed-origins-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true" - } - } - ] - }, - { - "id": "6db6a45c-dc08-4761-8813-a6d6031263c2", - "name": "email", - "description": "OpenID Connect built-in scope: email", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${emailScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "6e6b1d8b-b64d-40f1-9124-9bf4443bc28b", - "name": "email verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "emailVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email_verified", - "jsonType.label": "boolean" - } - }, - { - "id": "e34c46bf-3a8b-451d-b032-4e748e84ff94", - "name": "email", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "email", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "676d5990-14d3-4184-8914-de09eb870b7c", - "name": "address", - "description": "OpenID Connect built-in scope: address", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${addressScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "62e290dc-b95e-4349-b157-18fe3c59312f", - "name": "address", - "protocol": "openid-connect", - "protocolMapper": "oidc-address-mapper", - "consentRequired": false, - "config": { - "user.attribute.formatted": "formatted", - "user.attribute.country": "country", - "introspection.token.claim": "true", - "user.attribute.postal_code": "postal_code", - "userinfo.token.claim": "true", - "user.attribute.street": "street", - "id.token.claim": "true", - "user.attribute.region": "region", - "access.token.claim": "true", - "user.attribute.locality": "locality" - } - } - ] - }, - { - "id": "6009c9f3-0b85-4190-b96a-a2250b0f18f4", - "name": "acr", - "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "cb1e8f29-c493-4050-8d61-51449bcb9a4a", - "name": "acr loa level", - "protocol": "openid-connect", - "protocolMapper": "oidc-acr-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true" - } - } - ] - }, - { - "id": "ab5cecfa-7e71-4648-b064-515434dfca60", - "name": "saml_organization", - "description": "Organization Membership", - "protocol": "saml", - "attributes": { - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "ab631711-fde8-4df6-86ba-d9d1cd99ed15", - "name": "organization", - "protocol": "saml", - "protocolMapper": "saml-organization-membership-mapper", - "consentRequired": false, - "config": {} - } - ] - }, - { - "id": "3060eda9-70f7-4683-a1d8-3f1f6cf8f7cc", - "name": "organization", - "description": "Additional claims about the organization a subject belongs to", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${organizationScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "0b6ea7b1-994b-4c66-923f-f3b4f74110dc", - "name": "organization", - "protocol": "openid-connect", - "protocolMapper": "oidc-organization-membership-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "multivalued": "true", - "userinfo.token.claim": "false", - "addOrganizationAttributes": "false", - "id.token.claim": "true", - "lightweight.claim": "true", - "access.token.claim": "true", - "claim.name": "organization", - "jsonType.label": "String", - "addOrganizationId": "true" - } - } - ] - }, - { - "id": "5957dee3-52e9-47ae-a1bb-b0e9b073acba", - "name": "p2-org", - "description": "", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "gui.order": "", - "consent.screen.text": "" - }, - "protocolMappers": [ - { - "id": "520ed26f-03cb-4299-91db-a2fc0248b219", - "name": "test", - "protocol": "openid-connect", - "protocolMapper": "oidc-active-organization-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "id.token.claim": "true", - "lightweight.claim": "true", - "access.token.claim": "true", - "included.active.organization.properties": "id, name, role, attribute", - "claim.name": "active-org", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "f1d3fe6f-8b38-4b7e-a0ad-c270d6f3a188", - "name": "profile", - "description": "OpenID Connect built-in scope: profile", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${profileScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "bef74d4e-7058-428c-8196-fcc284d1ce67", - "name": "gender", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "gender", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "gender", - "jsonType.label": "String" - } - }, - { - "id": "a5f6d01f-3644-4359-85b2-bae816f63889", - "name": "updated at", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "updatedAt", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "updated_at", - "jsonType.label": "long" - } - }, - { - "id": "3af29723-b245-4013-9fd4-786d9989efaa", - "name": "middle name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "middleName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "middle_name", - "jsonType.label": "String" - } - }, - { - "id": "0243a06b-9709-45e4-bcf0-30f9feba42ca", - "name": "given name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "firstName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "given_name", - "jsonType.label": "String" - } - }, - { - "id": "60c74748-04c9-4b33-be78-5f1bb55a4db5", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - }, - { - "id": "dfabc58a-c00d-413f-8854-8e3bd32dac5f", - "name": "family name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "lastName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "family_name", - "jsonType.label": "String" - } - }, - { - "id": "df8e5ee5-860d-4059-8c3c-4260d6660986", - "name": "nickname", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "nickname", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "nickname", - "jsonType.label": "String" - } - }, - { - "id": "95cdad09-c58a-4e73-a425-44532300a67d", - "name": "website", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "website", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "website", - "jsonType.label": "String" - } - }, - { - "id": "57b38b68-58aa-4a07-9210-ef544c262989", - "name": "zoneinfo", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "zoneinfo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "zoneinfo", - "jsonType.label": "String" - } - }, - { - "id": "9b95e6c8-92f4-4481-a7a9-2df8aa5f3c3e", - "name": "full name", - "protocol": "openid-connect", - "protocolMapper": "oidc-full-name-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }, - { - "id": "c5042b8a-a6e7-408d-ae2b-5bce9160db43", - "name": "birthdate", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "birthdate", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "birthdate", - "jsonType.label": "String" - } - }, - { - "id": "652ca5aa-4b78-4c0e-a281-93b693083cf9", - "name": "profile", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "profile", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "profile", - "jsonType.label": "String" - } - }, - { - "id": "97865d02-967d-497e-8dae-2342d5ea5782", - "name": "picture", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "picture", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "picture", - "jsonType.label": "String" - } - }, - { - "id": "35607b22-5057-4fd2-9b8e-040423e40529", - "name": "username", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "preferred_username", - "jsonType.label": "String" - } - } - ] - } - ], - "defaultDefaultClientScopes": [ - "role_list", - "saml_organization", - "profile", - "email", - "roles", - "web-origins", - "acr", - "basic" - ], - "defaultOptionalClientScopes": [ - "offline_access", - "address", - "phone", - "microprofile-jwt", - "organization", - "p2-org" - ], - "browserSecurityHeaders": { - "contentSecurityPolicyReportOnly": "", - "xContentTypeOptions": "nosniff", - "referrerPolicy": "no-referrer", - "xRobotsTag": "none", - "xFrameOptions": "SAMEORIGIN", - "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "xXSSProtection": "1; mode=block", - "strictTransportSecurity": "max-age=31536000; includeSubDomains" - }, - "smtpServer": {}, - "eventsEnabled": true, - "eventsListeners": [ - "jboss-logging" - ], - "enabledEventTypes": [ - "SEND_RESET_PASSWORD", - "UPDATE_CONSENT_ERROR", - "GRANT_CONSENT", - "VERIFY_PROFILE_ERROR", - "REMOVE_TOTP", - "REVOKE_GRANT", - "UPDATE_TOTP", - "LOGIN_ERROR", - "CLIENT_LOGIN", - "RESET_PASSWORD_ERROR", - "UPDATE_CREDENTIAL", - "IMPERSONATE_ERROR", - "CODE_TO_TOKEN_ERROR", - "CUSTOM_REQUIRED_ACTION", - "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", - "RESTART_AUTHENTICATION", - "IMPERSONATE", - "UPDATE_PROFILE_ERROR", - "LOGIN", - "OAUTH2_DEVICE_VERIFY_USER_CODE", - "UPDATE_PASSWORD_ERROR", - "CLIENT_INITIATED_ACCOUNT_LINKING", - "OAUTH2_EXTENSION_GRANT", - "USER_DISABLED_BY_PERMANENT_LOCKOUT", - "REMOVE_CREDENTIAL_ERROR", - "TOKEN_EXCHANGE", - "AUTHREQID_TO_TOKEN", - "LOGOUT", - "REGISTER", - "DELETE_ACCOUNT_ERROR", - "CLIENT_REGISTER", - "IDENTITY_PROVIDER_LINK_ACCOUNT", - "USER_DISABLED_BY_TEMPORARY_LOCKOUT", - "DELETE_ACCOUNT", - "UPDATE_PASSWORD", - "CLIENT_DELETE", - "FEDERATED_IDENTITY_LINK_ERROR", - "IDENTITY_PROVIDER_FIRST_LOGIN", - "CLIENT_DELETE_ERROR", - "VERIFY_EMAIL", - "CLIENT_LOGIN_ERROR", - "RESTART_AUTHENTICATION_ERROR", - "EXECUTE_ACTIONS", - "REMOVE_FEDERATED_IDENTITY_ERROR", - "TOKEN_EXCHANGE_ERROR", - "PERMISSION_TOKEN", - "FEDERATED_IDENTITY_OVERRIDE_LINK", - "SEND_IDENTITY_PROVIDER_LINK_ERROR", - "UPDATE_CREDENTIAL_ERROR", - "EXECUTE_ACTION_TOKEN_ERROR", - "OAUTH2_EXTENSION_GRANT_ERROR", - "SEND_VERIFY_EMAIL", - "OAUTH2_DEVICE_AUTH", - "EXECUTE_ACTIONS_ERROR", - "REMOVE_FEDERATED_IDENTITY", - "OAUTH2_DEVICE_CODE_TO_TOKEN", - "IDENTITY_PROVIDER_POST_LOGIN", - "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", - "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", - "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", - "UPDATE_EMAIL", - "REGISTER_ERROR", - "REVOKE_GRANT_ERROR", - "EXECUTE_ACTION_TOKEN", - "LOGOUT_ERROR", - "UPDATE_EMAIL_ERROR", - "CLIENT_UPDATE_ERROR", - "AUTHREQID_TO_TOKEN_ERROR", - "INVITE_ORG_ERROR", - "UPDATE_PROFILE", - "CLIENT_REGISTER_ERROR", - "FEDERATED_IDENTITY_LINK", - "INVITE_ORG", - "SEND_IDENTITY_PROVIDER_LINK", - "SEND_VERIFY_EMAIL_ERROR", - "RESET_PASSWORD", - "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", - "OAUTH2_DEVICE_AUTH_ERROR", - "REMOVE_CREDENTIAL", - "UPDATE_CONSENT", - "REMOVE_TOTP_ERROR", - "VERIFY_EMAIL_ERROR", - "SEND_RESET_PASSWORD_ERROR", - "CLIENT_UPDATE", - "CUSTOM_REQUIRED_ACTION_ERROR", - "IDENTITY_PROVIDER_POST_LOGIN_ERROR", - "UPDATE_TOTP_ERROR", - "CODE_TO_TOKEN", - "VERIFY_PROFILE", - "GRANT_CONSENT_ERROR", - "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" - ], - "adminEventsEnabled": false, - "adminEventsDetailsEnabled": false, - "identityProviders": [ - { - "alias": "okta-broker", - "displayName": "", - "internalId": "086002ac-3242-424e-90b5-71757542ecd6", - "providerId": "saml", - "enabled": true, - "updateProfileFirstLoginMode": "on", - "trustEmail": false, - "storeToken": false, - "addReadTokenRoleOnCreate": false, - "authenticateByDefault": false, - "linkOnly": false, - "hideOnLogin": true, - "config": { - "postBindingLogout": "false", - "postBindingResponse": "true", - "backchannelSupported": "false", - "caseSensitiveOriginalUsername": "false", - "idpEntityId": "http://www.okta.com/exkngyq2mxx2aPSyx5d7", - "loginHint": "false", - "allowCreate": "true", - "enabledFromMetadata": "true", - "syncMode": "LEGACY", - "authnContextComparisonType": "exact", - "singleSignOnServiceUrl": "https://dev-99079880.okta.com/app/dev-99079880_testlogin_1/exkngyq2mxx2aPSyx5d7/sso/saml", - "wantAuthnRequestsSigned": "false", - "allowedClockSkew": "0", - "artifactBindingResponse": "false", - "validateSignature": "false", - "signingCertificate": "MIIDqDCCApCgAwIBAgIGAZUiq660MA0GCSqGSIb3DQEBCwUAMIGUMQswCQYDVQQGEwJVUzETMBEG\nA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU\nMBIGA1UECwwLU1NPUHJvdmlkZXIxFTATBgNVBAMMDGRldi05OTA3OTg4MDEcMBoGCSqGSIb3DQEJ\nARYNaW5mb0Bva3RhLmNvbTAeFw0yNTAyMjAwOTIyNTFaFw0zNTAyMjAwOTIzNTFaMIGUMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsG\nA1UECgwET2t0YTEUMBIGA1UECwwLU1NPUHJvdmlkZXIxFTATBgNVBAMMDGRldi05OTA3OTg4MDEc\nMBoGCSqGSIb3DQEJARYNaW5mb0Bva3RhLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC\nggEBAMdMDqiLeYhl2b3CGXxHvLGYXuuXzQr8gI1B5+3bOYMgzvpQ3Bt0dxHoBPza19ZGTRN+vK8N\nbZe4WimqcSuuTORssX2f9daOixm0Hokdjp9FBOqzKsac0rNzGjNohfCDUKEjdXo/UmuaknpKBqp6\nJzJ2GgYMyv34NqDFU0awtT3S08TO8sYdIcE8qdMoxDgEN6y5HP7u5ztu6AOObbp0m19XGH/NJ856\nr0rlT5n5yx+DlBH5LsxDH4shrQ1gomh1p9Z7KkRPAedSVyy4W2uUfj5XVi1pxyZmK+QcCBL6ECem\n6QOtO/KScEnpURQXWFWVs3NX58oerar5O93cM95a3MkCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA\nq4r9TuBiiUH4K6QlHP28Ua2tZQcMt0ZmjFfWKdhyR19juA4U7drQMb/NhCBC1kunZTBZ3TrGItJ5\nbKLt1mHxgjndy71YC/u3CiNsfJI9Iq0bjRuMLkRSTIkQC7+l/P0p2CBLCKOrr2HSlJ4f7PPib0NG\n/wNJvXCHTevzItlP4Hg/nFeBm4pGKpEOFdKYkhW3RnORErVPmeO0yfc92HYwZeX47stIOcpBZVnP\nKu1FJOTfno7zXr2Oz5sGCaZDiL+DhmTCdeluGguixQwMrIVEEx+oS/pLz75sFcGE0PAMJn6Ltntj\nJGYg4MIv+cXs+0/LbwrEoJVwBA5YKuT+pupC1A==", - "nameIDPolicyFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "entityId": "http://localhost:8080/realms/test-realm", - "signSpMetadata": "false", - "wantAssertionsEncrypted": "false", - "sendClientIdOnLogout": "false", - "wantAssertionsSigned": "false", - "sendIdTokenOnLogout": "true", - "postBindingAuthnRequest": "true", - "forceAuthn": "false", - "attributeConsumingServiceIndex": "0", - "addExtensionsElementWithKeyInfo": "false", - "principalType": "SUBJECT" - } - } - ], - "identityProviderMappers": [], - "components": { - "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ - { - "id": "f7dc37de-9df7-4c51-b7c1-40593558b2ca", - "name": "Max Clients Limit", - "providerId": "max-clients", - "subType": "anonymous", - "subComponents": {}, - "config": { - "max-clients": [ - "200" - ] - } - }, - { - "id": "e081e8ad-238f-4fd7-b4bb-9ba31bc434e1", - "name": "Consent Required", - "providerId": "consent-required", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "98f15875-e611-444a-9d4c-e43a303d41a5", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "oidc-sha256-pairwise-sub-mapper", - "oidc-address-mapper", - "saml-user-property-mapper", - "saml-role-list-mapper", - "oidc-usermodel-property-mapper", - "oidc-full-name-mapper", - "saml-user-attribute-mapper", - "oidc-usermodel-attribute-mapper" - ] - } - }, - { - "id": "b3878b7d-07a3-4d89-a41b-671fd63b0bf1", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allow-default-scopes": [ - "true" - ] - } - }, - { - "id": "7602d8e1-23cd-416f-a8b3-e8dc159e3a95", - "name": "Full Scope Disabled", - "providerId": "scope", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "2959b792-4a01-4c36-91df-2b3170c93a0d", - "name": "Trusted Hosts", - "providerId": "trusted-hosts", - "subType": "anonymous", - "subComponents": {}, - "config": { - "host-sending-registration-request-must-match": [ - "true" - ], - "client-uris-must-match": [ - "true" - ] - } - }, - { - "id": "6224dc4b-903d-4295-8503-a9d3133d1d6d", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "oidc-sha256-pairwise-sub-mapper", - "oidc-full-name-mapper", - "oidc-address-mapper", - "oidc-usermodel-attribute-mapper", - "saml-user-attribute-mapper", - "saml-user-property-mapper", - "oidc-usermodel-property-mapper", - "saml-role-list-mapper" - ] - } - }, - { - "id": "bbcf3c26-005e-4aa4-9f89-efebd336f89f", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allow-default-scopes": [ - "true" - ] - } - } - ], - "org.keycloak.userprofile.UserProfileProvider": [ - { - "id": "58198d81-7c1f-4a59-b6f7-e51773765e0f", - "providerId": "declarative-user-profile", - "subComponents": {}, - "config": { - "kc.user.profile.config": [ - "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"org.ro.active\",\"displayName\":\"Active organization ID\",\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" - ] - } - } - ], - "org.keycloak.keys.KeyProvider": [ - { - "id": "b601091a-9f95-4fa5-a60b-b9fd5a2edb41", - "name": "hmac-generated-hs512", - "providerId": "hmac-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ], - "algorithm": [ - "HS512" - ] - } - }, - { - "id": "06acf65c-d4d1-43ab-89a6-198f622a91ab", - "name": "rsa-enc-generated", - "providerId": "rsa-enc-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ], - "algorithm": [ - "RSA-OAEP" - ] - } - }, - { - "id": "ce14d2e3-845a-463b-b64e-465890906d80", - "name": "rsa-generated", - "providerId": "rsa-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ] - } - }, - { - "id": "a3b500b8-fdd5-4f8b-8b00-1c3e4da4d8ce", - "name": "aes-generated", - "providerId": "aes-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ] - } - } - ] - }, - "internationalizationEnabled": false, - "supportedLocales": [], - "authenticationFlows": [ - { - "id": "dd02f104-42a9-4e91-a5ce-6171fde27319", - "alias": "Account verification options", - "description": "Method with which to verity the existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-email-verification", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Verify Existing Account by Re-authentication", - "userSetupAllowed": false - } - ] - }, - { - "id": "7664aaab-19df-4a3c-91fe-496fecb26488", - "alias": "Browser - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "e41ef55f-0893-44e7-a6a8-aa4b3640c886", - "alias": "Browser - Conditional Organization", - "description": "Flow to determine if the organization identity-first login is to be used", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "organization", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "530671ad-24b8-44d3-9a5d-831dc7b909a9", - "alias": "Cookies Sub-Flow", - "description": "Cookie sub-flow which can be used to switch org.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "00cc805a-18b7-43f7-b6ec-ab368923a4c4", - "alias": "Copy of browser", - "description": "Browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": false, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-spnego", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "identity-provider-redirector", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 25, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Copy of browser forms", - "userSetupAllowed": false - } - ] - }, - { - "id": "c2332aa8-2fcc-47e3-9519-5ff2537c55d6", - "alias": "Copy of browser forms", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": false, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 11, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "0bbfc104-2d7c-462c-a6ce-6c0af28797d6", - "alias": "Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "direct-grant-validate-otp", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "d670b3ff-64ab-4162-ae56-383caec0857e", - "alias": "First Broker Login - Conditional Organization", - "description": "Flow to determine if the authenticator that adds organization members is to be used", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "idp-add-organization-member", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "654e1721-5c10-47cd-bfc4-3ab5f18ae55b", - "alias": "First broker login - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "e249296e-553b-4f71-a471-4cc58138e9e1", - "alias": "Forms Sub-Flow", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Org Browser - Conditional OTP", - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "02ff8a60-636e-4eb8-95c7-1d8e8f107127", - "alias": "Handle Existing Account", - "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-confirm-link", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Account verification options", - "userSetupAllowed": false - } - ] - }, - { - "id": "b2cf2b2f-31c5-4199-a67d-57136dc06682", - "alias": "IDP Sub-Flow", - "description": "IDP sub-flow to select org.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "identity-provider-redirector", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "bd36b4cd-a661-470b-8982-e39f84c1057e", - "alias": "Org Browser - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "3ceab586-1f3c-4a76-a9de-731ab1470e5f", - "alias": "Org Browser Flow", - "description": "Browser flow with select organization step.", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": true, - "flowAlias": "Cookies Sub-Flow", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "IDP Sub-Flow", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Forms Sub-Flow", - "userSetupAllowed": false - } - ] - }, - { - "id": "cd69d7eb-a8b5-4f0d-83ca-93f5ec44ed58", - "alias": "Org Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "09ffeaf0-780e-4c4d-a4ab-5cb02ff2699a", - "alias": "Org Direct Grant Flow", - "description": "Direct grant flow with select organization step.", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "direct-grant-validate-password", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Org Direct Grant - Conditional OTP", - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "d692cbbd-aa9e-4b82-851b-a181eccdb7a3", - "alias": "Organization", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 10, - "autheticatorFlow": true, - "flowAlias": "Browser - Conditional Organization", - "userSetupAllowed": false - } - ] - }, - { - "id": "d25abb90-d036-48f6-8bed-07bc886120a3", - "alias": "Reset - Conditional OTP", - "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "reset-otp", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "2ede7a30-6192-4f69-9507-d67002017baf", - "alias": "User creation or linking", - "description": "Flow for the existing/non-existing user alternatives", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "create unique user config", - "authenticator": "idp-create-user-if-unique", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Handle Existing Account", - "userSetupAllowed": false - } - ] - }, - { - "id": "ea9d6076-f4b5-4858-b93f-a59041085dd8", - "alias": "Verify Existing Account by Re-authentication", - "description": "Reauthentication of existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "First broker login - Conditional OTP", - "userSetupAllowed": false - } - ] - }, - { - "id": "a1d9ab01-87b6-4f6e-9eb6-e884310674b5", - "alias": "browser", - "description": "Browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-spnego", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "identity-provider-redirector", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 25, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 26, - "autheticatorFlow": true, - "flowAlias": "Organization", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "forms", - "userSetupAllowed": false - } - ] - }, - { - "id": "ed12fac2-2132-4d12-b7ad-729f69fc2d43", - "alias": "clients", - "description": "Base authentication for clients", - "providerId": "client-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "client-secret", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "client-jwt", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "client-secret-jwt", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "client-x509", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 40, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "c8333af0-961a-4e24-8f42-9b82a1d708c1", - "alias": "direct grant", - "description": "OpenID Connect Resource Owner Grant", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "direct-grant-validate-password", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Direct Grant - Conditional OTP", - "userSetupAllowed": false - } - ] - }, - { - "id": "2421e5d2-fffc-4a2c-955f-f5a5e7df54cb", - "alias": "docker auth", - "description": "Used by Docker clients to authenticate against the IDP", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "docker-http-basic-authenticator", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "b1a39fa2-d348-4872-8a2d-1757f5610eaf", - "alias": "first broker login", - "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "review profile config", - "authenticator": "idp-review-profile", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "User creation or linking", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 50, - "autheticatorFlow": true, - "flowAlias": "First Broker Login - Conditional Organization", - "userSetupAllowed": false - } - ] - }, - { - "id": "82d170ca-0872-411c-a80b-ac2e1c48f8f6", - "alias": "forms", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Browser - Conditional OTP", - "userSetupAllowed": false - } - ] - }, - { - "id": "3fa519f8-1ab5-40a5-b637-23a34e5945f4", - "alias": "magic link", - "description": "Simple magic link authentication flow.", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 0, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "identity-provider-redirector", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 0, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 1, - "autheticatorFlow": true, - "flowAlias": "magic link forms", - "userSetupAllowed": false - } - ] - }, - { - "id": "a1fdbf95-31db-4099-9a5a-0347ae463529", - "alias": "magic link forms", - "description": "Forms for simple magic link authentication flow.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": false, - "authenticationExecutions": [ - { - "authenticator": "ext-magic-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 0, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "d5fd5fdc-c324-4913-b4af-30e86ab7507a", - "alias": "post org broker login", - "description": "Post broker login flow used for organization IdPs.", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "ext-auth-org-note", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 0, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "ext-auth-org-add-user", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 0, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "a834ab07-b949-4580-8651-9766e1525487", - "alias": "registration", - "description": "Registration flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-page-form", - "authenticatorFlow": true, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": true, - "flowAlias": "registration form", - "userSetupAllowed": false - } - ] - }, - { - "id": "9535e9d1-64ec-4328-9f47-b788afb5f884", - "alias": "registration form", - "description": "Registration form", - "providerId": "form-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-user-creation", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "registration-password-action", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 50, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "registration-recaptcha-action", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 60, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "registration-terms-and-conditions", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 70, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "4cda7288-e2c8-4c76-ba83-8609029a15ec", - "alias": "reset credentials", - "description": "Reset credentials for a user if they forgot their password or something", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "reset-credentials-choose-user", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "reset-credential-email", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "reset-password", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 40, - "autheticatorFlow": true, - "flowAlias": "Reset - Conditional OTP", - "userSetupAllowed": false - } - ] - }, - { - "id": "46184936-df91-4a2f-9863-cc99fe082fe7", - "alias": "saml ecp", - "description": "SAML ECP Profile Authentication Flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "http-basic-authenticator", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - } - ], - "authenticatorConfig": [ - { - "id": "ef713bde-9f2d-4f37-98f4-8c84f623d809", - "alias": "create unique user config", - "config": { - "require.password.update.after.registration": "false" - } - }, - { - "id": "6aaf8437-72d8-45d6-80f1-5845d1eb9600", - "alias": "review profile config", - "config": { - "update.profile.on.first.login": "missing" - } - } - ], - "requiredActions": [ - { - "alias": "CONFIGURE_TOTP", - "name": "Configure OTP", - "providerId": "CONFIGURE_TOTP", - "enabled": true, - "defaultAction": false, - "priority": 10, - "config": {} - }, - { - "alias": "TERMS_AND_CONDITIONS", - "name": "Terms and Conditions", - "providerId": "TERMS_AND_CONDITIONS", - "enabled": false, - "defaultAction": false, - "priority": 20, - "config": {} - }, - { - "alias": "UPDATE_PASSWORD", - "name": "Update Password", - "providerId": "UPDATE_PASSWORD", - "enabled": true, - "defaultAction": false, - "priority": 30, - "config": {} - }, - { - "alias": "UPDATE_PROFILE", - "name": "Update Profile", - "providerId": "UPDATE_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 40, - "config": {} - }, - { - "alias": "VERIFY_EMAIL", - "name": "Verify Email", - "providerId": "VERIFY_EMAIL", - "enabled": true, - "defaultAction": false, - "priority": 50, - "config": {} - }, - { - "alias": "delete_account", - "name": "Delete Account", - "providerId": "delete_account", - "enabled": false, - "defaultAction": false, - "priority": 60, - "config": {} - }, - { - "alias": "webauthn-register", - "name": "Webauthn Register", - "providerId": "webauthn-register", - "enabled": true, - "defaultAction": false, - "priority": 70, - "config": {} - }, - { - "alias": "webauthn-register-passwordless", - "name": "Webauthn Register Passwordless", - "providerId": "webauthn-register-passwordless", - "enabled": true, - "defaultAction": false, - "priority": 80, - "config": {} - }, - { - "alias": "VERIFY_PROFILE", - "name": "Verify Profile", - "providerId": "VERIFY_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 90, - "config": {} - }, - { - "alias": "delete_credential", - "name": "Delete Credential", - "providerId": "delete_credential", - "enabled": true, - "defaultAction": false, - "priority": 100, - "config": {} - }, - { - "alias": "update_user_locale", - "name": "Update User Locale", - "providerId": "update_user_locale", - "enabled": true, - "defaultAction": false, - "priority": 1000, - "config": {} - } - ], - "browserFlow": "browser", - "registrationFlow": "registration", - "directGrantFlow": "direct grant", - "resetCredentialsFlow": "reset credentials", - "clientAuthenticationFlow": "clients", - "dockerAuthenticationFlow": "docker auth", - "firstBrokerLoginFlow": "first broker login", - "attributes": { - "cibaBackchannelTokenDeliveryMode": "poll", - "cibaExpiresIn": "120", - "cibaAuthRequestedUserHint": "login_hint", - "oauth2DeviceCodeLifespan": "600", - "oauth2DevicePollingInterval": "5", - "parRequestUriLifespan": "60", - "cibaInterval": "5", - "realmReusableOtpCode": "false" - }, - "keycloakVersion": "26.0.8", - "userManagedAccessAllowed": false, - "organizationsEnabled": false, - "clientProfiles": { - "profiles": [] - }, - "clientPolicies": { - "policies": [] - } -} \ No newline at end of file diff --git a/saml2/idp-initiated/keycloak/test-realm.json b/saml2/idp-initiated/keycloak/test-realm.json new file mode 100644 index 0000000..fcd5e71 --- /dev/null +++ b/saml2/idp-initiated/keycloak/test-realm.json @@ -0,0 +1,118 @@ +{ + "realm": "test-realm", + "displayName": "SAML IdP-initiated example", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "clients": [ + { + "clientId": "http://localhost:8081/saml2/metadata", + "name": "Spring Boot SAML service provider", + "protocol": "saml", + "enabled": true, + "frontchannelLogout": true, + "fullScopeAllowed": true, + "redirectUris": ["http://localhost:8081/*"], + "attributes": { + "saml_idp_initiated_sso_url_name": "okta-client", + "saml_assertion_consumer_url_post": "http://localhost:8081/login/saml2/sso", + "saml_single_logout_service_url_post": "http://localhost:8081/logout/saml2/slo", + "saml_name_id_format": "username", + "saml_force_name_id_format": "false", + "saml.force.post.binding": "true", + "saml.server.signature": "true", + "saml.signature.algorithm": "RSA_SHA256", + "saml.assertion.signature": "false", + "saml.client.signature": "false", + "saml.encrypt": "false", + "saml.authnstatement": "true" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "attribute.name": "email", + "friendly.name": "email", + "attribute.nameformat": "Basic" + } + }, + { + "name": "firstName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "attribute.name": "firstName", + "friendly.name": "firstName", + "attribute.nameformat": "Basic" + } + }, + { + "name": "lastName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "attribute.name": "lastName", + "friendly.name": "lastName", + "attribute.nameformat": "Basic" + } + } + ] + } + ], + "users": [ + { + "username": "test", + "email": "test@example.com", + "firstName": "Test", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "test", + "temporary": false + } + ] + } + ], + "identityProviders": [ + { + "alias": "okta-broker", + "displayName": "Okta", + "providerId": "saml", + "enabled": false, + "hideOnLogin": true, + "trustEmail": false, + "storeToken": false, + "linkOnly": false, + "config": { + "entityId": "http://localhost:8080/realms/test-realm", + "idpEntityId": "http://www.okta.com/your-okta-app-id", + "singleSignOnServiceUrl": "https://your-okta-domain.okta.com/app/your-okta-app/your-okta-app-id/sso/saml", + "nameIDPolicyFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "principalType": "SUBJECT", + "postBindingResponse": "true", + "postBindingAuthnRequest": "true", + "postBindingLogout": "false", + "wantAuthnRequestsSigned": "false", + "wantAssertionsSigned": "false", + "wantAssertionsEncrypted": "false", + "validateSignature": "true", + "allowCreate": "true", + "syncMode": "IMPORT" + } + } + ] +} From eaadfe8c259d5807f89503c2aae589ce27958a56 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:02:49 -0300 Subject: [PATCH 5/8] Rewrite the SAML example README --- saml2/idp-initiated/README.md | 156 +++++++++++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 12 deletions(-) diff --git a/saml2/idp-initiated/README.md b/saml2/idp-initiated/README.md index 2c632a2..74a86f1 100644 --- a/saml2/idp-initiated/README.md +++ b/saml2/idp-initiated/README.md @@ -1,15 +1,147 @@ -# Spring boot SAML 2.0 Idp-initiated flow example +# Phase Two SAML 2.0 example: IdP-initiated SSO with Spring Boot -This project was generated with [Spring Initializr](https://start.spring.io/). Contributions and corrections are welcome as pull requests. +A Spring Boot SAML 2.0 service provider (SP) that accepts IdP-initiated logins from Keycloak, either with Keycloak as the identity provider or with Keycloak brokering a login that starts in Okta. It is the code for the tutorial [Keycloak SAML Identity Provider (IdP) Initiated Flow with Okta](https://phasetwo.io/blog/keycloak-saml-identity-provider-idp-initiated-flow-with-okta). -### Local Development +## IdP-initiated SSO -1. Start a Keycloak instance locally. (e.q: http://localhost:8080/) . -2. Go to your OKTA tenant and add a new SAML 2.0 application with the following:\ - ```Single sign-on URL```: http://localhost:8080/realms/test-realm/broker/okta-broker/endpoint/clients/okta-client \ - ```Audience URI (SP Entity ID)```: http://localhost:8080/realms/test-realm -3. Import realm in file: `/keycloak/test-realm-export.json`\ - You will need to change the ```singleSignOnServiceUrl``` with the one found in the metadata from OKTA application created at step 1 -4. Import saml-client: `/keycloak/saml-client.json` -5. Run project using: `./gradlew bootRun` -6. Start authentication from OKTA application page +In the usual, SP-initiated flow, the user opens the application first. The SP sends a SAML `AuthnRequest` to the identity provider (IdP), the user logs in there, and the IdP posts a SAML `Response` back to the SP's assertion consumer service (ACS). The response refers to the request (`InResponseTo`), so the SP knows it asked for it. + +In the IdP-initiated flow, the user starts at the IdP, for example by clicking the application's tile in the Okta dashboard. There is no `AuthnRequest`: the IdP posts an unsolicited `Response` to the SP's ACS. The SP accepts it when it is signed by the IdP it trusts, addressed to its ACS (`Destination`), meant for its entity ID (`Audience`) and still valid. Because an unsolicited response can't be matched to a request, it is more exposed to replay and login CSRF than an SP-initiated one: keep assertion lifetimes short, sign (and, for sensitive attributes, encrypt) assertions, and prefer SP-initiated logins where you can. + +## Architecture + +The example runs in one of two ways: + +- **Keycloak as the IdP** (no Okta needed): the browser opens Keycloak's IdP-initiated SSO URL for the SAML client, the user logs in to Keycloak, and Keycloak posts a SAML response to the SP. +- **Okta, brokered by Keycloak**: the user clicks the app in Okta, Okta posts a SAML response to Keycloak's broker endpoint for the `okta-broker` identity provider, Keycloak validates it, creates or links the user and starts a Keycloak session, and then Keycloak's SAML client posts a new SAML response to the SP. + +```mermaid +sequenceDiagram + participant User + participant Okta + participant Keycloak as Keycloak (test-realm) + participant SP as Spring Boot SP (port 8081) + + User->>Okta: Clicks the app tile + Okta->>Keycloak: SAML response to /broker/okta-broker/endpoint/clients/okta-client + Keycloak->>Keycloak: Validates it, creates or links the user + Keycloak->>SP: SAML response to /login/saml2/sso + SP->>User: Logged in +``` + +Without Okta, the flow starts at Keycloak's `/protocol/saml/clients/okta-client` URL instead of the broker endpoint. `okta-client` is the SAML client's "IDP-Initiated SSO URL name", a name from the tutorial: both flows use the same client. + +| What | Value | +| ----------------------------------- | ----------------------------------------------------------------------------------------- | +| SP entity ID (Keycloak client ID) | `http://localhost:8081/saml2/metadata` | +| SP assertion consumer service (ACS) | `http://localhost:8081/login/saml2/sso` | +| SP single logout service | `http://localhost:8081/logout/saml2/slo` | +| SP metadata | `http://localhost:8081/saml2/metadata` (also `/saml2/metadata/keycloak`) | +| Keycloak IdP metadata | `http://localhost:8080/realms/test-realm/protocol/saml/descriptor` | +| Keycloak IdP-initiated SSO URL | `http://localhost:8080/realms/test-realm/protocol/saml/clients/okta-client` | +| Keycloak broker endpoint for Okta | `http://localhost:8080/realms/test-realm/broker/okta-broker/endpoint/clients/okta-client` | + +## The code + +- [build.gradle](./build.gradle): Spring Boot 4.1 with `spring-boot-starter-security-saml2`, which brings Spring Security 7.1 and OpenSAML 5. OpenSAML is not published to Maven Central, hence the Shibboleth repository. The build uses a Java 21 toolchain, which Gradle downloads if you don't have one. +- [application.yaml](./src/main/resources/application.yaml) defines the relying party registration `keycloak`: the SP's entity ID, ACS and single logout URLs, its signing key pair, and the URL of Keycloak's IdP metadata, which Spring reads at startup to learn Keycloak's endpoints and signing certificate. `{baseUrl}` is replaced with the URL the request came in on. +- [SecurityConfiguration.java](./src/main/java/com/saml2/idp_initiated/SecurityConfiguration.java) requires a login for every page. `saml2Login` processes SAML responses posted to `/login/saml2/sso` (the registration is found from the response's issuer, so the ACS URL needs no registration ID) and sends users who aren't logged in to Keycloak. `saml2Logout` handles single logout, and `saml2Metadata` publishes the SP metadata, which Spring Boot's default configuration doesn't. +- [RootController.java](./src/main/java/com/saml2/idp_initiated/controllers/RootController.java) and [index.html](./src/main/resources/templates/index.html) show the logged-in user and the decoded assertion: the NameID, the session index and every attribute. They read it through Spring Security 7's `Saml2AssertionAuthentication` and `Saml2ResponseAssertionAccessor`, which replace the deprecated `Saml2AuthenticatedPrincipal`. +- [keycloak/test-realm.json](./keycloak/test-realm.json) is the `test-realm` realm: the SAML client, a test user and a disabled placeholder for the Okta identity provider. +- [scripts/generate-sp-credentials.sh](./scripts/generate-sp-credentials.sh) creates the SP's signing key pair. + +## Prerequisites + +- Java 17 or newer to run Gradle (the build itself uses Java 21, see above) +- Docker with the Compose plugin +- bash and OpenSSL, to create the key pair (on Windows, use Git Bash or WSL) +- Ports 8080 and 8081 free. If the repository's shared Keycloak is running, stop it first: `docker compose -f keycloak/docker-compose.yml down` from the repository root. +- An Okta account, only for the [Okta flow](#full-flow-with-okta) + +## Run it + +In `saml2/idp-initiated`: + +```sh +./scripts/generate-sp-credentials.sh +docker compose up -d --wait +./gradlew bootRun +``` + +1. `generate-sp-credentials.sh` writes a 2048-bit RSA key and a self-signed certificate, valid for 10 years, to `credentials/private.key` and `credentials/cert.crt`. The `credentials` folder is ignored by git. Running the script again keeps an existing pair; delete the folder to create a new one. +2. `docker compose up -d --wait` starts a Phase Two Keycloak on port 8080 and imports [keycloak/test-realm.json](./keycloak/test-realm.json): the realm `test-realm`, the SAML client for this SP and the user `test` / `test`. The admin console is at , with `admin` / `admin`. +3. `./gradlew bootRun` starts the SP on . Start Keycloak first: the SP downloads Keycloak's IdP metadata at startup and doesn't start without it. It doesn't start without the key pair either, and then fails with `Private key location 'URL [file:credentials/private.key]' does not exist`. + +Opening starts an SP-initiated login: the SP sends you to Keycloak, and after you log in as `test` / `test`, back to the SP. + +Use `localhost` rather than `127.0.0.1`. The SP checks the response's `Destination` and `Audience` against URLs built from the host the request came in on, and Keycloak has `localhost` URLs. + +## Quick test without Okta + +1. Open Keycloak's IdP-initiated SSO URL for the client: . +2. Log in as `test` / `test`. +3. Keycloak posts a signed SAML response to , and the SP shows "Authenticated", the NameID `test` and the attributes Keycloak sent: `email`, `firstName` and `lastName` from the client's mappers, and `Role` from Keycloak's default `role_list` client scope. + +"Log out" logs you out of both: the SP ends its session and sends a signed `LogoutRequest` to Keycloak, Keycloak ends its session and posts a `LogoutResponse` back to `/logout/saml2/slo`, and the SP shows Spring Security's default login page with "You have been signed out". Its `keycloak` link starts an SP-initiated login. + +If a login fails, the SP redirects to the same page with the reason, for example an invalid signature or a mismatched `Destination`. + +## Full flow with Okta + +The realm has a SAML identity provider `okta-broker` with placeholder values (`your-okta-domain`, `your-okta-app-id`). It is disabled until you connect it to your Okta application. + +1. In the Okta admin console, create an app integration with SAML 2.0 as the sign-in method and these settings: + + | Okta setting | Value | + | --------------------------- | ----------------------------------------------------------------------------------------- | + | Single sign-on URL | `http://localhost:8080/realms/test-realm/broker/okta-broker/endpoint/clients/okta-client` | + | Audience URI (SP Entity ID) | `http://localhost:8080/realms/test-realm` | + | Name ID format | Unspecified | + | Application username | Okta username | + + Keep "Use this for Recipient URL and Destination URL" checked. Optionally, add the attribute statements `email`, `firstName` and `lastName` (from `user.email`, `user.firstName` and `user.lastName`), then assign the application to your Okta user. The single sign-on URL is Keycloak's broker endpoint followed by `/clients/okta-client`, which tells Keycloak which client to send the user to afterwards. + +2. In the Keycloak admin console, open `test-realm` > Identity providers > `okta-broker` and replace the placeholders with the values from the application's Sign On tab in Okta ("View SAML setup instructions", or the metadata URL): the identity provider entity ID (`http://www.okta.com/...`), the single sign-on service URL and Okta's signing certificate. Keep "Validate signatures" on and the service provider entity ID `http://localhost:8080/realms/test-realm`, then enable the provider. You can also delete `okta-broker` and create it again from Okta's metadata, as the tutorial does, as long as you keep the alias `okta-broker`. + +3. Open the Okta end-user dashboard and click the application's tile. Okta posts its response to Keycloak, Keycloak posts its own to the SP, and you land on the SP logged in with your Okta username as the NameID. On the first login, Keycloak creates the user and asks you to complete the profile unless Okta sent the email and names and you have added attribute importer mappers for them to `okta-broker`. + +## The SP's signing key and the Keycloak client + +- Keycloak's IdP metadata says it wants signed authentication requests (`WantAuthnRequestsSigned="true"`), so Spring Boot doesn't start without a signing key. The SP signs its `AuthnRequest`s (SP-initiated logins) and its logout requests with `credentials/private.key`. IdP-initiated logins don't use it: the SP sends nothing, it only verifies Keycloak's signature. +- The imported client has "Client signature required" off (`saml.client.signature` is `false`), so Keycloak doesn't check the SP's signatures and needs no SP certificate. To have them checked, turn it on and give Keycloak `credentials/cert.crt` in the client's Keys tab, or create the client from the SP metadata at , which contains the certificate. After creating a new key pair, update the certificate in Keycloak too. +- Keycloak signs its SAML responses with the realm's own key (Realm settings > Keys). The SP gets the matching certificate from the IdP metadata at startup. Neither side needs the other's private key: an earlier version of this example had you copy a private key from the Keycloak client into the SP, which isn't necessary. +- The certificate's common name is the SP entity ID only to make it easy to recognize. Neither Spring nor Keycloak checks it. + +## Using your own Keycloak + +To use another Keycloak, for example a [Phase Two](https://phasetwo.io) deployment, either import [keycloak/test-realm.json](./keycloak/test-realm.json) as a new realm, or create the client in an existing realm: + +1. Point `assertingparty.metadata-uri` in [application.yaml](./src/main/resources/application.yaml) at your realm's IdP metadata: `https:///realms//protocol/saml/descriptor`, with `/auth` before `/realms` on Keycloaks that use it, such as Phase Two's. +2. Start the SP, then import its metadata in Keycloak: Clients > Import client, with the file downloaded from . This sets the client ID, the ACS and single logout URLs, and the SP's certificate. +3. On the client, set "IDP-Initiated SSO URL name" to `okta-client` and check that "Sign documents" is on. Add `email`, `firstName` and `lastName` user property mappers if you want those attributes. + +The IdP-initiated SSO URL is then `https:///realms//protocol/saml/clients/okta-client`. + +## Tests + +```sh +./gradlew build +``` + +The tests need neither Keycloak nor a key pair, so they run in CI as they are. [src/test/resources/application.yaml](./src/test/resources/application.yaml) replaces the main configuration: it reads the IdP metadata from [test-realm-descriptor.xml](./src/test/resources/test-realm-descriptor.xml), a Keycloak descriptor for `test-realm` with a throwaway certificate, and sets `singlesignon.sign-request: false`, so no signing key is needed. The tests check the SP metadata endpoint, the redirect to the SAML login, the authentication request sent to Keycloak and the page shown after a SAML login. + +The [GitHub workflow](../../.github/workflows/saml2-idp-initiated.yml) runs `./gradlew build` with Java 21. + +## Stop + +```sh +docker compose down +``` + +Keycloak keeps nothing: the next `docker compose up` imports the realm again. + +## Security notes + +- Earlier versions of this example committed an SP private key (`src/main/resources/credentials/private.key`) and a Keycloak client private key (in `keycloak/saml-client.json`). They remain in the git history, so they are public: never trust or reuse them. +- `admin` / `admin` and `test` / `test` exist only in the local container. +- The Okta identity provider validates Okta's signatures. Don't turn "Validate signatures" off: Keycloak would then accept any response posted to its broker endpoint. From e5fc951ee824530c52b7b41f918818a1cd2ab748 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:02:49 -0300 Subject: [PATCH 6/8] Add CI workflow for the SAML example --- .github/workflows/saml2-idp-initiated.yml | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/saml2-idp-initiated.yml diff --git a/.github/workflows/saml2-idp-initiated.yml b/.github/workflows/saml2-idp-initiated.yml new file mode 100644 index 0000000..f3db11a --- /dev/null +++ b/.github/workflows/saml2-idp-initiated.yml @@ -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 From 5323102163a7aa86a6d82903b2047750675c2402 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:24:37 -0300 Subject: [PATCH 7/8] Rename the SAML realm file so Keycloak imports it --- saml2/idp-initiated/README.md | 6 +++--- .../keycloak/{test-realm.json => test-realm-export.json} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename saml2/idp-initiated/keycloak/{test-realm.json => test-realm-export.json} (100%) diff --git a/saml2/idp-initiated/README.md b/saml2/idp-initiated/README.md index 74a86f1..77702ea 100644 --- a/saml2/idp-initiated/README.md +++ b/saml2/idp-initiated/README.md @@ -47,7 +47,7 @@ Without Okta, the flow starts at Keycloak's `/protocol/saml/clients/okta-client` - [application.yaml](./src/main/resources/application.yaml) defines the relying party registration `keycloak`: the SP's entity ID, ACS and single logout URLs, its signing key pair, and the URL of Keycloak's IdP metadata, which Spring reads at startup to learn Keycloak's endpoints and signing certificate. `{baseUrl}` is replaced with the URL the request came in on. - [SecurityConfiguration.java](./src/main/java/com/saml2/idp_initiated/SecurityConfiguration.java) requires a login for every page. `saml2Login` processes SAML responses posted to `/login/saml2/sso` (the registration is found from the response's issuer, so the ACS URL needs no registration ID) and sends users who aren't logged in to Keycloak. `saml2Logout` handles single logout, and `saml2Metadata` publishes the SP metadata, which Spring Boot's default configuration doesn't. - [RootController.java](./src/main/java/com/saml2/idp_initiated/controllers/RootController.java) and [index.html](./src/main/resources/templates/index.html) show the logged-in user and the decoded assertion: the NameID, the session index and every attribute. They read it through Spring Security 7's `Saml2AssertionAuthentication` and `Saml2ResponseAssertionAccessor`, which replace the deprecated `Saml2AuthenticatedPrincipal`. -- [keycloak/test-realm.json](./keycloak/test-realm.json) is the `test-realm` realm: the SAML client, a test user and a disabled placeholder for the Okta identity provider. +- [keycloak/test-realm-export.json](./keycloak/test-realm-export.json) is the `test-realm` realm: the SAML client, a test user and a disabled placeholder for the Okta identity provider. Keep the `-export` suffix: Keycloak's import reads a file named `-realm.json` as the realm ``, so `test-realm.json` would fail to import. - [scripts/generate-sp-credentials.sh](./scripts/generate-sp-credentials.sh) creates the SP's signing key pair. ## Prerequisites @@ -69,7 +69,7 @@ docker compose up -d --wait ``` 1. `generate-sp-credentials.sh` writes a 2048-bit RSA key and a self-signed certificate, valid for 10 years, to `credentials/private.key` and `credentials/cert.crt`. The `credentials` folder is ignored by git. Running the script again keeps an existing pair; delete the folder to create a new one. -2. `docker compose up -d --wait` starts a Phase Two Keycloak on port 8080 and imports [keycloak/test-realm.json](./keycloak/test-realm.json): the realm `test-realm`, the SAML client for this SP and the user `test` / `test`. The admin console is at , with `admin` / `admin`. +2. `docker compose up -d --wait` starts a Phase Two Keycloak on port 8080 and imports [keycloak/test-realm-export.json](./keycloak/test-realm-export.json): the realm `test-realm`, the SAML client for this SP and the user `test` / `test`. The admin console is at , with `admin` / `admin`. 3. `./gradlew bootRun` starts the SP on . Start Keycloak first: the SP downloads Keycloak's IdP metadata at startup and doesn't start without it. It doesn't start without the key pair either, and then fails with `Private key location 'URL [file:credentials/private.key]' does not exist`. Opening starts an SP-initiated login: the SP sends you to Keycloak, and after you log in as `test` / `test`, back to the SP. @@ -114,7 +114,7 @@ The realm has a SAML identity provider `okta-broker` with placeholder values (`y ## Using your own Keycloak -To use another Keycloak, for example a [Phase Two](https://phasetwo.io) deployment, either import [keycloak/test-realm.json](./keycloak/test-realm.json) as a new realm, or create the client in an existing realm: +To use another Keycloak, for example a [Phase Two](https://phasetwo.io) deployment, either import [keycloak/test-realm-export.json](./keycloak/test-realm-export.json) as a new realm, or create the client in an existing realm: 1. Point `assertingparty.metadata-uri` in [application.yaml](./src/main/resources/application.yaml) at your realm's IdP metadata: `https:///realms//protocol/saml/descriptor`, with `/auth` before `/realms` on Keycloaks that use it, such as Phase Two's. 2. Start the SP, then import its metadata in Keycloak: Clients > Import client, with the file downloaded from . This sets the client ID, the ACS and single logout URLs, and the SP's certificate. diff --git a/saml2/idp-initiated/keycloak/test-realm.json b/saml2/idp-initiated/keycloak/test-realm-export.json similarity index 100% rename from saml2/idp-initiated/keycloak/test-realm.json rename to saml2/idp-initiated/keycloak/test-realm-export.json From 3714b21d633ad8f307ae597ef353fa5cb480c8b9 Mon Sep 17 00:00:00 2001 From: WictorGirardi Date: Thu, 24 Sep 2026 16:24:45 -0300 Subject: [PATCH 8/8] Correct SAML README claims found in end-to-end testing --- saml2/idp-initiated/README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/saml2/idp-initiated/README.md b/saml2/idp-initiated/README.md index 77702ea..c0dbcd5 100644 --- a/saml2/idp-initiated/README.md +++ b/saml2/idp-initiated/README.md @@ -74,17 +74,21 @@ docker compose up -d --wait Opening starts an SP-initiated login: the SP sends you to Keycloak, and after you log in as `test` / `test`, back to the SP. -Use `localhost` rather than `127.0.0.1`. The SP checks the response's `Destination` and `Audience` against URLs built from the host the request came in on, and Keycloak has `localhost` URLs. +Use `localhost` rather than `127.0.0.1`. The SP builds its entity ID and URLs from the host the request came in on, so on `127.0.0.1` it introduces itself as `http://127.0.0.1:8081/saml2/metadata`, which isn't a client in the realm, and Keycloak answers "Invalid Request". ## Quick test without Okta 1. Open Keycloak's IdP-initiated SSO URL for the client: . 2. Log in as `test` / `test`. -3. Keycloak posts a signed SAML response to , and the SP shows "Authenticated", the NameID `test` and the attributes Keycloak sent: `email`, `firstName` and `lastName` from the client's mappers, and `Role` from Keycloak's default `role_list` client scope. +3. Keycloak posts a signed SAML response to , and the SP shows "Authenticated", the NameID `test` and the attributes from the client's mappers: `email`, `firstName` and `lastName`. Keycloak's default `role_list` client scope also sends a `Role` attribute for users who have roles, such as users created in the admin console. The imported `test` user has none. "Log out" logs you out of both: the SP ends its session and sends a signed `LogoutRequest` to Keycloak, Keycloak ends its session and posts a `LogoutResponse` back to `/logout/saml2/slo`, and the SP shows Spring Security's default login page with "You have been signed out". Its `keycloak` link starts an SP-initiated login. -If a login fails, the SP redirects to the same page with the reason, for example an invalid signature or a mismatched `Destination`. +If a login fails, the SP sends you to the same page with only "Invalid credentials". The reason, for example `invalid_signature` or `invalid_destination`, is logged at trace level: + +```sh +./gradlew bootRun --args='--logging.level.org.springframework.security.saml2=TRACE' +``` ## Full flow with Okta @@ -117,7 +121,7 @@ The realm has a SAML identity provider `okta-broker` with placeholder values (`y To use another Keycloak, for example a [Phase Two](https://phasetwo.io) deployment, either import [keycloak/test-realm-export.json](./keycloak/test-realm-export.json) as a new realm, or create the client in an existing realm: 1. Point `assertingparty.metadata-uri` in [application.yaml](./src/main/resources/application.yaml) at your realm's IdP metadata: `https:///realms//protocol/saml/descriptor`, with `/auth` before `/realms` on Keycloaks that use it, such as Phase Two's. -2. Start the SP, then import its metadata in Keycloak: Clients > Import client, with the file downloaded from . This sets the client ID, the ACS and single logout URLs, and the SP's certificate. +2. Start the SP, then import its metadata in Keycloak: Clients > Import client, with the file downloaded from . This sets the client ID, the ACS and single logout URLs, and the SP's certificate, and turns "Client signature required" on, so Keycloak checks the SP's signatures. 3. On the client, set "IDP-Initiated SSO URL name" to `okta-client` and check that "Sign documents" is on. Add `email`, `firstName` and `lastName` user property mappers if you want those attributes. The IdP-initiated SSO URL is then `https:///realms//protocol/saml/clients/okta-client`. @@ -138,10 +142,11 @@ The [GitHub workflow](../../.github/workflows/saml2-idp-initiated.yml) runs `./g docker compose down ``` -Keycloak keeps nothing: the next `docker compose up` imports the realm again. +Keycloak keeps nothing: the next `docker compose up` imports the realm again, with new signing keys. Restart the SP after that, since it reads Keycloak's certificate only at startup; until then, logins fail with `invalid_signature`. ## Security notes - Earlier versions of this example committed an SP private key (`src/main/resources/credentials/private.key`) and a Keycloak client private key (in `keycloak/saml-client.json`). They remain in the git history, so they are public: never trust or reuse them. - `admin` / `admin` and `test` / `test` exist only in the local container. - The Okta identity provider validates Okta's signatures. Don't turn "Validate signatures" off: Keycloak would then accept any response posted to its broker endpoint. +- Spring Security accepts a response with an `InResponseTo` only in the browser session that sent the request, but it accepts an unsolicited, IdP-initiated response in any session and doesn't remember the responses it has already accepted. A captured response therefore logs in again until it expires: with Keycloak's defaults, about a minute after it is issued, plus the five minutes of clock skew Spring allows. Keep the client's "Assertion Lifespan" short in Keycloak and use HTTPS outside your machine.