├── CODEOWNERS
├── src
├── test
│ ├── resources
│ │ ├── static
│ │ │ ├── test.txt
│ │ │ ├── test.css
│ │ │ ├── test.js
│ │ │ └── nav-logo-red.svg
│ │ ├── junit-plattform.properties
│ │ ├── localhost.p12
│ │ ├── config-ssl.json
│ │ ├── META-INF
│ │ │ └── spring.factories
│ │ ├── application.yml
│ │ ├── config.json
│ │ └── login.example.html
│ ├── kotlin
│ │ ├── no
│ │ │ └── nav
│ │ │ │ └── security
│ │ │ │ └── mock
│ │ │ │ └── oauth2
│ │ │ │ ├── examples
│ │ │ │ ├── securedapi
│ │ │ │ │ ├── ExampleAppWithSecuredApi.kt
│ │ │ │ │ └── ExampleAppWithSecuredApiTest.kt
│ │ │ │ ├── clientcredentials
│ │ │ │ │ ├── ExampleAppWithClientCredentialsClientTest.kt
│ │ │ │ │ └── ExampleAppWithClientCredentialsClient.kt
│ │ │ │ ├── openidconnect
│ │ │ │ │ ├── ExampleAppWithOpenIdConnectTest.kt
│ │ │ │ │ └── ExampleAppWithOpenIdConnect.kt
│ │ │ │ └── AbstractExampleApp.kt
│ │ │ │ ├── extensions
│ │ │ │ ├── TemplateTest.kt
│ │ │ │ └── HttpUrlExtensionsTest.kt
│ │ │ │ ├── e2e
│ │ │ │ ├── StaticAssetsIntegrationTest.kt
│ │ │ │ ├── WellKnownIntegrationTest.kt
│ │ │ │ ├── LoginPageIntegrationTest.kt
│ │ │ │ ├── PasswordGrantIntegrationTest.kt
│ │ │ │ ├── UserInfoIntegrationTest.kt
│ │ │ │ ├── InteractiveLoginIntegrationTest.kt
│ │ │ │ ├── RevocationIntegrationTest.kt
│ │ │ │ └── CorsHeadersIntegrationTest.kt
│ │ │ │ ├── grant
│ │ │ │ └── RefreshTokenManagerTest.kt
│ │ │ │ ├── testutils
│ │ │ │ ├── Grant.kt
│ │ │ │ └── Http.kt
│ │ │ │ ├── login
│ │ │ │ └── LoginRequestHandlerTest.kt
│ │ │ │ ├── MockOAuth2ServerTest.kt
│ │ │ │ ├── StandaloneMockOAuth2ServerKtTest.kt
│ │ │ │ ├── token
│ │ │ │ ├── KeyGeneratorTest.kt
│ │ │ │ └── OAuth2TokenProviderECTest.kt
│ │ │ │ ├── server
│ │ │ │ └── OAuth2HttpServerTest.kt
│ │ │ │ └── userinfo
│ │ │ │ └── UserInfoTest.kt
│ │ └── examples
│ │ │ └── kotlin
│ │ │ └── ktor
│ │ │ ├── client
│ │ │ ├── OAuth2ClientTest.kt
│ │ │ └── OAuth2Client.kt
│ │ │ ├── login
│ │ │ ├── OAuth2LoginAppTest.kt
│ │ │ └── OAuth2LoginApp.kt
│ │ │ └── resourceserver
│ │ │ └── OAuth2ResourceServerAppTest.kt
│ └── java
│ │ └── examples
│ │ └── java
│ │ └── springboot
│ │ ├── MockOAuth2ServerInitializer.java
│ │ ├── resourceserver
│ │ ├── OAuth2ResourceServerAppTest.java
│ │ └── OAuth2ResourceServerApp.java
│ │ └── login
│ │ ├── OAuth2LoginApp.java
│ │ └── OAuth2LoginAppTest.java
└── main
│ ├── kotlin
│ └── no
│ │ └── nav
│ │ └── security
│ │ └── mock
│ │ └── oauth2
│ │ ├── extensions
│ │ ├── AsOAuth2HttpRequest.kt
│ │ ├── String.kt
│ │ ├── Template.kt
│ │ └── HttpUrlExtensions.kt
│ │ ├── grant
│ │ ├── GrantHandler.kt
│ │ ├── ClientCredentialsGrantHandler.kt
│ │ ├── RefreshTokenManager.kt
│ │ ├── TokenExchangeGrantHandler.kt
│ │ ├── PasswordGrantHandler.kt
│ │ ├── JwtBearerGrantHandler.kt
│ │ └── RefreshTokenGrantHandler.kt
│ │ ├── OAuth2Exception.kt
│ │ ├── login
│ │ └── LoginRequestHandler.kt
│ │ ├── http
│ │ └── CorsInterceptor.kt
│ │ ├── userinfo
│ │ └── UserInfo.kt
│ │ ├── StandaloneMockOAuth2Server.kt
│ │ ├── debugger
│ │ ├── SessionManager.kt
│ │ ├── Client.kt
│ │ └── DebuggerRequestHandler.kt
│ │ ├── token
│ │ ├── KeyProvider.kt
│ │ └── KeyGenerator.kt
│ │ ├── templates
│ │ └── TemplateMapper.kt
│ │ └── introspect
│ │ └── Introspect.kt
│ └── resources
│ ├── templates
│ ├── authorization_code_response.ftl
│ ├── main.ftl
│ ├── error.ftl
│ ├── debugger_callback.ftl
│ ├── login.ftl
│ ├── css
│ │ └── custom.css
│ └── debugger.ftl
│ ├── logback-standalone.xml
│ └── mock-oauth2-server-keys-ec.json
├── settings.gradle
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── gradle.properties
├── .gitignore
├── .github
├── dependabot.yml
├── workflows
│ ├── test-pr.yaml
│ ├── active-issue-pr.yml
│ ├── manual_testing.yml
│ ├── dokka.yml
│ ├── build-master.yml
│ └── publish-release.yml
└── release-drafter.yml
├── docker-compose-ssl.yaml
├── docker-compose.yaml
├── LICENSE.md
├── CONTRIBUTING.md
├── gradlew.bat
└── .editorconfig
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @navikt/pig-sikkerhet
2 |
--------------------------------------------------------------------------------
/src/test/resources/static/test.txt:
--------------------------------------------------------------------------------
1 | test
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'mock-oauth2-server'
--------------------------------------------------------------------------------
/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/junit-plattform.properties:
--------------------------------------------------------------------------------
1 | junit.jupiter.testinstance.lifecycle.default = per_class
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/navikt/mock-oauth2-server/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/src/test/resources/localhost.p12:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/navikt/mock-oauth2-server/HEAD/src/test/resources/localhost.p12
--------------------------------------------------------------------------------
/src/test/resources/config-ssl.json:
--------------------------------------------------------------------------------
1 | {
2 | "interactiveLogin": true,
3 | "httpServer": {
4 | "type": "NettyWrapper",
5 | "ssl": {}
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/test/resources/META-INF/spring.factories:
--------------------------------------------------------------------------------
1 | org.springframework.boot.logging.LoggingSystemFactory=\
2 | org.springframework.boot.logging.java.JavaLoggingSystem.Factory
3 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
17 | .kotlin
18 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | kotlinTarget = "1.9.0" # Minimum supported Kotlin version for consumers of the library
3 | kotlinToolchain = "2.2.20" # 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.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 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gradle"
4 | # Files stored in repository root
5 | directory: "/"
6 | schedule:
7 | interval: "weekly"
8 | - package-ecosystem: github-actions
9 | directory: "/"
10 | schedule:
11 | interval: "weekly"
12 | groups:
13 | github-actions:
14 | patterns:
15 | - "*"
16 |
--------------------------------------------------------------------------------
/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/resources/templates/authorization_code_response.ftl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | callback
6 |
7 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@v5
14 |
15 | - name: Set up JDK 21
16 | uses: actions/setup-java@v5
17 | with:
18 | java-version: 21
19 | distribution: 'temurin'
20 | cache: 'gradle'
21 |
22 | - name: Build with Gradle
23 | run: ./gradlew build
24 |
--------------------------------------------------------------------------------
/docker-compose-ssl.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | mock-oauth2-server:
3 | image: mock-oauth2-server:latest
4 | ports:
5 | - "8080:8080"
6 | volumes:
7 | - ./src/test/resources/config-ssl.json:/app/config.json
8 | environment:
9 | LOG_LEVEL: "debug"
10 | SERVER_PORT: 8080
11 | JSON_CONFIG_PATH: /app/config.json
12 | healthcheck:
13 | test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/isalive" ]
14 | start_period: 5s
15 | retries: 10
16 | interval: 2s
17 | timeout: 1s
18 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | mock-oauth2-server:
3 | image: mock-oauth2-server:latest
4 | ports:
5 | - "8080:8080"
6 | volumes:
7 | - ./src/test/resources/config.json:/app/config.json
8 | - ./src/test/resources/login.example.html:/app/login/login.example.html
9 | - ./src/test/resources/static/:/app/static/
10 | environment:
11 | LOG_LEVEL: "debug"
12 | SERVER_PORT: 8080
13 | JSON_CONFIG_PATH: /app/config.json
14 | healthcheck:
15 | test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/isalive" ]
16 | start_period: 5s
17 | retries: 10
18 | interval: 2s
19 | timeout: 1s
20 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 | #macro>
26 |
--------------------------------------------------------------------------------
/.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@v10
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 |
--------------------------------------------------------------------------------
/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/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/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 |
8 |
9 |
10 |
11 |
Could be expired session? Please try again using the debugger form - ${debugger_url}
12 |
13 |
14 |
15 |
${stacktrace}
16 |
17 |
18 |
19 |
23 |
24 | @layout.mainLayout>
25 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@v5
15 | with:
16 | ref: add-arm64-architecture
17 |
18 | - name: Set up JDK 21
19 | uses: actions/setup-java@v5
20 | with:
21 | java-version: 21
22 | distribution: 'temurin'
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/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@v5
19 |
20 | - name: Set up JDK 21
21 | uses: actions/setup-java@v5
22 | with:
23 | java-version: 21
24 | distribution: 'temurin'
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 |
--------------------------------------------------------------------------------
/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 |
8 |
9 |
10 |
11 |
Inspect callback parameters and the actual token response
12 |
13 |
14 |
${token_request}
15 |
16 |
${token_response}
17 |
18 |
19 |
20 |
24 |
25 | @layout.mainLayout>
26 |
--------------------------------------------------------------------------------
/src/test/resources/static/nav-logo-red.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5
17 | - name: Set up JDK 21
18 | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # ratchet:actions/setup-java@v5
19 | with:
20 | java-version: 21
21 | distribution: 'temurin'
22 | cache: 'gradle'
23 | - name: Setup Gradle
24 | uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # ratchet:gradle/actions/setup-gradle@v4
25 | - name: Generate and submit dependency graph
26 | uses: gradle/actions/dependency-submission@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # 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 |
--------------------------------------------------------------------------------
/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 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
8 |
9 |
10 |
11 |
12 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | <#list query as propName, propValue>
38 |
${propName} = ${propValue}
39 | #list>
40 |
41 |
42 |
43 |
44 |
45 | @layout.mainLayout>
46 |
--------------------------------------------------------------------------------
/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/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