├── .editorconfig ├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── active-issue-pr.yml │ ├── build-master.yml │ ├── dokka.yml │ ├── manual_testing.yml │ ├── publish-release.yml │ ├── test-dp-pr.yaml │ └── test-pr.yaml ├── .gitignore ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── build.gradle.kts ├── docker-compose-ssl.yaml ├── docker-compose.yaml ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── kotlin │ └── no │ │ └── nav │ │ └── security │ │ └── mock │ │ └── oauth2 │ │ ├── MockOAuth2Server.kt │ │ ├── OAuth2Config.kt │ │ ├── OAuth2Exception.kt │ │ ├── StandaloneMockOAuth2Server.kt │ │ ├── debugger │ │ ├── Client.kt │ │ ├── DebuggerRequestHandler.kt │ │ └── SessionManager.kt │ │ ├── extensions │ │ ├── AsOAuth2HttpRequest.kt │ │ ├── HttpUrlExtensions.kt │ │ ├── NimbusExtensions.kt │ │ ├── String.kt │ │ └── Template.kt │ │ ├── grant │ │ ├── AuthorizationCodeHandler.kt │ │ ├── ClientCredentialsGrantHandler.kt │ │ ├── GrantHandler.kt │ │ ├── JwtBearerGrantHandler.kt │ │ ├── PasswordGrantHandler.kt │ │ ├── RefreshTokenGrantHandler.kt │ │ ├── RefreshTokenManager.kt │ │ ├── TokenExchangeGrant.kt │ │ └── TokenExchangeGrantHandler.kt │ │ ├── http │ │ ├── CorsInterceptor.kt │ │ ├── OAuth2HttpRequest.kt │ │ ├── OAuth2HttpRequestHandler.kt │ │ ├── OAuth2HttpResponse.kt │ │ ├── OAuth2HttpRouter.kt │ │ ├── OAuth2HttpServer.kt │ │ └── Ssl.kt │ │ ├── introspect │ │ └── Introspect.kt │ │ ├── login │ │ └── LoginRequestHandler.kt │ │ ├── templates │ │ └── TemplateMapper.kt │ │ ├── token │ │ ├── KeyGenerator.kt │ │ ├── KeyProvider.kt │ │ ├── OAuth2TokenCallback.kt │ │ └── OAuth2TokenProvider.kt │ │ └── userinfo │ │ └── UserInfo.kt └── resources │ ├── logback-standalone.xml │ ├── mock-oauth2-server-keys-ec.json │ ├── mock-oauth2-server-keys.json │ └── templates │ ├── authorization_code_response.ftl │ ├── css │ ├── custom.css │ ├── normalize.css │ └── skeleton.css │ ├── debugger.ftl │ ├── debugger_callback.ftl │ ├── error.ftl │ ├── login.ftl │ └── main.ftl └── test ├── java └── examples │ └── java │ └── springboot │ ├── MockOAuth2ServerInitializer.java │ ├── login │ ├── OAuth2LoginApp.java │ └── OAuth2LoginAppTest.java │ └── resourceserver │ ├── OAuth2ResourceServerApp.java │ └── OAuth2ResourceServerAppTest.java ├── kotlin ├── examples │ └── kotlin │ │ └── ktor │ │ ├── client │ │ ├── OAuth2Client.kt │ │ └── OAuth2ClientTest.kt │ │ ├── login │ │ ├── OAuth2LoginApp.kt │ │ └── OAuth2LoginAppTest.kt │ │ └── resourceserver │ │ ├── OAuth2ResourceServerApp.kt │ │ └── OAuth2ResourceServerAppTest.kt └── no │ └── nav │ └── security │ └── mock │ └── oauth2 │ ├── MockOAuth2ServerTest.kt │ ├── OAuth2ConfigTest.kt │ ├── StandaloneMockOAuth2ServerKtTest.kt │ ├── e2e │ ├── CorsHeadersIntegrationTest.kt │ ├── InteractiveLoginIntegrationTest.kt │ ├── JwtBearerGrantIntegrationTest.kt │ ├── LoginPageIntegrationTest.kt │ ├── MockOAuth2ServerIntegrationTest.kt │ ├── OidcAuthorizationCodeGrantIntegrationTest.kt │ ├── PasswordGrantIntegrationTest.kt │ ├── RefreshTokenGrantIntegrationTest.kt │ ├── RevocationIntegrationTest.kt │ ├── StaticAssetsIntegrationTest.kt │ ├── TokenExchangeGrantIntegrationTest.kt │ ├── UserInfoIntegrationTest.kt │ └── WellKnownIntegrationTest.kt │ ├── examples │ ├── AbstractExampleApp.kt │ ├── clientcredentials │ │ ├── ExampleAppWithClientCredentialsClient.kt │ │ └── ExampleAppWithClientCredentialsClientTest.kt │ ├── openidconnect │ │ ├── ExampleAppWithOpenIdConnect.kt │ │ └── ExampleAppWithOpenIdConnectTest.kt │ └── securedapi │ │ ├── ExampleAppWithSecuredApi.kt │ │ └── ExampleAppWithSecuredApiTest.kt │ ├── extensions │ ├── HttpUrlExtensionsTest.kt │ └── TemplateTest.kt │ ├── grant │ ├── AuthorizationCodeHandlerTest.kt │ └── RefreshTokenManagerTest.kt │ ├── http │ ├── OAuth2HttpRequestHandlerTest.kt │ ├── OAuth2HttpRequestTest.kt │ └── OAuth2HttpRouterTest.kt │ ├── introspect │ └── IntrospectTest.kt │ ├── login │ └── LoginRequestHandlerTest.kt │ ├── server │ └── OAuth2HttpServerTest.kt │ ├── testutils │ ├── Grant.kt │ ├── Http.kt │ └── Token.kt │ ├── token │ ├── KeyGeneratorTest.kt │ ├── KeyProviderTest.kt │ ├── OAuth2TokenCallbackTest.kt │ ├── OAuth2TokenProviderECTest.kt │ └── OAuth2TokenProviderRSATest.kt │ └── userinfo │ └── UserInfoTest.kt └── resources ├── META-INF └── spring.factories ├── application.yml ├── config-ssl.json ├── config.json ├── junit-plattform.properties ├── localhost.p12 ├── login.example.html └── static ├── nav-logo-red.svg ├── test.css ├── test.js └── test.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | charset = utf-8 4 | end_of_line = lf 5 | indent_style = space 6 | insert_final_newline = true 7 | [*.json] 8 | indent_size = 2 9 | [*.{kts,kt}] 10 | indent_size = 4 11 | max_line_length = 160 12 | ij_continuation_indent_size = 4 13 | ij_kotlin_align_in_columns_case_branch = false 14 | ij_kotlin_align_multiline_binary_operation = false 15 | ij_kotlin_align_multiline_extends_list = false 16 | ij_kotlin_align_multiline_method_parentheses = false 17 | ij_kotlin_align_multiline_parameters = true 18 | ij_kotlin_align_multiline_parameters_in_calls = false 19 | ij_kotlin_assignment_wrap = normal 20 | ij_kotlin_blank_lines_after_class_header = 0 21 | ij_kotlin_blank_lines_around_block_when_branches = 0 22 | ij_kotlin_block_comment_at_first_column = true 23 | ij_kotlin_call_parameters_new_line_after_left_paren = true 24 | ij_kotlin_call_parameters_right_paren_on_new_line = true 25 | ij_kotlin_call_parameters_wrap = on_every_item 26 | ij_kotlin_catch_on_new_line = false 27 | ij_kotlin_class_annotation_wrap = split_into_lines 28 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 29 | ij_kotlin_continuation_indent_for_chained_calls = false 30 | ij_kotlin_continuation_indent_for_expression_bodies = false 31 | ij_kotlin_continuation_indent_in_argument_lists = false 32 | ij_kotlin_continuation_indent_in_elvis = false 33 | ij_kotlin_continuation_indent_in_if_conditions = false 34 | ij_kotlin_continuation_indent_in_parameter_lists = false 35 | ij_kotlin_continuation_indent_in_supertype_lists = false 36 | ij_kotlin_else_on_new_line = false 37 | ij_kotlin_enum_constants_wrap = off 38 | ij_kotlin_extends_list_wrap = normal 39 | ij_kotlin_field_annotation_wrap = split_into_lines 40 | ij_kotlin_finally_on_new_line = false 41 | ij_kotlin_if_rparen_on_new_line = true 42 | ij_kotlin_import_nested_classes = false 43 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 44 | ij_kotlin_keep_blank_lines_before_right_brace = 2 45 | ij_kotlin_keep_blank_lines_in_code = 2 46 | ij_kotlin_keep_blank_lines_in_declarations = 2 47 | ij_kotlin_keep_first_column_comment = true 48 | ij_kotlin_keep_indents_on_empty_lines = false 49 | ij_kotlin_keep_line_breaks = true 50 | ij_kotlin_lbrace_on_next_line = false 51 | ij_kotlin_line_comment_add_space = false 52 | ij_kotlin_line_comment_at_first_column = true 53 | ij_kotlin_method_annotation_wrap = split_into_lines 54 | ij_kotlin_method_call_chain_wrap = normal 55 | ij_kotlin_method_parameters_new_line_after_left_paren = true 56 | ij_kotlin_method_parameters_right_paren_on_new_line = true 57 | ij_kotlin_method_parameters_wrap = on_every_item 58 | ij_kotlin_name_count_to_use_star_import = 2147483647 59 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 60 | ij_kotlin_parameter_annotation_wrap = off 61 | ij_kotlin_space_after_comma = true 62 | ij_kotlin_space_after_extend_colon = true 63 | ij_kotlin_space_after_type_colon = true 64 | ij_kotlin_space_before_catch_parentheses = true 65 | ij_kotlin_space_before_comma = false 66 | ij_kotlin_space_before_extend_colon = true 67 | ij_kotlin_space_before_for_parentheses = true 68 | ij_kotlin_space_before_if_parentheses = true 69 | ij_kotlin_space_before_lambda_arrow = true 70 | ij_kotlin_space_before_type_colon = false 71 | ij_kotlin_space_before_when_parentheses = true 72 | ij_kotlin_space_before_while_parentheses = true 73 | ij_kotlin_spaces_around_additive_operators = true 74 | ij_kotlin_spaces_around_assignment_operators = true 75 | ij_kotlin_spaces_around_equality_operators = true 76 | ij_kotlin_spaces_around_function_type_arrow = true 77 | ij_kotlin_spaces_around_logical_operators = true 78 | ij_kotlin_spaces_around_multiplicative_operators = true 79 | ij_kotlin_spaces_around_range = false 80 | ij_kotlin_spaces_around_relational_operators = true 81 | ij_kotlin_spaces_around_unary_operator = false 82 | ij_kotlin_spaces_around_when_arrow = true 83 | ij_kotlin_variable_annotation_wrap = off 84 | ij_kotlin_while_on_new_line = false 85 | ij_kotlin_wrap_elvis_expressions = 1 86 | ij_kotlin_wrap_expression_body_functions = 1 87 | ij_kotlin_wrap_first_method_in_call_chain = false 88 | 89 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | # Files stored in repository root 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | open-pull-requests-limit: 10 9 | groups: 10 | github: 11 | patterns: 12 | - "*" 13 | - package-ecosystem: github-actions 14 | directory: "/" 15 | schedule: 16 | interval: daily 17 | open-pull-requests-limit: 10 18 | groups: 19 | github: 20 | patterns: 21 | - "*" 22 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: $NEXT_PATCH_VERSION 2 | tag-template: $NEXT_PATCH_VERSION 3 | change-template: '- $TITLE (#$NUMBER) @$AUTHOR' 4 | categories: 5 | - title: '🚀 Features' 6 | labels: 7 | - 'feature' 8 | - 'enhancement' 9 | - title: '⚠️ Breaking Changes' 10 | labels: 11 | - 'breaking' 12 | - title: '🐛 Bug Fixes' 13 | labels: 14 | - 'fix' 15 | - 'bugfix' 16 | - 'bug' 17 | - title: '🧰 Maintenance' 18 | labels: 19 | - 'chore' 20 | - title: '⬆️ Dependency upgrades' 21 | labels: 22 | - 'bump' 23 | - 'dependencies' 24 | exclude-labels: 25 | - 'skip-changelog' 26 | template: | 27 | ## What's Changed 28 | $CHANGES 29 | -------------------------------------------------------------------------------- /.github/workflows/active-issue-pr.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "00 10 * * 1-5" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | days-before-issue-stale: 60 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | remove-issue-stale-when-updated: true 19 | stale-issue-message: "This issue is stale because it has been open for 60 days with no activity." 20 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 21 | days-before-pr-stale: 15 22 | days-before-pr-close: 10 23 | remove-pr-stale-when-updated: true 24 | labels-to-add-when-unstale: "renewed" 25 | repo-token: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/build-master.yml: -------------------------------------------------------------------------------- 1 | name: Build master 2 | on: 3 | push: 4 | branches: 5 | - master 6 | permissions: 7 | contents: read 8 | jobs: 9 | build: 10 | permissions: 11 | packages: write 12 | contents: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout latest code 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 17 | - name: Set up JDK 17 18 | uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # ratchet:actions/setup-java@v4 19 | with: 20 | java-version: 17 21 | distribution: 'zulu' 22 | cache: 'gradle' 23 | - name: Setup Gradle 24 | uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # ratchet:gradle/actions/setup-gradle@v4 25 | - name: Generate and submit dependency graph 26 | uses: gradle/actions/dependency-submission@8379f6a1328ee0e06e2bb424dadb7b159856a326 # ratchet:gradle/actions/dependency-submission@v4 27 | - name: Build with Gradle 28 | run: ./gradlew build 29 | release-notes: 30 | permissions: 31 | contents: write # for release-drafter/release-drafter to create a github release 32 | pull-requests: write # for release-drafter/release-drafter to add label to PRs 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Release Drafter 36 | uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # ratchet:release-drafter/release-drafter@v6 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/dokka.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | dokka: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | packages: write 16 | steps: 17 | - name: Checkout latest code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up JDK 17 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: 17 24 | distribution: 'zulu' 25 | cache: 'gradle' 26 | 27 | - name: Get the tag name 28 | run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 29 | 30 | - name: Build Dokka 31 | run: ./gradlew dokkaHtm -Pversion=${{ env.VERSION }} 32 | 33 | - name: Publish documentation 34 | uses: JamesIves/github-pages-deploy-action@v4.7.3 35 | with: 36 | branch: gh-pages 37 | folder: build/dokka/html 38 | target-folder: docs 39 | commit-message: "doc: Add documentation for latest release: ${{ env.VERSION }}" 40 | -------------------------------------------------------------------------------- /.github/workflows/manual_testing.yml: -------------------------------------------------------------------------------- 1 | name: Manually triggered playground 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | 8 | IMAGE_NAME: ttl.sh/${{ github.repository }} 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout latest code 14 | uses: actions/checkout@v4 15 | with: 16 | ref: add-arm64-architecture 17 | 18 | - name: Set up JDK 17 19 | uses: actions/setup-java@v4 20 | with: 21 | java-version: 17 22 | distribution: 'zulu' 23 | cache: 'gradle' 24 | 25 | - name: Build JVM stuff 26 | run: ./gradlew build 27 | 28 | - name: Build Docker images for amd64 and arm64 29 | # The GITHUB_REF tag comes in the format 'refs/tags/xxx'. 30 | # So if we split on '/' and take the 3rd value, we can get the release name. 31 | run: | 32 | NEW_VERSION=1h 33 | IMAGE=${IMAGE_NAME}:${NEW_VERSION} 34 | echo "Building new version ${NEW_VERSION} of $IMAGE" 35 | ./gradlew jib --image="${IMAGE}" 36 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | IMAGE_NAME: ghcr.io/${{ github.repository }} 9 | 10 | jobs: 11 | publish-release: 12 | permissions: 13 | packages: write 14 | contents: read 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout latest code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up JDK 17 22 | uses: actions/setup-java@v4 23 | with: 24 | java-version: 17 25 | distribution: 'zulu' 26 | cache: 'gradle' 27 | 28 | - name: Login to GitHub Container Registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Publish artifact 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | GPG_KEYS: ${{ secrets.GPG_KEYS }} 39 | GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }} 40 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 41 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 42 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 43 | 44 | # The GITHUB_REF tag comes in the format 'refs/tags/xxx'. 45 | # So if we split on '/' and take the 3rd value, we can get the release name. 46 | run: | 47 | export GPG_TTY=$(tty) && echo "$GPG_KEYS" | gpg --fast-import --batch 48 | NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3) 49 | IMAGE=${IMAGE_NAME}:${NEW_VERSION} 50 | echo "Releasing new version ${NEW_VERSION} of $IMAGE" 51 | ./gradlew -Pversion=${NEW_VERSION} publish publishToSonatype closeAndReleaseStagingRepository -Dorg.gradle.internal.publish.checksums.insecure=true --info 52 | ./gradlew jib --image="${IMAGE}" 53 | 54 | -------------------------------------------------------------------------------- /.github/workflows/test-dp-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Test PR 2 | on: 3 | pull_request_target: 4 | paths-ignore: 5 | - '*.md' 6 | - 'LICENSE.md' 7 | 8 | permissions: 9 | pull-requests: write 10 | contents: write 11 | 12 | jobs: 13 | test_dp_pr: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.actor == 'dependabot[bot]' }} 16 | steps: 17 | - name: Checkout latest code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up JDK 17 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: 17 24 | distribution: 'zulu' 25 | cache: 'gradle' 26 | 27 | - name: Build with Gradle 28 | run: ./gradlew build 29 | 30 | dependabot_pr: 31 | runs-on: ubuntu-latest 32 | if: ${{ github.actor == 'dependabot[bot]' }} 33 | needs: test_dp_pr 34 | steps: 35 | - name: Dependabot metadata 36 | id: dependabot-metadata 37 | uses: dependabot/fetch-metadata@v2.4.0 38 | with: 39 | github-token: "${{ secrets.GITHUB_TOKEN }}" 40 | - name: Approve a PR 41 | run: gh pr review --approve "$PR_URL" 42 | env: 43 | PR_URL: ${{ github.event.pull_request.html_url }} 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Enable auto-merge for Dependabot PRs 46 | if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }} 47 | run: gh pr merge --auto --squash "$PR_URL" 48 | env: 49 | PR_URL: ${{ github.event.pull_request.html_url }} 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.github/workflows/test-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Test PR 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - '*.md' 6 | - 'LICENSE.md' 7 | 8 | jobs: 9 | test_pr: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout latest code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up JDK 17 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: 17 19 | distribution: 'zulu' 20 | cache: 'gradle' 21 | 22 | - name: Build with Gradle 23 | run: ./gradlew build 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Idea project files 2 | *.iml 3 | .idea/ 4 | 5 | # Gradle files 6 | out/ 7 | build/ 8 | .gradle/ 9 | /gradle/caches/ 10 | /gradle/daemon/ 11 | /gradle/native/ 12 | /gradle/wrapper/dists/ 13 | 14 | .DS_Store 15 | /compose/ 16 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @navikt/pig-sikkerhet 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | This project is open to accept feature requests and contributions from the open source community. 3 | Please fork the repo and start a new branch to work on. 4 | 5 | 6 | ## Building locally 7 | This project is using [Gradle](https://gradle.org/) for its build tool. 8 | A Gradle Wrapper is included in the code though, so you do not have to manage your own installation. 9 | 10 | To run a build simply execute the following: 11 | 12 | ```shell script 13 | ./gradlew build 14 | ``` 15 | 16 | This will run all the steps defined in the `build.gradle.kts` file. 17 | 18 | 19 | ## Testing 20 | If you are adding a new feature or bug fix please ensure there is proper test coverage. 21 | 22 | ## Pull Request Review 23 | If you have a branch on your fork that is ready to be merged, please create a new pull request. The maintainers will review to make sure the above guidelines have been followed and if the changes are helpful to all library users, they will be merged. 24 | 25 | ## Releasing 26 | The release process has been automated in GitHub Actions. Every merge into master is automatically added to the 27 | [draft release notes](https://github.com/navikt/mock-oauth2-server/releases) of the next version. Once the next 28 | version is ready to be released, simply publish the release with the version name as the title and tag and this 29 | will trigger to publishing process. 30 | 31 | This project uses [semantic versioning](https://semver.org/) and does NOT prefix tags or release titles with `v` i.e. use `1.2.3` instead of `v1.2.3` 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2025 NAV (Arbeids- og velferdsdirektoratet) - The Norwegian Labour and Welfare Administration 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 21 | USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose-ssl.yaml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | mock-oauth2-server: 5 | image: mock-oauth2-server:latest 6 | ports: 7 | - "8080:8080" 8 | volumes: 9 | - ./src/test/resources/config-ssl.json:/app/config.json 10 | environment: 11 | LOG_LEVEL: "debug" 12 | SERVER_PORT: 8080 13 | JSON_CONFIG_PATH: /app/config.json 14 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | mock-oauth2-server: 5 | image: mock-oauth2-server:latest 6 | ports: 7 | - "8080:8080" 8 | volumes: 9 | - ./src/test/resources/config.json:/app/config.json 10 | - ./src/test/resources/login.example.html:/app/login/login.example.html 11 | - ./src/test/resources/static/:/app/static/ 12 | environment: 13 | LOG_LEVEL: "debug" 14 | SERVER_PORT: 8080 15 | JSON_CONFIG_PATH: /app/config.json 16 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | group=no.nav.security 3 | # workaround for kotest: see https://github.com/kotest/kotest/issues/3035 4 | org.gradle.jvmargs=--add-opens=java.base/java.util=ALL-UNNAMED 5 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlinTarget = "1.9.0" # Minimum supported Kotlin version for consumers of the library 3 | kotlinToolchain = "2.1.21" # Actual version used by tooling within this project 4 | 5 | [plugins] 6 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinToolchain" } 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/navikt/mock-oauth2-server/2c7a77305feff178887e8930c21b81d52588b85a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'mock-oauth2-server' -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/OAuth2Exception.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2 2 | 3 | import com.nimbusds.oauth2.sdk.ErrorObject 4 | import com.nimbusds.oauth2.sdk.GrantType 5 | import com.nimbusds.oauth2.sdk.OAuth2Error 6 | import com.nimbusds.oauth2.sdk.http.HTTPResponse 7 | 8 | @Suppress("unused") 9 | class OAuth2Exception( 10 | val errorObject: ErrorObject?, 11 | msg: String, 12 | throwable: Throwable?, 13 | ) : RuntimeException(msg, throwable) { 14 | constructor(msg: String) : this(null, msg, null) 15 | constructor(msg: String, throwable: Throwable?) : this(null, msg, throwable) 16 | constructor(errorObject: ErrorObject?, msg: String) : this(errorObject, msg, null) 17 | } 18 | 19 | fun missingParameter(name: String): Nothing = 20 | "missing required parameter $name".let { 21 | throw OAuth2Exception(OAuth2Error.INVALID_REQUEST.setDescription(it), it) 22 | } 23 | 24 | fun invalidGrant(grantType: GrantType): Nothing = 25 | "grant_type $grantType not supported.".let { 26 | throw OAuth2Exception(OAuth2Error.INVALID_GRANT.setDescription(it), it) 27 | } 28 | 29 | fun invalidRequest(message: String): Nothing = 30 | message.let { 31 | throw OAuth2Exception(OAuth2Error.INVALID_REQUEST.setDescription(message), message) 32 | } 33 | 34 | fun notFound(message: String): Nothing = throw OAuth2Exception(ErrorObject("not_found", "Resource not found", HTTPResponse.SC_NOT_FOUND), message) 35 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/StandaloneMockOAuth2Server.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2 2 | 3 | import ch.qos.logback.classic.ClassicConstants 4 | import no.nav.security.mock.oauth2.StandaloneConfig.hostname 5 | import no.nav.security.mock.oauth2.StandaloneConfig.oauth2Config 6 | import no.nav.security.mock.oauth2.StandaloneConfig.port 7 | import no.nav.security.mock.oauth2.http.NettyWrapper 8 | import no.nav.security.mock.oauth2.http.OAuth2HttpResponse 9 | import no.nav.security.mock.oauth2.http.route 10 | import java.io.File 11 | import java.io.FileNotFoundException 12 | import java.net.InetAddress 13 | import java.net.InetSocketAddress 14 | 15 | object StandaloneConfig { 16 | const val JSON_CONFIG = "JSON_CONFIG" 17 | const val JSON_CONFIG_PATH = "JSON_CONFIG_PATH" 18 | const val SERVER_HOSTNAME = "SERVER_HOSTNAME" 19 | const val SERVER_PORT = "SERVER_PORT" 20 | const val PORT = "PORT" // Supports running Docker image on Heroku. 21 | 22 | fun hostname(): InetAddress = 23 | SERVER_HOSTNAME 24 | .fromEnv() 25 | ?.let { InetAddress.getByName(it) } ?: InetSocketAddress(0).address 26 | 27 | fun port(): Int = (SERVER_PORT.fromEnv()?.toInt() ?: PORT.fromEnv()?.toInt()) ?: 8080 28 | 29 | fun oauth2Config(): OAuth2Config = 30 | with(jsonFromEnv()) { 31 | if (this != null) { 32 | OAuth2Config.fromJson(this) 33 | } else { 34 | OAuth2Config( 35 | interactiveLogin = true, 36 | httpServer = NettyWrapper(), 37 | ) 38 | } 39 | } 40 | 41 | private fun jsonFromEnv() = JSON_CONFIG.fromEnv() ?: JSON_CONFIG_PATH.fromEnv("config.json").readFile() 42 | 43 | private fun String.readFile(): String? = 44 | try { 45 | File(this).readText() 46 | } catch (e: FileNotFoundException) { 47 | null 48 | } 49 | } 50 | 51 | fun main() { 52 | System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, "LOGBACK_CONFIG".fromEnv("logback-standalone.xml")) 53 | MockOAuth2Server( 54 | oauth2Config(), 55 | route("/isalive") { 56 | OAuth2HttpResponse(status = 200, body = "alive and well") 57 | }, 58 | ).apply { 59 | start(hostname(), port()) 60 | } 61 | } 62 | 63 | fun String.fromEnv(default: String): String = System.getenv(this) ?: default 64 | 65 | fun String.fromEnv(): String? = System.getenv(this) 66 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/debugger/Client.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.debugger 2 | 3 | import com.nimbusds.oauth2.sdk.OAuth2Error 4 | import no.nav.security.mock.oauth2.OAuth2Exception 5 | import no.nav.security.mock.oauth2.http.Ssl 6 | import okhttp3.Credentials 7 | import okhttp3.Headers 8 | import okhttp3.HttpUrl 9 | import okhttp3.MediaType.Companion.toMediaType 10 | import okhttp3.OkHttpClient 11 | import okhttp3.Request 12 | import okhttp3.RequestBody.Companion.toRequestBody 13 | import okhttp3.internal.toHostHeader 14 | import java.net.URLEncoder 15 | import java.nio.charset.StandardCharsets 16 | import javax.net.ssl.SSLContext 17 | import javax.net.ssl.TrustManagerFactory 18 | import javax.net.ssl.X509TrustManager 19 | 20 | internal class TokenRequest( 21 | val url: HttpUrl, 22 | clientAuthentication: ClientAuthentication, 23 | parameters: Map, 24 | ) { 25 | val headers = 26 | when (clientAuthentication.clientAuthMethod) { 27 | ClientAuthentication.Method.CLIENT_SECRET_BASIC -> Headers.headersOf("Authorization", clientAuthentication.basic()) 28 | else -> Headers.headersOf() 29 | } 30 | 31 | val body: String = 32 | if (clientAuthentication.clientAuthMethod == ClientAuthentication.Method.CLIENT_SECRET_POST) { 33 | parameters.toKeyValueString("&").plus("&${clientAuthentication.form()}") 34 | } else { 35 | parameters.toKeyValueString("&") 36 | } 37 | 38 | override fun toString(): String = 39 | "POST ${url.encodedPath} HTTP/1.1\n" + 40 | "Host: ${url.toHostHeader(true)}\n" + 41 | "Content-Type: application/x-www-form-urlencoded\n" + 42 | headers.joinToString("\n") { 43 | "${it.first}: ${it.second}" 44 | } + 45 | "\n\n$body" 46 | 47 | private fun Map.toKeyValueString(entrySeparator: String): String = 48 | this 49 | .map { "${it.key}=${it.value}" } 50 | .toList() 51 | .joinToString(entrySeparator) 52 | } 53 | 54 | internal data class ClientAuthentication( 55 | val clientId: String, 56 | val clientSecret: String, 57 | val clientAuthMethod: Method, 58 | ) { 59 | fun form(): String = "client_id=${clientId.urlEncode()}&client_secret=${clientSecret.urlEncode()}" 60 | 61 | fun basic(): String = Credentials.basic(clientId, clientSecret, StandardCharsets.UTF_8) 62 | 63 | companion object { 64 | fun fromMap(map: Map): ClientAuthentication = 65 | ClientAuthentication( 66 | map.require("client_id"), 67 | map.require("client_secret"), 68 | Method.valueOf(map.require("client_auth_method")), 69 | ) 70 | 71 | private fun Map.require(key: String): String = 72 | this[key] ?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "missing required parameter $key") 73 | } 74 | 75 | enum class Method { 76 | CLIENT_SECRET_POST, 77 | CLIENT_SECRET_BASIC, 78 | } 79 | } 80 | 81 | internal fun String.urlEncode(): String = URLEncoder.encode(this, StandardCharsets.UTF_8) 82 | 83 | internal fun OkHttpClient.post(tokenRequest: TokenRequest): String = 84 | this 85 | .newCall( 86 | Request 87 | .Builder() 88 | .headers(tokenRequest.headers) 89 | .url(tokenRequest.url) 90 | .post(tokenRequest.body.toRequestBody("application/x-www-form-urlencoded".toMediaType())) 91 | .build(), 92 | ).execute() 93 | .body 94 | ?.string() ?: throw RuntimeException("could not get response body from url=${tokenRequest.url}") 95 | 96 | fun OkHttpClient.withSsl( 97 | ssl: Ssl, 98 | followRedirects: Boolean = false, 99 | ): OkHttpClient = 100 | newBuilder() 101 | .apply { 102 | followRedirects(followRedirects) 103 | val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(ssl.sslKeystore.keyStore) } 104 | val sslContext = SSLContext.getInstance("TLS").apply { init(null, trustManagerFactory.trustManagers, null) } 105 | sslSocketFactory(sslContext.socketFactory, trustManagerFactory.trustManagers[0] as X509TrustManager) 106 | }.build() 107 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/debugger/DebuggerRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.debugger 2 | 3 | import com.nimbusds.oauth2.sdk.OAuth2Error 4 | import mu.KotlinLogging 5 | import no.nav.security.mock.oauth2.OAuth2Exception 6 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER 7 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER_CALLBACK 8 | import no.nav.security.mock.oauth2.extensions.removeAllEncodedQueryParams 9 | import no.nav.security.mock.oauth2.extensions.toAuthorizationEndpointUrl 10 | import no.nav.security.mock.oauth2.extensions.toDebuggerCallbackUrl 11 | import no.nav.security.mock.oauth2.extensions.toDebuggerUrl 12 | import no.nav.security.mock.oauth2.http.ExceptionHandler 13 | import no.nav.security.mock.oauth2.http.OAuth2HttpResponse 14 | import no.nav.security.mock.oauth2.http.Route 15 | import no.nav.security.mock.oauth2.http.Ssl 16 | import no.nav.security.mock.oauth2.http.html 17 | import no.nav.security.mock.oauth2.http.redirect 18 | import no.nav.security.mock.oauth2.http.routes 19 | import no.nav.security.mock.oauth2.http.templateMapper 20 | import okhttp3.Headers 21 | import okhttp3.HttpUrl 22 | import okhttp3.HttpUrl.Companion.toHttpUrl 23 | import okhttp3.OkHttpClient 24 | 25 | private val log = KotlinLogging.logger { } 26 | private val client: OkHttpClient = OkHttpClient().newBuilder().build() 27 | 28 | class DebuggerRequestHandler( 29 | sessionManager: SessionManager = SessionManager(), 30 | ssl: Ssl? = null, 31 | route: Route = 32 | routes { 33 | exceptionHandler(handle(sessionManager)) 34 | debuggerForm(sessionManager) 35 | debuggerCallback(sessionManager, ssl) 36 | }, 37 | ) : Route by route 38 | 39 | private fun handle(sessionManager: SessionManager): ExceptionHandler = 40 | { request, error -> 41 | OAuth2HttpResponse( 42 | status = 500, 43 | headers = Headers.headersOf("Content-Type", "text/html", "Set-Cookie", sessionManager.session(request).asCookie()), 44 | body = templateMapper.debuggerErrorHtml(request.url.toDebuggerUrl(), error.stackTraceToString()), 45 | ).also { 46 | log.error("received exception when handling url=${request.url}", error) 47 | } 48 | } 49 | 50 | private fun Route.Builder.debuggerForm(sessionManager: SessionManager) = 51 | apply { 52 | get(DEBUGGER) { 53 | log.debug("handling GET request, return html form") 54 | val url = 55 | it.url 56 | .toAuthorizationEndpointUrl() 57 | .newBuilder() 58 | .query( 59 | "client_id=debugger" + 60 | "&response_type=code" + 61 | "&redirect_uri=${it.url.toDebuggerCallbackUrl()}" + 62 | "&response_mode=query" + 63 | "&scope=openid+somescope" + 64 | "&state=1234" + 65 | "&nonce=5678", 66 | ).build() 67 | html(templateMapper.debuggerFormHtml(url, "CLIENT_SECRET_BASIC")) 68 | } 69 | post(DEBUGGER) { 70 | log.debug("handling POST request, return redirect") 71 | val authorizeUrl = it.formParameters.get("authorize_url") ?: error("authorize_url is missing") 72 | val httpUrl = 73 | authorizeUrl 74 | .toHttpUrl() 75 | .newBuilder() 76 | .encodedQuery(it.formParameters.parameterString) 77 | .removeAllEncodedQueryParams("authorize_url", "token_url", "client_secret", "client_auth_method") 78 | .build() 79 | 80 | log.debug("attempting to redirect to $httpUrl, setting received params in encrypted cookie") 81 | val session = sessionManager.session(it) 82 | session.putAll(it.formParameters.map) 83 | redirect(httpUrl.toString(), Headers.headersOf("Set-Cookie", session.asCookie())) 84 | } 85 | } 86 | 87 | private fun Route.Builder.debuggerCallback( 88 | sessionManager: SessionManager, 89 | ssl: Ssl? = null, 90 | ) = any(DEBUGGER_CALLBACK) { 91 | log.debug("handling ${it.method} request to debugger callback") 92 | val session = sessionManager.session(it) 93 | val tokenUrl: HttpUrl = session["token_url"].toHttpUrl() 94 | val code: String = 95 | it.url.queryParameter("code") 96 | ?: it.formParameters.get("code") 97 | ?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "no code parameter present") 98 | val clientAuthentication = ClientAuthentication.fromMap(session.parameters) 99 | val request = 100 | TokenRequest( 101 | tokenUrl, 102 | clientAuthentication, 103 | mapOf( 104 | "grant_type" to "authorization_code", 105 | "code" to code, 106 | "scope" to session["scope"].urlEncode(), 107 | "redirect_uri" to session["redirect_uri"].urlEncode(), 108 | ), 109 | ) 110 | val response = 111 | if (ssl != null) { 112 | client.withSsl(ssl).post(request) 113 | } else { 114 | client.post(request) 115 | } 116 | html(templateMapper.debuggerCallbackHtml(request.toString(), response)) 117 | } 118 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/debugger/SessionManager.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.debugger 2 | 3 | import com.fasterxml.jackson.module.kotlin.readValue 4 | import com.nimbusds.jose.EncryptionMethod 5 | import com.nimbusds.jose.JWEAlgorithm 6 | import com.nimbusds.jose.JWEHeader 7 | import com.nimbusds.jose.JWEObject 8 | import com.nimbusds.jose.Payload 9 | import com.nimbusds.jose.crypto.DirectDecrypter 10 | import com.nimbusds.jose.crypto.DirectEncrypter 11 | import mu.KotlinLogging 12 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 13 | import no.nav.security.mock.oauth2.http.objectMapper 14 | import javax.crypto.KeyGenerator 15 | import javax.crypto.SecretKey 16 | 17 | private val log = KotlinLogging.logger { } 18 | 19 | class SessionManager { 20 | private val encryptionKey: SecretKey = 21 | KeyGenerator 22 | .getInstance("AES") 23 | .apply { this.init(128) } 24 | .generateKey() 25 | 26 | fun session(request: OAuth2HttpRequest): Session = Session(encryptionKey, request) 27 | 28 | class Session( 29 | private val encryptionKey: SecretKey, 30 | val request: OAuth2HttpRequest, 31 | ) { 32 | val parameters: MutableMap = getSessionCookie() ?.let { objectMapper.readValue(it) } ?: mutableMapOf() 33 | 34 | fun putAll(map: Map) = parameters.putAll(map) 35 | 36 | operator fun get(key: String): String = parameters[key] ?: throw RuntimeException("could not get $key from session.") 37 | 38 | operator fun set( 39 | key: String, 40 | value: String, 41 | ) = parameters.put(key, value) 42 | 43 | fun asCookie(): String = 44 | objectMapper.writeValueAsString(parameters).encrypt(encryptionKey).let { 45 | "$DEBUGGER_SESSION_COOKIE=$it; HttpOnly; Path=/" 46 | } 47 | 48 | private fun String.encrypt(key: SecretKey): String = 49 | JWEObject( 50 | JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A128GCM), 51 | Payload(this), 52 | ).also { 53 | it.encrypt(DirectEncrypter(key)) 54 | }.serialize() 55 | 56 | private fun String.decrypt(key: SecretKey): String = 57 | JWEObject 58 | .parse(this) 59 | .also { 60 | it.decrypt(DirectDecrypter(key)) 61 | }.payload 62 | .toString() 63 | 64 | private fun getSessionCookie(): String? = 65 | runCatching { 66 | request.cookies[DEBUGGER_SESSION_COOKIE]?.decrypt(encryptionKey) 67 | }.fold( 68 | onSuccess = { result -> result }, 69 | onFailure = { error -> 70 | log.error("received exception when decrypting cookie", error) 71 | null 72 | }, 73 | ) 74 | 75 | companion object { 76 | const val DEBUGGER_SESSION_COOKIE = "debugger-session" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/extensions/AsOAuth2HttpRequest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.extensions 2 | 3 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 4 | import okhttp3.mockwebserver.RecordedRequest 5 | 6 | fun RecordedRequest.asOAuth2HttpRequest(): OAuth2HttpRequest = 7 | OAuth2HttpRequest(this.headers, checkNotNull(this.method), checkNotNull(this.requestUrl), this.body.copy().readUtf8()) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.extensions 2 | 3 | import com.nimbusds.oauth2.sdk.OAuth2Error 4 | import no.nav.security.mock.oauth2.OAuth2Exception 5 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.AUTHORIZATION 6 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER 7 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER_CALLBACK 8 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.END_SESSION 9 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT 10 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.JWKS 11 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OAUTH2_WELL_KNOWN 12 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OIDC_WELL_KNOWN 13 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE 14 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.TOKEN 15 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO 16 | import okhttp3.HttpUrl 17 | 18 | object OAuth2Endpoints { 19 | const val OAUTH2_WELL_KNOWN = "/.well-known/oauth-authorization-server" 20 | const val OIDC_WELL_KNOWN = "/.well-known/openid-configuration" 21 | const val AUTHORIZATION = "/authorize" 22 | const val TOKEN = "/token" 23 | const val END_SESSION = "/endsession" 24 | const val REVOKE = "/revoke" 25 | const val JWKS = "/jwks" 26 | const val USER_INFO = "/userinfo" 27 | const val INTROSPECT = "/introspect" 28 | const val DEBUGGER = "/debugger" 29 | const val DEBUGGER_CALLBACK = "/debugger/callback" 30 | 31 | val all = 32 | listOf( 33 | OAUTH2_WELL_KNOWN, 34 | OIDC_WELL_KNOWN, 35 | AUTHORIZATION, 36 | TOKEN, 37 | END_SESSION, 38 | REVOKE, 39 | JWKS, 40 | USER_INFO, 41 | INTROSPECT, 42 | DEBUGGER, 43 | DEBUGGER_CALLBACK, 44 | ) 45 | } 46 | 47 | fun HttpUrl.isWellKnownUrl(): Boolean = this.endsWith(OAUTH2_WELL_KNOWN) || this.endsWith(OIDC_WELL_KNOWN) 48 | 49 | fun HttpUrl.isAuthorizationEndpointUrl(): Boolean = this.endsWith(AUTHORIZATION) 50 | 51 | fun HttpUrl.isTokenEndpointUrl(): Boolean = this.endsWith(TOKEN) 52 | 53 | fun HttpUrl.isEndSessionEndpointUrl(): Boolean = this.endsWith(END_SESSION) 54 | 55 | fun HttpUrl.isJwksUrl(): Boolean = this.endsWith(JWKS) 56 | 57 | fun HttpUrl.isUserInfoUrl(): Boolean = this.endsWith(USER_INFO) 58 | 59 | fun HttpUrl.isIntrospectUrl(): Boolean = this.endsWith(INTROSPECT) 60 | 61 | fun HttpUrl.isDebuggerUrl(): Boolean = this.endsWith(DEBUGGER) 62 | 63 | fun HttpUrl.isDebuggerCallbackUrl(): Boolean = this.endsWith(DEBUGGER_CALLBACK) 64 | 65 | fun HttpUrl.toOAuth2AuthorizationServerMetadataUrl() = issuer(OAUTH2_WELL_KNOWN) 66 | 67 | fun HttpUrl.toWellKnownUrl(): HttpUrl = issuer(OIDC_WELL_KNOWN) 68 | 69 | fun HttpUrl.toAuthorizationEndpointUrl(): HttpUrl = issuer(AUTHORIZATION) 70 | 71 | fun HttpUrl.toEndSessionEndpointUrl(): HttpUrl = issuer(END_SESSION) 72 | 73 | fun HttpUrl.toRevocationEndpointUrl(): HttpUrl = issuer(REVOKE) 74 | 75 | fun HttpUrl.toTokenEndpointUrl(): HttpUrl = issuer(TOKEN) 76 | 77 | fun HttpUrl.toJwksUrl(): HttpUrl = issuer(JWKS) 78 | 79 | fun HttpUrl.toIssuerUrl(): HttpUrl = issuer() 80 | 81 | fun HttpUrl.toUserInfoUrl(): HttpUrl = issuer(USER_INFO) 82 | 83 | fun HttpUrl.toIntrospectUrl(): HttpUrl = issuer(INTROSPECT) 84 | 85 | fun HttpUrl.toDebuggerUrl(): HttpUrl = issuer(DEBUGGER) 86 | 87 | fun HttpUrl.toDebuggerCallbackUrl(): HttpUrl = issuer(DEBUGGER_CALLBACK) 88 | 89 | fun HttpUrl.issuerId(): String { 90 | val path = this.pathSegments.joinToString("/").trimPath() 91 | OAuth2Endpoints.all.forEach { 92 | if (path.endsWith(it)) { 93 | return path.substringBefore(it) 94 | } 95 | } 96 | return path 97 | } 98 | 99 | fun HttpUrl.Builder.removeAllEncodedQueryParams(vararg params: String) = apply { params.forEach { removeAllEncodedQueryParameters(it) } } 100 | 101 | fun HttpUrl.endsWith(path: String): Boolean = this.pathSegments.joinToString("/").endsWith(path.trimPath()) 102 | 103 | private fun String.trimPath() = removePrefix("/").removeSuffix("/") 104 | 105 | private fun HttpUrl.issuer(path: String = ""): HttpUrl = 106 | baseUrl().let { 107 | it.resolve(joinPaths(issuerId(), path)) 108 | ?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "cannot resolve path $path") 109 | } 110 | 111 | private fun joinPaths(vararg path: String) = path.filter { it.isNotEmpty() }.joinToString("/") { it.trimPath() } 112 | 113 | private fun HttpUrl.baseUrl(): HttpUrl = 114 | HttpUrl 115 | .Builder() 116 | .scheme(this.scheme) 117 | .host(this.host) 118 | .port(this.port) 119 | .build() 120 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/extensions/String.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.extensions 2 | 3 | import java.net.URLDecoder 4 | import java.nio.charset.StandardCharsets 5 | 6 | internal fun String.keyValuesToMap(listDelimiter: String): Map = 7 | this 8 | .split(listDelimiter) 9 | .filter { it.contains("=") } 10 | .associate { 11 | val (key, value) = it.split("=") 12 | key.urlDecode().trim() to value.urlDecode().trim() 13 | } 14 | 15 | internal fun String.urlDecode(): String = URLDecoder.decode(this, StandardCharsets.UTF_8) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/extensions/Template.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.extensions 2 | 3 | /** 4 | * Replaces all template values denoted with ${key} in a map with the corresponding values from the templates map. 5 | * 6 | * @param templates a map of template values 7 | * @return a new map with all template values replaced 8 | */ 9 | fun Map.replaceValues(templates: Map): Map { 10 | fun replaceTemplateString( 11 | value: String, 12 | templates: Map, 13 | ): String { 14 | val regex = Regex("""\$\{(\w+)\}""") 15 | return regex.replace(value) { matchResult -> 16 | val key = matchResult.groupValues[1] 17 | templates[key]?.toString() ?: matchResult.value 18 | } 19 | } 20 | 21 | fun replaceValue(value: Any): Any = 22 | when (value) { 23 | is String -> replaceTemplateString(value, templates) 24 | is List<*> -> value.map { it?.let { replaceValue(it) } } 25 | is Map<*, *> -> value.mapValues { v -> v.value?.let { replaceValue(it) } } 26 | else -> value 27 | } 28 | 29 | return this.mapValues { replaceValue(it.value) } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/ClientCredentialsGrantHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import no.nav.security.mock.oauth2.extensions.expiresIn 4 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 5 | import no.nav.security.mock.oauth2.http.OAuth2TokenResponse 6 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 7 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 8 | import okhttp3.HttpUrl 9 | 10 | internal class ClientCredentialsGrantHandler( 11 | private val tokenProvider: OAuth2TokenProvider, 12 | ) : GrantHandler { 13 | override fun tokenResponse( 14 | request: OAuth2HttpRequest, 15 | issuerUrl: HttpUrl, 16 | oAuth2TokenCallback: OAuth2TokenCallback, 17 | ): OAuth2TokenResponse { 18 | val tokenRequest = request.asNimbusTokenRequest() 19 | val accessToken = 20 | tokenProvider.accessToken( 21 | tokenRequest, 22 | issuerUrl, 23 | oAuth2TokenCallback, 24 | ) 25 | return OAuth2TokenResponse( 26 | tokenType = "Bearer", 27 | accessToken = accessToken.serialize(), 28 | expiresIn = accessToken.expiresIn(), 29 | scope = tokenRequest.scope?.toString(), 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/GrantHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 4 | import no.nav.security.mock.oauth2.http.OAuth2TokenResponse 5 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 6 | import okhttp3.HttpUrl 7 | 8 | interface GrantHandler { 9 | fun tokenResponse( 10 | request: OAuth2HttpRequest, 11 | issuerUrl: HttpUrl, 12 | oAuth2TokenCallback: OAuth2TokenCallback, 13 | ): OAuth2TokenResponse 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/JwtBearerGrantHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.jwt.JWTClaimsSet 4 | import com.nimbusds.oauth2.sdk.JWTBearerGrant 5 | import com.nimbusds.oauth2.sdk.OAuth2Error 6 | import com.nimbusds.oauth2.sdk.TokenRequest 7 | import no.nav.security.mock.oauth2.OAuth2Exception 8 | import no.nav.security.mock.oauth2.extensions.expiresIn 9 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 10 | import no.nav.security.mock.oauth2.http.OAuth2TokenResponse 11 | import no.nav.security.mock.oauth2.invalidRequest 12 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 13 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 14 | import okhttp3.HttpUrl 15 | 16 | internal class JwtBearerGrantHandler( 17 | private val tokenProvider: OAuth2TokenProvider, 18 | ) : GrantHandler { 19 | override fun tokenResponse( 20 | request: OAuth2HttpRequest, 21 | issuerUrl: HttpUrl, 22 | oAuth2TokenCallback: OAuth2TokenCallback, 23 | ): OAuth2TokenResponse { 24 | val tokenRequest = request.asNimbusTokenRequest() 25 | val receivedClaimsSet = tokenRequest.assertion() 26 | val accessToken = 27 | tokenProvider.exchangeAccessToken( 28 | tokenRequest, 29 | issuerUrl, 30 | receivedClaimsSet, 31 | oAuth2TokenCallback, 32 | ) 33 | return OAuth2TokenResponse( 34 | tokenType = "Bearer", 35 | accessToken = accessToken.serialize(), 36 | expiresIn = accessToken.expiresIn(), 37 | scope = tokenRequest.responseScope(), 38 | ) 39 | } 40 | 41 | private fun TokenRequest.responseScope(): String = 42 | scope?.toString() 43 | ?: assertion().getClaim("scope")?.toString() 44 | ?: invalidRequest("scope must be specified in request or as a claim in assertion parameter") 45 | 46 | private fun TokenRequest.assertion(): JWTClaimsSet = 47 | (this.authorizationGrant as? JWTBearerGrant)?.jwtAssertion?.jwtClaimsSet 48 | ?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "missing required parameter assertion") 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/PasswordGrantHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.jwt.SignedJWT 4 | import com.nimbusds.oauth2.sdk.ResourceOwnerPasswordCredentialsGrant 5 | import com.nimbusds.oauth2.sdk.TokenRequest 6 | import no.nav.security.mock.oauth2.extensions.expiresIn 7 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 8 | import no.nav.security.mock.oauth2.http.OAuth2TokenResponse 9 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 10 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 11 | import okhttp3.HttpUrl 12 | 13 | internal class PasswordGrantHandler( 14 | private val tokenProvider: OAuth2TokenProvider, 15 | ) : GrantHandler { 16 | override fun tokenResponse( 17 | request: OAuth2HttpRequest, 18 | issuerUrl: HttpUrl, 19 | oAuth2TokenCallback: OAuth2TokenCallback, 20 | ): OAuth2TokenResponse { 21 | val tokenRequest = request.asNimbusTokenRequest() 22 | val scope: String? = tokenRequest.scope?.toString() 23 | val passwordGrantTokenCallback = PasswordGrantTokenCallback(oAuth2TokenCallback) 24 | val accessToken: SignedJWT = tokenProvider.accessToken(tokenRequest, issuerUrl, passwordGrantTokenCallback) 25 | val idToken: SignedJWT = tokenProvider.idToken(tokenRequest, issuerUrl, passwordGrantTokenCallback, null) 26 | 27 | return OAuth2TokenResponse( 28 | tokenType = "Bearer", 29 | accessToken = accessToken.serialize(), 30 | idToken = idToken.serialize(), 31 | expiresIn = accessToken.expiresIn(), 32 | scope = scope, 33 | ) 34 | } 35 | 36 | private class PasswordGrantTokenCallback( 37 | private val tokenCallback: OAuth2TokenCallback, 38 | ) : OAuth2TokenCallback by tokenCallback { 39 | override fun subject(tokenRequest: TokenRequest) = 40 | tokenRequest.authorizationGrant 41 | ?.let { it as? ResourceOwnerPasswordCredentialsGrant } 42 | ?.username ?: tokenCallback.subject(tokenRequest) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenGrantHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.jwt.SignedJWT 4 | import com.nimbusds.oauth2.sdk.GrantType 5 | import com.nimbusds.oauth2.sdk.RefreshTokenGrant 6 | import com.nimbusds.oauth2.sdk.TokenRequest 7 | import mu.KotlinLogging 8 | import no.nav.security.mock.oauth2.extensions.expiresIn 9 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 10 | import no.nav.security.mock.oauth2.http.OAuth2TokenResponse 11 | import no.nav.security.mock.oauth2.invalidGrant 12 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 13 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 14 | import okhttp3.HttpUrl 15 | 16 | private val log = KotlinLogging.logger {} 17 | 18 | internal class RefreshTokenGrantHandler( 19 | private val tokenProvider: OAuth2TokenProvider, 20 | private val refreshTokenManager: RefreshTokenManager, 21 | private val rotateRefreshToken: Boolean = false, 22 | ) : GrantHandler { 23 | override fun tokenResponse( 24 | request: OAuth2HttpRequest, 25 | issuerUrl: HttpUrl, 26 | oAuth2TokenCallback: OAuth2TokenCallback, 27 | ): OAuth2TokenResponse { 28 | val tokenRequest = request.asNimbusTokenRequest() 29 | var refreshToken = tokenRequest.refreshTokenGrant().refreshToken.value 30 | log.debug("issuing token for refreshToken=$refreshToken") 31 | val scope: String? = tokenRequest.scope?.toString() 32 | val refreshTokenCallbackOrDefault = refreshTokenManager[refreshToken] ?: oAuth2TokenCallback 33 | if (rotateRefreshToken) { 34 | refreshToken = refreshTokenManager.rotate(refreshToken, refreshTokenCallbackOrDefault) 35 | } 36 | val idToken: SignedJWT = tokenProvider.idToken(tokenRequest, issuerUrl, refreshTokenCallbackOrDefault) 37 | val accessToken: SignedJWT = tokenProvider.accessToken(tokenRequest, issuerUrl, refreshTokenCallbackOrDefault) 38 | 39 | return OAuth2TokenResponse( 40 | tokenType = "Bearer", 41 | idToken = idToken.serialize(), 42 | accessToken = accessToken.serialize(), 43 | refreshToken = refreshToken, 44 | expiresIn = idToken.expiresIn(), 45 | scope = scope, 46 | ) 47 | } 48 | 49 | private fun TokenRequest.refreshTokenGrant(): RefreshTokenGrant = (this.authorizationGrant as? RefreshTokenGrant) ?: invalidGrant(GrantType.REFRESH_TOKEN) 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenManager.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.jwt.JWTClaimsSet 4 | import com.nimbusds.jwt.PlainJWT 5 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 6 | import java.util.UUID 7 | 8 | typealias RefreshToken = String 9 | typealias Nonce = String 10 | 11 | internal data class RefreshTokenManager( 12 | private val cache: MutableMap = HashMap(), 13 | ) { 14 | operator fun get(refreshToken: RefreshToken) = cache[refreshToken] 15 | 16 | fun remove(refreshToken: RefreshToken) = cache.remove(refreshToken) 17 | 18 | fun refreshToken( 19 | tokenCallback: OAuth2TokenCallback, 20 | nonce: Nonce? = null, 21 | ): RefreshToken { 22 | val jti = UUID.randomUUID().toString() 23 | // added for compatibility with keycloak js client which expects a jwt with nonce 24 | val refreshToken = nonce?.let { plainJWT(jti, nonce) } ?: jti 25 | cache[refreshToken] = tokenCallback 26 | return refreshToken 27 | } 28 | 29 | fun rotate( 30 | refreshToken: RefreshToken, 31 | fallbackTokenCallback: OAuth2TokenCallback, 32 | ): RefreshToken { 33 | val callback = cache.remove(refreshToken) ?: fallbackTokenCallback 34 | return refreshToken(callback) 35 | } 36 | 37 | private fun plainJWT( 38 | jti: String, 39 | nonce: String?, 40 | ): String = 41 | PlainJWT( 42 | JWTClaimsSet.parse( 43 | mapOf( 44 | "jti" to jti, 45 | "nonce" to nonce, 46 | ), 47 | ), 48 | ).serialize() 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/TokenExchangeGrant.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.oauth2.sdk.AuthorizationGrant 4 | import com.nimbusds.oauth2.sdk.GrantType 5 | import no.nav.security.mock.oauth2.invalidRequest 6 | 7 | val TOKEN_EXCHANGE = GrantType("urn:ietf:params:oauth:grant-type:token-exchange") 8 | 9 | @Suppress("MemberVisibilityCanBePrivate") 10 | class TokenExchangeGrant( 11 | val subjectTokenType: String, 12 | val subjectToken: String, 13 | val audience: MutableList, 14 | ) : AuthorizationGrant(TOKEN_EXCHANGE) { 15 | override fun toParameters(): MutableMap> = 16 | mutableMapOf( 17 | "grant_type" to mutableListOf(TOKEN_EXCHANGE.value), 18 | "subject_token_type" to mutableListOf(subjectTokenType), 19 | "subject_token" to mutableListOf(subjectToken), 20 | "audience" to audience, 21 | ) 22 | 23 | companion object { 24 | fun parse(parameters: Map): TokenExchangeGrant = 25 | TokenExchangeGrant( 26 | parameters.require("subject_token_type"), 27 | parameters.require("subject_token"), 28 | parameters 29 | .require("audience") 30 | .split(" ") 31 | .toMutableList(), 32 | ) 33 | } 34 | } 35 | 36 | private inline fun Map.require(name: String): T = this[name] ?: invalidRequest("missing required parameter $name") 37 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/grant/TokenExchangeGrantHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.jwt.SignedJWT 4 | import com.nimbusds.oauth2.sdk.TokenRequest 5 | import no.nav.security.mock.oauth2.extensions.expiresIn 6 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 7 | import no.nav.security.mock.oauth2.http.OAuth2TokenResponse 8 | import no.nav.security.mock.oauth2.invalidRequest 9 | import no.nav.security.mock.oauth2.token.OAuth2TokenCallback 10 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 11 | import okhttp3.HttpUrl 12 | 13 | internal class TokenExchangeGrantHandler( 14 | private val tokenProvider: OAuth2TokenProvider, 15 | ) : GrantHandler { 16 | override fun tokenResponse( 17 | request: OAuth2HttpRequest, 18 | issuerUrl: HttpUrl, 19 | oAuth2TokenCallback: OAuth2TokenCallback, 20 | ): OAuth2TokenResponse { 21 | val tokenRequest = request.asTokenExchangeRequest() 22 | val receivedClaimsSet = tokenRequest.subjectToken().jwtClaimsSet 23 | val accessToken = 24 | tokenProvider.exchangeAccessToken( 25 | tokenRequest, 26 | issuerUrl, 27 | receivedClaimsSet, 28 | oAuth2TokenCallback, 29 | ) 30 | return OAuth2TokenResponse( 31 | tokenType = "Bearer", 32 | issuedTokenType = "urn:ietf:params:oauth:token-type:access_token", 33 | accessToken = accessToken.serialize(), 34 | expiresIn = accessToken.expiresIn(), 35 | ) 36 | } 37 | } 38 | 39 | fun TokenRequest.subjectToken(): SignedJWT = SignedJWT.parse(this.tokenExchangeGrant().subjectToken) 40 | 41 | fun TokenRequest.tokenExchangeGrant() = this.authorizationGrant as? TokenExchangeGrant ?: invalidRequest("missing token exchange grant") 42 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/http/CorsInterceptor.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.http 2 | 3 | import mu.KotlinLogging 4 | 5 | private val log = KotlinLogging.logger {} 6 | 7 | class CorsInterceptor( 8 | private val allowedMethods: List = listOf("POST", "GET", "OPTIONS"), 9 | ) : ResponseInterceptor { 10 | companion object HeaderNames { 11 | const val ORIGIN = "origin" 12 | const val ACCESS_CONTROL_ALLOW_CREDENTIALS = "access-control-allow-credentials" 13 | const val ACCESS_CONTROL_REQUEST_HEADERS = "access-control-request-headers" 14 | const val ACCESS_CONTROL_ALLOW_HEADERS = "access-control-allow-headers" 15 | const val ACCESS_CONTROL_ALLOW_METHODS = "access-control-allow-methods" 16 | const val ACCESS_CONTROL_ALLOW_ORIGIN = "access-control-allow-origin" 17 | } 18 | 19 | override fun intercept( 20 | request: OAuth2HttpRequest, 21 | response: OAuth2HttpResponse, 22 | ): OAuth2HttpResponse { 23 | val origin = request.headers[ORIGIN] 24 | log.debug("intercept response if request origin header is set: $origin") 25 | return if (origin != null) { 26 | val headers = response.headers.newBuilder() 27 | if (request.method == "OPTIONS") { 28 | val reqHeader = request.headers[ACCESS_CONTROL_REQUEST_HEADERS] 29 | if (reqHeader != null) { 30 | headers[ACCESS_CONTROL_ALLOW_HEADERS] = reqHeader 31 | } 32 | headers[ACCESS_CONTROL_ALLOW_METHODS] = allowedMethods.joinToString(", ") 33 | } 34 | headers[ACCESS_CONTROL_ALLOW_ORIGIN] = origin 35 | headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] = "true" 36 | log.debug("adding CORS response headers") 37 | response.copy(headers = headers.build()) 38 | } else { 39 | response 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/introspect/Introspect.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.introspect 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude 4 | import com.fasterxml.jackson.annotation.JsonProperty 5 | import com.nimbusds.jwt.JWTClaimsSet 6 | import com.nimbusds.oauth2.sdk.OAuth2Error 7 | import mu.KotlinLogging 8 | import no.nav.security.mock.oauth2.OAuth2Exception 9 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT 10 | import no.nav.security.mock.oauth2.extensions.toIssuerUrl 11 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 12 | import no.nav.security.mock.oauth2.http.Route 13 | import no.nav.security.mock.oauth2.http.json 14 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 15 | import okhttp3.Headers 16 | 17 | private val log = KotlinLogging.logger { } 18 | 19 | internal fun Route.Builder.introspect(tokenProvider: OAuth2TokenProvider) = 20 | post(INTROSPECT) { request -> 21 | log.debug("received request to introspect endpoint, returning active and claims from token") 22 | 23 | if (!request.headers.authenticated()) { 24 | val msg = "The client authentication was invalid" 25 | throw OAuth2Exception(OAuth2Error.INVALID_CLIENT.setDescription(msg), msg) 26 | } 27 | 28 | request.verifyToken(tokenProvider)?.let { 29 | val claims = it.claims 30 | json( 31 | IntrospectResponse( 32 | true, 33 | claims["scope"].toString(), 34 | claims["client_id"].toString(), 35 | claims["username"].toString(), 36 | claims["token_type"].toString(), 37 | claims["exp"] as? Long, 38 | claims["iat"] as? Long, 39 | claims["nbf"] as? Long, 40 | claims["sub"].toString(), 41 | claims["aud"].toString(), 42 | claims["iss"].toString(), 43 | claims["jti"].toString(), 44 | ), 45 | ) 46 | } ?: json(IntrospectResponse(false)) 47 | } 48 | 49 | private fun OAuth2HttpRequest.verifyToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet? { 50 | return try { 51 | this.formParameters.get("token")?.let { 52 | tokenProvider.verify(url.toIssuerUrl(), it) 53 | } 54 | } catch (e: Exception) { 55 | log.debug("token_introspection: failed signature validation") 56 | return null 57 | } 58 | } 59 | 60 | private fun Headers.authenticated(): Boolean = 61 | this["Authorization"]?.let { authHeader -> 62 | authHeader.auth("Bearer ")?.isNotEmpty() 63 | ?: authHeader.auth("Basic ")?.isNotEmpty() 64 | ?: false 65 | } ?: false 66 | 67 | private fun String.auth(method: String): String? = 68 | this 69 | .split(method) 70 | .takeIf { it.size == 2 } 71 | ?.last() 72 | 73 | @JsonInclude(JsonInclude.Include.NON_NULL) 74 | data class IntrospectResponse( 75 | @JsonProperty("active") 76 | val active: Boolean, 77 | @JsonProperty("scope") 78 | val scope: String? = null, 79 | @JsonProperty("client_id") 80 | val clientId: String? = null, 81 | @JsonProperty("username") 82 | val username: String? = null, 83 | @JsonProperty("token_type") 84 | val tokenType: String? = null, 85 | @JsonProperty("exp") 86 | val exp: Long? = null, 87 | @JsonProperty("iat") 88 | val iat: Long? = null, 89 | @JsonProperty("nbf") 90 | val nbf: Long? = null, 91 | @JsonProperty("sub") 92 | val sub: String? = null, 93 | @JsonProperty("aud") 94 | val aud: String? = null, 95 | @JsonProperty("iss") 96 | val iss: String? = null, 97 | @JsonProperty("jti") 98 | val jti: String? = null, 99 | ) 100 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/login/LoginRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.login 2 | 3 | import no.nav.security.mock.oauth2.OAuth2Config 4 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 5 | import no.nav.security.mock.oauth2.missingParameter 6 | import no.nav.security.mock.oauth2.notFound 7 | import no.nav.security.mock.oauth2.templates.TemplateMapper 8 | import java.io.File 9 | import java.io.FileNotFoundException 10 | 11 | class LoginRequestHandler( 12 | private val templateMapper: TemplateMapper, 13 | private val config: OAuth2Config, 14 | ) { 15 | fun loginHtml(httpRequest: OAuth2HttpRequest): String = 16 | config.loginPagePath 17 | ?.let { 18 | try { 19 | File(it).readText() 20 | } catch (e: FileNotFoundException) { 21 | notFound("The configured loginPagePath '${config.loginPagePath}' is invalid, please ensure that it points to a valid html file") 22 | } 23 | } 24 | ?: templateMapper.loginHtml(httpRequest) 25 | 26 | fun loginSubmit(httpRequest: OAuth2HttpRequest): Login { 27 | val formParameters = httpRequest.formParameters 28 | val username = formParameters.get("username") ?: missingParameter("username") 29 | return Login(username, formParameters.get("claims")) 30 | } 31 | } 32 | 33 | data class Login( 34 | val username: String, 35 | val claims: String? = null, 36 | ) 37 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/templates/TemplateMapper.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.templates 2 | 3 | import freemarker.cache.ClassTemplateLoader 4 | import freemarker.template.Configuration 5 | import no.nav.security.mock.oauth2.extensions.toTokenEndpointUrl 6 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 7 | import okhttp3.HttpUrl 8 | import java.io.StringWriter 9 | 10 | data class HtmlContent( 11 | val template: String, 12 | val model: Any?, 13 | ) 14 | 15 | class TemplateMapper( 16 | private val config: Configuration, 17 | ) { 18 | fun loginHtml(oAuth2HttpRequest: OAuth2HttpRequest): String = 19 | asString( 20 | HtmlContent( 21 | "login.ftl", 22 | mapOf( 23 | "request_url" to 24 | oAuth2HttpRequest.url 25 | .newBuilder() 26 | .query(null) 27 | .build() 28 | .toString(), 29 | "query" to OAuth2HttpRequest.Parameters(oAuth2HttpRequest.url.query).map, 30 | ), 31 | ), 32 | ) 33 | 34 | fun debuggerCallbackHtml( 35 | tokenRequest: String, 36 | tokenResponse: String, 37 | ): String = 38 | asString( 39 | HtmlContent( 40 | "debugger_callback.ftl", 41 | mapOf( 42 | "token_request" to tokenRequest, 43 | "token_response" to tokenResponse, 44 | ), 45 | ), 46 | ) 47 | 48 | fun debuggerErrorHtml( 49 | debuggerUrl: HttpUrl, 50 | stacktrace: String, 51 | ) = asString( 52 | HtmlContent( 53 | "error.ftl", 54 | mapOf( 55 | "debugger_url" to debuggerUrl, 56 | "stacktrace" to stacktrace, 57 | ), 58 | ), 59 | ) 60 | 61 | fun debuggerFormHtml( 62 | url: HttpUrl, 63 | clientAuthMethod: String, 64 | ): String { 65 | val urlWithoutQuery = url.newBuilder().query(null) 66 | return asString( 67 | HtmlContent( 68 | "debugger.ftl", 69 | mapOf( 70 | "url" to urlWithoutQuery, 71 | "token_url" to url.toTokenEndpointUrl(), 72 | "query" to OAuth2HttpRequest.Parameters(url.query).map, 73 | "client_auth_method" to clientAuthMethod, 74 | ), 75 | ), 76 | ) 77 | } 78 | 79 | fun authorizationCodeResponseHtml( 80 | redirectUri: String, 81 | code: String, 82 | state: String, 83 | ): String = 84 | asString( 85 | HtmlContent( 86 | "authorization_code_response.ftl", 87 | mapOf( 88 | "redirect_uri" to redirectUri, 89 | "code" to code, 90 | "state" to state, 91 | ), 92 | ), 93 | ) 94 | 95 | private fun asString(htmlContent: HtmlContent): String = 96 | StringWriter() 97 | .apply { 98 | config.getTemplate(htmlContent.template).process(htmlContent.model, this) 99 | }.toString() 100 | 101 | companion object { 102 | fun create(configure: Configuration.() -> Unit): TemplateMapper { 103 | val config = 104 | Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS) 105 | .apply { 106 | templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates") 107 | }.apply(configure) 108 | return TemplateMapper(config) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/token/KeyGenerator.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.token 2 | 3 | import com.nimbusds.jose.JWSAlgorithm 4 | import com.nimbusds.jose.jwk.Curve 5 | import com.nimbusds.jose.jwk.ECKey 6 | import com.nimbusds.jose.jwk.JWK 7 | import com.nimbusds.jose.jwk.KeyType 8 | import com.nimbusds.jose.jwk.KeyUse 9 | import com.nimbusds.jose.jwk.RSAKey 10 | import com.nimbusds.jose.jwk.gen.RSAKeyGenerator 11 | import no.nav.security.mock.oauth2.OAuth2Exception 12 | import java.security.KeyPairGenerator 13 | import java.security.interfaces.ECPrivateKey 14 | import java.security.interfaces.ECPublicKey 15 | import java.security.interfaces.RSAPrivateKey 16 | import java.security.interfaces.RSAPublicKey 17 | 18 | data class KeyGenerator( 19 | val algorithm: JWSAlgorithm = JWSAlgorithm.RS256, 20 | var keyGenerator: KeyPairGenerator = generate(algorithm.name), 21 | ) { 22 | fun generateKey(keyId: String): JWK { 23 | if (keyGenerator.algorithm != KeyType.RSA.value) { 24 | return keyGenerator.generateECKey(keyId, algorithm) 25 | } 26 | return keyGenerator.generateRSAKey(keyId, algorithm) 27 | } 28 | 29 | private fun KeyPairGenerator.generateECKey( 30 | keyId: String, 31 | algorithm: JWSAlgorithm, 32 | ): JWK = 33 | generateKeyPair() 34 | .let { 35 | ECKey 36 | .Builder(toCurve(algorithm), it.public as ECPublicKey) 37 | .privateKey(it.private as ECPrivateKey) 38 | .keyUse(KeyUse.SIGNATURE) 39 | .keyID(keyId) 40 | .algorithm(algorithm) 41 | .build() 42 | } 43 | 44 | private fun toCurve(algorithm: JWSAlgorithm): Curve = 45 | requireNotNull( 46 | Curve.forJWSAlgorithm(algorithm).single(), 47 | ) { 48 | throw OAuth2Exception("Unsupported: $algorithm") 49 | } 50 | 51 | private fun KeyPairGenerator.generateRSAKey( 52 | keyId: String, 53 | algorithm: JWSAlgorithm, 54 | ): JWK = 55 | generateKeyPair() 56 | .let { 57 | RSAKey 58 | .Builder(it.public as RSAPublicKey) 59 | .privateKey(it.private as RSAPrivateKey) 60 | .keyUse(KeyUse.SIGNATURE) 61 | .keyID(keyId) 62 | .algorithm(algorithm) 63 | .build() 64 | } 65 | 66 | companion object { 67 | val rsaAlgorithmFamily = JWSAlgorithm.Family.RSA.toList() 68 | val ecAlgorithmFamily = 69 | JWSAlgorithm.Family.EC.filterNot { 70 | // ES256K is not a public used algorithm 71 | // ES512 is counted as "legacy" and is not supported 72 | it.name == "ES256K" || it.name == "ES512" 73 | } 74 | 75 | private val supportedAlgorithms = 76 | listOf( 77 | Algorithm(rsaAlgorithmFamily, KeyType.RSA), 78 | Algorithm(ecAlgorithmFamily, KeyType.EC), 79 | ) 80 | 81 | fun isSupported(algorithm: JWSAlgorithm) = supportedAlgorithms.flatMap { it.family }.contains(algorithm) 82 | 83 | fun generate(algorithm: String): KeyPairGenerator { 84 | val parsedAlgo = JWSAlgorithm.parse(algorithm) 85 | return supportedAlgorithms 86 | .mapNotNull { 87 | if (it.family.contains(parsedAlgo)) { 88 | KeyGenerator( 89 | parsedAlgo, 90 | KeyPairGenerator.getInstance(it.keyType.value).apply { 91 | if (it.keyType.value != KeyType.RSA.value) { 92 | this.initialize( 93 | parsedAlgo.name 94 | .subSequence(2, 5) 95 | .toString() 96 | .toInt(), 97 | ) 98 | } else { 99 | this.initialize(RSAKeyGenerator.MIN_KEY_SIZE_BITS) 100 | } 101 | }, 102 | ).keyGenerator 103 | } else { 104 | null 105 | } 106 | }.singleOrNull() ?: throw OAuth2Exception("Unsupported algorithm: $algorithm") 107 | } 108 | 109 | data class Algorithm( 110 | val family: List, 111 | val keyType: KeyType, 112 | ) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/token/KeyProvider.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.token 2 | 3 | import com.nimbusds.jose.JWSAlgorithm 4 | import com.nimbusds.jose.jwk.ECKey 5 | import com.nimbusds.jose.jwk.JWK 6 | import com.nimbusds.jose.jwk.JWKSelector 7 | import com.nimbusds.jose.jwk.JWKSet 8 | import com.nimbusds.jose.jwk.KeyType 9 | import com.nimbusds.jose.jwk.RSAKey 10 | import com.nimbusds.jose.jwk.source.JWKSource 11 | import com.nimbusds.jose.proc.SecurityContext 12 | import no.nav.security.mock.oauth2.OAuth2Exception 13 | import java.util.concurrent.ConcurrentHashMap 14 | import java.util.concurrent.LinkedBlockingDeque 15 | 16 | open class KeyProvider 17 | @JvmOverloads 18 | constructor( 19 | private val initialKeys: List = keysFromFile(INITIAL_KEYS_FILE), 20 | private val algorithm: String = JWSAlgorithm.RS256.name, 21 | ) : JWKSource { 22 | private val signingKeys: ConcurrentHashMap = ConcurrentHashMap() 23 | 24 | private var generator: KeyGenerator = KeyGenerator(JWSAlgorithm.parse(algorithm)) 25 | 26 | private val keyDeque = 27 | LinkedBlockingDeque().apply { 28 | initialKeys.forEach { 29 | put(it) 30 | } 31 | } 32 | 33 | fun signingKey(keyId: String): JWK = signingKeys.computeIfAbsent(keyId) { keyFromDequeOrNew(keyId) } 34 | 35 | private fun keyFromDequeOrNew(keyId: String): JWK = 36 | keyDeque.poll()?.let { polledJwk -> 37 | when (polledJwk.keyType.value) { 38 | KeyType.RSA.value -> { 39 | RSAKey.Builder(polledJwk.toRSAKey()).keyID(keyId).build() 40 | } 41 | 42 | KeyType.EC.value -> { 43 | ECKey.Builder(polledJwk.toECKey()).keyID(keyId).build() 44 | } 45 | 46 | else -> { 47 | throw OAuth2Exception("Unsupported key type: ${polledJwk.keyType.value}") 48 | } 49 | } 50 | } ?: generator.generateKey(keyId) 51 | 52 | fun algorithm(): JWSAlgorithm = JWSAlgorithm.parse(algorithm) 53 | 54 | fun keyType(): String = generator.keyGenerator.algorithm 55 | 56 | fun generate(algorithm: String) { 57 | generator = KeyGenerator(JWSAlgorithm.parse(algorithm)) 58 | } 59 | 60 | companion object { 61 | const val INITIAL_KEYS_FILE = "/mock-oauth2-server-keys.json" 62 | 63 | fun keysFromFile(filename: String): List { 64 | val keysFromFile = KeyProvider::class.java.getResource(filename) 65 | if (keysFromFile != null) { 66 | return JWKSet.parse(keysFromFile.readText()).keys.map { it as JWK } 67 | } 68 | return emptyList() 69 | } 70 | } 71 | 72 | override fun get( 73 | jwkSelector: JWKSelector?, 74 | context: SecurityContext?, 75 | ): MutableList = jwkSelector?.select(JWKSet(signingKeys.values.toList()).toPublicJWKSet()) ?: mutableListOf() 76 | } 77 | -------------------------------------------------------------------------------- /src/main/kotlin/no/nav/security/mock/oauth2/userinfo/UserInfo.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.userinfo 2 | 3 | import com.nimbusds.jwt.JWTClaimsSet 4 | import com.nimbusds.oauth2.sdk.ErrorObject 5 | import com.nimbusds.oauth2.sdk.http.HTTPResponse 6 | import mu.KotlinLogging 7 | import no.nav.security.mock.oauth2.OAuth2Exception 8 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO 9 | import no.nav.security.mock.oauth2.extensions.toIssuerUrl 10 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 11 | import no.nav.security.mock.oauth2.http.Route 12 | import no.nav.security.mock.oauth2.http.json 13 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 14 | import okhttp3.Headers 15 | 16 | private val log = KotlinLogging.logger { } 17 | 18 | internal fun Route.Builder.userInfo(tokenProvider: OAuth2TokenProvider) = 19 | get(USER_INFO) { 20 | log.debug("received request to userinfo endpoint, returning claims from token") 21 | val claims = it.verifyBearerToken(tokenProvider).claims 22 | json(claims) 23 | } 24 | 25 | private fun OAuth2HttpRequest.verifyBearerToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet = 26 | try { 27 | tokenProvider.verify(url.toIssuerUrl(), this.headers.bearerToken()) 28 | } catch (e: Exception) { 29 | throw invalidToken(e.message ?: "could not verify bearer token") 30 | } 31 | 32 | private fun Headers.bearerToken(): String = 33 | this["Authorization"] 34 | ?.split("Bearer ") 35 | ?.takeIf { it.size == 2 } 36 | ?.last() 37 | ?: throw invalidToken("missing bearer token") 38 | 39 | // OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse 40 | // OAuth 2.0 Bearer Token Usage - https://datatracker.ietf.org/doc/html/rfc6750#section-3.1 41 | private fun invalidToken(msg: String) = 42 | OAuth2Exception( 43 | ErrorObject( 44 | "invalid_token", 45 | msg, 46 | HTTPResponse.SC_UNAUTHORIZED, 47 | ), 48 | msg, 49 | ) 50 | -------------------------------------------------------------------------------- /src/main/resources/logback-standalone.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{70} - %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/mock-oauth2-server-keys-ec.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "kty": "EC", 5 | "d": "o9INzHyU_I97djF36YQRpHCJxFTgDTbS1OtwUnHc34U", 6 | "use": "sig", 7 | "crv": "P-256", 8 | "kid": "issuer0", 9 | "x": "umybCYzE-VX_UAIJaX3wc-GTOgB7WDp7A3JJAKW_hqU", 10 | "y": "m_sCzuMjiBSQ7At9yNktMQvE1cCKq68jO7wnRczwKw8", 11 | "alg":"ES256" 12 | }, 13 | { 14 | "kty": "EC", 15 | "d": "yK-ntUEZxKEH_QQZZVtmjpCQPfhyKmaqbCD9-3apDbk", 16 | "use": "sig", 17 | "crv": "P-256", 18 | "kid": "issuer1", 19 | "x": "YLAxep2KtJzgr6JZmlVgwmhoH08QKwG_ojgymdtcOkM", 20 | "y": "jpDJ7qE5g0iIBEBIrilQrOniOgbaKw0UjMky99j18G4", 21 | "alg":"ES256" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/main/resources/templates/authorization_code_response.ftl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | callback 6 | 7 |
8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/main/resources/templates/css/custom.css: -------------------------------------------------------------------------------- 1 | pre { 2 | background: #F1F1F1; 3 | border: 1px solid #E1E1E1; 4 | border-radius: 4px; 5 | } 6 | 7 | pre > code { 8 | background: none; 9 | border: none; 10 | } 11 | 12 | .container { 13 | max-width: 800px; 14 | } 15 | 16 | .header { 17 | margin-top: 6rem; 18 | text-align: center; 19 | } 20 | 21 | .value-prop { 22 | margin-top: 1rem; 23 | } 24 | 25 | .value-props { 26 | margin-top: 4rem; 27 | margin-bottom: 4rem; 28 | } 29 | 30 | .docs-header { 31 | text-transform: uppercase; 32 | font-size: 1.4rem; 33 | letter-spacing: .2rem; 34 | font-weight: 600; 35 | } 36 | 37 | .docs-section { 38 | border-top: 1px solid #eee; 39 | padding: 4rem 0; 40 | margin-bottom: 0; 41 | } 42 | 43 | .value-img { 44 | display: block; 45 | text-align: center; 46 | margin: 2.5rem auto 0; 47 | } 48 | 49 | .example-grid .column, 50 | .example-grid .columns { 51 | background: #EEE; 52 | text-align: center; 53 | border-radius: 4px; 54 | font-size: 1rem; 55 | text-transform: uppercase; 56 | height: 30px; 57 | line-height: 30px; 58 | margin-bottom: .75rem; 59 | font-weight: 600; 60 | letter-spacing: .1rem; 61 | } 62 | 63 | .docs-example .row, 64 | .docs-example.row, 65 | .docs-example form { 66 | margin-bottom: 0; 67 | } 68 | 69 | .docs-example h1, 70 | .docs-example h2, 71 | .docs-example h3, 72 | .docs-example h4, 73 | .docs-example h5, 74 | .docs-example h6 { 75 | margin-bottom: 1rem; 76 | } 77 | 78 | .heading-font-size { 79 | font-size: 1.2rem; 80 | color: #999; 81 | letter-spacing: normal; 82 | } 83 | 84 | .code-example { 85 | margin-top: 1.5rem; 86 | margin-bottom: 0; 87 | } 88 | 89 | .code-example-body { 90 | white-space: pre; 91 | word-wrap: break-word 92 | } 93 | 94 | /* Larger than phone */ 95 | @media (min-width: 550px) { 96 | .header { 97 | margin-top: 10rem; 98 | } 99 | 100 | .value-props { 101 | margin-top: 9rem; 102 | margin-bottom: 7rem; 103 | } 104 | 105 | .value-img { 106 | margin-bottom: 1rem; 107 | } 108 | 109 | .example-grid .column, 110 | .example-grid .columns { 111 | margin-bottom: 1.5rem; 112 | } 113 | 114 | .docs-section { 115 | padding: 6rem 0; 116 | } 117 | } 118 | 119 | .claims { 120 | resize: vertical; 121 | height: fit-content; 122 | } 123 | -------------------------------------------------------------------------------- /src/main/resources/templates/debugger.ftl: -------------------------------------------------------------------------------- 1 | <#import "main.ftl" as layout /> 2 | 3 | <@layout.mainLayout title="mock-oauth2-server debugger" description="Just a mock oauth2 client"> 4 |
5 |
6 |

OAuth2 Client Debugger

7 |
8 | 9 |
10 |
OpenID Connect
11 |

Insert your parameters here to get a token from your Identity Provider

12 |
13 |
14 | 15 | 17 | 18 | 20 | 21 | 23 | 24 | 26 | 27 | 29 | 30 | 33 | 34 | 36 | 37 | 39 | 40 | 42 | 43 | 46 | 47 |

Should be a preregistered URL at your identity 48 | provider. If using the mock-oauth2-server any URL will suffice

49 | 52 | 53 |
54 |
55 |
56 | 57 |
58 |
more debugging options
59 |

More options will be added shortly

60 |
61 |
62 | 63 | -------------------------------------------------------------------------------- /src/main/resources/templates/debugger_callback.ftl: -------------------------------------------------------------------------------- 1 | <#import "main.ftl" as layout /> 2 | 3 | <@layout.mainLayout title="mock-oauth2-server debugger" description="Just a mock oauth2 client"> 4 |
5 |
6 |

OAuth2 Client Debugger

7 |
8 | 9 |
10 |

OpenID Connect Callback

11 |

Inspect callback parameters and the actual token response

12 |
13 | 14 |
${token_request}
15 | 16 |
${token_response}
17 |
18 |
19 | 20 |
21 |
more debugging options
22 |

More options will be added shortly

23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /src/main/resources/templates/error.ftl: -------------------------------------------------------------------------------- 1 | <#import "main.ftl" as layout /> 2 | 3 | <@layout.mainLayout title="mock-oauth2-server debugger" description="Just a mock oauth2 client"> 4 |
5 |
6 |

OAuth2 Client Debugger

7 |
8 | 9 |
10 |

Something went wrong.

11 |

Could be expired session? Please try again using the debugger form - ${debugger_url}

12 | 13 |
14 | 15 |
${stacktrace}
16 |
17 |
18 | 19 |
20 |
more debugging options
21 |

More options will be added shortly

22 |
23 |
24 | 25 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.ftl: -------------------------------------------------------------------------------- 1 | <#import "main.ftl" as layout /> 2 | 3 | <@layout.mainLayout title="mock-oauth2-server" description="Just a mock login"> 4 |
5 |
6 |

Mock OAuth2 Server Sign-in

7 |
8 |
9 |
10 |
 
11 |
12 |
13 | 18 | 25 | 26 |
27 |
28 |
 
29 |
30 |
31 |
32 | 33 |
34 |
 
35 |
36 |
Authorization Request
37 | <#list query as propName, propValue> 38 |
${propName} = ${propValue}
39 | 40 |
41 |
 
42 |
43 |
44 |
45 | 46 | -------------------------------------------------------------------------------- /src/main/resources/templates/main.ftl: -------------------------------------------------------------------------------- 1 | <#macro mainLayout title="" description=""> 2 | 3 | 4 | 5 | 6 | ${title} | ${description} 7 | 8 | 9 | 10 | 11 | 14 | 17 | 20 | 21 | 22 | <#nested /> 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/test/java/examples/java/springboot/MockOAuth2ServerInitializer.java: -------------------------------------------------------------------------------- 1 | package examples.java.springboot; 2 | 3 | import no.nav.security.mock.oauth2.MockOAuth2Server; 4 | import org.springframework.boot.test.util.TestPropertyValues; 5 | import org.springframework.context.ApplicationContextInitializer; 6 | import org.springframework.context.ConfigurableApplicationContext; 7 | import org.springframework.context.support.GenericApplicationContext; 8 | 9 | import java.io.IOException; 10 | import java.util.Map; 11 | 12 | //neccessary in order to create and start the server before the ApplicationContext is initialized, due to 13 | //the spring boot oauth2 resource server dependency invoking the server on application context creation. 14 | public class MockOAuth2ServerInitializer implements ApplicationContextInitializer { 15 | 16 | public static final String MOCK_OAUTH_2_SERVER_BASE_URL = "mock-oauth2-server.baseUrl"; 17 | 18 | @Override 19 | public void initialize(ConfigurableApplicationContext applicationContext) { 20 | var server = registerMockOAuth2Server(applicationContext); 21 | var baseUrl = server.baseUrl().toString().replaceAll("/$", ""); 22 | 23 | TestPropertyValues 24 | .of(Map.of(MOCK_OAUTH_2_SERVER_BASE_URL, baseUrl)) 25 | .applyTo(applicationContext); 26 | } 27 | 28 | private MockOAuth2Server registerMockOAuth2Server(ConfigurableApplicationContext applicationContext) { 29 | var server = new MockOAuth2Server(); 30 | server.start(); 31 | ((GenericApplicationContext) applicationContext).registerBean(MockOAuth2Server.class, () -> server); 32 | return server; 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/test/java/examples/java/springboot/login/OAuth2LoginApp.java: -------------------------------------------------------------------------------- 1 | package examples.java.springboot.login; 2 | 3 | import org.springframework.boot.Banner; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.http.MediaType; 8 | import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; 9 | import org.springframework.security.config.web.server.ServerHttpSecurity; 10 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 11 | import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; 12 | import org.springframework.security.web.server.SecurityWebFilterChain; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | import org.springframework.web.reactive.function.client.WebClient; 17 | import reactor.core.publisher.Mono; 18 | 19 | import java.io.IOException; 20 | 21 | import static org.springframework.security.config.Customizer.withDefaults; 22 | 23 | @SpringBootApplication 24 | public class OAuth2LoginApp { 25 | public static void main(String[] args) throws IOException { 26 | SpringApplication app = new SpringApplication(OAuth2LoginApp.class); 27 | app.setBannerMode(Banner.Mode.OFF); 28 | app.run(args); 29 | } 30 | 31 | @RestController 32 | @RequestMapping("/api") 33 | static class ApiController { 34 | 35 | @GetMapping(value = "/ping", produces = MediaType.TEXT_HTML_VALUE) 36 | Mono ping(@AuthenticationPrincipal OAuth2AuthenticationToken token) { 37 | return Mono.just("hello " + token.getPrincipal().getAttribute("sub")); 38 | } 39 | } 40 | 41 | @EnableWebFluxSecurity 42 | static class SecurityConfiguration { 43 | 44 | @Bean 45 | public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { 46 | return http.authorizeExchange(exchanges -> exchanges.anyExchange().authenticated()) 47 | .oauth2Login(withDefaults()) 48 | .build(); 49 | } 50 | 51 | @Bean 52 | WebClient client() { 53 | return WebClient.builder().build(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/examples/java/springboot/login/OAuth2LoginAppTest.java: -------------------------------------------------------------------------------- 1 | package examples.java.springboot.login; 2 | 3 | import examples.java.springboot.MockOAuth2ServerInitializer; 4 | import io.netty.handler.codec.http.cookie.Cookie; 5 | import no.nav.security.mock.oauth2.MockOAuth2Server; 6 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.boot.test.web.server.LocalServerPort; 12 | import org.springframework.http.client.reactive.ClientHttpConnector; 13 | import org.springframework.http.client.reactive.ReactorClientHttpConnector; 14 | import org.springframework.test.context.ContextConfiguration; 15 | import org.springframework.test.context.junit.jupiter.SpringExtension; 16 | import org.springframework.web.reactive.function.client.WebClient; 17 | import reactor.netty.http.client.HttpClient; 18 | 19 | import java.util.HashMap; 20 | import java.util.Map; 21 | 22 | import static examples.java.springboot.MockOAuth2ServerInitializer.MOCK_OAUTH_2_SERVER_BASE_URL; 23 | import static examples.java.springboot.login.OAuth2LoginAppTest.PROVIDER; 24 | import static examples.java.springboot.login.OAuth2LoginAppTest.PROVIDER_ID; 25 | import static examples.java.springboot.login.OAuth2LoginAppTest.REGISTRATION; 26 | import static org.junit.jupiter.api.Assertions.assertEquals; 27 | 28 | @ExtendWith(SpringExtension.class) 29 | @SpringBootTest( 30 | webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 31 | classes = OAuth2LoginApp.class, 32 | //these can be set in application yaml if you desire 33 | properties = { 34 | REGISTRATION + PROVIDER_ID + ".client-id=testclient", 35 | REGISTRATION + PROVIDER_ID + ".client-secret=testsecret", 36 | REGISTRATION + PROVIDER_ID + ".authorization-grant-type=authorization_code", 37 | REGISTRATION + PROVIDER_ID + ".redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", 38 | REGISTRATION + PROVIDER_ID + ".scope=openid", 39 | PROVIDER + PROVIDER_ID + ".authorization-uri=${" + MOCK_OAUTH_2_SERVER_BASE_URL + "}/issuer1/authorize", 40 | PROVIDER + PROVIDER_ID + ".token-uri=${" + MOCK_OAUTH_2_SERVER_BASE_URL + "}/issuer1/token", 41 | PROVIDER + PROVIDER_ID + ".jwk-set-uri=${" + MOCK_OAUTH_2_SERVER_BASE_URL + "}/issuer1/jwks" 42 | } 43 | ) 44 | @ContextConfiguration(initializers = {MockOAuth2ServerInitializer.class}) 45 | public class OAuth2LoginAppTest { 46 | public static final String CLIENT = "spring.security.oauth2.client"; 47 | public static final String PROVIDER = CLIENT + ".provider."; 48 | public static final String REGISTRATION = CLIENT + ".registration."; 49 | public static final String PROVIDER_ID = "myprovider"; 50 | 51 | @LocalServerPort 52 | private int localPort; 53 | 54 | @Autowired 55 | private MockOAuth2Server mockOAuth2Server; 56 | 57 | @Test 58 | public void oidcUserFooShouldBeLoggedIn() { 59 | Map cookieManager = new HashMap<>(); 60 | WebClient webClient = WebClient.builder() 61 | .clientConnector(followRedirectsWithCookies(cookieManager)) 62 | .build(); 63 | 64 | mockOAuth2Server.enqueueCallback(new DefaultOAuth2TokenCallback("issuer1", "foo")); 65 | 66 | String response = webClient 67 | .mutate() 68 | .baseUrl("http://localhost:" + localPort) 69 | .build() 70 | .get() 71 | .uri("/api/ping") 72 | .header("Accept", "text/html") 73 | .retrieve() 74 | .bodyToMono(String.class).block(); 75 | 76 | assertEquals("hello foo", response); 77 | } 78 | 79 | private ClientHttpConnector followRedirectsWithCookies(Map cookieManager) { 80 | return new ReactorClientHttpConnector( 81 | HttpClient 82 | .create() 83 | .followRedirect((req, resp) -> { 84 | for (var entry : resp.cookies().entrySet()) { 85 | var cookie = entry.getValue().stream().findFirst().orElse(null); 86 | if (cookie != null && cookie.value() != null && !cookie.value().isBlank()) { 87 | cookieManager.put(entry.getKey().toString(), cookie); 88 | } 89 | } 90 | return resp.responseHeaders().contains("Location"); 91 | }, 92 | req -> { 93 | for (var cookie : cookieManager.entrySet()) { 94 | req.header("Cookie", cookie.getKey() + "=" + cookie.getValue().value()); 95 | } 96 | } 97 | ) 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/test/java/examples/java/springboot/resourceserver/OAuth2ResourceServerApp.java: -------------------------------------------------------------------------------- 1 | package examples.java.springboot.resourceserver; 2 | 3 | import org.springframework.boot.Banner; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.security.config.Customizer; 8 | import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; 9 | import org.springframework.security.config.web.server.ServerHttpSecurity; 10 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 11 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 12 | import org.springframework.security.web.server.SecurityWebFilterChain; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | import org.springframework.web.reactive.function.client.WebClient; 17 | import reactor.core.publisher.Mono; 18 | 19 | @SpringBootApplication 20 | public class OAuth2ResourceServerApp { 21 | 22 | public static void main(String[] args) { 23 | SpringApplication app = new SpringApplication(OAuth2ResourceServerApp.class); 24 | app.setBannerMode(Banner.Mode.OFF); 25 | app.run(args); 26 | } 27 | 28 | @RestController 29 | @RequestMapping("/api") 30 | class ApiController { 31 | 32 | @GetMapping("/ping") 33 | Mono ping(@AuthenticationPrincipal JwtAuthenticationToken jwtAuthenticationToken) { 34 | return Mono.just("hello " + jwtAuthenticationToken.getToken().getSubject()); 35 | } 36 | } 37 | 38 | @EnableWebFluxSecurity 39 | class SecurityConfiguration { 40 | 41 | @Bean 42 | public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { 43 | return http.authorizeExchange(exchanges -> exchanges.anyExchange().authenticated()) 44 | .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) 45 | .build(); 46 | } 47 | 48 | @Bean 49 | WebClient client() { 50 | return WebClient.builder().build(); 51 | } 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/test/java/examples/java/springboot/resourceserver/OAuth2ResourceServerAppTest.java: -------------------------------------------------------------------------------- 1 | package examples.java.springboot.resourceserver; 2 | 3 | import examples.java.springboot.MockOAuth2ServerInitializer; 4 | import no.nav.security.mock.oauth2.MockOAuth2Server; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.test.context.ContextConfiguration; 11 | import org.springframework.test.context.junit.jupiter.SpringExtension; 12 | import org.springframework.test.web.reactive.server.WebTestClient; 13 | 14 | import static examples.java.springboot.MockOAuth2ServerInitializer.MOCK_OAUTH_2_SERVER_BASE_URL; 15 | 16 | @ExtendWith(SpringExtension.class) 17 | @SpringBootTest( 18 | webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 19 | classes = OAuth2ResourceServerApp.class, 20 | properties = "spring.security.oauth2.resourceserver.jwt.issuer-uri=${" + MOCK_OAUTH_2_SERVER_BASE_URL + "}/issuer1" 21 | ) 22 | @ContextConfiguration(initializers = {MockOAuth2ServerInitializer.class}) 23 | public class OAuth2ResourceServerAppTest { 24 | @Autowired 25 | private WebTestClient webClient; 26 | @Autowired 27 | private MockOAuth2Server mockOAuth2Server; 28 | 29 | @Test 30 | @DisplayName("api should return 401 if no bearer token present") 31 | public void isUnauthorized() { 32 | webClient.get() 33 | .uri("/api/ping") 34 | .exchange() 35 | .expectStatus() 36 | .isUnauthorized(); 37 | } 38 | 39 | @Test 40 | @DisplayName("api should return 200 when valid token is present") 41 | public void validTokenShouldReturn200Ok() { 42 | var token = mockOAuth2Server.issueToken("issuer1", "foo"); 43 | webClient.get() 44 | .uri("/api/ping") 45 | .headers(headers -> headers.setBearerAuth(token.serialize())) 46 | .exchange() 47 | .expectStatus() 48 | .isOk() 49 | .expectBody(String.class).isEqualTo("hello foo"); 50 | 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/test/kotlin/examples/kotlin/ktor/client/OAuth2Client.kt: -------------------------------------------------------------------------------- 1 | package examples.kotlin.ktor.client 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.algorithms.Algorithm 5 | import com.fasterxml.jackson.annotation.JsonInclude 6 | import com.fasterxml.jackson.annotation.JsonProperty 7 | import com.fasterxml.jackson.databind.DeserializationFeature 8 | import io.ktor.client.HttpClient 9 | import io.ktor.client.engine.cio.CIO 10 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 11 | import io.ktor.client.request.forms.submitForm 12 | import io.ktor.client.request.header 13 | import io.ktor.http.Headers 14 | import io.ktor.http.Parameters 15 | import io.ktor.http.headersOf 16 | import io.ktor.serialization.jackson.jackson 17 | import java.nio.charset.StandardCharsets 18 | import java.security.KeyPair 19 | import java.security.interfaces.RSAPrivateKey 20 | import java.security.interfaces.RSAPublicKey 21 | import java.time.Duration 22 | import java.time.Instant 23 | import java.util.Base64 24 | import java.util.Date 25 | import java.util.UUID 26 | 27 | val httpClient = 28 | HttpClient(CIO) { 29 | install(ContentNegotiation) { 30 | jackson { 31 | configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 32 | setSerializationInclusion(JsonInclude.Include.NON_NULL) 33 | } 34 | } 35 | } 36 | 37 | suspend fun HttpClient.tokenRequest( 38 | url: String, 39 | auth: Auth, 40 | params: Map, 41 | ) = submitForm( 42 | url = url, 43 | formParameters = 44 | Parameters.build { 45 | auth.parameters.forEach { 46 | append(it.key, it.value) 47 | } 48 | params.forEach { 49 | append(it.key, it.value) 50 | } 51 | }, 52 | ) { 53 | auth.headers.forEach { s, list -> header(s, list.first()) } 54 | } 55 | 56 | suspend fun HttpClient.clientCredentialsGrant( 57 | url: String, 58 | auth: Auth, 59 | scope: String, 60 | ) = tokenRequest( 61 | url = url, 62 | auth = auth, 63 | params = 64 | mapOf( 65 | "grant_type" to "client_credentials", 66 | "scope" to scope, 67 | ), 68 | ) 69 | 70 | suspend fun HttpClient.onBehalfOfGrant( 71 | url: String, 72 | auth: Auth, 73 | token: String, 74 | scope: String, 75 | ) = tokenRequest( 76 | url = url, 77 | auth = auth, 78 | params = 79 | mapOf( 80 | "scope" to scope, 81 | "grant_type" to "urn:ietf:params:oauth:grant-type:jwt-bearer", 82 | "requested_token_use" to "on_behalf_of", 83 | "assertion" to token, 84 | ), 85 | ) 86 | 87 | class Auth internal constructor( 88 | val parameters: Map = emptyMap(), 89 | val headers: Headers = Headers.Empty, 90 | ) { 91 | companion object { 92 | private const val CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 93 | 94 | fun clientSecretBasic( 95 | clientId: String, 96 | clientSecret: String, 97 | ): Auth = 98 | Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray(StandardCharsets.UTF_8)).let { 99 | Auth(headers = headersOf("Authorization", "Basic $it")) 100 | } 101 | 102 | fun privateKeyJwt(jwt: String): Auth = 103 | Auth( 104 | parameters = 105 | mapOf( 106 | "client_assertion_type" to CLIENT_ASSERTION_TYPE, 107 | "client_assertion" to jwt, 108 | ), 109 | ) 110 | 111 | fun privateKeyJwt( 112 | keyPair: KeyPair, 113 | clientId: String, 114 | audience: String, 115 | expiry: Duration = Duration.ofSeconds(120), 116 | ): Auth = 117 | Auth( 118 | parameters = 119 | mapOf( 120 | "client_assertion_type" to CLIENT_ASSERTION_TYPE, 121 | "client_assertion" to keyPair.clientAssertion(clientId, audience, expiry), 122 | ), 123 | ) 124 | 125 | private fun KeyPair.clientAssertion( 126 | clientId: String, 127 | audience: String, 128 | expiry: Duration = Duration.ofSeconds(120), 129 | ): String { 130 | val now = Instant.now() 131 | return JWT 132 | .create() 133 | .withAudience(audience) 134 | .withIssuer(clientId) 135 | .withSubject(clientId) 136 | .withJWTId(UUID.randomUUID().toString()) 137 | .withIssuedAt(Date.from(now)) 138 | .withNotBefore(Date.from(now)) 139 | .withExpiresAt(Date.from(now.plusSeconds(expiry.toSeconds()))) 140 | .sign(Algorithm.RSA256(this.public as RSAPublicKey, this.private as RSAPrivateKey)) 141 | } 142 | } 143 | } 144 | 145 | data class TokenResponse( 146 | @JsonProperty("access_token") 147 | val accessToken: String, 148 | @JsonProperty("expires_in") 149 | val expiresIn: Int, 150 | @JsonProperty("token_type") 151 | val tokenType: String, 152 | ) 153 | -------------------------------------------------------------------------------- /src/test/kotlin/examples/kotlin/ktor/client/OAuth2ClientTest.kt: -------------------------------------------------------------------------------- 1 | package examples.kotlin.ktor.client 2 | 3 | import com.auth0.jwt.JWT 4 | import io.kotest.assertions.asClue 5 | import io.kotest.matchers.collections.shouldContainExactly 6 | import io.kotest.matchers.shouldBe 7 | import io.ktor.client.call.body 8 | import kotlinx.coroutines.runBlocking 9 | import no.nav.security.mock.oauth2.MockOAuth2Server 10 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback 11 | import org.junit.jupiter.api.AfterEach 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | import java.security.KeyPairGenerator 15 | 16 | internal class OAuth2ClientTest { 17 | private val server = MockOAuth2Server() 18 | 19 | @BeforeEach 20 | fun setup() = server.start() 21 | 22 | @AfterEach 23 | fun after() = server.shutdown() 24 | 25 | @Test 26 | fun `client credentials grant`() { 27 | runBlocking { 28 | server.enqueueCallback(DefaultOAuth2TokenCallback(subject = "client1", audience = listOf("targetScope"))) 29 | 30 | val tokenResponse = 31 | httpClient.clientCredentialsGrant( 32 | url = server.tokenEndpointUrl("default").toString(), 33 | auth = Auth.clientSecretBasic("client1", "secret"), 34 | scope = "targetScope", 35 | ) 36 | 37 | tokenResponse.asClue { 38 | it 39 | .body() 40 | .accessToken 41 | .asDecodedJWT() 42 | .subject shouldBe "client1" 43 | it 44 | .body() 45 | .accessToken 46 | .asDecodedJWT() 47 | .audience 48 | .shouldContainExactly("targetScope") 49 | } 50 | } 51 | } 52 | 53 | @Test 54 | fun `onbehalfof grant`() { 55 | runBlocking { 56 | val initialToken = server.issueToken(subject = "enduser") 57 | val tokenEndpointUrl = server.tokenEndpointUrl("default").toString() 58 | val issuerUrl = server.issuerUrl("default").toString() 59 | val tokenResponse = 60 | httpClient.onBehalfOfGrant( 61 | url = tokenEndpointUrl, 62 | auth = 63 | Auth.privateKeyJwt( 64 | keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair(), 65 | clientId = "client1", 66 | audience = issuerUrl, 67 | ), 68 | token = initialToken.serialize(), 69 | scope = "targetScope", 70 | ) 71 | 72 | tokenResponse.asClue { 73 | it 74 | .body() 75 | .accessToken 76 | .asDecodedJWT() 77 | .subject shouldBe "enduser" 78 | it 79 | .body() 80 | .accessToken 81 | .asDecodedJWT() 82 | .audience 83 | .shouldContainExactly("targetScope") 84 | } 85 | } 86 | } 87 | 88 | private fun String.asDecodedJWT() = JWT.decode(this) 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/examples/kotlin/ktor/login/OAuth2LoginApp.kt: -------------------------------------------------------------------------------- 1 | package examples.kotlin.ktor.login 2 | 3 | import com.auth0.jwt.JWT 4 | import com.fasterxml.jackson.annotation.JsonInclude 5 | import com.fasterxml.jackson.databind.DeserializationFeature 6 | import io.ktor.client.HttpClient 7 | import io.ktor.client.engine.cio.CIO 8 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 9 | import io.ktor.http.ContentType 10 | import io.ktor.http.HttpMethod 11 | import io.ktor.http.HttpStatusCode 12 | import io.ktor.serialization.jackson.jackson 13 | import io.ktor.server.application.Application 14 | import io.ktor.server.application.ApplicationCall 15 | import io.ktor.server.application.call 16 | import io.ktor.server.application.install 17 | import io.ktor.server.auth.Authentication 18 | import io.ktor.server.auth.OAuthAccessTokenResponse 19 | import io.ktor.server.auth.OAuthServerSettings 20 | import io.ktor.server.auth.authenticate 21 | import io.ktor.server.auth.authentication 22 | import io.ktor.server.auth.oauth 23 | import io.ktor.server.engine.embeddedServer 24 | import io.ktor.server.locations.KtorExperimentalLocationsAPI 25 | import io.ktor.server.locations.Location 26 | import io.ktor.server.locations.Locations 27 | import io.ktor.server.locations.location 28 | import io.ktor.server.locations.locations 29 | import io.ktor.server.locations.url 30 | import io.ktor.server.netty.Netty 31 | import io.ktor.server.response.respondText 32 | import io.ktor.server.routing.get 33 | import io.ktor.server.routing.param 34 | import io.ktor.server.routing.routing 35 | 36 | fun main() { 37 | embeddedServer(Netty, port = 8080) { 38 | module( 39 | AuthConfig( 40 | listOf( 41 | AuthConfig.IdProvider( 42 | name = "google", 43 | authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth", 44 | tokenEndpoint = "https://oauth2.googleapis.com/token", 45 | ), 46 | AuthConfig.IdProvider( 47 | name = "github", 48 | authorizationEndpoint = "https://github.com/login/oauth/authorize", 49 | tokenEndpoint = "https://github.com/login/oauth/access_token", 50 | ), 51 | ), 52 | ), 53 | ) 54 | }.start(true) 55 | } 56 | 57 | @OptIn(KtorExperimentalLocationsAPI::class) 58 | fun Application.module(authConfig: AuthConfig) { 59 | val idProviders = authConfig.providers.map { it.settings }.associateBy { it.name } 60 | 61 | install(Locations) 62 | install(Authentication) { 63 | oauth("oauth2") { 64 | client = httpClient 65 | providerLookup = { 66 | idProviders[application.locations.resolve(Login::class, this).type] ?: idProviders.values.first() 67 | } 68 | urlProvider = { 69 | url(Login(it.name)) 70 | } 71 | } 72 | } 73 | 74 | routing { 75 | authenticate("oauth2") { 76 | get { 77 | call.respondText("nothing to see here really") 78 | } 79 | location { 80 | param("error") { 81 | handle { 82 | call.respondText(ContentType.Text.Html, HttpStatusCode.BadRequest) { 83 | "received error on login: ${call.parameters.getAll("error").orEmpty()}" 84 | } 85 | } 86 | } 87 | handle { 88 | call.respondText("welcome ${call.subject()}") 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | @Location("/login/{type?}") 96 | @OptIn(KtorExperimentalLocationsAPI::class) 97 | class Login( 98 | val type: String = "", 99 | ) 100 | 101 | class AuthConfig( 102 | val providers: List = emptyList(), 103 | ) { 104 | class IdProvider( 105 | val name: String, 106 | authorizationEndpoint: String, 107 | tokenEndpoint: String, 108 | ) { 109 | val settings = 110 | OAuthServerSettings.OAuth2ServerSettings( 111 | name = name, 112 | authorizeUrl = authorizationEndpoint, 113 | accessTokenUrl = tokenEndpoint, 114 | requestMethod = HttpMethod.Post, 115 | clientId = "***", 116 | clientSecret = "***", 117 | defaultScopes = listOf("openid"), 118 | ) 119 | } 120 | } 121 | 122 | private fun ApplicationCall.subject(): String? { 123 | val idToken = authentication.principal()?.extraParameters?.get("id_token") 124 | // should verify id_token before use as ktor doesnt, however left out in the example 125 | return if (idToken != null) { 126 | JWT.decode(idToken).subject 127 | } else { 128 | null 129 | } 130 | } 131 | 132 | internal val httpClient = 133 | HttpClient(CIO) { 134 | install(ContentNegotiation) { 135 | jackson { 136 | configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 137 | setSerializationInclusion(JsonInclude.Include.NON_NULL) 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/test/kotlin/examples/kotlin/ktor/login/OAuth2LoginAppTest.kt: -------------------------------------------------------------------------------- 1 | package examples.kotlin.ktor.login 2 | 3 | import io.kotest.assertions.asClue 4 | import io.kotest.matchers.shouldBe 5 | import io.ktor.client.request.prepareGet 6 | import io.ktor.server.application.Application 7 | import io.ktor.server.engine.ApplicationEngine 8 | import io.ktor.server.engine.embeddedServer 9 | import io.ktor.server.netty.Netty 10 | import kotlinx.coroutines.runBlocking 11 | import no.nav.security.mock.oauth2.MockOAuth2Server 12 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback 13 | import org.junit.jupiter.api.AfterEach 14 | import org.junit.jupiter.api.BeforeEach 15 | import org.junit.jupiter.api.Test 16 | import org.junit.jupiter.api.fail 17 | import java.io.IOException 18 | import java.net.ServerSocket 19 | 20 | internal class OAuth2LoginAppTest { 21 | private val mockOAuth2Server = MockOAuth2Server() 22 | 23 | @BeforeEach 24 | fun setup() = mockOAuth2Server.start() 25 | 26 | @AfterEach 27 | fun after() = mockOAuth2Server.shutdown() 28 | 29 | @Test 30 | fun `login with google or github should return appropriate subject`() { 31 | mockOAuth2Server.enqueueCallback(DefaultOAuth2TokenCallback(issuerId = "google", subject = "googleSubject")) 32 | mockOAuth2Server.enqueueCallback(DefaultOAuth2TokenCallback(issuerId = "github", subject = "githubSubject")) 33 | 34 | val port = randomPort() 35 | 36 | withEmbeddedServer( 37 | { module(authConfig()) }, 38 | port, 39 | ) { 40 | get("http://localhost:$port/login/google").asClue { 41 | it shouldBe "welcome googleSubject" 42 | } 43 | get("http://localhost:$port/login/github").asClue { 44 | it shouldBe "welcome githubSubject" 45 | } 46 | } 47 | } 48 | 49 | private inline fun get(url: String): R = runBlocking { httpClient.prepareGet(url).body() } 50 | 51 | private fun withEmbeddedServer( 52 | moduleFunction: Application.() -> Unit, 53 | port: Int, 54 | test: ApplicationEngine.() -> R, 55 | ): R { 56 | val engine = 57 | embeddedServer(Netty, port = port) { 58 | moduleFunction(this) 59 | } 60 | engine.start() 61 | try { 62 | return engine.test() 63 | } finally { 64 | engine.stop(0L, 0L) 65 | } 66 | } 67 | 68 | private fun randomPort() = 69 | try { 70 | ServerSocket(0).use { serverSocket -> serverSocket.localPort } 71 | } catch (e: IOException) { 72 | fail("Port is not available") 73 | } 74 | 75 | private fun authConfig() = 76 | AuthConfig( 77 | listOf( 78 | AuthConfig.IdProvider( 79 | name = "google", 80 | authorizationEndpoint = mockOAuth2Server.authorizationEndpointUrl("google").toString(), 81 | tokenEndpoint = mockOAuth2Server.tokenEndpointUrl("google").toString(), 82 | ), 83 | AuthConfig.IdProvider( 84 | name = "github", 85 | authorizationEndpoint = mockOAuth2Server.authorizationEndpointUrl("github").toString(), 86 | tokenEndpoint = mockOAuth2Server.tokenEndpointUrl("github").toString(), 87 | ), 88 | ), 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/test/kotlin/examples/kotlin/ktor/resourceserver/OAuth2ResourceServerAppTest.kt: -------------------------------------------------------------------------------- 1 | package examples.kotlin.ktor.resourceserver 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.ktor.client.request.get 5 | import io.ktor.client.request.header 6 | import io.ktor.client.statement.bodyAsText 7 | import io.ktor.http.HttpStatusCode 8 | import io.ktor.server.testing.testApplication 9 | import no.nav.security.mock.oauth2.MockOAuth2Server 10 | import no.nav.security.mock.oauth2.withMockOAuth2Server 11 | import org.junit.jupiter.api.Test 12 | 13 | class OAuth2ResourceServerAppTest { 14 | @Test 15 | fun `http get to secured endpoint without token should return 401`() { 16 | withMockOAuth2Server { 17 | val authConfig = authConfig() 18 | 19 | testApplication { 20 | this.application { 21 | module(authConfig) 22 | } 23 | val response = client.get("/hello1") 24 | response.status shouldBe HttpStatusCode.Unauthorized 25 | } 26 | } 27 | } 28 | 29 | @Test 30 | fun `http get to hello1 endpoint should only accept tokens from provider1 with correct claims`() { 31 | withMockOAuth2Server { 32 | val mockOAuth2Server = this 33 | val authConfig = authConfig() 34 | testApplication { 35 | this.application { 36 | module(authConfig) 37 | } 38 | val response = 39 | client.get("/hello1") { 40 | header("Authorization", "Bearer ${mockOAuth2Server.tokenFromProvider1()}") 41 | } 42 | 43 | response.status shouldBe HttpStatusCode.OK 44 | response.bodyAsText() shouldBe "hello1 foo from issuer ${mockOAuth2Server.issuerUrl("provider1")}" 45 | } 46 | } 47 | } 48 | 49 | @Test 50 | fun `http get to hello2 endpoint should only accept tokens from provider2 with correct claims`() { 51 | withMockOAuth2Server { 52 | val mockOAuth2Server = this 53 | val authConfig = authConfig() 54 | testApplication { 55 | this.application { 56 | module(authConfig) 57 | } 58 | 59 | val response = 60 | client.get("/hello2") { 61 | header("Authorization", "Bearer ${mockOAuth2Server.tokenFromProvider2()}") 62 | } 63 | response.status shouldBe HttpStatusCode.OK 64 | response.bodyAsText() shouldBe "hello2 foo from issuer ${mockOAuth2Server.issuerUrl("provider2")}" 65 | } 66 | } 67 | } 68 | 69 | private fun MockOAuth2Server.tokenFromProvider1() = 70 | issueToken( 71 | "provider1", 72 | "foo", 73 | "scopeFromProvider1", 74 | mapOf("groups" to listOf("group1", "group2")), 75 | ).serialize() 76 | 77 | private fun MockOAuth2Server.tokenFromProvider2() = 78 | issueToken( 79 | "provider2", 80 | "foo", 81 | "scopeFromProvider2", 82 | mapOf("stringClaim" to "1"), 83 | ).serialize() 84 | 85 | private fun MockOAuth2Server.authConfig() = 86 | AuthConfig( 87 | mapOf( 88 | "provider1" to 89 | AuthConfig.TokenProvider( 90 | wellKnownUrl = wellKnownUrl("provider1").toString(), 91 | acceptedAudience = "scopeFromProvider1", 92 | requiredClaims = mapOf("groups" to listOf("group2")), 93 | ), 94 | "provider2" to 95 | AuthConfig.TokenProvider( 96 | wellKnownUrl = wellKnownUrl("provider2").toString(), 97 | acceptedAudience = "scopeFromProvider2", 98 | requiredClaims = mapOf("stringClaim" to "1"), 99 | ), 100 | ), 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/MockOAuth2ServerTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2 2 | 3 | import io.kotest.assertions.asClue 4 | import io.kotest.assertions.throwables.shouldThrow 5 | import io.kotest.matchers.shouldBe 6 | import no.nav.security.mock.oauth2.testutils.client 7 | import no.nav.security.mock.oauth2.testutils.get 8 | import no.nav.security.mock.oauth2.testutils.post 9 | import okhttp3.OkHttpClient 10 | import org.junit.jupiter.api.Test 11 | import java.util.concurrent.TimeUnit 12 | 13 | class MockOAuth2ServerTest { 14 | private val client: OkHttpClient = client() 15 | 16 | @Test 17 | fun `server takeRequest() should return sent request`() { 18 | withMockOAuth2Server { 19 | client.post(this.baseUrl(), mapOf("param1" to "value1")).body?.close() 20 | 21 | this.takeRequest().asClue { 22 | it.requestUrl shouldBe this.baseUrl() 23 | it.body.readUtf8() shouldBe "param1=value1" 24 | } 25 | 26 | client 27 | .post( 28 | this.tokenEndpointUrl("test"), 29 | mapOf( 30 | "client_id" to "client", 31 | "client_secret" to "sec", 32 | "grant_type" to "client_credentials", 33 | "scope" to "scope1", 34 | ), 35 | ).body 36 | ?.close() 37 | 38 | this.takeRequest().asClue { 39 | it.requestUrl shouldBe this.tokenEndpointUrl("test") 40 | it.body.readUtf8() shouldBe "client_id=client&client_secret=sec&grant_type=client_credentials&scope=scope1" 41 | } 42 | } 43 | } 44 | 45 | @Test 46 | fun `takeRequest should time out if no request is received`() { 47 | withMockOAuth2Server { 48 | shouldThrow { 49 | this.takeRequest(5, TimeUnit.MILLISECONDS) 50 | } 51 | val url = this.wellKnownUrl("1") 52 | client.get(url) 53 | this.takeRequest().requestUrl shouldBe url 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/StandaloneMockOAuth2ServerKtTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.fasterxml.jackson.module.kotlin.readValue 5 | import io.kotest.extensions.system.withEnvironment 6 | import io.kotest.matchers.collections.shouldContainExactly 7 | import io.kotest.matchers.should 8 | import io.kotest.matchers.shouldBe 9 | import io.kotest.matchers.types.beInstanceOf 10 | import no.nav.security.mock.oauth2.StandaloneConfig.JSON_CONFIG 11 | import no.nav.security.mock.oauth2.StandaloneConfig.JSON_CONFIG_PATH 12 | import no.nav.security.mock.oauth2.StandaloneConfig.PORT 13 | import no.nav.security.mock.oauth2.StandaloneConfig.SERVER_PORT 14 | import no.nav.security.mock.oauth2.StandaloneConfig.hostname 15 | import no.nav.security.mock.oauth2.StandaloneConfig.oauth2Config 16 | import no.nav.security.mock.oauth2.StandaloneConfig.port 17 | import no.nav.security.mock.oauth2.http.NettyWrapper 18 | import org.junit.jupiter.api.Test 19 | import java.io.File 20 | import java.net.InetSocketAddress 21 | 22 | internal class StandaloneMockOAuth2ServerKtTest { 23 | private val configFile = "src/test/resources/config.json" 24 | 25 | @Test 26 | fun `load config with no env vars set`() { 27 | val config = oauth2Config() 28 | config.tokenCallbacks.size shouldBe 0 29 | config.interactiveLogin shouldBe true 30 | config.httpServer should beInstanceOf() 31 | hostname() shouldBe InetSocketAddress(0).address 32 | port() shouldBe 8080 33 | } 34 | 35 | @Test 36 | fun `with the environment variable SERVER_PORT set`() { 37 | withEnvironment(SERVER_PORT to "9292") { 38 | port() shouldBe 9292 39 | } 40 | } 41 | 42 | @Test 43 | fun `with the environment variable PORT set`() { 44 | withEnvironment(PORT to "9292") { 45 | port() shouldBe 9292 46 | } 47 | } 48 | 49 | @Test 50 | fun `with the environment variables SERVER_PORT and PORT set`() { 51 | withEnvironment(mapOf(SERVER_PORT to "9292", PORT to "9393")) { 52 | port() shouldBe 9292 53 | } 54 | } 55 | 56 | @Test 57 | fun `load oauth2Config from file`() { 58 | withEnvironment(JSON_CONFIG_PATH to configFile) { 59 | val config = oauth2Config() 60 | config.tokenCallbacks.size shouldBe 2 61 | config.tokenCallbacks shouldContainExactly tokenCallbacksFromFile() 62 | } 63 | } 64 | 65 | @Test 66 | fun `load oauth2Config from env var`() { 67 | val json = File(configFile).readText() 68 | withEnvironment(JSON_CONFIG to json) { 69 | val config = oauth2Config() 70 | config.tokenCallbacks.size shouldBe 2 71 | config.tokenCallbacks shouldContainExactly tokenCallbacksFromFile() 72 | } 73 | } 74 | 75 | private fun tokenCallbacksFromFile() = jacksonObjectMapper().readValue(File(configFile).readText()).tokenCallbacks 76 | } 77 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/CorsHeadersIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import com.nimbusds.oauth2.sdk.GrantType 4 | import io.kotest.assertions.asClue 5 | import io.kotest.matchers.shouldBe 6 | import no.nav.security.mock.oauth2.http.CorsInterceptor.HeaderNames.ACCESS_CONTROL_ALLOW_CREDENTIALS 7 | import no.nav.security.mock.oauth2.http.CorsInterceptor.HeaderNames.ACCESS_CONTROL_ALLOW_HEADERS 8 | import no.nav.security.mock.oauth2.http.CorsInterceptor.HeaderNames.ACCESS_CONTROL_ALLOW_METHODS 9 | import no.nav.security.mock.oauth2.http.CorsInterceptor.HeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN 10 | import no.nav.security.mock.oauth2.http.CorsInterceptor.HeaderNames.ACCESS_CONTROL_REQUEST_HEADERS 11 | import no.nav.security.mock.oauth2.testutils.client 12 | import no.nav.security.mock.oauth2.testutils.get 13 | import no.nav.security.mock.oauth2.testutils.options 14 | import no.nav.security.mock.oauth2.testutils.tokenRequest 15 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback 16 | import no.nav.security.mock.oauth2.withMockOAuth2Server 17 | import okhttp3.Headers 18 | import org.junit.jupiter.api.Test 19 | 20 | class CorsHeadersIntegrationTest { 21 | private val client = client() 22 | 23 | private val origin = "https://theorigin" 24 | 25 | @Test 26 | fun `preflight response should allow specific origin, methods and headers`() { 27 | withMockOAuth2Server { 28 | client 29 | .options( 30 | this.baseUrl(), 31 | Headers.headersOf( 32 | "origin", 33 | origin, 34 | ACCESS_CONTROL_REQUEST_HEADERS, 35 | "X-MY-HEADER", 36 | ), 37 | ).asClue { 38 | it.code shouldBe 204 39 | it.headers[ACCESS_CONTROL_ALLOW_ORIGIN] shouldBe origin 40 | it.headers[ACCESS_CONTROL_ALLOW_METHODS] shouldBe "POST, GET, OPTIONS" 41 | it.headers[ACCESS_CONTROL_ALLOW_HEADERS] shouldBe "X-MY-HEADER" 42 | it.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] shouldBe "true" 43 | } 44 | } 45 | } 46 | 47 | @Test 48 | fun `wellknown response should allow origin`() { 49 | withMockOAuth2Server { 50 | client 51 | .get( 52 | this.wellKnownUrl("issuer"), 53 | Headers.headersOf("origin", origin), 54 | ).asClue { 55 | it.code shouldBe 200 56 | it.headers[ACCESS_CONTROL_ALLOW_ORIGIN] shouldBe origin 57 | it.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] shouldBe "true" 58 | } 59 | } 60 | } 61 | 62 | @Test 63 | fun `jwks response should allow all origins`() { 64 | withMockOAuth2Server { 65 | client 66 | .get( 67 | this.jwksUrl("issuer"), 68 | Headers.headersOf("origin", origin), 69 | ).asClue { 70 | it.code shouldBe 200 71 | it.headers[ACCESS_CONTROL_ALLOW_ORIGIN] shouldBe origin 72 | it.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] shouldBe "true" 73 | } 74 | } 75 | } 76 | 77 | @Test 78 | fun `token response should allow all origins`() { 79 | withMockOAuth2Server { 80 | val expectedSubject = "expectedSub" 81 | val issuerId = "idprovider" 82 | this.enqueueCallback(DefaultOAuth2TokenCallback(issuerId = issuerId, subject = expectedSubject)) 83 | 84 | val response = 85 | client.tokenRequest( 86 | this.tokenEndpointUrl(issuerId), 87 | Headers.headersOf("origin", origin), 88 | mapOf( 89 | "grant_type" to GrantType.REFRESH_TOKEN.value, 90 | "refresh_token" to "canbewhatever", 91 | "client_id" to "id", 92 | "client_secret" to "secret", 93 | ), 94 | ) 95 | 96 | response.code shouldBe 200 97 | response.headers[ACCESS_CONTROL_ALLOW_ORIGIN] shouldBe origin 98 | response.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] shouldBe "true" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/InteractiveLoginIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import io.kotest.assertions.asClue 5 | import io.kotest.matchers.maps.shouldContainAll 6 | import io.kotest.matchers.nulls.shouldNotBeNull 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.string.shouldContain 9 | import no.nav.security.mock.oauth2.MockOAuth2Server 10 | import no.nav.security.mock.oauth2.OAuth2Config 11 | import no.nav.security.mock.oauth2.testutils.authenticationRequest 12 | import no.nav.security.mock.oauth2.testutils.claims 13 | import no.nav.security.mock.oauth2.testutils.client 14 | import no.nav.security.mock.oauth2.testutils.get 15 | import no.nav.security.mock.oauth2.testutils.post 16 | import no.nav.security.mock.oauth2.testutils.subject 17 | import no.nav.security.mock.oauth2.testutils.toTokenResponse 18 | import no.nav.security.mock.oauth2.testutils.tokenRequest 19 | import okhttp3.HttpUrl.Companion.toHttpUrl 20 | import org.junit.jupiter.params.ParameterizedTest 21 | import org.junit.jupiter.params.provider.Arguments 22 | import org.junit.jupiter.params.provider.MethodSource 23 | import java.util.stream.Stream 24 | 25 | class InteractiveLoginIntegrationTest { 26 | private val issuerId = "default" 27 | private val server = MockOAuth2Server(OAuth2Config(interactiveLogin = true)).apply { start() } 28 | private val client = client() 29 | 30 | @ParameterizedTest 31 | @MethodSource("testUsers") 32 | internal fun `interactive login with a supplied username should result in id_token containing sub and claims from input`(user: User) { 33 | val code = loginForCode(user) 34 | 35 | val response = tokenRequest(code) 36 | 37 | response.idToken.shouldNotBeNull() 38 | response.idToken.subject shouldBe user.username 39 | response.idToken.claims shouldContainAll user.claims 40 | } 41 | 42 | companion object { 43 | @JvmStatic 44 | fun testUsers(): Stream = 45 | Stream.of( 46 | Arguments.of( 47 | User( 48 | username = "user1", 49 | claims = 50 | mapOf( 51 | "claim1" to "claim1value", 52 | ), 53 | ), 54 | ), 55 | Arguments.of( 56 | User( 57 | username = "user2", 58 | claims = 59 | mapOf( 60 | "claim2" to "claim2value", 61 | ), 62 | ), 63 | ), 64 | ) 65 | } 66 | 67 | private fun loginForCode(user: User): String { 68 | val loginUrl = server.authorizationEndpointUrl(issuerId).authenticationRequest() 69 | client.get(loginUrl).asClue { 70 | it.code shouldBe 200 71 | it.body?.string() shouldContain " 82 | val code = authResponse.headers["location"]?.toHttpUrl()?.queryParameter("code") 83 | code.shouldNotBeNull() 84 | } 85 | } 86 | 87 | private fun tokenRequest(authCode: String) = 88 | client 89 | .tokenRequest( 90 | server.tokenEndpointUrl(issuerId), 91 | mapOf( 92 | "client_id" to "client1", 93 | "client_secret" to "secret", 94 | "grant_type" to "authorization_code", 95 | "redirect_uri" to "http://mycallback", 96 | "code" to authCode, 97 | ), 98 | ).toTokenResponse() 99 | 100 | internal data class User( 101 | val username: String, 102 | val claims: Map = emptyMap(), 103 | ) { 104 | fun claimsAsJson(): String = jacksonObjectMapper().writeValueAsString(claims) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/LoginPageIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.kotest.matchers.shouldNotBe 5 | import io.kotest.matchers.string.shouldContain 6 | import no.nav.security.mock.oauth2.MockOAuth2Server 7 | import no.nav.security.mock.oauth2.OAuth2Config 8 | import no.nav.security.mock.oauth2.testutils.authenticationRequest 9 | import no.nav.security.mock.oauth2.testutils.client 10 | import no.nav.security.mock.oauth2.testutils.get 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.params.ParameterizedTest 13 | import org.junit.jupiter.params.provider.ValueSource 14 | 15 | class LoginPageIntegrationTest { 16 | private val client = client() 17 | 18 | @Test 19 | fun `authorization with interactive login should return built-in login page`() { 20 | val server = MockOAuth2Server(OAuth2Config(interactiveLogin = true)).apply { start() } 21 | val body = client.get(server.authorizationEndpointUrl("default").authenticationRequest()).body?.string() 22 | 23 | body shouldNotBe null 24 | body shouldContain "

Mock OAuth2 Server Sign-in

" 25 | } 26 | 27 | @Test 28 | fun `authorization with interactive login and login page path set should return external login page`() { 29 | val server = 30 | MockOAuth2Server( 31 | OAuth2Config( 32 | interactiveLogin = true, 33 | loginPagePath = "./src/test/resources/login.example.html", 34 | ), 35 | ).apply { start() } 36 | val body = client.get(server.authorizationEndpointUrl("default").authenticationRequest()).body?.string() 37 | 38 | body shouldNotBe null 39 | body shouldContain "Mock OAuth2 Server Example" 40 | } 41 | 42 | @ParameterizedTest 43 | @ValueSource(strings = ["./src/test/resources/does-not-exists.html", "./src/test/resources/", ""]) 44 | fun `authorization with interactive login and login page path set to invalid path should return 404`(path: String) { 45 | val server = 46 | MockOAuth2Server( 47 | OAuth2Config( 48 | interactiveLogin = true, 49 | loginPagePath = path, 50 | ), 51 | ).apply { start() } 52 | val code = client.get(server.authorizationEndpointUrl("default").authenticationRequest()).code 53 | 54 | code shouldBe 404 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/PasswordGrantIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import com.nimbusds.oauth2.sdk.GrantType 4 | import io.kotest.matchers.collections.shouldContainExactly 5 | import io.kotest.matchers.nulls.shouldNotBeNull 6 | import io.kotest.matchers.should 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.string.shouldContain 9 | import no.nav.security.mock.oauth2.testutils.ParsedTokenResponse 10 | import no.nav.security.mock.oauth2.testutils.audience 11 | import no.nav.security.mock.oauth2.testutils.client 12 | import no.nav.security.mock.oauth2.testutils.shouldBeValidFor 13 | import no.nav.security.mock.oauth2.testutils.subject 14 | import no.nav.security.mock.oauth2.testutils.toTokenResponse 15 | import no.nav.security.mock.oauth2.testutils.tokenRequest 16 | import no.nav.security.mock.oauth2.testutils.verifyWith 17 | import no.nav.security.mock.oauth2.withMockOAuth2Server 18 | import org.junit.jupiter.api.Test 19 | 20 | class PasswordGrantIntegrationTest { 21 | private val client = client() 22 | 23 | @Test 24 | fun `token request with password grant should return accesstoken with username as subject`() { 25 | withMockOAuth2Server { 26 | val issuerId = "default" 27 | val response: ParsedTokenResponse = 28 | client 29 | .tokenRequest( 30 | url = this.tokenEndpointUrl(issuerId), 31 | basicAuth = Pair("client", "secret"), 32 | parameters = 33 | mapOf( 34 | "grant_type" to GrantType.PASSWORD.value, 35 | "scope" to "scope1", 36 | "username" to "foo", 37 | "password" to "bar", 38 | ), 39 | ).toTokenResponse() 40 | 41 | response shouldBeValidFor GrantType.PASSWORD 42 | response.scope shouldContain "scope1" 43 | response.accessToken.shouldNotBeNull() 44 | response.accessToken should verifyWith(issuerId, this) 45 | response.accessToken.subject shouldBe "foo" 46 | response.accessToken.audience shouldContainExactly listOf("scope1") 47 | response.idToken.shouldNotBeNull() 48 | response.idToken should verifyWith(issuerId, this) 49 | response.idToken.subject shouldBe "foo" 50 | response.idToken.audience shouldContainExactly listOf("client") 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/RevocationIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import com.nimbusds.oauth2.sdk.GrantType 4 | import io.kotest.matchers.nulls.shouldNotBeNull 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.shouldNotBe 7 | import no.nav.security.mock.oauth2.MockOAuth2Server 8 | import no.nav.security.mock.oauth2.grant.RefreshToken 9 | import no.nav.security.mock.oauth2.testutils.ParsedTokenResponse 10 | import no.nav.security.mock.oauth2.testutils.authenticationRequest 11 | import no.nav.security.mock.oauth2.testutils.client 12 | import no.nav.security.mock.oauth2.testutils.post 13 | import no.nav.security.mock.oauth2.testutils.subject 14 | import no.nav.security.mock.oauth2.testutils.toTokenResponse 15 | import no.nav.security.mock.oauth2.testutils.tokenRequest 16 | import no.nav.security.mock.oauth2.withMockOAuth2Server 17 | import okhttp3.HttpUrl.Companion.toHttpUrl 18 | import okhttp3.OkHttpClient 19 | import org.junit.jupiter.api.Test 20 | 21 | class RevocationIntegrationTest { 22 | private val client: OkHttpClient = client() 23 | private val initialSubject = "yolo" 24 | private val issuerId = "idprovider" 25 | 26 | @Test 27 | fun `revocation request with refresh_token should should remove refresh token`() { 28 | withMockOAuth2Server { 29 | val tokenResponseBeforeRefresh = login() 30 | tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject 31 | tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject 32 | 33 | var refreshTokenResponse = refresh(tokenResponseBeforeRefresh.refreshToken) 34 | refreshTokenResponse.accessToken?.subject shouldBe initialSubject 35 | val refreshToken = checkNotNull(refreshTokenResponse.refreshToken) 36 | val revocationResponse = 37 | client.post( 38 | this.url("/default/revoke"), 39 | mapOf( 40 | "client_id" to "id", 41 | "client_secret" to "secret", 42 | "token" to refreshToken, 43 | "token_type_hint" to "refresh_token", 44 | ), 45 | ) 46 | revocationResponse.code shouldBe 200 47 | 48 | refreshTokenResponse = refresh(tokenResponseBeforeRefresh.refreshToken) 49 | refreshTokenResponse.accessToken?.subject shouldNotBe initialSubject 50 | } 51 | } 52 | 53 | private fun MockOAuth2Server.login(): ParsedTokenResponse { 54 | // Authenticate using Authorization Code Flow 55 | // simulate user interaction by doing the auth request as a post (instead of get with user punching username/pwd and submitting form) 56 | val authorizationCode = 57 | client 58 | .post( 59 | this.authorizationEndpointUrl("default").authenticationRequest(), 60 | mapOf("username" to initialSubject), 61 | ).let { authResponse -> 62 | authResponse.headers["location"]?.toHttpUrl()?.queryParameter("code") 63 | } 64 | 65 | authorizationCode.shouldNotBeNull() 66 | 67 | // Token Request based on authorization code 68 | return client 69 | .tokenRequest( 70 | this.tokenEndpointUrl(issuerId), 71 | mapOf( 72 | "grant_type" to GrantType.AUTHORIZATION_CODE.value, 73 | "code" to authorizationCode, 74 | "client_id" to "id", 75 | "client_secret" to "secret", 76 | "redirect_uri" to "http://something", 77 | ), 78 | ).toTokenResponse() 79 | } 80 | 81 | private fun MockOAuth2Server.refresh(token: RefreshToken?): ParsedTokenResponse { 82 | // make token request with the refresh_token grant 83 | val refreshToken = checkNotNull(token) 84 | return client 85 | .tokenRequest( 86 | this.tokenEndpointUrl(issuerId), 87 | mapOf( 88 | "grant_type" to GrantType.REFRESH_TOKEN.value, 89 | "refresh_token" to refreshToken, 90 | "client_id" to "id", 91 | "client_secret" to "secret", 92 | ), 93 | ).toTokenResponse() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/StaticAssetsIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import io.kotest.assertions.asClue 4 | import io.kotest.matchers.shouldBe 5 | import no.nav.security.mock.oauth2.MockOAuth2Server 6 | import no.nav.security.mock.oauth2.OAuth2Config 7 | import no.nav.security.mock.oauth2.testutils.client 8 | import no.nav.security.mock.oauth2.testutils.get 9 | import org.junit.jupiter.api.Test 10 | import java.io.File 11 | 12 | class StaticAssetsIntegrationTest { 13 | private val client = client() 14 | 15 | @Test 16 | fun `request to static asset should return file from static asset directory`() { 17 | val dir = File("./src/test/resources/static") 18 | val server = MockOAuth2Server(OAuth2Config(staticAssetsPath = dir.canonicalPath)).apply { start() } 19 | client.get(server.url("/static/test.txt")).asClue { 20 | it.code shouldBe 200 21 | it.headers["content-type"] shouldBe "text/plain" 22 | } 23 | client.get(server.url("/static/test.css")).asClue { 24 | it.code shouldBe 200 25 | it.headers["content-type"] shouldBe "text/css" 26 | } 27 | client.get(server.url("/static/test.js")).asClue { 28 | it.code shouldBe 200 29 | it.headers["content-type"] shouldBe "text/javascript" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/UserInfoIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import com.nimbusds.jose.JWSAlgorithm 4 | import com.nimbusds.jwt.SignedJWT 5 | import io.kotest.assertions.asClue 6 | import io.kotest.matchers.maps.shouldContainAll 7 | import io.kotest.matchers.shouldBe 8 | import no.nav.security.mock.oauth2.MockOAuth2Server 9 | import no.nav.security.mock.oauth2.OAuth2Config 10 | import no.nav.security.mock.oauth2.testutils.claims 11 | import no.nav.security.mock.oauth2.testutils.client 12 | import no.nav.security.mock.oauth2.testutils.get 13 | import no.nav.security.mock.oauth2.testutils.parse 14 | import no.nav.security.mock.oauth2.token.KeyProvider 15 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 16 | import no.nav.security.mock.oauth2.withMockOAuth2Server 17 | import okhttp3.Headers 18 | import org.junit.jupiter.api.Test 19 | 20 | class UserInfoIntegrationTest { 21 | private val client = client() 22 | private val rs384Config = 23 | OAuth2Config( 24 | tokenProvider = OAuth2TokenProvider(keyProvider = KeyProvider(initialKeys = emptyList(), algorithm = JWSAlgorithm.RS384.name)), 25 | ) 26 | 27 | @Test 28 | fun `userinfo should return claims from token when valid bearer token is present`() { 29 | withMockOAuth2Server { 30 | val issuerId = "default" 31 | val token = this.issueToken(issuerId = issuerId, subject = "foo", claims = mapOf("extra" to "bar")) 32 | client 33 | .get( 34 | url = this.userInfoUrl(issuerId), 35 | headers = token.asBearerTokenHeader(), 36 | ).asClue { 37 | it.parse>() shouldContainAll 38 | mapOf( 39 | "sub" to token.claims["sub"], 40 | "iss" to token.claims["iss"], 41 | "extra" to token.claims["extra"], 42 | ) 43 | } 44 | } 45 | } 46 | 47 | @Test 48 | fun `userinfo should return claims from token signed with non-default algorithm when valid bearer token is present`() { 49 | withMockOAuth2Server(config = rs384Config) { 50 | val issuerId = "default" 51 | val token = this.issueToken(issuerId = issuerId, subject = "foo", claims = mapOf("extra" to "bar")) 52 | token.header.algorithm.shouldBe(JWSAlgorithm.RS384) 53 | client 54 | .get( 55 | url = this.userInfoUrl(issuerId), 56 | headers = token.asBearerTokenHeader(), 57 | ).asClue { 58 | it.parse>() shouldContainAll 59 | mapOf( 60 | "sub" to token.claims["sub"], 61 | "iss" to token.claims["iss"], 62 | "extra" to token.claims["extra"], 63 | ) 64 | } 65 | } 66 | } 67 | 68 | @Test 69 | fun `userinfo should return error from token signed with non-default algorithm does not match server config`() { 70 | val issuerId = "default" 71 | val rs384Server = MockOAuth2Server(config = rs384Config) 72 | val token = rs384Server.issueToken(issuerId = issuerId, subject = "foo", claims = mapOf("extra" to "bar")) 73 | withMockOAuth2Server { 74 | client 75 | .get( 76 | url = this.userInfoUrl(issuerId), 77 | headers = token.asBearerTokenHeader(), 78 | ).asClue { 79 | it.code shouldBe 401 80 | it.message shouldBe "Client Error" 81 | } 82 | } 83 | } 84 | 85 | private fun SignedJWT.asBearerTokenHeader(): Headers = 86 | this.serialize().let { 87 | Headers.headersOf("Authorization", "Bearer $it") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/e2e/WellKnownIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.e2e 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.fasterxml.jackson.module.kotlin.readValue 5 | import io.kotest.assertions.asClue 6 | import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.shouldNotBe 9 | import no.nav.security.mock.oauth2.testutils.client 10 | import no.nav.security.mock.oauth2.testutils.get 11 | import no.nav.security.mock.oauth2.withMockOAuth2Server 12 | import org.junit.jupiter.api.Test 13 | 14 | class WellKnownIntegrationTest { 15 | private val client = client() 16 | 17 | @Test 18 | fun `get to well-known url should return oauth2 server metadata`() { 19 | withMockOAuth2Server { 20 | val response = client.get(this.wellKnownUrl("default")) 21 | val body = response.body?.string() 22 | response.code shouldBe 200 23 | body shouldNotBe null 24 | jacksonObjectMapper().readValue>(body!!).keys.asClue { 25 | it shouldContainExactlyInAnyOrder 26 | listOf( 27 | "issuer", 28 | "authorization_endpoint", 29 | "end_session_endpoint", 30 | "revocation_endpoint", 31 | "token_endpoint", 32 | "userinfo_endpoint", 33 | "jwks_uri", 34 | "introspection_endpoint", 35 | "response_modes_supported", 36 | "response_types_supported", 37 | "subject_types_supported", 38 | "id_token_signing_alg_values_supported", 39 | "code_challenge_methods_supported", 40 | ) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/AbstractExampleApp.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.nimbusds.jose.JOSEObjectType 5 | import com.nimbusds.jose.JWSAlgorithm 6 | import com.nimbusds.jose.jwk.JWKSet 7 | import com.nimbusds.jose.jwk.source.ImmutableJWKSet 8 | import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier 9 | import com.nimbusds.jose.proc.JWSKeySelector 10 | import com.nimbusds.jose.proc.JWSVerificationKeySelector 11 | import com.nimbusds.jose.proc.SecurityContext 12 | import com.nimbusds.jose.util.DefaultResourceRetriever 13 | import com.nimbusds.jwt.JWTClaimsSet 14 | import com.nimbusds.jwt.proc.ConfigurableJWTProcessor 15 | import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier 16 | import com.nimbusds.jwt.proc.DefaultJWTProcessor 17 | import com.nimbusds.oauth2.sdk.id.Issuer 18 | import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata 19 | import mu.KotlinLogging 20 | import okhttp3.HttpUrl 21 | import okhttp3.OkHttpClient 22 | import okhttp3.Request 23 | import okhttp3.mockwebserver.Dispatcher 24 | import okhttp3.mockwebserver.MockResponse 25 | import okhttp3.mockwebserver.MockWebServer 26 | import okhttp3.mockwebserver.RecordedRequest 27 | import java.net.URI 28 | import java.util.HashSet 29 | 30 | private val log = KotlinLogging.logger {} 31 | 32 | abstract class AbstractExampleApp( 33 | oauth2DiscoveryUrl: String, 34 | ) { 35 | val oauth2Client: OkHttpClient = 36 | OkHttpClient() 37 | .newBuilder() 38 | .followRedirects(false) 39 | .build() 40 | 41 | val metadata = OIDCProviderMetadata.parse(DefaultResourceRetriever().retrieveResource(URI(oauth2DiscoveryUrl).toURL()).content) 42 | 43 | lateinit var exampleApp: MockWebServer 44 | 45 | fun start() { 46 | exampleApp = MockWebServer() 47 | exampleApp.start() 48 | exampleApp.dispatcher = 49 | object : Dispatcher() { 50 | override fun dispatch(request: RecordedRequest): MockResponse = 51 | runCatching { 52 | handleRequest(request) 53 | }.fold( 54 | onSuccess = { result -> result }, 55 | onFailure = { error -> 56 | log.error("received unhandled exception.", error) 57 | MockResponse() 58 | .setResponseCode(500) 59 | .setBody("unhandled exception with message ${error.message}") 60 | }, 61 | ) 62 | } 63 | } 64 | 65 | fun shutdown() { 66 | exampleApp.shutdown() 67 | } 68 | 69 | fun url(path: String): HttpUrl = exampleApp.url(path) 70 | 71 | fun retrieveJwks(): JWKSet = 72 | oauth2Client 73 | .newCall( 74 | Request 75 | .Builder() 76 | .url(metadata.jwkSetURI.toURL()) 77 | .get() 78 | .build(), 79 | ).execute() 80 | .body 81 | ?.string() 82 | ?.let { 83 | JWKSet.parse(it) 84 | } ?: throw RuntimeException("could not retrieve jwks") 85 | 86 | fun verifyJwt( 87 | jwt: String, 88 | issuer: Issuer, 89 | jwkSet: JWKSet, 90 | ): JWTClaimsSet { 91 | val jwtProcessor: ConfigurableJWTProcessor = DefaultJWTProcessor() 92 | jwtProcessor.jwsTypeVerifier = DefaultJOSEObjectTypeVerifier(JOSEObjectType("JWT")) 93 | val keySelector: JWSKeySelector = 94 | JWSVerificationKeySelector( 95 | JWSAlgorithm.RS256, 96 | ImmutableJWKSet(jwkSet), 97 | ) 98 | jwtProcessor.jwsKeySelector = keySelector 99 | jwtProcessor.jwtClaimsSetVerifier = 100 | DefaultJWTClaimsVerifier( 101 | JWTClaimsSet.Builder().issuer(issuer.toString()).build(), 102 | HashSet(listOf("sub", "iat", "exp", "aud")), 103 | ) 104 | return try { 105 | jwtProcessor.process(jwt, null) 106 | } catch (e: Exception) { 107 | throw RuntimeException("invalid jwt.", e) 108 | } 109 | } 110 | 111 | fun bearerToken(request: RecordedRequest): String? = 112 | request.headers["Authorization"] 113 | ?.split("Bearer ") 114 | ?.let { it[1] } 115 | 116 | fun notAuthorized(): MockResponse = MockResponse().setResponseCode(401) 117 | 118 | fun json(value: Any): MockResponse = 119 | MockResponse() 120 | .setResponseCode(200) 121 | .setHeader("Content-Type", "application/json") 122 | .setBody(ObjectMapper().writeValueAsString(value)) 123 | 124 | abstract fun handleRequest(request: RecordedRequest): MockResponse 125 | } 126 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/clientcredentials/ExampleAppWithClientCredentialsClient.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples.clientcredentials 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.module.kotlin.readValue 6 | import no.nav.security.mock.oauth2.examples.AbstractExampleApp 7 | import okhttp3.Credentials 8 | import okhttp3.FormBody 9 | import okhttp3.Request 10 | import okhttp3.Response 11 | import okhttp3.mockwebserver.MockResponse 12 | import okhttp3.mockwebserver.RecordedRequest 13 | 14 | class ExampleAppWithClientCredentialsClient( 15 | oauth2DiscoveryUrl: String, 16 | ) : AbstractExampleApp(oauth2DiscoveryUrl) { 17 | override fun handleRequest(request: RecordedRequest): MockResponse = 18 | getClientCredentialsAccessToken() 19 | ?.let { 20 | MockResponse() 21 | .setResponseCode(200) 22 | .setBody("token=$it") 23 | } 24 | ?: MockResponse().setResponseCode(500).setBody("could not get access_token") 25 | 26 | private fun getClientCredentialsAccessToken(): String? { 27 | val tokenResponse: Response = 28 | oauth2Client 29 | .newCall( 30 | Request 31 | .Builder() 32 | .url(metadata.tokenEndpointURI.toURL()) 33 | .addHeader("Authorization", Credentials.basic("ExampleAppWithClientCredentialsClient", "test")) 34 | .post( 35 | FormBody 36 | .Builder() 37 | .add("client_id", "ExampleAppWithClientCredentialsClient") 38 | .add("scope", "scope1") 39 | .add("grant_type", "client_credentials") 40 | .build(), 41 | ).build(), 42 | ).execute() 43 | return tokenResponse.body?.string()?.let { 44 | ObjectMapper().readValue(it).get("access_token")?.textValue() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/clientcredentials/ExampleAppWithClientCredentialsClientTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples.clientcredentials 2 | 3 | import com.nimbusds.jwt.SignedJWT 4 | import no.nav.security.mock.oauth2.MockOAuth2Server 5 | import okhttp3.OkHttpClient 6 | import okhttp3.Request 7 | import okhttp3.Response 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.AfterEach 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.Test 12 | 13 | internal class ExampleAppWithClientCredentialsClientTest { 14 | private lateinit var client: OkHttpClient 15 | private lateinit var oAuth2Server: MockOAuth2Server 16 | private lateinit var exampleApp: ExampleAppWithClientCredentialsClient 17 | 18 | private val issuerId = "test" 19 | 20 | @BeforeEach 21 | fun before() { 22 | oAuth2Server = MockOAuth2Server() 23 | oAuth2Server.start() 24 | exampleApp = ExampleAppWithClientCredentialsClient(oAuth2Server.wellKnownUrl(issuerId).toString()) 25 | exampleApp.start() 26 | client = OkHttpClient().newBuilder().build() 27 | } 28 | 29 | @AfterEach 30 | fun shutdown() { 31 | oAuth2Server.shutdown() 32 | exampleApp.shutdown() 33 | } 34 | 35 | @Test 36 | fun appShouldReturnClientCredentialsAccessTokenWhenInvoked() { 37 | val response: Response = 38 | client 39 | .newCall( 40 | Request 41 | .Builder() 42 | .url(exampleApp.url("/clientcredentials")) 43 | .get() 44 | .build(), 45 | ).execute() 46 | assertThat(response.code).isEqualTo(200) 47 | 48 | val token: SignedJWT? = 49 | response.body 50 | ?.string() 51 | ?.split("token=") 52 | ?.let { it[1] } 53 | ?.let { SignedJWT.parse(it) } 54 | 55 | assertThat(token).isNotNull 56 | assertThat(token?.jwtClaimsSet?.subject).isEqualTo("ExampleAppWithClientCredentialsClient") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/openidconnect/ExampleAppWithOpenIdConnect.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples.openidconnect 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.module.kotlin.readValue 6 | import com.nimbusds.jwt.JWTClaimsSet 7 | import com.nimbusds.openid.connect.sdk.AuthenticationRequest 8 | import mu.KotlinLogging 9 | import no.nav.security.mock.oauth2.examples.AbstractExampleApp 10 | import okhttp3.FormBody 11 | import okhttp3.Request 12 | import okhttp3.mockwebserver.MockResponse 13 | import okhttp3.mockwebserver.RecordedRequest 14 | 15 | private val log = KotlinLogging.logger {} 16 | 17 | class ExampleAppWithOpenIdConnect( 18 | oidcDiscoveryUrl: String, 19 | ) : AbstractExampleApp(oidcDiscoveryUrl) { 20 | override fun handleRequest(request: RecordedRequest): MockResponse = 21 | when (request.requestUrl?.encodedPath) { 22 | "/login" -> { 23 | MockResponse() 24 | .setResponseCode(302) 25 | .setHeader("Location", authenticationRequest().toURI()) 26 | } 27 | "/callback" -> { 28 | log.debug("got callback: $request") 29 | val code = request.requestUrl?.queryParameter("code")!! 30 | val tokenResponse = 31 | oauth2Client 32 | .newCall( 33 | Request 34 | .Builder() 35 | .url(metadata.tokenEndpointURI.toURL()) 36 | .post( 37 | FormBody 38 | .Builder() 39 | .add("client_id", "client1") 40 | .add("code", code) 41 | .add("redirect_uri", exampleApp.url("/callback").toString()) 42 | .add("grant_type", "authorization_code") 43 | .build(), 44 | ).build(), 45 | ).execute() 46 | val idToken: String = ObjectMapper().readValue(tokenResponse.body!!.string()).get("id_token").textValue() 47 | val idTokenClaims: JWTClaimsSet = verifyJwt(idToken, metadata.issuer, retrieveJwks()) 48 | MockResponse() 49 | .setResponseCode(200) 50 | .setHeader("Set-Cookie", "id_token=$idToken") 51 | .setBody("logged in as ${idTokenClaims.subject}") 52 | } 53 | "/secured" -> { 54 | getCookies(request)["id_token"] 55 | ?.let { 56 | verifyJwt(it, metadata.issuer, retrieveJwks()) 57 | }?.let { 58 | MockResponse() 59 | .setResponseCode(200) 60 | .setBody("welcome ${it.subject}") 61 | } ?: MockResponse().setResponseCode(302).setHeader("Location", exampleApp.url("/login")) 62 | } 63 | else -> MockResponse().setResponseCode(404) 64 | } 65 | 66 | private fun getCookies(request: RecordedRequest): Map = 67 | request 68 | .getHeader("Cookie") 69 | ?.split(";") 70 | ?.filter { it.contains("=") } 71 | ?.associate { 72 | val (key, value) = it.split("=") 73 | key.trim() to value.trim() 74 | } ?: emptyMap() 75 | 76 | private fun authenticationRequest(): AuthenticationRequest = 77 | AuthenticationRequest.parse( 78 | metadata.authorizationEndpointURI, 79 | mutableMapOf( 80 | "client_id" to listOf("client"), 81 | "response_type" to listOf("code"), 82 | "redirect_uri" to listOf(exampleApp.url("/callback").toString()), 83 | "response_mode" to listOf("query"), 84 | "scope" to listOf("openid scope1"), 85 | "state" to listOf("1234"), 86 | "nonce" to listOf("5678"), 87 | ), 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/openidconnect/ExampleAppWithOpenIdConnectTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples.openidconnect 2 | 3 | import no.nav.security.mock.oauth2.MockOAuth2Server 4 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback 5 | import okhttp3.Cookie 6 | import okhttp3.CookieJar 7 | import okhttp3.HttpUrl 8 | import okhttp3.OkHttpClient 9 | import okhttp3.Request 10 | import org.assertj.core.api.Assertions.assertThat 11 | import org.junit.jupiter.api.AfterEach 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | 15 | class ExampleAppWithOpenIdConnectTest { 16 | private lateinit var client: OkHttpClient 17 | private lateinit var oAuth2Server: MockOAuth2Server 18 | private lateinit var exampleApp: ExampleAppWithOpenIdConnect 19 | 20 | private val issuerId = "test" 21 | 22 | @BeforeEach 23 | fun before() { 24 | oAuth2Server = MockOAuth2Server() 25 | oAuth2Server.start() 26 | exampleApp = ExampleAppWithOpenIdConnect(oAuth2Server.wellKnownUrl(issuerId).toString()) 27 | exampleApp.start() 28 | client = 29 | OkHttpClient() 30 | .newBuilder() 31 | .followRedirects(true) 32 | .cookieJar(InmemoryCookieJar()) 33 | .build() 34 | } 35 | 36 | @AfterEach 37 | fun shutdown() { 38 | oAuth2Server.shutdown() 39 | exampleApp.shutdown() 40 | } 41 | 42 | @Test 43 | fun loginWithOpenIdConnect() { 44 | val loginResponse = client.newCall(Request.Builder().url(exampleApp.url("/login")).build()).execute() 45 | assertThat(loginResponse.headers["Set-Cookie"]).contains("id_token=") 46 | } 47 | 48 | @Test 49 | fun loginAndAccessSecuredPathWithIdTokenForSubjectFoo() { 50 | oAuth2Server.enqueueCallback( 51 | DefaultOAuth2TokenCallback( 52 | issuerId = issuerId, 53 | subject = "foo", 54 | ), 55 | ) 56 | val loginResponse = client.newCall(Request.Builder().url(exampleApp.url("/login")).build()).execute() 57 | assertThat(loginResponse.headers["Set-Cookie"]).contains("id_token=") 58 | val securedResponse = client.newCall(Request.Builder().url(exampleApp.url("/secured")).build()).execute() 59 | assertThat(securedResponse.code).isEqualTo(200) 60 | val body = securedResponse.body?.string() 61 | assertThat(body).isEqualTo("welcome foo") 62 | } 63 | 64 | @Test 65 | fun requestToSecuredPathShouldRedirectToLogin() { 66 | val loginResponse = 67 | OkHttpClient() 68 | .newBuilder() 69 | .followRedirects(false) 70 | .build() 71 | .newCall(Request.Builder().url(exampleApp.url("/secured")).build()) 72 | .execute() 73 | assertThat(loginResponse.code).isEqualTo(302) 74 | assertThat(loginResponse.headers["Location"]).isEqualTo(exampleApp.url("/login").toString()) 75 | } 76 | 77 | internal class InmemoryCookieJar : CookieJar { 78 | private val cookieList: MutableList = mutableListOf() 79 | 80 | override fun loadForRequest(url: HttpUrl): List = cookieList 81 | 82 | override fun saveFromResponse( 83 | url: HttpUrl, 84 | cookies: List, 85 | ) { 86 | cookieList.addAll(cookies) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/securedapi/ExampleAppWithSecuredApi.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples.securedapi 2 | 3 | import no.nav.security.mock.oauth2.examples.AbstractExampleApp 4 | import okhttp3.mockwebserver.MockResponse 5 | import okhttp3.mockwebserver.RecordedRequest 6 | 7 | class ExampleAppWithSecuredApi( 8 | oauth2DiscoveryUrl: String, 9 | ) : AbstractExampleApp(oauth2DiscoveryUrl) { 10 | override fun handleRequest(request: RecordedRequest): MockResponse = 11 | bearerToken(request) 12 | ?.let { 13 | verifyJwt(it, metadata.issuer, retrieveJwks()) 14 | }?.let { 15 | MockResponse() 16 | .setResponseCode(200) 17 | .setHeader("Content-Type", "application/json") 18 | .setBody(greeting(it.subject)) 19 | } ?: notAuthorized() 20 | 21 | private fun greeting(subject: String): String = "{\n\"greeting\":\"welcome $subject\"\n}" 22 | } 23 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/examples/securedapi/ExampleAppWithSecuredApiTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.examples.securedapi 2 | 3 | import com.nimbusds.jwt.SignedJWT 4 | import no.nav.security.mock.oauth2.MockOAuth2Server 5 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback 6 | import okhttp3.OkHttpClient 7 | import okhttp3.Request 8 | import okhttp3.Response 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.AfterEach 11 | import org.junit.jupiter.api.BeforeEach 12 | import org.junit.jupiter.api.Test 13 | 14 | internal class ExampleAppWithSecuredApiTest { 15 | private lateinit var client: OkHttpClient 16 | private lateinit var oAuth2Server: MockOAuth2Server 17 | private lateinit var exampleApp: ExampleAppWithSecuredApi 18 | 19 | private val issuerId = "test" 20 | 21 | @BeforeEach 22 | fun before() { 23 | oAuth2Server = MockOAuth2Server() 24 | oAuth2Server.start() 25 | exampleApp = ExampleAppWithSecuredApi(oAuth2Server.wellKnownUrl(issuerId).toString()) 26 | exampleApp.start() 27 | client = OkHttpClient().newBuilder().build() 28 | } 29 | 30 | @AfterEach 31 | fun shutdown() { 32 | oAuth2Server.shutdown() 33 | exampleApp.shutdown() 34 | } 35 | 36 | @Test 37 | fun apiShouldDenyAccessWithoutValidToken() { 38 | val response: Response = 39 | client 40 | .newCall( 41 | Request 42 | .Builder() 43 | .url(exampleApp.url("/api")) 44 | .get() 45 | .build(), 46 | ).execute() 47 | assertThat(response.code).isEqualTo(401) 48 | } 49 | 50 | @Test 51 | fun apiShouldAllowAccessWhenTokenIsValid() { 52 | val token: SignedJWT = oAuth2Server.issueToken(issuerId, "myclient", DefaultOAuth2TokenCallback()) 53 | val response: Response = 54 | client 55 | .newCall( 56 | Request 57 | .Builder() 58 | .url(exampleApp.url("/api")) 59 | .addHeader("Authorization", "Bearer " + token.serialize()) 60 | .get() 61 | .build(), 62 | ).execute() 63 | assertThat(response.code).isEqualTo(200) 64 | assertThat(response.body?.string()).contains(token.jwtClaimsSet.subject) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.extensions 2 | 3 | import io.kotest.matchers.shouldBe 4 | import okhttp3.HttpUrl.Companion.toHttpUrl 5 | import org.junit.jupiter.api.Test 6 | 7 | internal class HttpUrlExtensionsTest { 8 | @Test 9 | fun `urls with no segments, one segment and multiple segments`() { 10 | "http://localhost".toHttpUrl().issuerId() shouldBe "" 11 | `verify oauth2 endpoint urls`("http://localhost") 12 | 13 | "http://localhost/path1".toHttpUrl().issuerId() shouldBe "path1" 14 | `verify oauth2 endpoint urls`("http://localhost/path1") 15 | 16 | "http://localhost/path1/path2".toHttpUrl().issuerId() shouldBe "path1/path2" 17 | `verify oauth2 endpoint urls`("http://localhost/path1/path2") 18 | } 19 | 20 | private fun `verify oauth2 endpoint urls`(baseUrl: String) { 21 | val httpUrl = baseUrl.toHttpUrl() 22 | httpUrl.toIssuerUrl() shouldBe "$baseUrl".toHttpUrl() 23 | httpUrl.toWellKnownUrl() shouldBe "$baseUrl/.well-known/openid-configuration".toHttpUrl() 24 | httpUrl.toOAuth2AuthorizationServerMetadataUrl() shouldBe "$baseUrl/.well-known/oauth-authorization-server".toHttpUrl() 25 | httpUrl.toTokenEndpointUrl() shouldBe "$baseUrl/token".toHttpUrl() 26 | httpUrl.toAuthorizationEndpointUrl() shouldBe "$baseUrl/authorize".toHttpUrl() 27 | httpUrl.toDebuggerCallbackUrl() shouldBe "$baseUrl/debugger/callback".toHttpUrl() 28 | httpUrl.toDebuggerUrl() shouldBe "$baseUrl/debugger".toHttpUrl() 29 | httpUrl.toEndSessionEndpointUrl() shouldBe "$baseUrl/endsession".toHttpUrl() 30 | httpUrl.toRevocationEndpointUrl() shouldBe "$baseUrl/revoke".toHttpUrl() 31 | httpUrl.toJwksUrl() shouldBe "$baseUrl/jwks".toHttpUrl() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/extensions/TemplateTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.extensions 2 | 3 | import io.kotest.assertions.asClue 4 | import io.kotest.matchers.shouldBe 5 | import org.junit.jupiter.api.Test 6 | 7 | class TemplateTest { 8 | @Test 9 | fun `template values in map should be replaced`() { 10 | val templates = 11 | mapOf( 12 | "templateVal1" to "val1", 13 | "templateVal2" to "val2", 14 | "templateListVal" to "listVal1", 15 | ) 16 | 17 | mapOf( 18 | "object1" to mapOf("key1" to "\${templateVal1}"), 19 | "object2" to "\${templateVal2}", 20 | "nestedObject" to mapOf("nestedKey" to mapOf("nestedKeyAgain" to "\${templateVal2}")), 21 | "list1" to listOf("\${templateListVal}"), 22 | ).replaceValues(templates).asClue { 23 | it["object1"] shouldBe mapOf("key1" to "val1") 24 | it["list1"] shouldBe listOf("listVal1") 25 | println(it) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenManagerTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.grant 2 | 3 | import com.nimbusds.jwt.PlainJWT 4 | import io.kotest.assertions.asClue 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.shouldNotBe 7 | import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback 8 | import org.junit.jupiter.api.Test 9 | 10 | internal class RefreshTokenManagerTest { 11 | @Test 12 | fun `refresh token should be a jwt with nonce included if nonce is not null (for keycloak compatibility)`() { 13 | val mgr = RefreshTokenManager() 14 | val tokenCallback = DefaultOAuth2TokenCallback() 15 | 16 | mgr.refreshToken(tokenCallback, "nonce123").asClue { 17 | val claims = PlainJWT.parse(it).jwtClaimsSet.claims 18 | 19 | claims["nonce"] shouldBe "nonce123" 20 | claims["jti"] shouldNotBe null 21 | } 22 | } 23 | 24 | @Test 25 | fun `tokencallback should be available in cache for specific refresh token`() { 26 | val mgr = RefreshTokenManager() 27 | val tokenCallback = DefaultOAuth2TokenCallback() 28 | 29 | val refreshToken = mgr.refreshToken(tokenCallback, null) 30 | mgr[refreshToken] shouldBe tokenCallback 31 | val refreshToken2 = mgr.refreshToken(tokenCallback, "nonce123") 32 | mgr[refreshToken2] shouldBe tokenCallback 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/login/LoginRequestHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.login 2 | 3 | import com.nimbusds.oauth2.sdk.OAuth2Error 4 | import io.kotest.assertions.asClue 5 | import io.kotest.assertions.throwables.shouldThrow 6 | import io.kotest.matchers.shouldBe 7 | import no.nav.security.mock.oauth2.OAuth2Config 8 | import no.nav.security.mock.oauth2.OAuth2Exception 9 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 10 | import no.nav.security.mock.oauth2.http.templateMapper 11 | import okhttp3.Headers 12 | import okhttp3.HttpUrl.Companion.toHttpUrl 13 | import org.junit.jupiter.api.Test 14 | 15 | internal class LoginRequestHandlerTest { 16 | private val handler = LoginRequestHandler(templateMapper, OAuth2Config()) 17 | 18 | @Test 19 | fun `loginSubmit should return login with username and claims from form params`() { 20 | handler.loginSubmit(request("username=foo&claims=someJsonString")).asClue { 21 | it shouldBe Login("foo", claims = "someJsonString") 22 | } 23 | } 24 | 25 | @Test 26 | fun `loginSubmit should fail with OAuth2Error invalid_request when missing required params`() { 27 | shouldThrow { 28 | handler.loginSubmit(request("param=value")) 29 | }.asClue { 30 | it.errorObject?.code shouldBe OAuth2Error.INVALID_REQUEST.code 31 | } 32 | } 33 | 34 | private fun request(body: String) = 35 | OAuth2HttpRequest( 36 | originalUrl = "http://localhost/issuer1/login".toHttpUrl(), 37 | headers = Headers.headersOf(), 38 | method = "POST", 39 | body = body, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/server/OAuth2HttpServerTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.server 2 | 3 | import io.kotest.matchers.shouldBe 4 | import mu.KotlinLogging 5 | import no.nav.security.mock.oauth2.http.MockWebServerWrapper 6 | import no.nav.security.mock.oauth2.http.NettyWrapper 7 | import no.nav.security.mock.oauth2.http.OAuth2HttpResponse 8 | import no.nav.security.mock.oauth2.http.OAuth2HttpServer 9 | import no.nav.security.mock.oauth2.http.RequestHandler 10 | import no.nav.security.mock.oauth2.http.Ssl 11 | import no.nav.security.mock.oauth2.http.SslKeystore 12 | import no.nav.security.mock.oauth2.http.redirect 13 | import no.nav.security.mock.oauth2.testutils.get 14 | import no.nav.security.mock.oauth2.testutils.post 15 | import no.nav.security.mock.oauth2.testutils.withTrustStore 16 | import okhttp3.Headers 17 | import okhttp3.OkHttpClient 18 | import org.junit.jupiter.api.Test 19 | import java.io.File 20 | 21 | private val log = KotlinLogging.logger { } 22 | 23 | internal class OAuth2HttpServerTest { 24 | val httpClient = OkHttpClient().newBuilder().followRedirects(false).build() 25 | 26 | val requestHandler: RequestHandler = { 27 | log.debug("received request on url=${it.url}") 28 | when { 29 | it.headers.contains("header1" to "headervalue1") -> ok("headermatch") 30 | it.url.pathSegments == listOf("1", "2") -> ok("pathmatch") 31 | it.url.query == "param1=value1¶m2=value2" -> ok("querymatch") 32 | it.body == "formparam=formvalue1" -> ok("bodymatch") 33 | it.url.pathSegments.contains("redirect") -> 34 | redirect("http://someredirect") 35 | else -> { 36 | OAuth2HttpResponse(status = 404) 37 | } 38 | } 39 | } 40 | 41 | @Test 42 | fun `Netty server should start and serve requests`() { 43 | NettyWrapper().start(requestHandler).shouldServeRequests().stop() 44 | NettyWrapper().start(port = 1234, requestHandler).shouldServeRequests().stop() 45 | } 46 | 47 | @Test 48 | fun `Netty server should start and serve requests with generated keystore and HTTPS enabled`() { 49 | val ssl = Ssl() 50 | NettyWrapper(ssl).start(requestHandler).shouldServeRequests(ssl).stop() 51 | NettyWrapper(ssl).start(port = 1234, requestHandler).shouldServeRequests(ssl).stop() 52 | } 53 | 54 | @Test 55 | fun `Netty server should start and serve requests with provided keystore and HTTPS enabled`() { 56 | val ssl = 57 | Ssl( 58 | SslKeystore( 59 | keyPassword = "", 60 | keystoreFile = File("src/test/resources/localhost.p12"), 61 | keystorePassword = "", 62 | keystoreType = SslKeystore.KeyStoreType.PKCS12, 63 | ), 64 | ) 65 | NettyWrapper(ssl).start(requestHandler).shouldServeRequests(ssl).stop() 66 | } 67 | 68 | @Test 69 | fun `MockWebServer should start and serve requests`() { 70 | MockWebServerWrapper().start(requestHandler).shouldServeRequests().stop() 71 | MockWebServerWrapper().start(port = 1234, requestHandler).shouldServeRequests().stop() 72 | } 73 | 74 | @Test 75 | fun `MockWebServer should start and serve requests with generated keystore and HTTPS enabled`() { 76 | val ssl = Ssl() 77 | MockWebServerWrapper(ssl).start(requestHandler).shouldServeRequests(ssl).stop() 78 | MockWebServerWrapper(ssl).start(port = 1234, requestHandler).shouldServeRequests(ssl).stop() 79 | } 80 | 81 | private fun OAuth2HttpServer.shouldServeRequests(ssl: Ssl? = null) = 82 | apply { 83 | val client = 84 | if (ssl != null) { 85 | httpClient.withTrustStore(ssl.sslKeystore.keyStore) 86 | } else { 87 | httpClient 88 | } 89 | 90 | client 91 | .get( 92 | this.url("/header"), 93 | Headers.headersOf("header1", "headervalue1"), 94 | ).body 95 | ?.string() shouldBe "headermatch" 96 | 97 | client.get(this.url("/1/2")).body?.string() shouldBe "pathmatch" 98 | client.get(this.url("path?param1=value1¶m2=value2")).body?.string() shouldBe "querymatch" 99 | client.post(this.url("/form"), mapOf("formparam" to "formvalue1")).body?.string() shouldBe "bodymatch" 100 | client.get(this.url("/notfound")).code shouldBe 404 101 | client.get(this.url("/redirect")).apply { 102 | this.code shouldBe 302 103 | this.headers["Location"] shouldBe "http://someredirect" 104 | } 105 | } 106 | 107 | private fun ok(body: String) = 108 | OAuth2HttpResponse( 109 | status = 200, 110 | body = body, 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/testutils/Grant.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.testutils 2 | 3 | import com.nimbusds.oauth2.sdk.pkce.CodeChallenge 4 | import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod 5 | import com.nimbusds.oauth2.sdk.pkce.CodeVerifier 6 | import okhttp3.HttpUrl 7 | 8 | fun HttpUrl.authenticationRequest( 9 | clientId: String = "defautlClient", 10 | redirectUri: String = "http://defaultRedirectUri", 11 | scope: List = listOf("openid"), 12 | responseType: String = "code", 13 | responseMode: String = "query", 14 | state: String = "1234", 15 | nonce: String = "5678", 16 | pkce: Pkce? = null, 17 | ): HttpUrl = 18 | newBuilder() 19 | .addQueryParameter("client_id", clientId) 20 | .addQueryParameter("response_type", responseType) 21 | .addQueryParameter("redirect_uri", redirectUri) 22 | .addQueryParameter("response_mode", responseMode) 23 | .addQueryParameter("scope", scope.joinToString(" ")) 24 | .addQueryParameter("state", state) 25 | .addQueryParameter("nonce", nonce) 26 | .apply { 27 | if (pkce != null) { 28 | addQueryParameter("code_challenge", pkce.challenge.value) 29 | addQueryParameter("code_challenge_method", pkce.method.value) 30 | } 31 | }.build() 32 | 33 | data class Pkce( 34 | val verifier: CodeVerifier = CodeVerifier(), 35 | val method: CodeChallengeMethod = CodeChallengeMethod.S256, 36 | ) { 37 | val challenge: CodeChallenge = CodeChallenge.compute(method, verifier) 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/testutils/Http.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.testutils 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.fasterxml.jackson.module.kotlin.readValue 5 | import no.nav.security.mock.oauth2.extensions.keyValuesToMap 6 | import okhttp3.Credentials 7 | import okhttp3.FormBody 8 | import okhttp3.Headers 9 | import okhttp3.HttpUrl 10 | import okhttp3.OkHttpClient 11 | import okhttp3.Request 12 | import okhttp3.Response 13 | import java.net.URLEncoder 14 | import java.security.KeyStore 15 | import javax.net.ssl.SSLContext 16 | import javax.net.ssl.TrustManagerFactory 17 | import javax.net.ssl.X509TrustManager 18 | 19 | fun Response.toTokenResponse(): ParsedTokenResponse = 20 | ParsedTokenResponse( 21 | this.code, 22 | checkNotNull(this.body).string(), 23 | ) 24 | 25 | inline fun Response.parse(): T = jacksonObjectMapper().readValue(checkNotNull(body?.string())) 26 | 27 | val Response.authorizationCode: String? 28 | get() = 29 | this.headers["location"]?.let { 30 | it.substringAfter("?").keyValuesToMap("&")["code"] 31 | } 32 | 33 | fun client(followRedirects: Boolean = false): OkHttpClient = 34 | OkHttpClient() 35 | .newBuilder() 36 | .followRedirects(followRedirects) 37 | .build() 38 | 39 | fun OkHttpClient.withTrustStore( 40 | keyStore: KeyStore, 41 | followRedirects: Boolean = false, 42 | ): OkHttpClient = 43 | newBuilder() 44 | .apply { 45 | followRedirects(followRedirects) 46 | val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(keyStore) } 47 | val sslContext = SSLContext.getInstance("TLS").apply { init(null, trustManagerFactory.trustManagers, null) } 48 | sslSocketFactory(sslContext.socketFactory, trustManagerFactory.trustManagers[0] as X509TrustManager) 49 | }.build() 50 | 51 | fun OkHttpClient.tokenRequest( 52 | url: HttpUrl, 53 | parameters: Map, 54 | ): Response = tokenRequest(url, Headers.headersOf(), parameters) 55 | 56 | fun OkHttpClient.tokenRequest( 57 | url: HttpUrl, 58 | headers: Headers, 59 | parameters: Map, 60 | ): Response = 61 | this 62 | .newCall( 63 | Request.Builder().post( 64 | url = url, 65 | headers = headers, 66 | parameters = parameters, 67 | ), 68 | ).execute() 69 | 70 | fun OkHttpClient.tokenRequest( 71 | url: HttpUrl, 72 | basicAuth: Pair, 73 | parameters: Map, 74 | ): Response = 75 | tokenRequest( 76 | url, 77 | Headers.headersOf("Authorization", Credentials.basic(basicAuth.first, basicAuth.second)), 78 | parameters, 79 | ) 80 | 81 | fun OkHttpClient.post( 82 | url: HttpUrl, 83 | parameters: Map, 84 | ): Response = 85 | this 86 | .newCall( 87 | Request.Builder().post( 88 | url = url, 89 | headers = Headers.headersOf(), 90 | parameters = parameters, 91 | ), 92 | ).execute() 93 | 94 | fun OkHttpClient.get( 95 | url: HttpUrl, 96 | headers: Headers = Headers.headersOf(), 97 | parameters: Map = emptyMap(), 98 | ): Response = 99 | this 100 | .newCall( 101 | Request.Builder().get( 102 | url, 103 | headers, 104 | parameters, 105 | ), 106 | ).execute() 107 | 108 | fun OkHttpClient.options( 109 | url: HttpUrl, 110 | headers: Headers = Headers.headersOf(), 111 | ): Response = 112 | this 113 | .newCall( 114 | Request.Builder().options( 115 | url, 116 | headers, 117 | ), 118 | ).execute() 119 | 120 | fun Request.Builder.get( 121 | url: HttpUrl, 122 | headers: Headers = Headers.headersOf(), 123 | parameters: Map = emptyMap(), 124 | ) = this 125 | .url(url.of(parameters)) 126 | .headers(headers) 127 | .get() 128 | .build() 129 | 130 | fun Request.Builder.get( 131 | url: HttpUrl, 132 | parameters: Map, 133 | ) = this 134 | .url(url.of(parameters)) 135 | .get() 136 | .build() 137 | 138 | fun Request.Builder.post( 139 | url: HttpUrl, 140 | headers: Headers, 141 | parameters: Map, 142 | ) = this 143 | .url(url) 144 | .headers(headers) 145 | .post(FormBody.Builder().of(parameters)) 146 | .build() 147 | 148 | fun Request.Builder.options( 149 | url: HttpUrl, 150 | headers: Headers = Headers.headersOf(), 151 | ) = this 152 | .url(url) 153 | .headers(headers) 154 | .method("OPTIONS", null) 155 | .build() 156 | 157 | fun HttpUrl.of(parameters: Map) = 158 | this 159 | .newBuilder() 160 | .apply { 161 | parameters.forEach { (k, v) -> this.addEncodedQueryParameter(k, URLEncoder.encode(v, "UTF-8")) } 162 | }.build() 163 | 164 | fun FormBody.Builder.of(parameters: Map) = 165 | this 166 | .apply { 167 | parameters.forEach { (k, v) -> this.add(k, v) } 168 | }.build() 169 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/token/KeyGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.token 2 | 3 | import com.nimbusds.jose.JOSEObjectType 4 | import com.nimbusds.jose.JWSAlgorithm 5 | import com.nimbusds.jose.JWSHeader 6 | import com.nimbusds.jose.crypto.ECDSASigner 7 | import com.nimbusds.jose.crypto.RSASSASigner 8 | import com.nimbusds.jose.jwk.JWKSet 9 | import com.nimbusds.jose.jwk.KeyType 10 | import com.nimbusds.jwt.JWTClaimsSet 11 | import com.nimbusds.jwt.SignedJWT 12 | import com.nimbusds.oauth2.sdk.id.Issuer 13 | import io.kotest.assertions.throwables.shouldNotThrow 14 | import io.kotest.matchers.collections.shouldBeIn 15 | import io.kotest.matchers.shouldBe 16 | import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer 17 | import no.nav.security.mock.oauth2.token.KeyGenerator.Companion.ecAlgorithmFamily 18 | import no.nav.security.mock.oauth2.token.KeyGenerator.Companion.rsaAlgorithmFamily 19 | import org.junit.jupiter.api.Test 20 | import java.time.Instant 21 | import java.util.Date 22 | 23 | class KeyGeneratorTest { 24 | @Test 25 | fun `verify RSA signing keys with the right algorithm is created`() { 26 | rsaAlgorithmFamily.forEachIndexed { index, jwsAlgorithm -> 27 | 28 | val generator = KeyGenerator(algorithm = jwsAlgorithm) 29 | generator.algorithm.toString() shouldBe jwsAlgorithm.name 30 | 31 | val keyId = "test$index" 32 | val keys = generator.generateKey(keyId) 33 | 34 | keys.keyID shouldBe keyId 35 | keys.keyType.toString() shouldBe KeyType.RSA.value 36 | keys.keyUse.toString() shouldBe "sig" 37 | keys.algorithm shouldBeIn rsaAlgorithmFamily 38 | 39 | val issuer = Issuer("issuer$index") 40 | val jwt = jwtWith(issuer.value, keyId, JOSEObjectType.JWT.type, jwsAlgorithm) 41 | val jwkSet = JWKSet.parse("""{"keys": [$keys]}""".trimIndent()) 42 | 43 | shouldNotThrow { 44 | jwt.apply { 45 | sign(RSASSASigner(keys.toRSAKey().toRSAPrivateKey())) 46 | } 47 | jwt.verifySignatureAndIssuer(issuer, jwkSet, jwsAlgorithm) 48 | } 49 | } 50 | } 51 | 52 | @Test 53 | fun `verify EC signing keys with the right algorithm is created`() { 54 | ecAlgorithmFamily.forEachIndexed { index, jwsAlgorithm -> 55 | 56 | val generator = KeyGenerator(algorithm = jwsAlgorithm) 57 | generator.algorithm.toString() shouldBe jwsAlgorithm.name 58 | 59 | val keyId = "test$index" 60 | val keys = generator.generateKey(keyId) 61 | 62 | keys.keyID shouldBe keyId 63 | keys.keyType.toString() shouldBe KeyType.EC.value 64 | keys.keyUse.toString() shouldBe "sig" 65 | keys.algorithm shouldBeIn ecAlgorithmFamily 66 | 67 | val issuer = Issuer("issuer$index") 68 | val jwt = jwtWith(issuer.value, keyId, JOSEObjectType.JWT.type, jwsAlgorithm) 69 | val jwkSet = JWKSet.parse("""{"keys": [$keys]}""".trimIndent()) 70 | 71 | shouldNotThrow { 72 | jwt.apply { 73 | sign(ECDSASigner(keys.toECKey().toECPrivateKey())) 74 | } 75 | jwt.verifySignatureAndIssuer(issuer, jwkSet, jwsAlgorithm) 76 | } 77 | } 78 | } 79 | 80 | private fun jwtWith( 81 | issuer: String, 82 | keyId: String, 83 | type: String, 84 | algorithm: JWSAlgorithm, 85 | ): SignedJWT = 86 | SignedJWT( 87 | JWSHeader 88 | .Builder(algorithm) 89 | .keyID(keyId) 90 | .type(JOSEObjectType(type)) 91 | .build(), 92 | JWTClaimsSet 93 | .Builder() 94 | .issuer(issuer) 95 | .subject("test") 96 | .issueTime(Date.from(Instant.now())) 97 | .expirationTime(Date.from(Instant.now().plusSeconds(20))) 98 | .build(), 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/token/OAuth2TokenProviderECTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.token 2 | 3 | import com.nimbusds.jose.JWSAlgorithm 4 | import com.nimbusds.jose.jwk.KeyType 5 | import com.nimbusds.jose.jwk.KeyUse 6 | import com.nimbusds.jose.proc.BadJOSEException 7 | import com.nimbusds.jwt.SignedJWT 8 | import com.nimbusds.oauth2.sdk.GrantType 9 | import com.nimbusds.oauth2.sdk.id.Issuer 10 | import io.kotest.assertions.asClue 11 | import io.kotest.assertions.throwables.shouldThrow 12 | import io.kotest.matchers.shouldBe 13 | import io.kotest.matchers.shouldNotBe 14 | import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer 15 | import no.nav.security.mock.oauth2.testutils.nimbusTokenRequest 16 | import okhttp3.HttpUrl.Companion.toHttpUrl 17 | import org.junit.jupiter.api.Test 18 | import org.junit.jupiter.params.ParameterizedTest 19 | import org.junit.jupiter.params.provider.ValueSource 20 | 21 | internal class OAuth2TokenProviderECTest { 22 | private val tokenProvider = 23 | OAuth2TokenProvider( 24 | KeyProvider( 25 | emptyList(), 26 | JWSAlgorithm.ES256.name, 27 | ), 28 | ) 29 | 30 | @Test 31 | fun `public jwks returns public part of JWKs`() { 32 | val jwkSet = tokenProvider.publicJwkSet() 33 | jwkSet.keys.any { it.isPrivate } shouldNotBe true 34 | } 35 | 36 | @Test 37 | fun `all keys in public jwks should contain kty, use and kid`() { 38 | val jwkSet = tokenProvider.publicJwkSet() 39 | jwkSet.keys.forEach { 40 | it.keyID shouldNotBe null 41 | it.keyType shouldBe KeyType.EC 42 | it.keyUse shouldBe KeyUse.SIGNATURE 43 | } 44 | } 45 | 46 | @Test 47 | fun `claims from tokencallback should be added to token in tokenExchange`() { 48 | val initialToken = 49 | tokenProvider.jwt( 50 | mapOf( 51 | "iss" to "http://initialissuer", 52 | "sub" to "initialsubject", 53 | "aud" to "initialaudience", 54 | "initialclaim" to "initialclaim", 55 | ), 56 | ) 57 | 58 | tokenProvider 59 | .exchangeAccessToken( 60 | tokenRequest = 61 | nimbusTokenRequest( 62 | "myclient", 63 | "grant_type" to GrantType.JWT_BEARER.value, 64 | "scope" to "scope1", 65 | "assertion" to initialToken.serialize(), 66 | ), 67 | issuerUrl = "http://default_if_not_overridden".toHttpUrl(), 68 | claimsSet = initialToken.jwtClaimsSet, 69 | oAuth2TokenCallback = 70 | DefaultOAuth2TokenCallback( 71 | claims = 72 | mapOf( 73 | "extraclaim" to "extra", 74 | "iss" to "http://overrideissuer", 75 | ), 76 | ), 77 | ).jwtClaimsSet 78 | .asClue { 79 | it.issuer shouldBe "http://overrideissuer" 80 | it.subject shouldBe "initialsubject" 81 | it.audience shouldBe listOf("scope1") 82 | it.claims["initialclaim"] shouldBe "initialclaim" 83 | it.claims["extraclaim"] shouldBe "extra" 84 | } 85 | } 86 | 87 | @Test 88 | fun `publicJwks should return different signing key for each issuerId`() { 89 | val keys1 = tokenProvider.publicJwkSet("issuer1").toJSONObject() 90 | keys1 shouldBe tokenProvider.publicJwkSet("issuer1").toJSONObject() 91 | val keys2 = tokenProvider.publicJwkSet("issuer2").toJSONObject() 92 | keys2 shouldNotBe keys1 93 | } 94 | 95 | @ParameterizedTest 96 | @ValueSource(strings = ["issuer1", "issuer2"]) 97 | fun `ensure idToken is signed with same key as returned from public jwks`(issuerId: String) { 98 | val issuer = Issuer("http://localhost/$issuerId") 99 | idToken(issuer.toString()).verifySignatureAndIssuer(issuer, tokenProvider.publicJwkSet(issuerId), JWSAlgorithm.ES256) 100 | 101 | shouldThrow { 102 | idToken(issuer.toString()).verifySignatureAndIssuer(issuer, tokenProvider.publicJwkSet("shouldfail"), JWSAlgorithm.ES256) 103 | } 104 | } 105 | 106 | private fun idToken(issuerUrl: String): SignedJWT = 107 | tokenProvider.idToken( 108 | tokenRequest = 109 | nimbusTokenRequest( 110 | "client1", 111 | "grant_type" to "authorization_code", 112 | "code" to "123", 113 | ), 114 | issuerUrl = issuerUrl.toHttpUrl(), 115 | oAuth2TokenCallback = DefaultOAuth2TokenCallback(), 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /src/test/kotlin/no/nav/security/mock/oauth2/userinfo/UserInfoTest.kt: -------------------------------------------------------------------------------- 1 | package no.nav.security.mock.oauth2.userinfo 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.fasterxml.jackson.module.kotlin.readValue 5 | import com.nimbusds.jose.JWSAlgorithm 6 | import io.kotest.assertions.asClue 7 | import io.kotest.assertions.throwables.shouldThrow 8 | import io.kotest.matchers.maps.shouldContainAll 9 | import io.kotest.matchers.shouldBe 10 | import no.nav.security.mock.oauth2.OAuth2Exception 11 | import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO 12 | import no.nav.security.mock.oauth2.http.OAuth2HttpRequest 13 | import no.nav.security.mock.oauth2.http.OAuth2HttpResponse 14 | import no.nav.security.mock.oauth2.http.routes 15 | import no.nav.security.mock.oauth2.token.KeyProvider 16 | import no.nav.security.mock.oauth2.token.OAuth2TokenProvider 17 | import okhttp3.Headers 18 | import okhttp3.HttpUrl.Companion.toHttpUrl 19 | import org.junit.jupiter.api.Test 20 | import java.time.Instant 21 | import java.time.temporal.ChronoUnit 22 | 23 | internal class UserInfoTest { 24 | @Test 25 | fun `userinfo should return claims from bearer token`() { 26 | val issuerUrl = "http://localhost/default" 27 | val tokenProvider = OAuth2TokenProvider() 28 | val claims = 29 | mapOf( 30 | "iss" to issuerUrl, 31 | "sub" to "foo", 32 | "extra" to "bar", 33 | ) 34 | val bearerToken = tokenProvider.jwt(claims) 35 | val request = request("$issuerUrl$USER_INFO", bearerToken.serialize()) 36 | 37 | routes { userInfo(tokenProvider) }.invoke(request).asClue { 38 | it.status shouldBe 200 39 | it.parse>() shouldContainAll claims 40 | } 41 | } 42 | 43 | @Test 44 | fun `userinfo should return claims from bearer token when using a custom timeProvider in OAuth2TokenProvider`() { 45 | val issuerUrl = "http://localhost/default" 46 | val yesterday = Instant.now().minus(1, ChronoUnit.DAYS) 47 | val tokenProvider = OAuth2TokenProvider(timeProvider = { yesterday }) 48 | val claims = 49 | mapOf( 50 | "iss" to issuerUrl, 51 | "sub" to "foo", 52 | "extra" to "bar", 53 | ) 54 | val bearerToken = tokenProvider.jwt(claims) 55 | val request = request("$issuerUrl$USER_INFO", bearerToken.serialize()) 56 | 57 | routes { userInfo(tokenProvider) }.invoke(request).asClue { 58 | it.status shouldBe 200 59 | it.parse>() shouldContainAll claims 60 | } 61 | } 62 | 63 | @Test 64 | fun `userinfo should throw OAuth2Exception when algorithm does not match`() { 65 | val issuerUrl = "http://localhost/default" 66 | val tokenProvider = OAuth2TokenProvider(keyProvider = KeyProvider(algorithm = JWSAlgorithm.RS384.name)) 67 | val claims = 68 | mapOf( 69 | "iss" to issuerUrl, 70 | "sub" to "foo", 71 | "extra" to "bar", 72 | ) 73 | val bearerToken = tokenProvider.jwt(claims) 74 | val request = request("$issuerUrl$USER_INFO", bearerToken.serialize()) 75 | 76 | shouldThrow { 77 | routes { 78 | userInfo(tokenProvider) 79 | }.invoke(request) 80 | }.asClue { 81 | it.errorObject?.code shouldBe "invalid_token" 82 | it.errorObject?.description shouldBe "Signed JWT rejected: Another algorithm expected, or no matching key(s) found" 83 | it.errorObject?.httpStatusCode shouldBe 401 84 | } 85 | } 86 | 87 | @Test 88 | fun `userinfo should throw OAuth2Exception when bearer token is missing`() { 89 | val url = "http://localhost/default$USER_INFO" 90 | 91 | shouldThrow { 92 | routes { 93 | userInfo(OAuth2TokenProvider()) 94 | }.invoke(request(url, null)) 95 | }.asClue { 96 | it.errorObject?.code shouldBe "invalid_token" 97 | it.errorObject?.description shouldBe "missing bearer token" 98 | it.errorObject?.httpStatusCode shouldBe 401 99 | } 100 | } 101 | 102 | @Test 103 | fun `userinfo should throw OAuth2Exception when bearer token is invalid`() { 104 | val url = "http://localhost/default$USER_INFO" 105 | 106 | shouldThrow { 107 | routes { 108 | userInfo(OAuth2TokenProvider()) 109 | }.invoke(request(url, "invalid")) 110 | }.asClue { 111 | it.errorObject?.code shouldBe "invalid_token" 112 | it.errorObject?.httpStatusCode shouldBe 401 113 | } 114 | } 115 | 116 | private inline fun OAuth2HttpResponse.parse(): T = jacksonObjectMapper().readValue(checkNotNull(body)) 117 | 118 | private fun request( 119 | url: String, 120 | bearerToken: String?, 121 | ): OAuth2HttpRequest = 122 | OAuth2HttpRequest( 123 | bearerToken?.let { Headers.headersOf("Authorization", "Bearer $it") } ?: Headers.headersOf(), 124 | "GET", 125 | url.toHttpUrl(), 126 | null, 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /src/test/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.logging.LoggingSystemFactory=\ 2 | org.springframework.boot.logging.java.JavaLoggingSystem.Factory 3 | -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | #spring: 2 | # security: 3 | # oauth2: 4 | # client: 5 | # registration: 6 | # aad: 7 | # client-id: client1 8 | # client-secret: secret 9 | # authorization-grant-type: authorization_code 10 | # redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}' 11 | # scope: openid 12 | # provider: 13 | # aad: 14 | # authorization-uri: http://localhost:1234/issuer1/authorize 15 | # token-uri: http://localhost:1234/issuer1/token 16 | # jwk-set-uri: http://localhost:1234/issuer1/jwks 17 | -------------------------------------------------------------------------------- /src/test/resources/config-ssl.json: -------------------------------------------------------------------------------- 1 | { 2 | "interactiveLogin": true, 3 | "httpServer": { 4 | "type": "NettyWrapper", 5 | "ssl": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "interactiveLogin": true, 3 | "httpServer": "NettyWrapper", 4 | "loginPagePath": "/app/login/login.example.html", 5 | "staticAssetsPath": "/app/static", 6 | "tokenCallbacks": [ 7 | { 8 | "issuerId": "issuer1", 9 | "tokenExpiry": 120, 10 | "requestMappings": [ 11 | { 12 | "requestParam": "scope", 13 | "match": "scope1", 14 | "claims": { 15 | "sub": "subByScope", 16 | "aud": [ 17 | "audByScope" 18 | ] 19 | } 20 | } 21 | ] 22 | }, 23 | { 24 | "issuerId": "issuer2", 25 | "requestMappings": [ 26 | { 27 | "requestParam": "someparam", 28 | "match": "somevalue", 29 | "claims": { 30 | "sub": "subBySomeParam", 31 | "aud": [ 32 | "audBySomeParam" 33 | ] 34 | } 35 | } 36 | ] 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/test/resources/junit-plattform.properties: -------------------------------------------------------------------------------- 1 | junit.jupiter.testinstance.lifecycle.default = per_class 2 | -------------------------------------------------------------------------------- /src/test/resources/localhost.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/navikt/mock-oauth2-server/2c7a77305feff178887e8930c21b81d52588b85a/src/test/resources/localhost.p12 -------------------------------------------------------------------------------- /src/test/resources/login.example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mock OAuth2 Server Example Sign-in 8 | 9 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
19 |

   Mock OAuth2 Server Example

20 |
21 |
22 | 24 |
25 |
26 | 31 |
32 | 33 |
34 |
35 |
36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/test/resources/static/nav-logo-red.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/static/test.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 800px; 3 | } 4 | -------------------------------------------------------------------------------- /src/test/resources/static/test.js: -------------------------------------------------------------------------------- 1 | let test = function() { 2 | return "test"; 3 | } 4 | -------------------------------------------------------------------------------- /src/test/resources/static/test.txt: -------------------------------------------------------------------------------- 1 | test 2 | --------------------------------------------------------------------------------