diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 4ba41d1b3..fbdb10b90 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Community Support - url: https://stackoverflow.com/questions/tagged/spring-security - about: Please ask and answer questions on StackOverflow with the tag `spring-security`. + url: https://stackoverflow.com/questions/tagged/spring-authorization-server + about: Please ask and answer questions on StackOverflow with the tag `spring-authorization-server`. diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 9d4e4d716..96830db43 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -6,123 +6,50 @@ on: - '**' schedule: - cron: '0 10 * * *' # Once per day at 10am UTC - -env: - RUN_JOBS: ${{ github.repository == 'spring-projects/spring-authorization-server' }} + workflow_dispatch: jobs: - prerequisites: - name: Pre-requisites for building - runs-on: ubuntu-latest - outputs: - runjobs: ${{ steps.continue.outputs.runjobs }} - project_version: ${{ steps.continue.outputs.project_version }} - steps: - - uses: actions/checkout@v2 - - id: continue - name: Determine if should continue - if: env.RUN_JOBS == 'true' - run: | - # Run jobs if in upstream repository - echo "::set-output name=runjobs::true" - # Extract version from gradle.properties - version=$(cat gradle.properties | grep "version=" | awk -F'=' '{print $2}') - echo "::set-output name=project_version::$version" build: name: Build - needs: [prerequisites] + uses: spring-io/spring-security-release-tools/.github/workflows/build.yml@v1 strategy: matrix: os: [ubuntu-latest, windows-latest] jdk: [17] - fail-fast: false - runs-on: ${{ matrix.os }} - if: needs.prerequisites.outputs.runjobs - steps: - - uses: actions/checkout@v2 - - name: Set up JDK ${{ matrix.jdk }} - uses: actions/setup-java@v1 - with: - java-version: ${{ matrix.jdk }} - - name: Setup gradle user name - run: | - mkdir -p ~/.gradle - echo 'systemProp.user.name=spring-builds+github' >> ~/.gradle/gradle.properties - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - env: - GRADLE_USER_HOME: ~/.gradle - - name: Build with Gradle - env: - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: ./gradlew clean build --continue -PartifactoryUsername="$ARTIFACTORY_USERNAME" -PartifactoryPassword="$ARTIFACTORY_PASSWORD" - snapshot_tests: - name: Test against snapshots - needs: [prerequisites] - runs-on: ubuntu-latest - if: needs.prerequisites.outputs.runjobs - steps: - - uses: actions/checkout@v2 - - name: Set up JDK - uses: actions/setup-java@v1 - with: - java-version: 17 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - name: Snapshot Tests - env: - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: ./gradlew test --refresh-dependencies -Duser.name=spring-builds+github -PartifactoryUsername="$ARTIFACTORY_USERNAME" -PartifactoryPassword="$ARTIFACTORY_PASSWORD" -PforceMavenRepositories=snapshot -PspringFrameworkVersion='6.0.+' -PspringSecurityVersion='6.0.+' -PlocksDisabled --stacktrace - deploy_artifacts: + with: + runs-on: ${{ matrix.os }} + java-version: ${{ matrix.jdk }} + distribution: temurin + secrets: inherit + test: + name: Test Against Snapshots + uses: spring-io/spring-security-release-tools/.github/workflows/test.yml@v1 + with: + test-args: --refresh-dependencies --stacktrace -PforceMavenRepositories=snapshot -PspringFrameworkVersion=6.0.+ -PspringSecurityVersion=6.1.+ + secrets: inherit + deploy-artifacts: name: Deploy Artifacts - needs: [build, snapshot_tests] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up JDK - uses: actions/setup-java@v1 - with: - java-version: 17 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - name: Deploy Artifacts - env: - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }} - ORG_GRADLE_PROJECT_signingKey: ${{ secrets.GPG_PRIVATE_KEY }} - ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.GPG_PASSPHRASE }} - OSSRH_TOKEN_USERNAME: ${{ secrets.OSSRH_S01_TOKEN_USERNAME }} - OSSRH_TOKEN_PASSWORD: ${{ secrets.OSSRH_S01_TOKEN_PASSWORD }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: ./gradlew publishArtifacts finalizeDeployArtifacts -Duser.name=spring-builds+github -PossrhUsername="$OSSRH_TOKEN_USERNAME" -PossrhPassword="$OSSRH_TOKEN_PASSWORD" -PartifactoryUsername="$ARTIFACTORY_USERNAME" -PartifactoryPassword="$ARTIFACTORY_PASSWORD" --stacktrace - deploy_docs: + needs: [build, test] + uses: spring-io/spring-security-release-tools/.github/workflows/deploy-artifacts.yml@v1 + with: + should-deploy-artifacts: ${{ needs.build.outputs.should-deploy-artifacts }} + secrets: inherit + deploy-docs: name: Deploy Docs - needs: [build, snapshot_tests] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up JDK - uses: actions/setup-java@v1 - with: - java-version: 17 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - name: Deploy Docs - env: - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }} - DOCS_USERNAME: ${{ secrets.DOCS_USERNAME }} - DOCS_SSH_KEY: ${{ secrets.DOCS_SSH_KEY }} - DOCS_HOST: ${{ secrets.DOCS_HOST }} - run: ./gradlew deployDocs -PdeployDocsSshKey="$DOCS_SSH_KEY" -PdeployDocsSshUsername="$DOCS_USERNAME" -PdeployDocsHost="$DOCS_HOST" --stacktrace + needs: [build, test] + uses: spring-io/spring-security-release-tools/.github/workflows/deploy-docs.yml@v1 + with: + should-deploy-docs: ${{ needs.build.outputs.should-deploy-artifacts }} + secrets: inherit + perform-release: + name: Perform Release + needs: [deploy-artifacts, deploy-docs] + uses: spring-io/spring-security-release-tools/.github/workflows/perform-release.yml@v1 + with: + should-perform-release: ${{ needs.deploy-artifacts.outputs.artifacts-deployed }} + project-version: ${{ needs.deploy-artifacts.outputs.project-version }} + milestone-repo-url: https://repo.spring.io/artifactory/milestone + release-repo-url: https://repo1.maven.org/maven2 + artifact-path: org/springframework/security/spring-security-oauth2-authorization-server + slack-announcing-id: spring-authorization-server-announcing + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/pr-build-workflow.yml b/.github/workflows/pr-build-workflow.yml index 57d19e6a6..8621c2b65 100644 --- a/.github/workflows/pr-build-workflow.yml +++ b/.github/workflows/pr-build-workflow.yml @@ -15,10 +15,11 @@ jobs: jdk: [17] fail-fast: false steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK ${{ matrix.jdk }} - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: ${{ matrix.jdk }} + distribution: 'temurin' - name: Build with Gradle run: ./gradlew clean build diff --git a/.github/workflows/update-scheduled-release-version.yml b/.github/workflows/update-scheduled-release-version.yml new file mode 100644 index 000000000..5c0a574c4 --- /dev/null +++ b/.github/workflows/update-scheduled-release-version.yml @@ -0,0 +1,10 @@ +name: Update Scheduled Release Version + +on: + workflow_dispatch: # Manual trigger only. Triggered by release-scheduler.yml on main. + +jobs: + update-scheduled-release-version: + name: Update Scheduled Release Version + uses: spring-io/spring-security-release-tools/.github/workflows/update-scheduled-release-version.yml@v1 + secrets: inherit diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index 5576b77af..b876f45d6 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -11,7 +11,7 @@ Please report unacceptable behavior to spring-code-of-conduct@pivotal.io. == Using GitHub Issues We use GitHub issues to track bugs and enhancements. If you have a general usage question please ask on https://stackoverflow.com[Stack Overflow]. -The Spring Security team and the broader community monitor the https://stackoverflow.com/tags/spring-security[`spring-security`] tag. +The Spring Security team and the broader community monitor the https://stackoverflow.com/tags/spring-authorization-server[`spring-authorization-server`] tag. If you are reporting a bug, please help to speed up problem diagnosis by providing as much information as possible. Ideally, that would include a https://stackoverflow.com/help/minimal-reproducible-example[complete & minimal sample project] that reproduces the problem. diff --git a/README.adoc b/README.adoc index fc3befa22..82bb6796c 100644 --- a/README.adoc +++ b/README.adoc @@ -9,9 +9,8 @@ The Spring Authorization Server project, led by the https://spring.io/projects/s This project replaces the Authorization Server support provided by https://spring.io/projects/spring-security-oauth/[Spring Security OAuth]. == Feature Planning -This project uses https://www.zenhub.com/[ZenHub] to prioritize the feature roadmap and help organize the project plan. -The project board can be accessed https://app.zenhub.com/workspaces/authorization-server-5e8f3182b5e8f5841bfc4902/board?repos=248032165[here]. -It is recommended to install the ZenHub https://www.zenhub.com/extension[browser extension] as it integrates natively within GitHub's user interface. +This project uses https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects[GitHub Projects] to prioritize the feature roadmap and help organize the project plan. +The project board can be accessed https://github.com/orgs/spring-projects/projects/8[here]. The feature list can be viewed in the https://docs.spring.io/spring-authorization-server/docs/current/reference/html/overview.html#feature-list[reference documentation]. @@ -79,8 +78,8 @@ git clone git@github.com:spring-projects/spring-authorization-server.git Discover more commands with `./gradlew tasks`. == Getting Support -Check out the https://stackoverflow.com/questions/tagged/spring-security[Spring Security tags on Stack Overflow]. -https://spring.io/services[Commercial support] is available too. +Check out the https://stackoverflow.com/questions/tagged/spring-authorization-server[Spring Authorization Server tags on Stack Overflow]. +https://spring.io/support[Commercial support] is available too. == Contributing https://help.github.com/articles/creating-a-pull-request[Pull requests] are welcome; see the link:CONTRIBUTING.adoc[contributor guidelines] for details. diff --git a/build.gradle b/build.gradle index 35ff5b0e7..2b173f9bd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,20 @@ plugins { id "io.spring.convention.root" + id "io.spring.security.release" version "1.0.1" } group = "org.springframework.security" description = "Spring Authorization Server" +springRelease { + repositoryOwner = "spring-projects" + weekOfMonth = 3 + dayOfWeek = 2 + referenceDocUrl = "https://docs.spring.io/spring-authorization-server/docs/{version}/reference/html/" + apiDocUrl = "https://docs.spring.io/spring-authorization-server/docs/{version}/api/" + replaceSnapshotVersionInReferenceDocUrl = false +} + if (hasProperty("buildScan")) { buildScan { termsOfServiceUrl = "https://gradle.com/terms-of-service" diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 13c8a355c..c013445db 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -21,7 +21,7 @@ dependencies { implementation "org.asciidoctor:asciidoctor-gradle-jvm-pdf:3.3.2" implementation "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" implementation "org.hidetake:gradle-ssh-plugin:2.10.1" - implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" + implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.29.0" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:6.0.0" + implementation "org.springframework:spring-core:6.0.18" } diff --git a/buildSrc/src/main/java/io/spring/gradle/convention/SpringRootProjectPlugin.java b/buildSrc/src/main/java/io/spring/gradle/convention/SpringRootProjectPlugin.java index d9c09639a..169b433cc 100644 --- a/buildSrc/src/main/java/io/spring/gradle/convention/SpringRootProjectPlugin.java +++ b/buildSrc/src/main/java/io/spring/gradle/convention/SpringRootProjectPlugin.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import org.gradle.api.plugins.PluginManager; import org.springframework.gradle.classpath.SpringCheckProhibitedDependenciesLifecyclePlugin; +import org.springframework.gradle.maven.SpringArtifactoryPlugin; import org.springframework.gradle.maven.SpringNexusPlugin; import org.springframework.gradle.nohttp.SpringNoHttpPlugin; import org.springframework.gradle.sonarqube.SpringSonarQubePlugin; @@ -38,6 +39,7 @@ public void apply(Project project) { pluginManager.apply(SpringNoHttpPlugin.class); pluginManager.apply(SpringNexusPlugin.class); pluginManager.apply(SpringCheckProhibitedDependenciesLifecyclePlugin.class); + pluginManager.apply(SpringArtifactoryPlugin.class); pluginManager.apply(SpringSonarQubePlugin.class); // Apply default repositories diff --git a/buildSrc/src/main/java/org/springframework/gradle/docs/SpringAsciidoctorPlugin.java b/buildSrc/src/main/java/org/springframework/gradle/docs/SpringAsciidoctorPlugin.java index 6910009da..5b8d3bd62 100644 --- a/buildSrc/src/main/java/org/springframework/gradle/docs/SpringAsciidoctorPlugin.java +++ b/buildSrc/src/main/java/org/springframework/gradle/docs/SpringAsciidoctorPlugin.java @@ -104,7 +104,7 @@ private void createAsciidoctorExtensionsConfiguration(Project project) { project.getConfigurations().matching((candidate) -> "management".equals(candidate.getName())) .all(configuration::extendsFrom); configuration.getDependencies().add(project.getDependencies() - .create("io.spring.asciidoctor.backends:spring-asciidoctor-backends:0.0.3")); + .create("io.spring.asciidoctor.backends:spring-asciidoctor-backends:0.0.5")); configuration.getDependencies() .add(project.getDependencies().create("org.asciidoctor:asciidoctorj-pdf:1.5.3")); }); diff --git a/buildSrc/src/main/java/org/springframework/gradle/maven/SpringArtifactoryPlugin.java b/buildSrc/src/main/java/org/springframework/gradle/maven/SpringArtifactoryPlugin.java index 912fafc91..c5ba47ab4 100644 --- a/buildSrc/src/main/java/org/springframework/gradle/maven/SpringArtifactoryPlugin.java +++ b/buildSrc/src/main/java/org/springframework/gradle/maven/SpringArtifactoryPlugin.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.publish.maven.plugins.MavenPublishPlugin; import org.jfrog.gradle.plugin.artifactory.ArtifactoryPlugin; import org.jfrog.gradle.plugin.artifactory.dsl.ArtifactoryPluginConvention; @@ -49,7 +50,10 @@ public void apply(Project project) { repository.setPassword(project.findProperty("artifactoryPassword")); } }); - publish.defaults((defaults) -> defaults.publications("mavenJava")); + // Would fail if maven publish is not applied, i.e. in root project (SpringRootProjectPlugin) + project.getPlugins().withType(MavenPublishPlugin.class, mavenPublish -> { + publish.defaults((defaults) -> defaults.publications("mavenJava")); + }); }); }); } diff --git a/buildSrc/src/main/java/org/springframework/gradle/nohttp/SpringNoHttpPlugin.java b/buildSrc/src/main/java/org/springframework/gradle/nohttp/SpringNoHttpPlugin.java index 364b4f20e..f48b3a73d 100644 --- a/buildSrc/src/main/java/org/springframework/gradle/nohttp/SpringNoHttpPlugin.java +++ b/buildSrc/src/main/java/org/springframework/gradle/nohttp/SpringNoHttpPlugin.java @@ -36,6 +36,6 @@ public void apply(Project project) { NoHttpExtension nohttp = project.getExtensions().getByType(NoHttpExtension.class); File allowlistFile = project.getRootProject().file("etc/nohttp/allowlist.lines"); nohttp.setAllowlistFile(allowlistFile); - nohttp.getSource().exclude("buildSrc/build/**"); + nohttp.getSource().exclude("**/build/**"); } } diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 57f3dddee..efdb59097 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -9,16 +9,16 @@ javaPlatform { dependencies { api platform("org.springframework:spring-framework-bom:$springFrameworkVersion") api platform("org.springframework.security:spring-security-bom:$springSecurityVersion") - api platform("com.fasterxml.jackson:jackson-bom:2.14.0") + api platform("com.fasterxml.jackson:jackson-bom:2.15.4") constraints { - api "com.nimbusds:nimbus-jose-jwt:9.24.4" + api "com.nimbusds:nimbus-jose-jwt:9.31" api "jakarta.servlet:jakarta.servlet-api:6.0.0" - api "org.junit.jupiter:junit-jupiter:5.9.1" - api "org.assertj:assertj-core:3.23.1" - api "org.mockito:mockito-core:4.8.1" + api "org.junit.jupiter:junit-jupiter:5.9.3" + api "org.assertj:assertj-core:3.24.2" + api "org.mockito:mockito-core:4.11.0" api "com.squareup.okhttp3:mockwebserver:4.10.0" api "com.squareup.okhttp3:okhttp:4.10.0" - api "com.jayway.jsonpath:json-path:2.7.0" - api "org.hsqldb:hsqldb:2.7.1" + api "com.jayway.jsonpath:json-path:2.8.0" + api "org.hsqldb:hsqldb:2.7.2" } } diff --git a/docs/spring-authorization-server-docs.gradle b/docs/spring-authorization-server-docs.gradle index fd180ddb6..ce3e644de 100644 --- a/docs/spring-authorization-server-docs.gradle +++ b/docs/spring-authorization-server-docs.gradle @@ -7,7 +7,9 @@ asciidoctor { "spring-authorization-server-version": project.version, "spring-security-reference-base-url": "https://docs.spring.io/spring-security/reference", "spring-security-api-base-url": "https://docs.spring.io/spring-security/site/docs/current/api", + "spring-boot-reference-base-url": "https://docs.spring.io/spring-boot/docs/current/reference/html", "examples-dir": "examples", + "samples-dir": "$rootDir/samples", "docs-java": "$sourceDir/examples/src/main/java", "chomp": "default headers packages", "toc": "left", diff --git a/docs/src/docs/asciidoc/configuration-model.adoc b/docs/src/docs/asciidoc/configuration-model.adoc index 388c0203f..1afa75d28 100644 --- a/docs/src/docs/asciidoc/configuration-model.adoc +++ b/docs/src/docs/asciidoc/configuration-model.adoc @@ -14,6 +14,8 @@ The OAuth2 authorization server `SecurityFilterChain` `@Bean` is configured with the following default protocol endpoints: * xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[OAuth2 Authorization endpoint] +* xref:protocol-endpoints.adoc#oauth2-device-authorization-endpoint[OAuth2 Device Authorization Endpoint] +* xref:protocol-endpoints.adoc#oauth2-device-verification-endpoint[OAuth2 Device Verification Endpoint] * xref:protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint] * xref:protocol-endpoints.adoc#oauth2-token-introspection-endpoint[OAuth2 Token Introspection endpoint] * xref:protocol-endpoints.adoc#oauth2-token-revocation-endpoint[OAuth2 Token Revocation endpoint] @@ -68,6 +70,7 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h In addition to the default protocol endpoints, the OAuth2 authorization server `SecurityFilterChain` `@Bean` is configured with the following OpenID Connect 1.0 protocol endpoints: * xref:protocol-endpoints.adoc#oidc-provider-configuration-endpoint[OpenID Connect 1.0 Provider Configuration endpoint] +* xref:protocol-endpoints.adoc#oidc-logout-endpoint[OpenID Connect 1.0 Logout endpoint] * xref:protocol-endpoints.adoc#oidc-user-info-endpoint[OpenID Connect 1.0 UserInfo endpoint] [NOTE] @@ -93,7 +96,7 @@ The main intent of `OAuth2AuthorizationServerConfiguration` is to provide a conv `OAuth2AuthorizationServerConfigurer` provides the ability to fully customize the security configuration for an OAuth2 authorization server. It lets you specify the core components to use - for example, xref:core-model-components.adoc#registered-client-repository[`RegisteredClientRepository`], xref:core-model-components.adoc#oauth2-authorization-service[`OAuth2AuthorizationService`], xref:core-model-components.adoc#oauth2-token-generator[`OAuth2TokenGenerator`], and others. -Furthermore, it lets you customize the request processing logic for the protocol endpoints – for example, xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[authorization endpoint], xref:protocol-endpoints.adoc#oauth2-token-endpoint[token endpoint], xref:protocol-endpoints.adoc#oauth2-token-introspection-endpoint[token introspection endpoint], and others. +Furthermore, it lets you customize the request processing logic for the protocol endpoints – for example, xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[authorization endpoint], xref:protocol-endpoints.adoc#oauth2-device-authorization-endpoint[device authorization endpoint], xref:protocol-endpoints.adoc#oauth2-device-verification-endpoint[device verification endpoint], xref:protocol-endpoints.adoc#oauth2-token-endpoint[token endpoint], xref:protocol-endpoints.adoc#oauth2-token-introspection-endpoint[token introspection endpoint], and others. `OAuth2AuthorizationServerConfigurer` provides the following configuration options: @@ -113,14 +116,17 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h .tokenGenerator(tokenGenerator) <5> .clientAuthentication(clientAuthentication -> { }) <6> .authorizationEndpoint(authorizationEndpoint -> { }) <7> - .tokenEndpoint(tokenEndpoint -> { }) <8> - .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint -> { }) <9> - .tokenRevocationEndpoint(tokenRevocationEndpoint -> { }) <10> - .authorizationServerMetadataEndpoint(authorizationServerMetadataEndpoint -> { }) <11> + .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint -> { }) <8> + .deviceVerificationEndpoint(deviceVerificationEndpoint -> { }) <9> + .tokenEndpoint(tokenEndpoint -> { }) <10> + .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint -> { }) <11> + .tokenRevocationEndpoint(tokenRevocationEndpoint -> { }) <12> + .authorizationServerMetadataEndpoint(authorizationServerMetadataEndpoint -> { }) <13> .oidc(oidc -> oidc - .providerConfigurationEndpoint(providerConfigurationEndpoint -> { }) <12> - .userInfoEndpoint(userInfoEndpoint -> { }) <13> - .clientRegistrationEndpoint(clientRegistrationEndpoint -> { }) <14> + .providerConfigurationEndpoint(providerConfigurationEndpoint -> { }) <14> + .logoutEndpoint(logoutEndpoint -> { }) <15> + .userInfoEndpoint(userInfoEndpoint -> { }) <16> + .clientRegistrationEndpoint(clientRegistrationEndpoint -> { }) <17> ); return http.build(); @@ -133,13 +139,16 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h <5> `tokenGenerator()`: The xref:core-model-components.adoc#oauth2-token-generator[`OAuth2TokenGenerator`] for generating tokens supported by the OAuth2 authorization server. <6> `clientAuthentication()`: The configurer for <>. <7> `authorizationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[OAuth2 Authorization endpoint]. -<8> `tokenEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint]. -<9> `tokenIntrospectionEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-token-introspection-endpoint[OAuth2 Token Introspection endpoint]. -<10> `tokenRevocationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-token-revocation-endpoint[OAuth2 Token Revocation endpoint]. -<11> `authorizationServerMetadataEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-authorization-server-metadata-endpoint[OAuth2 Authorization Server Metadata endpoint]. -<12> `providerConfigurationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-provider-configuration-endpoint[OpenID Connect 1.0 Provider Configuration endpoint]. -<13> `userInfoEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-user-info-endpoint[OpenID Connect 1.0 UserInfo endpoint]. -<14> `clientRegistrationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[OpenID Connect 1.0 Client Registration endpoint]. +<8> `deviceAuthorizationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-device-authorization-endpoint[OAuth2 Device Authorization endpoint]. +<9> `deviceVerificationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-device-verification-endpoint[OAuth2 Device Verification endpoint]. +<10> `tokenEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint]. +<11> `tokenIntrospectionEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-token-introspection-endpoint[OAuth2 Token Introspection endpoint]. +<12> `tokenRevocationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-token-revocation-endpoint[OAuth2 Token Revocation endpoint]. +<13> `authorizationServerMetadataEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oauth2-authorization-server-metadata-endpoint[OAuth2 Authorization Server Metadata endpoint]. +<14> `providerConfigurationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-provider-configuration-endpoint[OpenID Connect 1.0 Provider Configuration endpoint]. +<15> `logoutEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-logout-endpoint[OpenID Connect 1.0 Logout endpoint]. +<16> `userInfoEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-user-info-endpoint[OpenID Connect 1.0 UserInfo endpoint]. +<17> `clientRegistrationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[OpenID Connect 1.0 Client Registration endpoint]. [[configuring-authorization-server-settings]] == Configuring Authorization Server Settings @@ -157,10 +166,13 @@ public final class AuthorizationServerSettings extends AbstractSettings { public static Builder builder() { return new Builder() .authorizationEndpoint("/oauth2/authorize") + .deviceAuthorizationEndpoint("/oauth2/device_authorization") + .deviceVerificationEndpoint("/oauth2/device_verification") .tokenEndpoint("/oauth2/token") .tokenIntrospectionEndpoint("/oauth2/introspect") .tokenRevocationEndpoint("/oauth2/revoke") .jwkSetEndpoint("/oauth2/jwks") + .oidcLogoutEndpoint("/connect/logout") .oidcUserInfoEndpoint("/userinfo") .oidcClientRegistrationEndpoint("/connect/register"); } @@ -185,10 +197,13 @@ public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .issuer("https://example.com") .authorizationEndpoint("/oauth2/v1/authorize") + .deviceAuthorizationEndpoint("/oauth2/v1/device_authorization") + .deviceVerificationEndpoint("/oauth2/v1/device_verification") .tokenEndpoint("/oauth2/v1/token") .tokenIntrospectionEndpoint("/oauth2/v1/introspect") .tokenRevocationEndpoint("/oauth2/v1/revoke") .jwkSetEndpoint("/oauth2/v1/jwks") + .oidcLogoutEndpoint("/connect/v1/logout") .oidcUserInfoEndpoint("/connect/v1/userinfo") .oidcClientRegistrationEndpoint("/connect/v1/register") .build(); diff --git a/docs/src/docs/asciidoc/core-model-components.adoc b/docs/src/docs/asciidoc/core-model-components.adoc index d3487d150..f42445038 100644 --- a/docs/src/docs/asciidoc/core-model-components.adoc +++ b/docs/src/docs/asciidoc/core-model-components.adoc @@ -69,9 +69,10 @@ public class RegisteredClient implements Serializable { private Set clientAuthenticationMethods; <7> private Set authorizationGrantTypes; <8> private Set redirectUris; <9> - private Set scopes; <10> - private ClientSettings clientSettings; <11> - private TokenSettings tokenSettings; <12> + private Set postLogoutRedirectUris; <10> + private Set scopes; <11> + private ClientSettings clientSettings; <12> + private TokenSettings tokenSettings; <13> ... @@ -84,11 +85,12 @@ public class RegisteredClient implements Serializable { <5> `clientSecretExpiresAt`: The time at which the client secret expires. <6> `clientName`: A descriptive name used for the client. The name may be used in certain scenarios, such as when displaying the client name in the consent page. <7> `clientAuthenticationMethods`: The authentication method(s) that the client may use. The supported values are `client_secret_basic`, `client_secret_post`, https://datatracker.ietf.org/doc/html/rfc7523[`private_key_jwt`], `client_secret_jwt`, and `none` https://datatracker.ietf.org/doc/html/rfc7636[(public clients)]. -<8> `authorizationGrantTypes`: The https://datatracker.ietf.org/doc/html/rfc6749#section-1.3[authorization grant type(s)] that the client can use. The supported values are `authorization_code`, `client_credentials`, and `refresh_token`. +<8> `authorizationGrantTypes`: The https://datatracker.ietf.org/doc/html/rfc6749#section-1.3[authorization grant type(s)] that the client can use. The supported values are `authorization_code`, `client_credentials`, `refresh_token`, and `urn:ietf:params:oauth:grant-type:device_code`. <9> `redirectUris`: The registered https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2[redirect URI(s)] that the client may use in redirect-based flows – for example, `authorization_code` grant. -<10> `scopes`: The scope(s) that the client is allowed to request. -<11> `clientSettings`: The custom settings for the client – for example, require https://datatracker.ietf.org/doc/html/rfc7636[PKCE], require authorization consent, and others. -<12> `tokenSettings`: The custom settings for the OAuth2 tokens issued to the client – for example, access/refresh token time-to-live, reuse refresh tokens, and others. +<10> `postLogoutRedirectUris`: The post logout redirect URI(s) that the client may use for logout. +<11> `scopes`: The scope(s) that the client is allowed to request. +<12> `clientSettings`: The custom settings for the client – for example, require https://datatracker.ietf.org/doc/html/rfc7636[PKCE], require authorization consent, and others. +<13> `tokenSettings`: The custom settings for the OAuth2 tokens issued to the client – for example, access/refresh token time-to-live, reuse refresh tokens, and others. [[registered-client-repository]] == RegisteredClientRepository @@ -452,7 +454,7 @@ public OAuth2TokenCustomizer accessTokenCustomizer() { If the `OAuth2TokenGenerator` is not provided as a `@Bean` or is not configured through the `OAuth2AuthorizationServerConfigurer`, an `OAuth2TokenCustomizer` `@Bean` will automatically be configured with an `OAuth2AccessTokenGenerator`. An `OAuth2TokenCustomizer` declared with a generic type of `JwtEncodingContext` (`implements OAuth2TokenContext`) provides the ability to customize the headers and claims of a `Jwt`. -`JwtEncodingContext.getHeaders()` provides access to the `JwsHeader.Builder`, allowing the ability to add, replace, and remove headers. +`JwtEncodingContext.getJwsHeader()` provides access to the `JwsHeader.Builder`, allowing the ability to add, replace, and remove headers. `JwtEncodingContext.getClaims()` provides access to the `JwtClaimsSet.Builder`, allowing the ability to add, replace, and remove claims. The following example shows how to implement an `OAuth2TokenCustomizer` and configure it with a `JwtGenerator`: @@ -473,7 +475,7 @@ public OAuth2TokenGenerator tokenGenerator() { @Bean public OAuth2TokenCustomizer jwtCustomizer() { return context -> { - JwsHeader.Builder headers = context.getHeaders(); + JwsHeader.Builder headers = context.getJwsHeader(); JwtClaimsSet.Builder claims = context.getClaims(); if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) { // Customize headers/claims for access_token @@ -491,3 +493,34 @@ If the `OAuth2TokenGenerator` is not provided as a `@Bean` or is not configured [TIP] For an example showing how you can xref:guides/how-to-userinfo.adoc#customize-id-token[customize the ID token], see the guide xref:guides/how-to-userinfo.adoc#how-to-userinfo[How-to: Customize the OpenID Connect 1.0 UserInfo response]. + +[[session-registry]] +== SessionRegistry + +If OpenID Connect 1.0 is enabled, a `SessionRegistry` instance is used to track authenticated sessions. +The `SessionRegistry` is used by the default implementation of `SessionAuthenticationStrategy` associated to the xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[OAuth2 Authorization Endpoint] for registering new authenticated sessions. + +[NOTE] +If a `SessionRegistry` `@Bean` is not registered, the default implementation `SessionRegistryImpl` will be used. + +[IMPORTANT] +If a `SessionRegistry` `@Bean` is registered and is an instance of `SessionRegistryImpl`, a `HttpSessionEventPublisher` `@Bean` *SHOULD* also be registered as it's responsible for notifying `SessionRegistryImpl` of session lifecycle events, for example, `SessionDestroyedEvent`, to provide the ability to remove the `SessionInformation` instance. + +When a logout is requested by an End-User, the xref:protocol-endpoints.adoc#oidc-logout-endpoint[OpenID Connect 1.0 Logout Endpoint] uses the `SessionRegistry` to lookup the `SessionInformation` associated to the authenticated End-User to perform the logout. + +If Spring Security's {spring-security-reference-base-url}/servlet/authentication/session-management.html#ns-concurrent-sessions[Concurrent Session Control] feature is being used, it is *RECOMMENDED* to register a `SessionRegistry` `@Bean` to ensure it's shared between Spring Security's Concurrent Session Control and Spring Authorization Server's Logout feature. + +The following example shows how to register a `SessionRegistry` `@Bean` and `HttpSessionEventPublisher` `@Bean` (required by `SessionRegistryImpl`): + +[source,java] +---- +@Bean +public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); +} + +@Bean +public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); +} +---- diff --git a/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle b/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle index ec816eeca..a64e3a13e 100644 --- a/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle +++ b/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle @@ -8,18 +8,20 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/milestone" } } dependencies { - implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.0-RC2") + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.1.4")) { + exclude group: "org.springframework.security", module: "spring-security-oauth2-authorization-server" + } + implementation platform("org.springframework.security:spring-security-bom:6.1.0") implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation "org.springframework.boot:spring-boot-starter-security" implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server" implementation "org.springframework.boot:spring-boot-starter-data-jpa" - implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" implementation project(":spring-security-oauth2-authorization-server") runtimeOnly "com.h2database:h2" testImplementation "org.springframework.boot:spring-boot-starter-test" diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationConverter.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationConverter.java new file mode 100644 index 000000000..5c128c34a --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationConverter.java @@ -0,0 +1,83 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.extgrant; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +public class CustomCodeGrantAuthenticationConverter implements AuthenticationConverter { + + @Nullable + @Override + public Authentication convert(HttpServletRequest request) { + // grant_type (REQUIRED) + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + if (!"urn:ietf:params:oauth:grant-type:custom_code".equals(grantType)) { // <1> + return null; + } + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + MultiValueMap parameters = getParameters(request); + + // code (REQUIRED) + String code = parameters.getFirst(OAuth2ParameterNames.CODE); // <2> + if (!StringUtils.hasText(code) || + parameters.get(OAuth2ParameterNames.CODE).size() != 1) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && + !key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.CODE)) { + additionalParameters.put(key, value.get(0)); + } + }); + + return new CustomCodeGrantAuthenticationToken(code, clientPrincipal, additionalParameters); // <3> + } + + // @fold:on + private static MultiValueMap getParameters(HttpServletRequest request) { + Map parameterMap = request.getParameterMap(); + MultiValueMap parameters = new LinkedMultiValueMap<>(parameterMap.size()); + parameterMap.forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + parameters.add(key, value); + } + } + }); + return parameters; + } + // @fold:off + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationProvider.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationProvider.java new file mode 100644 index 000000000..b70e07f6c --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationProvider.java @@ -0,0 +1,129 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.extgrant; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; + +public class CustomCodeGrantAuthenticationProvider implements AuthenticationProvider { + // @fold:on + private final OAuth2AuthorizationService authorizationService; + private final OAuth2TokenGenerator tokenGenerator; + + public CustomCodeGrantAuthenticationProvider(OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(tokenGenerator, "tokenGenerator cannot be null"); + this.authorizationService = authorizationService; + this.tokenGenerator = tokenGenerator; + } + // @fold:off + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + CustomCodeGrantAuthenticationToken customCodeGrantAuthentication = + (CustomCodeGrantAuthenticationToken) authentication; + + // Ensure the client is authenticated + OAuth2ClientAuthenticationToken clientPrincipal = + getAuthenticatedClientElseThrowInvalidClient(customCodeGrantAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + // Ensure the client is configured to use this authorization grant type + if (!registeredClient.getAuthorizationGrantTypes().contains(customCodeGrantAuthentication.getGrantType())) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); + } + + // TODO Validate the code parameter + + // Generate the access token + OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(clientPrincipal) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .tokenType(OAuth2TokenType.ACCESS_TOKEN) + .authorizationGrantType(customCodeGrantAuthentication.getGrantType()) + .authorizationGrant(customCodeGrantAuthentication) + .build(); + + OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext); + if (generatedAccessToken == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the access token.", null); + throw new OAuth2AuthenticationException(error); + } + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(), + generatedAccessToken.getExpiresAt(), null); + + // Initialize the OAuth2Authorization + OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(clientPrincipal.getName()) + .authorizationGrantType(customCodeGrantAuthentication.getGrantType()); + if (generatedAccessToken instanceof ClaimAccessor) { + authorizationBuilder.token(accessToken, (metadata) -> + metadata.put( + OAuth2Authorization.Token.CLAIMS_METADATA_NAME, + ((ClaimAccessor) generatedAccessToken).getClaims()) + ); + } else { + authorizationBuilder.accessToken(accessToken); + } + OAuth2Authorization authorization = authorizationBuilder.build(); + + // Save the OAuth2Authorization + this.authorizationService.save(authorization); + + return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken); + } + + @Override + public boolean supports(Class authentication) { + return CustomCodeGrantAuthenticationToken.class.isAssignableFrom(authentication); + } + + // @fold:on + private static OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient(Authentication authentication) { + OAuth2ClientAuthenticationToken clientPrincipal = null; + if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) { + clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal(); + } + if (clientPrincipal != null && clientPrincipal.isAuthenticated()) { + return clientPrincipal; + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); + } + // @fold:off + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationToken.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationToken.java new file mode 100644 index 000000000..c70673154 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationToken.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.extgrant; + +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; +import org.springframework.util.Assert; + +public class CustomCodeGrantAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + private final String code; + + public CustomCodeGrantAuthenticationToken(String code, Authentication clientPrincipal, + @Nullable Map additionalParameters) { + super(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:custom_code"), + clientPrincipal, additionalParameters); + Assert.hasText(code, "code cannot be empty"); + this.code = code; + } + + public String getCode() { + return this.code; + } + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/SecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/SecurityConfig.java new file mode 100644 index 000000000..4effaedb2 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/SecurityConfig.java @@ -0,0 +1,116 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.extgrant; + +import java.util.UUID; + +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator; +import org.springframework.security.oauth2.server.authorization.token.JwtGenerator; +import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator; +import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.RequestMatcher; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + // @formatter:off + @Bean + SecurityFilterChain authorizationServerSecurityFilterChain( + HttpSecurity http, + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) throws Exception { + + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer(); + + authorizationServerConfigurer + .tokenEndpoint(tokenEndpoint -> + tokenEndpoint + .accessTokenRequestConverter( // <1> + new CustomCodeGrantAuthenticationConverter()) + .authenticationProvider( // <2> + new CustomCodeGrantAuthenticationProvider( + authorizationService, tokenGenerator))); + + // @fold:on + RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher(); + + http + .securityMatcher(endpointsMatcher) + .authorizeHttpRequests(authorize -> + authorize + .anyRequest().authenticated() + ) + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .apply(authorizationServerConfigurer); + // @fold:off + + return http.build(); + } + // @formatter:on + + // @fold:on + // @formatter:off + @Bean + RegisteredClientRepository registeredClientRepository() { + RegisteredClient messagingClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("messaging-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:custom_code")) + .scope("message.read") + .scope("message.write") + .build(); + + return new InMemoryRegisteredClientRepository(messagingClient); + } + // @formatter:on + + @Bean + OAuth2AuthorizationService authorizationService() { + return new InMemoryOAuth2AuthorizationService(); + } + + @Bean + OAuth2TokenGenerator tokenGenerator(JWKSource jwkSource) { + JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource)); + OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator(); + OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator(); + return new DelegatingOAuth2TokenGenerator( + jwtGenerator, accessTokenGenerator, refreshTokenGenerator); + } + // @fold:off + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java index 3880f4a49..0b7b65caf 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java @@ -30,9 +30,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -50,8 +51,10 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; @Configuration +@EnableWebSecurity public class SecurityConfig { @Bean // <1> @@ -66,11 +69,14 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h // Redirect to the login page when not authenticated from the // authorization endpoint .exceptionHandling((exceptions) -> exceptions - .authenticationEntryPoint( - new LoginUrlAuthenticationEntryPoint("/login")) + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) ) // Accept access tokens for User Info and/or Client Registration - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + .oauth2ResourceServer((resourceServer) -> resourceServer + .jwt(Customizer.withDefaults())); // @formatter:on return http.build(); @@ -109,24 +115,21 @@ public UserDetailsService userDetailsService() { @Bean // <4> public RegisteredClientRepository registeredClientRepository() { // @formatter:off - RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) - .clientId("messaging-client") + RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("oidc-client") .clientSecret("{noop}secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) - .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") - .redirectUri("http://127.0.0.1:8080/authorized") + .redirectUri("http://127.0.0.1:8080/login/oauth2/code/oidc-client") + .postLogoutRedirectUri("http://127.0.0.1:8080/") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) - .scope("message.read") - .scope("message.write") .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); // @formatter:on - return new InMemoryRegisteredClientRepository(registeredClient); + return new InMemoryRegisteredClientRepository(oidcClient); } @Bean // <5> diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/application.yml b/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/application.yml new file mode 100644 index 000000000..a8b74a70a --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/application.yml @@ -0,0 +1,32 @@ +server: + port: 9000 + +logging: + level: + org.springframework.security: trace + +spring: + security: + user: + name: user + password: password + oauth2: + authorizationserver: + client: + oidc-client: + registration: + client-id: "oidc-client" + client-secret: "{noop}secret" + client-authentication-methods: + - "client_secret_basic" + authorization-grant-types: + - "authorization_code" + - "refresh_token" + redirect-uris: + - "http://127.0.0.1:8080/login/oauth2/code/oidc-client" + post-logout-redirect-uris: + - "http://127.0.0.1:8080/" + scopes: + - "openid" + - "profile" + require-authorization-consent: true diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java index 95a29e003..356e2eaf2 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,6 +70,20 @@ public class Authorization { @Column(length = 2000) private String oidcIdTokenClaims; + @Column(length = 4000) + private String userCodeValue; + private Instant userCodeIssuedAt; + private Instant userCodeExpiresAt; + @Column(length = 2000) + private String userCodeMetadata; + + @Column(length = 4000) + private String deviceCodeValue; + private Instant deviceCodeIssuedAt; + private Instant deviceCodeExpiresAt; + @Column(length = 2000) + private String deviceCodeMetadata; + // @fold:on public String getId() { return id; @@ -278,5 +292,69 @@ public String getOidcIdTokenClaims() { public void setOidcIdTokenClaims(String idTokenClaims) { this.oidcIdTokenClaims = idTokenClaims; } + + public String getUserCodeValue() { + return this.userCodeValue; + } + + public void setUserCodeValue(String userCodeValue) { + this.userCodeValue = userCodeValue; + } + + public Instant getUserCodeIssuedAt() { + return this.userCodeIssuedAt; + } + + public void setUserCodeIssuedAt(Instant userCodeIssuedAt) { + this.userCodeIssuedAt = userCodeIssuedAt; + } + + public Instant getUserCodeExpiresAt() { + return this.userCodeExpiresAt; + } + + public void setUserCodeExpiresAt(Instant userCodeExpiresAt) { + this.userCodeExpiresAt = userCodeExpiresAt; + } + + public String getUserCodeMetadata() { + return this.userCodeMetadata; + } + + public void setUserCodeMetadata(String userCodeMetadata) { + this.userCodeMetadata = userCodeMetadata; + } + + public String getDeviceCodeValue() { + return this.deviceCodeValue; + } + + public void setDeviceCodeValue(String deviceCodeValue) { + this.deviceCodeValue = deviceCodeValue; + } + + public Instant getDeviceCodeIssuedAt() { + return this.deviceCodeIssuedAt; + } + + public void setDeviceCodeIssuedAt(Instant deviceCodeIssuedAt) { + this.deviceCodeIssuedAt = deviceCodeIssuedAt; + } + + public Instant getDeviceCodeExpiresAt() { + return this.deviceCodeExpiresAt; + } + + public void setDeviceCodeExpiresAt(Instant deviceCodeExpiresAt) { + this.deviceCodeExpiresAt = deviceCodeExpiresAt; + } + + public String getDeviceCodeMetadata() { + return this.deviceCodeMetadata; + } + + public void setDeviceCodeMetadata(String deviceCodeMetadata) { + this.deviceCodeMetadata = deviceCodeMetadata; + } // @fold:off } diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/client/Client.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/client/Client.java index b4cfb529a..d8885c876 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/client/Client.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/client/Client.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,6 +39,8 @@ public class Client { @Column(length = 1000) private String redirectUris; @Column(length = 1000) + private String postLogoutRedirectUris; + @Column(length = 1000) private String scopes; @Column(length = 2000) private String clientSettings; @@ -118,6 +120,14 @@ public void setRedirectUris(String redirectUris) { this.redirectUris = redirectUris; } + public String getPostLogoutRedirectUris() { + return this.postLogoutRedirectUris; + } + + public void setPostLogoutRedirectUris(String postLogoutRedirectUris) { + this.postLogoutRedirectUris = postLogoutRedirectUris; + } + public String getScopes() { return scopes; } diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java index d7dbd7a33..11b08ab06 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,10 +30,16 @@ public interface AuthorizationRepository extends JpaRepository findByAuthorizationCodeValue(String authorizationCode); Optional findByAccessTokenValue(String accessToken); Optional findByRefreshTokenValue(String refreshToken); + Optional findByOidcIdTokenValue(String idToken); + Optional findByUserCodeValue(String userCode); + Optional findByDeviceCodeValue(String deviceCode); @Query("select a from Authorization a where a.state = :token" + " or a.authorizationCodeValue = :token" + " or a.accessTokenValue = :token" + - " or a.refreshTokenValue = :token" + " or a.refreshTokenValue = :token" + + " or a.oidcIdTokenValue = :token" + + " or a.userCodeValue = :token" + + " or a.deviceCodeValue = :token" ) - Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValue(@Param("token") String token); + Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue(@Param("token") String token); } diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java index 14da3c4df..508002c44 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,10 +31,13 @@ import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; @@ -88,7 +91,7 @@ public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) Optional result; if (tokenType == null) { - result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValue(token); + result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue(token); } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { result = this.authorizationRepository.findByState(token); } else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) { @@ -97,6 +100,12 @@ public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) result = this.authorizationRepository.findByAccessTokenValue(token); } else if (OAuth2ParameterNames.REFRESH_TOKEN.equals(tokenType.getValue())) { result = this.authorizationRepository.findByRefreshTokenValue(token); + } else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) { + result = this.authorizationRepository.findByOidcIdTokenValue(token); + } else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) { + result = this.authorizationRepository.findByUserCodeValue(token); + } else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) { + result = this.authorizationRepository.findByDeviceCodeValue(token); } else { result = Optional.empty(); } @@ -156,6 +165,22 @@ private OAuth2Authorization toObject(Authorization entity) { builder.token(idToken, metadata -> metadata.putAll(parseMap(entity.getOidcIdTokenMetadata()))); } + if (entity.getUserCodeValue() != null) { + OAuth2UserCode userCode = new OAuth2UserCode( + entity.getUserCodeValue(), + entity.getUserCodeIssuedAt(), + entity.getUserCodeExpiresAt()); + builder.token(userCode, metadata -> metadata.putAll(parseMap(entity.getUserCodeMetadata()))); + } + + if (entity.getDeviceCodeValue() != null) { + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode( + entity.getDeviceCodeValue(), + entity.getDeviceCodeIssuedAt(), + entity.getDeviceCodeExpiresAt()); + builder.token(deviceCode, metadata -> metadata.putAll(parseMap(entity.getDeviceCodeMetadata()))); + } + return builder.build(); } @@ -215,6 +240,26 @@ private Authorization toEntity(OAuth2Authorization authorization) { entity.setOidcIdTokenClaims(writeMap(oidcIdToken.getClaims())); } + OAuth2Authorization.Token userCode = + authorization.getToken(OAuth2UserCode.class); + setTokenValues( + userCode, + entity::setUserCodeValue, + entity::setUserCodeIssuedAt, + entity::setUserCodeExpiresAt, + entity::setUserCodeMetadata + ); + + OAuth2Authorization.Token deviceCode = + authorization.getToken(OAuth2DeviceCode.class); + setTokenValues( + deviceCode, + entity::setDeviceCodeValue, + entity::setDeviceCodeIssuedAt, + entity::setDeviceCodeExpiresAt, + entity::setDeviceCodeMetadata + ); + return entity; } @@ -257,6 +302,8 @@ private static AuthorizationGrantType resolveAuthorizationGrantType(String autho return AuthorizationGrantType.CLIENT_CREDENTIALS; } else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) { return AuthorizationGrantType.REFRESH_TOKEN; + } else if (AuthorizationGrantType.DEVICE_CODE.getValue().equals(authorizationGrantType)) { + return AuthorizationGrantType.DEVICE_CODE; } return new AuthorizationGrantType(authorizationGrantType); // Custom authorization grant type } diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/client/JpaRegisteredClientRepository.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/client/JpaRegisteredClientRepository.java index 1db232aaa..20f14e915 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/client/JpaRegisteredClientRepository.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/client/JpaRegisteredClientRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,6 +78,8 @@ private RegisteredClient toObject(Client client) { client.getAuthorizationGrantTypes()); Set redirectUris = StringUtils.commaDelimitedListToSet( client.getRedirectUris()); + Set postLogoutRedirectUris = StringUtils.commaDelimitedListToSet( + client.getPostLogoutRedirectUris()); Set clientScopes = StringUtils.commaDelimitedListToSet( client.getScopes()); @@ -94,6 +96,7 @@ private RegisteredClient toObject(Client client) { authorizationGrantTypes.forEach(grantType -> grantTypes.add(resolveAuthorizationGrantType(grantType)))) .redirectUris((uris) -> uris.addAll(redirectUris)) + .postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris)) .scopes((scopes) -> scopes.addAll(clientScopes)); Map clientSettingsMap = parseMap(client.getClientSettings()); @@ -124,6 +127,7 @@ private Client toEntity(RegisteredClient registeredClient) { entity.setClientAuthenticationMethods(StringUtils.collectionToCommaDelimitedString(clientAuthenticationMethods)); entity.setAuthorizationGrantTypes(StringUtils.collectionToCommaDelimitedString(authorizationGrantTypes)); entity.setRedirectUris(StringUtils.collectionToCommaDelimitedString(registeredClient.getRedirectUris())); + entity.setPostLogoutRedirectUris(StringUtils.collectionToCommaDelimitedString(registeredClient.getPostLogoutRedirectUris())); entity.setScopes(StringUtils.collectionToCommaDelimitedString(registeredClient.getScopes())); entity.setClientSettings(writeMap(registeredClient.getClientSettings().getSettings())); entity.setTokenSettings(writeMap(registeredClient.getTokenSettings().getSettings())); diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/ClientConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/ClientConfig.java new file mode 100644 index 000000000..29ec9d8a6 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/ClientConfig.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.pkce; + +import java.util.UUID; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; + +@Configuration +public class ClientConfig { + + // tag::client[] + @Bean + public RegisteredClientRepository registeredClientRepository() { + // @formatter:off + RegisteredClient publicClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("public-client") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("http://127.0.0.1:4200") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .requireProofKey(true) + .build() + ) + .build(); + // @formatter:on + + return new InMemoryRegisteredClientRepository(publicClient); + } + // end::client[] + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/SecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/SecurityConfig.java new file mode 100644 index 000000000..045ddc984 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/SecurityConfig.java @@ -0,0 +1,95 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.pkce; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) + throws Exception { + // @fold:on + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + // @formatter:off + http + // Redirect to the login page when not authenticated from the + // authorization endpoint + .exceptionHandling((exceptions) -> exceptions + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) + ) + // Accept access tokens for User Info and/or Client Registration + .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())); + // @formatter:on + + // @fold:off + return http.cors(Customizer.withDefaults()).build(); + } + + @Bean + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) + throws Exception { + // @fold:on + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + // Form login handles the redirect to the login page from the + // authorization server filter chain + .formLogin(Customizer.withDefaults()); + // @formatter:on + + // @fold:off + return http.cors(Customizer.withDefaults()).build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.addAllowedOrigin("http://127.0.0.1:4200"); + config.setAllowCredentials(true); + source.registerCorsConfiguration("/**", config); + return source; + } + +} \ No newline at end of file diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/application.yml b/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/application.yml new file mode 100644 index 000000000..4c6f8b579 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/application.yml @@ -0,0 +1,19 @@ +spring: + security: + oauth2: + authorizationserver: + client: + public-client: + registration: + client-id: "public-client" + client-authentication-methods: + - "none" + authorization-grant-types: + - "authorization_code" + redirect-uris: + - "http://127.0.0.1:4200" + scopes: + - "openid" + - "profile" + require-authorization-consent: true + require-proof-key: true diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/socialLogin/SecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/socialLogin/SecurityConfig.java new file mode 100644 index 000000000..7583ae0bb --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/socialLogin/SecurityConfig.java @@ -0,0 +1,76 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.socialLogin; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean // <1> + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) + throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + // @formatter:off + http + // Redirect to the OAuth 2.0 Login endpoint when not authenticated + // from the authorization endpoint + .exceptionHandling((exceptions) -> exceptions + .defaultAuthenticationEntryPointFor( // <2> + new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/my-client"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) + ) + // Accept access tokens for User Info and/or Client Registration + .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + @Bean // <3> + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) + throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + // OAuth2 Login handles the redirect to the OAuth 2.0 Login endpoint + // from the authorization server filter chain + .oauth2Login(Customizer.withDefaults()); // <4> + // @formatter:on + + return http.build(); + } + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/socialLogin/application.yml b/docs/src/docs/asciidoc/examples/src/main/java/sample/socialLogin/application.yml new file mode 100644 index 000000000..5e28adfc8 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/socialLogin/application.yml @@ -0,0 +1,23 @@ +okta: + base-url: ${OKTA_BASE_URL} + +spring: + security: + oauth2: + client: + registration: + my-client: + provider: okta + client-id: ${OKTA_CLIENT_ID} + client-secret: ${OKTA_CLIENT_SECRET} + scope: + - openid + - profile + - email + provider: + okta: + authorization-uri: ${okta.base-url}/oauth2/v1/authorize + token-uri: ${okta.base-url}/oauth2/v1/token + user-info-uri: ${okta.base-url}/oauth2/v1/userinfo + jwk-set-uri: ${okta.base-url}/oauth2/v1/keys + user-name-attribute: sub \ No newline at end of file diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java index 245994e9e..d8f6178fb 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java @@ -30,9 +30,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -50,8 +51,10 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; @Configuration(proxyBeanMethods = false) +@EnableWebSecurity public class EnableUserInfoSecurityConfig { @Bean // <1> @@ -62,9 +65,12 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 // @formatter:off http - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) // <2> + .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())) // <2> .exceptionHandling((exceptions) -> exceptions - .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) ); // @formatter:on diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java index c214f624d..35f3b3c73 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java @@ -31,9 +31,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -55,9 +56,11 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @Configuration(proxyBeanMethods = false) +@EnableWebSecurity public class JwtUserInfoMapperSecurityConfig { @Bean // <1> @@ -88,9 +91,14 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h .anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) // <4> + .oauth2ResourceServer(resourceServer -> resourceServer + .jwt(Customizer.withDefaults()) // <4> + ) .exceptionHandling((exceptions) -> exceptions - .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) ) .apply(authorizationServerConfigurer); // <5> // @formatter:on diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java index 7339946ef..534e2b7e7 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -85,6 +86,17 @@ public void addScope(String scope) { * @return The state parameter for submitting consent for authorization */ public String authorize(RegisteredClient registeredClient) throws Exception { + return authorize(registeredClient, null); + } + + /** + * Perform the authorization request and obtain a state parameter. + * + * @param registeredClient The registered client + * @param additionalParameters Additional parameters for the request + * @return The state parameter for submitting consent for authorization + */ + public String authorize(RegisteredClient registeredClient, MultiValueMap additionalParameters) throws Exception { MultiValueMap parameters = new LinkedMultiValueMap<>(); parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue()); parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); @@ -92,13 +104,18 @@ public String authorize(RegisteredClient registeredClient) throws Exception { parameters.set(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); parameters.set(OAuth2ParameterNames.STATE, "state"); + if (additionalParameters != null) { + parameters.addAll(additionalParameters); + } + // @formatter:off MvcResult mvcResult = this.mockMvc.perform(get("/oauth2/authorize") - .params(parameters) + .queryParams(parameters) .with(user(this.username).roles("USER"))) .andExpect(status().isOk()) .andExpect(header().string("content-type", containsString(MediaType.TEXT_HTML_VALUE))) .andReturn(); + // @formatter:on String responseHtml = mvcResult.getResponse().getContentAsString(); Matcher matcher = HIDDEN_STATE_INPUT_PATTERN.matcher(responseHtml); @@ -109,7 +126,7 @@ public String authorize(RegisteredClient registeredClient) throws Exception { * Submit consent for the authorization request and obtain an authorization code. * * @param registeredClient The registered client - * @param state The state paramter from the authorization request + * @param state The state parameter from the authorization request * @return An authorization code */ public String submitConsent(RegisteredClient registeredClient, String state) throws Exception { @@ -120,14 +137,16 @@ public String submitConsent(RegisteredClient registeredClient, String state) thr parameters.add(OAuth2ParameterNames.SCOPE, scope); } + // @formatter:off MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/authorize") .params(parameters) .with(user(this.username).roles("USER"))) .andExpect(status().is3xxRedirection()) .andReturn(); + // @formatter:on String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); assertThat(redirectedUrl).isNotNull(); - assertThat(redirectedUrl).matches("http://127.0.0.1:8080/authorized\\?code=.{15,}&state=state"); + assertThat(redirectedUrl).matches("\\S+\\?code=.{15,}&state=state"); String locationHeader = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8.name()); UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build(); @@ -143,29 +162,67 @@ public String submitConsent(RegisteredClient registeredClient, String state) thr * @return The token response */ public Map getTokenResponse(RegisteredClient registeredClient, String authorizationCode) throws Exception { + return getTokenResponse(registeredClient, authorizationCode, null); + } + + /** + * Exchange an authorization code for an access token. + * + * @param registeredClient The registered client + * @param authorizationCode The authorization code obtained from the authorization request + * @param additionalParameters Additional parameters for the request + * @return The token response + */ + public Map getTokenResponse(RegisteredClient registeredClient, String authorizationCode, MultiValueMap additionalParameters) throws Exception { MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); parameters.set(OAuth2ParameterNames.CODE, authorizationCode); parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next()); + if (additionalParameters != null) { + parameters.addAll(additionalParameters); + } - HttpHeaders basicAuth = new HttpHeaders(); - basicAuth.setBasicAuth(registeredClient.getClientId(), "secret"); + boolean publicClient = (registeredClient.getClientSecret() == null); + HttpHeaders headers = new HttpHeaders(); + if (!publicClient) { + headers.setBasicAuth(registeredClient.getClientId(), + registeredClient.getClientSecret().replace("{noop}", "")); + } + // @formatter:off MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/token") .params(parameters) - .headers(basicAuth)) + .headers(headers)) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE))) .andExpect(jsonPath("$.access_token").isNotEmpty()) .andExpect(jsonPath("$.token_type").isNotEmpty()) .andExpect(jsonPath("$.expires_in").isNotEmpty()) - .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(publicClient + ? jsonPath("$.refresh_token").doesNotExist() + : jsonPath("$.refresh_token").isNotEmpty() + ) .andExpect(jsonPath("$.scope").isNotEmpty()) .andExpect(jsonPath("$.id_token").isNotEmpty()) .andReturn(); + // @formatter:on ObjectMapper objectMapper = new ObjectMapper(); String responseJson = mvcResult.getResponse().getContentAsString(); return objectMapper.readValue(responseJson, TOKEN_RESPONSE_TYPE_REFERENCE); } + + public static MultiValueMap withCodeChallenge() { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(PkceParameterNames.CODE_CHALLENGE, "BqZZ8pTVLsiA3t3tDOys2flJTSH7LoL3Pp5ZqM_YOnE"); + parameters.set(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + return parameters; + } + + public static MultiValueMap withCodeVerifier() { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(PkceParameterNames.CODE_VERIFIER, "yZ6eB-lEB4BBhIzqoDPqXTTATC0Vkgov7qDF8ar2qT4"); + return parameters; + } } diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java new file mode 100644 index 000000000..3abe7dce1 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java @@ -0,0 +1,188 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Helper class that performs steps of the {@code urn:ietf:params:oauth:grant-type:device_code} + * flow using {@link MockMvc} for testing. + * + * @author Steve Riesenberg + */ +public class DeviceAuthorizationGrantFlow { + private static final Pattern HIDDEN_STATE_INPUT_PATTERN = Pattern.compile(".+.+"); + private static final TypeReference> JSON_RESPONSE_TYPE_REFERENCE = new TypeReference<>() { + }; + + private final MockMvc mockMvc; + + private String username = "user"; + + private Set scopes = new HashSet<>(); + + public DeviceAuthorizationGrantFlow(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + public void setUsername(String username) { + this.username = username; + } + + public void addScope(String scope) { + this.scopes.add(scope); + } + + /** + * Perform the device authorization request and obtain the response + * containing a user code and device code. + * + * @param registeredClient The registered client + * @return The device authorization response + */ + public Map authorize(RegisteredClient registeredClient) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + HttpHeaders basicAuth = new HttpHeaders(); + basicAuth.setBasicAuth(registeredClient.getClientId(), "secret"); + + MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/device_authorization") + .params(parameters) + .headers(basicAuth)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE))) + .andExpect(jsonPath("$.user_code").isNotEmpty()) + .andExpect(jsonPath("$.device_code").isNotEmpty()) + .andExpect(jsonPath("$.verification_uri").isNotEmpty()) + .andExpect(jsonPath("$.verification_uri_complete").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNotEmpty()) + .andReturn(); + + ObjectMapper objectMapper = new ObjectMapper(); + String responseJson = mvcResult.getResponse().getContentAsString(); + return objectMapper.readValue(responseJson, JSON_RESPONSE_TYPE_REFERENCE); + } + + /** + * Submit the user code and obtain a state parameter from the consent screen. + * + * @param userCode The user code from the device authorization request + * @return The state parameter for submitting consent for authorization + */ + public String submitCode(String userCode) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.USER_CODE, userCode); + + MvcResult mvcResult = this.mockMvc.perform(get("/oauth2/device_verification") + .queryParams(parameters) + .with(user(this.username).roles("USER"))) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE))) + .andReturn(); + String responseHtml = mvcResult.getResponse().getContentAsString(); + Matcher matcher = HIDDEN_STATE_INPUT_PATTERN.matcher(responseHtml); + + return matcher.matches() ? matcher.group(1) : null; + } + + /** + * Submit consent for the device authorization request. + * + * @param registeredClient The registered client + * @param state The state parameter from the consent screen + * @param userCode The user code from the device authorization request + */ + public void submitConsent(RegisteredClient registeredClient, String state, String userCode) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.STATE, state); + for (String scope : this.scopes) { + parameters.add(OAuth2ParameterNames.SCOPE, scope); + } + parameters.set(OAuth2ParameterNames.USER_CODE, userCode); + + MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/device_verification") + .params(parameters) + .with(user(this.username).roles("USER"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + assertThat(redirectedUrl).isNotNull(); + assertThat(redirectedUrl).isEqualTo("/?success"); + } + + /** + * Exchange a device code for an access token. + * + * @param registeredClient The registered client + * @param deviceCode The device code obtained from the device authorization request + * @return The token response + */ + public Map getTokenResponse(RegisteredClient registeredClient, String deviceCode) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue()); + parameters.set(OAuth2ParameterNames.DEVICE_CODE, deviceCode); + + HttpHeaders basicAuth = new HttpHeaders(); + basicAuth.setBasicAuth(registeredClient.getClientId(), "secret"); + + MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/token") + .params(parameters) + .headers(basicAuth)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE))) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNotEmpty()) + .andReturn(); + + ObjectMapper objectMapper = new ObjectMapper(); + String responseJson = mvcResult.getResponse().getContentAsString(); + return objectMapper.readValue(responseJson, JSON_RESPONSE_TYPE_REFERENCE); + } +} diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/extgrant/CustomCodeGrantTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/extgrant/CustomCodeGrantTests.java new file mode 100644 index 000000000..721611e2a --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/extgrant/CustomCodeGrantTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.extgrant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import sample.test.SpringTestContext; +import sample.test.SpringTestContextExtension; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.HttpHeaders; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringTestContextExtension.class) +public class CustomCodeGrantTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Autowired + private MockMvc mvc; + + @Test + public void requestWhenTokenRequestValidThenTokenResponse() throws Exception { + this.spring.register(AuthorizationServerConfig.class).autowire(); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client"); + + HttpHeaders headers = new HttpHeaders(); + headers.setBasicAuth(registeredClient.getClientId(), + registeredClient.getClientSecret().replace("{noop}", "")); + + // @formatter:off + this.mvc.perform(post("/oauth2/token") + .param(OAuth2ParameterNames.GRANT_TYPE, "urn:ietf:params:oauth:grant-type:custom_code") + .param(OAuth2ParameterNames.CODE, "7QR49T1W3") + .headers(headers)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()); + // @formatter:on + } + + @EnableWebSecurity + @EnableAutoConfiguration + @ComponentScan + static class AuthorizationServerConfig { + } + +} diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java index 22993e032..cf827c3ad 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import org.springframework.context.annotation.Import; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService; @@ -76,13 +77,13 @@ public void oidcLoginWhenGettingStartedConfigUsedThenSuccess() throws Exception assertThat(this.authorizationService).isInstanceOf(InMemoryOAuth2AuthorizationService.class); assertThat(this.authorizationConsentService).isInstanceOf(InMemoryOAuth2AuthorizationConsentService.class); - RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client"); + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("oidc-client"); assertThat(registeredClient).isNotNull(); AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc); authorizationCodeGrantFlow.setUsername("user"); - authorizationCodeGrantFlow.addScope("message.read"); - authorizationCodeGrantFlow.addScope("message.write"); + authorizationCodeGrantFlow.addScope(OidcScopes.OPENID); + authorizationCodeGrantFlow.addScope(OidcScopes.PROFILE); String state = authorizationCodeGrantFlow.authorize(registeredClient); assertThatAuthorization(state, OAuth2ParameterNames.STATE).isNotNull(); @@ -102,7 +103,8 @@ public void oidcLoginWhenGettingStartedConfigUsedThenSuccess() throws Exception assertThatAuthorization(refreshToken, null).isNotNull(); String idToken = (String) tokenResponse.get(OidcParameterNames.ID_TOKEN); - assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNull(); // id_token is not searchable + assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNotNull(); + assertThatAuthorization(idToken, null).isNotNull(); OAuth2Authorization authorization = findAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN); assertThat(authorization.getToken(idToken)).isNotNull(); diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java index 8e95fbb6d..50991c882 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import sample.AuthorizationCodeGrantFlow; +import sample.DeviceAuthorizationGrantFlow; import sample.jose.TestJwks; import sample.jpa.service.authorization.JpaOAuth2AuthorizationService; import sample.jpa.service.authorizationConsent.JpaOAuth2AuthorizationConsentService; @@ -38,10 +39,10 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -57,6 +58,7 @@ import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; import org.springframework.test.web.servlet.MockMvc; import org.springframework.util.StringUtils; @@ -117,7 +119,8 @@ public void oidcLoginWhenJpaCoreServicesAutowiredThenUsed() throws Exception { assertThatAuthorization(refreshToken, null).isNotNull(); String idToken = (String) tokenResponse.get(OidcParameterNames.ID_TOKEN); - assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNull(); // id_token is not searchable + assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNotNull(); + assertThatAuthorization(idToken, null).isNotNull(); OAuth2Authorization authorization = findAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN); assertThat(authorization.getToken(idToken)).isNotNull(); @@ -130,6 +133,53 @@ public void oidcLoginWhenJpaCoreServicesAutowiredThenUsed() throws Exception { StringUtils.delimitedListToStringArray(scopes, " ")); } + @Test + public void deviceAuthorizationWhenJpaCoreServicesAutowiredThenSuccess() throws Exception { + this.spring.register(AuthorizationServerConfig.class).autowire(); + assertThat(this.registeredClientRepository).isInstanceOf(JpaRegisteredClientRepository.class); + assertThat(this.authorizationService).isInstanceOf(JpaOAuth2AuthorizationService.class); + assertThat(this.authorizationConsentService).isInstanceOf(JpaOAuth2AuthorizationConsentService.class); + + RegisteredClient registeredClient = messagingClient(); + this.registeredClientRepository.save(registeredClient); + + DeviceAuthorizationGrantFlow deviceAuthorizationGrantFlow = new DeviceAuthorizationGrantFlow(this.mockMvc); + deviceAuthorizationGrantFlow.setUsername("user"); + deviceAuthorizationGrantFlow.addScope("message.read"); + deviceAuthorizationGrantFlow.addScope("message.write"); + + Map deviceAuthorizationResponse = deviceAuthorizationGrantFlow.authorize(registeredClient); + String userCode = (String) deviceAuthorizationResponse.get(OAuth2ParameterNames.USER_CODE); + assertThatAuthorization(userCode, OAuth2ParameterNames.USER_CODE).isNotNull(); + assertThatAuthorization(userCode, null).isNotNull(); + + String deviceCode = (String) deviceAuthorizationResponse.get(OAuth2ParameterNames.DEVICE_CODE); + assertThatAuthorization(deviceCode, OAuth2ParameterNames.DEVICE_CODE).isNotNull(); + assertThatAuthorization(deviceCode, null).isNotNull(); + + String state = deviceAuthorizationGrantFlow.submitCode(userCode); + assertThatAuthorization(state, OAuth2ParameterNames.STATE).isNotNull(); + assertThatAuthorization(state, null).isNotNull(); + + deviceAuthorizationGrantFlow.submitConsent(registeredClient, state, userCode); + + Map tokenResponse = deviceAuthorizationGrantFlow.getTokenResponse(registeredClient, deviceCode); + String accessToken = (String) tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN); + assertThatAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN).isNotNull(); + assertThatAuthorization(accessToken, null).isNotNull(); + + String refreshToken = (String) tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN); + assertThatAuthorization(refreshToken, OAuth2ParameterNames.REFRESH_TOKEN).isNotNull(); + assertThatAuthorization(refreshToken, null).isNotNull(); + + String scopes = (String) tokenResponse.get(OAuth2ParameterNames.SCOPE); + OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService.findById( + registeredClient.getId(), "user"); + assertThat(authorizationConsent).isNotNull(); + assertThat(authorizationConsent.getScopes()).containsExactlyInAnyOrder( + StringUtils.delimitedListToStringArray(scopes, " ")); + } + private ObjectAssert assertThatAuthorization(String token, String tokenType) { return assertThat(findAuthorization(token, tokenType)); } @@ -152,10 +202,15 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h // @formatter:off http - .exceptionHandling(exceptions -> - exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + .exceptionHandling((exceptions) -> exceptions + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) ) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + .oauth2ResourceServer((resourceServer) -> resourceServer + .jwt(Customizer.withDefaults()) + ); // @formatter:on return http.build(); } diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/pkce/PublicClientTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/pkce/PublicClientTests.java new file mode 100644 index 000000000..317ae92e3 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/pkce/PublicClientTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.pkce; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import sample.AuthorizationCodeGrantFlow; +import sample.test.SpringTestContext; +import sample.test.SpringTestContextExtension; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static sample.AuthorizationCodeGrantFlow.withCodeChallenge; +import static sample.AuthorizationCodeGrantFlow.withCodeVerifier; + +/** + * @author Steve Riesenberg + */ +@ExtendWith(SpringTestContextExtension.class) +public class PublicClientTests { + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Test + public void oidcLoginWhenPublicClientThenSuccess() throws Exception { + this.spring.register(AuthorizationServerConfig.class).autowire(); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("public-client"); + assertThat(registeredClient).isNotNull(); + + AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc); + authorizationCodeGrantFlow.setUsername("user"); + authorizationCodeGrantFlow.addScope(OidcScopes.OPENID); + authorizationCodeGrantFlow.addScope(OidcScopes.PROFILE); + + String state = authorizationCodeGrantFlow.authorize(registeredClient, withCodeChallenge()); + assertThat(state).isNotNull(); + + String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state); + assertThat(authorizationCode).isNotNull(); + + Map tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient, + authorizationCode, withCodeVerifier()); + assertThat(tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN)).isNotNull(); + // Note: Refresh tokens are not issued to public clients + assertThat(tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN)).isNull(); + assertThat(tokenResponse.get(OidcParameterNames.ID_TOKEN)).isNotNull(); + } + + @EnableWebSecurity + @EnableAutoConfiguration + @ComponentScan + static class AuthorizationServerConfig { + + } + +} diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java index c4af4b722..e9ccb1622 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,9 @@ public static RegisteredClient messagingClient() { .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .redirectUri("http://127.0.0.1:8080/authorized") + .postLogoutRedirectUri("http://127.0.0.1:8080/index") .scope(OidcScopes.OPENID) .scope("message.read") .scope("message.write") diff --git a/docs/src/docs/asciidoc/getting-help.adoc b/docs/src/docs/asciidoc/getting-help.adoc index 3e49831b3..c95e9575b 100644 --- a/docs/src/docs/asciidoc/getting-help.adoc +++ b/docs/src/docs/asciidoc/getting-help.adoc @@ -17,7 +17,7 @@ The following are some of the best ways to get help: * Learn the Spring Security basics that Spring Authorization Server builds on. If you are starting out with Spring Security, check the https://spring.io/projects/spring-security#learn[reference documentation] or try one of the https://github.com/spring-projects/spring-security-samples[samples]. * Read through xref:index.adoc[this documentation]. * Try one of our many https://github.com/spring-projects/spring-authorization-server/tree/main/samples[sample applications]. -* Ask a question on Stack Overflow with the https://stackoverflow.com/questions/tagged/spring-security[`spring-security`] tag. +* Ask a question on Stack Overflow with the https://stackoverflow.com/questions/tagged/spring-authorization-server[`spring-authorization-server`] tag. * Report bugs and enhancement requests on https://github.com/spring-projects/spring-authorization-server/issues[GitHub]. NOTE: Spring Authorization Server is open source, including the documentation. If you find problems with the docs or if you want to improve them, please https://github.com/spring-projects/spring-authorization-server[get involved]. diff --git a/docs/src/docs/asciidoc/getting-started.adoc b/docs/src/docs/asciidoc/getting-started.adoc index d7e003d7e..fe6b2609e 100644 --- a/docs/src/docs/asciidoc/getting-started.adoc +++ b/docs/src/docs/asciidoc/getting-started.adoc @@ -15,7 +15,28 @@ Spring Authorization Server can be used anywhere you already use https://docs.sp The easiest way to begin using Spring Authorization Server is by creating a https://spring.io/projects/spring-boot[Spring Boot]-based application. You can use https://start.spring.io[start.spring.io] to generate a basic project or use the https://github.com/spring-projects/spring-authorization-server/tree/main/samples/default-authorizationserver[default authorization server sample] as a guide. -Then add Spring Authorization Server as a dependency, as in the following example: +Then add Spring Boot's starter for Spring Authorization Server as a dependency: + +[[spring-boot-maven-dependency]] +.Maven +[source,xml,role="primary",subs="attributes,verbatim"] +---- + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + +---- + +[[spring-boot-gradle-dependency]] +.Gradle +[source,gradle,role="secondary",subs="attributes,verbatim"] +---- +implementation "org.springframework.boot:spring-boot-starter-oauth2-authorization-server" +---- + +TIP: See https://docs.spring.io/spring-boot/docs/current/reference/html/getting-started.html#getting-started.installing[Installing Spring Boot] for more information on using Spring Boot with Maven or Gradle. + +Alternatively, you can add Spring Authorization Server without Spring Boot using the following example: [[maven-dependency]] .Maven @@ -35,14 +56,26 @@ Then add Spring Authorization Server as a dependency, as in the following exampl implementation "org.springframework.security:spring-security-oauth2-authorization-server:{spring-authorization-server-version}" ---- -TIP: See https://docs.spring.io/spring-boot/docs/current/reference/html/getting-started.html#getting-started.installing[Installing Spring Boot] for more information on using Spring Boot with Maven or Gradle. - [[developing-your-first-application]] == Developing Your First Application -To get started, you need the minimum required components defined as a `@Bean` in a Spring `@Configuration`. These components can be defined as follows: +To get started, you need the minimum required components defined as a `@Bean`. When using the `spring-boot-starter-oauth2-authorization-server` dependency, define the following properties and Spring Boot will provide the necessary `@Bean` definitions for you: + +[[application-yml]] +.application.yml +[source,yaml] +---- +include::{docs-java}/sample/gettingStarted/application.yml[] +---- + +TIP: Beyond the Getting Started experience, most users will want to customize the default configuration. The <> demonstrates providing all of the necessary beans yourself. + +[[defining-required-components]] +== Defining Required Components + +If you want to customize the default configuration (regardless of whether you're using Spring Boot), you can define the minimum required components as a `@Bean` in a Spring `@Configuration`. -TIP: To skip the setup and run a working example, see the https://github.com/spring-projects/spring-authorization-server/tree/main/samples/default-authorizationserver[default authorization server sample]. +These components can be defined as follows: [[sample.gettingStarted]] include::code:SecurityConfig[] diff --git a/docs/src/docs/asciidoc/guides/how-to-ext-grant-type.adoc b/docs/src/docs/asciidoc/guides/how-to-ext-grant-type.adoc new file mode 100644 index 000000000..6ccc13753 --- /dev/null +++ b/docs/src/docs/asciidoc/guides/how-to-ext-grant-type.adoc @@ -0,0 +1,76 @@ +[[how-to-extension-grant-type]] += How-to: Implement an Extension Authorization Grant Type +:index-link: ../how-to.html +:docs-dir: .. +:examples-dir: {docs-dir}/examples + +This guide shows how to extend xref:{docs-dir}/index.adoc#top[Spring Authorization Server] with an https://datatracker.ietf.org/doc/html/rfc6749#section-4.5[extension authorization grant type]. +The purpose of this guide is to demonstrate how to implement an extension authorization grant type and configure it at the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint]. + +Extending Spring Authorization Server with a new authorization grant type requires implementing an `AuthenticationConverter` and `AuthenticationProvider`, and configuring both components at the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint]. +In addition to the component implementations, a unique absolute URI needs to be assigned for use with the `grant_type` parameter. + +* <> +* <> +* <> +* <> + +[[implement-authentication-converter]] +== Implement AuthenticationConverter + +Assuming the absolute URI for the `grant_type` parameter is `urn:ietf:params:oauth:grant-type:custom_code` and the `code` parameter represents the authorization grant, the following example shows a sample implementation of the `AuthenticationConverter`: + +.AuthenticationConverter +[source,java] +---- +include::{examples-dir}/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationConverter.java[] +---- + +TIP: Click on the "Expand folded text" icon in the code sample above to display the full example. + +<1> If the `grant_type` parameter is *not* `urn:ietf:params:oauth:grant-type:custom_code`, then return `null`, allowing another `AuthenticationConverter` to process the token request. +<2> The `code` parameter contains the authorization grant. +<3> Return an instance of `CustomCodeGrantAuthenticationToken`, which is processed by <>. + +[[implement-authentication-provider]] +== Implement AuthenticationProvider + +The `AuthenticationProvider` implementation is responsible for validating the authorization grant, and if valid and authorized, issues an access token. + +The following example shows a sample implementation of the `AuthenticationProvider`: + +.AuthenticationProvider +[source,java] +---- +include::{examples-dir}/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationProvider.java[] +---- + +NOTE: `CustomCodeGrantAuthenticationProvider` processes `CustomCodeGrantAuthenticationToken`, which is created by <>. + +[[configure-token-endpoint]] +== Configure OAuth2 Token Endpoint + +The following example shows how to configure the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint] with the `AuthenticationConverter` and `AuthenticationProvider`: + +.SecurityConfig +[source,java] +---- +include::{examples-dir}/src/main/java/sample/extgrant/SecurityConfig.java[] +---- + +<1> Add the `AuthenticationConverter` to the OAuth2 Token endpoint configuration. +<2> Add the `AuthenticationProvider` to the OAuth2 Token endpoint configuration. + +[[request-access-token]] +== Request the Access Token + +The client can request the access token by making the following (authenticated) request to the OAuth2 Token endpoint: + +[source,shell] +---- +POST /oauth2/token HTTP/1.1 +Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ= +Content-Type: application/x-www-form-urlencoded + +grant_type=urn:ietf:params:oauth:grant-type:custom_code&code=7QR49T1W3 +---- diff --git a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc index a4c980ed5..94feb9d74 100644 --- a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc +++ b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc @@ -45,6 +45,7 @@ CREATE TABLE client ( clientAuthenticationMethods varchar(1000) NOT NULL, authorizationGrantTypes varchar(1000) NOT NULL, redirectUris varchar(1000) DEFAULT NULL, + postLogoutRedirectUris varchar(1000) DEFAULT NULL, scopes varchar(1000) NOT NULL, clientSettings varchar(2000) NOT NULL, tokenSettings varchar(2000) NOT NULL, @@ -72,6 +73,7 @@ CREATE TABLE authorization ( registeredClientId varchar(255) NOT NULL, principalName varchar(255) NOT NULL, authorizationGrantType varchar(255) NOT NULL, + authorizedScopes varchar(1000) DEFAULT NULL, attributes varchar(4000) DEFAULT NULL, state varchar(500) DEFAULT NULL, authorizationCodeValue varchar(4000) DEFAULT NULL, @@ -93,6 +95,14 @@ CREATE TABLE authorization ( oidcIdTokenExpiresAt timestamp DEFAULT NULL, oidcIdTokenMetadata varchar(2000) DEFAULT NULL, oidcIdTokenClaims varchar(2000) DEFAULT NULL, + userCodeValue varchar(4000) DEFAULT NULL, + userCodeIssuedAt timestamp DEFAULT NULL, + userCodeExpiresAt timestamp DEFAULT NULL, + userCodeMetadata varchar(2000) DEFAULT NULL, + deviceCodeValue varchar(4000) DEFAULT NULL, + deviceCodeIssuedAt timestamp DEFAULT NULL, + deviceCodeExpiresAt timestamp DEFAULT NULL, + deviceCodeMetadata varchar(2000) DEFAULT NULL, PRIMARY KEY (id) ); ---- @@ -175,7 +185,7 @@ include::code:ClientRepository[] [[authorization-repository]] === Authorization Repository -The following listing shows the `AuthorizationRepository`, which is able to find an <> by the `id` field as well as the `state`, `authorizationCodeValue`, `accessTokenValue` and `refreshTokenValue` token fields. +The following listing shows the `AuthorizationRepository`, which is able to find an <> by the `id` field as well as the `state`, `authorizationCodeValue`, `accessTokenValue`, `refreshTokenValue`, `userCodeValue` and `deviceCodeValue` token fields. It also allows querying a combination of token fields. [[sample.jpa.repository.authorization]] diff --git a/docs/src/docs/asciidoc/guides/how-to-pkce.adoc b/docs/src/docs/asciidoc/guides/how-to-pkce.adoc new file mode 100644 index 000000000..ff7c040f4 --- /dev/null +++ b/docs/src/docs/asciidoc/guides/how-to-pkce.adoc @@ -0,0 +1,77 @@ +[[how-to-pkce]] += How-to: Authenticate using a Single Page Application with PKCE +:index-link: ../how-to.html +:docs-dir: .. +:examples-dir: {docs-dir}/examples + +This guide shows how to configure xref:{docs-dir}/index.adoc#top[Spring Authorization Server] to support a Single Page Application (SPA) with Proof Key for Code Exchange (PKCE). +The purpose of this guide is to demonstrate how to support a public client and require PKCE for client authentication. + +NOTE: Spring Authorization Server will not issue refresh tokens for a public client. We recommend the backend for frontend (BFF) pattern as an alternative to exposing a public client. See https://github.com/spring-projects/spring-authorization-server/issues/297#issue-896744390[gh-297] for more information. + +* <> +* <> +* <> + +[[enable-cors]] +== Enable CORS + +A SPA consists of static resources that can be deployed in a variety of ways. +It can be deployed separately from the backend such as with a CDN or separate web server, or it can be deployed along side the backend using Spring Boot. + +When a SPA is hosted under a different domain, Cross Origin Resource Sharing (CORS) can be used to allow the application to communicate with the backend. + +For example, if you have an Angular dev server running locally on port `4200`, you can define a `CorsConfigurationSource` `@Bean` and configure Spring Security to allow pre-flight requests using the `cors()` DSL as in the following example: + +[[enable-cors-configuration]] +.Enable CORS +[source,java] +---- +include::{examples-dir}/src/main/java/sample/pkce/SecurityConfig.java[] +---- + +TIP: Click on the "Expand folded text" icon in the code sample above to display the full example. + +[[configure-public-client]] +== Configure a Public Client + +A SPA cannot securely store credentials and therefore must be treated as a https://datatracker.ietf.org/doc/html/rfc6749#section-2.1[public client^]. +Public clients should be required to use https://datatracker.ietf.org/doc/html/rfc7636#section-4[Proof Key for Code Exchange] (PKCE). + +Continuing the <> example, you can configure Spring Authorization Server to support a public client using the Client Authentication Method `none` and require PKCE as in the following example: + +[[configure-public-client-example]] +.Yaml +[source,yaml,role="primary"] +---- +include::{examples-dir}/src/main/java/sample/pkce/application.yml[] +---- + +.Java +[source,java,role="secondary"] +---- +include::{examples-dir}/src/main/java/sample/pkce/ClientConfig.java[tag=client,indent=0] +---- + +NOTE: The `requireProofKey` setting is helpful in situations where you forget to include the `code_challenge` and `code_challenge_method` query parameters because you will receive an error indicating PKCE is required during the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-authorization-endpoint[Authorization Request] instead of a general client authentication error during the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[Token Request]. + +[[authenticate-with-client]] +== Authenticate with the Client + +Once the server is configured to support a public client, a common question is: _How do I authenticate the client and get an access token?_ +The short answer is: The same way you would with any other client. + +NOTE: A SPA is a browser-based application and therefore uses the same redirection-based flow as any other client. This question is usually related to an expectation that authentication can be performed via a REST API, which is not the case with OAuth2. + +A more detailed answer requires an understanding of the flow(s) involved in OAuth2 and OpenID Connect, in this case the Authorization Code flow. +The steps of the Authorization Code flow are as follows: + +1. The client initiates an OAuth2 request via a redirect to the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-authorization-endpoint[Authorization Endpoint]. For a public client, this step includes generating the `code_verifier` and calculating the `code_challenge`, which is then sent as a query parameter. +2. If the user is not authenticated, the authorization server will redirect to the login page. After authentication, the user is redirected back to the Authorization Endpoint again. +3. If the user has not consented to the requested scope(s) and consent is required, the consent page is displayed. +4. Once the user has consented, the authorization server generates an `authorization_code` and redirects back to the client via the `redirect_uri`. +5. The client obtains the `authorization_code` via a query parameter and performs a request to the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[Token Endpoint]. For a public client, this step includes sending the `code_verifier` parameter instead of credentials for authentication. + +As you can see, the flow is fairly involved and this overview only scratches the surface. + +TIP: It is recommended that you use a robust client-side library supported by your single-page app framework to handle the Authorization Code flow. diff --git a/docs/src/docs/asciidoc/guides/how-to-social-login.adoc b/docs/src/docs/asciidoc/guides/how-to-social-login.adoc new file mode 100644 index 000000000..8bd2c6670 --- /dev/null +++ b/docs/src/docs/asciidoc/guides/how-to-social-login.adoc @@ -0,0 +1,167 @@ +[[how-to-social-login]] += How-to: Authenticate using Social Login +:index-link: ../how-to.html +:docs-dir: .. +:examples-dir: {docs-dir}/examples +:samples-dir: {docs-dir}/../../../../samples +:github-ref: main +:github-base-url: https://github.com/spring-projects/spring-authorization-server/blob/{github-ref} + +This guide shows how to configure xref:{docs-dir}/index.adoc#top[Spring Authorization Server] with a social login provider (such as Google, GitHub, etc.) for {spring-security-reference-base-url}/servlet/authentication/index.html[authentication]. +The purpose of this guide is to demonstrate how to replace {spring-security-reference-base-url}/servlet/authentication/passwords/form.html[Form Login] with {spring-security-reference-base-url}/servlet/oauth2/login/index.html[OAuth 2.0 Login]. + +NOTE: Spring Authorization Server is built on {spring-security-reference-base-url}/index.html[Spring Security] and we will be using Spring Security concepts throughout this guide. + +* <> +* <> +* <> + +[[register-social-login-provider]] +== Register with Social Login Provider + +To get started, you will need to set up an application with your chosen social login provider. +Common providers include: + +* https://developers.google.com/identity/openid-connect/openid-connect#appsetup[Google] +* https://github.com/settings/developers[GitHub] +* https://developers.facebook.com/apps[Facebook] +* https://www.okta.com/developer/signup[Okta] + +Follow the steps for your provider until you are asked to specify a Redirect URI. +To set up a Redirect URI, choose a `registrationId` (such as `google`, `my-client` or any other unique identifier you wish) which you will use to configure both Spring Security **and** your provider. + +NOTE: The `registrationId` is a unique identifier for the `ClientRegistration` in Spring Security. The default Redirect URI template is `\{baseUrl\}/login/oauth2/code/\{registrationId\}`. See {spring-security-reference-base-url}/servlet/oauth2/login/core.html#oauth2login-sample-redirect-uri[Setting the Redirect URI] in the Spring Security reference for more information. + +TIP: For example, testing locally on port `9000` with a `registrationId` of `google`, your Redirect URI would be `http://localhost:9000/login/oauth2/code/google`. Enter this value as the Redirect URI when setting up the application with your provider. + +Once you've completed the set-up process with your social login provider, you should have obtained credentials (a Client ID and Client Secret). +In addition, you will need to reference the provider's documentation and take note of the following values: + +* **Authorization URI**: The endpoint that is used to initiate the `authorization_code` flow at the provider. +* **Token URI**: The endpoint that is used to exchange an `authorization_code` for an `access_token` and optionally an `id_token`. +* **JWK Set URI**: The endpoint that is used to obtain keys for verifying the signature of a JWT, which is required when an `id_token` is available. +* **User Info URI**: The endpoint that is used to obtain user information, which is required when an `id_token` is not available. +* **User Name Attribute**: The claim in either the `id_token` or the User Info Response containing the username of the user. + +[[configure-oauth2-login]] +== Configure OAuth 2.0 Login + +Once you've <> with a social login provider, you can proceed to configuring Spring Security for {spring-security-reference-base-url}/servlet/oauth2/login/index.html[OAuth 2.0 Login]. + +* <> +* <> +* <> + +[[configure-oauth2-login-dependency]] +=== Add OAuth2 Client Dependency + +First, add the following dependency: + +[[configure-oauth2-login-maven-dependency]] +.Maven +[source,xml,role="primary",subs="attributes,verbatim"] +---- + + org.springframework.boot + spring-boot-starter-oauth2-client + +---- + +[[configure-oauth2-login-gradle-dependency]] +.Gradle +[source,gradle,role="secondary",subs="attributes,verbatim"] +---- +implementation "org.springframework.boot:spring-boot-starter-oauth2-client" +---- + +[[configure-oauth2-login-client-registration]] +=== Register a Client + +Next, configure the `ClientRegistration` with the values obtained <>. +Using Okta as an example, configure the following properties: + +[[configure-oauth2-login-okta-example]] +.application.yml +[source,yaml] +---- +include::{examples-dir}/src/main/java/sample/socialLogin/application.yml[] +---- + +NOTE: The `registrationId` in the above example is `my-client`. + +TIP: The above example demonstrates the *recommended* way to set the Provider URL, Client ID and Client Secret using environment variables (`OKTA_BASE_URL`, `OKTA_CLIENT_ID` and `OKTA_CLIENT_SECRET`). See {spring-boot-reference-base-url}/features.html#features.external-config[Externalized Configuration] in the Spring Boot reference for more information. + +This simple example demonstrates a typical configuration, but some providers will require additional configuration. +For more information about configuring the `ClientRegistration`, see {spring-security-reference-base-url}/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings[Spring Boot Property Mappings] in the Spring Security reference. + +[[configure-oauth2-login-authentication]] +=== Configure Authentication + +Finally, to configure Spring Authorization Server to use a social login provider for authentication, you can use `oauth2Login()` instead of `formLogin()`. +You can also automatically redirect an unauthenticated user to the provider by configuring `exceptionHandling()` with an `AuthenticationEntryPoint`. + +Continuing our <>, configure Spring Security using a `@Configuration` as in the following example: + +.Configure OAuth 2.0 Login +[source,java] +---- +include::{examples-dir}/src/main/java/sample/socialLogin/SecurityConfig.java[] +---- + +<1> A Spring Security filter chain for the xref:{docs-dir}/protocol-endpoints.adoc[Protocol Endpoints]. +<2> Configure an `AuthenticationEntryPoint` for redirecting to the {spring-security-reference-base-url}/servlet/oauth2/login/advanced.html#oauth2login-advanced-login-page[OAuth 2.0 Login endpoint]. +<3> A Spring Security filter chain for https://docs.spring.io/spring-security/reference/servlet/authentication/index.html[authentication]. +<4> Configure {spring-security-reference-base-url}/servlet/oauth2/login/index.html[OAuth 2.0 Login] for authentication. + +If you configured a `UserDetailsService` when xref:{docs-dir}/getting-started.adoc#developing-your-first-application[getting started], you can remove it now. + +[[advanced-use-cases]] +== Advanced Use Cases + +The https://github.com/spring-projects/spring-authorization-server/tree/{github-ref}/samples#demo-sample[demo authorization server sample^] demonstrates advanced configuration options for federating identity providers. +Select from the following use cases to see an example of each: + +* I want to <> +* I want to <> + +[[advanced-use-cases-capture-users]] +=== Capture Users in a Database + +The following example `AuthenticationSuccessHandler` uses a custom component to capture users in a local database when they first log in: + +.`FederatedIdentityAuthenticationSuccessHandler` +[source,java] +---- +include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationSuccessHandler.java[tags=imports;class] +---- + +Using the `AuthenticationSuccessHandler` above, you can plug in your own `Consumer` that can capture users in a database or other data store for concepts like Federated Account Linking or JIT Account Provisioning. +Here is an example that simply stores users in-memory: + +.`UserRepositoryOAuth2UserHandler` +[source,java] +---- +include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/UserRepositoryOAuth2UserHandler.java[tags=imports;class] +---- + +[[advanced-use-cases-map-claims]] +=== Map Claims to an ID Token + +The following example `OAuth2TokenCustomizer` maps a user's claims from an authentication provider to the `id_token` produced by Spring Authorization Server: + +.`FederatedIdentityIdTokenCustomizer` +[source,java] +---- +include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityIdTokenCustomizer.java[tags=imports;class] +---- + +You can configure Spring Authorization Server to use this customizer by publishing it as a `@Bean` as in the following example: + +.Configure `FederatedIdentityIdTokenCustomizer` +[source,java] +---- +@Bean +public OAuth2TokenCustomizer idTokenCustomizer() { + return new FederatedIdentityIdTokenCustomizer(); +} +---- diff --git a/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc b/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc index f7b8db6f0..cee5ca94c 100644 --- a/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc +++ b/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc @@ -22,7 +22,7 @@ The following listing shows how to enable the {spring-security-reference-base-ur [[sample.userinfo]] include::code:EnableUserInfoSecurityConfig[] -TIP: Click on the "Expanded folded text" icon in the code sample above to display the full example. +TIP: Click on the "Expand folded text" icon in the code sample above to display the full example. This configuration provides the following: diff --git a/docs/src/docs/asciidoc/how-to.adoc b/docs/src/docs/asciidoc/how-to.adoc index 6d82989be..4f7104a24 100644 --- a/docs/src/docs/asciidoc/how-to.adoc +++ b/docs/src/docs/asciidoc/how-to.adoc @@ -4,5 +4,8 @@ [[how-to-overview]] == List of Guides +* xref:guides/how-to-pkce.adoc[Authenticate using a Single Page Application with PKCE] +* xref:guides/how-to-social-login.adoc[Authenticate using Social Login] +* xref:guides/how-to-ext-grant-type.adoc[Implement an Extension Authorization Grant Type] * xref:guides/how-to-userinfo.adoc[Customize the OpenID Connect 1.0 UserInfo response] * xref:guides/how-to-jpa.adoc[Implement core services with JPA] diff --git a/docs/src/docs/asciidoc/overview.adoc b/docs/src/docs/asciidoc/overview.adoc index e85ba590d..2853bb4a3 100644 --- a/docs/src/docs/asciidoc/overview.adoc +++ b/docs/src/docs/asciidoc/overview.adoc @@ -24,6 +24,8 @@ Spring Authorization Server supports the following features: ** xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[User Consent] * Client Credentials * Refresh Token +* Device Code +** xref:protocol-endpoints.adoc#oauth2-device-verification-endpoint[User Consent] | * The OAuth 2.1 Authorization Framework (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07[draft]) ** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-4.1[Authorization Code Grant] @@ -31,6 +33,9 @@ Spring Authorization Server supports the following features: ** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-4.3[Refresh Token Grant] * OpenID Connect Core 1.0 (https://openid.net/specs/openid-connect-core-1_0.html[spec]) ** https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth[Authorization Code Flow] +* OAuth 2.0 Device Authorization Grant +(https://tools.ietf.org/html/rfc8628[spec]) +** https://tools.ietf.org/html/rfc8628#section-3[Device Flow] |xref:core-model-components.adoc#oauth2-token-generator[Token Formats] | @@ -55,24 +60,32 @@ Spring Authorization Server supports the following features: |xref:protocol-endpoints.adoc[Protocol Endpoints] | * xref:protocol-endpoints.adoc#oauth2-authorization-endpoint[OAuth2 Authorization Endpoint] +* xref:protocol-endpoints.adoc#oauth2-device-authorization-endpoint[OAuth2 Device Authorization Endpoint] +* xref:protocol-endpoints.adoc#oauth2-device-verification-endpoint[OAuth2 Device Verification Endpoint] * xref:protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token Endpoint] * xref:protocol-endpoints.adoc#oauth2-token-introspection-endpoint[OAuth2 Token Introspection Endpoint] * xref:protocol-endpoints.adoc#oauth2-token-revocation-endpoint[OAuth2 Token Revocation Endpoint] * xref:protocol-endpoints.adoc#oauth2-authorization-server-metadata-endpoint[OAuth2 Authorization Server Metadata Endpoint] * xref:protocol-endpoints.adoc#jwk-set-endpoint[JWK Set Endpoint] * xref:protocol-endpoints.adoc#oidc-provider-configuration-endpoint[OpenID Connect 1.0 Provider Configuration Endpoint] +* xref:protocol-endpoints.adoc#oidc-logout-endpoint[OpenID Connect 1.0 Logout Endpoint] * xref:protocol-endpoints.adoc#oidc-user-info-endpoint[OpenID Connect 1.0 UserInfo Endpoint] * xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[OpenID Connect 1.0 Client Registration Endpoint] | * The OAuth 2.1 Authorization Framework (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07[draft]) ** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-3.1[Authorization Endpoint] ** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-3.2[Token Endpoint] +* OAuth 2.0 Device Authorization Grant (https://tools.ietf.org/html/rfc8628[RFC 8628]) +** https://tools.ietf.org/html/rfc8628#section-3.1[Device Authorization Endpoint] +** https://tools.ietf.org/html/rfc8628#section-3.3[Device Verification Endpoint] * OAuth 2.0 Token Introspection (https://tools.ietf.org/html/rfc7662[RFC 7662]) * OAuth 2.0 Token Revocation (https://tools.ietf.org/html/rfc7009[RFC 7009]) * OAuth 2.0 Authorization Server Metadata (https://tools.ietf.org/html/rfc8414[RFC 8414]) * JSON Web Key (JWK) (https://tools.ietf.org/html/rfc7517[RFC 7517]) * OpenID Connect Discovery 1.0 (https://openid.net/specs/openid-connect-discovery-1_0.html[spec]) ** https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Provider Configuration Endpoint] +* OpenID Connect RP-Initiated Logout 1.0 (https://openid.net/specs/openid-connect-rpinitiated-1_0.html[spec]) +** https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout[Logout Endpoint] * OpenID Connect Core 1.0 (https://openid.net/specs/openid-connect-core-1_0.html[spec]) ** https://openid.net/specs/openid-connect-core-1_0.html#UserInfo[UserInfo Endpoint] * OpenID Connect Dynamic Client Registration 1.0 (https://openid.net/specs/openid-connect-registration-1_0.html[spec]) diff --git a/docs/src/docs/asciidoc/protocol-endpoints.adoc b/docs/src/docs/asciidoc/protocol-endpoints.adoc index 83c2ea5e2..1c30209f1 100644 --- a/docs/src/docs/asciidoc/protocol-endpoints.adoc +++ b/docs/src/docs/asciidoc/protocol-endpoints.adoc @@ -120,6 +120,104 @@ static class CustomRedirectUriValidator implements Consumer + deviceAuthorizationEndpoint + .deviceAuthorizationRequestConverter(deviceAuthorizationRequestConverter) <1> + .deviceAuthorizationRequestConverters(deviceAuthorizationRequestConvertersConsumer) <2> + .authenticationProvider(authenticationProvider) <3> + .authenticationProviders(authenticationProvidersConsumer) <4> + .deviceAuthorizationResponseHandler(deviceAuthorizationResponseHandler) <5> + .errorResponseHandler(errorResponseHandler) <6> + .verificationUri("/oauth2/v1/device_verification") <7> + ); + + return http.build(); +} +---- +<1> `deviceAuthorizationRequestConverter()`: Adds an `AuthenticationConverter` (_pre-processor_) used when attempting to extract an https://datatracker.ietf.org/doc/html/rfc8628#section-3.1[OAuth2 device authorization request] from `HttpServletRequest` to an instance of `OAuth2DeviceAuthorizationRequestAuthenticationToken`. +<2> `deviceAuthorizationRequestConverters()`: Sets the `Consumer` providing access to the `List` of default and (optionally) added ``AuthenticationConverter``'s allowing the ability to add, remove, or customize a specific `AuthenticationConverter`. +<3> `authenticationProvider()`: Adds an `AuthenticationProvider` (_main processor_) used for authenticating the `OAuth2DeviceAuthorizationRequestAuthenticationToken`. +<4> `authenticationProviders()`: Sets the `Consumer` providing access to the `List` of default and (optionally) added ``AuthenticationProvider``'s allowing the ability to add, remove, or customize a specific `AuthenticationProvider`. +<5> `deviceAuthorizationResponseHandler()`: The `AuthenticationSuccessHandler` (_post-processor_) used for handling an "`authenticated`" `OAuth2DeviceAuthorizationRequestAuthenticationToken` and returning the https://datatracker.ietf.org/doc/html/rfc8628#section-3.2[OAuth2DeviceAuthorizationResponse]. +<6> `errorResponseHandler()`: The `AuthenticationFailureHandler` (_post-processor_) used for handling an `OAuth2AuthenticationException` and returning the https://datatracker.ietf.org/doc/html/rfc6749#section-5.2[OAuth2Error response]. +<7> `verificationUri()`: The `URI` of the custom end-user verification page to direct resource owners to on a secondary device. + +`OAuth2DeviceAuthorizationEndpointConfigurer` configures the `OAuth2DeviceAuthorizationEndpointFilter` and registers it with the OAuth2 authorization server `SecurityFilterChain` `@Bean`. +`OAuth2DeviceAuthorizationEndpointFilter` is the `Filter` that processes OAuth2 device authorization requests. + +`OAuth2DeviceAuthorizationEndpointFilter` is configured with the following defaults: + +* `*AuthenticationConverter*` -- An `OAuth2DeviceAuthorizationRequestAuthenticationConverter`. +* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OAuth2DeviceAuthorizationRequestAuthenticationProvider`. +* `*AuthenticationSuccessHandler*` -- An internal implementation that handles an "`authenticated`" `OAuth2DeviceAuthorizationRequestAuthenticationToken` and returns the `OAuth2DeviceAuthorizationResponse`. +* `*AuthenticationFailureHandler*` -- An internal implementation that uses the `OAuth2Error` associated with the `OAuth2AuthenticationException` and returns the `OAuth2Error` response. + +[[oauth2-device-verification-endpoint]] +== OAuth2 Device Verification Endpoint + +`OAuth2DeviceVerificationEndpointConfigurer` provides the ability to customize the https://datatracker.ietf.org/doc/html/rfc8628#section-3.3[OAuth2 Device Verification endpoint] (or "User Interaction"). +It defines extension points that let you customize the pre-processing, main processing, and post-processing logic for OAuth2 device verification requests. + +`OAuth2DeviceVerificationEndpointConfigurer` provides the following configuration options: + +[source,java] +---- +@Bean +public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer(); + http.apply(authorizationServerConfigurer); + + authorizationServerConfigurer + .deviceVerificationEndpoint(deviceVerificationEndpoint -> + deviceVerificationEndpoint + .deviceVerificationRequestConverter(deviceVerificationRequestConverter) <1> + .deviceVerificationRequestConverters(deviceVerificationRequestConvertersConsumer) <2> + .authenticationProvider(authenticationProvider) <3> + .authenticationProviders(authenticationProvidersConsumer) <4> + .deviceVerificationResponseHandler(deviceVerificationResponseHandler) <5> + .errorResponseHandler(errorResponseHandler) <6> + .consentPage("/oauth2/v1/consent") <7> + ); + + return http.build(); +} +---- +<1> `deviceVerificationRequestConverter()`: Adds an `AuthenticationConverter` (_pre-processor_) used when attempting to extract an https://datatracker.ietf.org/doc/html/rfc8628#section-3.3[OAuth2 device verification request] (or consent) from `HttpServletRequest` to an instance of `OAuth2DeviceVerificationAuthenticationToken` or `OAuth2DeviceAuthorizationConsentAuthenticationToken`. +<2> `deviceVerificationRequestConverters()`: Sets the `Consumer` providing access to the `List` of default and (optionally) added ``AuthenticationConverter``'s allowing the ability to add, remove, or customize a specific `AuthenticationConverter`. +<3> `authenticationProvider()`: Adds an `AuthenticationProvider` (_main processor_) used for authenticating the `OAuth2DeviceVerificationAuthenticationToken` or `OAuth2DeviceAuthorizationConsentAuthenticationToken`. +<4> `authenticationProviders()`: Sets the `Consumer` providing access to the `List` of default and (optionally) added ``AuthenticationProvider``'s allowing the ability to add, remove, or customize a specific `AuthenticationProvider`. +<5> `deviceVerificationResponseHandler()`: The `AuthenticationSuccessHandler` (_post-processor_) used for handling an "`authenticated`" `OAuth2DeviceVerificationAuthenticationToken` and directing the resource owner to return to their device. +<6> `errorResponseHandler()`: The `AuthenticationFailureHandler` (_post-processor_) used for handling an `OAuth2AuthenticationException` and returning the error response. +<7> `consentPage()`: The `URI` of the custom consent page to redirect resource owners to if consent is required during the device verification request flow. + +`OAuth2DeviceVerificationEndpointConfigurer` configures the `OAuth2DeviceVerificationEndpointFilter` and registers it with the OAuth2 authorization server `SecurityFilterChain` `@Bean`. +`OAuth2DeviceVerificationEndpointFilter` is the `Filter` that processes OAuth2 device verification requests (and consents). + +`OAuth2DeviceVerificationEndpointFilter` is configured with the following defaults: + +* `*AuthenticationConverter*` -- A `DelegatingAuthenticationConverter` composed of `OAuth2DeviceVerificationAuthenticationConverter` and `OAuth2DeviceAuthorizationConsentAuthenticationConverter`. +* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OAuth2DeviceVerificationAuthenticationProvider` and `OAuth2DeviceAuthorizationConsentAuthenticationProvider`. +* `*AuthenticationSuccessHandler*` -- A `SimpleUrlAuthenticationSuccessHandler` that handles an "`authenticated`" `OAuth2DeviceVerificationAuthenticationToken` and redirects the user to a success page (`/?success`). +* `*AuthenticationFailureHandler*` -- An internal implementation that uses the `OAuth2Error` associated with the `OAuth2AuthenticationException` and returns the `OAuth2Error` response. + [[oauth2-token-endpoint]] == OAuth2 Token Endpoint @@ -159,12 +257,12 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h `OAuth2TokenEndpointConfigurer` configures the `OAuth2TokenEndpointFilter` and registers it with the OAuth2 authorization server `SecurityFilterChain` `@Bean`. `OAuth2TokenEndpointFilter` is the `Filter` that processes OAuth2 access token requests. -The supported https://datatracker.ietf.org/doc/html/rfc6749#section-1.3[authorization grant types] are `authorization_code`, `refresh_token`, and `client_credentials`. +The supported https://datatracker.ietf.org/doc/html/rfc6749#section-1.3[authorization grant types] are `authorization_code`, `refresh_token`, `client_credentials`, and `urn:ietf:params:oauth:grant-type:device_code`. `OAuth2TokenEndpointFilter` is configured with the following defaults: -* `*AuthenticationConverter*` -- A `DelegatingAuthenticationConverter` composed of `OAuth2AuthorizationCodeAuthenticationConverter`, `OAuth2RefreshTokenAuthenticationConverter`, and `OAuth2ClientCredentialsAuthenticationConverter`. -* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OAuth2AuthorizationCodeAuthenticationProvider`, `OAuth2RefreshTokenAuthenticationProvider`, and `OAuth2ClientCredentialsAuthenticationProvider`. +* `*AuthenticationConverter*` -- A `DelegatingAuthenticationConverter` composed of `OAuth2AuthorizationCodeAuthenticationConverter`, `OAuth2RefreshTokenAuthenticationConverter`, `OAuth2ClientCredentialsAuthenticationConverter`, and `OAuth2DeviceCodeAuthenticationConverter`. +* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OAuth2AuthorizationCodeAuthenticationProvider`, `OAuth2RefreshTokenAuthenticationProvider`, `OAuth2ClientCredentialsAuthenticationProvider`, and `OAuth2DeviceCodeAuthenticationProvider`. * `*AuthenticationSuccessHandler*` -- An internal implementation that handles an `OAuth2AccessTokenAuthenticationToken` and returns the `OAuth2AccessTokenResponse`. * `*AuthenticationFailureHandler*` -- An internal implementation that uses the `OAuth2Error` associated with the `OAuth2AuthenticationException` and returns the `OAuth2Error` response. @@ -335,6 +433,62 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h `OidcProviderConfigurationEndpointConfigurer` configures the `OidcProviderConfigurationEndpointFilter` and registers it with the OAuth2 authorization server `SecurityFilterChain` `@Bean`. `OidcProviderConfigurationEndpointFilter` is the `Filter` that returns the https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse[OidcProviderConfiguration response]. +[[oidc-logout-endpoint]] +== OpenID Connect 1.0 Logout Endpoint + +`OidcLogoutEndpointConfigurer` provides the ability to customize the https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout[OpenID Connect 1.0 Logout endpoint]. +It defines extension points that let you customize the pre-processing, main processing, and post-processing logic for RP-Initiated Logout requests. + +`OidcLogoutEndpointConfigurer` provides the following configuration options: + +[source,java] +---- +@Bean +public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer(); + http.apply(authorizationServerConfigurer); + + authorizationServerConfigurer + .oidc(oidc -> + oidc + .logoutEndpoint(logoutEndpoint -> + logoutEndpoint + .logoutRequestConverter(logoutRequestConverter) <1> + .logoutRequestConverters(logoutRequestConvertersConsumer) <2> + .authenticationProvider(authenticationProvider) <3> + .authenticationProviders(authenticationProvidersConsumer) <4> + .logoutResponseHandler(logoutResponseHandler) <5> + .errorResponseHandler(errorResponseHandler) <6> + ) + ); + + return http.build(); +} +---- +<1> `logoutRequestConverter()`: Adds an `AuthenticationConverter` (_pre-processor_) used when attempting to extract a https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout[Logout request] from `HttpServletRequest` to an instance of `OidcLogoutAuthenticationToken`. +<2> `logoutRequestConverters()`: Sets the `Consumer` providing access to the `List` of default and (optionally) added ``AuthenticationConverter``'s allowing the ability to add, remove, or customize a specific `AuthenticationConverter`. +<3> `authenticationProvider()`: Adds an `AuthenticationProvider` (_main processor_) used for authenticating the `OidcLogoutAuthenticationToken`. +<4> `authenticationProviders()`: Sets the `Consumer` providing access to the `List` of default and (optionally) added ``AuthenticationProvider``'s allowing the ability to add, remove, or customize a specific `AuthenticationProvider`. +<5> `logoutResponseHandler()`: The `AuthenticationSuccessHandler` (_post-processor_) used for handling an "`authenticated`" `OidcLogoutAuthenticationToken` and performing the logout. +<6> `errorResponseHandler()`: The `AuthenticationFailureHandler` (_post-processor_) used for handling an `OAuth2AuthenticationException` and returning the error response. + +`OidcLogoutEndpointConfigurer` configures the `OidcLogoutEndpointFilter` and registers it with the OAuth2 authorization server `SecurityFilterChain` `@Bean`. +`OidcLogoutEndpointFilter` is the `Filter` that processes https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout[RP-Initiated Logout requests] and performs the logout of the End-User. + +`OidcLogoutEndpointFilter` is configured with the following defaults: + +* `*AuthenticationConverter*` -- An `OidcLogoutAuthenticationConverter`. +* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OidcLogoutAuthenticationProvider`. +* `*AuthenticationSuccessHandler*` -- An internal implementation that handles an "`authenticated`" `OidcLogoutAuthenticationToken` and performs the logout. +* `*AuthenticationFailureHandler*` -- An internal implementation that uses the `OAuth2Error` associated with the `OAuth2AuthenticationException` and returns the `OAuth2Error` response. + +[NOTE] +`OidcLogoutAuthenticationProvider` uses a xref:core-model-components.adoc#session-registry[`SessionRegistry`] to look up the `SessionInformation` instance associated to the End-User requesting to be logged out. + +[TIP] +`OidcClientInitiatedLogoutSuccessHandler` is the corresponding configuration in Spring Security’s OAuth2 Client support for configuring {spring-security-reference-base-url}/servlet/oauth2/login/advanced.html#oauth2login-advanced-oidc-logout[OpenID Connect 1.0 RP-Initiated Logout]. + [[oidc-user-info-endpoint]] == OpenID Connect 1.0 UserInfo Endpoint @@ -403,7 +557,7 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h ... - http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults())); return http.build(); } @@ -496,7 +650,7 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h ... - http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults())); return http.build(); } diff --git a/git/hooks/forward-merge b/git/hooks/forward-merge new file mode 100755 index 000000000..51640718d --- /dev/null +++ b/git/hooks/forward-merge @@ -0,0 +1,135 @@ +#!/usr/bin/ruby +require 'json' +require 'net/http' +require 'yaml' +require 'logger' + +$log = Logger.new(STDOUT) +$log.level = Logger::WARN + +class ForwardMerge + attr_reader :issue, :milestone, :message, :line + def initialize(issue, milestone, message, line) + @issue = issue + @milestone = milestone + @message = message + @line = line + end +end + +def find_forward_merges(message_file) + $log.debug "Searching for forward merge" + rev=`git rev-parse -q --verify MERGE_HEAD`.strip + $log.debug "Found #{rev} from git rev-parse" + return nil unless rev + message = File.read(message_file) + forward_merges = [] + message.each_line do |line| + $log.debug "Checking #{line} for message" + match = /^(?:Fixes|Closes) gh-(\d+) in (\d\.\d\.[\dx](?:[\.\-](?:M|RC)\d)?)$/.match(line) + if match then + issue = match[1] + milestone = match[2] + $log.debug "Matched reference to issue #{issue} in milestone #{milestone}" + forward_merges << ForwardMerge.new(issue, milestone, message, line) + end + end + $log.debug "No match in merge message" unless forward_merges + return forward_merges +end + +def get_issue(username, password, repository, number) + $log.debug "Getting issue #{number} from GitHub repository #{repository}" + uri = URI("https://api.github.com/repos/#{repository}/issues/#{number}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl=true + request = Net::HTTP::Get.new(uri.path) + request.basic_auth(username, password) + response = http.request(request) + $log.debug "Get HTTP response #{response.code}" + return JSON.parse(response.body) unless response.code != '200' + puts "Failed to retrieve issue #{number}: #{response.message}" + exit 1 +end + +def find_milestone(username, password, repository, title) + $log.debug "Finding milestone #{title} from GitHub repository #{repository}" + uri = URI("https://api.github.com/repos/#{repository}/milestones") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl=true + request = Net::HTTP::Get.new(uri.path) + request.basic_auth(username, password) + response = http.request(request) + milestones = JSON.parse(response.body) + if title.end_with?(".x") + prefix = title.delete_suffix('.x') + $log.debug "Finding nearest milestone from candidates starting with #{prefix}" + titles = milestones.map { |milestone| milestone['title'] } + titles = titles.select{ |title| title.start_with?(prefix) unless title.end_with?('.x')} + titles = titles.sort_by { |v| Gem::Version.new(v) } + $log.debug "Considering candidates #{titles}" + if(titles.empty?) + puts "Cannot find nearest milestone for prefix #{title}" + exit 1 + end + title = titles.first + $log.debug "Found nearest milestone #{title}" + end + milestones.each do |milestone| + $log.debug "Considering #{milestone['title']}" + return milestone['number'] if milestone['title'] == title + end + puts "Milestone #{title} not found in #{repository}" + exit 1 +end + +def create_issue(username, password, repository, original, title, labels, milestone, milestone_name, dry_run) + $log.debug "Finding forward-merge issue in GitHub repository #{repository} for '#{title}'" + uri = URI("https://api.github.com/repos/#{repository}/issues") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl=true + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.basic_auth(username, password) + request.body = { + title: title, + labels: labels, + milestone: milestone.to_i, + body: "Forward port of issue ##{original} to #{milestone_name}." + }.to_json + if dry_run then + puts "Dry run" + puts "POSTing to #{uri} with body #{request.body}" + return "dry-run" + end + response = JSON.parse(http.request(request).body) + $log.debug "Created new issue #{response['number']}" + return response['number'] +end + +$log.debug "Running forward-merge hook script" +message_file=ARGV[0] + +forward_merges = find_forward_merges(message_file) +exit 0 unless forward_merges + +$log.debug "Loading config from ~/.spring-authorization-server/forward_merge.yml" +config = YAML.load_file(File.join(Dir.home, '.spring-authorization-server', 'forward-merge.yml')) +username = config['github']['credentials']['username'] +password = config['github']['credentials']['password'] +dry_run = config['dry_run'] +repository = 'spring-projects/spring-authorization-server' + +forward_merges.each do |forward_merge| + existing_issue = get_issue(username, password, repository, forward_merge.issue) + title = existing_issue['title'] + labels = existing_issue['labels'].map { |label| label['name'] } + labels << "status: forward-port" + $log.debug "Processing issue '#{title}'" + + milestone = find_milestone(username, password, repository, forward_merge.milestone) + new_issue_number = create_issue(username, password, repository, forward_merge.issue, title, labels, milestone, forward_merge.milestone, dry_run) + + puts "Created gh-#{new_issue_number} for forward port of gh-#{forward_merge.issue} into #{forward_merge.milestone}" + rewritten_message = forward_merge.message.sub(forward_merge.line, "Closes gh-#{new_issue_number}\n") + File.write(message_file, rewritten_message) +end diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge new file mode 100755 index 000000000..928c97d0a --- /dev/null +++ b/git/hooks/prepare-forward-merge @@ -0,0 +1,71 @@ +#!/usr/bin/ruby +require 'json' +require 'net/http' +require 'yaml' +require 'logger' + +$main_branch = "1.1.x" + +$log = Logger.new(STDOUT) +$log.level = Logger::WARN + +def get_fixed_issues() + $log.debug "Searching for for forward merge" + rev=`git rev-parse -q --verify MERGE_HEAD`.strip + $log.debug "Found #{rev} from git rev-parse" + return nil unless rev + fixed = [] + message = `git log -1 --pretty=%B #{rev}` + message.each_line do |line| + $log.debug "Checking #{line} for message" + fixed << line.strip if /^(?:Fixes|Closes) gh-(\d+)/.match(line) + end + $log.debug "Found fixed issues #{fixed}" + return fixed; +end + +def rewrite_message(message_file, fixed) + current_branch = `git rev-parse --abbrev-ref HEAD`.strip + if current_branch == "main" + current_branch = $main_branch + end + rewritten_message = "" + message = File.read(message_file) + message.each_line do |line| + match = /^Merge.*branch\ '(.*)'(?:\ into\ (.*))?$/.match(line) + if match + from_branch = match[1] + if from_branch.include? "/" + from_branch = from_branch.partition("/").last + end + to_brach = match[2] + $log.debug "Rewriting merge message" + line = "Merge branch '#{from_branch}'" + (to_brach ? " into #{to_brach}\n" : "\n") + end + if fixed and line.start_with?("#") + $log.debug "Adding fixed" + rewritten_message << "\n" + fixed.each do |fixes| + rewritten_message << "#{fixes} in #{current_branch}\n" + end + fixed = nil + end + rewritten_message << line + end + return rewritten_message +end + +$log.debug "Running prepare-forward-merge hook script" + +message_file=ARGV[0] +message_type=ARGV[1] + +if message_type != "merge" + $log.debug "Not a merge commit" + exit 0; +end + +$log.debug "Searching for for forward merge" +fixed = get_fixed_issues() +rewritten_message = rewrite_message(message_file, fixed) +File.write(message_file, rewritten_message) diff --git a/gradle.properties b/gradle.properties index deab73c47..ec26d3c51 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,11 +1,11 @@ -version=1.0.0 +version=1.1.6 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=6.0.0 -springSecurityVersion=6.0.0 -springJavaformatVersion=0.0.31 +springFrameworkVersion=6.0.18 +springSecurityVersion=6.1.8 +springJavaformatVersion=0.0.38 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 -nohttpCheckstyleVersion=0.0.10 +nohttpCheckstyleVersion=0.0.11 jacocoToolVersion=0.8.7 diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/AbstractOAuth2AuthorizationServerMetadata.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/AbstractOAuth2AuthorizationServerMetadata.java index b7c7b81fa..1c382949d 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/AbstractOAuth2AuthorizationServerMetadata.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/AbstractOAuth2AuthorizationServerMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,7 @@ * @since 0.1.1 * @see 3.2. Authorization Server Metadata Response * @see 4.2. OpenID Provider Configuration Response + * @see 4. Device Authorization Grant Metadata */ public abstract class AbstractOAuth2AuthorizationServerMetadata implements OAuth2AuthorizationServerMetadataClaimAccessor, Serializable { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; @@ -96,6 +97,17 @@ public B authorizationEndpoint(String authorizationEndpoint) { return claim(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, authorizationEndpoint); } + /** + * Use this {@code device_authorization_endpoint} in the resulting {@link AbstractOAuth2AuthorizationServerMetadata}, OPTIONAL. + * + * @param deviceAuthorizationEndpoint the {@code URL} of the OAuth 2.0 Device Authorization Endpoint + * @return the {@link AbstractBuilder} for further configuration + * @since 1.1 + */ + public B deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) { + return claim(OAuth2AuthorizationServerMetadataClaimNames.DEVICE_AUTHORIZATION_ENDPOINT, deviceAuthorizationEndpoint); + } + /** * Use this {@code token_endpoint} in the resulting {@link AbstractOAuth2AuthorizationServerMetadata}, REQUIRED. * @@ -346,6 +358,9 @@ protected void validate() { validateURL(getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.ISSUER), "issuer must be a valid URL"); Assert.notNull(getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint cannot be null"); validateURL(getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint must be a valid URL"); + if (getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.DEVICE_AUTHORIZATION_ENDPOINT) != null) { + validateURL(getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.DEVICE_AUTHORIZATION_ENDPOINT), "deviceAuthorizationEndpoint must be a valid URL"); + } Assert.notNull(getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint cannot be null"); validateURL(getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint must be a valid URL"); if (getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED) != null) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java index 1cf2b91ae..701042adc 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,12 @@ import org.springframework.lang.Nullable; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.util.Assert; /** @@ -150,15 +154,24 @@ private static boolean hasToken(OAuth2Authorization authorization, String token, return matchesState(authorization, token) || matchesAuthorizationCode(authorization, token) || matchesAccessToken(authorization, token) || - matchesRefreshToken(authorization, token); + matchesIdToken(authorization, token) || + matchesRefreshToken(authorization, token) || + matchesDeviceCode(authorization, token) || + matchesUserCode(authorization, token); } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { return matchesState(authorization, token); } else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) { return matchesAuthorizationCode(authorization, token); } else if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) { return matchesAccessToken(authorization, token); + } else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) { + return matchesIdToken(authorization, token); } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { return matchesRefreshToken(authorization, token); + } else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) { + return matchesDeviceCode(authorization, token); + } else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) { + return matchesUserCode(authorization, token); } return false; } @@ -185,6 +198,24 @@ private static boolean matchesRefreshToken(OAuth2Authorization authorization, St return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token); } + private static boolean matchesIdToken(OAuth2Authorization authorization, String token) { + OAuth2Authorization.Token idToken = + authorization.getToken(OidcIdToken.class); + return idToken != null && idToken.getToken().getTokenValue().equals(token); + } + + private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) { + OAuth2Authorization.Token deviceCode = + authorization.getToken(OAuth2DeviceCode.class); + return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token); + } + + private static boolean matchesUserCode(OAuth2Authorization authorization, String token) { + OAuth2Authorization.Token userCode = + authorization.getToken(OAuth2UserCode.class); + return userCode != null && userCode.getToken().getTokenValue().equals(token); + } + private static final class MaxSizeHashMap extends LinkedHashMap { private final int maxSize; diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationConsentService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationConsentService.java index e489fc0a4..24ca449e6 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationConsentService.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationConsentService.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,11 +43,17 @@ * {@link JdbcOperations} for {@link OAuth2AuthorizationConsent} persistence. * *

- * NOTE: This {@code OAuth2AuthorizationConsentService} depends on the table definition + * IMPORTANT: This {@code OAuth2AuthorizationConsentService} depends on the table definition * described in * "classpath:org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql" and * therefore MUST be defined in the database schema. * + *

+ * NOTE: This {@code OAuth2AuthorizationConsentService} is a simplified JDBC implementation that MAY be used in a production environment. + * However, it does have limitations as it likely won't perform well in an environment requiring high throughput. + * The expectation is that the consuming application will provide their own implementation of {@code OAuth2AuthorizationConsentService} + * that meets the performance requirements for its deployment environment. + * * @author Ovidiu Popa * @since 0.1.2 * @see OAuth2AuthorizationConsentService diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java index e053a99c5..96e089669 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,10 +49,13 @@ import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; @@ -65,11 +68,17 @@ * {@link JdbcOperations} for {@link OAuth2Authorization} persistence. * *

- * NOTE: This {@code OAuth2AuthorizationService} depends on the table definition + * IMPORTANT: This {@code OAuth2AuthorizationService} depends on the table definition * described in * "classpath:org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql" and * therefore MUST be defined in the database schema. * + *

+ * NOTE: This {@code OAuth2AuthorizationService} is a simplified JDBC implementation that MAY be used in a production environment. + * However, it does have limitations as it likely won't perform well in an environment requiring high throughput. + * The expectation is that the consuming application will provide their own implementation of {@code OAuth2AuthorizationService} + * that meets the performance requirements for its deployment environment. + * * @author Ovidiu Popa * @author Joe Grandja * @since 0.1.2 @@ -105,19 +114,31 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic + "refresh_token_value," + "refresh_token_issued_at," + "refresh_token_expires_at," - + "refresh_token_metadata"; + + "refresh_token_metadata," + + "user_code_value," + + "user_code_issued_at," + + "user_code_expires_at," + + "user_code_metadata," + + "device_code_value," + + "device_code_issued_at," + + "device_code_expires_at," + + "device_code_metadata"; // @formatter:on private static final String TABLE_NAME = "oauth2_authorization"; private static final String PK_FILTER = "id = ?"; - private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorization_code_value = ? OR " + - "access_token_value = ? OR refresh_token_value = ?"; + private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorization_code_value = ? OR " + + "access_token_value = ? OR oidc_id_token_value = ? OR refresh_token_value = ? OR user_code_value = ? OR " + + "device_code_value = ?"; private static final String STATE_FILTER = "state = ?"; private static final String AUTHORIZATION_CODE_FILTER = "authorization_code_value = ?"; private static final String ACCESS_TOKEN_FILTER = "access_token_value = ?"; + private static final String ID_TOKEN_FILTER = "oidc_id_token_value = ?"; private static final String REFRESH_TOKEN_FILTER = "refresh_token_value = ?"; + private static final String USER_CODE_FILTER = "user_code_value = ?"; + private static final String DEVICE_CODE_FILTER = "device_code_value = ?"; // @formatter:off private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES @@ -127,7 +148,7 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic // @formatter:off private static final String SAVE_AUTHORIZATION_SQL = "INSERT INTO " + TABLE_NAME - + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; // @formatter:on // @formatter:off @@ -136,7 +157,9 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic + " authorization_code_value = ?, authorization_code_issued_at = ?, authorization_code_expires_at = ?, authorization_code_metadata = ?," + " access_token_value = ?, access_token_issued_at = ?, access_token_expires_at = ?, access_token_metadata = ?, access_token_type = ?, access_token_scopes = ?," + " oidc_id_token_value = ?, oidc_id_token_issued_at = ?, oidc_id_token_expires_at = ?, oidc_id_token_metadata = ?," - + " refresh_token_value = ?, refresh_token_issued_at = ?, refresh_token_expires_at = ?, refresh_token_metadata = ?" + + " refresh_token_value = ?, refresh_token_issued_at = ?, refresh_token_expires_at = ?, refresh_token_metadata = ?," + + " user_code_value = ?, user_code_issued_at = ?, user_code_expires_at = ?, user_code_metadata = ?," + + " device_code_value = ?, device_code_issued_at = ?, device_code_expires_at = ?, device_code_metadata = ?" + " WHERE " + PK_FILTER; // @formatter:on @@ -240,7 +263,10 @@ public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType t parameters.add(new SqlParameterValue(Types.VARCHAR, token)); parameters.add(mapToSqlParameter("authorization_code_value", token)); parameters.add(mapToSqlParameter("access_token_value", token)); + parameters.add(mapToSqlParameter("oidc_id_token_value", token)); parameters.add(mapToSqlParameter("refresh_token_value", token)); + parameters.add(mapToSqlParameter("user_code_value", token)); + parameters.add(mapToSqlParameter("device_code_value", token)); return findBy(UNKNOWN_TOKEN_TYPE_FILTER, parameters); } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { parameters.add(new SqlParameterValue(Types.VARCHAR, token)); @@ -251,9 +277,18 @@ public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType t } else if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) { parameters.add(mapToSqlParameter("access_token_value", token)); return findBy(ACCESS_TOKEN_FILTER, parameters); + } else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) { + parameters.add(mapToSqlParameter("oidc_id_token_value", token)); + return findBy(ID_TOKEN_FILTER, parameters); } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { parameters.add(mapToSqlParameter("refresh_token_value", token)); return findBy(REFRESH_TOKEN_FILTER, parameters); + } else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) { + parameters.add(mapToSqlParameter("user_code_value", token)); + return findBy(USER_CODE_FILTER, parameters); + } else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) { + parameters.add(mapToSqlParameter("device_code_value", token)); + return findBy(DEVICE_CODE_FILTER, parameters); } return null; } @@ -419,6 +454,27 @@ public OAuth2Authorization mapRow(ResultSet rs, int rowNum) throws SQLException refreshTokenValue, tokenIssuedAt, tokenExpiresAt); builder.token(refreshToken, (metadata) -> metadata.putAll(refreshTokenMetadata)); } + + String userCodeValue = getLobValue(rs, "user_code_value"); + if (StringUtils.hasText(userCodeValue)) { + tokenIssuedAt = rs.getTimestamp("user_code_issued_at").toInstant(); + tokenExpiresAt = rs.getTimestamp("user_code_expires_at").toInstant(); + Map userCodeMetadata = parseMap(getLobValue(rs, "user_code_metadata")); + + OAuth2UserCode userCode = new OAuth2UserCode(userCodeValue, tokenIssuedAt, tokenExpiresAt); + builder.token(userCode, (metadata) -> metadata.putAll(userCodeMetadata)); + } + + String deviceCodeValue = getLobValue(rs, "device_code_value"); + if (StringUtils.hasText(deviceCodeValue)) { + tokenIssuedAt = rs.getTimestamp("device_code_issued_at").toInstant(); + tokenExpiresAt = rs.getTimestamp("device_code_expires_at").toInstant(); + Map deviceCodeMetadata = parseMap(getLobValue(rs, "device_code_metadata")); + + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, tokenIssuedAt, tokenExpiresAt); + builder.token(deviceCode, (metadata) -> metadata.putAll(deviceCodeMetadata)); + } + return builder.build(); } @@ -539,6 +595,17 @@ public List apply(OAuth2Authorization authorization) { List refreshTokenSqlParameters = toSqlParameterList( "refresh_token_value", "refresh_token_metadata", refreshToken); parameters.addAll(refreshTokenSqlParameters); + + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + List userCodeSqlParameters = toSqlParameterList( + "user_code_value", "user_code_metadata", userCode); + parameters.addAll(userCodeSqlParameters); + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + List deviceCodeSqlParameters = toSqlParameterList( + "device_code_value", "device_code_metadata", deviceCode); + parameters.addAll(deviceCodeSqlParameters); + return parameters; } @@ -664,6 +731,14 @@ private static void initColumnMetadata(JdbcOperations jdbcOperations) { columnMetadataMap.put(columnMetadata.getColumnName(), columnMetadata); columnMetadata = getColumnMetadata(jdbcOperations, "refresh_token_metadata", Types.BLOB); columnMetadataMap.put(columnMetadata.getColumnName(), columnMetadata); + columnMetadata = getColumnMetadata(jdbcOperations, "user_code_value", Types.BLOB); + columnMetadataMap.put(columnMetadata.getColumnName(), columnMetadata); + columnMetadata = getColumnMetadata(jdbcOperations, "user_code_metadata", Types.BLOB); + columnMetadataMap.put(columnMetadata.getColumnName(), columnMetadata); + columnMetadata = getColumnMetadata(jdbcOperations, "device_code_value", Types.BLOB); + columnMetadataMap.put(columnMetadata.getColumnName(), columnMetadata); + columnMetadata = getColumnMetadata(jdbcOperations, "device_code_metadata", Types.BLOB); + columnMetadataMap.put(columnMetadata.getColumnName(), columnMetadata); } private static ColumnMetadata getColumnMetadata(JdbcOperations jdbcOperations, String columnName, int defaultDataType) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java index 84d341fd4..3d13f4bd7 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -93,7 +93,7 @@ public Set getScopes() { Set authorities = new HashSet<>(); for (GrantedAuthority authority : getAuthorities()) { if (authority.getAuthority().startsWith(AUTHORITIES_SCOPE_PREFIX)) { - authorities.add(authority.getAuthority().replaceFirst(AUTHORITIES_SCOPE_PREFIX, "")); + authorities.add(authority.getAuthority().substring(AUTHORITIES_SCOPE_PREFIX.length())); } } return authorities; diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimAccessor.java index 8767843f5..089944d10 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimAccessor.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ * @see OAuth2AuthorizationServerMetadataClaimNames * @see 2. Authorization Server Metadata * @see 3. OpenID Provider Metadata + * @see 4. Device Authorization Grant Metadata */ public interface OAuth2AuthorizationServerMetadataClaimAccessor extends ClaimAccessor { @@ -51,6 +52,16 @@ default URL getAuthorizationEndpoint() { return getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT); } + /** + * Returns the {@code URL} of the OAuth 2.0 Device Authorization Endpoint {@code (device_authorization_endpoint)}. + * + * @return the {@code URL} of the OAuth 2.0 Device Authorization Endpoint + * @since 1.1 + */ + default URL getDeviceAuthorizationEndpoint() { + return getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.DEVICE_AUTHORIZATION_ENDPOINT); + } + /** * Returns the {@code URL} of the OAuth 2.0 Token Endpoint {@code (token_endpoint)}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimNames.java index a4fb116ae..831e0ec7e 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataClaimNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ * @since 0.1.1 * @see 2. Authorization Server Metadata * @see 3. OpenID Provider Metadata + * @see 4. Device Authorization Grant Metadata */ public class OAuth2AuthorizationServerMetadataClaimNames { @@ -36,6 +37,12 @@ public class OAuth2AuthorizationServerMetadataClaimNames { */ public static final String AUTHORIZATION_ENDPOINT = "authorization_endpoint"; + /** + * {@code device_authorization_endpoint} - the {@code URL} of the OAuth 2.0 Device Authorization Endpoint + * @since 1.1 + */ + public static final String DEVICE_AUTHORIZATION_ENDPOINT = "device_authorization_endpoint"; + /** * {@code token_endpoint} - the {@code URL} of the OAuth 2.0 Token Endpoint */ diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProvider.java index 60e824859..b71065210 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -122,6 +122,13 @@ public Authentication authenticate(Authentication authentication) throws Authent throwInvalidClient("client_secret_expires_at"); } + if (this.passwordEncoder.upgradeEncoding(registeredClient.getClientSecret())) { + registeredClient = RegisteredClient.from(registeredClient) + .clientSecret(this.passwordEncoder.encode(clientSecret)) + .build(); + this.registeredClientRepository.save(registeredClient); + } + if (this.logger.isTraceEnabled()) { this.logger.trace("Validated client authentication parameters"); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/CodeVerifierAuthenticator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/CodeVerifierAuthenticator.java index 98a577ee6..dc063bead 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/CodeVerifierAuthenticator.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/CodeVerifierAuthenticator.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -94,8 +94,10 @@ private boolean authenticate(OAuth2ClientAuthenticationToken clientAuthenticatio String codeChallenge = (String) authorizationRequest.getAdditionalParameters() .get(PkceParameterNames.CODE_CHALLENGE); + String codeVerifier = (String) parameters.get(PkceParameterNames.CODE_VERIFIER); if (!StringUtils.hasText(codeChallenge)) { - if (registeredClient.getClientSettings().isRequireProofKey()) { + if (registeredClient.getClientSettings().isRequireProofKey() || + StringUtils.hasText(codeVerifier)) { throwInvalidGrant(PkceParameterNames.CODE_CHALLENGE); } else { if (this.logger.isTraceEnabled()) { @@ -111,7 +113,6 @@ private boolean authenticate(OAuth2ClientAuthenticationToken clientAuthenticatio String codeChallengeMethod = (String) authorizationRequest.getAdditionalParameters() .get(PkceParameterNames.CODE_CHALLENGE_METHOD); - String codeVerifier = (String) parameters.get(PkceParameterNames.CODE_VERIFIER); if (!codeVerifierValid(codeVerifier, codeChallenge, codeChallengeMethod)) { throwInvalidGrant(PkceParameterNames.CODE_VERIFIER); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java index 95bc0ff8f..3e49e7f4e 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import javax.crypto.spec.SecretKeySpec; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -51,6 +52,7 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; /** @@ -87,6 +89,15 @@ public final class JwtClientAssertionDecoderFactory implements JwtDecoderFactory JCA_ALGORITHM_MAPPINGS = Collections.unmodifiableMap(mappings); } + private static final RestTemplate restTemplate = new RestTemplate(); + + static { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(15_000); + requestFactory.setReadTimeout(15_000); + restTemplate.setRequestFactory(requestFactory); + } + private final Map jwtDecoders = new ConcurrentHashMap<>(); private Function> jwtValidatorFactory = DEFAULT_JWT_VALIDATOR_FACTORY; @@ -124,7 +135,8 @@ private static NimbusJwtDecoder buildDecoder(RegisteredClient registeredClient) JWT_CLIENT_AUTHENTICATION_ERROR_URI); throw new OAuth2AuthenticationException(oauth2Error); } - return NimbusJwtDecoder.withJwkSetUri(jwkSetUrl).jwsAlgorithm((SignatureAlgorithm) jwsAlgorithm).build(); + return NimbusJwtDecoder.withJwkSetUri(jwkSetUrl).jwsAlgorithm((SignatureAlgorithm) jwsAlgorithm) + .restOperations(restTemplate).build(); } if (jwsAlgorithm instanceof MacAlgorithm) { String clientSecret = registeredClient.getClientSecret(); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java index b795674c8..ab84a3bd0 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,9 +15,16 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.Principal; +import java.util.ArrayList; +import java.util.Base64; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.apache.commons.logging.Log; @@ -27,6 +34,8 @@ import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -52,6 +61,7 @@ import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient; @@ -79,6 +89,7 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth private final Log logger = LogFactory.getLog(getClass()); private final OAuth2AuthorizationService authorizationService; private final OAuth2TokenGenerator tokenGenerator; + private SessionRegistry sessionRegistry; /** * Constructs an {@code OAuth2AuthorizationCodeAuthenticationProvider} using the provided parameters. @@ -142,6 +153,19 @@ public Authentication authenticate(Authentication authentication) throws Authent } if (!authorizationCode.isActive()) { + if (authorizationCode.isInvalidated()) { + OAuth2Authorization.Token token = authorization.getRefreshToken() != null ? + authorization.getRefreshToken() : + authorization.getAccessToken(); + if (token != null) { + // Invalidate the access (and refresh) token as the client is attempting to use the authorization code more than once + authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, token.getToken()); + this.authorizationService.save(authorization); + if (this.logger.isWarnEnabled()) { + this.logger.warn(LogMessage.format("Invalidated authorization token(s) previously issued to registered client '%s'", registeredClient.getId())); + } + } + } throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); } @@ -149,10 +173,12 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Validated token request parameters"); } + Authentication principal = authorization.getAttribute(Principal.class.getName()); + // @formatter:off DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() .registeredClient(registeredClient) - .principal(authorization.getAttribute(Principal.class.getName())) + .principal(principal) .authorizationServerContext(AuthorizationServerContextHolder.getContext()) .authorization(authorization) .authorizedScopes(authorization.getAuthorizedScopes()) @@ -210,6 +236,19 @@ public Authentication authenticate(Authentication authentication) throws Authent // ----- ID token ----- OidcIdToken idToken; if (authorizationRequest.getScopes().contains(OidcScopes.OPENID)) { + SessionInformation sessionInformation = getSessionInformation(principal); + if (sessionInformation != null) { + try { + // Compute (and use) hash for Session ID + sessionInformation = new SessionInformation(sessionInformation.getPrincipal(), + createHash(sessionInformation.getSessionId()), sessionInformation.getLastRequest()); + } catch (NoSuchAlgorithmException ex) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "Failed to compute hash for Session ID.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + tokenContextBuilder.put(SessionInformation.class, sessionInformation); + } // @formatter:off tokenContext = tokenContextBuilder .tokenType(ID_TOKEN_TOKEN_TYPE) @@ -265,4 +304,38 @@ public boolean supports(Class authentication) { return OAuth2AuthorizationCodeAuthenticationToken.class.isAssignableFrom(authentication); } + /** + * Sets the {@link SessionRegistry} used to track OpenID Connect sessions. + * + * @param sessionRegistry the {@link SessionRegistry} used to track OpenID Connect sessions + * @since 1.1 + */ + public void setSessionRegistry(SessionRegistry sessionRegistry) { + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.sessionRegistry = sessionRegistry; + } + + private SessionInformation getSessionInformation(Authentication principal) { + SessionInformation sessionInformation = null; + if (this.sessionRegistry != null) { + List sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), false); + if (!CollectionUtils.isEmpty(sessions)) { + sessionInformation = sessions.get(0); + if (sessions.size() > 1) { + // Get the most recent session + sessions = new ArrayList<>(sessions); + sessions.sort(Comparator.comparing(SessionInformation::getLastRequest)); + sessionInformation = sessions.get(sessions.size() - 1); + } + } + } + return sessionInformation; + } + + private static String createHash(String value) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java index 1c3a16663..aecc5b385 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -333,7 +333,7 @@ private static void throwError(OAuth2Error error, String parameterName, OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest) { - String redirectUri = resolveRedirectUri(authorizationRequest, registeredClient); + String redirectUri = resolveRedirectUri(authorizationCodeRequestAuthentication, authorizationRequest, registeredClient); if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST) && (parameterName.equals(OAuth2ParameterNames.CLIENT_ID) || parameterName.equals(OAuth2ParameterNames.STATE))) { @@ -350,7 +350,13 @@ private static void throwError(OAuth2Error error, String parameterName, throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthenticationResult); } - private static String resolveRedirectUri(OAuth2AuthorizationRequest authorizationRequest, RegisteredClient registeredClient) { + private static String resolveRedirectUri( + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + OAuth2AuthorizationRequest authorizationRequest, RegisteredClient registeredClient) { + + if (authorizationCodeRequestAuthentication != null && StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) { + return authorizationCodeRequestAuthentication.getRedirectUri(); + } if (authorizationRequest != null && StringUtils.hasText(authorizationRequest.getRedirectUri())) { return authorizationRequest.getRedirectUri(); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java index 742325b3b..2c8dc2ad0 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -100,23 +100,8 @@ private static void validateRedirectUri(OAuth2AuthorizationCodeRequestAuthentica authorizationCodeRequestAuthentication, registeredClient); } - String requestedRedirectHost = requestedRedirect.getHost(); - if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) { - // As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-9.7.1 - // While redirect URIs using localhost (i.e., "http://localhost:{port}/{path}") - // function similarly to loopback IP redirects described in Section 10.3.3, - // the use of "localhost" is NOT RECOMMENDED. - OAuth2Error error = new OAuth2Error( - OAuth2ErrorCodes.INVALID_REQUEST, - "localhost is not allowed for the redirect_uri (" + requestedRedirectUri + "). " + - "Use the IP literal (127.0.0.1) instead.", - "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-9.7.1"); - throwError(error, OAuth2ParameterNames.REDIRECT_URI, - authorizationCodeRequestAuthentication, registeredClient); - } - - if (!isLoopbackAddress(requestedRedirectHost)) { - // As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-9.7 + if (!isLoopbackAddress(requestedRedirect.getHost())) { + // As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-22#section-4.1.3 // When comparing client redirect URIs against pre-registered URIs, // authorization servers MUST utilize exact string matching. if (!registeredClient.getRedirectUris().contains(requestedRedirectUri)) { @@ -124,7 +109,7 @@ private static void validateRedirectUri(OAuth2AuthorizationCodeRequestAuthentica authorizationCodeRequestAuthentication, registeredClient); } } else { - // As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-10.3.3 + // As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-08#section-8.4.2 // The authorization server MUST allow any port to be specified at the // time of the request for loopback IP redirect URIs, to accommodate // clients that obtain an available ephemeral port from the operating @@ -157,6 +142,9 @@ private static void validateRedirectUri(OAuth2AuthorizationCodeRequestAuthentica } private static boolean isLoopbackAddress(String host) { + if (!StringUtils.hasText(host)) { + return false; + } // IPv6 loopback address should either be "0:0:0:0:0:0:0:1" or "::1" if ("[0:0:0:0:0:0:0:1]".equals(host) || "[::1]".equals(host)) { return true; diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java index c8e572c7a..a60f03987 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.util.function.Consumer; import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; @@ -161,8 +162,11 @@ public Builder authorizationRequest(OAuth2AuthorizationRequest authorizationRequ public OAuth2AuthorizationConsentAuthenticationContext build() { Assert.notNull(get(OAuth2AuthorizationConsent.Builder.class), "authorizationConsentBuilder cannot be null"); Assert.notNull(get(RegisteredClient.class), "registeredClient cannot be null"); - Assert.notNull(get(OAuth2Authorization.class), "authorization cannot be null"); - Assert.notNull(get(OAuth2AuthorizationRequest.class), "authorizationRequest cannot be null"); + OAuth2Authorization authorization = get(OAuth2Authorization.class); + Assert.notNull(authorization, "authorization cannot be null"); + if (authorization.getAuthorizationGrantType().equals(AuthorizationGrantType.AUTHORIZATION_CODE)) { + Assert.notNull(get(OAuth2AuthorizationRequest.class), "authorizationRequest cannot be null"); + } return new OAuth2AuthorizationConsentAuthenticationContext(getContext()); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationProvider.java index 9603393d6..8cdd789bc 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationProvider.java @@ -91,6 +91,12 @@ public OAuth2AuthorizationConsentAuthenticationProvider(RegisteredClientReposito @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication instanceof OAuth2DeviceAuthorizationConsentAuthenticationToken) { + // This is NOT an OAuth 2.0 Authorization Consent for the Authorization Code Grant, + // return null and let OAuth2DeviceAuthorizationConsentAuthenticationProvider handle it instead + return null; + } + OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthentication = (OAuth2AuthorizationConsentAuthenticationToken) authentication; diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java new file mode 100644 index 000000000..a3cc37aa6 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java @@ -0,0 +1,267 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the Device Authorization Consent + * used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceAuthorizationConsentAuthenticationToken + * @see OAuth2AuthorizationConsent + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider + * @see RegisteredClientRepository + * @see OAuth2AuthorizationService + * @see OAuth2AuthorizationConsentService + */ +public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + private final OAuth2AuthorizationService authorizationService; + private final OAuth2AuthorizationConsentService authorizationConsentService; + private Consumer authorizationConsentCustomizer; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationProvider} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param authorizationConsentService the authorization consent service + */ + public OAuth2DeviceAuthorizationConsentAuthenticationProvider( + RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, + OAuth2AuthorizationConsentService authorizationConsentService) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + this.authorizationConsentService = authorizationConsentService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication; + + OAuth2Authorization authorization = this.authorizationService.findByToken( + deviceAuthorizationConsentAuthentication.getState(), STATE_TOKEN_TYPE); + if (authorization == null) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with device authorization consent state"); + } + + // The authorization must be associated to the current principal + Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal(); + if (!isPrincipalAuthenticated(principal) || !principal.getName().equals(authorization.getPrincipalName())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE); + } + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( + deviceAuthorizationConsentAuthentication.getClientId()); + if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + Set requestedScopes = authorization.getAttribute(OAuth2ParameterNames.SCOPE); + Set authorizedScopes = new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()); + if (!requestedScopes.containsAll(authorizedScopes)) { + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device authorization consent request parameters"); + } + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + authorization.getRegisteredClientId(), principal.getName()); + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : Collections.emptySet(); + + if (!currentAuthorizedScopes.isEmpty()) { + for (String requestedScope : requestedScopes) { + if (currentAuthorizedScopes.contains(requestedScope)) { + authorizedScopes.add(requestedScope); + } + } + } + + OAuth2AuthorizationConsent.Builder authorizationConsentBuilder; + if (currentAuthorizationConsent != null) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved existing authorization consent"); + } + authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent); + } else { + authorizationConsentBuilder = OAuth2AuthorizationConsent.withId( + authorization.getRegisteredClientId(), principal.getName()); + } + authorizedScopes.forEach(authorizationConsentBuilder::scope); + + if (this.authorizationConsentCustomizer != null) { + // @formatter:off + OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext = + OAuth2AuthorizationConsentAuthenticationContext.with(deviceAuthorizationConsentAuthentication) + .authorizationConsent(authorizationConsentBuilder) + .registeredClient(registeredClient) + .authorization(authorization) + .build(); + // @formatter:on + this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Customized authorization consent"); + } + } + + Set authorities = new HashSet<>(); + authorizationConsentBuilder.authorities(authorities::addAll); + + OAuth2Authorization.Token deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class); + OAuth2Authorization.Token userCodeToken = authorization.getToken(OAuth2UserCode.class); + + if (authorities.isEmpty()) { + // Authorization consent denied (or revoked) + if (currentAuthorizationConsent != null) { + this.authorizationConsentService.remove(currentAuthorizationConsent); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Revoked authorization consent"); + } + } + authorization = OAuth2Authorization.from(authorization) + .token(deviceCodeToken.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .token(userCodeToken.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .build(); + this.authorizationService.save(authorization); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Invalidated device code and user code because authorization consent was denied"); + } + throwError(OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID); + } + + OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); + if (!authorizationConsent.equals(currentAuthorizationConsent)) { + this.authorizationConsentService.save(authorizationConsent); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization consent"); + } + } + + authorization = OAuth2Authorization.from(authorization) + .authorizedScopes(authorizedScopes) + .token(userCodeToken.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.SCOPE)) + .build(); + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization with authorized scopes"); + // This log is kept separate for consistency with other providers + this.logger.trace("Authenticated device authorization consent request"); + } + + return new OAuth2DeviceVerificationAuthenticationToken(principal, + deviceAuthorizationConsentAuthentication.getUserCode(), registeredClient.getClientId()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceAuthorizationConsentAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationConsentAuthenticationContext} + * containing an {@link OAuth2AuthorizationConsent.Builder} and additional context information. + * + *

+ * The following context attributes are available: + *

    + *
  • The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent + * prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.
  • + *
  • The {@link Authentication} of type + * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.
  • + *
  • The {@link RegisteredClient} associated with the device authorization request.
  • + *
  • The {@link OAuth2Authorization} associated with the state token presented in the + * device authorization consent request.
  • + *
+ * + * @param authorizationConsentCustomizer the {@code Consumer} providing access to the + * {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder} + */ + public void setAuthorizationConsentCustomizer(Consumer authorizationConsentCustomizer) { + Assert.notNull(authorizationConsentCustomizer, "authorizationConsentCustomizer cannot be null"); + this.authorizationConsentCustomizer = authorizationConsentCustomizer; + } + + private static boolean isPrincipalAuthenticated(Authentication principal) { + return principal != null && + !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && + principal.isAuthenticated(); + } + + private static void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java new file mode 100644 index 000000000..fbfe3d35f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java @@ -0,0 +1,106 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Device Authorization Consent used + * in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AbstractAuthenticationToken + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + */ +public class OAuth2DeviceAuthorizationConsentAuthenticationToken extends OAuth2AuthorizationConsentAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final String userCode; + private final Set requestedScopes; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationToken} using the provided parameters. + * + * @param authorizationUri the authorization URI + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization response + * @param state the state + * @param authorizedScopes the authorized scope(s) + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceAuthorizationConsentAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, String userCode, String state, @Nullable Set authorizedScopes, + @Nullable Map additionalParameters) { + super(authorizationUri, clientId, principal, state, authorizedScopes, additionalParameters); + Assert.hasText(userCode, "userCode cannot be empty"); + this.userCode = userCode; + this.requestedScopes = null; + setAuthenticated(false); + } + + /** + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationToken} using the provided parameters. + * + * @param authorizationUri the authorization URI + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization response + * @param state the state + * @param requestedScopes the requested scope(s) + * @param authorizedScopes the authorized scope(s) + */ + public OAuth2DeviceAuthorizationConsentAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, String userCode, String state, @Nullable Set requestedScopes, + @Nullable Set authorizedScopes) { + super(authorizationUri, clientId, principal, state, authorizedScopes, null); + Assert.hasText(userCode, "userCode cannot be empty"); + this.userCode = userCode; + this.requestedScopes = Collections.unmodifiableSet( + requestedScopes != null ? + new HashSet<>(requestedScopes) : + Collections.emptySet()); + setAuthenticated(true); + } + + /** + * Returns the user code. + * + * @return the user code + */ + public String getUserCode() { + return this.userCode; + } + + /** + * Returns the requested scopes. + * + * @return the requested scopes + */ + public Set getRequestedScopes() { + return this.requestedScopes; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java new file mode 100644 index 000000000..d21ed9c41 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java @@ -0,0 +1,270 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.time.Instant; +import java.util.Base64; +import java.util.HashSet; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient; + +/** + * An {@link AuthenticationProvider} implementation for the Device Authorization Request + * used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceAuthorizationRequestAuthenticationToken + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider + * @see OAuth2AuthorizationService + * @see OAuth2TokenGenerator + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.1 Device Authorization Request + */ +public final class OAuth2DeviceAuthorizationRequestAuthenticationProvider implements AuthenticationProvider { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE); + + private final Log logger = LogFactory.getLog(getClass()); + private final OAuth2AuthorizationService authorizationService; + private OAuth2TokenGenerator deviceCodeGenerator = new OAuth2DeviceCodeGenerator(); + private OAuth2TokenGenerator userCodeGenerator = new OAuth2UserCodeGenerator(); + + /** + * Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + */ + public OAuth2DeviceAuthorizationRequestAuthenticationProvider(OAuth2AuthorizationService authorizationService) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + this.authorizationService = authorizationService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = + getAuthenticatedClientElseThrowInvalidClient(deviceAuthorizationRequestAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.DEVICE_CODE)) { + throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID); + } + + Set requestedScopes = deviceAuthorizationRequestAuthentication.getScopes(); + if (!CollectionUtils.isEmpty(requestedScopes)) { + for (String requestedScope : requestedScopes) { + if (!registeredClient.getScopes().contains(requestedScope)) { + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE); + } + } + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device authorization request parameters"); + } + + // @formatter:off + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(clientPrincipal) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .authorizationGrant(deviceAuthorizationRequestAuthentication); + // @formatter:on + + // Generate a high-entropy string to use as the device code + OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(DEVICE_CODE_TOKEN_TYPE).build(); + OAuth2DeviceCode deviceCode = this.deviceCodeGenerator.generate(tokenContext); + if (deviceCode == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the device code.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated device code"); + } + + // Generate a low-entropy string to use as the user code + tokenContext = tokenContextBuilder.tokenType(USER_CODE_TOKEN_TYPE).build(); + OAuth2UserCode userCode = this.userCodeGenerator.generate(tokenContext); + if (userCode == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the user code.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated user code"); + } + + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(clientPrincipal.getName()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(deviceCode) + .token(userCode) + .attribute(OAuth2ParameterNames.SCOPE, new HashSet<>(requestedScopes)) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated device authorization request"); + } + + return new OAuth2DeviceAuthorizationRequestAuthenticationToken( + clientPrincipal, requestedScopes, deviceCode, userCode); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceAuthorizationRequestAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Sets the {@link OAuth2TokenGenerator} that generates the {@link OAuth2DeviceCode}. + * + * @param deviceCodeGenerator the {@link OAuth2TokenGenerator} that generates the {@link OAuth2DeviceCode} + */ + public void setDeviceCodeGenerator(OAuth2TokenGenerator deviceCodeGenerator) { + Assert.notNull(deviceCodeGenerator, "deviceCodeGenerator cannot be null"); + this.deviceCodeGenerator = deviceCodeGenerator; + } + + /** + * Sets the {@link OAuth2TokenGenerator} that generates the {@link OAuth2UserCode}. + * + * @param userCodeGenerator the {@link OAuth2TokenGenerator} that generates the {@link OAuth2UserCode} + */ + public void setUserCodeGenerator(OAuth2TokenGenerator userCodeGenerator) { + Assert.notNull(userCodeGenerator, "userCodeGenerator cannot be null"); + this.userCodeGenerator = userCodeGenerator; + } + + private static void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + private static final class OAuth2DeviceCodeGenerator implements OAuth2TokenGenerator { + + private final StringKeyGenerator deviceCodeGenerator = + new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96); + + @Nullable + @Override + public OAuth2DeviceCode generate(OAuth2TokenContext context) { + if (context.getTokenType() == null || + !OAuth2ParameterNames.DEVICE_CODE.equals(context.getTokenType().getValue())) { + return null; + } + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getDeviceCodeTimeToLive()); + return new OAuth2DeviceCode(this.deviceCodeGenerator.generateKey(), issuedAt, expiresAt); + } + + } + + private static final class UserCodeStringKeyGenerator implements StringKeyGenerator { + + // @formatter:off + private static final char[] VALID_CHARS = { + 'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', + 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Z' + }; + // @formatter:on + + private final BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom(8); + + @Override + public String generateKey() { + byte[] bytes = this.keyGenerator.generateKey(); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + int offset = Math.abs(b % 20); + sb.append(VALID_CHARS[offset]); + } + sb.insert(4, '-'); + return sb.toString(); + } + + } + + private static final class OAuth2UserCodeGenerator implements OAuth2TokenGenerator { + + private final StringKeyGenerator userCodeGenerator = new UserCodeStringKeyGenerator(); + + @Nullable + @Override + public OAuth2UserCode generate(OAuth2TokenContext context) { + if (context.getTokenType() == null || + !OAuth2ParameterNames.USER_CODE.equals(context.getTokenType().getValue())) { + return null; + } + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getDeviceCodeTimeToLive()); + return new OAuth2UserCode(this.userCodeGenerator.generateKey(), issuedAt, expiresAt); + } + + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java new file mode 100644 index 000000000..c80d9b647 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java @@ -0,0 +1,159 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Device Authorization Request + * used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AbstractAuthenticationToken + * @see OAuth2ClientAuthenticationToken + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + */ +public class OAuth2DeviceAuthorizationRequestAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final Authentication clientPrincipal; + private final String authorizationUri; + private final Set scopes; + private final OAuth2DeviceCode deviceCode; + private final OAuth2UserCode userCode; + private final Map additionalParameters; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationToken} using the provided parameters. + * + * @param clientPrincipal the authenticated client principal + * @param authorizationUri the authorization {@code URI} + * @param scopes the requested scope(s) + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication clientPrincipal, String authorizationUri, + @Nullable Set scopes, @Nullable Map additionalParameters) { + super(Collections.emptyList()); + Assert.notNull(clientPrincipal, "clientPrincipal cannot be null"); + Assert.hasText(authorizationUri, "authorizationUri cannot be empty"); + this.clientPrincipal = clientPrincipal; + this.authorizationUri = authorizationUri; + this.scopes = Collections.unmodifiableSet( + scopes != null ? + new HashSet<>(scopes) : + Collections.emptySet()); + this.additionalParameters = Collections.unmodifiableMap( + additionalParameters != null ? + new HashMap<>(additionalParameters) : + Collections.emptyMap()); + this.deviceCode = null; + this.userCode = null; + } + + /** + * Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationToken} using the provided parameters. + * + * @param clientPrincipal the authenticated client principal + * @param scopes the requested scope(s) + * @param deviceCode the {@link OAuth2DeviceCode} + * @param userCode the {@link OAuth2UserCode} + */ + public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication clientPrincipal, @Nullable Set scopes, + OAuth2DeviceCode deviceCode, OAuth2UserCode userCode) { + super(Collections.emptyList()); + Assert.notNull(clientPrincipal, "clientPrincipal cannot be null"); + Assert.notNull(deviceCode, "deviceCode cannot be null"); + Assert.notNull(userCode, "userCode cannot be null"); + this.clientPrincipal = clientPrincipal; + this.scopes = Collections.unmodifiableSet( + scopes != null ? + new HashSet<>(scopes) : + Collections.emptySet()); + this.deviceCode = deviceCode; + this.userCode = userCode; + this.authorizationUri = null; + this.additionalParameters = null; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.clientPrincipal; + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the authorization {@code URI}. + * + * @return the authorization {@code URI} + */ + public String getAuthorizationUri() { + return this.authorizationUri; + } + + /** + * Returns the requested scope(s). + * + * @return the requested scope(s) + */ + public Set getScopes() { + return this.scopes; + } + + /** + * Returns the device code. + * + * @return the device code + */ + public OAuth2DeviceCode getDeviceCode() { + return this.deviceCode; + } + + /** + * Returns the user code. + * + * @return the user code + */ + public OAuth2UserCode getUserCode() { + return this.userCode; + } + + /** + * Returns the additional parameters. + * + * @return the additional parameters + */ + public Map getAdditionalParameters() { + return this.additionalParameters; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java new file mode 100644 index 000000000..5e08ea541 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java @@ -0,0 +1,265 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; + +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient; + +/** + * An {@link AuthenticationProvider} implementation for the Device Access Token Request + * used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceCodeAuthenticationToken + * @see OAuth2AccessTokenAuthenticationToken + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth2AuthorizationService + * @see OAuth2TokenGenerator + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.4 Device Access Token Request + * @see Section 3.5 Device Access Token Response + */ +public final class OAuth2DeviceCodeAuthenticationProvider implements AuthenticationProvider { + + private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.5"; + static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + static final String EXPIRED_TOKEN = "expired_token"; + static final String AUTHORIZATION_PENDING = "authorization_pending"; + + private final Log logger = LogFactory.getLog(getClass()); + private final OAuth2AuthorizationService authorizationService; + private final OAuth2TokenGenerator tokenGenerator; + + /** + * Constructs an {@code OAuth2DeviceCodeAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + * @param tokenGenerator the token generator + */ + public OAuth2DeviceCodeAuthenticationProvider( + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(tokenGenerator, "tokenGenerator cannot be null"); + this.authorizationService = authorizationService; + this.tokenGenerator = tokenGenerator; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceCodeAuthenticationToken deviceCodeAuthentication = + (OAuth2DeviceCodeAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = + getAuthenticatedClientElseThrowInvalidClient(deviceCodeAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + OAuth2Authorization authorization = this.authorizationService.findByToken( + deviceCodeAuthentication.getDeviceCode(), DEVICE_CODE_TOKEN_TYPE); + if (authorization == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with device code"); + } + + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + + if (!registeredClient.getId().equals(authorization.getRegisteredClientId())) { + if (!deviceCode.isInvalidated()) { + // Invalidate the device code given that a different client is attempting to use it + authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, deviceCode.getToken()); + this.authorizationService.save(authorization); + if (this.logger.isWarnEnabled()) { + this.logger.warn(LogMessage.format( + "Invalidated device code used by registered client '%s'", authorization.getRegisteredClientId())); + } + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + // In https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5, + // the following error codes are defined: + + // authorization_pending + // The authorization request is still pending as the end user hasn't + // yet completed the user-interaction steps (Section 3.3). The + // client SHOULD repeat the access token request to the token + // endpoint (a process known as polling). Before each new request, + // the client MUST wait at least the number of seconds specified by + // the "interval" parameter of the device authorization response (see + // Section 3.2), or 5 seconds if none was provided, and respect any + // increase in the polling interval required by the "slow_down" + // error. + if (!userCode.isInvalidated()) { + OAuth2Error error = new OAuth2Error(AUTHORIZATION_PENDING, null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + // slow_down + // A variant of "authorization_pending", the authorization request is + // still pending and polling should continue, but the interval MUST + // be increased by 5 seconds for this and all subsequent requests. + // NOTE: This error is not handled in the framework. + + // access_denied + // The authorization request was denied. + if (deviceCode.isInvalidated()) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.ACCESS_DENIED, null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + // expired_token + // The "device_code" has expired, and the device authorization + // session has concluded. The client MAY commence a new device + // authorization request but SHOULD wait for user interaction before + // restarting to avoid unnecessary polling. + if (deviceCode.isExpired()) { + // Invalidate the device code + authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, deviceCode.getToken()); + this.authorizationService.save(authorization); + if (this.logger.isWarnEnabled()) { + this.logger.warn(LogMessage.format( + "Invalidated device code used by registered client '%s'", authorization.getRegisteredClientId())); + } + OAuth2Error error = new OAuth2Error(EXPIRED_TOKEN, null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device token request parameters"); + } + + // @formatter:off + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(authorization.getAttribute(Principal.class.getName())) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .authorization(authorization) + .authorizedScopes(authorization.getAuthorizedScopes()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .authorizationGrant(deviceCodeAuthentication); + // @formatter:on + + // @formatter:off + OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization) + // Invalidate the device code as it can only be used (successfully) once + .token(deviceCode.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)); + // @formatter:on + + // ----- Access token ----- + OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build(); + OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext); + if (generatedAccessToken == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the access token.", DEFAULT_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated access token"); + } + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(), + generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes()); + if (generatedAccessToken instanceof ClaimAccessor) { + authorizationBuilder.token(accessToken, (metadata) -> + metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims())); + } else { + authorizationBuilder.accessToken(accessToken); + } + + // ----- Refresh token ----- + OAuth2RefreshToken refreshToken = null; + if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN)) { + tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build(); + OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext); + if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the refresh token.", DEFAULT_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated refresh token"); + } + + refreshToken = (OAuth2RefreshToken) generatedRefreshToken; + authorizationBuilder.refreshToken(refreshToken); + } + + authorization = authorizationBuilder.build(); + + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated device token request"); + } + + return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceCodeAuthenticationToken.class.isAssignableFrom(authentication); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java new file mode 100644 index 000000000..29f7cfdf5 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Device Access Token Request + * used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2AuthorizationGrantAuthenticationToken + * @see OAuth2DeviceCodeAuthenticationProvider + */ +public class OAuth2DeviceCodeAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + + private final String deviceCode; + + /** + * Constructs an {@code OAuth2DeviceCodeAuthenticationToken} using the provided parameters. + * + * @param deviceCode the device code + * @param clientPrincipal the authenticated client principal + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceCodeAuthenticationToken(String deviceCode, Authentication clientPrincipal, + @Nullable Map additionalParameters) { + super(AuthorizationGrantType.DEVICE_CODE, clientPrincipal, additionalParameters); + Assert.hasText(deviceCode, "deviceCode cannot be empty"); + this.deviceCode = deviceCode; + } + + /** + * Returns the device code. + * + * @return the device code + */ + public String getDeviceCode() { + return this.deviceCode; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java new file mode 100644 index 000000000..d0b0c2e90 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java @@ -0,0 +1,203 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; +import java.util.Base64; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the Device Verification Request + * (submission of the user code) used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceVerificationAuthenticationToken + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider + * @see RegisteredClientRepository + * @see OAuth2AuthorizationService + * @see OAuth2AuthorizationConsentService + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.3 User Interaction + */ +public final class OAuth2DeviceVerificationAuthenticationProvider implements AuthenticationProvider { + + static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE); + private static final StringKeyGenerator DEFAULT_STATE_GENERATOR = + new Base64StringKeyGenerator(Base64.getUrlEncoder()); + + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + private final OAuth2AuthorizationService authorizationService; + private final OAuth2AuthorizationConsentService authorizationConsentService; + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationProvider} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param authorizationConsentService the authorization consent service + */ + public OAuth2DeviceVerificationAuthenticationProvider( + RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, + OAuth2AuthorizationConsentService authorizationConsentService) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + this.authorizationConsentService = authorizationConsentService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication = + (OAuth2DeviceVerificationAuthenticationToken) authentication; + + OAuth2Authorization authorization = this.authorizationService.findByToken( + deviceVerificationAuthentication.getUserCode(), USER_CODE_TOKEN_TYPE); + if (authorization == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with user code"); + } + + Authentication principal = (Authentication) deviceVerificationAuthentication.getPrincipal(); + if (!isPrincipalAuthenticated(principal)) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Did not authenticate device verification request since principal not authenticated"); + } + // Return the device verification request as-is where isAuthenticated() is false + return deviceVerificationAuthentication; + } + + RegisteredClient registeredClient = this.registeredClientRepository.findById( + authorization.getRegisteredClientId()); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + Set requestedScopes = authorization.getAttribute(OAuth2ParameterNames.SCOPE); + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + registeredClient.getId(), principal.getName()); + + if (requiresAuthorizationConsent(requestedScopes, currentAuthorizationConsent)) { + String state = DEFAULT_STATE_GENERATOR.generateKey(); + authorization = OAuth2Authorization.from(authorization) + .principalName(principal.getName()) + .attribute(Principal.class.getName(), principal) + .attribute(OAuth2ParameterNames.STATE, state) + .build(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated device authorization consent state"); + } + + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : null; + + AuthorizationServerSettings authorizationServerSettings = + AuthorizationServerContextHolder.getContext().getAuthorizationServerSettings(); + String deviceVerificationUri = authorizationServerSettings.getDeviceVerificationEndpoint(); + + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(deviceVerificationUri, + registeredClient.getClientId(), principal, deviceVerificationAuthentication.getUserCode(), state, + requestedScopes, currentAuthorizedScopes); + } + + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + // @formatter:off + authorization = OAuth2Authorization.from(authorization) + .principalName(principal.getName()) + .authorizedScopes(requestedScopes) + .token(userCode.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attribute(Principal.class.getName(), principal) + .attributes(attributes -> attributes.remove(OAuth2ParameterNames.SCOPE)) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization with authorized scopes"); + // This log is kept separate for consistency with other providers + this.logger.trace("Authenticated device verification request"); + } + + return new OAuth2DeviceVerificationAuthenticationToken(principal, + deviceVerificationAuthentication.getUserCode(), registeredClient.getClientId()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceVerificationAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static boolean requiresAuthorizationConsent( + Set requestedScopes, OAuth2AuthorizationConsent authorizationConsent) { + + if (authorizationConsent != null && + authorizationConsent.getScopes().containsAll(requestedScopes)) { + return false; + } + + return true; + } + + private static boolean isPrincipalAuthenticated(Authentication principal) { + return principal != null && + !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && + principal.isAuthenticated(); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java new file mode 100644 index 000000000..40d40de2a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Device Verification Request + * (submission of the user code) used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AbstractAuthenticationToken + * @see OAuth2DeviceVerificationAuthenticationProvider + */ +public class OAuth2DeviceVerificationAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final Authentication principal; + private final String userCode; + private final Map additionalParameters; + private final String clientId; + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters. + * + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization response + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceVerificationAuthenticationToken(Authentication principal, String userCode, + @Nullable Map additionalParameters) { + super(Collections.emptyList()); + Assert.notNull(principal, "principal cannot be null"); + Assert.hasText(userCode, "userCode cannot be empty"); + this.principal = principal; + this.userCode = userCode; + this.additionalParameters = Collections.unmodifiableMap( + additionalParameters != null ? + new HashMap<>(additionalParameters) : + Collections.emptyMap()); + this.clientId = null; + } + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters. + * + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization response + * @param clientId the client identifier + */ + public OAuth2DeviceVerificationAuthenticationToken(Authentication principal, String userCode, String clientId) { + super(Collections.emptyList()); + Assert.notNull(principal, "principal cannot be null"); + Assert.hasText(userCode, "userCode cannot be empty"); + Assert.hasText(clientId, "clientId cannot be empty"); + this.principal = principal; + this.userCode = userCode; + this.clientId = clientId; + this.additionalParameters = null; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the user code. + * + * @return the user code + */ + public String getUserCode() { + return this.userCode; + } + + /** + * Returns the additional parameters. + * + * @return the additional parameters, or an empty {@code Map} if not available + */ + public Map getAdditionalParameters() { + return this.additionalParameters; + } + + /** + * Returns the client identifier. + * + * @return the client identifier + */ + public String getClientId() { + return this.clientId; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java index 83cc98817..72440957d 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -111,7 +111,7 @@ public Authentication authenticate(Authentication authentication) throws Authent } if (!registeredClient.getId().equals(authorization.getRegisteredClientId())) { - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); } if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN)) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java index 38edf7dff..65492dac0 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,7 +72,9 @@ public InMemoryRegisteredClientRepository(List registrations) @Override public void save(RegisteredClient registeredClient) { Assert.notNull(registeredClient, "registeredClient cannot be null"); - assertUniqueIdentifiers(registeredClient, this.idRegistrationMap); + if (!this.idRegistrationMap.containsKey(registeredClient.getId())) { + assertUniqueIdentifiers(registeredClient, this.idRegistrationMap); + } this.idRegistrationMap.put(registeredClient.getId(), registeredClient); this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java index 32e2442b0..6def19451 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,10 +52,16 @@ * {@link JdbcOperations} for {@link RegisteredClient} persistence. * *

- * NOTE: This {@code RegisteredClientRepository} depends on the table definition described in + * IMPORTANT: This {@code RegisteredClientRepository} depends on the table definition described in * "classpath:org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql" and * therefore MUST be defined in the database schema. * + *

+ * NOTE: This {@code RegisteredClientRepository} is a simplified JDBC implementation that MAY be used in a production environment. + * However, it does have limitations as it likely won't perform well in an environment requiring high throughput. + * The expectation is that the consuming application will provide their own implementation of {@code RegisteredClientRepository} + * that meets the performance requirements for its deployment environment. + * * @author Rafal Lewczuk * @author Joe Grandja * @author Ovidiu Popa @@ -77,6 +83,7 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor + "client_authentication_methods, " + "authorization_grant_types, " + "redirect_uris, " + + "post_logout_redirect_uris, " + "scopes, " + "client_settings," + "token_settings"; @@ -90,13 +97,14 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor // @formatter:off private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME - + "(" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + "(" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; // @formatter:on // @formatter:off private static final String UPDATE_REGISTERED_CLIENT_SQL = "UPDATE " + TABLE_NAME - + " SET client_name = ?, client_authentication_methods = ?, authorization_grant_types = ?," - + " redirect_uris = ?, scopes = ?, client_settings = ?, token_settings = ?" + + " SET client_secret = ?, client_secret_expires_at = ?, client_name = ?, client_authentication_methods = ?," + + " authorization_grant_types = ?, redirect_uris = ?, post_logout_redirect_uris = ?, scopes = ?," + + " client_settings = ?, token_settings = ?" + " WHERE " + PK_FILTER; // @formatter:on @@ -135,8 +143,6 @@ private void updateRegisteredClient(RegisteredClient registeredClient) { SqlParameterValue id = parameters.remove(0); parameters.remove(0); // remove client_id parameters.remove(0); // remove client_id_issued_at - parameters.remove(0); // remove client_secret - parameters.remove(0); // remove client_secret_expires_at parameters.add(id); PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray()); this.jdbcOperations.update(UPDATE_REGISTERED_CLIENT_SQL, pss); @@ -241,6 +247,7 @@ public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException { Set clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(rs.getString("client_authentication_methods")); Set authorizationGrantTypes = StringUtils.commaDelimitedListToSet(rs.getString("authorization_grant_types")); Set redirectUris = StringUtils.commaDelimitedListToSet(rs.getString("redirect_uris")); + Set postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(rs.getString("post_logout_redirect_uris")); Set clientScopes = StringUtils.commaDelimitedListToSet(rs.getString("scopes")); // @formatter:off @@ -257,6 +264,7 @@ public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException { authorizationGrantTypes.forEach(grantType -> grantTypes.add(resolveAuthorizationGrantType(grantType)))) .redirectUris((uris) -> uris.addAll(redirectUris)) + .postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris)) .scopes((scopes) -> scopes.addAll(clientScopes)); // @formatter:on @@ -354,6 +362,7 @@ public List apply(RegisteredClient registeredClient) { new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(clientAuthenticationMethods)), new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(authorizationGrantTypes)), new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(registeredClient.getRedirectUris())), + new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(registeredClient.getPostLogoutRedirectUris())), new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(registeredClient.getScopes())), new SqlParameterValue(Types.VARCHAR, writeMap(registeredClient.getClientSettings().getSettings())), new SqlParameterValue(Types.VARCHAR, writeMap(registeredClient.getTokenSettings().getSettings()))); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClient.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClient.java index 42df4c3cb..d03afac0c 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClient.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -54,6 +54,7 @@ public class RegisteredClient implements Serializable { private Set clientAuthenticationMethods; private Set authorizationGrantTypes; private Set redirectUris; + private Set postLogoutRedirectUris; private Set scopes; private ClientSettings clientSettings; private TokenSettings tokenSettings; @@ -145,6 +146,18 @@ public Set getRedirectUris() { return this.redirectUris; } + /** + * Returns the post logout redirect URI(s) that the client may use for logout. + * The {@code post_logout_redirect_uri} parameter is used by the client when requesting + * that the End-User's User Agent be redirected to after a logout has been performed. + * + * @return the {@code Set} of post logout redirect URI(s) + * @since 1.1 + */ + public Set getPostLogoutRedirectUris() { + return this.postLogoutRedirectUris; + } + /** * Returns the scope(s) that the client may use. * @@ -190,6 +203,7 @@ public boolean equals(Object obj) { Objects.equals(this.clientAuthenticationMethods, that.clientAuthenticationMethods) && Objects.equals(this.authorizationGrantTypes, that.authorizationGrantTypes) && Objects.equals(this.redirectUris, that.redirectUris) && + Objects.equals(this.postLogoutRedirectUris, that.postLogoutRedirectUris) && Objects.equals(this.scopes, that.scopes) && Objects.equals(this.clientSettings, that.clientSettings) && Objects.equals(this.tokenSettings, that.tokenSettings); @@ -199,7 +213,7 @@ public boolean equals(Object obj) { public int hashCode() { return Objects.hash(this.id, this.clientId, this.clientIdIssuedAt, this.clientSecret, this.clientSecretExpiresAt, this.clientName, this.clientAuthenticationMethods, this.authorizationGrantTypes, this.redirectUris, - this.scopes, this.clientSettings, this.tokenSettings); + this.postLogoutRedirectUris, this.scopes, this.clientSettings, this.tokenSettings); } @Override @@ -211,6 +225,7 @@ public String toString() { ", clientAuthenticationMethods=" + this.clientAuthenticationMethods + ", authorizationGrantTypes=" + this.authorizationGrantTypes + ", redirectUris=" + this.redirectUris + + ", postLogoutRedirectUris=" + this.postLogoutRedirectUris + ", scopes=" + this.scopes + ", clientSettings=" + this.clientSettings + ", tokenSettings=" + this.tokenSettings + @@ -253,6 +268,7 @@ public static class Builder implements Serializable { private final Set clientAuthenticationMethods = new HashSet<>(); private final Set authorizationGrantTypes = new HashSet<>(); private final Set redirectUris = new HashSet<>(); + private final Set postLogoutRedirectUris = new HashSet<>(); private final Set scopes = new HashSet<>(); private ClientSettings clientSettings; private TokenSettings tokenSettings; @@ -277,6 +293,9 @@ protected Builder(RegisteredClient registeredClient) { if (!CollectionUtils.isEmpty(registeredClient.getRedirectUris())) { this.redirectUris.addAll(registeredClient.getRedirectUris()); } + if (!CollectionUtils.isEmpty(registeredClient.getPostLogoutRedirectUris())) { + this.postLogoutRedirectUris.addAll(registeredClient.getPostLogoutRedirectUris()); + } if (!CollectionUtils.isEmpty(registeredClient.getScopes())) { this.scopes.addAll(registeredClient.getScopes()); } @@ -421,6 +440,33 @@ public Builder redirectUris(Consumer> redirectUrisConsumer) { return this; } + /** + * Adds a post logout redirect URI the client may use for logout. + * The {@code post_logout_redirect_uri} parameter is used by the client when requesting + * that the End-User's User Agent be redirected to after a logout has been performed. + * + * @param postLogoutRedirectUri the post logout redirect URI + * @return the {@link Builder} + * @since 1.1 + */ + public Builder postLogoutRedirectUri(String postLogoutRedirectUri) { + this.postLogoutRedirectUris.add(postLogoutRedirectUri); + return this; + } + + /** + * A {@code Consumer} of the post logout redirect URI(s) + * allowing the ability to add, replace, or remove. + * + * @param postLogoutRedirectUrisConsumer a {@link Consumer} of the post logout redirect URI(s) + * @return the {@link Builder} + * @since 1.1 + */ + public Builder postLogoutRedirectUris(Consumer> postLogoutRedirectUrisConsumer) { + postLogoutRedirectUrisConsumer.accept(this.postLogoutRedirectUris); + return this; + } + /** * Adds a scope the client may use. * @@ -499,6 +545,7 @@ public RegisteredClient build() { } validateScopes(); validateRedirectUris(); + validatePostLogoutRedirectUris(); return create(); } @@ -523,6 +570,8 @@ private RegisteredClient create() { new HashSet<>(this.authorizationGrantTypes)); registeredClient.redirectUris = Collections.unmodifiableSet( new HashSet<>(this.redirectUris)); + registeredClient.postLogoutRedirectUris = Collections.unmodifiableSet( + new HashSet<>(this.postLogoutRedirectUris)); registeredClient.scopes = Collections.unmodifiableSet( new HashSet<>(this.scopes)); registeredClient.clientSettings = this.clientSettings; @@ -557,12 +606,23 @@ private void validateRedirectUris() { return; } - for (String redirectUri : redirectUris) { + for (String redirectUri : this.redirectUris) { Assert.isTrue(validateRedirectUri(redirectUri), "redirect_uri \"" + redirectUri + "\" is not a valid redirect URI or contains fragment"); } } + private void validatePostLogoutRedirectUris() { + if (CollectionUtils.isEmpty(this.postLogoutRedirectUris)) { + return; + } + + for (String postLogoutRedirectUri : this.postLogoutRedirectUris) { + Assert.isTrue(validateRedirectUri(postLogoutRedirectUri), + "post_logout_redirect_uri \"" + postLogoutRedirectUri + "\" is not a valid post logout redirect URI or contains fragment"); + } + } + private static boolean validateRedirectUri(String redirectUri) { try { URI validRedirectUri = new URI(redirectUri); @@ -571,5 +631,6 @@ private static boolean validateRedirectUri(String redirectUri) { return false; } } + } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationEndpointConfigurer.java index f2a59a998..e1ae33e90 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationEndpointConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationEndpointConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -68,6 +69,7 @@ public final class OAuth2AuthorizationEndpointConfigurer extends AbstractOAuth2C private AuthenticationFailureHandler errorResponseHandler; private String consentPage; private Consumer authorizationCodeRequestAuthenticationValidator; + private SessionAuthenticationStrategy sessionAuthenticationStrategy; /** * Restrict for internal use only. @@ -200,6 +202,10 @@ void addAuthorizationCodeRequestAuthenticationValidator( this.authorizationCodeRequestAuthenticationValidator.andThen(authenticationValidator); } + void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionAuthenticationStrategy) { + this.sessionAuthenticationStrategy = sessionAuthenticationStrategy; + } + @Override void init(HttpSecurity httpSecurity) { AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity); @@ -245,6 +251,9 @@ void configure(HttpSecurity httpSecurity) { if (StringUtils.hasText(this.consentPage)) { authorizationEndpointFilter.setConsentPage(this.consentPage); } + if (this.sessionAuthenticationStrategy != null) { + authorizationEndpointFilter.setSessionAuthenticationStrategy(this.sessionAuthenticationStrategy); + } httpSecurity.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java index 1468a3513..1b04ac158 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java @@ -23,12 +23,19 @@ import com.nimbusds.jose.jwk.source.JWKSource; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.GenericApplicationListenerAdapter; +import org.springframework.context.event.SmartApplicationListener; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; +import org.springframework.security.context.DelegatingApplicationListener; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2Token; @@ -65,6 +72,8 @@ * @see OAuth2TokenEndpointConfigurer * @see OAuth2TokenIntrospectionEndpointConfigurer * @see OAuth2TokenRevocationEndpointConfigurer + * @see OAuth2DeviceAuthorizationEndpointConfigurer + * @see OAuth2DeviceVerificationEndpointConfigurer * @see OidcConfigurer * @see RegisteredClientRepository * @see OAuth2AuthorizationService @@ -208,6 +217,30 @@ public OAuth2AuthorizationServerConfigurer tokenRevocationEndpoint(Customizer deviceAuthorizationEndpointCustomizer) { + deviceAuthorizationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceAuthorizationEndpointConfigurer.class)); + return this; + } + + /** + * Configures the OAuth 2.0 Device Verification Endpoint. + * + * @param deviceVerificationEndpointCustomizer the {@link Customizer} providing access to the {@link OAuth2DeviceVerificationEndpointConfigurer} + * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration + * @since 1.1 + */ + public OAuth2AuthorizationServerConfigurer deviceVerificationEndpoint(Customizer deviceVerificationEndpointCustomizer) { + deviceVerificationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceVerificationEndpointConfigurer.class)); + return this; + } + /** * Configures OpenID Connect 1.0 support (disabled by default). * @@ -240,8 +273,24 @@ public void init(HttpSecurity httpSecurity) { AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity); validateAuthorizationServerSettings(authorizationServerSettings); - OidcConfigurer oidcConfigurer = getConfigurer(OidcConfigurer.class); - if (oidcConfigurer == null) { + if (isOidcEnabled()) { + // Add OpenID Connect session tracking capabilities. + initSessionRegistry(httpSecurity); + SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class); + OAuth2AuthorizationEndpointConfigurer authorizationEndpointConfigurer = + getConfigurer(OAuth2AuthorizationEndpointConfigurer.class); + authorizationEndpointConfigurer.setSessionAuthenticationStrategy((authentication, request, response) -> { + if (authentication instanceof OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) { + if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) { + if (sessionRegistry.getSessionInformation(request.getSession().getId()) == null) { + sessionRegistry.registerNewSession( + request.getSession().getId(), + ((Authentication) authorizationCodeRequestAuthentication.getPrincipal()).getPrincipal()); + } + } + } + }); + } else { // OpenID Connect is disabled. // Add an authentication validator that rejects authentication requests. OAuth2AuthorizationEndpointConfigurer authorizationEndpointConfigurer = @@ -275,7 +324,8 @@ public void init(HttpSecurity httpSecurity) { new OrRequestMatcher( getRequestMatcher(OAuth2TokenEndpointConfigurer.class), getRequestMatcher(OAuth2TokenIntrospectionEndpointConfigurer.class), - getRequestMatcher(OAuth2TokenRevocationEndpointConfigurer.class)) + getRequestMatcher(OAuth2TokenRevocationEndpointConfigurer.class), + getRequestMatcher(OAuth2DeviceAuthorizationEndpointConfigurer.class)) ); } } @@ -297,6 +347,10 @@ public void configure(HttpSecurity httpSecurity) { } } + private boolean isOidcEnabled() { + return getConfigurer(OidcConfigurer.class) != null; + } + private Map, AbstractOAuth2Configurer> createConfigurers() { Map, AbstractOAuth2Configurer> configurers = new LinkedHashMap<>(); configurers.put(OAuth2ClientAuthenticationConfigurer.class, new OAuth2ClientAuthenticationConfigurer(this::postProcess)); @@ -305,6 +359,8 @@ private Map, AbstractOAuth2Configurer> configurers.put(OAuth2TokenEndpointConfigurer.class, new OAuth2TokenEndpointConfigurer(this::postProcess)); configurers.put(OAuth2TokenIntrospectionEndpointConfigurer.class, new OAuth2TokenIntrospectionEndpointConfigurer(this::postProcess)); configurers.put(OAuth2TokenRevocationEndpointConfigurer.class, new OAuth2TokenRevocationEndpointConfigurer(this::postProcess)); + configurers.put(OAuth2DeviceAuthorizationEndpointConfigurer.class, new OAuth2DeviceAuthorizationEndpointConfigurer(this::postProcess)); + configurers.put(OAuth2DeviceVerificationEndpointConfigurer.class, new OAuth2DeviceVerificationEndpointConfigurer(this::postProcess)); return configurers; } @@ -338,4 +394,23 @@ private static void validateAuthorizationServerSettings(AuthorizationServerSetti } } + private static void initSessionRegistry(HttpSecurity httpSecurity) { + SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getOptionalBean(httpSecurity, SessionRegistry.class); + if (sessionRegistry == null) { + sessionRegistry = new SessionRegistryImpl(); + registerDelegateApplicationListener(httpSecurity, (SessionRegistryImpl) sessionRegistry); + } + httpSecurity.setSharedObject(SessionRegistry.class, sessionRegistry); + } + + private static void registerDelegateApplicationListener(HttpSecurity httpSecurity, ApplicationListener delegate) { + DelegatingApplicationListener delegatingApplicationListener = + OAuth2ConfigurerUtils.getOptionalBean(httpSecurity, DelegatingApplicationListener.class); + if (delegatingApplicationListener == null) { + return; + } + SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate); + delegatingApplicationListener.addListener(smartListener); + } + } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java index 4bd1d874e..7e37982f3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -168,6 +168,9 @@ void init(HttpSecurity httpSecurity) { HttpMethod.POST.name()), new AntPathRequestMatcher( authorizationServerSettings.getTokenRevocationEndpoint(), + HttpMethod.POST.name()), + new AntPathRequestMatcher( + authorizationServerSettings.getDeviceAuthorizationEndpoint(), HttpMethod.POST.name())); List authenticationProviders = createDefaultAuthenticationProviders(httpSecurity); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java index 7b4974c5d..9d7ab7ad3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java new file mode 100644 index 000000000..765548f32 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java @@ -0,0 +1,232 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationRequestAuthenticationConverter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Configurer for the OAuth 2.0 Device Authorization Endpoint. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2AuthorizationServerConfigurer#deviceAuthorizationEndpoint + * @see OAuth2DeviceAuthorizationEndpointFilter + */ +public final class OAuth2DeviceAuthorizationEndpointConfigurer extends AbstractOAuth2Configurer { + + private RequestMatcher requestMatcher; + private final List deviceAuthorizationRequestConverters = new ArrayList<>(); + private Consumer> deviceAuthorizationRequestConvertersConsumer = (deviceAuthorizationRequestConverters) -> {}; + private final List authenticationProviders = new ArrayList<>(); + private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; + private AuthenticationSuccessHandler deviceAuthorizationResponseHandler; + private AuthenticationFailureHandler errorResponseHandler; + private String verificationUri; + + /** + * Restrict for internal use only. + */ + OAuth2DeviceAuthorizationEndpointConfigurer(ObjectPostProcessor objectPostProcessor) { + super(objectPostProcessor); + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating the request. + * + * @param deviceAuthorizationRequestConverter the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverter(AuthenticationConverter deviceAuthorizationRequestConverter) { + Assert.notNull(deviceAuthorizationRequestConverter, "deviceAuthorizationRequestConverter cannot be null"); + this.deviceAuthorizationRequestConverters.add(deviceAuthorizationRequestConverter); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #deviceAuthorizationRequestConverter(AuthenticationConverter) AuthenticationConverter}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}. + * + * @param deviceAuthorizationRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverters( + Consumer> deviceAuthorizationRequestConvertersConsumer) { + Assert.notNull(deviceAuthorizationRequestConvertersConsumer, "deviceAuthorizationRequestConvertersConsumer cannot be null"); + this.deviceAuthorizationRequestConvertersConsumer = deviceAuthorizationRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}. + * + * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProviders.add(authenticationProvider); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}. + * + * @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProviders( + Consumer> authenticationProvidersConsumer) { + Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null"); + this.authenticationProvidersConsumer = authenticationProvidersConsumer; + return this; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the {@link OAuth2DeviceAuthorizationResponse Device Authorization Response}. + * + * @param deviceAuthorizationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationResponseHandler(AuthenticationSuccessHandler deviceAuthorizationResponseHandler) { + this.deviceAuthorizationResponseHandler = deviceAuthorizationResponseHandler; + return this; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + return this; + } + + /** + * Sets the end-user verification {@code URI} on the authorization server. + * + * @param verificationUri the end-user verification {@code URI} on the authorization server + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer verificationUri(String verificationUri) { + this.verificationUri = verificationUri; + return this; + } + + @Override + public void init(HttpSecurity builder) { + AuthorizationServerSettings authorizationServerSettings = + OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + this.requestMatcher = new AntPathRequestMatcher( + authorizationServerSettings.getDeviceAuthorizationEndpoint(), HttpMethod.POST.name()); + + List authenticationProviders = createDefaultAuthenticationProviders(builder); + if (!this.authenticationProviders.isEmpty()) { + authenticationProviders.addAll(0, this.authenticationProviders); + } + this.authenticationProvidersConsumer.accept(authenticationProviders); + authenticationProviders.forEach(authenticationProvider -> + builder.authenticationProvider(postProcess(authenticationProvider))); + } + + @Override + public void configure(HttpSecurity builder) { + AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); + AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + + OAuth2DeviceAuthorizationEndpointFilter deviceAuthorizationEndpointFilter = + new OAuth2DeviceAuthorizationEndpointFilter( + authenticationManager, authorizationServerSettings.getDeviceAuthorizationEndpoint()); + + List authenticationConverters = createDefaultAuthenticationConverters(); + if (!this.deviceAuthorizationRequestConverters.isEmpty()) { + authenticationConverters.addAll(0, this.deviceAuthorizationRequestConverters); + } + this.deviceAuthorizationRequestConvertersConsumer.accept(authenticationConverters); + deviceAuthorizationEndpointFilter.setAuthenticationConverter( + new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.deviceAuthorizationResponseHandler != null) { + deviceAuthorizationEndpointFilter.setAuthenticationSuccessHandler(this.deviceAuthorizationResponseHandler); + } + if (this.errorResponseHandler != null) { + deviceAuthorizationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + if (StringUtils.hasText(this.verificationUri)) { + deviceAuthorizationEndpointFilter.setVerificationUri(this.verificationUri); + } + builder.addFilterAfter(postProcess(deviceAuthorizationEndpointFilter), AuthorizationFilter.class); + } + + @Override + RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } + + private static List createDefaultAuthenticationConverters() { + List authenticationConverters = new ArrayList<>(); + authenticationConverters.add(new OAuth2DeviceAuthorizationRequestAuthenticationConverter()); + + return authenticationConverters; + } + + private static List createDefaultAuthenticationProviders(HttpSecurity builder) { + List authenticationProviders = new ArrayList<>(); + + OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(builder); + + OAuth2DeviceAuthorizationRequestAuthenticationProvider deviceAuthorizationRequestAuthenticationProvider = + new OAuth2DeviceAuthorizationRequestAuthenticationProvider(authorizationService); + authenticationProviders.add(deviceAuthorizationRequestAuthenticationProvider); + + return authenticationProviders; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java new file mode 100644 index 000000000..bc50f7b43 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java @@ -0,0 +1,285 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationConsentAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceVerificationAuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Configurer for the OAuth 2.0 Device Verification Endpoint. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2AuthorizationServerConfigurer#deviceVerificationEndpoint + * @see OAuth2DeviceVerificationEndpointFilter + */ +public final class OAuth2DeviceVerificationEndpointConfigurer extends AbstractOAuth2Configurer { + + private RequestMatcher requestMatcher; + private final List deviceVerificationRequestConverters = new ArrayList<>(); + private Consumer> deviceVerificationRequestConvertersConsumer = (deviceVerificationRequestConverters) -> {}; + private final List authenticationProviders = new ArrayList<>(); + private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; + private AuthenticationSuccessHandler deviceVerificationResponseHandler; + private AuthenticationFailureHandler errorResponseHandler; + private String consentPage; + + /** + * Restrict for internal use only. + */ + OAuth2DeviceVerificationEndpointConfigurer(ObjectPostProcessor objectPostProcessor) { + super(objectPostProcessor); + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Device Authorization Consent) from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} used for authenticating the request. + * + * @param deviceVerificationRequestConverter the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Device Authorization Consent) from {@link HttpServletRequest} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverter(AuthenticationConverter deviceVerificationRequestConverter) { + Assert.notNull(deviceVerificationRequestConverter, "deviceVerificationRequestConverter cannot be null"); + this.deviceVerificationRequestConverters.add(deviceVerificationRequestConverter); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #deviceVerificationRequestConverter(AuthenticationConverter) AuthenticationConverter}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}. + * + * @param deviceVerificationRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverters( + Consumer> deviceVerificationRequestConvertersConsumer) { + Assert.notNull(deviceVerificationRequestConvertersConsumer, "deviceVerificationRequestConvertersConsumer cannot be null"); + this.deviceVerificationRequestConvertersConsumer = deviceVerificationRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}. + * + * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProviders.add(authenticationProvider); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}. + * + * @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer authenticationProviders( + Consumer> authenticationProvidersConsumer) { + Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null"); + this.authenticationProvidersConsumer = authenticationProvidersConsumer; + return this; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + * and returning the response. + * + * @param deviceVerificationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationResponseHandler(AuthenticationSuccessHandler deviceVerificationResponseHandler) { + this.deviceVerificationResponseHandler = deviceVerificationResponseHandler; + return this; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + return this; + } + + /** + * Specify the URI to redirect Resource Owners to if consent is required during + * the {@code device_code} flow. A default consent page will be generated when + * this attribute is not specified. + * + * If a URI is specified, applications are required to process the specified URI to generate + * a consent page. The query string will contain the following parameters: + * + *
    + *
  • {@code client_id} - the client identifier
  • + *
  • {@code scope} - a space-delimited list of scopes present in the device authorization request
  • + *
  • {@code state} - a CSRF protection token
  • + *
  • {@code user_code} - the user code
  • + *
+ * + * In general, the consent page should create a form that submits + * a request with the following requirements: + * + *
    + *
  • It must be an HTTP POST
  • + *
  • It must be submitted to {@link AuthorizationServerSettings#getDeviceVerificationEndpoint()}
  • + *
  • It must include the received {@code client_id} as an HTTP parameter
  • + *
  • It must include the received {@code state} as an HTTP parameter
  • + *
  • It must include the list of {@code scope}s the {@code Resource Owner} + * consented to as an HTTP parameter
  • + *
  • It must include the received {@code user_code} as an HTTP parameter
  • + *
+ * + * @param consentPage the URI of the custom consent page to redirect to if consent is required (e.g. "/oauth2/consent") + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer consentPage(String consentPage) { + this.consentPage = consentPage; + return this; + } + + @Override + public void init(HttpSecurity builder) { + AuthorizationServerSettings authorizationServerSettings = + OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + this.requestMatcher = new OrRequestMatcher( + new AntPathRequestMatcher( + authorizationServerSettings.getDeviceVerificationEndpoint(), + HttpMethod.GET.name()), + new AntPathRequestMatcher( + authorizationServerSettings.getDeviceVerificationEndpoint(), + HttpMethod.POST.name())); + + List authenticationProviders = createDefaultAuthenticationProviders(builder); + if (!this.authenticationProviders.isEmpty()) { + authenticationProviders.addAll(0, this.authenticationProviders); + } + this.authenticationProvidersConsumer.accept(authenticationProviders); + authenticationProviders.forEach(authenticationProvider -> + builder.authenticationProvider(postProcess(authenticationProvider))); + } + + @Override + public void configure(HttpSecurity builder) { + AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); + AuthorizationServerSettings authorizationServerSettings = + OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + + OAuth2DeviceVerificationEndpointFilter deviceVerificationEndpointFilter = + new OAuth2DeviceVerificationEndpointFilter( + authenticationManager, + authorizationServerSettings.getDeviceVerificationEndpoint()); + List authenticationConverters = createDefaultAuthenticationConverters(); + if (!this.deviceVerificationRequestConverters.isEmpty()) { + authenticationConverters.addAll(0, this.deviceVerificationRequestConverters); + } + this.deviceVerificationRequestConvertersConsumer.accept(authenticationConverters); + deviceVerificationEndpointFilter.setAuthenticationConverter( + new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.deviceVerificationResponseHandler != null) { + deviceVerificationEndpointFilter.setAuthenticationSuccessHandler(this.deviceVerificationResponseHandler); + } + if (this.errorResponseHandler != null) { + deviceVerificationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + if (StringUtils.hasText(this.consentPage)) { + deviceVerificationEndpointFilter.setConsentPage(this.consentPage); + } + builder.addFilterBefore(postProcess(deviceVerificationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); + } + + @Override + RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } + + private static List createDefaultAuthenticationConverters() { + List authenticationConverters = new ArrayList<>(); + + authenticationConverters.add(new OAuth2DeviceVerificationAuthenticationConverter()); + authenticationConverters.add(new OAuth2DeviceAuthorizationConsentAuthenticationConverter()); + + return authenticationConverters; + } + + private static List createDefaultAuthenticationProviders(HttpSecurity builder) { + RegisteredClientRepository registeredClientRepository = + OAuth2ConfigurerUtils.getRegisteredClientRepository(builder); + OAuth2AuthorizationService authorizationService = + OAuth2ConfigurerUtils.getAuthorizationService(builder); + OAuth2AuthorizationConsentService authorizationConsentService = + OAuth2ConfigurerUtils.getAuthorizationConsentService(builder); + + List authenticationProviders = new ArrayList<>(); + + // @formatter:off + OAuth2DeviceVerificationAuthenticationProvider deviceVerificationAuthenticationProvider = + new OAuth2DeviceVerificationAuthenticationProvider( + registeredClientRepository, authorizationService, authorizationConsentService); + // @formatter:on + authenticationProviders.add(deviceVerificationAuthenticationProvider); + + // @formatter:off + OAuth2DeviceAuthorizationConsentAuthenticationProvider deviceAuthorizationConsentAuthenticationProvider = + new OAuth2DeviceAuthorizationConsentAuthenticationProvider( + registeredClientRepository, authorizationService, authorizationConsentService); + // @formatter:on + authenticationProviders.add(deviceAuthorizationConsentAuthenticationProvider); + + return authenticationProviders; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java index c9a9cfa92..61e089f2d 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2Token; @@ -35,6 +36,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; @@ -42,6 +44,7 @@ import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -207,6 +210,7 @@ private static List createDefaultAuthenticationConverte authenticationConverters.add(new OAuth2AuthorizationCodeAuthenticationConverter()); authenticationConverters.add(new OAuth2RefreshTokenAuthenticationConverter()); authenticationConverters.add(new OAuth2ClientCredentialsAuthenticationConverter()); + authenticationConverters.add(new OAuth2DeviceCodeAuthenticationConverter()); return authenticationConverters; } @@ -219,6 +223,10 @@ private static List createDefaultAuthenticationProviders OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(authorizationService, tokenGenerator); + SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class); + if (sessionRegistry != null) { + authorizationCodeAuthenticationProvider.setSessionRegistry(sessionRegistry); + } authenticationProviders.add(authorizationCodeAuthenticationProvider); OAuth2RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider = @@ -229,6 +237,10 @@ private static List createDefaultAuthenticationProviders new OAuth2ClientCredentialsAuthenticationProvider(authorizationService, tokenGenerator); authenticationProviders.add(clientCredentialsAuthenticationProvider); + OAuth2DeviceCodeAuthenticationProvider deviceCodeAuthenticationProvider = + new OAuth2DeviceCodeAuthenticationProvider(authorizationService, tokenGenerator); + authenticationProviders.add(deviceCodeAuthenticationProvider); + return authenticationProviders; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationEndpointConfigurer.java index 3a7922dce..082b23448 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationEndpointConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationEndpointConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration; @@ -221,6 +222,10 @@ private static List createDefaultAuthenticationProviders OAuth2ConfigurerUtils.getRegisteredClientRepository(httpSecurity), OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity), OAuth2ConfigurerUtils.getTokenGenerator(httpSecurity)); + PasswordEncoder passwordEncoder = OAuth2ConfigurerUtils.getOptionalBean(httpSecurity, PasswordEncoder.class); + if (passwordEncoder != null) { + oidcClientRegistrationAuthenticationProvider.setPasswordEncoder(passwordEncoder); + } authenticationProviders.add(oidcClientRegistrationAuthenticationProvider); OidcClientConfigurationAuthenticationProvider oidcClientConfigurationAuthenticationProvider = diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcConfigurer.java index 5ce9cbc38..d1abe9674 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ * @since 0.2.0 * @see OAuth2AuthorizationServerConfigurer#oidc * @see OidcProviderConfigurationEndpointConfigurer + * @see OidcLogoutEndpointConfigurer * @see OidcClientRegistrationEndpointConfigurer * @see OidcUserInfoEndpointConfigurer */ @@ -50,6 +51,7 @@ public final class OidcConfigurer extends AbstractOAuth2Configurer { OidcConfigurer(ObjectPostProcessor objectPostProcessor) { super(objectPostProcessor); addConfigurer(OidcProviderConfigurationEndpointConfigurer.class, new OidcProviderConfigurationEndpointConfigurer(objectPostProcessor)); + addConfigurer(OidcLogoutEndpointConfigurer.class, new OidcLogoutEndpointConfigurer(objectPostProcessor)); addConfigurer(OidcUserInfoEndpointConfigurer.class, new OidcUserInfoEndpointConfigurer(objectPostProcessor)); } @@ -65,6 +67,18 @@ public OidcConfigurer providerConfigurationEndpoint(Customizer logoutEndpointCustomizer) { + logoutEndpointCustomizer.customize(getConfigurer(OidcLogoutEndpointConfigurer.class)); + return this; + } + /** * Configures the OpenID Connect Dynamic Client Registration 1.0 Endpoint. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.java new file mode 100644 index 000000000..04b356ff8 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.java @@ -0,0 +1,220 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.oidc.web.OidcLogoutEndpointFilter; +import org.springframework.security.oauth2.server.authorization.oidc.web.authentication.OidcLogoutAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * Configurer for OpenID Connect 1.0 RP-Initiated Logout Endpoint. + * + * @author Joe Grandja + * @since 1.1 + * @see OidcConfigurer#logoutEndpoint + * @see OidcLogoutEndpointFilter + */ +public final class OidcLogoutEndpointConfigurer extends AbstractOAuth2Configurer { + private RequestMatcher requestMatcher; + private final List logoutRequestConverters = new ArrayList<>(); + private Consumer> logoutRequestConvertersConsumer = (logoutRequestConverters) -> {}; + private final List authenticationProviders = new ArrayList<>(); + private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; + private AuthenticationSuccessHandler logoutResponseHandler; + private AuthenticationFailureHandler errorResponseHandler; + + /** + * Restrict for internal use only. + */ + OidcLogoutEndpointConfigurer(ObjectPostProcessor objectPostProcessor) { + super(objectPostProcessor); + } + + /** + * Adds an {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest} + * to an instance of {@link OidcLogoutAuthenticationToken} used for authenticating the request. + * + * @param logoutRequestConverter an {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest} + * @return the {@link OidcLogoutEndpointConfigurer} for further configuration + */ + public OidcLogoutEndpointConfigurer logoutRequestConverter( + AuthenticationConverter logoutRequestConverter) { + Assert.notNull(logoutRequestConverter, "logoutRequestConverter cannot be null"); + this.logoutRequestConverters.add(logoutRequestConverter); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #logoutRequestConverter(AuthenticationConverter) AuthenticationConverter}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}. + * + * @param logoutRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s + * @return the {@link OidcLogoutEndpointConfigurer} for further configuration + */ + public OidcLogoutEndpointConfigurer logoutRequestConverters( + Consumer> logoutRequestConvertersConsumer) { + Assert.notNull(logoutRequestConvertersConsumer, "logoutRequestConvertersConsumer cannot be null"); + this.logoutRequestConvertersConsumer = logoutRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OidcLogoutAuthenticationToken}. + * + * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OidcLogoutAuthenticationToken} + * @return the {@link OidcLogoutEndpointConfigurer} for further configuration + */ + public OidcLogoutEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProviders.add(authenticationProvider); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}. + * + * @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s + * @return the {@link OidcLogoutEndpointConfigurer} for further configuration + */ + public OidcLogoutEndpointConfigurer authenticationProviders( + Consumer> authenticationProvidersConsumer) { + Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null"); + this.authenticationProvidersConsumer = authenticationProvidersConsumer; + return this; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken} + * and performing the logout. + * + * @param logoutResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken} + * @return the {@link OidcLogoutEndpointConfigurer} for further configuration + */ + public OidcLogoutEndpointConfigurer logoutResponseHandler(AuthenticationSuccessHandler logoutResponseHandler) { + this.logoutResponseHandler = logoutResponseHandler; + return this; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * @return the {@link OidcLogoutEndpointConfigurer} for further configuration + */ + public OidcLogoutEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + return this; + } + + @Override + void init(HttpSecurity httpSecurity) { + AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity); + String logoutEndpointUri = authorizationServerSettings.getOidcLogoutEndpoint(); + this.requestMatcher = new OrRequestMatcher( + new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.GET.name()), + new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.POST.name()) + ); + + List authenticationProviders = createDefaultAuthenticationProviders(httpSecurity); + if (!this.authenticationProviders.isEmpty()) { + authenticationProviders.addAll(0, this.authenticationProviders); + } + this.authenticationProvidersConsumer.accept(authenticationProviders); + authenticationProviders.forEach(authenticationProvider -> + httpSecurity.authenticationProvider(postProcess(authenticationProvider))); + } + + @Override + void configure(HttpSecurity httpSecurity) { + AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManager.class); + AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity); + + OidcLogoutEndpointFilter oidcLogoutEndpointFilter = + new OidcLogoutEndpointFilter( + authenticationManager, + authorizationServerSettings.getOidcLogoutEndpoint()); + List authenticationConverters = createDefaultAuthenticationConverters(); + if (!this.logoutRequestConverters.isEmpty()) { + authenticationConverters.addAll(0, this.logoutRequestConverters); + } + this.logoutRequestConvertersConsumer.accept(authenticationConverters); + oidcLogoutEndpointFilter.setAuthenticationConverter( + new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.logoutResponseHandler != null) { + oidcLogoutEndpointFilter.setAuthenticationSuccessHandler(this.logoutResponseHandler); + } + if (this.errorResponseHandler != null) { + oidcLogoutEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + httpSecurity.addFilterBefore(postProcess(oidcLogoutEndpointFilter), LogoutFilter.class); + } + + @Override + RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } + + private static List createDefaultAuthenticationConverters() { + List authenticationConverters = new ArrayList<>(); + + authenticationConverters.add(new OidcLogoutAuthenticationConverter()); + + return authenticationConverters; + } + + private static List createDefaultAuthenticationProviders(HttpSecurity httpSecurity) { + List authenticationProviders = new ArrayList<>(); + + OidcLogoutAuthenticationProvider oidcLogoutAuthenticationProvider = + new OidcLogoutAuthenticationProvider( + OAuth2ConfigurerUtils.getRegisteredClientRepository(httpSecurity), + OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity), + httpSecurity.getSharedObject(SessionRegistry.class)); + authenticationProviders.add(oidcLogoutAuthenticationProvider); + + return authenticationProviders; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/HttpMessageConverters.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/HttpMessageConverters.java index a5e536315..864eb7f83 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/HttpMessageConverters.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/HttpMessageConverters.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ * Utility methods for {@link HttpMessageConverter}'s. * * @author Joe Grandja + * @author luamas * @since 0.1.1 */ final class HttpMessageConverters { @@ -41,7 +42,7 @@ final class HttpMessageConverters { jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); - jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); } private HttpMessageConverters() { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverter.java index 50ae02d79..af156ec0a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -136,6 +136,7 @@ private OAuth2AuthorizationServerMetadataConverter() { Map> claimConverters = new HashMap<>(); claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, urlConverter); claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, urlConverter); + claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.DEVICE_AUTHORIZATION_ENDPOINT, urlConverter); claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, urlConverter); claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, collectionStringConverter); claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, urlConverter); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java index b52e2f2d6..09a78c72a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ * @see OidcClientMetadataClaimNames * @see OidcClientRegistration * @see 2. Client Metadata + * @see 3.1. Client Registration Metadata */ public interface OidcClientMetadataClaimAccessor extends ClaimAccessor { @@ -94,6 +95,18 @@ default List getRedirectUris() { return getClaimAsStringList(OidcClientMetadataClaimNames.REDIRECT_URIS); } + /** + * Returns the post logout redirection {@code URI} values used by the Client {@code (post_logout_redirect_uris)}. + * The {@code post_logout_redirect_uri} parameter is used by the client when requesting + * that the End-User's User Agent be redirected to after a logout has been performed. + * + * @return the post logout redirection {@code URI} values used by the Client + * @since 1.1 + */ + default List getPostLogoutRedirectUris() { + return getClaimAsStringList(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + } + /** * Returns the authentication method used by the Client for the Token Endpoint {@code (token_endpoint_auth_method)}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimNames.java index 06cecaf77..d39990e58 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ * @author Joe Grandja * @since 0.1.1 * @see 2. Client Metadata + * @see 3.1. Client Registration Metadata */ public final class OidcClientMetadataClaimNames { @@ -61,6 +62,14 @@ public final class OidcClientMetadataClaimNames { */ public static final String REDIRECT_URIS = "redirect_uris"; + /** + * {@code post_logout_redirect_uris} - the post logout redirection {@code URI} values used by the Client. + * The {@code post_logout_redirect_uri} parameter is used by the client when requesting + * that the End-User's User Agent be redirected to after a logout has been performed. + * @since 1.1 + */ + public static final String POST_LOGOUT_REDIRECT_URIS = "post_logout_redirect_uris"; + /** * {@code token_endpoint_auth_method} - the authentication method used by the Client for the Token Endpoint */ diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistration.java index 21c1b7216..07490727a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistration.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,8 +44,9 @@ * @author Joe Grandja * @since 0.1.1 * @see OidcClientMetadataClaimAccessor - * @see 3.1. Client Registration Request - * @see 3.2. Client Registration Response + * @see 3.1. Client Registration Request + * @see 3.2. Client Registration Response + * @see 3.1. Client Registration Metadata */ public final class OidcClientRegistration implements OidcClientMetadataClaimAccessor, Serializable { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; @@ -168,6 +169,33 @@ public Builder redirectUris(Consumer> redirectUrisConsumer) { return this; } + /** + * Add the post logout redirection {@code URI} used by the Client, OPTIONAL. + * The {@code post_logout_redirect_uri} parameter is used by the client when requesting + * that the End-User's User Agent be redirected to after a logout has been performed. + * + * @param postLogoutRedirectUri the post logout redirection {@code URI} used by the Client + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder postLogoutRedirectUri(String postLogoutRedirectUri) { + addClaimToClaimList(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUri); + return this; + } + + /** + * A {@code Consumer} of the post logout redirection {@code URI} values used by the Client, + * allowing the ability to add, replace, or remove, OPTIONAL. + * + * @param postLogoutRedirectUrisConsumer a {@code Consumer} of the post logout redirection {@code URI} values used by the Client + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder postLogoutRedirectUris(Consumer> postLogoutRedirectUrisConsumer) { + acceptClaimValues(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUrisConsumer); + return this; + } + /** * Sets the authentication method used by the Client for the Token Endpoint, OPTIONAL. * @@ -358,6 +386,10 @@ private void validate() { Assert.notNull(this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris cannot be null"); Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris must be of type List"); Assert.notEmpty((List) this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris cannot be empty"); + if (this.claims.get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS) != null) { + Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS), "post_logout_redirect_uris must be of type List"); + Assert.notEmpty((List) this.claims.get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS), "post_logout_redirect_uris cannot be empty"); + } if (this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES) != null) { Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES), "grant_types must be of type List"); Assert.notEmpty((List) this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES), "grant_types cannot be empty"); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfiguration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfiguration.java index 96549c3bf..6309c4f1d 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfiguration.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ * The claims are defined by the OpenID Connect Discovery 1.0 specification. * * @author Daniel Garnier-Moiroux + * @author Joe Grandja * @since 0.1.0 * @see AbstractOAuth2AuthorizationServerMetadata * @see OidcProviderMetadataClaimAccessor @@ -130,6 +131,17 @@ public Builder userInfoEndpoint(String userInfoEndpoint) { return claim(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, userInfoEndpoint); } + /** + * Use this {@code end_session_endpoint} in the resulting {@link OidcProviderConfiguration}, OPTIONAL. + * + * @param endSessionEndpoint the {@code URL} of the OpenID Connect 1.0 End Session Endpoint + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder endSessionEndpoint(String endSessionEndpoint) { + return claim(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, endSessionEndpoint); + } + /** * Validate the claims and build the {@link OidcProviderConfiguration}. *

@@ -159,6 +171,9 @@ protected void validate() { if (getClaims().get(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT) != null) { validateURL(getClaims().get(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT), "userInfoEndpoint must be a valid URL"); } + if (getClaims().get(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT) != null) { + validateURL(getClaims().get(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT), "endSessionEndpoint must be a valid URL"); + } } @SuppressWarnings("unchecked") diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimAccessor.java index 277ade2e7..4afb1aaa3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimAccessor.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,6 @@ */ package org.springframework.security.oauth2.server.authorization.oidc; - import java.net.URL; import java.util.List; @@ -30,6 +29,7 @@ * in the OpenID Provider Configuration Response. * * @author Daniel Garnier-Moiroux + * @author Joe Grandja * @since 0.1.0 * @see ClaimAccessor * @see OAuth2AuthorizationServerMetadataClaimAccessor @@ -68,4 +68,14 @@ default URL getUserInfoEndpoint() { return getClaimAsURL(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT); } + /** + * Returns the {@code URL} of the OpenID Connect 1.0 End Session Endpoint {@code (end_session_endpoint)}. + * + * @return the {@code URL} of the OpenID Connect 1.0 End Session Endpoint + * @since 1.1 + */ + default URL getEndSessionEndpoint() { + return getClaimAsURL(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT); + } + } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimNames.java index ed8b64265..00e5d6caa 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ * in the OpenID Provider Configuration Response. * * @author Daniel Garnier-Moiroux + * @author Joe Grandja * @since 0.1.0 * @see OAuth2AuthorizationServerMetadataClaimNames * @see 3. OpenID Provider Metadata @@ -46,6 +47,12 @@ public final class OidcProviderMetadataClaimNames extends OAuth2AuthorizationSer */ public static final String USER_INFO_ENDPOINT = "userinfo_endpoint"; + /** + * {@code end_session_endpoint} - the {@code URL} of the OpenID Connect 1.0 End Session Endpoint + * @since 1.1 + */ + public static final String END_SESSION_ENDPOINT = "end_session_endpoint"; + private OidcProviderMetadataClaimNames() { } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java index 97166d0d9..6ebf79695 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,8 +34,10 @@ import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -79,6 +81,7 @@ * @see OAuth2TokenGenerator * @see OidcClientRegistrationAuthenticationToken * @see OidcClientConfigurationAuthenticationProvider + * @see PasswordEncoder * @see 3. Client Registration Endpoint */ public final class OidcClientRegistrationAuthenticationProvider implements AuthenticationProvider { @@ -90,6 +93,7 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe private final OAuth2TokenGenerator tokenGenerator; private final Converter clientRegistrationConverter; private Converter registeredClientConverter; + private PasswordEncoder passwordEncoder; /** * Constructs an {@code OidcClientRegistrationAuthenticationProvider} using the provided parameters. @@ -109,6 +113,7 @@ public OidcClientRegistrationAuthenticationProvider(RegisteredClientRepository r this.tokenGenerator = tokenGenerator; this.clientRegistrationConverter = new RegisteredClientOidcClientRegistrationConverter(); this.registeredClientConverter = new OidcClientRegistrationRegisteredClientConverter(); + this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); } @Override @@ -167,6 +172,18 @@ public void setRegisteredClientConverter(Converter redirectUris.addAll(clientRegistration.getRedirectUris())); + if (!CollectionUtils.isEmpty(clientRegistration.getPostLogoutRedirectUris())) { + builder.postLogoutRedirectUris(postLogoutRedirectUris -> + postLogoutRedirectUris.addAll(clientRegistration.getPostLogoutRedirectUris())); + } + if (!CollectionUtils.isEmpty(clientRegistration.getGrantTypes())) { builder.authorizationGrantTypes(authorizationGrantTypes -> clientRegistration.getGrantTypes().forEach(grantType -> diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java new file mode 100644 index 000000000..8b59322a0 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java @@ -0,0 +1,206 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.authentication; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.util.Base64; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * An {@link AuthenticationProvider} implementation for OpenID Connect 1.0 RP-Initiated Logout Endpoint. + * + * @author Joe Grandja + * @since 1.1 + * @see RegisteredClientRepository + * @see OAuth2AuthorizationService + * @see SessionRegistry + * @see 2. RP-Initiated Logout + */ +public final class OidcLogoutAuthenticationProvider implements AuthenticationProvider { + private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = + new OAuth2TokenType(OidcParameterNames.ID_TOKEN); + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + private final OAuth2AuthorizationService authorizationService; + private final SessionRegistry sessionRegistry; + + /** + * Constructs an {@code OidcLogoutAuthenticationProvider} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param sessionRegistry the {@link SessionRegistry} used to track OpenID Connect sessions + */ + public OidcLogoutAuthenticationProvider(RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, SessionRegistry sessionRegistry) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + this.sessionRegistry = sessionRegistry; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OidcLogoutAuthenticationToken oidcLogoutAuthentication = + (OidcLogoutAuthenticationToken) authentication; + + OAuth2Authorization authorization = this.authorizationService.findByToken( + oidcLogoutAuthentication.getIdTokenHint(), ID_TOKEN_TOKEN_TYPE); + if (authorization == null) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, "id_token_hint"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with ID Token"); + } + + OAuth2Authorization.Token authorizedIdToken = authorization.getToken(OidcIdToken.class); + if (authorizedIdToken.isInvalidated() || + authorizedIdToken.isBeforeUse()) { // Expired ID Token should be accepted + throwError(OAuth2ErrorCodes.INVALID_TOKEN, "id_token_hint"); + } + + RegisteredClient registeredClient = this.registeredClientRepository.findById( + authorization.getRegisteredClientId()); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + OidcIdToken idToken = authorizedIdToken.getToken(); + + // Validate client identity + List audClaim = idToken.getAudience(); + if (CollectionUtils.isEmpty(audClaim) || + !audClaim.contains(registeredClient.getClientId())) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.AUD); + } + if (StringUtils.hasText(oidcLogoutAuthentication.getClientId()) && + !oidcLogoutAuthentication.getClientId().equals(registeredClient.getClientId())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); + } + if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri()) && + !registeredClient.getPostLogoutRedirectUris().contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated logout request parameters"); + } + + // Validate user identity + if (oidcLogoutAuthentication.isPrincipalAuthenticated()) { + Authentication currentUserPrincipal = (Authentication) oidcLogoutAuthentication.getPrincipal(); + Authentication authorizedUserPrincipal = authorization.getAttribute(Principal.class.getName()); + if (!StringUtils.hasText(idToken.getSubject()) || + !currentUserPrincipal.getName().equals(authorizedUserPrincipal.getName())) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.SUB); + } + + // Check for active session + if (StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) { + SessionInformation sessionInformation = findSessionInformation( + currentUserPrincipal, oidcLogoutAuthentication.getSessionId()); + if (sessionInformation != null) { + String sessionIdHash; + try { + sessionIdHash = createHash(sessionInformation.getSessionId()); + } catch (NoSuchAlgorithmException ex) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "Failed to compute hash for Session ID.", null); + throw new OAuth2AuthenticationException(error); + } + + String sidClaim = idToken.getClaim("sid"); + if (!StringUtils.hasText(sidClaim) || + !sidClaim.equals(sessionIdHash)) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, "sid"); + } + } + } + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated logout request"); + } + + return new OidcLogoutAuthenticationToken(idToken, (Authentication) oidcLogoutAuthentication.getPrincipal(), + oidcLogoutAuthentication.getSessionId(), oidcLogoutAuthentication.getClientId(), + oidcLogoutAuthentication.getPostLogoutRedirectUri(), oidcLogoutAuthentication.getState()); + } + + @Override + public boolean supports(Class authentication) { + return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication); + } + + private SessionInformation findSessionInformation(Authentication principal, String sessionId) { + List sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), true); + SessionInformation sessionInformation = null; + if (!CollectionUtils.isEmpty(sessions)) { + for (SessionInformation session : sessions) { + if (session.getSessionId().equals(sessionId)) { + sessionInformation = session; + break; + } + } + } + return sessionInformation; + } + + private static void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error( + errorCode, + "OpenID Connect 1.0 Logout Request Parameter: " + parameterName, + "https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling"); + throw new OAuth2AuthenticationException(error); + } + + private static String createHash(String value) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationToken.java new file mode 100644 index 000000000..1e086c1bb --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationToken.java @@ -0,0 +1,181 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.authentication; + +import java.util.Collections; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation used for OpenID Connect 1.0 RP-Initiated Logout Endpoint. + * + * @author Joe Grandja + * @since 1.1 + * @see AbstractAuthenticationToken + * @see OidcLogoutAuthenticationProvider + */ +public class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final String idTokenHint; + private final OidcIdToken idToken; + private final Authentication principal; + private final String sessionId; + private final String clientId; + private final String postLogoutRedirectUri; + private final String state; + + /** + * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters. + * + * @param idTokenHint the ID Token previously issued by the Provider to the Client and used as a hint about the End-User's current authenticated session with the Client + * @param principal the authenticated principal representing the End-User + * @param sessionId the End-User's current authenticated session identifier with the Provider + * @param clientId the client identifier the ID Token was issued to + * @param postLogoutRedirectUri the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed + * @param state the opaque value used by the Client to maintain state between the logout request and the callback to the {@code postLogoutRedirectUri} + */ + public OidcLogoutAuthenticationToken(String idTokenHint, Authentication principal, @Nullable String sessionId, + @Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) { + super(Collections.emptyList()); + Assert.hasText(idTokenHint, "idTokenHint cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + this.idTokenHint = idTokenHint; + this.idToken = null; + this.principal = principal; + this.sessionId = sessionId; + this.clientId = clientId; + this.postLogoutRedirectUri = postLogoutRedirectUri; + this.state = state; + setAuthenticated(false); + } + + /** + * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters. + * + * @param idToken the ID Token previously issued by the Provider to the Client + * @param principal the authenticated principal representing the End-User + * @param sessionId the End-User's current authenticated session identifier with the Provider + * @param clientId the client identifier the ID Token was issued to + * @param postLogoutRedirectUri the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed + * @param state the opaque value used by the Client to maintain state between the logout request and the callback to the {@code postLogoutRedirectUri} + */ + public OidcLogoutAuthenticationToken(OidcIdToken idToken, Authentication principal, @Nullable String sessionId, + @Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) { + super(Collections.emptyList()); + Assert.notNull(idToken, "idToken cannot be null"); + Assert.notNull(principal, "principal cannot be null"); + this.idTokenHint = idToken.getTokenValue(); + this.idToken = idToken; + this.principal = principal; + this.sessionId = sessionId; + this.clientId = clientId; + this.postLogoutRedirectUri = postLogoutRedirectUri; + this.state = state; + setAuthenticated(true); + } + + /** + * Returns the authenticated principal representing the End-User. + * + * @return the authenticated principal representing the End-User + */ + @Override + public Object getPrincipal() { + return this.principal; + } + + /** + * Returns {@code true} if {@link #getPrincipal()} is authenticated, {@code false} otherwise. + * + * @return {@code true} if {@link #getPrincipal()} is authenticated, {@code false} otherwise + */ + public boolean isPrincipalAuthenticated() { + return !AnonymousAuthenticationToken.class.isAssignableFrom(this.principal.getClass()) && + this.principal.isAuthenticated(); + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the ID Token previously issued by the Provider to the Client and used as a hint + * about the End-User's current authenticated session with the Client. + * + * @return the ID Token previously issued by the Provider to the Client + */ + public String getIdTokenHint() { + return this.idTokenHint; + } + + /** + * Returns the ID Token previously issued by the Provider to the Client. + * + * @return the ID Token previously issued by the Provider to the Client + */ + @Nullable + public OidcIdToken getIdToken() { + return this.idToken; + } + + /** + * Returns the End-User's current authenticated session identifier with the Provider. + * + * @return the End-User's current authenticated session identifier with the Provider + */ + @Nullable + public String getSessionId() { + return this.sessionId; + } + + /** + * Returns the client identifier the ID Token was issued to. + * + * @return the client identifier + */ + @Nullable + public String getClientId() { + return this.clientId; + } + + /** + * Returns the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed. + * + * @return the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed + */ + @Nullable + public String getPostLogoutRedirectUri() { + return this.postLogoutRedirectUri; + } + + /** + * Returns the opaque value used by the Client to maintain state between the logout request and the callback to the {@link #getPostLogoutRedirectUri()}. + * + * @return the opaque value used by the Client to maintain state between the logout request and the callback to the {@link #getPostLogoutRedirectUri()} + */ + @Nullable + public String getState() { + return this.state; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/RegisteredClientOidcClientRegistrationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/RegisteredClientOidcClientRegistrationConverter.java index 75aa17c96..7cd62d223 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/RegisteredClientOidcClientRegistrationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/RegisteredClientOidcClientRegistrationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,11 @@ public OidcClientRegistration convert(RegisteredClient registeredClient) { builder.redirectUris(redirectUris -> redirectUris.addAll(registeredClient.getRedirectUris())); + if (!CollectionUtils.isEmpty(registeredClient.getPostLogoutRedirectUris())) { + builder.postLogoutRedirectUris(postLogoutRedirectUris -> + postLogoutRedirectUris.addAll(registeredClient.getPostLogoutRedirectUris())); + } + builder.grantTypes(grantTypes -> registeredClient.getAuthorizationGrantTypes().forEach(authorizationGrantType -> grantTypes.add(authorizationGrantType.getValue()))); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/HttpMessageConverters.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/HttpMessageConverters.java index 2cf27c759..1ba662f3a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/HttpMessageConverters.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/HttpMessageConverters.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ * Utility methods for {@link HttpMessageConverter}'s. * * @author Joe Grandja + * @author luamas * @since 0.1.0 */ final class HttpMessageConverters { @@ -41,7 +42,7 @@ final class HttpMessageConverters { jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); - jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); } private HttpMessageConverters() { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java index 78655975a..3442c2ae6 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -148,6 +148,7 @@ private MapOidcClientRegistrationConverter() { claimConverters.put(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, MapOidcClientRegistrationConverter::convertClientSecretExpiresAt); claimConverters.put(OidcClientMetadataClaimNames.CLIENT_NAME, stringConverter); claimConverters.put(OidcClientMetadataClaimNames.REDIRECT_URIS, collectionStringConverter); + claimConverters.put(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, collectionStringConverter); claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, stringConverter); claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, stringConverter); claimConverters.put(OidcClientMetadataClaimNames.GRANT_TYPES, collectionStringConverter); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java new file mode 100644 index 000000000..b2d102eb1 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java @@ -0,0 +1,219 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.web; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.oidc.web.authentication.OidcLogoutAuthenticationConverter; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +/** + * A {@code Filter} that processes OpenID Connect 1.0 RP-Initiated Logout Requests. + * + * @author Joe Grandja + * @since 1.1 + * @see OidcLogoutAuthenticationConverter + * @see OidcLogoutAuthenticationProvider + * @see 2. RP-Initiated Logout + */ +public final class OidcLogoutEndpointFilter extends OncePerRequestFilter { + + /** + * The default endpoint {@code URI} for OpenID Connect 1.0 RP-Initiated Logout Requests. + */ + private static final String DEFAULT_OIDC_LOGOUT_ENDPOINT_URI = "/connect/logout"; + + private final AuthenticationManager authenticationManager; + private final RequestMatcher logoutEndpointMatcher; + private final LogoutHandler logoutHandler; + private final LogoutSuccessHandler logoutSuccessHandler; + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + private AuthenticationConverter authenticationConverter; + private AuthenticationSuccessHandler authenticationSuccessHandler = this::performLogout; + private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + + /** + * Constructs an {@code OidcLogoutEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ + public OidcLogoutEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_OIDC_LOGOUT_ENDPOINT_URI); + } + + /** + * Constructs an {@code OidcLogoutEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + * @param logoutEndpointUri the endpoint {@code URI} for OpenID Connect 1.0 RP-Initiated Logout Requests + */ + public OidcLogoutEndpointFilter(AuthenticationManager authenticationManager, + String logoutEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(logoutEndpointUri, "logoutEndpointUri cannot be empty"); + this.authenticationManager = authenticationManager; + this.logoutEndpointMatcher = new OrRequestMatcher( + new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.GET.name()), + new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.POST.name())); + this.logoutHandler = new SecurityContextLogoutHandler(); + SimpleUrlLogoutSuccessHandler urlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler(); + urlLogoutSuccessHandler.setDefaultTargetUrl("/"); + this.logoutSuccessHandler = urlLogoutSuccessHandler; + this.authenticationConverter = new OidcLogoutAuthenticationConverter(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.logoutEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication oidcLogoutAuthentication = this.authenticationConverter.convert(request); + + Authentication oidcLogoutAuthenticationResult = + this.authenticationManager.authenticate(oidcLogoutAuthentication); + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, oidcLogoutAuthenticationResult); + } catch (OAuth2AuthenticationException ex) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Logout request failed: %s", ex.getError()), ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex); + } catch (Exception ex) { + OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_REQUEST, + "OpenID Connect 1.0 RP-Initiated Logout Error: " + ex.getMessage(), + "https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling"); + if (this.logger.isTraceEnabled()) { + this.logger.trace(error, ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, + new OAuth2AuthenticationException(error)); + } + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest} + * to an instance of {@link OidcLogoutAuthenticationToken} used for authenticating the request. + * + * @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken} + * and performing the logout. + * + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken} + */ + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + */ + public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + } + + private void performLogout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + OidcLogoutAuthenticationToken oidcLogoutAuthentication = (OidcLogoutAuthenticationToken) authentication; + + // Check for active user session + if (oidcLogoutAuthentication.isPrincipalAuthenticated() && + StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) { + // Perform logout + this.logoutHandler.logout(request, response, + (Authentication) oidcLogoutAuthentication.getPrincipal()); + } + + if (oidcLogoutAuthentication.isAuthenticated() && + StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri())) { + // Perform post-logout redirect + UriComponentsBuilder uriBuilder = UriComponentsBuilder + .fromUriString(oidcLogoutAuthentication.getPostLogoutRedirectUri()); + String redirectUri; + if (StringUtils.hasText(oidcLogoutAuthentication.getState())) { + uriBuilder.queryParam( + OAuth2ParameterNames.STATE, + UriUtils.encode(oidcLogoutAuthentication.getState(), StandardCharsets.UTF_8)); + } + redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded + this.redirectStrategy.sendRedirect(request, response, redirectUri); + } else { + // Perform default redirect + this.logoutSuccessHandler.onLogoutSuccess(request, response, + (Authentication) oidcLogoutAuthentication.getPrincipal()); + } + } + + private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + + OAuth2Error error = ((OAuth2AuthenticationException) exception).getError(); + response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java index 83e6a19fa..35752f248 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -94,14 +94,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcProviderConfiguration.Builder providerConfiguration = OidcProviderConfiguration.builder() .issuer(issuer) .authorizationEndpoint(asUrl(issuer, authorizationServerSettings.getAuthorizationEndpoint())) + .deviceAuthorizationEndpoint(asUrl(issuer, authorizationServerSettings.getDeviceAuthorizationEndpoint())) .tokenEndpoint(asUrl(issuer, authorizationServerSettings.getTokenEndpoint())) .tokenEndpointAuthenticationMethods(clientAuthenticationMethods()) .jwkSetUrl(asUrl(issuer, authorizationServerSettings.getJwkSetEndpoint())) .userInfoEndpoint(asUrl(issuer, authorizationServerSettings.getOidcUserInfoEndpoint())) + .endSessionEndpoint(asUrl(issuer, authorizationServerSettings.getOidcLogoutEndpoint())) .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) .grantType(AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .grantType(AuthorizationGrantType.DEVICE_CODE.getValue()) .tokenRevocationEndpoint(asUrl(issuer, authorizationServerSettings.getTokenRevocationEndpoint())) .tokenRevocationEndpointAuthenticationMethods(clientAuthenticationMethods()) .tokenIntrospectionEndpoint(asUrl(issuer, authorizationServerSettings.getTokenIntrospectionEndpoint())) diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2EndpointUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OAuth2EndpointUtils.java similarity index 55% rename from oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2EndpointUtils.java rename to oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OAuth2EndpointUtils.java index c364dc8e9..0eb6d711a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2EndpointUtils.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OAuth2EndpointUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.security.oauth2.server.authorization.web; +package org.springframework.security.oauth2.server.authorization.oidc.web.authentication; import java.util.Map; @@ -21,23 +21,41 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; /** * Utility methods for the OAuth 2.0 Protocol Endpoints. * * @author Joe Grandja - * @since 0.0.1 + * @author Greg Li + * @since 1.1.4 */ final class OAuth2EndpointUtils { private OAuth2EndpointUtils() { } - static MultiValueMap getParameters(HttpServletRequest request) { + static MultiValueMap getFormParameters(HttpServletRequest request) { Map parameterMap = request.getParameterMap(); - MultiValueMap parameters = new LinkedMultiValueMap<>(parameterMap.size()); + MultiValueMap parameters = new LinkedMultiValueMap<>(); parameterMap.forEach((key, values) -> { - if (values.length > 0) { + String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : ""; + // If not query parameter then it's a form parameter + if (!queryString.contains(key) && values.length > 0) { + for (String value : values) { + parameters.add(key, value); + } + } + }); + return parameters; + } + + static MultiValueMap getQueryParameters(HttpServletRequest request) { + Map parameterMap = request.getParameterMap(); + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameterMap.forEach((key, values) -> { + String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : ""; + if (queryString.contains(key) && values.length > 0) { for (String value : values) { parameters.add(key, value); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcClientRegistrationAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcClientRegistrationAuthenticationConverter.java index 54c6c1baf..c4933398a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcClientRegistrationAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcClientRegistrationAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ import org.springframework.security.oauth2.server.authorization.oidc.http.converter.OidcClientRegistrationHttpMessageConverter; import org.springframework.security.oauth2.server.authorization.oidc.web.OidcClientRegistrationEndpointFilter; import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; /** @@ -65,10 +66,12 @@ public Authentication convert(HttpServletRequest request) { return new OidcClientRegistrationAuthenticationToken(principal, clientRegistration); } + MultiValueMap parameters = OAuth2EndpointUtils.getQueryParameters(request); + // client_id (REQUIRED) - String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID); + String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); if (!StringUtils.hasText(clientId) || - request.getParameterValues(OAuth2ParameterNames.CLIENT_ID).length != 1) { + parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java new file mode 100644 index 000000000..dfa1f2cd2 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java @@ -0,0 +1,107 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.web.authentication; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.oidc.web.OidcLogoutEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract an OpenID Connect 1.0 RP-Initiated Logout Request from {@link HttpServletRequest} + * and then converts to an {@link OidcLogoutAuthenticationToken} used for authenticating the request. + * + * @author Joe Grandja + * @since 1.1 + * @see AuthenticationConverter + * @see OidcLogoutAuthenticationToken + * @see OidcLogoutEndpointFilter + */ +public final class OidcLogoutAuthenticationConverter implements AuthenticationConverter { + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken( + "anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + @Override + public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = + "GET".equals(request.getMethod()) ? + OAuth2EndpointUtils.getQueryParameters(request) : + OAuth2EndpointUtils.getFormParameters(request); + + // id_token_hint (REQUIRED) // RECOMMENDED as per spec + String idTokenHint = parameters.getFirst("id_token_hint"); + if (!StringUtils.hasText(idTokenHint) || + parameters.get("id_token_hint").size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "id_token_hint"); + } + + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + String sessionId = null; + HttpSession session = request.getSession(false); + if (session != null) { + sessionId = session.getId(); + } + + // client_id (OPTIONAL) + String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); + if (StringUtils.hasText(clientId) && + parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); + } + + // post_logout_redirect_uri (OPTIONAL) + String postLogoutRedirectUri = parameters.getFirst("post_logout_redirect_uri"); + if (StringUtils.hasText(postLogoutRedirectUri) && + parameters.get("post_logout_redirect_uri").size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri"); + } + + // state (OPTIONAL) + String state = parameters.getFirst(OAuth2ParameterNames.STATE); + if (StringUtils.hasText(state) && + parameters.get(OAuth2ParameterNames.STATE).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE); + } + + return new OidcLogoutAuthenticationToken(idTokenHint, principal, + sessionId, clientId, postLogoutRedirectUri, state); + } + + private static void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error( + errorCode, + "OpenID Connect 1.0 Logout Request Parameter: " + parameterName, + "https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling"); + throw new OAuth2AuthenticationException(error); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java index 53484bbcb..4698da24c 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ private AuthorizationServerSettings(Map settings) { } /** - * Returns the URL of the Authorization Server's Issuer Identifier + * Returns the URL of the Authorization Server's Issuer Identifier. * * @return the URL of the Authorization Server's Issuer Identifier */ @@ -52,6 +52,26 @@ public String getAuthorizationEndpoint() { return getSetting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT); } + /** + * Returns the OAuth 2.0 Device Authorization endpoint. The default is {@code /oauth2/device_authorization}. + * + * @return the Device Authorization endpoint + * @since 1.1 + */ + public String getDeviceAuthorizationEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_AUTHORIZATION_ENDPOINT); + } + + /** + * Returns the OAuth 2.0 Device Verification endpoint. The default is {@code /oauth2/device_verification}. + * + * @return the Device Verification endpoint + * @since 1.1 + */ + public String getDeviceVerificationEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT); + } + /** * Returns the OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}. * @@ -106,6 +126,16 @@ public String getOidcUserInfoEndpoint() { return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_USER_INFO_ENDPOINT); } + /** + * Returns the OpenID Connect 1.0 Logout endpoint. The default is {@code /connect/logout}. + * + * @return the OpenID Connect 1.0 Logout endpoint + * @since 1.1 + */ + public String getOidcLogoutEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT); + } + /** * Constructs a new {@link Builder} with the default settings. * @@ -114,12 +144,15 @@ public String getOidcUserInfoEndpoint() { public static Builder builder() { return new Builder() .authorizationEndpoint("/oauth2/authorize") + .deviceAuthorizationEndpoint("/oauth2/device_authorization") + .deviceVerificationEndpoint("/oauth2/device_verification") .tokenEndpoint("/oauth2/token") .jwkSetEndpoint("/oauth2/jwks") .tokenRevocationEndpoint("/oauth2/revoke") .tokenIntrospectionEndpoint("/oauth2/introspect") .oidcClientRegistrationEndpoint("/connect/register") - .oidcUserInfoEndpoint("/userinfo"); + .oidcUserInfoEndpoint("/userinfo") + .oidcLogoutEndpoint("/connect/logout"); } /** @@ -162,6 +195,28 @@ public Builder authorizationEndpoint(String authorizationEndpoint) { return setting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT, authorizationEndpoint); } + /** + * Sets the OAuth 2.0 Device Authorization endpoint. + * + * @param deviceAuthorizationEndpoint the Device Authorization endpoint + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_AUTHORIZATION_ENDPOINT, deviceAuthorizationEndpoint); + } + + /** + * Sets the OAuth 2.0 Device Verification endpoint. + * + * @param deviceVerificationEndpoint the Device Verification endpoint + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder deviceVerificationEndpoint(String deviceVerificationEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT, deviceVerificationEndpoint); + } + /** * Sets the OAuth 2.0 Token endpoint. * @@ -222,6 +277,17 @@ public Builder oidcUserInfoEndpoint(String oidcUserInfoEndpoint) { return setting(ConfigurationSettingNames.AuthorizationServer.OIDC_USER_INFO_ENDPOINT, oidcUserInfoEndpoint); } + /** + * Sets the OpenID Connect 1.0 Logout endpoint. + * + * @param oidcLogoutEndpoint the OpenID Connect 1.0 Logout endpoint + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder oidcLogoutEndpoint(String oidcLogoutEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT, oidcLogoutEndpoint); + } + /** * Builds the {@link AuthorizationServerSettings}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java index b548019b2..c51e545a5 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -86,6 +86,16 @@ public static final class AuthorizationServer { */ public static final String AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("authorization-endpoint"); + /** + * Set the OAuth 2.0 Device Authorization endpoint. + */ + public static final String DEVICE_AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("device-authorization-endpoint"); + + /** + * Set the OAuth 2.0 Device Verification endpoint. + */ + public static final String DEVICE_VERIFICATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("device-verification-endpoint"); + /** * Set the OAuth 2.0 Token endpoint. */ @@ -116,6 +126,12 @@ public static final class AuthorizationServer { */ public static final String OIDC_USER_INFO_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("oidc-user-info-endpoint"); + /** + * Set the OpenID Connect 1.0 Logout endpoint. + * @since 1.1 + */ + public static final String OIDC_LOGOUT_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("oidc-logout-endpoint"); + private AuthorizationServer() { } @@ -144,6 +160,12 @@ public static final class Token { */ public static final String ACCESS_TOKEN_FORMAT = TOKEN_SETTINGS_NAMESPACE.concat("access-token-format"); + /** + * Set the time-to-live for a device code. + * @since 1.1 + */ + public static final String DEVICE_CODE_TIME_TO_LIVE = TOKEN_SETTINGS_NAMESPACE.concat("device-code-time-to-live"); + /** * Set to {@code true} if refresh tokens are reused when returning the access token response, * or {@code false} if a new refresh token is issued. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java index 887bdca80..2cbb024c4 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -66,6 +66,16 @@ public OAuth2TokenFormat getAccessTokenFormat() { return getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT); } + /** + * Returns the time-to-live for a device code. The default is 5 minutes. + * + * @return the time-to-live for a device code + * @since 1.1 + */ + public Duration getDeviceCodeTimeToLive() { + return getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE); + } + /** * Returns {@code true} if refresh tokens are reused when returning the access token response, * or {@code false} if a new refresh token is issued. The default is {@code true}. @@ -103,6 +113,7 @@ public static Builder builder() { .authorizationCodeTimeToLive(Duration.ofMinutes(5)) .accessTokenTimeToLive(Duration.ofMinutes(5)) .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) + .deviceCodeTimeToLive(Duration.ofMinutes(5)) .reuseRefreshTokens(true) .refreshTokenTimeToLive(Duration.ofMinutes(60)) .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256); @@ -166,6 +177,19 @@ public Builder accessTokenFormat(OAuth2TokenFormat accessTokenFormat) { return setting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, accessTokenFormat); } + /** + * Set the time-to-live for a device code. Must be greater than {@code Duration.ZERO}. + * + * @param deviceCodeTimeToLive the time-to-live for a device code + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder deviceCodeTimeToLive(Duration deviceCodeTimeToLive) { + Assert.notNull(deviceCodeTimeToLive, "deviceCodeTimeToLive cannot be null"); + Assert.isTrue(deviceCodeTimeToLive.getSeconds() > 0, "deviceCodeTimeToLive must be greater than Duration.ZERO"); + return setting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, deviceCodeTimeToLive); + } + /** * Set to {@code true} if refresh tokens are reused when returning the access token response, * or {@code false} if a new refresh token is issued. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java index 3cc52de73..94ba32e30 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,10 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collections; +import java.util.Date; import org.springframework.lang.Nullable; +import org.springframework.security.core.session.SessionInformation; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; @@ -125,8 +127,20 @@ public Jwt generate(OAuth2TokenContext context) { if (StringUtils.hasText(nonce)) { claimsBuilder.claim(IdTokenClaimNames.NONCE, nonce); } + SessionInformation sessionInformation = context.get(SessionInformation.class); + if (sessionInformation != null) { + claimsBuilder.claim("sid", sessionInformation.getSessionId()); + claimsBuilder.claim(IdTokenClaimNames.AUTH_TIME, sessionInformation.getLastRequest()); + } + } else if (AuthorizationGrantType.REFRESH_TOKEN.equals(context.getAuthorizationGrantType())) { + OidcIdToken currentIdToken = context.getAuthorization().getToken(OidcIdToken.class).getToken(); + if (currentIdToken.hasClaim("sid")) { + claimsBuilder.claim("sid", currentIdToken.getClaim("sid")); + } + if (currentIdToken.hasClaim(IdTokenClaimNames.AUTH_TIME)) { + claimsBuilder.claim(IdTokenClaimNames.AUTH_TIME, currentIdToken.getClaim(IdTokenClaimNames.AUTH_TIME)); + } } - // TODO Add 'auth_time' claim } // @formatter:on @@ -147,6 +161,12 @@ public Jwt generate(OAuth2TokenContext context) { if (context.getAuthorizationGrant() != null) { jwtContextBuilder.authorizationGrant(context.getAuthorizationGrant()); } + if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { + SessionInformation sessionInformation = context.get(SessionInformation.class); + if (sessionInformation != null) { + jwtContextBuilder.put(SessionInformation.class, sessionInformation); + } + } // @formatter:on JwtEncodingContext jwtContext = jwtContextBuilder.build(); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/util/SpringAuthorizationServerVersion.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/util/SpringAuthorizationServerVersion.java index 1f164da9b..2e09e5b79 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/util/SpringAuthorizationServerVersion.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/util/SpringAuthorizationServerVersion.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ */ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 1; - private static final int MINOR = 0; + private static final int MINOR = 1; private static final int PATCH = 0; /** diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java new file mode 100644 index 000000000..620a1ec5c --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java @@ -0,0 +1,155 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcScopes; + +/** + * For internal use only. + */ +class DefaultConsentPage { + private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); + + private DefaultConsentPage() { + } + + static void displayConsent(HttpServletRequest request, HttpServletResponse response, String clientId, + Authentication principal, Set requestedScopes, Set authorizedScopes, String state, + Map additionalParameters) throws IOException { + + String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state, additionalParameters); + response.setContentType(TEXT_HTML_UTF8.toString()); + response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length); + response.getWriter().write(consentPage); + } + + private static String generateConsentPage(HttpServletRequest request, + String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state, + Map additionalParameters) { + Set scopesToAuthorize = new HashSet<>(); + Set scopesPreviouslyAuthorized = new HashSet<>(); + for (String scope : requestedScopes) { + if (authorizedScopes.contains(scope)) { + scopesPreviouslyAuthorized.add(scope); + } else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent + scopesToAuthorize.add(scope); + } + } + + // https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1 + // The server SHOULD display + // the "user_code" to the user and ask them to verify that it matches + // the "user_code" being displayed on the device to confirm they are + // authorizing the correct device. + String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE); + + StringBuilder builder = new StringBuilder(); + + builder.append(""); + builder.append(""); + builder.append(""); + builder.append(" "); + builder.append(" "); + builder.append(" "); + builder.append(" Consent required"); + builder.append(" "); + builder.append(""); + builder.append(""); + builder.append("

"); + builder.append("
"); + builder.append("

Consent required

"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("

" + clientId + " wants to access your account " + principal.getName() + "

"); + builder.append("
"); + builder.append("
"); + if (userCode != null) { + builder.append("
"); + builder.append("
"); + builder.append("

You have provided the code " + userCode + ". Verify that this code matches what is shown on your device.

"); + builder.append("
"); + builder.append("
"); + } + builder.append("
"); + builder.append("
"); + builder.append("

The following permissions are requested by the above app.
Please review these and consent if you approve.

"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append(" "); + builder.append(" "); + if (userCode != null) { + builder.append(" "); + } + + for (String scope : scopesToAuthorize) { + builder.append("
"); + builder.append(" "); + builder.append(" "); + builder.append("
"); + } + + if (!scopesPreviouslyAuthorized.isEmpty()) { + builder.append("

You have already granted the following permissions to the above app:

"); + for (String scope : scopesPreviouslyAuthorized) { + builder.append("
"); + builder.append(" "); + builder.append(" "); + builder.append("
"); + } + } + + builder.append("
"); + builder.append(" "); + builder.append("
"); + builder.append("
"); + builder.append(" "); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("

Your consent to provide access is required.
If you do not approve, click Cancel, in which case no information will be shared with the app.

"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append(""); + builder.append(""); + + return builder.toString(); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java index 7bf292b68..cacb855cc 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; +import java.util.Collections; import java.util.Set; import jakarta.servlet.FilterChain; @@ -31,12 +29,12 @@ import org.springframework.core.log.LogMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; @@ -56,6 +54,7 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.util.RedirectUrlBuilder; import org.springframework.security.web.util.UrlUtils; import org.springframework.security.web.util.matcher.AndRequestMatcher; @@ -67,6 +66,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; /** * A {@code Filter} for the OAuth 2.0 Authorization Code Grant, @@ -98,6 +98,7 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte private AuthenticationConverter authenticationConverter; private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendAuthorizationResponse; private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + private SessionAuthenticationStrategy sessionAuthenticationStrategy = (authentication, request, response) -> {}; private String consentPage; /** @@ -183,6 +184,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } + this.sessionAuthenticationStrategy.onAuthentication( + authenticationResult, request, response); + this.authenticationSuccessHandler.onAuthenticationSuccess( request, response, authenticationResult); @@ -239,6 +243,19 @@ public void setAuthenticationFailureHandler(AuthenticationFailureHandler authent this.authenticationFailureHandler = authenticationFailureHandler; } + /** + * Sets the {@link SessionAuthenticationStrategy} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken} + * before calling the {@link AuthenticationSuccessHandler}. + * If OpenID Connect is enabled, the default implementation tracks OpenID Connect sessions using a {@link SessionRegistry}. + * + * @param sessionAuthenticationStrategy the {@link SessionAuthenticationStrategy} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken} + * @since 1.1 + */ + public void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionAuthenticationStrategy) { + Assert.notNull(sessionAuthenticationStrategy, "sessionAuthenticationStrategy cannot be null"); + this.sessionAuthenticationStrategy = sessionAuthenticationStrategy; + } + /** * Specify the URI to redirect Resource Owners to if consent is required. A default consent * page will be generated when this attribute is not specified. @@ -270,7 +287,7 @@ private void sendAuthorizationConsent(HttpServletRequest request, HttpServletRes if (this.logger.isTraceEnabled()) { this.logger.trace("Displaying generated consent screen"); } - DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state); + DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state, Collections.emptyMap()); } } @@ -299,15 +316,12 @@ private void sendAuthorizationResponse(HttpServletRequest request, HttpServletRe UriComponentsBuilder uriBuilder = UriComponentsBuilder .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()) .queryParam(OAuth2ParameterNames.CODE, authorizationCodeRequestAuthentication.getAuthorizationCode().getTokenValue()); - String redirectUri; if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) { - uriBuilder.queryParam(OAuth2ParameterNames.STATE, "{state}"); - Map queryParams = new HashMap<>(); - queryParams.put(OAuth2ParameterNames.STATE, authorizationCodeRequestAuthentication.getState()); - redirectUri = uriBuilder.build(queryParams).toString(); - } else { - redirectUri = uriBuilder.toUriString(); + uriBuilder.queryParam( + OAuth2ParameterNames.STATE, + UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8)); } + String redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded this.redirectStrategy.sendRedirect(request, response, redirectUri); } @@ -334,124 +348,22 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()) .queryParam(OAuth2ParameterNames.ERROR, error.getErrorCode()); if (StringUtils.hasText(error.getDescription())) { - uriBuilder.queryParam(OAuth2ParameterNames.ERROR_DESCRIPTION, error.getDescription()); + uriBuilder.queryParam( + OAuth2ParameterNames.ERROR_DESCRIPTION, + UriUtils.encode(error.getDescription(), StandardCharsets.UTF_8)); } if (StringUtils.hasText(error.getUri())) { - uriBuilder.queryParam(OAuth2ParameterNames.ERROR_URI, error.getUri()); + uriBuilder.queryParam( + OAuth2ParameterNames.ERROR_URI, + UriUtils.encode(error.getUri(), StandardCharsets.UTF_8)); } - String redirectUri; if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) { - uriBuilder.queryParam(OAuth2ParameterNames.STATE, "{state}"); - Map queryParams = new HashMap<>(); - queryParams.put(OAuth2ParameterNames.STATE, authorizationCodeRequestAuthentication.getState()); - redirectUri = uriBuilder.build(queryParams).toString(); - } else { - redirectUri = uriBuilder.toUriString(); + uriBuilder.queryParam( + OAuth2ParameterNames.STATE, + UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8)); } + String redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded this.redirectStrategy.sendRedirect(request, response, redirectUri); } - /** - * For internal use only. - */ - private static class DefaultConsentPage { - private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); - - private static void displayConsent(HttpServletRequest request, HttpServletResponse response, - String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state) - throws IOException { - - String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state); - response.setContentType(TEXT_HTML_UTF8.toString()); - response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length); - response.getWriter().write(consentPage); - } - - private static String generateConsentPage(HttpServletRequest request, - String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state) { - Set scopesToAuthorize = new HashSet<>(); - Set scopesPreviouslyAuthorized = new HashSet<>(); - for (String scope : requestedScopes) { - if (authorizedScopes.contains(scope)) { - scopesPreviouslyAuthorized.add(scope); - } else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent - scopesToAuthorize.add(scope); - } - } - - StringBuilder builder = new StringBuilder(); - - builder.append(""); - builder.append(""); - builder.append(""); - builder.append(" "); - builder.append(" "); - builder.append(" "); - builder.append(" Consent required"); - builder.append(" "); - builder.append(""); - builder.append(""); - builder.append("
"); - builder.append("
"); - builder.append("

Consent required

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("

" + clientId + " wants to access your account " + principal.getName() + "

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("

The following permissions are requested by the above app.
Please review these and consent if you approve.

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append(" "); - builder.append(" "); - - for (String scope : scopesToAuthorize) { - builder.append("
"); - builder.append(" "); - builder.append(" "); - builder.append("
"); - } - - if (!scopesPreviouslyAuthorized.isEmpty()) { - builder.append("

You have already granted the following permissions to the above app:

"); - for (String scope : scopesPreviouslyAuthorized) { - builder.append("
"); - builder.append(" "); - builder.append(" "); - builder.append("
"); - } - } - - builder.append("
"); - builder.append(" "); - builder.append("
"); - builder.append("
"); - builder.append(" "); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("

Your consent to provide access is required.
If you do not approve, click Cancel, in which case no information will be shared with the app.

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append(""); - builder.append(""); - - return builder.toString(); - } - } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java index c0e31f393..c561260c0 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -92,6 +92,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OAuth2AuthorizationServerMetadata.Builder authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder() .issuer(issuer) .authorizationEndpoint(asUrl(issuer, authorizationServerSettings.getAuthorizationEndpoint())) + .deviceAuthorizationEndpoint(asUrl(issuer, authorizationServerSettings.getDeviceAuthorizationEndpoint())) .tokenEndpoint(asUrl(issuer, authorizationServerSettings.getTokenEndpoint())) .tokenEndpointAuthenticationMethods(clientAuthenticationMethods()) .jwkSetUrl(asUrl(issuer, authorizationServerSettings.getJwkSetEndpoint())) @@ -99,6 +100,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) .grantType(AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .grantType(AuthorizationGrantType.DEVICE_CODE.getValue()) .tokenRevocationEndpoint(asUrl(issuer, authorizationServerSettings.getTokenRevocationEndpoint())) .tokenRevocationEndpointAuthenticationMethods(clientAuthenticationMethods()) .tokenIntrospectionEndpoint(asUrl(issuer, authorizationServerSettings.getTokenIntrospectionEndpoint())) diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java new file mode 100644 index 000000000..f5473f923 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java @@ -0,0 +1,237 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationRequestAuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@code Filter} for the OAuth 2.0 Device Authorization endpoint, + * which handles the processing of the OAuth 2.0 Device Authorization Request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationManager + * @see OAuth2DeviceAuthorizationRequestAuthenticationConverter + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.1 Device Authorization Request + * @see Section 3.2 Device Authorization Response + */ +public final class OAuth2DeviceAuthorizationEndpointFilter extends OncePerRequestFilter { + + private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorization"; + + private final AuthenticationManager authenticationManager; + private final RequestMatcher deviceAuthorizationEndpointMatcher; + private final HttpMessageConverter deviceAuthorizationHttpResponseConverter = + new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + private AuthenticationDetailsSource authenticationDetailsSource = + new WebAuthenticationDetailsSource(); + private AuthenticationConverter authenticationConverter; + private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendDeviceAuthorizationResponse; + private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + private String verificationUri = OAuth2DeviceVerificationEndpointFilter.DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ + public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI); + } + + /** + * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + * @param deviceAuthorizationEndpointUri the endpoint {@code URI} for device authorization requests + */ + public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager, String deviceAuthorizationEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(deviceAuthorizationEndpointUri, "deviceAuthorizationEndpointUri cannot be empty"); + this.authenticationManager = authenticationManager; + this.deviceAuthorizationEndpointMatcher = new AntPathRequestMatcher(deviceAuthorizationEndpointUri, + HttpMethod.POST.name()); + this.authenticationConverter = new OAuth2DeviceAuthorizationRequestAuthenticationConverter(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.deviceAuthorizationEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication deviceAuthorizationRequestAuthentication = this.authenticationConverter.convert(request); + if (deviceAuthorizationRequestAuthentication instanceof AbstractAuthenticationToken) { + ((AbstractAuthenticationToken) deviceAuthorizationRequestAuthentication) + .setDetails(this.authenticationDetailsSource.buildDetails(request)); + } + + Authentication deviceAuthorizationRequestAuthenticationResult = + this.authenticationManager.authenticate(deviceAuthorizationRequestAuthentication); + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, + deviceAuthorizationRequestAuthenticationResult); + } catch (OAuth2AuthenticationException ex) { + SecurityContextHolder.clearContext(); + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Device authorization request failed: %s", ex.getError()), ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex); + } + } + + /** + * Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}. + * + * @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest} + */ + public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) { + Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null"); + this.authenticationDetailsSource = authenticationDetailsSource; + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating the request. + * + * @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the {@link OAuth2DeviceAuthorizationResponse Device Authorization Response}. + * + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + */ + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + */ + public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + } + + /** + * Sets the end-user verification {@code URI} on the authorization server. + * + * @param verificationUri the end-user verification {@code URI} on the authorization server + * @see Section 3.2 Device Authorization Response + */ + public void setVerificationUri(String verificationUri) { + Assert.hasText(verificationUri, "verificationUri cannot be empty"); + this.verificationUri = verificationUri; + } + + private void sendDeviceAuthorizationResponse(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication; + + OAuth2DeviceCode deviceCode = deviceAuthorizationRequestAuthentication.getDeviceCode(); + OAuth2UserCode userCode = deviceAuthorizationRequestAuthentication.getUserCode(); + + // Generate the fully-qualified verification URI + String issuerUri = AuthorizationServerContextHolder.getContext().getIssuer(); + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(issuerUri) + .path(this.verificationUri); + String verificationUri = uriComponentsBuilder.build().toUriString(); + // @formatter:off + String verificationUriComplete = uriComponentsBuilder + .queryParam(OAuth2ParameterNames.USER_CODE, userCode.getTokenValue()) + .build().toUriString(); + // @formatter:on + + // @formatter:off + OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = + OAuth2DeviceAuthorizationResponse.with(deviceCode, userCode) + .verificationUri(verificationUri) + .verificationUriComplete(verificationUriComplete) + .build(); + // @formatter:on + + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + this.deviceAuthorizationHttpResponseConverter.write(deviceAuthorizationResponse, null, httpResponse); + } + + private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + + OAuth2Error error = ((OAuth2AuthenticationException) authenticationException).getError(); + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + httpResponse.setStatusCode(HttpStatus.BAD_REQUEST); + this.errorHttpResponseConverter.write(error, null, httpResponse); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java new file mode 100644 index 000000000..5e0e5dc1a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java @@ -0,0 +1,285 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationConsentAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceVerificationAuthenticationConverter; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.util.RedirectUrlBuilder; +import org.springframework.security.web.util.UrlUtils; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@code Filter} for the OAuth 2.0 Device Authorization Grant, + * which handles the processing of the Device Verification Request (submission of the user code) + * and the Device Authorization Consent. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationManager + * @see OAuth2DeviceVerificationAuthenticationConverter + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationConverter + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.3 User Interaction + */ +public final class OAuth2DeviceVerificationEndpointFilter extends OncePerRequestFilter { + + static final String DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI = "/oauth2/device_verification"; + + private final AuthenticationManager authenticationManager; + private final RequestMatcher deviceVerificationEndpointMatcher; + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + private AuthenticationDetailsSource authenticationDetailsSource = + new WebAuthenticationDetailsSource(); + private AuthenticationConverter authenticationConverter; + private AuthenticationSuccessHandler authenticationSuccessHandler = + new SimpleUrlAuthenticationSuccessHandler("/?success"); + private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + private String consentPage; + + /** + * Constructs an {@code OAuth2DeviceVerificationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ + public OAuth2DeviceVerificationEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI); + } + + /** + * Constructs an {@code OAuth2DeviceVerificationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + * @param deviceVerificationEndpointUri the endpoint {@code URI} for device verification requests + */ + public OAuth2DeviceVerificationEndpointFilter(AuthenticationManager authenticationManager, String deviceVerificationEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(deviceVerificationEndpointUri, "deviceVerificationEndpointUri cannot be empty"); + this.authenticationManager = authenticationManager; + this.deviceVerificationEndpointMatcher = createDefaultRequestMatcher(deviceVerificationEndpointUri); + this.authenticationConverter = new DelegatingAuthenticationConverter( + Arrays.asList( + new OAuth2DeviceVerificationAuthenticationConverter(), + new OAuth2DeviceAuthorizationConsentAuthenticationConverter())); + } + + private RequestMatcher createDefaultRequestMatcher(String deviceVerificationEndpointUri) { + RequestMatcher verificationRequestGetMatcher = new AntPathRequestMatcher( + deviceVerificationEndpointUri, HttpMethod.GET.name()); + RequestMatcher verificationRequestPostMatcher = new AntPathRequestMatcher( + deviceVerificationEndpointUri, HttpMethod.POST.name()); + RequestMatcher userCodeParameterMatcher = request -> + request.getParameter(OAuth2ParameterNames.USER_CODE) != null; + + return new AndRequestMatcher( + new OrRequestMatcher(verificationRequestGetMatcher, verificationRequestPostMatcher), + userCodeParameterMatcher); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.deviceVerificationEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication authentication = this.authenticationConverter.convert(request); + if (authentication instanceof AbstractAuthenticationToken) { + ((AbstractAuthenticationToken) authentication) + .setDetails(this.authenticationDetailsSource.buildDetails(request)); + } + + Authentication authenticationResult = this.authenticationManager.authenticate(authentication); + if (!authenticationResult.isAuthenticated()) { + // If the Principal (Resource Owner) is not authenticated then + // pass through the chain with the expectation that the authentication process + // will commence via AuthenticationEntryPoint + filterChain.doFilter(request, response); + return; + } + + if (authenticationResult instanceof OAuth2DeviceAuthorizationConsentAuthenticationToken) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Device authorization consent is required"); + } + sendAuthorizationConsent(request, response, authenticationResult); + return; + } + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, authenticationResult); + } catch (OAuth2AuthenticationException ex) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Device verification request failed: %s", ex.getError()), ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex); + } + } + + /** + * Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}. + * + * @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest} + */ + public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) { + Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null"); + this.authenticationDetailsSource = authenticationDetailsSource; + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Device Authorization Consent) from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} + * used for authenticating the request. + * + * @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Device Authorization Consent) from {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + * and returning the response. + * + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + */ + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + */ + public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + } + + /** + * Specify the URI to redirect Resource Owners to if consent is required. A default consent + * page will be generated when this attribute is not specified. + * + * @param consentPage the URI of the custom consent page to redirect to if consent is required (e.g. "/oauth2/consent") + */ + public void setConsentPage(String consentPage) { + this.consentPage = consentPage; + } + + private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2DeviceAuthorizationConsentAuthenticationToken authorizationConsentAuthentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication; + + String clientId = authorizationConsentAuthentication.getClientId(); + Authentication principal = (Authentication) authorizationConsentAuthentication.getPrincipal(); + Set requestedScopes = authorizationConsentAuthentication.getRequestedScopes(); + Set authorizedScopes = authorizationConsentAuthentication.getScopes(); + String state = authorizationConsentAuthentication.getState(); + String userCode = authorizationConsentAuthentication.getUserCode(); + + if (hasConsentUri()) { + String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request)) + .queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes)) + .queryParam(OAuth2ParameterNames.CLIENT_ID, clientId) + .queryParam(OAuth2ParameterNames.STATE, state) + .queryParam(OAuth2ParameterNames.USER_CODE, userCode) + .toUriString(); + this.redirectStrategy.sendRedirect(request, response, redirectUri); + } else { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Displaying generated consent screen"); + } + Map additionalParameters = new HashMap<>(); + additionalParameters.put(OAuth2ParameterNames.USER_CODE, userCode); + DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state, additionalParameters); + } + } + + private boolean hasConsentUri() { + return StringUtils.hasText(this.consentPage); + } + + private String resolveConsentUri(HttpServletRequest request) { + if (UrlUtils.isAbsoluteUrl(this.consentPage)) { + return this.consentPage; + } + RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder(); + urlBuilder.setScheme(request.getScheme()); + urlBuilder.setServerName(request.getServerName()); + urlBuilder.setPort(request.getServerPort()); + urlBuilder.setContextPath(request.getContextPath()); + urlBuilder.setPathInfo(this.consentPage); + return urlBuilder.getUrl(); + } + + private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + + OAuth2Error error = ((OAuth2AuthenticationException) authenticationException).getError(); + response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java index 547977a23..e44106dd3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,10 +49,12 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -91,6 +93,7 @@ * @see OAuth2AuthorizationCodeAuthenticationProvider * @see OAuth2RefreshTokenAuthenticationProvider * @see OAuth2ClientCredentialsAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider * @see Section 3.2 Token Endpoint */ public final class OAuth2TokenEndpointFilter extends OncePerRequestFilter { @@ -136,7 +139,8 @@ public OAuth2TokenEndpointFilter(AuthenticationManager authenticationManager, St Arrays.asList( new OAuth2AuthorizationCodeAuthenticationConverter(), new OAuth2RefreshTokenAuthenticationConverter(), - new OAuth2ClientCredentialsAuthenticationConverter())); + new OAuth2ClientCredentialsAuthenticationConverter(), + new OAuth2DeviceCodeAuthenticationConverter())); } @Override diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverter.java index 441e762d6..04207fb56 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,7 +47,7 @@ public final class ClientSecretPostAuthenticationConverter implements Authentica @Nullable @Override public Authentication convert(HttpServletRequest request) { - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); // client_id (REQUIRED) String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverter.java index ce6118d5b..a29c663f6 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,13 +48,13 @@ public final class JwtClientAssertionAuthenticationConverter implements Authenti @Nullable @Override public Authentication convert(HttpServletRequest request) { - if (request.getParameter(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE) == null || - request.getParameter(OAuth2ParameterNames.CLIENT_ASSERTION) == null) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + + if (parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE) == null || + parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION) == null) { return null; } - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); - // client_assertion_type (REQUIRED) String clientAssertionType = parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE); if (parameters.get(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE).size() != 1) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeAuthenticationConverter.java index 9beb954f1..2dda4c0e2 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,16 +47,16 @@ public final class OAuth2AuthorizationCodeAuthenticationConverter implements Aut @Nullable @Override public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + // grant_type (REQUIRED) - String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE); if (!AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(grantType)) { return null; } Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); - // code (REQUIRED) String code = parameters.getFirst(OAuth2ParameterNames.CODE); if (!StringUtils.hasText(code) || @@ -84,7 +84,7 @@ public Authentication convert(HttpServletRequest request) { !key.equals(OAuth2ParameterNames.CLIENT_ID) && !key.equals(OAuth2ParameterNames.CODE) && !key.equals(OAuth2ParameterNames.REDIRECT_URI)) { - additionalParameters.put(key, value.get(0)); + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); } }); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java index 6431a3be8..3d5ea80d6 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -66,10 +66,13 @@ public Authentication convert(HttpServletRequest request) { return null; } - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + MultiValueMap parameters = + "GET".equals(request.getMethod()) ? + OAuth2EndpointUtils.getQueryParameters(request) : + OAuth2EndpointUtils.getFormParameters(request); // response_type (REQUIRED) - String responseType = request.getParameter(OAuth2ParameterNames.RESPONSE_TYPE); + String responseType = parameters.getFirst(OAuth2ParameterNames.RESPONSE_TYPE); if (!StringUtils.hasText(responseType) || parameters.get(OAuth2ParameterNames.RESPONSE_TYPE).size() != 1) { throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.RESPONSE_TYPE); @@ -138,7 +141,7 @@ public Authentication convert(HttpServletRequest request) { !key.equals(OAuth2ParameterNames.REDIRECT_URI) && !key.equals(OAuth2ParameterNames.SCOPE) && !key.equals(OAuth2ParameterNames.STATE)) { - additionalParameters.put(key, value.get(0)); + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); } }); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationConsentAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationConsentAuthenticationConverter.java index ca0e8e14d..7da9e9f18 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationConsentAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationConsentAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -54,13 +54,13 @@ public final class OAuth2AuthorizationConsentAuthenticationConverter implements @Override public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + if (!"POST".equals(request.getMethod()) || - request.getParameter(OAuth2ParameterNames.RESPONSE_TYPE) != null) { + parameters.getFirst(OAuth2ParameterNames.RESPONSE_TYPE) != null) { return null; } - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); - String authorizationUri = request.getRequestURL().toString(); // client_id (REQUIRED) @@ -93,7 +93,7 @@ public Authentication convert(HttpServletRequest request) { if (!key.equals(OAuth2ParameterNames.CLIENT_ID) && !key.equals(OAuth2ParameterNames.STATE) && !key.equals(OAuth2ParameterNames.SCOPE)) { - additionalParameters.put(key, value.get(0)); + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); } }); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2ClientCredentialsAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2ClientCredentialsAuthenticationConverter.java index 6bcace6f8..7c34754a7 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2ClientCredentialsAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2ClientCredentialsAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,16 +50,16 @@ public final class OAuth2ClientCredentialsAuthenticationConverter implements Aut @Nullable @Override public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + // grant_type (REQUIRED) - String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE); if (!AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(grantType)) { return null; } Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); - // scope (OPTIONAL) String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); if (StringUtils.hasText(scope) && @@ -79,7 +79,7 @@ public Authentication convert(HttpServletRequest request) { parameters.forEach((key, value) -> { if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && !key.equals(OAuth2ParameterNames.SCOPE)) { - additionalParameters.put(key, value.get(0)); + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); } }); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java new file mode 100644 index 000000000..3c4ea0edc --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract a Device Authorization Consent from {@link HttpServletRequest} + * for the OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} used for + * authenticating the request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceAuthorizationConsentAuthenticationToken + * @see OAuth2DeviceVerificationEndpointFilter + */ +public final class OAuth2DeviceAuthorizationConsentAuthenticationConverter implements AuthenticationConverter { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken( + "anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + @Override + public Authentication convert(HttpServletRequest request) { + if (!"POST".equals(request.getMethod()) || + request.getParameter(OAuth2ParameterNames.STATE) == null) { + return null; + } + + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + + String authorizationUri = request.getRequestURL().toString(); + + // client_id (REQUIRED) + String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); + if (!StringUtils.hasText(clientId) || + parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.CLIENT_ID, + ERROR_URI); + } + + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!OAuth2EndpointUtils.validateUserCode(userCode) || + parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.USER_CODE, + ERROR_URI); + } + + // state (REQUIRED) + String state = parameters.getFirst(OAuth2ParameterNames.STATE); + if (!StringUtils.hasText(state) || + parameters.get(OAuth2ParameterNames.STATE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.STATE, + ERROR_URI); + } + + // scope (OPTIONAL) + Set scopes = null; + if (parameters.containsKey(OAuth2ParameterNames.SCOPE)) { + scopes = new HashSet<>(parameters.get(OAuth2ParameterNames.SCOPE)); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.USER_CODE) && + !key.equals(OAuth2ParameterNames.STATE) && + !key.equals(OAuth2ParameterNames.SCOPE)) { + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); + } + }); + + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationUri, clientId, principal, + OAuth2EndpointUtils.normalizeUserCode(userCode), state, scopes, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java new file mode 100644 index 000000000..925dfed63 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract a Device Authorization Request from {@link HttpServletRequest} for the + * OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating + * the request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceAuthorizationRequestAuthenticationToken + * @see OAuth2DeviceAuthorizationEndpointFilter + */ +public final class OAuth2DeviceAuthorizationRequestAuthenticationConverter implements AuthenticationConverter { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.1"; + + @Override + public Authentication convert(HttpServletRequest request) { + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + + String authorizationUri = request.getRequestURL().toString(); + + // scope (OPTIONAL) + String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); + if (StringUtils.hasText(scope) && + parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.SCOPE, + ERROR_URI); + } + Set requestedScopes = null; + if (StringUtils.hasText(scope)) { + requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.SCOPE)) { + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); + } + }); + + return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, authorizationUri, + requestedScopes, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java new file mode 100644 index 000000000..1405b76fb --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java @@ -0,0 +1,84 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract a Device Access Token Request from {@link HttpServletRequest} for the + * OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceCodeAuthenticationToken} used for authenticating the + * authorization grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceCodeAuthenticationToken + * @see OAuth2TokenEndpointFilter + */ +public final class OAuth2DeviceCodeAuthenticationConverter implements AuthenticationConverter { + + @Nullable + @Override + public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + + // grant_type (REQUIRED) + String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.DEVICE_CODE.getValue().equals(grantType)) { + return null; + } + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + // device_code (REQUIRED) + String deviceCode = parameters.getFirst(OAuth2ParameterNames.DEVICE_CODE); + if (!StringUtils.hasText(deviceCode) || + parameters.get(OAuth2ParameterNames.DEVICE_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.DEVICE_CODE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && + !key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.DEVICE_CODE)) { + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); + } + }); + + return new OAuth2DeviceCodeAuthenticationToken(deviceCode, clientPrincipal, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java new file mode 100644 index 000000000..ad879ba85 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java @@ -0,0 +1,93 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; + +/** + * Attempts to extract a user code from {@link HttpServletRequest} for the + * OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceVerificationAuthenticationToken} used for authenticating + * the request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceVerificationAuthenticationToken + * @see OAuth2DeviceVerificationEndpointFilter + */ +public final class OAuth2DeviceVerificationAuthenticationConverter implements AuthenticationConverter { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken( + "anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + @Override + public Authentication convert(HttpServletRequest request) { + if (!("GET".equals(request.getMethod()) || "POST".equals(request.getMethod()))) { + return null; + } + if (request.getParameter(OAuth2ParameterNames.STATE) != null + || request.getParameter(OAuth2ParameterNames.USER_CODE) == null) { + return null; + } + + MultiValueMap parameters = + "GET".equals(request.getMethod()) ? + OAuth2EndpointUtils.getQueryParameters(request) : + OAuth2EndpointUtils.getFormParameters(request); + + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!OAuth2EndpointUtils.validateUserCode(userCode) || + parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.USER_CODE, + ERROR_URI); + } + + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.USER_CODE)) { + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); + } + }); + + return new OAuth2DeviceVerificationAuthenticationToken(principal, + OAuth2EndpointUtils.normalizeUserCode(userCode), additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java index 92c488a9c..972deaa09 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,13 +26,16 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; /** * Utility methods for the OAuth 2.0 Protocol Endpoints. * * @author Joe Grandja + * @author Greg Li * @since 0.1.2 */ final class OAuth2EndpointUtils { @@ -41,11 +44,27 @@ final class OAuth2EndpointUtils { private OAuth2EndpointUtils() { } - static MultiValueMap getParameters(HttpServletRequest request) { + static MultiValueMap getFormParameters(HttpServletRequest request) { Map parameterMap = request.getParameterMap(); - MultiValueMap parameters = new LinkedMultiValueMap<>(parameterMap.size()); + MultiValueMap parameters = new LinkedMultiValueMap<>(); parameterMap.forEach((key, values) -> { - if (values.length > 0) { + String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : ""; + // If not query parameter then it's a form parameter + if (!queryString.contains(key) && values.length > 0) { + for (String value : values) { + parameters.add(key, value); + } + } + }); + return parameters; + } + + static MultiValueMap getQueryParameters(HttpServletRequest request) { + Map parameterMap = request.getParameterMap(); + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameterMap.forEach((key, values) -> { + String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : ""; + if (queryString.contains(key) && values.length > 0) { for (String value : values) { parameters.add(key, value); } @@ -58,10 +77,18 @@ static Map getParametersIfMatchesAuthorizationCodeGrantRequest(H if (!matchesAuthorizationCodeGrantRequest(request)) { return Collections.emptyMap(); } - Map parameters = new HashMap<>(getParameters(request).toSingleValueMap()); + MultiValueMap multiValueParameters = + "GET".equals(request.getMethod()) ? + getQueryParameters(request) : + getFormParameters(request); for (String exclusion : exclusions) { - parameters.remove(exclusion); + multiValueParameters.remove(exclusion); } + + Map parameters = new HashMap<>(); + multiValueParameters.forEach((key, value) -> + parameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0]))); + return parameters; } @@ -81,4 +108,15 @@ static void throwError(String errorCode, String parameterName, String errorUri) throw new OAuth2AuthenticationException(error); } + static String normalizeUserCode(String userCode) { + Assert.hasText(userCode, "userCode cannot be empty"); + StringBuilder sb = new StringBuilder(userCode.toUpperCase().replaceAll("[^A-Z\\d]+", "")); + Assert.isTrue(sb.length() == 8, "userCode must be exactly 8 alpha/numeric characters"); + sb.insert(4, '-'); + return sb.toString(); + } + + static boolean validateUserCode(String userCode) { + return (userCode != null && userCode.toUpperCase().replaceAll("[^A-Z\\d]+", "").length() == 8); + } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java index 0ff786de5..1b34eef86 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,16 +50,16 @@ public final class OAuth2RefreshTokenAuthenticationConverter implements Authenti @Nullable @Override public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + // grant_type (REQUIRED) - String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE); if (!AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grantType)) { return null; } Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); - // refresh_token (REQUIRED) String refreshToken = parameters.getFirst(OAuth2ParameterNames.REFRESH_TOKEN); if (!StringUtils.hasText(refreshToken) || @@ -90,7 +90,7 @@ public Authentication convert(HttpServletRequest request) { if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && !key.equals(OAuth2ParameterNames.REFRESH_TOKEN) && !key.equals(OAuth2ParameterNames.SCOPE)) { - additionalParameters.put(key, value.get(0)); + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); } }); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenIntrospectionAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenIntrospectionAuthenticationConverter.java index 4039e1e5f..28dbcb0c1 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenIntrospectionAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenIntrospectionAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,7 +49,7 @@ public final class OAuth2TokenIntrospectionAuthenticationConverter implements Au public Authentication convert(HttpServletRequest request) { Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); // token (REQUIRED) String token = parameters.getFirst(OAuth2ParameterNames.TOKEN); @@ -69,7 +69,7 @@ public Authentication convert(HttpServletRequest request) { parameters.forEach((key, value) -> { if (!key.equals(OAuth2ParameterNames.TOKEN) && !key.equals(OAuth2ParameterNames.TOKEN_TYPE_HINT)) { - additionalParameters.put(key, value.get(0)); + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); } }); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenRevocationAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenRevocationAuthenticationConverter.java index fea3fab28..a8765ed2f 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenRevocationAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenRevocationAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,7 +46,7 @@ public final class OAuth2TokenRevocationAuthenticationConverter implements Authe public Authentication convert(HttpServletRequest request) { Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); // token (REQUIRED) String token = parameters.getFirst(OAuth2ParameterNames.TOKEN); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverter.java index a4c36198e..2fda13935 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.springframework.security.oauth2.server.authorization.web.authentication; import java.util.HashMap; +import java.util.Map; import jakarta.servlet.http.HttpServletRequest; @@ -52,7 +53,10 @@ public Authentication convert(HttpServletRequest request) { return null; } - MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + MultiValueMap parameters = + "GET".equals(request.getMethod()) ? + OAuth2EndpointUtils.getQueryParameters(request) : + OAuth2EndpointUtils.getFormParameters(request); // client_id (REQUIRED for public clients) String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); @@ -68,7 +72,11 @@ public Authentication convert(HttpServletRequest request) { parameters.remove(OAuth2ParameterNames.CLIENT_ID); + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0]))); + return new OAuth2ClientAuthenticationToken(clientId, ClientAuthenticationMethod.NONE, null, - new HashMap<>(parameters.toSingleValueMap())); + additionalParameters); } } diff --git a/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql b/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql index a12023077..a11ff75c8 100644 --- a/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql +++ b/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql @@ -8,6 +8,7 @@ CREATE TABLE oauth2_registered_client ( client_authentication_methods varchar(1000) NOT NULL, authorization_grant_types varchar(1000) NOT NULL, redirect_uris varchar(1000) DEFAULT NULL, + post_logout_redirect_uris varchar(1000) DEFAULT NULL, scopes varchar(1000) NOT NULL, client_settings varchar(2000) NOT NULL, token_settings varchar(2000) NOT NULL, diff --git a/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql b/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql index d70243910..1528032cf 100644 --- a/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql +++ b/oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql @@ -29,5 +29,13 @@ CREATE TABLE oauth2_authorization ( refresh_token_issued_at timestamp DEFAULT NULL, refresh_token_expires_at timestamp DEFAULT NULL, refresh_token_metadata blob DEFAULT NULL, + user_code_value blob DEFAULT NULL, + user_code_issued_at timestamp DEFAULT NULL, + user_code_expires_at timestamp DEFAULT NULL, + user_code_metadata blob DEFAULT NULL, + device_code_value blob DEFAULT NULL, + device_code_issued_at timestamp DEFAULT NULL, + device_code_expires_at timestamp DEFAULT NULL, + device_code_metadata blob DEFAULT NULL, PRIMARY KEY (id) ); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java index 225767930..83c3049ae 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; @@ -47,6 +49,7 @@ public class InMemoryOAuth2AuthorizationServiceTests { "code", Instant.now(), Instant.now().plus(5, ChronoUnit.MINUTES)); private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE); private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN); private InMemoryOAuth2AuthorizationService authorizationService; @BeforeEach @@ -263,6 +266,29 @@ public void findByTokenWhenAccessTokenExistsThenFound() { assertThat(authorization).isEqualTo(result); } + @Test + public void findByTokenWhenIdTokenExistsThenFound() { + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject("subject") + .issuedAt(Instant.now().minusSeconds(60)) + .expiresAt(Instant.now()) + .build(); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .id(ID) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .token(idToken) + .build(); + this.authorizationService.save(authorization); + + OAuth2Authorization result = this.authorizationService.findByToken( + idToken.getTokenValue(), ID_TOKEN_TOKEN_TYPE); + assertThat(authorization).isEqualTo(result); + result = this.authorizationService.findByToken(idToken.getTokenValue(), null); + assertThat(authorization).isEqualTo(result); + } + @Test public void findByTokenWhenRefreshTokenExistsThenFound() { OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now()); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java index 5eb467f61..4321aacec 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,10 +45,13 @@ import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; @@ -69,6 +72,7 @@ * Tests for {@link JdbcOAuth2AuthorizationService}. * * @author Ovidiu Popa + * @author Steve Riesenberg */ public class JdbcOAuth2AuthorizationServiceTests { private static final String OAUTH2_AUTHORIZATION_SCHEMA_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql"; @@ -76,6 +80,9 @@ public class JdbcOAuth2AuthorizationServiceTests { private static final String OAUTH2_AUTHORIZATION_SCHEMA_CLOB_DATA_TYPE_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql"; private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE); private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN); + private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE); + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); private static final String ID = "id"; private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build(); private static final String PRINCIPAL_NAME = "principal"; @@ -344,6 +351,32 @@ public void findByTokenWhenAccessTokenExistsThenFound() { assertThat(authorization).isEqualTo(result); } + @Test + public void findByTokenWhenIdTokenExistsThenFound() { + when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))) + .thenReturn(REGISTERED_CLIENT); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject("subject") + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .id(ID) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .token(idToken, (metadata) -> + metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + this.authorizationService.save(authorization); + + OAuth2Authorization result = this.authorizationService.findByToken( + idToken.getTokenValue(), ID_TOKEN_TOKEN_TYPE); + assertThat(authorization).isEqualTo(result); + result = this.authorizationService.findByToken(idToken.getTokenValue(), null); + assertThat(authorization).isEqualTo(result); + } + @Test public void findByTokenWhenRefreshTokenExistsThenFound() { when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))) @@ -366,6 +399,50 @@ public void findByTokenWhenRefreshTokenExistsThenFound() { assertThat(authorization).isEqualTo(result); } + @Test + public void findByTokenWhenDeviceCodeExistsThenFound() { + when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))) + .thenReturn(REGISTERED_CLIENT); + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", + Instant.now().truncatedTo(ChronoUnit.MILLIS), + Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS)); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .id(ID) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .token(deviceCode) + .build(); + this.authorizationService.save(authorization); + + OAuth2Authorization result = this.authorizationService.findByToken( + deviceCode.getTokenValue(), DEVICE_CODE_TOKEN_TYPE); + assertThat(authorization).isEqualTo(result); + result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null); + assertThat(authorization).isEqualTo(result); + } + + @Test + public void findByTokenWhenUserCodeExistsThenFound() { + when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))) + .thenReturn(REGISTERED_CLIENT); + OAuth2UserCode userCode = new OAuth2UserCode("user-code", + Instant.now().truncatedTo(ChronoUnit.MILLIS), + Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS)); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .id(ID) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .token(userCode) + .build(); + this.authorizationService.save(authorization); + + OAuth2Authorization result = this.authorizationService.findByToken( + userCode.getTokenValue(), USER_CODE_TOKEN_TYPE); + assertThat(authorization).isEqualTo(result); + result = this.authorizationService.findByToken(userCode.getTokenValue(), null); + assertThat(authorization).isEqualTo(result); + } + @Test public void findByTokenWhenWrongTokenTypeThenNotFound() { OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now().truncatedTo(ChronoUnit.MILLIS)); @@ -487,14 +564,23 @@ private static final class CustomJdbcOAuth2AuthorizationService extends JdbcOAut + "refreshTokenValue," + "refreshTokenIssuedAt," + "refreshTokenExpiresAt," - + "refreshTokenMetadata"; + + "refreshTokenMetadata," + + "userCodeValue," + + "userCodeIssuedAt," + + "userCodeExpiresAt," + + "userCodeMetadata," + + "deviceCodeValue," + + "deviceCodeIssuedAt," + + "deviceCodeExpiresAt," + + "deviceCodeMetadata"; // @formatter:on private static final String TABLE_NAME = "oauth2Authorization"; private static final String PK_FILTER = "id = ?"; private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorizationCodeValue = ? OR " + - "accessTokenValue = ? OR refreshTokenValue = ?"; + "accessTokenValue = ? OR oidcIdTokenValue = ? OR refreshTokenValue = ? OR userCodeValue = ? OR " + + "deviceCodeValue = ?"; // @formatter:off private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES @@ -504,7 +590,7 @@ private static final class CustomJdbcOAuth2AuthorizationService extends JdbcOAut // @formatter:off private static final String SAVE_AUTHORIZATION_SQL = "INSERT INTO " + TABLE_NAME - + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; // @formatter:on private static final String REMOVE_AUTHORIZATION_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + PK_FILTER; @@ -539,7 +625,7 @@ public OAuth2Authorization findById(String id) { @Override public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) { - return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token); + return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token, token, token, token); } private OAuth2Authorization findBy(String filter, Object... args) { @@ -644,6 +730,26 @@ public OAuth2Authorization mapRow(ResultSet rs, int rowNum) throws SQLException builder.token(refreshToken, (metadata) -> metadata.putAll(refreshTokenMetadata)); } + tokenValue = rs.getString("userCodeValue"); + if (tokenValue != null) { + tokenIssuedAt = rs.getTimestamp("userCodeIssuedAt").toInstant(); + tokenExpiresAt = rs.getTimestamp("userCodeExpiresAt").toInstant(); + Map userCodeMetadata = parseMap(rs.getString("userCodeMetadata")); + + OAuth2UserCode userCode = new OAuth2UserCode(tokenValue, tokenIssuedAt, tokenExpiresAt); + builder.token(userCode, (metadata) -> metadata.putAll(userCodeMetadata)); + } + + tokenValue = rs.getString("deviceCodeValue"); + if (tokenValue != null) { + tokenIssuedAt = rs.getTimestamp("deviceCodeIssuedAt").toInstant(); + tokenExpiresAt = rs.getTimestamp("deviceCodeExpiresAt").toInstant(); + Map deviceCodeMetadata = parseMap(rs.getString("deviceCodeMetadata")); + + OAuth2UserCode deviceCode = new OAuth2UserCode(tokenValue, tokenIssuedAt, tokenExpiresAt); + builder.token(deviceCode, (metadata) -> metadata.putAll(deviceCodeMetadata)); + } + return builder.build(); } @@ -710,6 +816,15 @@ public List apply(OAuth2Authorization authorization) { OAuth2Authorization.Token refreshToken = authorization.getRefreshToken(); List refreshTokenSqlParameters = toSqlParameterList(refreshToken); parameters.addAll(refreshTokenSqlParameters); + + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + List userCodeSqlParameters = toSqlParameterList(userCode); + parameters.addAll(userCodeSqlParameters); + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + List deviceCodeSqlParameters = toSqlParameterList(deviceCode); + parameters.addAll(deviceCodeSqlParameters); + return parameters; } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataTests.java index f818296ff..2c772fb45 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,43 +38,43 @@ public class OAuth2AuthorizationServerMetadataTests { // @formatter:off private final Builder minimalBuilder = OAuth2AuthorizationServerMetadata.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") .responseType("code"); // @formatter:on @Test public void buildWhenAllClaimsProvidedThenCreated() { OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") + .jwkSetUrl("https://example.com/oauth2/jwks") .scope("openid") .responseType("code") .grantType("authorization_code") .grantType("client_credentials") - .tokenRevocationEndpoint("https://example.com/issuer1/oauth2/revoke") + .tokenRevocationEndpoint("https://example.com/oauth2/revoke") .tokenRevocationEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) - .tokenIntrospectionEndpoint("https://example.com/issuer1/oauth2/introspect") + .tokenIntrospectionEndpoint("https://example.com/oauth2/introspect") .tokenIntrospectionEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) .codeChallengeMethod("S256") .claim("a-claim", "a-value") .build(); - assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); - assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(authorizationServerMetadata.getScopes()).containsExactly("openid"); assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code"); assertThat(authorizationServerMetadata.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); - assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/revoke")); + assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/oauth2/revoke")); assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); - assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/introspect")); + assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(url("https://example.com/oauth2/introspect")); assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("S256"); assertThat(authorizationServerMetadata.getClaimAsString("a-claim")).isEqualTo("a-value"); @@ -83,15 +83,15 @@ public void buildWhenAllClaimsProvidedThenCreated() { @Test public void buildWhenOnlyRequiredClaimsProvidedThenCreated() { OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") .responseType("code") .build(); - assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull(); assertThat(authorizationServerMetadata.getJwkSetUrl()).isNull(); assertThat(authorizationServerMetadata.getScopes()).isNull(); @@ -107,29 +107,29 @@ public void buildWhenOnlyRequiredClaimsProvidedThenCreated() { @Test public void withClaimsWhenClaimsProvidedThenCreated() { HashMap claims = new HashMap<>(); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, "https://example.com/issuer1"); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/issuer1/oauth2/authorize"); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/issuer1/oauth2/token"); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, "https://example.com/issuer1/oauth2/jwks"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, "https://example.com"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/oauth2/authorize"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/oauth2/token"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, "https://example.com/oauth2/jwks"); claims.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid")); claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code")); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, "https://example.com/issuer1/oauth2/revoke"); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT, "https://example.com/issuer1/oauth2/introspect"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, "https://example.com/oauth2/revoke"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT, "https://example.com/oauth2/introspect"); claims.put("some-claim", "some-value"); OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.withClaims(claims).build(); - assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull(); - assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(authorizationServerMetadata.getScopes()).containsExactly("openid"); assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code"); assertThat(authorizationServerMetadata.getGrantTypes()).isNull(); - assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/revoke")); + assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/oauth2/revoke")); assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).isNull(); - assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/introspect")); + assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(url("https://example.com/oauth2/introspect")); assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull(); assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull(); assertThat(authorizationServerMetadata.getClaimAsString("some-claim")).isEqualTo("some-value"); @@ -138,28 +138,28 @@ public void withClaimsWhenClaimsProvidedThenCreated() { @Test public void withClaimsWhenClaimsWithUrlsProvidedThenCreated() { HashMap claims = new HashMap<>(); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, url("https://example.com/issuer1")); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/issuer1/oauth2/authorize")); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/issuer1/oauth2/token")); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, url("https://example.com/issuer1/oauth2/jwks")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, url("https://example.com")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/oauth2/authorize")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/oauth2/token")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, url("https://example.com/oauth2/jwks")); claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code")); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, url("https://example.com/issuer1/oauth2/revoke")); - claims.put(OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT, url("https://example.com/issuer1/oauth2/introspect")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, url("https://example.com/oauth2/revoke")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT, url("https://example.com/oauth2/introspect")); claims.put("some-claim", "some-value"); OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.withClaims(claims).build(); - assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull(); - assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(authorizationServerMetadata.getScopes()).isNull(); assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code"); assertThat(authorizationServerMetadata.getGrantTypes()).isNull(); - assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/revoke")); + assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/oauth2/revoke")); assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).isNull(); - assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/introspect")); + assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(url("https://example.com/oauth2/introspect")); assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull(); assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull(); assertThat(authorizationServerMetadata.getClaimAsString("some-claim")).isEqualTo("some-value"); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProviderTests.java index 90a25e9be..bd773f380 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,6 +85,11 @@ public String encode(CharSequence rawPassword) { public boolean matches(CharSequence rawPassword, String encodedPassword) { return NoOpPasswordEncoder.getInstance().matches(rawPassword, encodedPassword); } + + @Override + public boolean upgradeEncoding(String encodedPassword) { + return true; + } }); this.authenticationProvider.setPasswordEncoder(this.passwordEncoder); } @@ -222,6 +227,27 @@ public void authenticateWhenValidCredentialsThenAuthenticated() { assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); } + @Test + public void authenticateWhenValidCredentialsAndRequiresUpgradingThenClientSecretUpgraded() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret(), null); + OAuth2ClientAuthenticationToken authenticationResult = + (OAuth2ClientAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + verify(this.passwordEncoder).matches(any(), any()); + verify(this.passwordEncoder).upgradeEncoding(any()); + verify(this.passwordEncoder).encode(any()); + verify(this.registeredClientRepository).save(any()); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret()); + assertThat(authenticationResult.getRegisteredClient()).isNotSameAs(registeredClient); + } + @Test public void authenticateWhenAuthorizationCodeGrantAndValidCredentialsThenAuthenticated() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java index 0f010f1a1..802282ade 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,12 +15,19 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -31,6 +38,8 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -76,6 +85,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -95,6 +105,7 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests { private OAuth2TokenCustomizer jwtCustomizer; private OAuth2TokenCustomizer accessTokenCustomizer; private OAuth2TokenGenerator tokenGenerator; + private SessionRegistry sessionRegistry; private OAuth2AuthorizationCodeAuthenticationProvider authenticationProvider; @BeforeEach @@ -116,8 +127,10 @@ public OAuth2Token generate(OAuth2TokenContext context) { return delegatingTokenGenerator.generate(context); } }); + this.sessionRegistry = mock(SessionRegistry.class); this.authenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider( this.authorizationService, this.tokenGenerator); + this.authenticationProvider.setSessionRegistry(this.sessionRegistry); AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build(); AuthorizationServerContextHolder.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null)); } @@ -146,6 +159,13 @@ public void supportsWhenTypeOAuth2AuthorizationCodeAuthenticationTokenThenReturn assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeAuthenticationToken.class)).isTrue(); } + @Test + public void setSessionRegistryWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.authenticationProvider.setSessionRegistry(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("sessionRegistry cannot be null"); + } + @Test public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); @@ -256,6 +276,40 @@ public void authenticateWhenInvalidatedCodeThenThrowOAuth2AuthenticationExceptio .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) .extracting("errorCode") .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + assertThat(updatedAuthorization.getAccessToken().isInvalidated()).isTrue(); + assertThat(updatedAuthorization.getRefreshToken().isInvalidated()).isTrue(); + } + + // gh-1233 + @Test + public void authenticateWhenInvalidatedCodeAndAccessTokenNullThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode( + AUTHORIZATION_CODE, Instant.now(), Instant.now().plusSeconds(120)); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient, authorizationCode) + .token(authorizationCode, (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .build(); + when(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE))) + .thenReturn(authorization); + + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + registeredClient, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( + OAuth2AuthorizationRequest.class.getName()); + OAuth2AuthorizationCodeAuthenticationToken authentication = + new OAuth2AuthorizationCodeAuthenticationToken(AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + + verify(this.authorizationService, never()).save(any()); } // gh-290 @@ -439,7 +493,7 @@ public void authenticateWhenValidCodeThenReturnAccessToken() { } @Test - public void authenticateWhenValidCodeAndAuthenticationRequestThenReturnIdToken() { + public void authenticateWhenValidCodeAndAuthenticationRequestThenReturnIdToken() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode( "code", Instant.now(), Instant.now().plusSeconds(120)); @@ -456,6 +510,19 @@ public void authenticateWhenValidCodeAndAuthenticationRequestThenReturnIdToken() when(this.jwtEncoder.encode(any())).thenReturn(createJwt()); + Authentication principal = authorization.getAttribute(Principal.class.getName()); + + List sessions = new ArrayList<>(); + sessions.add(new SessionInformation(principal.getPrincipal(), + "session3", Date.from(Instant.now()))); + sessions.add(new SessionInformation(principal.getPrincipal(), + "session2", Date.from(Instant.now().minus(1, ChronoUnit.HOURS)))); + sessions.add(new SessionInformation(principal.getPrincipal(), + "session1", Date.from(Instant.now().minus(2, ChronoUnit.HOURS)))); + SessionInformation expectedSession = sessions.get(0); // Most recent + when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(false))) + .thenReturn(sessions); + OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication); @@ -464,7 +531,7 @@ public void authenticateWhenValidCodeAndAuthenticationRequestThenReturnIdToken() // Access Token context JwtEncodingContext accessTokenContext = jwtEncodingContextCaptor.getAllValues().get(0); assertThat(accessTokenContext.getRegisteredClient()).isEqualTo(registeredClient); - assertThat(accessTokenContext.getPrincipal()).isEqualTo(authorization.getAttribute(Principal.class.getName())); + assertThat(accessTokenContext.getPrincipal()).isEqualTo(principal); assertThat(accessTokenContext.getAuthorization()).isEqualTo(authorization); assertThat(accessTokenContext.getAuthorization().getAccessToken()).isNull(); assertThat(accessTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes()); @@ -480,13 +547,16 @@ public void authenticateWhenValidCodeAndAuthenticationRequestThenReturnIdToken() // ID Token context JwtEncodingContext idTokenContext = jwtEncodingContextCaptor.getAllValues().get(1); assertThat(idTokenContext.getRegisteredClient()).isEqualTo(registeredClient); - assertThat(idTokenContext.getPrincipal()).isEqualTo(authorization.getAttribute(Principal.class.getName())); + assertThat(idTokenContext.getPrincipal()).isEqualTo(principal); assertThat(idTokenContext.getAuthorization()).isNotEqualTo(authorization); assertThat(idTokenContext.getAuthorization().getAccessToken()).isNotNull(); assertThat(idTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes()); assertThat(idTokenContext.getTokenType().getValue()).isEqualTo(OidcParameterNames.ID_TOKEN); assertThat(idTokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); assertThat(idTokenContext.getAuthorizationGrant()).isEqualTo(authentication); + SessionInformation sessionInformation = idTokenContext.get(SessionInformation.class); + assertThat(sessionInformation).isNotNull(); + assertThat(sessionInformation.getSessionId()).isEqualTo(createHash(expectedSession.getSessionId())); assertThat(idTokenContext.getJwsHeader()).isNotNull(); assertThat(idTokenContext.getClaims()).isNotNull(); @@ -674,4 +744,11 @@ private static Jwt createJwt(Instant issuedAt, Instant expiresAt) { .expiresAt(expiresAt) .build(); } + + private static String createHash(String value) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java index f0691cc65..b9e96da43 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -132,10 +132,11 @@ public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException( @Test public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) .satisfies(ex -> @@ -180,27 +181,6 @@ public void authenticateWhenInvalidRedirectUriFragmentThenThrowOAuth2Authorizati ); } - // gh-243 - @Test - public void authenticateWhenRedirectUriLocalhostThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2AuthorizationCodeRequestAuthenticationToken authentication = - new OAuth2AuthorizationCodeRequestAuthenticationToken( - AUTHORIZATION_URI, registeredClient.getClientId(), principal, - "https://localhost:5000", STATE, registeredClient.getScopes(), null); - assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) - .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) - .satisfies(ex -> - assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, - OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) - ) - .extracting(ex -> ((OAuth2AuthorizationCodeRequestAuthenticationException) ex).getError()) - .satisfies(error -> - assertThat(error.getDescription()).isEqualTo("localhost is not allowed for the redirect_uri (https://localhost:5000). Use the IP literal (127.0.0.1) instead.")); - } - @Test public void authenticateWhenUnregisteredRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); @@ -301,10 +281,11 @@ public void authenticateWhenClientNotAuthorizedToRequestCodeThenThrowOAuth2Autho .build(); when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) .satisfies(ex -> @@ -319,10 +300,11 @@ public void authenticateWhenInvalidScopeThenThrowOAuth2AuthorizationCodeRequestA .build(); when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, + redirectUri, STATE, Collections.singleton("invalid-scope"), null); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) @@ -339,10 +321,11 @@ public void authenticateWhenPkceRequiredAndMissingCodeChallengeThenThrowOAuth2Au .build(); when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) .satisfies(ex -> @@ -356,13 +339,14 @@ public void authenticateWhenPkceUnsupportedCodeChallengeMethodThenThrowOAuth2Aut RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0]; Map additionalParameters = new HashMap<>(); additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported"); OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), additionalParameters); + redirectUri, STATE, registeredClient.getScopes(), additionalParameters); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) .satisfies(ex -> @@ -377,12 +361,13 @@ public void authenticateWhenPkceMissingCodeChallengeMethodThenThrowOAuth2Authori RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; Map additionalParameters = new HashMap<>(); additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), additionalParameters); + redirectUri, STATE, registeredClient.getScopes(), additionalParameters); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) .satisfies(ex -> @@ -398,10 +383,11 @@ public void authenticateWhenPrincipalNotAuthenticatedThenReturnAuthorizationCode .thenReturn(registeredClient); this.principal.setAuthenticated(false); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); @@ -418,10 +404,11 @@ public void authenticateWhenRequireAuthorizationConsentThenReturnAuthorizationCo when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); OAuth2AuthorizationConsentAuthenticationToken authenticationResult = (OAuth2AuthorizationConsentAuthenticationToken) this.authenticationProvider.authenticate(authentication); @@ -468,10 +455,11 @@ public void authenticateWhenRequireAuthorizationConsentAndOnlyOpenidScopeRequest when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); @@ -494,10 +482,11 @@ public void authenticateWhenRequireAuthorizationConsentAndAllPreviouslyApprovedT when(this.authorizationConsentService.findById(eq(registeredClient.getId()), eq(this.principal.getName()))) .thenReturn(previousAuthorizationConsent); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); @@ -511,13 +500,14 @@ public void authenticateWhenAuthorizationCodeRequestValidThenReturnAuthorization when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0]; Map additionalParameters = new HashMap<>(); additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), additionalParameters); + redirectUri, STATE, registeredClient.getScopes(), additionalParameters); OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); @@ -535,10 +525,11 @@ public void authenticateWhenAuthorizationCodeNotGeneratedThenThrowOAuth2Authoriz OAuth2TokenGenerator authorizationCodeGenerator = mock(OAuth2TokenGenerator.class); this.authenticationProvider.setAuthorizationCodeGenerator(authorizationCodeGenerator); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) @@ -559,10 +550,11 @@ public void authenticateWhenCustomAuthenticationValidatorThenUsed() { Consumer authenticationValidator = mock(Consumer.class); this.authenticationProvider.setAuthenticationValidator(authenticationValidator); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); + redirectUri, STATE, registeredClient.getScopes(), null); OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java new file mode 100644 index 000000000..19352a1cc --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java @@ -0,0 +1,444 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE; + +/** + * Tests for {@link OAuth2DeviceAuthorizationConsentAuthenticationProvider}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceAuthorizationConsentAuthenticationProviderTests { + private static final String AUTHORIZATION_URI = "/oauth2/device_authorization"; + private static final String DEVICE_CODE = "EfYu_0jEL"; + private static final String USER_CODE = "BCDF-GHJK"; + private static final String STATE = "abc123"; + + private RegisteredClientRepository registeredClientRepository; + private OAuth2AuthorizationService authorizationService; + private OAuth2AuthorizationConsentService authorizationConsentService; + private OAuth2DeviceAuthorizationConsentAuthenticationProvider authenticationProvider; + + @BeforeEach + public void setUp() { + this.registeredClientRepository = mock(RegisteredClientRepository.class); + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class); + this.authenticationProvider = new OAuth2DeviceAuthorizationConsentAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, this.authorizationConsentService); + } + + @Test + public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider( + null, this.authorizationService, this.authorizationConsentService)) + .withMessage("registeredClientRepository cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider( + this.registeredClientRepository, null, this.authorizationConsentService)) + .withMessage("authorizationService cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, null)) + .withMessage("authorizationConsentService cannot be null"); + // @formatter:on + } + + @Test + public void setAuthorizationConsentCustomizerWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authenticationProvider.setAuthorizationConsentCustomizer(null)) + .withMessageContaining("authorizationConsentCustomizer cannot be null"); + // @formatter:on + } + + @Test + public void supportsWhenTypeOAuth2DeviceAuthorizationConsentAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationConsentAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.STATE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService); + } + + @Test + public void authenticateWhenPrincipalIsNotAuthenticatedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = createAuthorization(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + TestingAuthenticationToken principal = new TestingAuthenticationToken(authorization.getPrincipalName(), null); + Authentication authentication = new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, + registeredClient.getClientId(), principal, USER_CODE, STATE, null, Collections.emptyMap()); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.STATE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService); + } + + @Test + public void authenticateWhenPrincipalNameDoesNotMatchThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = createAuthorization(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + TestingAuthenticationToken principal = new TestingAuthenticationToken("invalid", null, Collections.emptyList()); + Authentication authentication = new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, + registeredClient.getClientId(), principal, USER_CODE, STATE, null, Collections.emptyMap()); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.STATE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService); + } + + @Test + public void authenticateWhenRegisteredClientNotFoundThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = createAuthorization(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.CLIENT_ID) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService); + verifyNoInteractions(this.authorizationConsentService); + } + + @Test + public void authenticateWhenRegisteredClientDoesNotMatchAuthorizationThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build(); + OAuth2Authorization authorization = createAuthorization(registeredClient2); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.CLIENT_ID) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService); + verifyNoInteractions(this.authorizationConsentService); + } + + @Test + public void authenticateWhenRequestedScopesNotAuthorizedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear) + .scope("invalid").build(); + OAuth2Authorization authorization = createAuthorization(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient); + Authentication authentication = createAuthentication(registeredClient2); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.SCOPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE); + // @formatter:on + + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService); + verifyNoInteractions(this.authorizationConsentService); + } + + @Test + public void authenticateWhenAuthoritiesIsEmptyThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear).build(); + OAuth2Authorization authorization = createAuthorization(registeredClient2); + Authentication authentication = createAuthentication(registeredClient2); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.ACCESS_DENIED); + // @formatter:on + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)).isNull(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + assertThat(updatedAuthorization.getToken(OAuth2UserCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + } + + @Test + public void authenticateWhenAuthoritiesIsNotEmptyThenAuthorizationConsentSaved() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = createAuthorization(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient); + + Authentication authentication = createAuthentication(registeredClient); + OAuth2DeviceVerificationAuthenticationToken authenticationResult = + (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal()); + assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verify(this.authorizationConsentService).save(any(OAuth2AuthorizationConsent.class)); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authentication.getName()); + assertThat(updatedAuthorization.getAuthorizedScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)).isNull(); + assertThat(updatedAuthorization.>getAttribute(OAuth2ParameterNames.SCOPE)).isNull(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(false); + assertThat(updatedAuthorization.getToken(OAuth2UserCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + } + + @Test + public void authenticateWhenExistingAuthorizationConsentThenUpdated() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope("additional").build(); + RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear) + .scope("additional").build(); + OAuth2Authorization authorization = createAuthorization(registeredClient2); + Authentication authentication = createAuthentication(registeredClient2); + // @formatter:off + OAuth2AuthorizationConsent authorizationConsent = + OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName()) + .scope("scope1").build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient); + when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(authorizationConsent); + + OAuth2DeviceVerificationAuthenticationToken authenticationResult = + (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal()); + assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE); + + ArgumentCaptor authorizationConsentCaptor = ArgumentCaptor.forClass( + OAuth2AuthorizationConsent.class); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture()); + verify(this.authorizationService).save(any(OAuth2Authorization.class)); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue(); + assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId()); + assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(authentication.getName()); + assertThat(updatedAuthorizationConsent.getScopes()).hasSameElementsAs(registeredClient.getScopes()); + } + + @Test + public void authenticateWhenAuthorizationConsentCustomizerSetThenUsed() { + SimpleGrantedAuthority customAuthority = new SimpleGrantedAuthority("test"); + this.authenticationProvider.setAuthorizationConsentCustomizer((context) -> context.getAuthorizationConsent() + .authority(customAuthority)); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes(Set::clear).build(); + OAuth2Authorization authorization = createAuthorization(registeredClient); + Authentication authentication = createAuthentication(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient); + when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(null); + + OAuth2DeviceVerificationAuthenticationToken authenticationResult = + (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal()); + assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE); + + ArgumentCaptor authorizationConsentCaptor = ArgumentCaptor.forClass( + OAuth2AuthorizationConsent.class); + verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE); + verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture()); + verify(this.authorizationService).save(any(OAuth2Authorization.class)); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue(); + assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId()); + assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(authentication.getName()); + assertThat(updatedAuthorizationConsent.getAuthorities()).containsExactly(customAuthority); + } + + private static OAuth2Authorization createAuthorization(RegisteredClient registeredClient) { + // @formatter:off + return TestOAuth2Authorizations.authorization(registeredClient) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(createDeviceCode()) + .token(createUserCode()) + .attributes(Map::clear) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .build(); + // @formatter:on + } + + private static OAuth2DeviceAuthorizationConsentAuthenticationToken createAuthentication(RegisteredClient registeredClient) { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", null, Collections.emptyList()); + Set authorizedScopes = registeredClient.getScopes(); + if (authorizedScopes.isEmpty()) { + authorizedScopes = null; + } + Map additionalParameters = null; + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, + registeredClient.getClientId(), principal, USER_CODE, STATE, authorizedScopes, additionalParameters); + } + + private static OAuth2DeviceCode createDeviceCode() { + Instant issuedAt = Instant.now(); + return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2UserCode createUserCode() { + Instant issuedAt = Instant.now(); + return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static Function, Boolean> isInvalidated() { + return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java new file mode 100644 index 000000000..d94fe9e65 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java @@ -0,0 +1,351 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider.USER_CODE_TOKEN_TYPE; + +/** + * Tests for {@link OAuth2DeviceAuthorizationRequestAuthenticationProvider}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceAuthorizationRequestAuthenticationProviderTests { + private static final String AUTHORIZATION_URI = "/oauth2/device_authorization"; + private static final String DEVICE_CODE = "EfYu_0jEL"; + private static final String USER_CODE = "BCDF-GHJK"; + + private OAuth2AuthorizationService authorizationService; + private OAuth2DeviceAuthorizationRequestAuthenticationProvider authenticationProvider; + + @BeforeEach + public void setUp() { + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.authenticationProvider = new OAuth2DeviceAuthorizationRequestAuthenticationProvider( + this.authorizationService); + mockAuthorizationServerContext(); + } + + @AfterEach + public void tearDown() { + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceAuthorizationRequestAuthenticationProvider(null)) + .withMessage("authorizationService cannot be null"); + // @formatter:on + } + + @Test + public void setDeviceCodeGeneratorWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authenticationProvider.setDeviceCodeGenerator(null)) + .withMessage("deviceCodeGenerator cannot be null"); + // @formatter:on + } + + @Test + public void setUserCodeGeneratorWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authenticationProvider.setUserCodeGenerator(null)) + .withMessage("userCodeGenerator cannot be null"); + // @formatter:on + } + + @Test + public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationRequestAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() { + OAuth2ClientAuthenticationToken clientPrincipal = + new OAuth2ClientAuthenticationToken("client-1", ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null); + OAuth2DeviceAuthorizationRequestAuthenticationToken authentication = + new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI, null, null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + // @formatter:on + } + + @Test + public void authenticateWhenInvalidGrantTypeThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.CLIENT_ID) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); + // @formatter:on + } + + @Test + public void authenticateWhenInvalidScopesThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + Authentication authentication = new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, + AUTHORIZATION_URI, Collections.singleton("invalid"), null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining(OAuth2ParameterNames.SCOPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE); + // @formatter:on + } + + @Test + public void authenticateWhenDeviceCodeIsNullThenThrowOAuth2AuthenticationException() { + @SuppressWarnings("unchecked") + OAuth2TokenGenerator deviceCodeGenerator = mock(OAuth2TokenGenerator.class); + when(deviceCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(null); + this.authenticationProvider.setDeviceCodeGenerator(deviceCodeGenerator); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining("The token generator failed to generate the device code.") + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.SERVER_ERROR); + // @formatter:on + + verify(deviceCodeGenerator).generate(any(OAuth2TokenContext.class)); + verifyNoMoreInteractions(deviceCodeGenerator); + verifyNoInteractions(this.authorizationService); + } + + @Test + public void authenticateWhenUserCodeIsNullThenThrowOAuth2AuthenticationException() { + @SuppressWarnings("unchecked") + OAuth2TokenGenerator userCodeGenerator = mock(OAuth2TokenGenerator.class); + when(userCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(null); + this.authenticationProvider.setUserCodeGenerator(userCodeGenerator); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessageContaining("The token generator failed to generate the user code.") + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.SERVER_ERROR); + // @formatter:on + + verify(userCodeGenerator).generate(any(OAuth2TokenContext.class)); + verifyNoMoreInteractions(userCodeGenerator); + verifyNoInteractions(this.authorizationService); + } + + @Test + public void authenticateWhenScopesRequestedThenReturnDeviceCodeAndUserCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128); + assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9); // 8 chars + 1 dash + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService); + + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); + assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE); + assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull(); + assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull(); + assertThat(authorization.>getAttribute(OAuth2ParameterNames.SCOPE)) + .hasSameElementsAs(registeredClient.getScopes()); + } + + @Test + public void authenticateWhenNoScopesRequestedThenReturnDeviceCodeAndUserCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes(Set::clear) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128); + assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9); // 8 chars + 1 dash + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService); + + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); + assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE); + assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull(); + assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull(); + assertThat(authorization.>getAttribute(OAuth2ParameterNames.SCOPE)) + .hasSameElementsAs(registeredClient.getScopes()); + } + + @Test + public void authenticateWhenDeviceCodeGeneratorSetThenUsed() { + @SuppressWarnings("unchecked") + OAuth2TokenGenerator deviceCodeGenerator = mock(OAuth2TokenGenerator.class); + when(deviceCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(createDeviceCode()); + this.authenticationProvider.setDeviceCodeGenerator(deviceCodeGenerator); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(authenticationResult.getDeviceCode().getTokenValue()).isEqualTo(DEVICE_CODE); + assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9); // 8 chars + 1 dash + + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(deviceCodeGenerator).generate(tokenContextCaptor.capture()); + verify(this.authorizationService).save(any(OAuth2Authorization.class)); + verifyNoMoreInteractions(this.authorizationService, deviceCodeGenerator); + + OAuth2TokenContext tokenContext = tokenContextCaptor.getValue(); + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(tokenContext.getAuthorizationServerContext()).isNotNull(); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE); + assertThat(tokenContext.getAuthorizationGrant()).isEqualTo(authentication); + assertThat(tokenContext.getTokenType()).isEqualTo(DEVICE_CODE_TOKEN_TYPE); + } + + @Test + public void authenticateWhenUserCodeGeneratorSetThenUsed() { + @SuppressWarnings("unchecked") + OAuth2TokenGenerator userCodeGenerator = mock(OAuth2TokenGenerator.class); + when(userCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(createUserCode()); + this.authenticationProvider.setUserCodeGenerator(userCodeGenerator); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128); + assertThat(authenticationResult.getUserCode().getTokenValue()).isEqualTo(USER_CODE); + + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(userCodeGenerator).generate(tokenContextCaptor.capture()); + verify(this.authorizationService).save(any(OAuth2Authorization.class)); + verifyNoMoreInteractions(this.authorizationService, userCodeGenerator); + + OAuth2TokenContext tokenContext = tokenContextCaptor.getValue(); + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(tokenContext.getAuthorizationServerContext()).isNotNull(); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE); + assertThat(tokenContext.getAuthorizationGrant()).isEqualTo(authentication); + assertThat(tokenContext.getTokenType()).isEqualTo(USER_CODE_TOKEN_TYPE); + } + + private static void mockAuthorizationServerContext() { + AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build(); + TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext( + authorizationServerSettings, () -> "https://provider.com"); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + } + + private static OAuth2DeviceAuthorizationRequestAuthenticationToken createAuthentication(RegisteredClient registeredClient) { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + Set requestedScopes = registeredClient.getScopes(); + if (requestedScopes.isEmpty()) { + requestedScopes = null; + } + return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI, requestedScopes, null); + } + + private static OAuth2DeviceCode createDeviceCode() { + Instant issuedAt = Instant.now(); + return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2UserCode createUserCode() { + Instant issuedAt = Instant.now(); + return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java new file mode 100644 index 000000000..7d2720a81 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java @@ -0,0 +1,431 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider.AUTHORIZATION_PENDING; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider.EXPIRED_TOKEN; + +/** + * Tests for {@link OAuth2DeviceCodeAuthenticationProvider}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceCodeAuthenticationProviderTests { + private static final String DEVICE_CODE = "EfYu_0jEL"; + private static final String USER_CODE = "BCDF-GHJK"; + private static final String ACCESS_TOKEN = "abc123"; + private static final String REFRESH_TOKEN = "xyz456"; + + private OAuth2AuthorizationService authorizationService; + private OAuth2TokenGenerator tokenGenerator; + private OAuth2DeviceCodeAuthenticationProvider authenticationProvider; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.tokenGenerator = mock(OAuth2TokenGenerator.class); + this.authenticationProvider = new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService, + this.tokenGenerator); + mockAuthorizationServerContext(); + } + + @AfterEach + public void tearDown() { + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceCodeAuthenticationProvider(null, this.tokenGenerator)) + .withMessage("authorizationService cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenTokenGeneratorIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService, null)) + .withMessage("tokenGenerator cannot be null"); + // @formatter:on + } + + @Test + public void supportsWhenTypeOAuth2DeviceCodeAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2DeviceCodeAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() { + OAuth2ClientAuthenticationToken clientPrincipal = + new OAuth2ClientAuthenticationToken("client-1", ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null); + Authentication authentication = new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE, clientPrincipal, null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + // @formatter:on + } + + @Test + public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenRegisteredClientDoesNotMatchClientIdThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient2) + .token(createDeviceCode()).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + } + + @Test + public void authenticateWhenUserCodeIsNotInvalidatedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createUserCode()).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(AUTHORIZATION_PENDING); + // @formatter:on + + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenDeviceCodeIsInvalidatedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createDeviceCode(), withInvalidated()).token(createUserCode(), withInvalidated()).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.ACCESS_DENIED); + // @formatter:on + + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + } + + @Test + public void authenticateWhenDeviceCodeIsExpiredThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createExpiredDeviceCode()).token(createUserCode(), withInvalidated()).build(); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(EXPIRED_TOKEN); + // @formatter:on + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.tokenGenerator); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + } + + @Test + public void authenticateWhenAccessTokenIsNullThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createDeviceCode()) + .token(createUserCode(), withInvalidated()) + .attribute(Principal.class.getName(), authentication.getPrincipal()) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessage("The token generator failed to generate the access token.") + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.SERVER_ERROR); + // @formatter:on + + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verify(this.tokenGenerator).generate(any(OAuth2TokenContext.class)); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + } + + @Test + public void authenticateWhenRefreshTokenIsNullThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createDeviceCode()) + .token(createUserCode(), withInvalidated()) + .attribute(Principal.class.getName(), authentication.getPrincipal()) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(createAccessToken(), + (OAuth2RefreshToken) null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessage("The token generator failed to generate the refresh token.") + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.SERVER_ERROR); + // @formatter:on + + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verify(this.tokenGenerator, times(2)).generate(any(OAuth2TokenContext.class)); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + } + + @Test + public void authenticateWhenTokenGeneratorReturnsWrongTypeThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createDeviceCode()) + .token(createUserCode(), withInvalidated()) + .attribute(Principal.class.getName(), authentication.getPrincipal()) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + OAuth2AccessToken accessToken = createAccessToken(); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken, accessToken); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .withMessage("The token generator failed to generate the refresh token.") + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.SERVER_ERROR); + // @formatter:on + + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verify(this.tokenGenerator, times(2)).generate(any(OAuth2TokenContext.class)); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + } + + @Test + public void authenticateWhenValidDeviceCodeThenReturnAccessTokenAndRefreshToken() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Authentication authentication = createAuthentication(registeredClient); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createDeviceCode()) + .token(createUserCode(), withInvalidated()) + .attribute(Principal.class.getName(), authentication.getPrincipal()) + .build(); + // @formatter:on + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + OAuth2AccessToken accessToken = createAccessToken(); + OAuth2RefreshToken refreshToken = createRefreshToken(); + when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken, refreshToken); + OAuth2AccessTokenAuthenticationToken authenticationResult = + (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken); + assertThat(authenticationResult.getRefreshToken()).isEqualTo(refreshToken); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verify(this.tokenGenerator, times(2)).generate(tokenContextCaptor.capture()); + verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + assertThat(updatedAuthorization.getAccessToken().getToken()).isEqualTo(accessToken); + assertThat(updatedAuthorization.getRefreshToken().getToken()).isEqualTo(refreshToken); + + for (OAuth2TokenContext tokenContext : tokenContextCaptor.getAllValues()) { + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(tokenContext.getAuthorizationServerContext()).isNotNull(); + assertThat(tokenContext.getAuthorization()).isEqualTo(authorization); + assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes()); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE); + assertThat(tokenContext.getAuthorizationGrant()).isEqualTo(authentication); + } + assertThat(tokenContextCaptor.getAllValues().get(0).getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN); + assertThat(tokenContextCaptor.getAllValues().get(1).getTokenType()).isEqualTo(OAuth2TokenType.REFRESH_TOKEN); + } + + private static void mockAuthorizationServerContext() { + AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build(); + TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext( + authorizationServerSettings, () -> "https://provider.com"); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + } + + private static OAuth2DeviceCodeAuthenticationToken createAuthentication(RegisteredClient registeredClient) { + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + return new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE, clientPrincipal, null); + } + + private static OAuth2DeviceCode createDeviceCode() { + Instant issuedAt = Instant.now(); + return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2DeviceCode createExpiredDeviceCode() { + Instant issuedAt = Instant.now().minus(45, ChronoUnit.MINUTES); + return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2UserCode createUserCode() { + Instant issuedAt = Instant.now(); + return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2AccessToken createAccessToken() { + Instant issuedAt = Instant.now(); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2RefreshToken createRefreshToken() { + Instant issuedAt = Instant.now(); + return new OAuth2RefreshToken(REFRESH_TOKEN, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static Consumer> withInvalidated() { + return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true); + } + + public static Function, Boolean> isInvalidated() { + return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java new file mode 100644 index 000000000..c340af7fa --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java @@ -0,0 +1,326 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE; + +/** + * Tests for {@link OAuth2DeviceVerificationAuthenticationProvider}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceVerificationAuthenticationProviderTests { + private static final String AUTHORIZATION_URI = "/oauth2/device_verification"; + private static final String DEVICE_CODE = "EfYu_0jEL"; + private static final String USER_CODE = "BCDF-GHJK"; + + private RegisteredClientRepository registeredClientRepository; + private OAuth2AuthorizationService authorizationService; + private OAuth2AuthorizationConsentService authorizationConsentService; + private OAuth2DeviceVerificationAuthenticationProvider authenticationProvider; + + @BeforeEach + public void setUp() { + this.registeredClientRepository = mock(RegisteredClientRepository.class); + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class); + this.authenticationProvider = new OAuth2DeviceVerificationAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, this.authorizationConsentService); + mockAuthorizationServerContext(); + } + + @Test + public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider( + null, this.authorizationService, this.authorizationConsentService)) + .withMessage("registeredClientRepository cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider( + this.registeredClientRepository, null, this.authorizationConsentService)) + .withMessage("authorizationService cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, null)) + .withMessage("authorizationConsentService cannot be null"); + // @formatter:on + } + + @Test + public void supportsWhenTypeOAuth2DeviceVerificationAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2DeviceVerificationAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() { + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(null); + Authentication authentication = createAuthentication(); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + + verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService); + } + + @Test + public void authenticateWhenPrincipalNotAuthenticatedThenReturnUnauthenticated() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build(); + TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null); + Authentication authentication = new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE, Collections.emptyMap()); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + + OAuth2DeviceVerificationAuthenticationToken authenticationResult = + (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult).isEqualTo(authentication); + assertThat(authenticationResult.isAuthenticated()).isFalse(); + + verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE); + verifyNoMoreInteractions(this.authorizationService); + verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService); + } + + @Test + public void authenticateWhenAuthorizationConsentDoesNotExistThenReturnAuthorizationConsentWithState() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createDeviceCode()) + .token(createUserCode()) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .build(); + // @formatter:on + Authentication authentication = createAuthentication(); + when(this.registeredClientRepository.findById(anyString())).thenReturn(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(null); + + OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE); + assertThat(authenticationResult.getState()).hasSize(44); + assertThat(authenticationResult.getRequestedScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(authenticationResult.getScopes()).isEmpty(); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE); + verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)) + .isEqualTo(authenticationResult.getState()); + } + + @Test + public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesMatchThenReturnDeviceVerification() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(createDeviceCode()) + .token(createUserCode()) + .attributes(Map::clear) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .build(); + // @formatter:on + Authentication authentication = createAuthentication(); + // @formatter:off + OAuth2AuthorizationConsent authorizationConsent = + OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName()) + .scope(registeredClient.getScopes().iterator().next()) + .build(); + // @formatter:on + when(this.registeredClientRepository.findById(anyString())).thenReturn(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(authorizationConsent); + + OAuth2DeviceVerificationAuthenticationToken authenticationResult = + (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE); + verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authentication.getName()); + assertThat(updatedAuthorization.getAuthorizedScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(updatedAuthorization.getAttribute(Principal.class.getName())) + .isEqualTo(authentication.getPrincipal()); + assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)).isNull(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(false); + assertThat(updatedAuthorization.getToken(OAuth2UserCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + } + + @Test + public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesDoNotMatchThenReturnAuthorizationConsentWithState() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + // @formatter:off + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(createDeviceCode()) + .token(createUserCode()) + .attributes(Map::clear) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .build(); + // @formatter:on + Authentication authentication = createAuthentication(); + // @formatter:off + OAuth2AuthorizationConsent authorizationConsent = + OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName()) + .scope("previous") + .build(); + // @formatter:on + when(this.registeredClientRepository.findById(anyString())).thenReturn(registeredClient); + when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization); + when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(authorizationConsent); + + OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider.authenticate(authentication); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE); + assertThat(authenticationResult.getState()).hasSize(44); + assertThat(authenticationResult.getRequestedScopes()).hasSameElementsAs(registeredClient.getScopes()); + assertThat(authenticationResult.getScopes()).containsExactly("previous"); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE); + verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName()); + verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService, + this.authorizationConsentService); + + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)) + .isEqualTo(authenticationResult.getState()); + } + + private static void mockAuthorizationServerContext() { + AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build(); + TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext( + authorizationServerSettings, () -> "https://provider.com"); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + } + + private static OAuth2DeviceVerificationAuthenticationToken createAuthentication() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null, + AuthorityUtils.createAuthorityList("USER")); + return new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE, Collections.emptyMap()); + } + + private static OAuth2DeviceCode createDeviceCode() { + Instant issuedAt = Instant.now(); + return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2UserCode createUserCode() { + Instant issuedAt = Instant.now(); + return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static Function, Boolean> isInvalidated() { + return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java index 7ba7f4a96..61f0452da 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -38,6 +39,7 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; @@ -196,7 +198,15 @@ public void authenticateWhenValidRefreshTokenThenReturnAccessToken() { @Test public void authenticateWhenValidRefreshTokenThenReturnIdToken() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build(); + OidcIdToken authorizedIdToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject("subject") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("sid", "sessionId-1234") + .claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now())) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).token(authorizedIdToken).build(); when(this.authorizationService.findByToken( eq(authorization.getRefreshToken().getToken().getTokenValue()), eq(OAuth2TokenType.REFRESH_TOKEN))) @@ -400,7 +410,7 @@ public void authenticateWhenRefreshTokenIssuedToAnotherClientThenThrowOAuth2Auth .isInstanceOf(OAuth2AuthenticationException.class) .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) .extracting("errorCode") - .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); } @Test diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java index d7638ee8c..fbc448666 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -153,12 +153,12 @@ public void saveWhenNullThenThrowIllegalArgumentException() { } @Test - public void saveWhenExistingIdThenThrowIllegalArgumentException() { + public void saveWhenExistingIdThenUpdate() { RegisteredClient registeredClient = createRegisteredClient( this.registration.getId(), "client-id-2", "client-secret-2"); - assertThatIllegalArgumentException() - .isThrownBy(() -> this.clients.save(registeredClient)) - .withMessage("Registered client must be unique. Found duplicate identifier: " + registeredClient.getId()); + this.clients.save(registeredClient); + RegisteredClient savedClient = this.clients.findByClientId(registeredClient.getClientId()); + assertThat(savedClient).isEqualTo(registeredClient); } @Test diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java index 4600f9f84..a7f39a04f 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -294,6 +294,7 @@ private static final class CustomJdbcRegisteredClientRepository extends JdbcRegi + "clientAuthenticationMethods, " + "authorizationGrantTypes, " + "redirectUris, " + + "postLogoutRedirectUris, " + "scopes, " + "clientSettings," + "tokenSettings"; @@ -305,7 +306,7 @@ private static final class CustomJdbcRegisteredClientRepository extends JdbcRegi // @formatter:off private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME - + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; // @formatter:on private CustomJdbcRegisteredClientRepository(JdbcOperations jdbcOperations) { @@ -353,6 +354,7 @@ public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException { Set clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(rs.getString("clientAuthenticationMethods")); Set authorizationGrantTypes = StringUtils.commaDelimitedListToSet(rs.getString("authorizationGrantTypes")); Set redirectUris = StringUtils.commaDelimitedListToSet(rs.getString("redirectUris")); + Set postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(rs.getString("postLogoutRedirectUris")); Set clientScopes = StringUtils.commaDelimitedListToSet(rs.getString("scopes")); // @formatter:off @@ -369,6 +371,7 @@ public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException { authorizationGrantTypes.forEach(grantType -> grantTypes.add(resolveAuthorizationGrantType(grantType)))) .redirectUris((uris) -> uris.addAll(redirectUris)) + .postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris)) .scopes((scopes) -> scopes.addAll(clientScopes)); // @formatter:on diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java index 4e445cd67..a6b73a891 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,6 +40,7 @@ public class RegisteredClientTests { private static final String CLIENT_ID = "client-1"; private static final String CLIENT_SECRET = "secret"; private static final Set REDIRECT_URIS = Collections.singleton("https://example.com"); + private static final Set POST_LOGOUT_REDIRECT_URIS = Collections.singleton("https://example.com/oidc-post-logout"); private static final Set SCOPES = Collections.unmodifiableSet( Stream.of("openid", "profile", "email").collect(Collectors.toSet())); private static final Set CLIENT_AUTHENTICATION_METHODS = @@ -71,6 +72,7 @@ public void buildWhenAllAttributesProvidedThenAllAttributesAreSet() { .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .redirectUris(redirectUris -> redirectUris.addAll(REDIRECT_URIS)) + .postLogoutRedirectUris(postLogoutRedirectUris -> postLogoutRedirectUris.addAll(POST_LOGOUT_REDIRECT_URIS)) .scopes(scopes -> scopes.addAll(SCOPES)) .build(); @@ -84,6 +86,7 @@ public void buildWhenAllAttributesProvidedThenAllAttributesAreSet() { .isEqualTo(Collections.singleton(AuthorizationGrantType.AUTHORIZATION_CODE)); assertThat(registration.getClientAuthenticationMethods()).isEqualTo(CLIENT_AUTHENTICATION_METHODS); assertThat(registration.getRedirectUris()).isEqualTo(REDIRECT_URIS); + assertThat(registration.getPostLogoutRedirectUris()).isEqualTo(POST_LOGOUT_REDIRECT_URIS); assertThat(registration.getScopes()).isEqualTo(SCOPES); } @@ -229,6 +232,35 @@ public void buildWhenRedirectUriContainsFragmentThenThrowIllegalArgumentExceptio ).isInstanceOf(IllegalArgumentException.class); } + @Test + public void buildWhenPostLogoutRedirectUriInvalidThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> + RegisteredClient.withId(ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .redirectUris(redirectUris -> redirectUris.addAll(REDIRECT_URIS)) + .postLogoutRedirectUri("invalid URI") + .build() + ).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void buildWhenPostLogoutRedirectUriContainsFragmentThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> + RegisteredClient.withId(ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .redirectUri("https://example.com") + .postLogoutRedirectUri("https://example.com/index#fragment") + .scopes(scopes -> scopes.addAll(SCOPES)) + .build() + ).isInstanceOf(IllegalArgumentException.class); + } + @Test public void buildWhenTwoAuthorizationGrantTypesAreProvidedThenBothAreRegistered() { RegisteredClient registration = RegisteredClient.withId(ID) @@ -345,6 +377,8 @@ public void buildWhenRegisteredClientProvidedThenMakesACopy() { assertThat(registration.getAuthorizationGrantTypes()).isNotSameAs(updated.getAuthorizationGrantTypes()); assertThat(registration.getRedirectUris()).isEqualTo(updated.getRedirectUris()); assertThat(registration.getRedirectUris()).isNotSameAs(updated.getRedirectUris()); + assertThat(registration.getPostLogoutRedirectUris()).isEqualTo(updated.getPostLogoutRedirectUris()); + assertThat(registration.getPostLogoutRedirectUris()).isNotSameAs(updated.getPostLogoutRedirectUris()); assertThat(registration.getScopes()).isEqualTo(updated.getScopes()); assertThat(registration.getScopes()).isNotSameAs(updated.getScopes()); assertThat(registration.getClientSettings()).isEqualTo(updated.getClientSettings()); @@ -360,6 +394,7 @@ public void buildWhenRegisteredClientValuesOverriddenThenPropagated() { String newSecret = "new-secret"; String newScope = "new-scope"; String newRedirectUri = "https://another-redirect-uri.com"; + String newPostLogoutRedirectUri = "https://another-post-logout-redirect-uri.com"; RegisteredClient updated = RegisteredClient.from(registration) .clientName(newName) .clientSecret(newSecret) @@ -371,6 +406,10 @@ public void buildWhenRegisteredClientValuesOverriddenThenPropagated() { redirectUris.clear(); redirectUris.add(newRedirectUri); }) + .postLogoutRedirectUris(postLogoutRedirectUris -> { + postLogoutRedirectUris.clear(); + postLogoutRedirectUris.add(newPostLogoutRedirectUri); + }) .build(); assertThat(registration.getClientName()).isNotEqualTo(newName); @@ -381,6 +420,8 @@ public void buildWhenRegisteredClientValuesOverriddenThenPropagated() { assertThat(updated.getScopes()).containsExactly(newScope); assertThat(registration.getRedirectUris()).doesNotContain(newRedirectUri); assertThat(updated.getRedirectUris()).containsExactly(newRedirectUri); + assertThat(registration.getPostLogoutRedirectUris()).doesNotContain(newPostLogoutRedirectUri); + assertThat(updated.getPostLogoutRedirectUris()).containsExactly(newPostLogoutRedirectUri); } @Test diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java index bf2c26403..90151201e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,7 +35,10 @@ public static RegisteredClient.Builder registeredClient() { .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) - .redirectUri("https://example.com") + .redirectUri("https://example.com/callback-1") + .redirectUri("https://example.com/callback-2") + .redirectUri("https://example.com/callback-3") + .postLogoutRedirectUri("https://example.com/oidc-post-logout") .scope("scope1"); } @@ -50,6 +53,7 @@ public static RegisteredClient.Builder registeredClient2() { .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .redirectUri("https://example.com") + .postLogoutRedirectUri("https://example.com/oidc-post-logout") .scope("scope1") .scope("scope2"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java index 3e75b7e91..822f2bc4d 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -128,6 +128,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.containsString; @@ -153,6 +154,7 @@ * @author Daniel Garnier-Moiroux * @author Dmitriy Dubson * @author Steve Riesenberg + * @author Greg Li */ @ExtendWith(SpringTestContextExtension.class) public class OAuth2AuthorizationCodeGrantTests { @@ -255,7 +257,7 @@ public void requestWhenAuthorizationRequestNotAuthenticatedThenUnauthorized() th this.registeredClientRepository.save(registeredClient); this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient))) + .queryParams(getAuthorizationRequestParameters(registeredClient))) .andExpect(status().isUnauthorized()) .andReturn(); } @@ -287,16 +289,24 @@ public void requestWhenAuthorizationRequestCustomEndpointThenRedirectToClient() } private void assertAuthorizationRequestRedirectsToClient(String authorizationEndpointUri) throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUris(redirectUris -> { + redirectUris.clear(); + redirectUris.add("https://example.com/callback-1?param=encoded%20parameter%20value"); // gh-1011 + }) + .build(); this.registeredClientRepository.save(registeredClient); + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); MvcResult mvcResult = this.mvc.perform(get(authorizationEndpointUri) - .params(getAuthorizationRequestParameters(registeredClient)) + .queryParams(authorizationRequestParameters) .with(user("user"))) .andExpect(status().is3xxRedirection()) .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); - assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=" + STATE_URL_ENCODED); + String redirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + String code = extractParameterFromRedirectUri(redirectedUrl, "code"); + assertThat(redirectedUrl).isEqualTo(redirectUri + "&code=" + code + "&state=" + STATE_URL_ENCODED); String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); @@ -381,9 +391,9 @@ public void requestWhenPublicClientWithPkceThenReturnAccessTokenResponse() throw this.registeredClientRepository.save(registeredClient); MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient)) - .param(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) - .param(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") + .queryParams(getAuthorizationRequestParameters(registeredClient)) + .queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) + .queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") .with(user("user"))) .andExpect(status().is3xxRedirection()) .andReturn(); @@ -424,15 +434,17 @@ public void requestWhenConfidentialClientWithPkceAndMissingCodeVerifierThenBadRe RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); this.registeredClientRepository.save(registeredClient); + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient)) - .param(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) - .param(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") + .queryParams(authorizationRequestParameters) + .queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) + .queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") .with(user("user"))) .andExpect(status().is3xxRedirection()) .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); - assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=" + STATE_URL_ENCODED); + String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=" + STATE_URL_ENCODED); String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); @@ -446,6 +458,65 @@ public void requestWhenConfidentialClientWithPkceAndMissingCodeVerifierThenBadRe .andExpect(status().isBadRequest()); } + // gh-1011 + @Test + public void requestWhenConfidentialClientWithPkceAndMissingCodeChallengeThenErrorResponseEncoded() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + String redirectUri = "https://example.com/callback-1?param=encoded%20parameter%20value"; + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUris(redirectUris -> { + redirectUris.clear(); + redirectUris.add(redirectUri); + }) + .clientSettings(ClientSettings.builder().requireProofKey(true).build()) + .build(); + this.registeredClientRepository.save(registeredClient); + + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); + MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParams(authorizationRequestParameters) + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + String expectedRedirectUri = redirectUri + "&" + + "error=invalid_request&" + + "error_description=" + UriUtils.encode("OAuth 2.0 Parameter: code_challenge", StandardCharsets.UTF_8) + "&" + + "error_uri=" + UriUtils.encode("https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1", StandardCharsets.UTF_8) + "&" + + "state=" + STATE_URL_ENCODED; + assertThat(redirectedUrl).isEqualTo(expectedRedirectUri); + } + + @Test + public void requestWhenConfidentialClientWithPkceAndMissingCodeChallengeButCodeVerifierProvidedThenBadRequest() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + this.registeredClientRepository.save(registeredClient); + + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); + MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParams(authorizationRequestParameters) + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=" + STATE_URL_ENCODED); + + String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); + OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); + assertThat(authorizationCodeAuthorization).isNotNull(); + assertThat(authorizationCodeAuthorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization)) + .param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER) + .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))) + .andExpect(status().isBadRequest()); + } + @Test public void requestWhenCustomTokenGeneratorThenUsed() throws Exception { this.spring.register(AuthorizationServerConfigurationWithTokenGenerator.class).autowire(); @@ -479,7 +550,7 @@ public void requestWhenRequiresConsentThenDisplaysConsentPage() throws Exception this.registeredClientRepository.save(registeredClient); String consentPage = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient)) + .queryParams(getAuthorizationRequestParameters(registeredClient)) .with(user("user"))) .andExpect(status().is2xxSuccessful()) .andReturn() @@ -528,7 +599,7 @@ public void requestWhenConsentRequestThenReturnAccessTokenResponse() throws Exce .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); - assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=" + STATE_URL_ENCODED); + assertThat(redirectedUrl).matches(authorizationRequest.getRedirectUri() + "\\?code=.{15,}&state=" + STATE_URL_ENCODED); String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); @@ -562,7 +633,7 @@ public void requestWhenCustomConsentPageConfiguredThenRedirect() throws Exceptio this.registeredClientRepository.save(registeredClient); MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient)) + .queryParams(getAuthorizationRequestParameters(registeredClient)) .with(user("user"))) .andExpect(status().is3xxRedirection()) .andReturn(); @@ -615,7 +686,7 @@ public void requestWhenCustomConsentCustomizerConfiguredThenUsed() throws Except .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); - assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=" + STATE_URL_ENCODED); + assertThat(redirectedUrl).matches(authorizationRequest.getRedirectUri() + "\\?code=.{15,}&state=" + STATE_URL_ENCODED); String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); @@ -697,9 +768,9 @@ public void requestWhenClientObtainsAccessTokenThenClientAuthenticationNotPersis this.registeredClientRepository.save(registeredClient); MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient)) - .param(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) - .param(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") + .queryParams(getAuthorizationRequestParameters(registeredClient)) + .queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) + .queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") .with(user("user"))) .andExpect(status().is3xxRedirection()) .andReturn(); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerMetadataTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerMetadataTests.java index efffaa0a8..083f83273 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerMetadataTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerMetadataTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -65,7 +65,7 @@ @ExtendWith(SpringTestContextExtension.class) public class OAuth2AuthorizationServerMetadataTests { private static final String DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI = "/.well-known/oauth-authorization-server"; - private static final String issuerUrl = "https://example.com/issuer1"; + private static final String ISSUER_URL = "https://example.com"; private static EmbeddedDatabase db; private static JWKSource jwkSource; @@ -105,9 +105,9 @@ public static void destroy() { public void requestWhenAuthorizationServerMetadataRequestAndIssuerSetThenUsed() throws Exception { this.spring.register(AuthorizationServerConfiguration.class).autowire(); - this.mvc.perform(get(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI)) + this.mvc.perform(get(ISSUER_URL.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI))) .andExpect(status().is2xxSuccessful()) - .andExpect(jsonPath("issuer").value(issuerUrl)) + .andExpect(jsonPath("issuer").value(ISSUER_URL)) .andReturn(); } @@ -115,7 +115,7 @@ public void requestWhenAuthorizationServerMetadataRequestAndIssuerSetThenUsed() public void requestWhenAuthorizationServerMetadataRequestAndIssuerNotSetThenResolveFromRequest() throws Exception { this.spring.register(AuthorizationServerConfigurationWithIssuerNotSet.class).autowire(); - this.mvc.perform(get(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI)) + this.mvc.perform(get("http://localhost".concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI))) .andExpect(status().is2xxSuccessful()) .andExpect(jsonPath("issuer").value("http://localhost")) .andReturn(); @@ -126,7 +126,7 @@ public void requestWhenAuthorizationServerMetadataRequestAndIssuerNotSetThenReso public void requestWhenAuthorizationServerMetadataRequestAndMetadataCustomizerSetThenReturnCustomMetadataResponse() throws Exception { this.spring.register(AuthorizationServerConfigurationWithMetadataCustomizer.class).autowire(); - this.mvc.perform(get(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI)) + this.mvc.perform(get(ISSUER_URL.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI))) .andExpect(status().is2xxSuccessful()) .andExpect(jsonPath(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, hasItems("scope1", "scope2"))); @@ -156,7 +156,7 @@ JWKSource jwkSource() { @Bean AuthorizationServerSettings authorizationServerSettings() { - return AuthorizationServerSettings.builder().issuer(issuerUrl).build(); + return AuthorizationServerSettings.builder().issuer(ISSUER_URL).build(); } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java index d20060ed0..9b097db8e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,6 +55,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -71,6 +72,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; @@ -89,6 +91,7 @@ import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; @@ -231,6 +234,28 @@ public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponse() thr verify(jwtCustomizer).customize(any()); } + @Test + public void requestWhenTokenRequestPostsClientCredentialsAndRequiresUpgradingThenClientSecretUpgraded() throws Exception { + this.spring.register(AuthorizationServerConfigurationCustomPasswordEncoder.class).autowire(); + + String clientSecret = "secret-2"; + RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().clientSecret("{noop}" + clientSecret).build(); + this.registeredClientRepository.save(registeredClient); + + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope1 scope2") + .param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()) + .param(OAuth2ParameterNames.CLIENT_SECRET, clientSecret)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").value("scope1 scope2")); + + verify(jwtCustomizer).customize(any()); + RegisteredClient updatedRegisteredClient = this.registeredClientRepository.findByClientId(registeredClient.getClientId()); + assertThat(updatedRegisteredClient.getClientSecret()).startsWith("{bcrypt}"); + } + @Test public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception { this.spring.register(AuthorizationServerConfigurationCustomTokenEndpoint.class).autowire(); @@ -268,7 +293,8 @@ public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception { converter == authenticationConverter || converter instanceof OAuth2AuthorizationCodeAuthenticationConverter || converter instanceof OAuth2RefreshTokenAuthenticationConverter || - converter instanceof OAuth2ClientCredentialsAuthenticationConverter); + converter instanceof OAuth2ClientCredentialsAuthenticationConverter || + converter instanceof OAuth2DeviceCodeAuthenticationConverter); verify(authenticationProvider).authenticate(eq(clientCredentialsAuthentication)); @@ -280,7 +306,8 @@ public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception { provider == authenticationProvider || provider instanceof OAuth2AuthorizationCodeAuthenticationProvider || provider instanceof OAuth2RefreshTokenAuthenticationProvider || - provider instanceof OAuth2ClientCredentialsAuthenticationProvider); + provider instanceof OAuth2ClientCredentialsAuthenticationProvider || + provider instanceof OAuth2DeviceCodeAuthenticationProvider); verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(accessTokenAuthentication)); } @@ -429,6 +456,15 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h // @formatter:on } + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + static class AuthorizationServerConfigurationCustomPasswordEncoder extends AuthorizationServerConfiguration { + @Override + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) static class AuthorizationServerConfigurationCustomClientAuthentication extends AuthorizationServerConfiguration { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java new file mode 100644 index 000000000..f8b039833 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java @@ -0,0 +1,584 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; + +import java.security.Principal; +import java.time.Instant; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter; +import org.springframework.security.oauth2.jose.TestJwks; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.test.SpringTestContext; +import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for OAuth 2.0 Device Grant. + * + * @author Steve Riesenberg + */ +@ExtendWith(SpringTestContextExtension.class) +public class OAuth2DeviceCodeGrantTests { + private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorization"; + private static final String DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI = "/oauth2/device_verification"; + private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token"; + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + private static final String USER_CODE = "ABCD-EFGH"; + private static final String STATE = "123"; + private static final String DEVICE_CODE = "abc-XYZ"; + + private static EmbeddedDatabase db; + + private static JWKSource jwkSource; + + private static final HttpMessageConverter deviceAuthorizationResponseHttpMessageConverter = + new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); + + private static final HttpMessageConverter accessTokenResponseHttpMessageConverter = + new OAuth2AccessTokenResponseHttpMessageConverter(); + + public final SpringTestContext spring = new SpringTestContext(); + + @Autowired + private MockMvc mvc; + + @Autowired + private JdbcOperations jdbcOperations; + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Autowired + private OAuth2AuthorizationService authorizationService; + + @Autowired + private OAuth2AuthorizationConsentService authorizationConsentService; + + @BeforeAll + public static void init() { + JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK); + jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + // @formatter:off + db = new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.HSQL) + .setScriptEncoding("UTF-8") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql") + .build(); + // @formatter:on + } + + @AfterEach + public void tearDown() { + jdbcOperations.update("truncate table oauth2_authorization"); + jdbcOperations.update("truncate table oauth2_authorization_consent"); + jdbcOperations.update("truncate table oauth2_registered_client"); + } + + @AfterAll + public static void destroy() { + db.shutdown(); + } + + @Test + public void requestWhenDeviceAuthorizationRequestNotAuthenticatedThenUnauthorized() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + // @formatter:off + this.mvc.perform(post(DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI) + .params(parameters)) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @Test + public void requestWhenRegisteredClientMissingThenUnauthorized() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + // @formatter:off + this.mvc.perform(post(DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI) + .params(parameters) + .headers(withClientAuth(registeredClient))) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @Test + public void requestWhenDeviceAuthorizationRequestValidThenReturnDeviceAuthorizationResponse() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + + // @formatter:off + MvcResult mvcResult = this.mvc.perform(post(DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI) + .params(parameters) + .headers(withClientAuth(registeredClient))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.device_code").isNotEmpty()) + .andExpect(jsonPath("$.user_code").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNumber()) + .andExpect(jsonPath("$.verification_uri").isNotEmpty()) + .andExpect(jsonPath("$.verification_uri_complete").isNotEmpty()) + .andReturn(); + // @formatter:on + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(), + HttpStatus.OK); + OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = + deviceAuthorizationResponseHttpMessageConverter.read(OAuth2DeviceAuthorizationResponse.class, + httpResponse); + String userCode = deviceAuthorizationResponse.getUserCode().getTokenValue(); + assertThat(userCode).matches("[A-Z]{4}-[A-Z]{4}"); + assertThat(deviceAuthorizationResponse.getVerificationUri()) + .isEqualTo("http://localhost/oauth2/device_verification"); + assertThat(deviceAuthorizationResponse.getVerificationUriComplete()) + .isEqualTo("http://localhost/oauth2/device_verification?user_code=" + userCode); + + String deviceCode = deviceAuthorizationResponse.getDeviceCode().getTokenValue(); + OAuth2Authorization authorization = this.authorizationService.findByToken(deviceCode, DEVICE_CODE_TOKEN_TYPE); + assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull(); + assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull(); + } + + @Test + public void requestWhenDeviceVerificationRequestUnauthenticatedThenUnauthorized() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(registeredClient.getClientId()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt)) + .token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt)) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE); + + // @formatter:off + this.mvc.perform(get(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI) + .queryParams(parameters)) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @Test + public void requestWhenDeviceVerificationRequestValidThenDisplaysConsentPage() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(registeredClient.getClientId()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt)) + .token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt)) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE); + + // @formatter:off + MvcResult mvcResult = this.mvc.perform(get(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI) + .queryParams(parameters) + .with(user("user"))) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andReturn(); + // @formatter:on + + String responseHtml = mvcResult.getResponse().getContentAsString(); + assertThat(responseHtml).contains("Consent required"); + + OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId()); + assertThat(updatedAuthorization.getPrincipalName()).isEqualTo("user"); + assertThat(updatedAuthorization).isNotNull(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2UserCode.class)) + .extracting(isInvalidated()) + .isEqualTo(false); + // @formatter:on + } + + @Test + public void requestWhenDeviceAuthorizationConsentRequestUnauthenticatedThenBadRequest() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName("user") + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt)) + .token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt)) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .attribute(OAuth2ParameterNames.STATE, STATE) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, registeredClient.getScopes().iterator().next()); + parameters.set(OAuth2ParameterNames.STATE, STATE); + + // @formatter:off + this.mvc.perform(post(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI) + .params(parameters)) + .andExpect(status().isBadRequest()); + // @formatter:on + } + + @Test + public void requestWhenDeviceAuthorizationConsentRequestValidThenRedirectsToSuccessPage() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName("user") + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt)) + .token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt)) + .attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes()) + .attribute(OAuth2ParameterNames.STATE, STATE) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.SCOPE, registeredClient.getScopes().iterator().next()); + parameters.set(OAuth2ParameterNames.STATE, STATE); + + // @formatter:off + MvcResult mvcResult = this.mvc.perform(post(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI) + .params(parameters) + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + // @formatter:on + + assertThat(mvcResult.getResponse().getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success"); + + OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId()); + assertThat(updatedAuthorization).isNotNull(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2UserCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + } + + @Test + public void requestWhenAccessTokenRequestUnauthenticatedThenUnauthorized() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(registeredClient.getClientId()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt)) + .token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt), withInvalidated()) + .authorizedScopes(registeredClient.getScopes()) + .attribute(Principal.class.getName(), new UsernamePasswordAuthenticationToken("user", null)) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue()); + parameters.set(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE); + + // @formatter:off + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(parameters)) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @Test + public void requestWhenAccessTokenRequestValidThenReturnAccessTokenResponse() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .build(); + // @formatter:on + this.registeredClientRepository.save(registeredClient); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plusSeconds(300); + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(registeredClient.getClientId()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt)) + .token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt), withInvalidated()) + .authorizedScopes(registeredClient.getScopes()) + .attribute(Principal.class.getName(), new UsernamePasswordAuthenticationToken("user", null)) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + // @formatter:off + OAuth2AuthorizationConsent authorizationConsent = + OAuth2AuthorizationConsent.withId(registeredClient.getClientId(), "user") + .scope(registeredClient.getScopes().iterator().next()) + .build(); + // @formatter:on + this.authorizationConsentService.save(authorizationConsent); + + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue()); + parameters.set(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE); + + // @formatter:off + MvcResult mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(parameters) + .headers(withClientAuth(registeredClient))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNumber()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andReturn(); + // @formatter:on + + OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId()); + assertThat(updatedAuthorization).isNotNull(); + assertThat(updatedAuthorization.getAccessToken()).isNotNull(); + assertThat(updatedAuthorization.getRefreshToken()).isNotNull(); + // @formatter:off + assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class)) + .extracting(isInvalidated()) + .isEqualTo(true); + // @formatter:on + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(), + HttpStatus.OK); + OAuth2AccessTokenResponse accessTokenResponse = + accessTokenResponseHttpMessageConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + String accessToken = accessTokenResponse.getAccessToken().getTokenValue(); + OAuth2Authorization accessTokenAuthorization = this.authorizationService.findByToken(accessToken, + OAuth2TokenType.ACCESS_TOKEN); + assertThat(accessTokenAuthorization).isEqualTo(updatedAuthorization); + } + + private static HttpHeaders withClientAuth(RegisteredClient registeredClient) { + HttpHeaders headers = new HttpHeaders(); + headers.setBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret()); + return headers; + } + + private static Consumer> withInvalidated() { + return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true); + } + + private static Function, Boolean> isInvalidated() { + return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME); + } + + @EnableWebSecurity + @Import(OAuth2AuthorizationServerConfiguration.class) + static class AuthorizationServerConfiguration { + + @Bean + RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) { + return new JdbcRegisteredClientRepository(jdbcOperations); + } + + @Bean + OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository); + } + + @Bean + OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository); + } + + @Bean + JdbcOperations jdbcOperations() { + return new JdbcTemplate(db); + } + + @Bean + JWKSource jwkSource() { + return jwkSource; + } + + @Bean + PasswordEncoder passwordEncoder() { + return NoOpPasswordEncoder.getInstance(); + } + + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationTests.java index 96c89c035..67ef81c75 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,15 +17,18 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.UUID; import java.util.function.Consumer; -import jakarta.servlet.http.HttpServletResponse; +import javax.crypto.spec.SecretKeySpec; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.http.HttpServletResponse; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterAll; @@ -39,6 +42,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -56,8 +60,9 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; -import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -68,6 +73,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.security.oauth2.jose.TestJwks; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.Jwt; @@ -92,6 +98,7 @@ import org.springframework.security.oauth2.server.authorization.oidc.web.authentication.OidcClientRegistrationAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; import org.springframework.security.oauth2.server.authorization.test.SpringTestContext; import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension; import org.springframework.security.web.SecurityFilterChain; @@ -101,6 +108,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.CollectionUtils; import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; @@ -112,6 +120,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -290,7 +299,7 @@ public void requestWhenClientConfigurationRequestAuthorizedThenClientRegistratio assertThat(clientConfigurationResponse.getClientId()).isEqualTo(clientRegistrationResponse.getClientId()); assertThat(clientConfigurationResponse.getClientIdIssuedAt()).isEqualTo(clientRegistrationResponse.getClientIdIssuedAt()); - assertThat(clientConfigurationResponse.getClientSecret()).isEqualTo(clientRegistrationResponse.getClientSecret()); + assertThat(clientConfigurationResponse.getClientSecret()).isNotNull(); assertThat(clientConfigurationResponse.getClientSecretExpiresAt()).isEqualTo(clientRegistrationResponse.getClientSecretExpiresAt()); assertThat(clientConfigurationResponse.getClientName()).isEqualTo(clientRegistrationResponse.getClientName()); assertThat(clientConfigurationResponse.getRedirectUris()) @@ -371,6 +380,111 @@ public void requestWhenClientRegistrationEndpointCustomizedWithAuthenticationFai verifyNoInteractions(authenticationSuccessHandler); } + // gh-1056 + @Test + public void requestWhenClientRegistersWithSecretThenClientAuthenticationSuccess() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .clientName("client-name") + .redirectUri("https://client.example.com") + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .scope("scope1") + .scope("scope2") + .build(); + // @formatter:on + + OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration); + + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope1") + .with(httpBasic(clientRegistrationResponse.getClientId(), clientRegistrationResponse.getClientSecret()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").value("scope1")) + .andReturn(); + } + + // gh-1344 + @Test + public void requestWhenClientRegistersWithClientSecretJwtThenClientAuthenticationSuccess() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // @formatter:off + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .clientName("client-name") + .redirectUri("https://client.example.com") + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()) + .scope("scope1") + .scope("scope2") + .build(); + // @formatter:on + + OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration); + + JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256) + .build(); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS); + JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder() + .issuer(clientRegistrationResponse.getClientId()) + .subject(clientRegistrationResponse.getClientId()) + .audience(Collections.singletonList(asUrl(this.authorizationServerSettings.getIssuer(), this.authorizationServerSettings.getTokenEndpoint()))) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .build(); + + JWKSet jwkSet = new JWKSet(TestJwks.jwk( + new SecretKeySpec(clientRegistrationResponse.getClientSecret().getBytes(), "HS256")).build()); + JwtEncoder jwtClientAssertionEncoder = new NimbusJwtEncoder((jwkSelector, securityContext) -> jwkSelector.select(jwkSet)); + + Jwt jwtAssertion = jwtClientAssertionEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)); + + this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope1") + .param(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + .param(OAuth2ParameterNames.CLIENT_ASSERTION, jwtAssertion.getTokenValue()) + .param(OAuth2ParameterNames.CLIENT_ID, clientRegistrationResponse.getClientId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").value("scope1")); + } + + @Test + public void requestWhenClientRegistersWithCustomMetadataThenSavedToRegisteredClient() throws Exception { + this.spring.register(CustomClientMetadataConfiguration.class).autowire(); + + // @formatter:off + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .clientName("client-name") + .redirectUri("https://client.example.com") + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .scope("scope1") + .scope("scope2") + .claim("custom-metadata-name-1", "value-1") + .claim("custom-metadata-name-2", "value-2") + .build(); + // @formatter:on + + OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( + clientRegistrationResponse.getClientId()); + + assertThat(registeredClient.getClientSettings().getSetting("custom-metadata-name-1")) + .isEqualTo("value-1"); + assertThat(registeredClient.getClientSettings().getSetting("custom-metadata-name-2")) + .isEqualTo("value-2"); + } + private OidcClientRegistration registerClient(OidcClientRegistration clientRegistration) throws Exception { // ***** (1) Obtain the "initial" access token used for registering the client @@ -494,13 +608,158 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h authorize.anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults()) + ) .apply(authorizationServerConfigurer); return http.build(); } // @formatter:on } + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + static class CustomClientMetadataConfiguration extends AuthorizationServerConfiguration { + + // @formatter:off + @Bean + @Override + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer(); + authorizationServerConfigurer + .oidc(oidc -> + oidc + .clientRegistrationEndpoint(clientRegistration -> + clientRegistration + .authenticationProviders(configureRegisteredClientConverter()) + ) + ); + RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher(); + + http + .securityMatcher(endpointsMatcher) + .authorizeHttpRequests(authorize -> + authorize.anyRequest().authenticated() + ) + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults()) + ) + .apply(authorizationServerConfigurer); + return http.build(); + } + // @formatter:on + + private Consumer> configureRegisteredClientConverter() { + return (authenticationProviders) -> { + authenticationProviders.forEach((authenticationProvider) -> { + if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider) { + ((OidcClientRegistrationAuthenticationProvider) authenticationProvider) + .setRegisteredClientConverter(new OidcClientRegistrationRegisteredClientConverter()); + } + }); + }; + } + + // NOTE: + // This is a copy of OidcClientRegistrationAuthenticationProvider.OidcClientRegistrationRegisteredClientConverter + // with a minor enhancement supporting custom metadata claims. + private static final class OidcClientRegistrationRegisteredClientConverter implements Converter { + private static final StringKeyGenerator CLIENT_ID_GENERATOR = new Base64StringKeyGenerator( + Base64.getUrlEncoder().withoutPadding(), 32); + private static final StringKeyGenerator CLIENT_SECRET_GENERATOR = new Base64StringKeyGenerator( + Base64.getUrlEncoder().withoutPadding(), 48); + + @Override + public RegisteredClient convert(OidcClientRegistration clientRegistration) { + // @formatter:off + RegisteredClient.Builder builder = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(CLIENT_ID_GENERATOR.generateKey()) + .clientIdIssuedAt(Instant.now()) + .clientName(clientRegistration.getClientName()); + + if (ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue().equals(clientRegistration.getTokenEndpointAuthenticationMethod())) { + builder + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientSecret(CLIENT_SECRET_GENERATOR.generateKey()); + } else if (ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue().equals(clientRegistration.getTokenEndpointAuthenticationMethod())) { + builder + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT) + .clientSecret(CLIENT_SECRET_GENERATOR.generateKey()); + } else if (ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equals(clientRegistration.getTokenEndpointAuthenticationMethod())) { + builder.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT); + } else { + builder + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .clientSecret(CLIENT_SECRET_GENERATOR.generateKey()); + } + + builder.redirectUris(redirectUris -> + redirectUris.addAll(clientRegistration.getRedirectUris())); + + if (!CollectionUtils.isEmpty(clientRegistration.getPostLogoutRedirectUris())) { + builder.postLogoutRedirectUris(postLogoutRedirectUris -> + postLogoutRedirectUris.addAll(clientRegistration.getPostLogoutRedirectUris())); + } + + if (!CollectionUtils.isEmpty(clientRegistration.getGrantTypes())) { + builder.authorizationGrantTypes(authorizationGrantTypes -> + clientRegistration.getGrantTypes().forEach(grantType -> + authorizationGrantTypes.add(new AuthorizationGrantType(grantType)))); + } else { + builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + } + if (CollectionUtils.isEmpty(clientRegistration.getResponseTypes()) || + clientRegistration.getResponseTypes().contains(OAuth2AuthorizationResponseType.CODE.getValue())) { + builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + } + + if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) { + builder.scopes(scopes -> + scopes.addAll(clientRegistration.getScopes())); + } + + ClientSettings.Builder clientSettingsBuilder = ClientSettings.builder() + .requireProofKey(true) + .requireAuthorizationConsent(true); + + if (ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue().equals(clientRegistration.getTokenEndpointAuthenticationMethod())) { + MacAlgorithm macAlgorithm = MacAlgorithm.from(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()); + if (macAlgorithm == null) { + macAlgorithm = MacAlgorithm.HS256; + } + clientSettingsBuilder.tokenEndpointAuthenticationSigningAlgorithm(macAlgorithm); + } else if (ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equals(clientRegistration.getTokenEndpointAuthenticationMethod())) { + SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.from(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()); + if (signatureAlgorithm == null) { + signatureAlgorithm = SignatureAlgorithm.RS256; + } + clientSettingsBuilder.tokenEndpointAuthenticationSigningAlgorithm(signatureAlgorithm); + clientSettingsBuilder.jwkSetUrl(clientRegistration.getJwkSetUrl().toString()); + } + + // Add custom metadata claims + clientRegistration.getClaims().forEach((claim, value) -> { + if (claim.startsWith("custom-metadata")) { + clientSettingsBuilder.setting(claim, value); + } + }); + + builder + .clientSettings(clientSettingsBuilder.build()) + .tokenSettings(TokenSettings.builder() + .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256) + .build()); + + return builder.build(); + // @formatter:on + } + + } + + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) static class AuthorizationServerConfiguration { @@ -521,7 +780,9 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h authorize.anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults()) + ) .apply(authorizationServerConfigurer); return http.build(); } @@ -566,7 +827,7 @@ AuthorizationServerSettings authorizationServerSettings() { @Bean PasswordEncoder passwordEncoder() { - return NoOpPasswordEncoder.getInstance(); + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java index 5b304f7fb..a3ae73276 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,7 +63,7 @@ @ExtendWith(SpringTestContextExtension.class) public class OidcProviderConfigurationTests { private static final String DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI = "/.well-known/openid-configuration"; - private static final String ISSUER_URL = "https://example.com/issuer1"; + private static final String ISSUER_URL = "https://example.com"; public final SpringTestContext spring = new SpringTestContext(); @@ -77,7 +77,7 @@ public class OidcProviderConfigurationTests { public void requestWhenConfigurationRequestAndIssuerSetThenReturnDefaultConfigurationResponse() throws Exception { this.spring.register(AuthorizationServerConfiguration.class).autowire(); - this.mvc.perform(get(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)) + this.mvc.perform(get(ISSUER_URL.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI))) .andExpect(status().is2xxSuccessful()) .andExpectAll(defaultConfigurationMatchers()); } @@ -87,7 +87,7 @@ public void requestWhenConfigurationRequestAndIssuerSetThenReturnDefaultConfigur public void requestWhenConfigurationRequestAndUserAuthenticatedThenReturnConfigurationResponse() throws Exception { this.spring.register(AuthorizationServerConfiguration.class).autowire(); - this.mvc.perform(get(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI) + this.mvc.perform(get(ISSUER_URL.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)) .with(user("user"))) .andExpect(status().is2xxSuccessful()) .andExpectAll(defaultConfigurationMatchers()); @@ -98,7 +98,7 @@ public void requestWhenConfigurationRequestAndUserAuthenticatedThenReturnConfigu public void requestWhenConfigurationRequestAndConfigurationCustomizerSetThenReturnCustomConfigurationResponse() throws Exception { this.spring.register(AuthorizationServerConfigurationWithProviderConfigurationCustomizer.class).autowire(); - this.mvc.perform(get(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)) + this.mvc.perform(get(ISSUER_URL.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI))) .andExpect(status().is2xxSuccessful()) .andExpect(jsonPath(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, hasItems(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL))); @@ -108,7 +108,7 @@ public void requestWhenConfigurationRequestAndConfigurationCustomizerSetThenRetu public void requestWhenConfigurationRequestAndClientRegistrationEnabledThenConfigurationResponseIncludesRegistrationEndpoint() throws Exception { this.spring.register(AuthorizationServerConfigurationWithClientRegistrationEnabled.class).autowire(); - this.mvc.perform(get(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)) + this.mvc.perform(get(ISSUER_URL.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI))) .andExpect(status().is2xxSuccessful()) .andExpectAll(defaultConfigurationMatchers()) .andExpect(jsonPath("$.registration_endpoint").value(ISSUER_URL.concat(this.authorizationServerSettings.getOidcClientRegistrationEndpoint()))); @@ -126,6 +126,7 @@ private ResultMatcher[] defaultConfigurationMatchers() { jsonPath("$.token_endpoint_auth_methods_supported[3]").value(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()), jsonPath("jwks_uri").value(ISSUER_URL.concat(this.authorizationServerSettings.getJwkSetEndpoint())), jsonPath("userinfo_endpoint").value(ISSUER_URL.concat(this.authorizationServerSettings.getOidcUserInfoEndpoint())), + jsonPath("end_session_endpoint").value(ISSUER_URL.concat(this.authorizationServerSettings.getOidcLogoutEndpoint())), jsonPath("response_types_supported").value(OAuth2AuthorizationResponseType.CODE.getValue()), jsonPath("$.grant_types_supported[0]").value(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), jsonPath("$.grant_types_supported[1]").value(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()), diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java index ad79f2fcc..be9a39d04 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,12 +47,15 @@ import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -123,12 +126,14 @@ public class OidcTests { private static final String DEFAULT_AUTHORIZATION_ENDPOINT_URI = "/oauth2/authorize"; private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token"; + private static final String DEFAULT_OIDC_LOGOUT_ENDPOINT_URI = "/connect/logout"; private static final String AUTHORITIES_CLAIM = "authorities"; private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE); private static EmbeddedDatabase db; private static JWKSource jwkSource; private static HttpMessageConverter accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + private static SessionRegistry sessionRegistry; public final SpringTestContext spring = new SpringTestContext(); @@ -161,6 +166,7 @@ public static void init() { .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql") .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql") .build(); + sessionRegistry = spy(new SessionRegistryImpl()); } @AfterEach @@ -183,21 +189,23 @@ public void requestWhenAuthenticationRequestThenTokenResponseIncludesIdToken() t RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); this.registeredClientRepository.save(registeredClient); + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(getAuthorizationRequestParameters(registeredClient)) + .queryParams(authorizationRequestParameters) .with(user("user").roles("A", "B"))) .andExpect(status().is3xxRedirection()) .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); - assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=state"); + String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=state"); String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) - .params(getTokenRequestParameters(registeredClient, authorization)) - .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( - registeredClient.getClientId(), registeredClient.getClientSecret()))) + .params(getTokenRequestParameters(registeredClient, authorization)) + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient.getClientId(), registeredClient.getClientSecret()))) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) @@ -214,8 +222,9 @@ public void requestWhenAuthenticationRequestThenTokenResponseIncludesIdToken() t servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); - // Assert user authorities was propagated as claim in ID Token Jwt idToken = this.jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN)); + + // Assert user authorities was propagated as claim in ID Token List authoritiesClaim = idToken.getClaim(AUTHORITIES_CLAIM); Authentication principal = authorization.getAttribute(Principal.class.getName()); Set userAuthorities = new HashSet<>(); @@ -223,6 +232,194 @@ public void requestWhenAuthenticationRequestThenTokenResponseIncludesIdToken() t userAuthorities.add(authority.getAuthority()); } assertThat(authoritiesClaim).containsExactlyInAnyOrderElementsOf(userAuthorities); + + // Assert sid claim was added in ID Token + assertThat(idToken.getClaim("sid")).isNotNull(); + } + + // gh-1224 + @Test + public void requestWhenRefreshTokenRequestThenIdTokenContainsSidClaim() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); + this.registeredClientRepository.save(registeredClient); + + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); + MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParams(authorizationRequestParameters) + .with(user("user").roles("A", "B"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=state"); + + String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); + OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); + + mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient, authorization)) + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient.getClientId(), registeredClient.getClientSecret()))) + .andExpect(status().isOk()) + .andReturn(); + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + Jwt idToken = this.jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN)); + + String sidClaim = idToken.getClaim("sid"); + assertThat(sidClaim).isNotNull(); + + // Refresh access token + mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .param(OAuth2ParameterNames.REFRESH_TOKEN, accessTokenResponse.getRefreshToken().getTokenValue()) + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient.getClientId(), registeredClient.getClientSecret()))) + .andExpect(status().isOk()) + .andReturn(); + + servletResponse = mvcResult.getResponse(); + httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + idToken = this.jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN)); + + assertThat(idToken.getClaim("sid")).isEqualTo(sidClaim); + } + + @Test + public void requestWhenLogoutRequestThenLogout() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); + this.registeredClientRepository.save(registeredClient); + + // Login + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); + MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParams(authorizationRequestParameters) + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + MockHttpSession session = (MockHttpSession) mvcResult.getRequest().getSession(); + assertThat(session.isNew()).isTrue(); + + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); + OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); + + // Get ID Token + mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient, authorization)) + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient.getClientId(), registeredClient.getClientSecret()))) + .andExpect(status().isOk()) + .andReturn(); + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + String idToken = (String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN); + + // Logout + mvcResult = this.mvc.perform(post(DEFAULT_OIDC_LOGOUT_ENDPOINT_URI) + .param("id_token_hint", idToken) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andReturn(); + redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + + assertThat(redirectedUrl).matches("/"); + assertThat(session.isInvalid()).isTrue(); + } + + @Test + public void requestWhenLogoutRequestWithOtherUsersIdTokenThenNotLogout() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + // Login user1 + RegisteredClient registeredClient1 = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); + this.registeredClientRepository.save(registeredClient1); + + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient1); + MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParams(authorizationRequestParameters) + .with(user("user1"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + MockHttpSession user1Session = (MockHttpSession) mvcResult.getRequest().getSession(); + assertThat(user1Session.isNew()).isTrue(); + + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); + OAuth2Authorization user1Authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); + + mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient1, user1Authorization)) + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient1.getClientId(), registeredClient1.getClientSecret()))) + .andExpect(status().isOk()) + .andReturn(); + + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + String user1IdToken = (String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN); + + // Login user2 + RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().scope(OidcScopes.OPENID).build(); + this.registeredClientRepository.save(registeredClient2); + + authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient2); + mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParams(authorizationRequestParameters) + .with(user("user2"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + MockHttpSession user2Session = (MockHttpSession) mvcResult.getRequest().getSession(); + assertThat(user2Session.isNew()).isTrue(); + + redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); + OAuth2Authorization user2Authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); + + mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient2, user2Authorization)) + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient2.getClientId(), registeredClient2.getClientSecret()))) + .andExpect(status().isOk()) + .andReturn(); + + servletResponse = mvcResult.getResponse(); + httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + + String user2IdToken = (String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN); + + // Attempt to log out user1 using user2's ID Token + mvcResult = this.mvc.perform(post(DEFAULT_OIDC_LOGOUT_ENDPOINT_URI) + .param("id_token_hint", user2IdToken) + .session(user1Session)) + .andExpect(status().isBadRequest()) + .andExpect(status().reason("[invalid_token] OpenID Connect 1.0 Logout Request Parameter: sub")) + .andReturn(); + + assertThat(user1Session.isInvalid()).isFalse(); } @Test @@ -345,6 +542,11 @@ PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); } + @Bean + SessionRegistry sessionRegistry() { + return sessionRegistry; + } + static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper { RowMapper(RegisteredClientRepository registeredClientRepository) { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcUserInfoTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcUserInfoTests.java index e7ceb887f..66f927612 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcUserInfoTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcUserInfoTests.java @@ -24,12 +24,11 @@ import java.util.function.Consumer; import java.util.function.Function; -import jakarta.servlet.http.HttpServletResponse; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,7 +44,6 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcScopes; @@ -402,7 +400,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { authorize.anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults()) + ) .apply(authorizationServerConfigurer) .oidc(oidc -> oidc .userInfoEndpoint(userInfo -> userInfo @@ -442,7 +442,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { authorize.anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults()) + ) .securityContext(securityContext -> securityContext.securityContextRepository(securityContextRepository)) .apply(authorizationServerConfigurer); @@ -472,7 +474,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { authorize.anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults()) + ) .apply(authorizationServerConfigurer); // @formatter:on diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverterTests.java index d0a44799e..362dc6834 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,9 +62,9 @@ public void setAuthorizationServerMetadataConverterWhenConverterIsNullThenThrowI public void readInternalWhenRequiredParametersThenSuccess() throws Exception { // @formatter:off String authorizationServerMetadataResponse = "{\n" - + " \"issuer\": \"https://example.com/issuer1\",\n" - + " \"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n" - + " \"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n" + + " \"issuer\": \"https://example.com\",\n" + + " \"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n" + + " \"token_endpoint\": \"https://example.com/oauth2/token\",\n" + " \"response_types_supported\": [\"code\"]\n" + "}\n"; // @formatter:on @@ -72,9 +72,9 @@ public void readInternalWhenRequiredParametersThenSuccess() throws Exception { OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.messageConverter .readInternal(OAuth2AuthorizationServerMetadata.class, response); - assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(new URL("https://example.com/issuer1")); - assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize")); - assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(new URL("https://example.com")); + assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/oauth2/authorize")); + assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(new URL("https://example.com/oauth2/token")); assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull(); assertThat(authorizationServerMetadata.getJwkSetUrl()).isNull(); assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code"); @@ -91,17 +91,17 @@ public void readInternalWhenRequiredParametersThenSuccess() throws Exception { public void readInternalWhenValidParametersThenSuccess() throws Exception { // @formatter:off String authorizationServerMetadataResponse = "{\n" - + " \"issuer\": \"https://example.com/issuer1\",\n" - + " \"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n" - + " \"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n" + + " \"issuer\": \"https://example.com\",\n" + + " \"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n" + + " \"token_endpoint\": \"https://example.com/oauth2/token\",\n" + " \"token_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n" - + " \"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n" + + " \"jwks_uri\": \"https://example.com/oauth2/jwks\",\n" + " \"scopes_supported\": [\"openid\"],\n" + " \"response_types_supported\": [\"code\"],\n" + " \"grant_types_supported\": [\"authorization_code\", \"client_credentials\"],\n" - + " \"revocation_endpoint\": \"https://example.com/issuer1/oauth2/revoke\",\n" + + " \"revocation_endpoint\": \"https://example.com/oauth2/revoke\",\n" + " \"revocation_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n" - + " \"introspection_endpoint\": \"https://example.com/issuer1/oauth2/introspect\",\n" + + " \"introspection_endpoint\": \"https://example.com/oauth2/introspect\",\n" + " \"introspection_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n" + " \"code_challenge_methods_supported\": [\"S256\"],\n" + " \"custom_claim\": \"value\",\n" @@ -113,17 +113,17 @@ public void readInternalWhenValidParametersThenSuccess() throws Exception { .readInternal(OAuth2AuthorizationServerMetadata.class, response); assertThat(authorizationServerMetadata.getClaims()).hasSize(15); - assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(new URL("https://example.com/issuer1")); - assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize")); - assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(new URL("https://example.com")); + assertThat(authorizationServerMetadata.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/oauth2/authorize")); + assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(new URL("https://example.com/oauth2/token")); assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); - assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(new URL("https://example.com/oauth2/jwks")); assertThat(authorizationServerMetadata.getScopes()).containsExactly("openid"); assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code"); assertThat(authorizationServerMetadata.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); - assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/revoke")); + assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isEqualTo(new URL("https://example.com/oauth2/revoke")); assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); - assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/introspect")); + assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isEqualTo(new URL("https://example.com/oauth2/introspect")); assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("S256"); assertThat(authorizationServerMetadata.getClaimAsString("custom_claim")).isEqualTo("value"); @@ -159,18 +159,18 @@ public void readInternalWhenInvalidOAuth2AuthorizationServerMetadataThenThrowExc public void writeInternalWhenOAuth2AuthorizationServerMetadataThenSuccess() { OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") + .jwkSetUrl("https://example.com/oauth2/jwks") .scope("openid") .responseType("code") .grantType("authorization_code") .grantType("client_credentials") - .tokenRevocationEndpoint("https://example.com/issuer1/oauth2/revoke") + .tokenRevocationEndpoint("https://example.com/oauth2/revoke") .tokenRevocationEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) - .tokenIntrospectionEndpoint("https://example.com/issuer1/oauth2/introspect") + .tokenIntrospectionEndpoint("https://example.com/oauth2/introspect") .tokenIntrospectionEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) .codeChallengeMethod("S256") .claim("custom_claim", "value") @@ -181,17 +181,17 @@ public void writeInternalWhenOAuth2AuthorizationServerMetadataThenSuccess() { this.messageConverter.writeInternal(authorizationServerMetadata, outputMessage); String authorizationServerMetadataResponse = outputMessage.getBodyAsString(); - assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com/issuer1\""); - assertThat(authorizationServerMetadataResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/authorize\""); - assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/token\""); + assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com\""); + assertThat(authorizationServerMetadataResponse).contains("\"authorization_endpoint\":\"https://example.com/oauth2/authorize\""); + assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/token\""); assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/jwks\""); + assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/jwks\""); assertThat(authorizationServerMetadataResponse).contains("\"scopes_supported\":[\"openid\"]"); assertThat(authorizationServerMetadataResponse).contains("\"response_types_supported\":[\"code\"]"); assertThat(authorizationServerMetadataResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint\":\"https://example.com/issuer1/oauth2/revoke\""); + assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/revoke\""); assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint\":\"https://example.com/issuer1/oauth2/introspect\""); + assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/introspect\""); assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\"]"); assertThat(authorizationServerMetadataResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]"); assertThat(authorizationServerMetadataResponse).contains("\"custom_claim\":\"value\""); @@ -210,9 +210,9 @@ public void writeInternalWhenWriteFailsThenThrowException() { MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") .responseType("code") .build(); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java index 5985ceb6e..de126fdaa 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,6 +58,7 @@ public void buildWhenAllClaimsProvidedThenCreated() throws Exception { .clientSecretExpiresAt(clientSecretExpiresAt) .clientName("client-name") .redirectUri("https://client.example.com") + .postLogoutRedirectUri("https://client.example.com/oidc-post-logout") .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()) .tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256.getName()) .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) @@ -79,6 +80,7 @@ public void buildWhenAllClaimsProvidedThenCreated() throws Exception { assertThat(clientRegistration.getClientSecretExpiresAt()).isEqualTo(clientSecretExpiresAt); assertThat(clientRegistration.getClientName()).isEqualTo("client-name"); assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com"); + assertThat(clientRegistration.getPostLogoutRedirectUris()).containsOnly("https://client.example.com/oidc-post-logout"); assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()); assertThat(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256.getName()); assertThat(clientRegistration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); @@ -108,6 +110,7 @@ public void withClaimsWhenClaimsProvidedThenCreated() throws Exception { claims.put(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, clientSecretExpiresAt); claims.put(OidcClientMetadataClaimNames.CLIENT_NAME, "client-name"); claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, Collections.singletonList("https://client.example.com")); + claims.put(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, Collections.singletonList("https://client.example.com/oidc-post-logout")); claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()); claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, MacAlgorithm.HS256.getName()); claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, Arrays.asList( @@ -128,6 +131,7 @@ public void withClaimsWhenClaimsProvidedThenCreated() throws Exception { assertThat(clientRegistration.getClientSecretExpiresAt()).isEqualTo(clientSecretExpiresAt); assertThat(clientRegistration.getClientName()).isEqualTo("client-name"); assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com"); + assertThat(clientRegistration.getPostLogoutRedirectUris()).containsOnly("https://client.example.com/oidc-post-logout"); assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()); assertThat(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256.getName()); assertThat(clientRegistration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); @@ -261,6 +265,41 @@ public void buildWhenRedirectUrisAddingOrRemovingThenCorrectValues() { assertThat(clientRegistration.getRedirectUris()).containsExactly("https://client2.example.com"); } + @Test + public void buildWhenPostLogoutRedirectUrisNotListThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.minimalBuilder + .claim(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, "postLogoutRedirectUris"); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("post_logout_redirect_uris must be of type List"); + } + + @Test + public void buildWhenPostLogoutRedirectUrisEmptyListThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.minimalBuilder + .claim(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, Collections.emptyList()); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("post_logout_redirect_uris cannot be empty"); + } + + @Test + public void buildWhenPostLogoutRedirectUrisAddingOrRemovingThenCorrectValues() { + // @formatter:off + OidcClientRegistration clientRegistration = this.minimalBuilder + .postLogoutRedirectUri("https://client1.example.com/oidc-post-logout") + .postLogoutRedirectUris(postLogoutRedirectUris -> { + postLogoutRedirectUris.clear(); + postLogoutRedirectUris.add("https://client2.example.com/oidc-post-logout"); + }) + .build(); + // @formatter:on + + assertThat(clientRegistration.getPostLogoutRedirectUris()).containsExactly("https://client2.example.com/oidc-post-logout"); + } + @Test public void buildWhenGrantTypesNotListThenThrowIllegalArgumentException() { OidcClientRegistration.Builder builder = this.minimalBuilder diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java index 567e0f63a..6a160b267 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,10 +37,10 @@ public class OidcProviderConfigurationTests { private final OidcProviderConfiguration.Builder minimalConfigurationBuilder = OidcProviderConfiguration.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") + .jwkSetUrl("https://example.com/oauth2/jwks") .scope("openid") .responseType("code") .subjectType("public") @@ -49,54 +49,56 @@ public class OidcProviderConfigurationTests { @Test public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() { OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") + .jwkSetUrl("https://example.com/oauth2/jwks") .scope("openid") .responseType("code") .grantType("authorization_code") .grantType("client_credentials") .subjectType("public") .idTokenSigningAlgorithm("RS256") - .userInfoEndpoint("https://example.com/issuer1/userinfo") + .userInfoEndpoint("https://example.com/userinfo") .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) - .clientRegistrationEndpoint("https://example.com/issuer1/connect/register") + .clientRegistrationEndpoint("https://example.com/connect/register") + .endSessionEndpoint("https://example.com/connect/logout") .claim("a-claim", "a-value") .build(); - assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); - assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); + assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(providerConfiguration.getScopes()).containsExactly("openid"); assertThat(providerConfiguration.getResponseTypes()).containsExactly("code"); assertThat(providerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public"); assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256"); - assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo")); + assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/userinfo")); assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); - assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register")); + assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/connect/register")); + assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/connect/logout")); assertThat(providerConfiguration.getClaim("a-claim")).isEqualTo("a-value"); } @Test public void buildWhenOnlyRequiredClaimsThenCreated() { OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") + .jwkSetUrl("https://example.com/oauth2/jwks") .scope("openid") .responseType("code") .subjectType("public") .idTokenSigningAlgorithm("RS256") .build(); - assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); - assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); + assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(providerConfiguration.getScopes()).containsExactly("openid"); assertThat(providerConfiguration.getResponseTypes()).containsExactly("code"); assertThat(providerConfiguration.getGrantTypes()).isNull(); @@ -108,64 +110,68 @@ public void buildWhenOnlyRequiredClaimsThenCreated() { @Test public void buildWhenClaimsProvidedThenCreated() { Map claims = new HashMap<>(); - claims.put(OidcProviderMetadataClaimNames.ISSUER, "https://example.com/issuer1"); - claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/issuer1/oauth2/authorize"); - claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/issuer1/oauth2/token"); - claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "https://example.com/issuer1/oauth2/jwks"); + claims.put(OidcProviderMetadataClaimNames.ISSUER, "https://example.com"); + claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/oauth2/authorize"); + claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/oauth2/token"); + claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "https://example.com/oauth2/jwks"); claims.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid")); claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code")); claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.singletonList("public")); claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, Collections.singletonList("RS256")); - claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, "https://example.com/issuer1/userinfo"); - claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, "https://example.com/issuer1/connect/register"); + claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, "https://example.com/userinfo"); + claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, "https://example.com/connect/register"); + claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, "https://example.com/connect/logout"); claims.put("some-claim", "some-value"); OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build(); - assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); - assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); + assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(providerConfiguration.getScopes()).containsExactly("openid"); assertThat(providerConfiguration.getResponseTypes()).containsExactly("code"); assertThat(providerConfiguration.getGrantTypes()).isNull(); assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public"); assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256"); - assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo")); + assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/userinfo")); assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); - assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register")); + assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/connect/register")); + assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/connect/logout")); assertThat(providerConfiguration.getClaim("some-claim")).isEqualTo("some-value"); } @Test public void buildWhenClaimsProvidedWithUrlsThenCreated() { Map claims = new HashMap<>(); - claims.put(OidcProviderMetadataClaimNames.ISSUER, url("https://example.com/issuer1")); - claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/issuer1/oauth2/authorize")); - claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/issuer1/oauth2/token")); - claims.put(OidcProviderMetadataClaimNames.JWKS_URI, url("https://example.com/issuer1/oauth2/jwks")); + claims.put(OidcProviderMetadataClaimNames.ISSUER, url("https://example.com")); + claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/oauth2/authorize")); + claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/oauth2/token")); + claims.put(OidcProviderMetadataClaimNames.JWKS_URI, url("https://example.com/oauth2/jwks")); claims.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid")); claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code")); claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.singletonList("public")); claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, Collections.singletonList("RS256")); - claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, url("https://example.com/issuer1/userinfo")); - claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, url("https://example.com/issuer1/connect/register")); + claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, url("https://example.com/userinfo")); + claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, url("https://example.com/connect/register")); + claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, url("https://example.com/connect/logout")); claims.put("some-claim", "some-value"); OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build(); - assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); - assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); - assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); - assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com")); + assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/oauth2/authorize")); + assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token")); + assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks")); assertThat(providerConfiguration.getScopes()).containsExactly("openid"); assertThat(providerConfiguration.getResponseTypes()).containsExactly("code"); assertThat(providerConfiguration.getGrantTypes()).isNull(); assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public"); assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256"); - assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo")); + assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/userinfo")); assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); - assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register")); + assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/connect/register")); + assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/connect/logout")); assertThat(providerConfiguration.getClaim("some-claim")).isEqualTo("some-value"); } @@ -412,6 +418,16 @@ public void buildWhenClientRegistrationEndpointNotUrlThenThrowIllegalArgumentExc .withMessage("clientRegistrationEndpoint must be a valid URL"); } + @Test + public void buildWhenEndSessionEndpointNotUrlThenThrowIllegalArgumentException() { + OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, "not an url")); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("endSessionEndpoint must be a valid URL"); + } + @Test public void responseTypesWhenAddingOrRemovingThenCorrectValues() { OidcProviderConfiguration configuration = this.minimalConfigurationBuilder diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java index 3c51c8a67..c7b04d746 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,8 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AccessToken; @@ -71,9 +73,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -87,6 +91,7 @@ public class OidcClientRegistrationAuthenticationProviderTests { private OAuth2AuthorizationService authorizationService; private JwtEncoder jwtEncoder; private OAuth2TokenGenerator tokenGenerator; + private PasswordEncoder passwordEncoder; private AuthorizationServerSettings authorizationServerSettings; private OidcClientRegistrationAuthenticationProvider authenticationProvider; @@ -102,10 +107,22 @@ public Jwt generate(OAuth2TokenContext context) { return jwtGenerator.generate(context); } }); + this.passwordEncoder = spy(new PasswordEncoder() { + @Override + public String encode(CharSequence rawPassword) { + return NoOpPasswordEncoder.getInstance().encode(rawPassword); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return NoOpPasswordEncoder.getInstance().matches(rawPassword, encodedPassword); + } + }); this.authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build(); AuthorizationServerContextHolder.setContext(new TestAuthorizationServerContext(this.authorizationServerSettings, null)); this.authenticationProvider = new OidcClientRegistrationAuthenticationProvider( this.registeredClientRepository, this.authorizationService, this.tokenGenerator); + this.authenticationProvider.setPasswordEncoder(this.passwordEncoder); } @AfterEach @@ -141,6 +158,13 @@ public void setRegisteredClientConverterWhenNullThenThrowIllegalArgumentExceptio .withMessage("registeredClientConverter cannot be null"); } + @Test + public void setPasswordEncoderWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.authenticationProvider.setPasswordEncoder(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("passwordEncoder cannot be null"); + } + @Test public void supportsWhenTypeOidcClientRegistrationAuthenticationTokenThenReturnTrue() { assertThat(this.authenticationProvider.supports(OidcClientRegistrationAuthenticationToken.class)).isTrue(); @@ -359,6 +383,78 @@ public void authenticateWhenRedirectUriContainsFragmentThenThrowOAuth2Authentica eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN)); } + @Test + public void authenticateWhenInvalidPostLogoutRedirectUriThenThrowOAuth2AuthenticationException() { + Jwt jwt = createJwtClientRegistration(); + OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + jwt.getTokenValue(), jwt.getIssuedAt(), + jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE)); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization( + registeredClient, jwtAccessToken, jwt.getClaims()).build(); + when(this.authorizationService.findByToken( + eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + + JwtAuthenticationToken principal = new JwtAuthenticationToken( + jwt, AuthorityUtils.createAuthorityList("SCOPE_client.create")); + // @formatter:off + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("https://client.example.com") + .postLogoutRedirectUri("invalid uri") + .build(); + // @formatter:on + + OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken( + principal, clientRegistration); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo("invalid_client_metadata"); + assertThat(error.getDescription()).contains(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + }); + verify(this.authorizationService).findByToken( + eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN)); + } + + @Test + public void authenticateWhenPostLogoutRedirectUriContainsFragmentThenThrowOAuth2AuthenticationException() { + Jwt jwt = createJwtClientRegistration(); + OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + jwt.getTokenValue(), jwt.getIssuedAt(), + jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE)); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization( + registeredClient, jwtAccessToken, jwt.getClaims()).build(); + when(this.authorizationService.findByToken( + eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + + JwtAuthenticationToken principal = new JwtAuthenticationToken( + jwt, AuthorityUtils.createAuthorityList("SCOPE_client.create")); + // @formatter:off + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("https://client.example.com") + .postLogoutRedirectUri("https://client.example.com/oidc-post-logout#fragment") + .build(); + // @formatter:on + + OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken( + principal, clientRegistration); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo("invalid_client_metadata"); + assertThat(error.getDescription()).contains(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + }); + verify(this.authorizationService).findByToken( + eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN)); + } + @Test public void authenticateWhenInvalidTokenEndpointAuthenticationMethodThenThrowOAuth2AuthenticationException() { Jwt jwt = createJwtClientRegistration(); @@ -472,6 +568,8 @@ public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvid assertThat(authenticationResult.getClientRegistration().getTokenEndpointAuthenticationSigningAlgorithm()) .isEqualTo(MacAlgorithm.HS256.getName()); assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNotNull(); + verify(this.passwordEncoder).encode(any()); + reset(this.passwordEncoder); // @formatter:off builder @@ -483,6 +581,7 @@ public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvid assertThat(authenticationResult.getClientRegistration().getTokenEndpointAuthenticationSigningAlgorithm()) .isEqualTo(SignatureAlgorithm.RS256.getName()); assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNull(); + verifyNoInteractions(this.passwordEncoder); } @Test @@ -545,6 +644,7 @@ public void authenticateWhenValidAccessTokenThenReturnClientRegistration() { OidcClientRegistration clientRegistration = OidcClientRegistration.builder() .clientName("client-name") .redirectUri("https://client.example.com") + .postLogoutRedirectUri("https://client.example.com/oidc-post-logout") .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) .scope("scope1") @@ -565,6 +665,7 @@ public void authenticateWhenValidAccessTokenThenReturnClientRegistration() { verify(this.registeredClientRepository).save(registeredClientCaptor.capture()); verify(this.authorizationService, times(2)).save(authorizationCaptor.capture()); verify(this.jwtEncoder).encode(any()); + verify(this.passwordEncoder).encode(any()); // assert "registration" access token, which should be used for subsequent calls to client configuration endpoint OAuth2Authorization authorizationResult = authorizationCaptor.getAllValues().get(0); @@ -588,6 +689,7 @@ public void authenticateWhenValidAccessTokenThenReturnClientRegistration() { assertThat(registeredClientResult.getClientName()).isEqualTo(clientRegistration.getClientName()); assertThat(registeredClientResult.getClientAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); assertThat(registeredClientResult.getRedirectUris()).containsExactly("https://client.example.com"); + assertThat(registeredClientResult.getPostLogoutRedirectUris()).containsExactly("https://client.example.com/oidc-post-logout"); assertThat(registeredClientResult.getAuthorizationGrantTypes()) .containsExactlyInAnyOrder(AuthorizationGrantType.AUTHORIZATION_CODE, AuthorizationGrantType.CLIENT_CREDENTIALS); assertThat(registeredClientResult.getScopes()).containsExactlyInAnyOrder("scope1", "scope2"); @@ -603,6 +705,8 @@ public void authenticateWhenValidAccessTokenThenReturnClientRegistration() { assertThat(clientRegistrationResult.getClientName()).isEqualTo(registeredClientResult.getClientName()); assertThat(clientRegistrationResult.getRedirectUris()) .containsExactlyInAnyOrderElementsOf(registeredClientResult.getRedirectUris()); + assertThat(clientRegistrationResult.getPostLogoutRedirectUris()) + .containsExactlyInAnyOrderElementsOf(registeredClientResult.getPostLogoutRedirectUris()); List grantTypes = new ArrayList<>(); registeredClientResult.getAuthorizationGrantTypes().forEach(authorizationGrantType -> diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java new file mode 100644 index 000000000..ceb4e0115 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java @@ -0,0 +1,574 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.authentication; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OidcLogoutAuthenticationProvider}. + * + * @author Joe Grandja + */ +public class OidcLogoutAuthenticationProviderTests { + private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN); + private RegisteredClientRepository registeredClientRepository; + private OAuth2AuthorizationService authorizationService; + private SessionRegistry sessionRegistry; + private AuthorizationServerSettings authorizationServerSettings; + private OidcLogoutAuthenticationProvider authenticationProvider; + + @BeforeEach + public void setUp() { + this.registeredClientRepository = mock(RegisteredClientRepository.class); + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.sessionRegistry = mock(SessionRegistry.class); + this.authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build(); + TestAuthorizationServerContext authorizationServerContext = + new TestAuthorizationServerContext(this.authorizationServerSettings, null); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + this.authenticationProvider = new OidcLogoutAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, this.sessionRegistry); + } + + @AfterEach + public void cleanup() { + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationProvider(null, this.authorizationService, this.sessionRegistry)) + .withMessage("registeredClientRepository cannot be null"); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationProvider(this.registeredClientRepository, null, this.sessionRegistry)) + .withMessage("authorizationService cannot be null"); + } + + @Test + public void constructorWhenSessionRegistryNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationProvider(this.registeredClientRepository, this.authorizationService, null)) + .withMessage("sessionRegistry cannot be null"); + } + + @Test + public void supportsWhenTypeOidcLogoutAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OidcLogoutAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenIdTokenNotFoundThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", principal, "session-1", null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains("id_token_hint"); + }); + + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + } + + @Test + public void authenticateWhenIdTokenInvalidatedThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, (metadata) -> { + metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()); + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true); + }) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, "session-1", null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains("id_token_hint"); + }); + + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + } + + @Test + public void authenticateWhenMissingAudienceThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, "session-1", null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains(IdTokenClaimNames.AUD); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenInvalidAudienceThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId() + "-invalid")) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, "session-1", null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains(IdTokenClaimNames.AUD); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, "session-1", registeredClient.getClientId() + "-invalid", null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenInvalidPostLogoutRedirectUriThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, "session-1", registeredClient.getClientId(), + "https://example.com/callback-1-invalid", null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + assertThat(error.getDescription()).contains("post_logout_redirect_uri"); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenMissingSubThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + principal.setAuthenticated(true); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, "session-1", null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains("sub"); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + // gh-1235 + @Test + public void authenticateWhenInvalidPrincipalThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + principal.setAuthenticated(true); + + TestingAuthenticationToken otherPrincipal = new TestingAuthenticationToken("other-principal", "credentials"); + otherPrincipal.setAuthenticated(true); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), otherPrincipal, "session-1", null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains("sub"); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenMissingSidThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + String sessionId = "session-1"; + List sessions = Collections.singletonList( + new SessionInformation(principal.getPrincipal(), sessionId, Date.from(Instant.now()))); + when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true))) + .thenReturn(sessions); + + principal.setAuthenticated(true); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, sessionId, null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains("sid"); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenInvalidSidThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .claim("sid", "other-session") + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + String sessionId = "session-1"; + List sessions = Collections.singletonList( + new SessionInformation(principal.getPrincipal(), sessionId, Date.from(Instant.now()))); + when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true))) + .thenReturn(sessions); + + principal.setAuthenticated(true); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, sessionId, null, null, null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).contains("sid"); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenValidIdTokenThenAuthenticated() throws Exception { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + String sessionId = "session-1"; + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .claim("sid", createHash(sessionId)) + .build(); + authenticateValidIdToken(principal, registeredClient, sessionId, idToken); + } + + // gh-1440 + @Test + public void authenticateWhenValidExpiredIdTokenThenAuthenticated() throws Exception { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + String sessionId = "session-1"; + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject(principal.getName()) + .audience(Collections.singleton(registeredClient.getClientId())) + .issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS)) + .expiresAt(Instant.now().minusSeconds(30).truncatedTo(ChronoUnit.MILLIS)) // Expired + .claim("sid", createHash(sessionId)) + .build(); + authenticateValidIdToken(principal, registeredClient, sessionId, idToken); + } + + private void authenticateValidIdToken(Authentication principal, RegisteredClient registeredClient, + String sessionId, OidcIdToken idToken) { + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(principal.getName()) + .token(idToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims())) + .build(); + when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId()))) + .thenReturn(registeredClient); + + SessionInformation sessionInformation = new SessionInformation( + principal.getPrincipal(), sessionId, Date.from(Instant.now())); + List sessions = Collections.singletonList(sessionInformation); + when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true))) + .thenReturn(sessions); + + principal.setAuthenticated(true); + String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().toArray(new String[0])[0]; + String state = "state"; + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + idToken.getTokenValue(), principal, sessionId, registeredClient.getClientId(), postLogoutRedirectUri, state); + + OidcLogoutAuthenticationToken authenticationResult = + (OidcLogoutAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + verify(this.authorizationService).findByToken( + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + + assertThat(authenticationResult.getPrincipal()).isEqualTo(principal); + assertThat(authenticationResult.getCredentials().toString()).isEmpty(); + assertThat(authenticationResult.getIdToken()).isEqualTo(idToken); + assertThat(authenticationResult.getSessionId()).isEqualTo(sessionInformation.getSessionId()); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPostLogoutRedirectUri()).isEqualTo(postLogoutRedirectUri); + assertThat(authenticationResult.getState()).isEqualTo(state); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + } + + private static String createHash(String value) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java new file mode 100644 index 000000000..3a3367333 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.authentication; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OidcLogoutAuthenticationToken}. + * + * @author Joe Grandja + */ +public class OidcLogoutAuthenticationTokenTests { + private final String idTokenHint = "id-token"; + private final OidcIdToken idToken = OidcIdToken.withTokenValue(this.idTokenHint) + .issuer("https://provider.com") + .subject("principal") + .issuedAt(Instant.now().minusSeconds(60)) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + private final TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + private final String sessionId = "session-1"; + private final String clientId = "client-1"; + private final String postLogoutRedirectUri = "https://example.com/oidc-post-logout"; + private final String state = "state-1"; + + @Test + public void constructorWhenIdTokenHintEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + "", this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idTokenHint cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + (String) null, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idTokenHint cannot be empty"); + } + + @Test + public void constructorWhenIdTokenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + (OidcIdToken) null, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idToken cannot be null"); + } + + @Test + public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + this.idTokenHint, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("principal cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + this.idToken, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("principal cannot be null"); + } + + @Test + public void constructorWhenIdTokenHintProvidedThenCreated() { + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + this.idTokenHint, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state); + assertThat(authentication.getPrincipal()).isEqualTo(this.principal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getIdTokenHint()).isEqualTo(this.idTokenHint); + assertThat(authentication.getIdToken()).isNull(); + assertThat(authentication.getSessionId()).isEqualTo(this.sessionId); + assertThat(authentication.getClientId()).isEqualTo(this.clientId); + assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri); + assertThat(authentication.getState()).isEqualTo(this.state); + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + public void constructorWhenIdTokenProvidedThenCreated() { + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + this.idToken, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state); + assertThat(authentication.getPrincipal()).isEqualTo(this.principal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getIdTokenHint()).isEqualTo(this.idToken.getTokenValue()); + assertThat(authentication.getIdToken()).isEqualTo(this.idToken); + assertThat(authentication.getSessionId()).isEqualTo(this.sessionId); + assertThat(authentication.getClientId()).isEqualTo(this.clientId); + assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri); + assertThat(authentication.getState()).isEqualTo(this.state); + assertThat(authentication.isAuthenticated()).isTrue(); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java index 65f2ada78..3799ca84e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -99,6 +99,9 @@ public void readInternalWhenValidParametersThenSuccess() throws Exception { +" \"redirect_uris\": [\n" + " \"https://client.example.com\"\n" + " ],\n" + +" \"post_logout_redirect_uris\": [\n" + + " \"https://client.example.com/oidc-post-logout\"\n" + + " ],\n" +" \"token_endpoint_auth_method\": \"client_secret_jwt\",\n" +" \"token_endpoint_auth_signing_alg\": \"HS256\",\n" +" \"grant_types\": [\n" @@ -125,6 +128,7 @@ public void readInternalWhenValidParametersThenSuccess() throws Exception { assertThat(clientRegistration.getClientSecretExpiresAt()).isEqualTo(Instant.ofEpochSecond(1607637467L)); assertThat(clientRegistration.getClientName()).isEqualTo("client-name"); assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com"); + assertThat(clientRegistration.getPostLogoutRedirectUris()).containsOnly("https://client.example.com/oidc-post-logout"); assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()); assertThat(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256.getName()); assertThat(clientRegistration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); @@ -183,6 +187,7 @@ public void writeInternalWhenClientRegistrationThenSuccess() { .clientSecretExpiresAt(Instant.ofEpochSecond(1607637467)) .clientName("client-name") .redirectUri("https://client.example.com") + .postLogoutRedirectUri("https://client.example.com/oidc-post-logout") .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()) .tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256.getName()) .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) @@ -208,6 +213,7 @@ public void writeInternalWhenClientRegistrationThenSuccess() { assertThat(clientRegistrationResponse).contains("\"client_secret_expires_at\":1607637467"); assertThat(clientRegistrationResponse).contains("\"client_name\":\"client-name\""); assertThat(clientRegistrationResponse).contains("\"redirect_uris\":[\"https://client.example.com\"]"); + assertThat(clientRegistrationResponse).contains("\"post_logout_redirect_uris\":[\"https://client.example.com/oidc-post-logout\"]"); assertThat(clientRegistrationResponse).contains("\"token_endpoint_auth_method\":\"client_secret_jwt\""); assertThat(clientRegistrationResponse).contains("\"token_endpoint_auth_signing_alg\":\"HS256\""); assertThat(clientRegistrationResponse).contains("\"grant_types\":[\"authorization_code\",\"client_credentials\"]"); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java index 1db31b067..f3658f52b 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,10 +61,10 @@ public void setProviderConfigurationConverterWhenNullThenThrowIllegalArgumentExc public void readInternalWhenRequiredParametersThenSuccess() throws Exception { // @formatter:off String providerConfigurationResponse = "{\n" - + " \"issuer\": \"https://example.com/issuer1\",\n" - + " \"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n" - + " \"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n" - + " \"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n" + + " \"issuer\": \"https://example.com\",\n" + + " \"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n" + + " \"token_endpoint\": \"https://example.com/oauth2/token\",\n" + + " \"jwks_uri\": \"https://example.com/oauth2/jwks\",\n" + " \"response_types_supported\": [\"code\"],\n" + " \"subject_types_supported\": [\"public\"],\n" + " \"id_token_signing_alg_values_supported\": [\"RS256\"]\n" @@ -74,10 +74,10 @@ public void readInternalWhenRequiredParametersThenSuccess() throws Exception { OidcProviderConfiguration providerConfiguration = this.messageConverter .readInternal(OidcProviderConfiguration.class, response); - assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com/issuer1")); - assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize")); - assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token")); - assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks")); + assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com")); + assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/oauth2/authorize")); + assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/oauth2/token")); + assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(new URL("https://example.com/oauth2/jwks")); assertThat(providerConfiguration.getResponseTypes()).containsExactly("code"); assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public"); assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256"); @@ -90,11 +90,11 @@ public void readInternalWhenRequiredParametersThenSuccess() throws Exception { public void readInternalWhenValidParametersThenSuccess() throws Exception { // @formatter:off String providerConfigurationResponse = "{\n" - + " \"issuer\": \"https://example.com/issuer1\",\n" - + " \"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n" - + " \"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n" - + " \"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n" - + " \"userinfo_endpoint\": \"https://example.com/issuer1/userinfo\",\n" + + " \"issuer\": \"https://example.com\",\n" + + " \"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n" + + " \"token_endpoint\": \"https://example.com/oauth2/token\",\n" + + " \"jwks_uri\": \"https://example.com/oauth2/jwks\",\n" + + " \"userinfo_endpoint\": \"https://example.com/userinfo\",\n" + " \"scopes_supported\": [\"openid\"],\n" + " \"response_types_supported\": [\"code\"],\n" + " \"grant_types_supported\": [\"authorization_code\", \"client_credentials\"],\n" @@ -109,11 +109,11 @@ public void readInternalWhenValidParametersThenSuccess() throws Exception { OidcProviderConfiguration providerConfiguration = this.messageConverter .readInternal(OidcProviderConfiguration.class, response); - assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com/issuer1")); - assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize")); - assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token")); - assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks")); - assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(new URL("https://example.com/issuer1/userinfo")); + assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com")); + assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/oauth2/authorize")); + assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/oauth2/token")); + assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(new URL("https://example.com/oauth2/jwks")); + assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(new URL("https://example.com/userinfo")); assertThat(providerConfiguration.getScopes()).containsExactly("openid"); assertThat(providerConfiguration.getResponseTypes()).containsExactly("code"); assertThat(providerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); @@ -153,11 +153,11 @@ public void readInternalWhenInvalidProviderConfigurationThenThrowException() { public void writeInternalWhenProviderConfigurationThenSuccess() { OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") - .userInfoEndpoint("https://example.com/issuer1/userinfo") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") + .jwkSetUrl("https://example.com/oauth2/jwks") + .userInfoEndpoint("https://example.com/userinfo") .scope("openid") .responseType("code") .grantType("authorization_code") @@ -173,11 +173,11 @@ public void writeInternalWhenProviderConfigurationThenSuccess() { this.messageConverter.writeInternal(providerConfiguration, outputMessage); String providerConfigurationResponse = outputMessage.getBodyAsString(); - assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com/issuer1\""); - assertThat(providerConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/authorize\""); - assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/token\""); - assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/jwks\""); - assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/issuer1/userinfo\""); + assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com\""); + assertThat(providerConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/oauth2/authorize\""); + assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/token\""); + assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/jwks\""); + assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/userinfo\""); assertThat(providerConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]"); assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]"); assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]"); @@ -199,10 +199,10 @@ public void writeInternalWhenWriteFailsThenThrowsException() { OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder() - .issuer("https://example.com/issuer1") - .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") - .tokenEndpoint("https://example.com/issuer1/oauth2/token") - .jwkSetUrl("https://example.com/issuer1/oauth2/jwks") + .issuer("https://example.com") + .authorizationEndpoint("https://example.com/oauth2/authorize") + .tokenEndpoint("https://example.com/oauth2/token") + .jwkSetUrl("https://example.com/oauth2/jwks") .responseType("code") .subjectType("public") .idTokenSigningAlgorithm("RS256") diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java index 20fd07253..51208b45e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,7 @@ import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -327,6 +328,7 @@ public void doFilterWhenClientConfigurationRequestEmptyClientIdThenNotProcessed( MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.addParameter(OAuth2ParameterNames.CLIENT_ID, ""); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -342,6 +344,7 @@ public void doFilterWhenClientConfigurationRequestMultipleClientIdThenInvalidReq request.setServletPath(requestUri); request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-id"); request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-id2"); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -388,6 +391,7 @@ private void doFilterWhenClientConfigurationRequestInvalidThenError( MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client1"); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -421,6 +425,7 @@ public void doFilterWhenClientConfigurationRequestValidThenSuccessResponse() thr MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.setParameter(OAuth2ParameterNames.CLIENT_ID, expectedClientRegistrationResponse.getClientId()); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -463,6 +468,7 @@ public void doFilterWhenCustomAuthenticationConverterThenUsed() throws ServletEx MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client-id"); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -492,6 +498,7 @@ public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exce MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.setParameter(OAuth2ParameterNames.CLIENT_ID, expectedClientRegistrationResponse.getClientId()); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -513,6 +520,7 @@ public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exce MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client1"); + updateQueryString(request); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -522,6 +530,18 @@ public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exce any(OAuth2AuthenticationException.class)); } + private static void updateQueryString(MockHttpServletRequest request) { + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(request.getRequestURI()); + request.getParameterMap().forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + uriBuilder.queryParam(key, value); + } + } + }); + request.setQueryString(uriBuilder.build().getQuery()); + } + private OAuth2Error readError(MockHttpServletResponse response) throws Exception { MockClientHttpResponse httpResponse = new MockClientHttpResponse( response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus())); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java new file mode 100644 index 000000000..a678823ed --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java @@ -0,0 +1,355 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.oidc.web; + +import java.util.function.Consumer; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OidcLogoutEndpointFilter}. + * + * @author Joe Grandja + */ +public class OidcLogoutEndpointFilterTests { + private static final String DEFAULT_OIDC_LOGOUT_ENDPOINT_URI = "/connect/logout"; + private AuthenticationManager authenticationManager; + private OidcLogoutEndpointFilter filter; + private TestingAuthenticationToken principal; + + @BeforeEach + public void setUp() { + this.authenticationManager = mock(AuthenticationManager.class); + this.filter = new OidcLogoutEndpointFilter(this.authenticationManager); + this.principal = new TestingAuthenticationToken("principal", "credentials"); + this.principal.setAuthenticated(true); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OidcLogoutEndpointFilter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationManager cannot be null"); + } + + @Test + public void constructorWhenLogoutEndpointUriNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OidcLogoutEndpointFilter(this.authenticationManager, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("logoutEndpointUri cannot be empty"); + } + + @Test + public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationConverter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationConverter cannot be null"); + } + + @Test + public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationSuccessHandler cannot be null"); + } + + @Test + public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationFailureHandler(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationFailureHandler cannot be null"); + } + + @Test + public void doFilterWhenNotLogoutRequestThenNotProcessed() throws Exception { + String requestUri = "/path"; + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenLogoutRequestMissingIdTokenHintThenInvalidRequestError() throws Exception { + doFilterWhenRequestInvalidParameterThenError( + createLogoutRequest(TestRegisteredClients.registeredClient().build()), + "id_token_hint", + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.removeParameter("id_token_hint")); + } + + @Test + public void doFilterWhenLogoutRequestMultipleIdTokenHintThenInvalidRequestError() throws Exception { + doFilterWhenRequestInvalidParameterThenError( + createLogoutRequest(TestRegisteredClients.registeredClient().build()), + "id_token_hint", + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.addParameter("id_token_hint", "id-token-2")); + } + + @Test + public void doFilterWhenLogoutRequestMultipleClientIdThenInvalidRequestError() throws Exception { + doFilterWhenRequestInvalidParameterThenError( + createLogoutRequest(TestRegisteredClients.registeredClient().build()), + OAuth2ParameterNames.CLIENT_ID, + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2")); + } + + @Test + public void doFilterWhenLogoutRequestMultiplePostLogoutRedirectUriThenInvalidRequestError() throws Exception { + doFilterWhenRequestInvalidParameterThenError( + createLogoutRequest(TestRegisteredClients.registeredClient().build()), + "post_logout_redirect_uri", + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.addParameter("post_logout_redirect_uri", "https://example.com/callback-4")); + } + + @Test + public void doFilterWhenLogoutRequestMultipleStateThenInvalidRequestError() throws Exception { + doFilterWhenRequestInvalidParameterThenError( + createLogoutRequest(TestRegisteredClients.registeredClient().build()), + OAuth2ParameterNames.STATE, + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.addParameter(OAuth2ParameterNames.STATE, "state-2")); + } + + private void doFilterWhenRequestInvalidParameterThenError(MockHttpServletRequest request, + String parameterName, String errorCode, Consumer requestConsumer) throws Exception { + + requestConsumer.accept(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertThat(response.getErrorMessage()).isEqualTo("[" + errorCode + "] OpenID Connect 1.0 Logout Request Parameter: " + parameterName); + } + + @Test + public void doFilterWhenLogoutRequestAuthenticationExceptionThenErrorResponse() throws Exception { + OAuth2Error error = new OAuth2Error("errorCode", "errorDescription", "errorUri"); + when(this.authenticationManager.authenticate(any())) + .thenThrow(new OAuth2AuthenticationException(error)); + + MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build()); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertThat(response.getErrorMessage()).isEqualTo(error.toString()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(this.principal); + } + + @Test + public void doFilterWhenCustomAuthenticationConverterThenUsed() throws Exception { + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", this.principal, null, null, null, null); + + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + when(authenticationConverter.convert(any())).thenReturn(authentication); + this.filter.setAuthenticationConverter(authenticationConverter); + + when(this.authenticationManager.authenticate(any())) + .thenReturn(authentication); + + MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build()); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(authenticationConverter).convert(any()); + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception { + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", this.principal, null, null, null, null); + + AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class); + this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler); + + when(this.authenticationManager.authenticate(any())) + .thenReturn(authentication); + + MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build()); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), same(authentication)); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exception { + AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class); + this.filter.setAuthenticationFailureHandler(authenticationFailureHandler); + + when(this.authenticationManager.authenticate(any())) + .thenThrow(new AuthenticationServiceException("AuthenticationServiceException")); + + MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build()); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + ArgumentCaptor authenticationExceptionCaptor = ArgumentCaptor.forClass(AuthenticationException.class); + verify(this.authenticationManager).authenticate(any()); + verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), authenticationExceptionCaptor.capture()); + verifyNoInteractions(filterChain); + + assertThat(authenticationExceptionCaptor.getValue()) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + assertThat(error.getDescription()).contains("AuthenticationServiceException"); + }); + } + + @Test + public void doFilterWhenLogoutRequestAuthenticatedThenLogout() throws Exception { + MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build()); + MockHttpSession session = (MockHttpSession) request.getSession(true); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", this.principal, session.getId(), null, null, null); + + when(this.authenticationManager.authenticate(any())) + .thenReturn(authentication); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).isEqualTo("/"); + assertThat(session.isInvalid()).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + public void doFilterWhenLogoutRequestAuthenticatedWithPostLogoutRedirectUriThenPostLogoutRedirect() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + MockHttpServletRequest request = createLogoutRequest(registeredClient); + MockHttpSession session = (MockHttpSession) request.getSession(true); + + String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().iterator().next(); + String state = "state-1"; + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", this.principal, session.getId(), + registeredClient.getClientId(), postLogoutRedirectUri, state); + authentication.setAuthenticated(true); + + when(this.authenticationManager.authenticate(any())) + .thenReturn(authentication); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).isEqualTo(postLogoutRedirectUri + "?state=" + state); + assertThat(session.isInvalid()).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private static MockHttpServletRequest createLogoutRequest(RegisteredClient registeredClient) { + String requestUri = DEFAULT_OIDC_LOGOUT_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + + request.addParameter("id_token_hint", "id-token"); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + request.addParameter("post_logout_redirect_uri", registeredClient.getPostLogoutRedirectUris().iterator().next()); + request.addParameter(OAuth2ParameterNames.STATE, "state"); + + return request; + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java index aeca75386..2e2f28ff0 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,11 +87,12 @@ public void doFilterWhenConfigurationRequestPostThenNotProcessed() throws Except @Test public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws Exception { - String issuer = "https://example.com/issuer1"; + String issuer = "https://example.com"; String authorizationEndpoint = "/oauth2/v1/authorize"; String tokenEndpoint = "/oauth2/v1/token"; String jwkSetEndpoint = "/oauth2/v1/jwks"; String userInfoEndpoint = "/userinfo"; + String logoutEndpoint = "/connect/logout"; String tokenRevocationEndpoint = "/oauth2/v1/revoke"; String tokenIntrospectionEndpoint = "/oauth2/v1/introspect"; @@ -101,6 +102,7 @@ public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws E .tokenEndpoint(tokenEndpoint) .jwkSetEndpoint(jwkSetEndpoint) .oidcUserInfoEndpoint(userInfoEndpoint) + .oidcLogoutEndpoint(logoutEndpoint) .tokenRevocationEndpoint(tokenRevocationEndpoint) .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint) .build(); @@ -118,20 +120,21 @@ public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws E assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE); String providerConfigurationResponse = response.getContentAsString(); - assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com/issuer1\""); - assertThat(providerConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/v1/authorize\""); - assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/v1/token\""); - assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/v1/jwks\""); + assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com\""); + assertThat(providerConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/oauth2/v1/authorize\""); + assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/v1/token\""); + assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/v1/jwks\""); assertThat(providerConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]"); assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]"); - assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\"]"); - assertThat(providerConfigurationResponse).contains("\"revocation_endpoint\":\"https://example.com/issuer1/oauth2/v1/revoke\""); + assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\"]"); + assertThat(providerConfigurationResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\""); assertThat(providerConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]"); - assertThat(providerConfigurationResponse).contains("\"introspection_endpoint\":\"https://example.com/issuer1/oauth2/v1/introspect\""); + assertThat(providerConfigurationResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\""); assertThat(providerConfigurationResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]"); assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]"); assertThat(providerConfigurationResponse).contains("\"id_token_signing_alg_values_supported\":[\"RS256\"]"); - assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/issuer1/userinfo\""); + assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/userinfo\""); + assertThat(providerConfigurationResponse).contains("\"end_session_endpoint\":\"https://example.com/connect/logout\""); assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java index 3d27e919f..9eb902aa3 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ * Tests for {@link AuthorizationServerSettings}. * * @author Daniel Garnier-Moiroux + * @author Joe Grandja */ public class AuthorizationServerSettingsTests { @@ -39,6 +40,7 @@ public void buildWhenDefaultThenDefaultsAreSet() { assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect"); assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint()).isEqualTo("/connect/register"); assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo("/userinfo"); + assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo("/connect/logout"); } @Test @@ -50,6 +52,7 @@ public void buildWhenSettingsProvidedThenSet() { String tokenIntrospectionEndpoint = "/oauth2/v1/introspect"; String oidcClientRegistrationEndpoint = "/connect/v1/register"; String oidcUserInfoEndpoint = "/connect/v1/userinfo"; + String oidcLogoutEndpoint = "/connect/v1/logout"; String issuer = "https://example.com:9000"; AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder() @@ -62,6 +65,7 @@ public void buildWhenSettingsProvidedThenSet() { .tokenRevocationEndpoint(tokenRevocationEndpoint) .oidcClientRegistrationEndpoint(oidcClientRegistrationEndpoint) .oidcUserInfoEndpoint(oidcUserInfoEndpoint) + .oidcLogoutEndpoint(oidcLogoutEndpoint) .build(); assertThat(authorizationServerSettings.getIssuer()).isEqualTo(issuer); @@ -72,6 +76,7 @@ public void buildWhenSettingsProvidedThenSet() { assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo(tokenIntrospectionEndpoint); assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint()).isEqualTo(oidcClientRegistrationEndpoint); assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo(oidcUserInfoEndpoint); + assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo(oidcLogoutEndpoint); } @Test @@ -81,7 +86,7 @@ public void settingWhenCustomThenSet() { .settings(settings -> settings.put("name2", "value2")) .build(); - assertThat(authorizationServerSettings.getSettings()).hasSize(9); + assertThat(authorizationServerSettings.getSettings()).hasSize(12); assertThat(authorizationServerSettings.getSetting("name1")).isEqualTo("value1"); assertThat(authorizationServerSettings.getSetting("name2")).isEqualTo("value2"); } @@ -142,4 +147,11 @@ public void jwksEndpointWhenNullThenThrowIllegalArgumentException() { .withMessage("value cannot be null"); } + @Test + public void oidcLogoutEndpointWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> AuthorizationServerSettings.builder().oidcLogoutEndpoint(null)) + .withMessage("value cannot be null"); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java index 743d73342..d1552dfed 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,10 +34,11 @@ public class TokenSettingsTests { @Test public void buildWhenDefaultThenDefaultsAreSet() { TokenSettings tokenSettings = TokenSettings.builder().build(); - assertThat(tokenSettings.getSettings()).hasSize(6); + assertThat(tokenSettings.getSettings()).hasSize(7); assertThat(tokenSettings.getAuthorizationCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5)); assertThat(tokenSettings.getAccessTokenTimeToLive()).isEqualTo(Duration.ofMinutes(5)); assertThat(tokenSettings.getAccessTokenFormat()).isEqualTo(OAuth2TokenFormat.SELF_CONTAINED); + assertThat(tokenSettings.getDeviceCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5)); assertThat(tokenSettings.isReuseRefreshTokens()).isTrue(); assertThat(tokenSettings.getRefreshTokenTimeToLive()).isEqualTo(Duration.ofMinutes(60)); assertThat(tokenSettings.getIdTokenSignatureAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); @@ -163,7 +164,7 @@ public void settingWhenCustomThenSet() { .setting("name1", "value1") .settings(settings -> settings.put("name2", "value2")) .build(); - assertThat(tokenSettings.getSettings()).hasSize(8); + assertThat(tokenSettings.getSettings()).hasSize(9); assertThat(tokenSettings.getSetting("name1")).isEqualTo("value1"); assertThat(tokenSettings.getSetting("name2")).isEqualTo("value2"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java index c22a5492f..fc1a7ba65 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import java.security.Principal; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -27,11 +28,14 @@ import org.mockito.ArgumentCaptor; import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; @@ -44,9 +48,9 @@ import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; -import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext; import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; @@ -67,7 +71,7 @@ public class JwtGeneratorTests { private JwtEncoder jwtEncoder; private OAuth2TokenCustomizer jwtCustomizer; private JwtGenerator jwtGenerator; - private AuthorizationServerContext authorizationServerContext; + private TestAuthorizationServerContext authorizationServerContext; @BeforeEach public void setUp() { @@ -151,7 +155,7 @@ public void generateWhenAccessTokenTypeThenReturnJwt() { } @Test - public void generateWhenIdTokenTypeThenReturnJwt() { + public void generateWhenIdTokenTypeAndAuthorizationCodeGrantThenReturnJwt() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient() .scope(OidcScopes.OPENID) .tokenSettings(TokenSettings.builder().idTokenSignatureAlgorithm(SignatureAlgorithm.ES256).build()) @@ -168,16 +172,105 @@ public void generateWhenIdTokenTypeThenReturnJwt() { OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken("code", clientPrincipal, authorizationRequest.getRedirectUri(), null); + Authentication principal = authorization.getAttribute(Principal.class.getName()); + SessionInformation sessionInformation = new SessionInformation( + principal.getPrincipal(), "session1", Date.from(Instant.now().minus(2, ChronoUnit.HOURS))); + // @formatter:off OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder() .registeredClient(registeredClient) - .principal(authorization.getAttribute(Principal.class.getName())) + .principal(principal) .authorizationServerContext(this.authorizationServerContext) .authorization(authorization) .authorizedScopes(authorization.getAuthorizedScopes()) .tokenType(ID_TOKEN_TOKEN_TYPE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrant(authentication) + .put(SessionInformation.class, sessionInformation) + .build(); + // @formatter:on + + assertGeneratedTokenType(tokenContext); + } + + // gh-1224 + @Test + public void generateWhenIdTokenTypeAndRefreshTokenGrantThenReturnJwt() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scope(OidcScopes.OPENID) + .build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject("subject") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("sid", "sessionId-1234") + .claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now())) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(idToken) + .build(); + + OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + registeredClient, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + + OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken( + refreshToken.getTokenValue(), clientPrincipal, null, null); + + Authentication principal = authorization.getAttribute(Principal.class.getName()); + + // @formatter:off + OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(principal) + .authorizationServerContext(this.authorizationServerContext) + .authorization(authorization) + .authorizedScopes(authorization.getAuthorizedScopes()) + .tokenType(ID_TOKEN_TOKEN_TYPE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrant(authentication) + .build(); + // @formatter:on + + assertGeneratedTokenType(tokenContext); + } + + // gh-1283 + @Test + public void generateWhenIdTokenTypeWithoutSidAndRefreshTokenGrantThenReturnJwt() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scope(OidcScopes.OPENID) + .build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject("subject") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(idToken) + .build(); + + OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + registeredClient, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + + OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken( + refreshToken.getTokenValue(), clientPrincipal, null, null); + + Authentication principal = authorization.getAttribute(Principal.class.getName()); + + // @formatter:off + OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(principal) + .authorizationServerContext(this.authorizationServerContext) + .authorization(authorization) + .authorizedScopes(authorization.getAuthorizedScopes()) + .tokenType(ID_TOKEN_TOKEN_TYPE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrant(authentication) .build(); // @formatter:on @@ -233,11 +326,20 @@ private void assertGeneratedTokenType(OAuth2TokenContext tokenContext) { assertThat(scopes).isEqualTo(tokenContext.getAuthorizedScopes()); } else { assertThat(jwtClaimsSet.getClaim(IdTokenClaimNames.AZP)).isEqualTo(tokenContext.getRegisteredClient().getClientId()); + if (tokenContext.getAuthorizationGrantType().equals(AuthorizationGrantType.AUTHORIZATION_CODE)) { + OAuth2AuthorizationRequest authorizationRequest = tokenContext.getAuthorization().getAttribute( + OAuth2AuthorizationRequest.class.getName()); + String nonce = (String) authorizationRequest.getAdditionalParameters().get(OidcParameterNames.NONCE); + assertThat(jwtClaimsSet.getClaim(IdTokenClaimNames.NONCE)).isEqualTo(nonce); - OAuth2AuthorizationRequest authorizationRequest = tokenContext.getAuthorization().getAttribute( - OAuth2AuthorizationRequest.class.getName()); - String nonce = (String) authorizationRequest.getAdditionalParameters().get(OidcParameterNames.NONCE); - assertThat(jwtClaimsSet.getClaim(IdTokenClaimNames.NONCE)).isEqualTo(nonce); + SessionInformation sessionInformation = tokenContext.get(SessionInformation.class); + assertThat(jwtClaimsSet.getClaim("sid")).isEqualTo(sessionInformation.getSessionId()); + assertThat(jwtClaimsSet.getClaim(IdTokenClaimNames.AUTH_TIME)).isEqualTo(sessionInformation.getLastRequest()); + } else if (tokenContext.getAuthorizationGrantType().equals(AuthorizationGrantType.REFRESH_TOKEN)) { + OidcIdToken currentIdToken = tokenContext.getAuthorization().getToken(OidcIdToken.class).getToken(); + assertThat(jwtClaimsSet.getClaim("sid")).isEqualTo(currentIdToken.getClaim("sid")); + assertThat(jwtClaimsSet.getClaim(IdTokenClaimNames.AUTH_TIME)).isEqualTo(currentIdToken.getClaim(IdTokenClaimNames.AUTH_TIME)); + } } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java index 417f4848d..9419de80c 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,7 +58,9 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -78,6 +80,7 @@ * @author Daniel Garnier-Moiroux * @author Anoop Garlapati * @author Dmitriy Dubson + * @author Greg Li * @since 0.0.1 */ public class OAuth2AuthorizationEndpointFilterTests { @@ -151,6 +154,13 @@ public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentExcep .hasMessage("authenticationFailureHandler cannot be null"); } + @Test + public void setSessionAuthenticationStrategyWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setSessionAuthenticationStrategy(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("sessionAuthenticationStrategy cannot be null"); + } + @Test public void doFilterWhenNotAuthorizationRequestThenNotProcessed() throws Exception { String requestUri = "/path"; @@ -170,7 +180,10 @@ public void doFilterWhenAuthorizationRequestMissingResponseTypeThenInvalidReques TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.RESPONSE_TYPE, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.removeParameter(OAuth2ParameterNames.RESPONSE_TYPE)); + request -> { + request.removeParameter(OAuth2ParameterNames.RESPONSE_TYPE); + updateQueryString(request); + }); } @Test @@ -179,7 +192,10 @@ public void doFilterWhenAuthorizationRequestMultipleResponseTypeThenInvalidReque TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.RESPONSE_TYPE, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); + request -> { + request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token"); + updateQueryString(request); + }); } @Test @@ -188,7 +204,10 @@ public void doFilterWhenAuthorizationRequestInvalidResponseTypeThenUnsupportedRe TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.RESPONSE_TYPE, OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, - request -> request.setParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); + request -> { + request.setParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token"); + updateQueryString(request); + }); } @Test @@ -197,7 +216,10 @@ public void doFilterWhenAuthorizationRequestMissingClientIdThenInvalidRequestErr TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.CLIENT_ID, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.removeParameter(OAuth2ParameterNames.CLIENT_ID)); + request -> { + request.removeParameter(OAuth2ParameterNames.CLIENT_ID); + updateQueryString(request); + }); } @Test @@ -206,7 +228,10 @@ public void doFilterWhenAuthorizationRequestMultipleClientIdThenInvalidRequestEr TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.CLIENT_ID, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2")); + request -> { + request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2"); + updateQueryString(request); + }); } @Test @@ -215,7 +240,10 @@ public void doFilterWhenAuthorizationRequestMultipleRedirectUriThenInvalidReques TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.REDIRECT_URI, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.REDIRECT_URI, "https://example2.com")); + request -> { + request.addParameter(OAuth2ParameterNames.REDIRECT_URI, "https://example2.com"); + updateQueryString(request); + }); } @Test @@ -224,7 +252,10 @@ public void doFilterWhenAuthorizationRequestMultipleScopeThenInvalidRequestError TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.SCOPE, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.SCOPE, "scope2")); + request -> { + request.addParameter(OAuth2ParameterNames.SCOPE, "scope2"); + updateQueryString(request); + }); } @Test @@ -233,7 +264,10 @@ public void doFilterWhenAuthorizationRequestMultipleStateThenInvalidRequestError TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.STATE, OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.STATE, "state2")); + request -> { + request.addParameter(OAuth2ParameterNames.STATE, "state2"); + updateQueryString(request); + }); } @Test @@ -263,6 +297,7 @@ public void doFilterWhenAuthorizationRequestMultipleCodeChallengeThenInvalidRequ request -> { request.addParameter(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); request.addParameter(PkceParameterNames.CODE_CHALLENGE, "another-code-challenge"); + updateQueryString(request); }); } @@ -275,17 +310,23 @@ public void doFilterWhenAuthorizationRequestMultipleCodeChallengeMethodThenInval request -> { request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + updateQueryString(request); }); } @Test public void doFilterWhenAuthorizationRequestAuthenticationExceptionThenErrorResponse() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUris(redirectUris -> { + redirectUris.clear(); + redirectUris.add("https://example.com?param=encoded%20parameter%20value"); + }) + .build(); OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes(), null); - OAuth2Error error = new OAuth2Error("errorCode", "errorDescription", "errorUri"); + registeredClient.getRedirectUris().iterator().next(), "client state", registeredClient.getScopes(), null); + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "error description", "error uri"); when(this.authenticationManager.authenticate(any())) .thenThrow(new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthentication)); @@ -299,7 +340,8 @@ public void doFilterWhenAuthorizationRequestAuthenticationExceptionThenErrorResp verifyNoInteractions(filterChain); assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); - assertThat(response.getRedirectedUrl()).isEqualTo("https://example.com?error=errorCode&error_description=errorDescription&error_uri=errorUri&state=state"); + assertThat(response.getRedirectedUrl()).isEqualTo( + "https://example.com?param=encoded%20parameter%20value&error=invalid_request&error_description=error%20description&error_uri=error%20uri&state=client%20state"); assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(this.principal); } @@ -381,6 +423,31 @@ public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exce verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), same(authenticationException)); } + @Test + public void doFilterWhenCustomSessionAuthenticationStrategyThenUsed() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + new OAuth2AuthorizationCodeRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), principal, this.authorizationCode, + registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes()); + authorizationCodeRequestAuthenticationResult.setAuthenticated(true); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + + SessionAuthenticationStrategy sessionAuthenticationStrategy = mock(SessionAuthenticationStrategy.class); + this.filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + verify(sessionAuthenticationStrategy).onAuthentication(same(authorizationCodeRequestAuthenticationResult), any(), any()); + } + @Test public void doFilterWhenCustomAuthenticationDetailsSourceThenUsed() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); @@ -535,16 +602,24 @@ public void doFilterWhenAuthorizationRequestConsentRequiredWithPreviouslyApprove @Test public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUris(redirectUris -> { + redirectUris.clear(); + redirectUris.add("https://example.com?param=encoded%20parameter%20value"); + }) + .build(); OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = new OAuth2AuthorizationCodeRequestAuthenticationToken( AUTHORIZATION_URI, registeredClient.getClientId(), principal, this.authorizationCode, - registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes()); + registeredClient.getRedirectUris().iterator().next(), "client state", registeredClient.getScopes()); authorizationCodeRequestAuthenticationResult.setAuthenticated(true); when(this.authenticationManager.authenticate(any())) .thenReturn(authorizationCodeRequestAuthenticationResult); MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + request.addParameter("custom-param", "custom-value-1", "custom-value-2"); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -559,8 +634,15 @@ public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationRespon .asInstanceOf(type(WebAuthenticationDetails.class)) .extracting(WebAuthenticationDetails::getRemoteAddress) .isEqualTo(REMOTE_ADDRESS); + + // Assert that multi-valued request parameters are preserved + assertThat(authorizationCodeRequestAuthenticationCaptor.getValue().getAdditionalParameters()) + .extracting(params -> params.get("custom-param")) + .asInstanceOf(type(String[].class)) + .isEqualTo(new String[] { "custom-value-1", "custom-value-2" }); assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); - assertThat(response.getRedirectedUrl()).isEqualTo("https://example.com?code=code&state=state"); + assertThat(response.getRedirectedUrl()).isEqualTo( + "https://example.com?param=encoded%20parameter%20value&code=code&state=client%20state"); } @Test @@ -582,6 +664,7 @@ public void doFilterWhenAuthenticationRequestAuthenticatedThenAuthorizationRespo MockHttpServletRequest request = createAuthorizationRequest(registeredClient); request.setMethod("POST"); // OpenID Connect supports POST method + request.setQueryString(null); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -591,7 +674,8 @@ public void doFilterWhenAuthenticationRequestAuthenticatedThenAuthorizationRespo verifyNoInteractions(filterChain); assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); - assertThat(response.getRedirectedUrl()).isEqualTo("https://example.com?code=code&state=state"); + assertThat(response.getRedirectedUrl()).isEqualTo( + request.getParameter(OAuth2ParameterNames.REDIRECT_URI) + "?code=code&state=state"); } private void doFilterWhenAuthorizationRequestInvalidParameterThenError(RegisteredClient registeredClient, @@ -635,6 +719,7 @@ private static MockHttpServletRequest createAuthorizationRequest(RegisteredClien request.addParameter(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); request.addParameter(OAuth2ParameterNames.STATE, "state"); + updateQueryString(request); return request; } @@ -652,6 +737,18 @@ private static MockHttpServletRequest createAuthorizationConsentRequest(Register return request; } + private static void updateQueryString(MockHttpServletRequest request) { + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(request.getRequestURI()); + request.getParameterMap().forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + uriBuilder.queryParam(key, value); + } + } + }); + request.setQueryString(uriBuilder.build().getQuery()); + } + private static String scopeCheckbox(String scope) { return MessageFormat.format( "", diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java index 1d5f6d9e9..5c0b09680 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,7 +87,7 @@ public void doFilterWhenAuthorizationServerMetadataRequestPostThenNotProcessed() @Test public void doFilterWhenAuthorizationServerMetadataRequestThenMetadataResponse() throws Exception { - String issuer = "https://example.com/issuer1"; + String issuer = "https://example.com"; String authorizationEndpoint = "/oauth2/v1/authorize"; String tokenEndpoint = "/oauth2/v1/token"; String jwkSetEndpoint = "/oauth2/v1/jwks"; @@ -116,16 +116,16 @@ public void doFilterWhenAuthorizationServerMetadataRequestThenMetadataResponse() assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE); String authorizationServerMetadataResponse = response.getContentAsString(); - assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com/issuer1\""); - assertThat(authorizationServerMetadataResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/v1/authorize\""); - assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/v1/token\""); + assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com\""); + assertThat(authorizationServerMetadataResponse).contains("\"authorization_endpoint\":\"https://example.com/oauth2/v1/authorize\""); + assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/v1/token\""); assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/v1/jwks\""); + assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/v1/jwks\""); assertThat(authorizationServerMetadataResponse).contains("\"response_types_supported\":[\"code\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint\":\"https://example.com/issuer1/oauth2/v1/revoke\""); + assertThat(authorizationServerMetadataResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\"]"); + assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\""); assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint\":\"https://example.com/issuer1/oauth2/v1/introspect\""); + assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\""); assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]"); assertThat(authorizationServerMetadataResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java new file mode 100644 index 000000000..7f8a54482 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java @@ -0,0 +1,425 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2DeviceAuthorizationEndpointFilter}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceAuthorizationEndpointFilterTests { + private static final String ISSUER_URI = "https://provider.com"; + private static final String REMOTE_ADDRESS = "remote-address"; + private static final String AUTHORIZATION_URI = "/oauth2/device_authorization"; + private static final String VERIFICATION_URI = "/oauth2/device_verification"; + private static final String CLIENT_ID = "client-1"; + private static final String DEVICE_CODE = "EfYu_0jEL"; + private static final String USER_CODE = "BCDF-GHJK"; + + private AuthenticationManager authenticationManager; + private OAuth2DeviceAuthorizationEndpointFilter filter; + + private final HttpMessageConverter deviceAuthorizationHttpResponseConverter = + new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + + @BeforeEach + public void setUp() { + this.authenticationManager = mock(AuthenticationManager.class); + this.filter = new OAuth2DeviceAuthorizationEndpointFilter(this.authenticationManager); + mockAuthorizationServerContext(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenAuthenticationMangerIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceAuthorizationEndpointFilter(null)) + .withMessage("authenticationManager cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenDeviceAuthorizationEndpointUriIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceAuthorizationEndpointFilter(this.authenticationManager, null)) + .withMessage("deviceAuthorizationEndpointUri cannot be empty"); + // @formatter:on + } + + @Test + public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationConverter(null)) + .withMessage("authenticationConverter cannot be null"); + // @formatter:on + } + + @Test + public void setAuthenticationDetailsSourceWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationDetailsSource(null)) + .withMessage("authenticationDetailsSource cannot be null"); + // @formatter:on + } + + @Test + public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null)) + .withMessage("authenticationSuccessHandler cannot be null"); + // @formatter:on + } + + @Test + public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null)) + .withMessage("authenticationFailureHandler cannot be null"); + // @formatter:on + } + + @Test + public void setVerificationUriWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setVerificationUri(null)) + .withMessage("verificationUri cannot be empty"); + // @formatter:on + } + + @Test + public void doFilterWhenNotDeviceAuthorizationRequestThenNotProcessed() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + verify(filterChain).doFilter(request, response); + verifyNoInteractions(this.authenticationManager); + } + + @Test + public void doFilterWhenDeviceAuthorizationRequestGetThenNotProcessed() throws Exception { + MockHttpServletRequest request = createRequest(); + request.setMethod(HttpMethod.GET.name()); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + verify(filterChain).doFilter(request, response); + verifyNoInteractions(this.authenticationManager); + } + + @Test + public void doFilterWhenDeviceAuthorizationRequestThenDeviceAuthorizationResponse() throws Exception { + Authentication authenticationResult = createAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + MockHttpServletRequest request = createRequest(); + request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + ArgumentCaptor deviceAuthorizationRequestAuthenticationCaptor = + ArgumentCaptor.forClass(OAuth2DeviceAuthorizationRequestAuthenticationToken.class); + verify(this.authenticationManager).authenticate(deviceAuthorizationRequestAuthenticationCaptor.capture()); + verifyNoInteractions(filterChain); + + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication = + deviceAuthorizationRequestAuthenticationCaptor.getValue(); + assertThat(deviceAuthorizationRequestAuthentication.getAuthorizationUri()).endsWith(AUTHORIZATION_URI); + assertThat(deviceAuthorizationRequestAuthentication.getPrincipal()).isEqualTo(clientPrincipal); + assertThat(deviceAuthorizationRequestAuthentication.getScopes()).isEmpty(); + assertThat(deviceAuthorizationRequestAuthentication.getAdditionalParameters()) + .containsExactly(entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" })); + // @formatter:off + assertThat(deviceAuthorizationRequestAuthentication.getDetails()) + .asInstanceOf(type(WebAuthenticationDetails.class)) + .extracting(WebAuthenticationDetails::getRemoteAddress) + .isEqualTo(REMOTE_ADDRESS); + // @formatter:on + + OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = readDeviceAuthorizationResponse(response); + String verificationUri = ISSUER_URI + VERIFICATION_URI; + assertThat(deviceAuthorizationResponse.getVerificationUri()).isEqualTo(verificationUri); + assertThat(deviceAuthorizationResponse.getVerificationUriComplete()) + .isEqualTo("%s?%s=%s".formatted(verificationUri, OAuth2ParameterNames.USER_CODE, USER_CODE)); + OAuth2DeviceCode deviceCode = deviceAuthorizationResponse.getDeviceCode(); + assertThat(deviceCode.getTokenValue()).isEqualTo(DEVICE_CODE); + assertThat(deviceCode.getExpiresAt()).isAfter(deviceCode.getIssuedAt()); + OAuth2UserCode userCode = deviceAuthorizationResponse.getUserCode(); + assertThat(userCode.getTokenValue()).isEqualTo(USER_CODE); + assertThat(deviceCode.getExpiresAt()).isAfter(deviceCode.getIssuedAt()); + } + + @Test + public void doFilterWhenInvalidRequestErrorThenBadRequest() throws Exception { + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + OAuth2AuthenticationException authenticationException = new OAuth2AuthenticationException( + new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "Invalid request", "error-uri")); + when(authenticationConverter.convert(any(HttpServletRequest.class))).thenThrow(authenticationException); + this.filter.setAuthenticationConverter(authenticationConverter); + + MockHttpServletRequest request = createRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + + verify(authenticationConverter).convert(request); + verifyNoInteractions(filterChain, this.authenticationManager); + + OAuth2Error error = readError(response); + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + assertThat(error.getDescription()).isEqualTo("Invalid request"); + assertThat(error.getUri()).isEqualTo("error-uri"); + } + + @Test + public void doFilterWhenCustomDeviceAuthorizationEndpointUriThenUsed() throws Exception { + Authentication authenticationResult = createAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + MockHttpServletRequest request = createRequest(); + request.setRequestURI("/device"); + request.setServletPath("/device"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter = new OAuth2DeviceAuthorizationEndpointFilter(this.authenticationManager, "/device"); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationConverterSetThenUsed() throws Exception { + Authentication authenticationResult = createAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationRequest = + new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI, null, null); + when(authenticationConverter.convert(any(HttpServletRequest.class))).thenReturn(authenticationRequest); + this.filter.setAuthenticationConverter(authenticationConverter); + + MockHttpServletRequest request = createRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(authenticationConverter).convert(request); + verify(this.authenticationManager).authenticate(authenticationRequest); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationDetailsSourceSetThenUsed() throws Exception { + Authentication authenticationResult = createAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + MockHttpServletRequest request = createRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + @SuppressWarnings("unchecked") + AuthenticationDetailsSource authenticationDetailsSource = mock(AuthenticationDetailsSource.class); + when(authenticationDetailsSource.buildDetails(any(HttpServletRequest.class))).thenReturn(new WebAuthenticationDetails(request)); + this.filter.setAuthenticationDetailsSource(authenticationDetailsSource); + + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(authenticationDetailsSource).buildDetails(request); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationSuccessHandlerSetThenUsed() throws Exception { + Authentication authenticationResult = createAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class); + this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler); + + MockHttpServletRequest request = createRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(authenticationSuccessHandler).onAuthenticationSuccess(request, response, authenticationResult); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationFailureHandlerSetThenUsed() throws Exception { + OAuth2AuthenticationException authenticationException = + new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenThrow(authenticationException); + + Authentication clientPrincipal = (Authentication) createAuthentication().getPrincipal(); + mockSecurityContext(clientPrincipal); + + AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class); + this.filter.setAuthenticationFailureHandler(authenticationFailureHandler); + + MockHttpServletRequest request = createRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(authenticationFailureHandler).onAuthenticationFailure(request, response, authenticationException); + verifyNoInteractions(filterChain); + } + + private OAuth2DeviceAuthorizationResponse readDeviceAuthorizationResponse(MockHttpServletResponse response) throws IOException { + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus())); + return this.deviceAuthorizationHttpResponseConverter.read(OAuth2DeviceAuthorizationResponse.class, httpResponse); + } + + private OAuth2Error readError(MockHttpServletResponse response) throws IOException { + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus())); + return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse); + } + + private static void mockAuthorizationServerContext() { + AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build(); + TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext( + authorizationServerSettings, () -> ISSUER_URI); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + } + + private static void mockSecurityContext(Authentication clientPrincipal) { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(clientPrincipal); + SecurityContextHolder.setContext(securityContext); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.POST.name()); + request.setRequestURI(AUTHORIZATION_URI); + request.setServletPath(AUTHORIZATION_URI); + request.setRemoteAddr(REMOTE_ADDRESS); + return request; + } + + private static OAuth2DeviceAuthorizationRequestAuthenticationToken createAuthentication() { + TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(CLIENT_ID, null); + return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, null, createDeviceCode(), + createUserCode()); + } + + private static OAuth2DeviceCode createDeviceCode() { + Instant issuedAt = Instant.now(); + return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } + + private static OAuth2UserCode createUserCode() { + Instant issuedAt = Instant.now(); + return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES)); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java new file mode 100644 index 000000000..524112b5d --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java @@ -0,0 +1,483 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web; + +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.web.util.UriComponentsBuilder; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2DeviceVerificationEndpointFilter}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceVerificationEndpointFilterTests { + private static final String ISSUER_URI = "https://provider.com"; + private static final String REMOTE_ADDRESS = "remote-address"; + private static final String AUTHORIZATION_URI = "/oauth2/device_authorization"; + private static final String VERIFICATION_URI = "/oauth2/device_verification"; + private static final String CLIENT_ID = "client-1"; + private static final String STATE = "12345"; + private static final String USER_CODE = "BCDF-GHJK"; + + private AuthenticationManager authenticationManager; + private OAuth2DeviceVerificationEndpointFilter filter; + + @BeforeEach + public void setUp() { + this.authenticationManager = mock(AuthenticationManager.class); + this.filter = new OAuth2DeviceVerificationEndpointFilter(this.authenticationManager); + mockAuthorizationServerContext(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenAuthenticationManagerIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceVerificationEndpointFilter(null)) + .withMessage("authenticationManager cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenDeviceVerificationEndpointUriIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2DeviceVerificationEndpointFilter(this.authenticationManager, null)) + .withMessage("deviceVerificationEndpointUri cannot be empty"); + // @formatter:on + } + + @Test + public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationConverter(null)) + .withMessage("authenticationConverter cannot be null"); + // @formatter:on + } + + @Test + public void setAuthenticationDetailsSourceWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationDetailsSource(null)) + .withMessage("authenticationDetailsSource cannot be null"); + // @formatter:on + } + + @Test + public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null)) + .withMessage("authenticationSuccessHandler cannot be null"); + // @formatter:on + } + + @Test + public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null)) + .withMessage("authenticationFailureHandler cannot be null"); + // @formatter:on + } + + @Test + public void doFilterWhenNotDeviceVerificationRequestThenNotProcessed() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + verify(filterChain).doFilter(request, response); + verifyNoInteractions(this.authenticationManager); + } + + @Test + public void doFilterWhenUnauthenticatedThenPassThrough() throws Exception { + TestingAuthenticationToken unauthenticatedResult = new TestingAuthenticationToken("user", null); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(unauthenticatedResult); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(filterChain).doFilter(request, response); + } + + @Test + public void doFilterWhenDeviceAuthorizationConsentRequestThenSuccess() throws Exception { + Authentication authenticationResult = createDeviceVerificationAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + MockHttpServletRequest request = createRequest(); + request.setMethod(HttpMethod.POST.name()); + request.addParameter(OAuth2ParameterNames.SCOPE, "scope-1"); + request.addParameter(OAuth2ParameterNames.SCOPE, "scope-2"); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success"); + + ArgumentCaptor authenticationCaptor = + ArgumentCaptor.forClass(OAuth2DeviceAuthorizationConsentAuthenticationToken.class); + verify(this.authenticationManager).authenticate(authenticationCaptor.capture()); + verifyNoInteractions(filterChain); + + OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication = + authenticationCaptor.getValue(); + assertThat(deviceAuthorizationConsentAuthentication.getAuthorizationUri()).endsWith(VERIFICATION_URI); + assertThat(deviceAuthorizationConsentAuthentication.getClientId()).isEqualTo(CLIENT_ID); + assertThat(deviceAuthorizationConsentAuthentication.getPrincipal()) + .isInstanceOf(TestingAuthenticationToken.class); + assertThat(deviceAuthorizationConsentAuthentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(deviceAuthorizationConsentAuthentication.getScopes()).containsExactly("scope-1", "scope-2"); + assertThat(deviceAuthorizationConsentAuthentication.getAdditionalParameters()) + .containsExactly(entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" })); + } + + @Test + public void doFilterWhenDeviceVerificationRequestAndConsentNotRequiredThenSuccess() throws Exception { + Authentication authenticationResult = createDeviceVerificationAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal(); + mockSecurityContext(clientPrincipal); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + request.addParameter("custom-param-1", "custom-value-1"); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success"); + + ArgumentCaptor authenticationCaptor = + ArgumentCaptor.forClass(OAuth2DeviceVerificationAuthenticationToken.class); + verify(this.authenticationManager).authenticate(authenticationCaptor.capture()); + verifyNoInteractions(filterChain); + + OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication = authenticationCaptor.getValue(); + assertThat(deviceVerificationAuthentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(deviceVerificationAuthentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(deviceVerificationAuthentication.getAdditionalParameters()) + .containsExactly(entry("custom-param-1", "custom-value-1")); + } + + @Test + public void doFilterWhenDeviceVerificationRequestAndConsentRequiredThenConsentScreen() throws Exception { + Authentication authenticationResult = createDeviceAuthorizationConsentAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getContentType()) + .isEqualTo(new MediaType("text", "html", StandardCharsets.UTF_8).toString()); + assertThat(response.getContentAsString()).contains(scopeCheckbox("scope-1")); + assertThat(response.getContentAsString()).contains(scopeCheckbox("scope-2")); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenDeviceVerificationRequestAndConsentRequiredWithPreviouslyApprovedThenConsentScreen() throws Exception { + Authentication authenticationResult = createDeviceAuthorizationConsentAuthenticationWithAuthorizedScopes(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getContentType()) + .isEqualTo(new MediaType("text", "html", StandardCharsets.UTF_8).toString()); + assertThat(response.getContentAsString()).contains(disabledScopeCheckbox("scope-1")); + assertThat(response.getContentAsString()).contains(scopeCheckbox("scope-2")); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenDeviceVerificationRequestAndConsentRequiredAndConsentPageSetThenRedirect() throws Exception { + Authentication authentication = createDeviceAuthorizationConsentAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authentication); + + MockHttpServletRequest request = createRequest(); + request.setScheme("https"); + request.setServerPort(443); + request.setServerName("provider.com"); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.setConsentPage("/consent"); + this.filter.doFilter(request, response, filterChain); + String redirectUri = UriComponentsBuilder.fromUriString("https://provider.com/consent") + .queryParam(OAuth2ParameterNames.SCOPE, "scope-1 scope-2") + .queryParam(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID) + .queryParam(OAuth2ParameterNames.STATE, STATE) + .queryParam(OAuth2ParameterNames.USER_CODE, USER_CODE) + .toUriString(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(redirectUri); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationConverterSetThenUsed() throws Exception { + Authentication authenticationResult = createDeviceVerificationAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication = + new OAuth2DeviceVerificationAuthenticationToken((Authentication) authenticationResult.getPrincipal(), + USER_CODE, Collections.emptyMap()); + when(authenticationConverter.convert(any(HttpServletRequest.class))).thenReturn(deviceVerificationAuthentication); + this.filter.setAuthenticationConverter(authenticationConverter); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success"); + + verify(authenticationConverter).convert(request); + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationDetailsSourceSetThenUsed() throws Exception { + Authentication authenticationResult = createDeviceVerificationAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + @SuppressWarnings("unchecked") + AuthenticationDetailsSource authenticationDetailsSource = mock(AuthenticationDetailsSource.class); + when(authenticationDetailsSource.buildDetails(any(HttpServletRequest.class))).thenReturn(new WebAuthenticationDetails(request)); + this.filter.setAuthenticationDetailsSource(authenticationDetailsSource); + + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success"); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(authenticationDetailsSource).buildDetails(request); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationSuccessHandlerSetThenUsed() throws Exception { + Authentication authenticationResult = createDeviceVerificationAuthentication(); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult); + + AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class); + this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(authenticationSuccessHandler).onAuthenticationSuccess(request, response, authenticationResult); + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenAuthenticationFailureHandlerSetThenUsed() throws Exception { + OAuth2AuthenticationException authenticationException = + new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + when(this.authenticationManager.authenticate(any(Authentication.class))).thenThrow(authenticationException); + + AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class); + this.filter.setAuthenticationFailureHandler(authenticationFailureHandler); + + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + updateQueryString(request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + this.filter.doFilter(request, response, filterChain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + + verify(this.authenticationManager).authenticate(any(Authentication.class)); + verify(authenticationFailureHandler).onAuthenticationFailure(request, response, authenticationException); + verifyNoInteractions(filterChain); + } + + private static void mockAuthorizationServerContext() { + AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build(); + TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext( + authorizationServerSettings, () -> ISSUER_URI); + AuthorizationServerContextHolder.setContext(authorizationServerContext); + } + + private static void mockSecurityContext(Authentication clientPrincipal) { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(clientPrincipal); + SecurityContextHolder.setContext(securityContext); + } + + private static OAuth2DeviceVerificationAuthenticationToken createDeviceVerificationAuthentication() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null); + return new OAuth2DeviceVerificationAuthenticationToken(principal, CLIENT_ID, USER_CODE); + } + + private static Authentication createDeviceAuthorizationConsentAuthentication() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null); + Set requestedScopes = new HashSet<>(); + requestedScopes.add("scope-1"); + requestedScopes.add("scope-2"); + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, CLIENT_ID, principal, + USER_CODE, STATE, requestedScopes, new HashSet<>()); + } + + private static Authentication createDeviceAuthorizationConsentAuthenticationWithAuthorizedScopes() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null); + Set requestedScopes = new HashSet<>(); + requestedScopes.add("scope-1"); + requestedScopes.add("scope-2"); + Set authorizedScopes = new HashSet<>(); + authorizedScopes.add("scope-1"); + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, CLIENT_ID, principal, + USER_CODE, STATE, requestedScopes, authorizedScopes); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.GET.name()); + request.setRequestURI(VERIFICATION_URI); + request.setServletPath(VERIFICATION_URI); + request.setRemoteAddr(REMOTE_ADDRESS); + return request; + } + + private static void updateQueryString(MockHttpServletRequest request) { + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(request.getRequestURI()); + request.getParameterMap().forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + uriBuilder.queryParam(key, value); + } + } + }); + request.setQueryString(uriBuilder.build().getQuery()); + } + + private static String scopeCheckbox(String scope) { + return MessageFormat.format( + "", + scope + ); + } + + private static String disabledScopeCheckbox(String scope) { + return MessageFormat.format( + "", + scope + ); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java index 5f1de9fff..824b9f10d 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,6 @@ import java.time.Duration; import java.time.Instant; import java.util.Arrays; -import java.util.Collections; import java.util.HashSet; import java.util.Map; @@ -242,10 +241,9 @@ public void doFilterWhenAuthorizationCodeTokenRequestThenAccessTokenResponse() t new HashSet<>(Arrays.asList("scope1", "scope2"))); OAuth2RefreshToken refreshToken = new OAuth2RefreshToken( "refresh-token", Instant.now(), Instant.now().plus(Duration.ofDays(1))); - Map additionalParameters = Collections.singletonMap("custom-param", "custom-value"); OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = new OAuth2AccessTokenAuthenticationToken( - registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters); + registeredClient, clientPrincipal, accessToken, refreshToken); when(this.authenticationManager.authenticate(any())).thenReturn(accessTokenAuthentication); @@ -273,7 +271,8 @@ public void doFilterWhenAuthorizationCodeTokenRequestThenAccessTokenResponse() t assertThat(authorizationCodeAuthentication.getRedirectUri()).isEqualTo( request.getParameter(OAuth2ParameterNames.REDIRECT_URI)); assertThat(authorizationCodeAuthentication.getAdditionalParameters()) - .containsExactly(entry("custom-param-1", "custom-value-1")); + .containsExactly(entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" })); assertThat(authorizationCodeAuthentication.getDetails()) .asInstanceOf(type(WebAuthenticationDetails.class)) .extracting(WebAuthenticationDetails::getRemoteAddress) @@ -291,7 +290,6 @@ public void doFilterWhenAuthorizationCodeTokenRequestThenAccessTokenResponse() t accessToken.getExpiresAt().minusSeconds(1), accessToken.getExpiresAt().plusSeconds(1)); assertThat(accessTokenResult.getScopes()).isEqualTo(accessToken.getScopes()); assertThat(accessTokenResponse.getRefreshToken().getTokenValue()).isEqualTo(refreshToken.getTokenValue()); - assertThat(accessTokenResponse.getAdditionalParameters()).containsExactly(entry("custom-param", "custom-value")); } @Test @@ -340,7 +338,8 @@ public void doFilterWhenClientCredentialsTokenRequestThenAccessTokenResponse() t assertThat(clientCredentialsAuthentication.getPrincipal()).isEqualTo(clientPrincipal); assertThat(clientCredentialsAuthentication.getScopes()).isEqualTo(registeredClient.getScopes()); assertThat(clientCredentialsAuthentication.getAdditionalParameters()) - .containsExactly(entry("custom-param-1", "custom-value-1")); + .containsExactly(entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" })); assertThat(clientCredentialsAuthentication.getDetails()) .asInstanceOf(type(WebAuthenticationDetails.class)) .extracting(WebAuthenticationDetails::getRemoteAddress) @@ -430,7 +429,8 @@ public void doFilterWhenRefreshTokenRequestThenAccessTokenResponse() throws Exce assertThat(refreshTokenAuthenticationToken.getPrincipal()).isEqualTo(clientPrincipal); assertThat(refreshTokenAuthenticationToken.getScopes()).isEqualTo(registeredClient.getScopes()); assertThat(refreshTokenAuthenticationToken.getAdditionalParameters()) - .containsExactly(entry("custom-param-1", "custom-value-1")); + .containsExactly(entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" })); assertThat(refreshTokenAuthenticationToken.getDetails()) .asInstanceOf(type(WebAuthenticationDetails.class)) .extracting(WebAuthenticationDetails::getRemoteAddress) @@ -613,6 +613,7 @@ private static MockHttpServletRequest createAuthorizationCodeTokenRequest(Regist // The client does not need to send the client ID param, but we are resilient in case they do request.addParameter(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); return request; } @@ -627,6 +628,7 @@ private static MockHttpServletRequest createClientCredentialsTokenRequest(Regist request.addParameter(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); return request; } @@ -642,6 +644,7 @@ private static MockHttpServletRequest createRefreshTokenTokenRequest(RegisteredC request.addParameter(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); return request; } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java index cb5d2382e..41a67755e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -219,7 +219,7 @@ public void doFilterWhenTokenIntrospectionRequestValidThenSuccessResponse() thro MockHttpServletRequest request = createTokenIntrospectionRequest( accessToken.getTokenValue(), OAuth2TokenType.ACCESS_TOKEN.getValue()); request.addParameter("custom-param-1", "custom-value-1"); - request.addParameter("custom-param-2", "custom-value-2"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); @@ -236,7 +236,7 @@ public void doFilterWhenTokenIntrospectionRequestValidThenSuccessResponse() thro assertThat(tokenIntrospectionAuthentication.getValue().getAdditionalParameters()) .contains( entry("custom-param-1", "custom-value-1"), - entry("custom-param-2", "custom-value-2")); + entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"})); OAuth2TokenIntrospection tokenIntrospectionResponse = readTokenIntrospectionResponse(response); assertThat(tokenIntrospectionResponse.isActive()).isEqualTo(tokenClaims.isActive()); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretBasicAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretBasicAuthenticationConverterTests.java index 17a52f61d..7b5a222c1 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretBasicAuthenticationConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretBasicAuthenticationConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -106,6 +106,7 @@ public void convertWhenAuthorizationHeaderBasicWithValidCredentialsThenReturnCli @Test public void convertWhenConfidentialClientWithPkceParametersThenAdditionalParametersIncluded() throws Exception { MockHttpServletRequest request = createPkceTokenRequest(); + request.addParameter("custom-param", "custom-value-1", "custom-value-2"); request.addHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth("clientId", "secret")); OAuth2ClientAuthenticationToken authentication = (OAuth2ClientAuthenticationToken) this.converter.convert(request); assertThat(authentication.getPrincipal()).isEqualTo("clientId"); @@ -115,7 +116,8 @@ public void convertWhenConfidentialClientWithPkceParametersThenAdditionalParamet .containsOnly( entry(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), entry(OAuth2ParameterNames.CODE, "code"), - entry(PkceParameterNames.CODE_VERIFIER, "code-verifier-1")); + entry(PkceParameterNames.CODE_VERIFIER, "code-verifier-1"), + entry("custom-param", new String[] { "custom-value-1", "custom-value-2" })); } private static String encodeBasicAuth(String clientId, String secret) throws Exception { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverterTests.java index 5f0a96d93..894fb409e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/ClientSecretPostAuthenticationConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -95,6 +95,7 @@ public void convertWhenConfidentialClientWithPkceParametersThenAdditionalParamet MockHttpServletRequest request = createPkceTokenRequest(); request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-1"); request.addParameter(OAuth2ParameterNames.CLIENT_SECRET, "client-secret"); + request.addParameter("custom-param", "custom-value-1", "custom-value-2"); OAuth2ClientAuthenticationToken authentication = (OAuth2ClientAuthenticationToken) this.converter.convert(request); assertThat(authentication.getPrincipal()).isEqualTo("client-1"); assertThat(authentication.getCredentials()).isEqualTo("client-secret"); @@ -103,7 +104,8 @@ public void convertWhenConfidentialClientWithPkceParametersThenAdditionalParamet .containsOnly( entry(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), entry(OAuth2ParameterNames.CODE, "code"), - entry(PkceParameterNames.CODE_VERIFIER, "code-verifier-1")); + entry(PkceParameterNames.CODE_VERIFIER, "code-verifier-1"), + entry("custom-param", new String[] { "custom-value-1", "custom-value-2" })); } private static MockHttpServletRequest createPkceTokenRequest() { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverterTests.java index 38cec8633..13fb64884 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/JwtClientAssertionAuthenticationConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -107,6 +107,8 @@ public void convertWhenJwtAssertionThenReturnClientAuthenticationToken() { request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-1"); request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); request.addParameter(OAuth2ParameterNames.CODE, "code"); + request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); OAuth2ClientAuthenticationToken authentication = (OAuth2ClientAuthenticationToken) this.converter.convert(request); assertThat(authentication.getPrincipal()).isEqualTo("client-1"); assertThat(authentication.getCredentials()).isEqualTo("jwt-assertion"); @@ -114,7 +116,9 @@ public void convertWhenJwtAssertionThenReturnClientAuthenticationToken() { assertThat(authentication.getAdditionalParameters()) .containsOnly( entry(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), - entry(OAuth2ParameterNames.CODE, "code")); + entry(OAuth2ParameterNames.CODE, "code"), + entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"})); } private void assertThrown(MockHttpServletRequest request, String errorCode) { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java new file mode 100644 index 000000000..dfa5ed682 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java @@ -0,0 +1,312 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link OAuth2DeviceAuthorizationConsentAuthenticationConverter}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceAuthorizationConsentAuthenticationConverterTests { + private static final String VERIFICATION_URI = "/oauth2/device_verification"; + private static final String USER_CODE = "BCDF-GHJK"; + private static final String CLIENT_ID = "client-1"; + private static final String STATE = "abc123"; + + private OAuth2DeviceAuthorizationConsentAuthenticationConverter converter; + + @BeforeEach + public void setUp() { + this.converter = new OAuth2DeviceAuthorizationConsentAuthenticationConverter(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void convertWhenGetThenReturnNull() { + MockHttpServletRequest request = createRequest(); + request.setMethod(HttpMethod.GET.name()); + assertThat(this.converter.convert(request)).isNull(); + } + + @Test + public void convertWhenMissingStateThenReturnNull() { + MockHttpServletRequest request = createRequest(); + assertThat(this.converter.convert(request)).isNull(); + } + + @Test + public void convertWhenMissingClientIdThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.CLIENT_ID) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenEmptyClientIdThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, ""); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.CLIENT_ID) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleClientIdParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, "another"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.CLIENT_ID) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMissingUserCodeThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenEmptyUserCodeThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, ""); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenInvalidUserCodeThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, "LONG-USER-CODE"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleUserCodeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + request.addParameter(OAuth2ParameterNames.USER_CODE, "another"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenEmptyStateParameterThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, ""); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.STATE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleStateParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.STATE, "another"); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.STATE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMissingPrincipalThenReturnDeviceAuthorizationConsentAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + + OAuth2DeviceAuthorizationConsentAuthenticationToken authentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI); + assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID); + assertThat(authentication.getPrincipal()).isInstanceOf(AnonymousAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getScopes()).isEmpty(); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + } + + @Test + public void convertWhenMissingScopeThenReturnDeviceAuthorizationConsentAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken("user", null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceAuthorizationConsentAuthenticationToken authentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI); + assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getScopes()).isEmpty(); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + } + + @Test + public void convertWhenAllParametersThenReturnDeviceAuthorizationConsentAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + request.addParameter(OAuth2ParameterNames.SCOPE, "message.read"); + request.addParameter(OAuth2ParameterNames.SCOPE, "message.write"); + request.addParameter("param-1", "value-1"); + request.addParameter("param-2", "value-1", "value-2"); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken("user", null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceAuthorizationConsentAuthenticationToken authentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI); + assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getScopes()).containsExactly("message.read", "message.write"); + assertThat(authentication.getAdditionalParameters()) + .containsExactly(entry("param-1", "value-1"), + entry("param-2", new String[] {"value-1", "value-2"})); + } + + @Test + public void convertWhenNonNormalizedUserCodeThenReturnDeviceAuthorizationConsentAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.STATE, STATE); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . ")); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken("user", null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceAuthorizationConsentAuthenticationToken authentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI); + assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getScopes()).isEmpty(); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.POST.name()); + request.setRequestURI(VERIFICATION_URI); + return request; + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java new file mode 100644 index 000000000..220331eed --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link OAuth2DeviceAuthorizationRequestAuthenticationConverter}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceAuthorizationRequestAuthenticationConverterTests { + private static final String AUTHORIZATION_URI = "/oauth2/device_authorization"; + private static final String CLIENT_ID = "client-1"; + + private OAuth2DeviceAuthorizationRequestAuthenticationConverter converter; + + @BeforeEach + public void setUp() { + this.converter = new OAuth2DeviceAuthorizationRequestAuthenticationConverter(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void convertWhenMultipleScopeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.SCOPE, "message.read"); + request.addParameter(OAuth2ParameterNames.SCOPE, "message.write"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.SCOPE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMissingScopeThenReturnDeviceAuthorizationRequestAuthenticationToken() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceAuthorizationRequestAuthenticationToken authentication = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getAuthorizationUri()).endsWith(AUTHORIZATION_URI); + assertThat(authentication.getScopes()).isEmpty(); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + } + + @Test + public void convertWhenAllParametersThenReturnDeviceAuthorizationRequestAuthenticationToken() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.SCOPE, "message.read message.write"); + request.addParameter("param-1", "value-1"); + request.addParameter("param-2", "value-1", "value-2"); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceAuthorizationRequestAuthenticationToken authentication = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getAuthorizationUri()).endsWith(AUTHORIZATION_URI); + assertThat(authentication.getScopes()).containsExactly("message.read", "message.write"); + assertThat(authentication.getAdditionalParameters()) + .containsExactly(entry("param-1", "value-1"), + entry("param-2", new String[] {"value-1", "value-2"})); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.POST.name()); + request.setRequestURI(AUTHORIZATION_URI); + return request; + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java new file mode 100644 index 000000000..f1a52138a --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationToken; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link OAuth2DeviceCodeAuthenticationConverter}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceCodeAuthenticationConverterTests { + private static final String CLIENT_ID = "client-1"; + private static final String TOKEN_URI = "/oauth2/token"; + private static final String DEVICE_CODE = "EfYu_0jEL"; + + private OAuth2DeviceCodeAuthenticationConverter converter; + + @BeforeEach + public void setUp() { + this.converter = new OAuth2DeviceCodeAuthenticationConverter(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void convertWhenMissingGrantTypeThenReturnNull() { + MockHttpServletRequest request = createRequest(); + Authentication authentication = this.converter.convert(request); + assertThat(authentication).isNull(); + } + + @Test + public void convertWhenMissingDeviceCodeThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue()); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.DEVICE_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleDeviceCodeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue()); + request.addParameter(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE); + request.addParameter(OAuth2ParameterNames.DEVICE_CODE, "another"); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.DEVICE_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenAllParametersThenReturnDeviceCodeAuthenticationToken() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue()); + request.addParameter(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE); + request.addParameter("param-1", "value-1"); + request.addParameter("param-2", "value-1", "value-2"); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceCodeAuthenticationToken authentication = + (OAuth2DeviceCodeAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getDeviceCode()).isEqualTo(DEVICE_CODE); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getAdditionalParameters()) + .containsExactly(entry("param-1", "value-1"), + entry("param-2", new String[] {"value-1", "value-2"})); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.POST.name()); + request.setRequestURI(TOKEN_URI); + return request; + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java new file mode 100644 index 000000000..0b8ba1ef1 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java @@ -0,0 +1,204 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.web.authentication; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.web.util.UriComponentsBuilder; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link OAuth2DeviceVerificationAuthenticationConverter}. + * + * @author Steve Riesenberg + */ +public class OAuth2DeviceVerificationAuthenticationConverterTests { + private static final String VERIFICATION_URI = "/oauth2/device_verification"; + private static final String USER_CODE = "BCDF-GHJK"; + + private OAuth2DeviceVerificationAuthenticationConverter converter; + + @BeforeEach + public void setUp() { + this.converter = new OAuth2DeviceVerificationAuthenticationConverter(); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void convertWhenPutThenReturnNull() { + MockHttpServletRequest request = createRequest(); + request.setMethod(HttpMethod.PUT.name()); + Authentication authentication = this.converter.convert(request); + assertThat(authentication).isNull(); + } + + @Test + public void convertWhenStateThenReturnNull() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.STATE, "abc123"); + updateQueryString(request); + Authentication authentication = this.converter.convert(request); + assertThat(authentication).isNull(); + } + + @Test + public void convertWhenMissingUserCodeThenReturnNull() { + MockHttpServletRequest request = createRequest(); + Authentication authentication = this.converter.convert(request); + assertThat(authentication).isNull(); + } + + @Test + public void convertWhenEmptyUserCodeParameterThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, ""); + updateQueryString(request); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenInvalidUserCodeParameterThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, "LONG-USER-CODE"); + updateQueryString(request); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMultipleUserCodeParameterThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + request.addParameter(OAuth2ParameterNames.USER_CODE, "another"); + updateQueryString(request); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.converter.convert(request)) + .withMessageContaining(OAuth2ParameterNames.USER_CODE) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); + // @formatter:on + } + + @Test + public void convertWhenMissingPrincipalThenReturnDeviceVerificationAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . ")); + updateQueryString(request); + + OAuth2DeviceVerificationAuthenticationToken authentication = + (OAuth2DeviceVerificationAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isInstanceOf(AnonymousAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + } + + @Test + public void convertWhenNonNormalizedUserCodeThenReturnDeviceVerificationAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . ")); + updateQueryString(request); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken("user", null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceVerificationAuthenticationToken authentication = + (OAuth2DeviceVerificationAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + } + + @Test + public void convertWhenAllParametersThenReturnDeviceVerificationAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); + request.addParameter("param-1", "value-1"); + request.addParameter("param-2", "value-1", "value-2"); + updateQueryString(request); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken("user", null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2DeviceVerificationAuthenticationToken authentication = + (OAuth2DeviceVerificationAuthenticationToken) this.converter.convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class); + assertThat(authentication.getUserCode()).isEqualTo(USER_CODE); + assertThat(authentication.getAdditionalParameters()) + .containsExactly(entry("param-1", "value-1"), + entry("param-2", new String[] {"value-1", "value-2"})); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.GET.name()); + request.setRequestURI(VERIFICATION_URI); + return request; + } + + private static void updateQueryString(MockHttpServletRequest request) { + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(request.getRequestURI()); + request.getParameterMap().forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + uriBuilder.queryParam(key, value); + } + } + }); + request.setQueryString(uriBuilder.build().getQuery()); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverterTests.java index 5518b575d..0278e597a 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/PublicClientAuthenticationConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,6 +82,8 @@ public void convertWhenMultipleCodeVerifierThenInvalidRequestError() { @Test public void convertWhenPublicClientThenReturnClientAuthenticationToken() { MockHttpServletRequest request = createPkceTokenRequest(); + request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); OAuth2ClientAuthenticationToken authentication = (OAuth2ClientAuthenticationToken) this.converter.convert(request); assertThat(authentication.getPrincipal()).isEqualTo("client-1"); assertThat(authentication.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.NONE); @@ -89,7 +91,9 @@ public void convertWhenPublicClientThenReturnClientAuthenticationToken() { .containsOnly( entry(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), entry(OAuth2ParameterNames.CODE, "code"), - entry(PkceParameterNames.CODE_VERIFIER, "code-verifier-1")); + entry(PkceParameterNames.CODE_VERIFIER, "code-verifier-1"), + entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"})); } private static MockHttpServletRequest createPkceTokenRequest() { diff --git a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql index 64d3a4872..5eb82db39 100644 --- a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql +++ b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql @@ -8,6 +8,7 @@ CREATE TABLE oauth2RegisteredClient ( clientAuthenticationMethods varchar(1000) NOT NULL, authorizationGrantTypes varchar(1000) NOT NULL, redirectUris varchar(1000) DEFAULT NULL, + postLogoutRedirectUris varchar(1000) DEFAULT NULL, scopes varchar(1000) NOT NULL, clientSettings varchar(2000) NOT NULL, tokenSettings varchar(2000) NOT NULL, diff --git a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql index 3161d3021..c32430dce 100644 --- a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql +++ b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql @@ -24,5 +24,13 @@ CREATE TABLE oauth2_authorization ( refresh_token_issued_at timestamp DEFAULT NULL, refresh_token_expires_at timestamp DEFAULT NULL, refresh_token_metadata varchar(2000) DEFAULT NULL, + user_code_value clob DEFAULT NULL, + user_code_issued_at timestamp DEFAULT NULL, + user_code_expires_at timestamp DEFAULT NULL, + user_code_metadata varchar(2000) DEFAULT NULL, + device_code_value clob DEFAULT NULL, + device_code_issued_at timestamp DEFAULT NULL, + device_code_expires_at timestamp DEFAULT NULL, + device_code_metadata varchar(2000) DEFAULT NULL, PRIMARY KEY (id) ); diff --git a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql index a704b4609..462240dbb 100644 --- a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql +++ b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql @@ -24,5 +24,13 @@ CREATE TABLE oauth2Authorization ( refreshTokenIssuedAt timestamp DEFAULT NULL, refreshTokenExpiresAt timestamp DEFAULT NULL, refreshTokenMetadata varchar(2000) DEFAULT NULL, + userCodeValue varchar(1000) DEFAULT NULL, + userCodeIssuedAt timestamp DEFAULT NULL, + userCodeExpiresAt timestamp DEFAULT NULL, + userCodeMetadata varchar(2000) DEFAULT NULL, + deviceCodeValue varchar(1000) DEFAULT NULL, + deviceCodeIssuedAt timestamp DEFAULT NULL, + deviceCodeExpiresAt timestamp DEFAULT NULL, + deviceCodeMetadata varchar(2000) DEFAULT NULL, PRIMARY KEY (id) ); diff --git a/samples/README.adoc b/samples/README.adoc index bd0bc87ce..3378a6232 100644 --- a/samples/README.adoc +++ b/samples/README.adoc @@ -1,32 +1,36 @@ = Samples -[[messages-sample]] -== Messages Sample +[[default-sample]] +== Default Sample -The messages sample integrates `spring-security-oauth2-client` and `spring-security-oauth2-resource-server` with *Spring Authorization Server*. +The default sample provides the minimal configuration to get started with Spring Authorization Server. -The username is `user1` and the password is `password`. +[[demo-sample]] +== Demo Sample -[[run-messages-sample]] +The demo sample provides custom configuration for various features implemented by Spring Authorization Server. + +[[run-demo-sample]] === Run the Sample -* Run Authorization Server -> `./gradlew -b samples/default-authorizationserver/samples-default-authorizationserver.gradle bootRun` +* Run Authorization Server -> `./gradlew -b samples/demo-authorizationserver/samples-demo-authorizationserver.gradle bootRun` +* Run Client -> `./gradlew -b samples/demo-client/samples-demo-client.gradle bootRun` * Run Resource Server -> `./gradlew -b samples/messages-resource/samples-messages-resource.gradle bootRun` -* Run Client -> `./gradlew -b samples/messages-client/samples-messages-client.gradle bootRun` * Go to `http://127.0.0.1:8080` +** Login with credentials -> user1 \ password -[[federated-identity-sample]] -== Federated Identity Sample +[[configuring-social-login]] +=== Configuring Social Login -The federated identity sample builds on the messages sample above, adding social login and federated identity features to *Spring Authorization Server* using custom configuration. +The demo sample may be configured to provide social login capability. [[google-login]] -=== Login with Google +==== Login with Google -This section shows how to configure Spring Security using Google as an Authentication Provider. +This section shows how to configure Google as a social login provider. [[google-initial-setup]] -==== Initial setup +===== Initial setup To use Google's OAuth 2.0 authentication system for login, you must set up a project in the Google API Console to obtain OAuth 2.0 credentials. @@ -38,7 +42,7 @@ Follow the instructions on the https://developers.google.com/identity/protocols/ After completing the "Obtain OAuth 2.0 credentials" instructions, you should have a new OAuth Client with credentials consisting of a Client ID and a Client Secret. [[google-redirect-uri]] -==== Setting the redirect URI +===== Setting the redirect URI The redirect URI is the path in the application that the end-user's user-agent is redirected back to after they have authenticated with Google and have granted access to the OAuth Client _(created in the previous step)_ on the Consent page. @@ -49,7 +53,7 @@ TIP: The default redirect URI template is `{baseUrl}/login/oauth2/code/{registra The *_registrationId_* is a unique identifier for the `ClientRegistration`. [[google-application-config]] -==== Configure application.yml +===== Configure application.yml Now that you have a new OAuth Client with Google, you need to configure the application to use the OAuth Client for the _authentication flow_. To do so: @@ -80,12 +84,12 @@ Alternatively, you can set the following environment variables in the Spring Boo * `GOOGLE_CLIENT_SECRET` [[github-login]] -=== Login with GitHub +==== Login with GitHub -This section shows how to configure Spring Security using Github as an Authentication Provider. +This section shows how to configure GitHub as a social login provider. [[github-register-application]] -==== Register OAuth application +===== Register OAuth application To use GitHub's OAuth 2.0 authentication system for login, you must https://github.com/settings/applications/new[Register a new OAuth application]. @@ -98,7 +102,7 @@ TIP: The default redirect URI template is `{baseUrl}/login/oauth2/code/{registra The *_registrationId_* is a unique identifier for the `ClientRegistration`. [[github-application-config]] -==== Configure application.yml +===== Configure application.yml Now that you have a new OAuth application with GitHub, you need to configure the application to use the OAuth application for the _authentication flow_. To do so: @@ -127,11 +131,3 @@ spring: Alternatively, you can set the following environment variables in the Spring Boot application: * `GITHUB_CLIENT_ID` * `GITHUB_CLIENT_SECRET` - -[[run-federated-identity-sample]] -=== Run the Sample - -* Run Authorization Server -> `./gradlew -b samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle bootRun` -* Run Resource Server -> `./gradlew -b samples/messages-resource/samples-messages-resource.gradle bootRun` -* Run Client -> `./gradlew -b samples/messages-client/samples-messages-client.gradle bootRun` -* Go to `http://127.0.0.1:8080` \ No newline at end of file diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java deleted file mode 100644 index a2cf209b6..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.config; - -import java.util.UUID; - -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.SecurityContext; -import sample.jose.Jwks; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.ClientAuthenticationMethod; -import org.springframework.security.oauth2.core.oidc.OidcScopes; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; -import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; -import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; -import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; -import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; -import org.springframework.security.web.util.matcher.RequestMatcher; - -/** - * @author Joe Grandja - * @author Daniel Garnier-Moiroux - */ -@Configuration(proxyBeanMethods = false) -public class AuthorizationServerConfig { - private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent"; - - @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) - public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { - OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = - new OAuth2AuthorizationServerConfigurer(); - authorizationServerConfigurer - .authorizationEndpoint(authorizationEndpoint -> - authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)) - .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 - - RequestMatcher endpointsMatcher = authorizationServerConfigurer - .getEndpointsMatcher(); - - http - .securityMatcher(endpointsMatcher) - .authorizeHttpRequests(authorize -> - authorize.anyRequest().authenticated() - ) - .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) - .exceptionHandling(exceptions -> - exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) - ) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) - .apply(authorizationServerConfigurer); - return http.build(); - } - - // @formatter:off - @Bean - public RegisteredClientRepository registeredClientRepository() { - RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) - .clientId("messaging-client") - .clientSecret("{noop}secret") - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) - .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") - .redirectUri("http://127.0.0.1:8080/authorized") - .scope(OidcScopes.OPENID) - .scope(OidcScopes.PROFILE) - .scope("message.read") - .scope("message.write") - .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) - .build(); - return new InMemoryRegisteredClientRepository(registeredClient); - } - // @formatter:on - - @Bean - public JWKSource jwkSource() { - RSAKey rsaKey = Jwks.generateRsa(); - JWKSet jwkSet = new JWKSet(rsaKey); - return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); - } - - @Bean - public JwtDecoder jwtDecoder(JWKSource jwkSource) { - return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); - } - - @Bean - public AuthorizationServerSettings authorizationServerSettings() { - return AuthorizationServerSettings.builder().build(); - } - - @Bean - public OAuth2AuthorizationConsentService authorizationConsentService() { - // Will be used by the ConsentController - return new InMemoryOAuth2AuthorizationConsentService(); - } - -} diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java deleted file mode 100644 index 7266901af..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; -import org.springframework.security.web.SecurityFilterChain; - -import static org.springframework.security.config.Customizer.withDefaults; - -/** - * @author Joe Grandja - */ -@EnableWebSecurity -@Configuration(proxyBeanMethods = false) -public class DefaultSecurityConfig { - - // @formatter:off - @Bean - SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authorize -> - authorize.anyRequest().authenticated() - ) - .formLogin(withDefaults()); - return http.build(); - } - // @formatter:on - - // @formatter:off - @Bean - UserDetailsService users() { - UserDetails user = User.withDefaultPasswordEncoder() - .username("user1") - .password("password") - .roles("USER") - .build(); - return new InMemoryUserDetailsManager(user); - } - // @formatter:on - -} diff --git a/samples/custom-consent-authorizationserver/src/main/resources/application.yml b/samples/custom-consent-authorizationserver/src/main/resources/application.yml deleted file mode 100644 index 5e879a67f..000000000 --- a/samples/custom-consent-authorizationserver/src/main/resources/application.yml +++ /dev/null @@ -1,10 +0,0 @@ -server: - port: 9000 - -logging: - level: - root: INFO - org.springframework.web: INFO - org.springframework.security: INFO - org.springframework.security.oauth2: INFO -# org.springframework.boot.autoconfigure: DEBUG diff --git a/samples/custom-consent-authorizationserver/src/main/resources/templates/consent.html b/samples/custom-consent-authorizationserver/src/main/resources/templates/consent.html deleted file mode 100644 index 761dfc281..000000000 --- a/samples/custom-consent-authorizationserver/src/main/resources/templates/consent.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - Custom consent page - Consent required - - - - -
-
-

App permissions

-
-
-
-

- The application - - wants to access your account - -

-
-
-
-

The following permissions are requested by the above app.
Please review - these and consent if you approve.

-
-
-
-
- - - -
- - -

-
- -

You have already granted the following permissions to the above app:

-
- - -

-
- -
- -
-
- -
-
-
-
-
-
-

- - Your consent to provide access is required. -
If you do not approve, click Cancel, in which case no information will be shared with the app. -
-

-
-
-
- - diff --git a/samples/default-authorizationserver/gradle.properties b/samples/default-authorizationserver/gradle.properties new file mode 100644 index 000000000..712ac6a7c --- /dev/null +++ b/samples/default-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0 diff --git a/samples/default-authorizationserver/samples-default-authorizationserver.gradle b/samples/default-authorizationserver/samples-default-authorizationserver.gradle index c0d0124dd..08c1c8443 100644 --- a/samples/default-authorizationserver/samples-default-authorizationserver.gradle +++ b/samples/default-authorizationserver/samples-default-authorizationserver.gradle @@ -1,6 +1,6 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" - id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "org.springframework.boot" version "3.1.4" + id "io.spring.dependency-management" version "1.1.0" id "java" } @@ -10,18 +10,20 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/milestone" } } dependencies { implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-security" - implementation "org.springframework.boot:spring-boot-starter-jdbc" implementation project(":spring-security-oauth2-authorization-server") - runtimeOnly "com.h2database:h2" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.springframework.security:spring-security-test" testImplementation "org.junit.jupiter:junit-jupiter" testImplementation "net.sourceforge.htmlunit:htmlunit" } + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java deleted file mode 100644 index f1d9b7e4f..000000000 --- a/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.config; - -import java.util.UUID; - -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.SecurityContext; -import sample.jose.Jwks; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; -import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; -import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.ClientAuthenticationMethod; -import org.springframework.security.oauth2.core.oidc.OidcScopes; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; -import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; -import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; -import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; -import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; -import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; - -/** - * @author Joe Grandja - * @since 0.0.1 - */ -@Configuration(proxyBeanMethods = false) -public class AuthorizationServerConfig { - - @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) - public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); - http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) - .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 - - // @formatter:off - http - .exceptionHandling(exceptions -> - exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) - ) - .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); - // @formatter:on - return http.build(); - } - - // @formatter:off - @Bean - public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { - RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) - .clientId("messaging-client") - .clientSecret("{noop}secret") - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) - .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") - .redirectUri("http://127.0.0.1:8080/authorized") - .scope(OidcScopes.OPENID) - .scope(OidcScopes.PROFILE) - .scope("message.read") - .scope("message.write") - .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) - .build(); - - // Save registered client in db as if in-memory - JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); - registeredClientRepository.save(registeredClient); - - return registeredClientRepository; - } - // @formatter:on - - @Bean - public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { - return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); - } - - @Bean - public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { - return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); - } - - @Bean - public JWKSource jwkSource() { - RSAKey rsaKey = Jwks.generateRsa(); - JWKSet jwkSet = new JWKSet(rsaKey); - return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); - } - - @Bean - public JwtDecoder jwtDecoder(JWKSource jwkSource) { - return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); - } - - @Bean - public AuthorizationServerSettings authorizationServerSettings() { - return AuthorizationServerSettings.builder().build(); - } - - @Bean - public EmbeddedDatabase embeddedDatabase() { - // @formatter:off - return new EmbeddedDatabaseBuilder() - .generateUniqueName(true) - .setType(EmbeddedDatabaseType.H2) - .setScriptEncoding("UTF-8") - .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql") - .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql") - .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql") - .build(); - // @formatter:on - } - -} diff --git a/samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java deleted file mode 100644 index 36c5f7f67..000000000 --- a/samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; -import org.springframework.security.web.SecurityFilterChain; - -import static org.springframework.security.config.Customizer.withDefaults; - -/** - * @author Joe Grandja - * @since 0.1.0 - */ -@EnableWebSecurity -@Configuration(proxyBeanMethods = false) -public class DefaultSecurityConfig { - - // @formatter:off - @Bean - SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authorize -> - authorize.anyRequest().authenticated() - ) - .formLogin(withDefaults()); - return http.build(); - } - // @formatter:on - - // @formatter:off - @Bean - UserDetailsService users() { - UserDetails user = User.withDefaultPasswordEncoder() - .username("user1") - .password("password") - .roles("USER") - .build(); - return new InMemoryUserDetailsManager(user); - } - // @formatter:on - -} diff --git a/samples/default-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/default-authorizationserver/src/main/java/sample/jose/Jwks.java deleted file mode 100644 index 0a02e6ccb..000000000 --- a/samples/default-authorizationserver/src/main/java/sample/jose/Jwks.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2020-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.jose; - -import java.security.KeyPair; -import java.security.interfaces.ECPrivateKey; -import java.security.interfaces.ECPublicKey; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.util.UUID; - -import javax.crypto.SecretKey; - -import com.nimbusds.jose.jwk.Curve; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.OctetSequenceKey; -import com.nimbusds.jose.jwk.RSAKey; - -/** - * @author Joe Grandja - * @since 0.1.0 - */ -public final class Jwks { - - private Jwks() { - } - - public static RSAKey generateRsa() { - KeyPair keyPair = KeyGeneratorUtils.generateRsaKey(); - RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); - RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); - // @formatter:off - return new RSAKey.Builder(publicKey) - .privateKey(privateKey) - .keyID(UUID.randomUUID().toString()) - .build(); - // @formatter:on - } - - public static ECKey generateEc() { - KeyPair keyPair = KeyGeneratorUtils.generateEcKey(); - ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); - ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate(); - Curve curve = Curve.forECParameterSpec(publicKey.getParams()); - // @formatter:off - return new ECKey.Builder(curve, publicKey) - .privateKey(privateKey) - .keyID(UUID.randomUUID().toString()) - .build(); - // @formatter:on - } - - public static OctetSequenceKey generateSecret() { - SecretKey secretKey = KeyGeneratorUtils.generateSecretKey(); - // @formatter:off - return new OctetSequenceKey.Builder(secretKey) - .keyID(UUID.randomUUID().toString()) - .build(); - // @formatter:on - } -} diff --git a/samples/default-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/default-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java deleted file mode 100644 index babaf2858..000000000 --- a/samples/default-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.jose; - -import java.math.BigInteger; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.spec.ECFieldFp; -import java.security.spec.ECParameterSpec; -import java.security.spec.ECPoint; -import java.security.spec.EllipticCurve; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; - -/** - * @author Joe Grandja - * @since 0.1.0 - */ -final class KeyGeneratorUtils { - - private KeyGeneratorUtils() { - } - - static SecretKey generateSecretKey() { - SecretKey hmacKey; - try { - hmacKey = KeyGenerator.getInstance("HmacSha256").generateKey(); - } catch (Exception ex) { - throw new IllegalStateException(ex); - } - return hmacKey; - } - - static KeyPair generateRsaKey() { - KeyPair keyPair; - try { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(2048); - keyPair = keyPairGenerator.generateKeyPair(); - } catch (Exception ex) { - throw new IllegalStateException(ex); - } - return keyPair; - } - - static KeyPair generateEcKey() { - EllipticCurve ellipticCurve = new EllipticCurve( - new ECFieldFp( - new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951")), - new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"), - new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291")); - ECPoint ecPoint = new ECPoint( - new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"), - new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109")); - ECParameterSpec ecParameterSpec = new ECParameterSpec( - ellipticCurve, - ecPoint, - new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"), - 1); - - KeyPair keyPair; - try { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); - keyPairGenerator.initialize(ecParameterSpec); - keyPair = keyPairGenerator.generateKeyPair(); - } catch (Exception ex) { - throw new IllegalStateException(ex); - } - return keyPair; - } -} diff --git a/samples/default-authorizationserver/src/main/resources/application.yml b/samples/default-authorizationserver/src/main/resources/application.yml index 5e879a67f..9336a2a51 100644 --- a/samples/default-authorizationserver/src/main/resources/application.yml +++ b/samples/default-authorizationserver/src/main/resources/application.yml @@ -3,8 +3,34 @@ server: logging: level: - root: INFO - org.springframework.web: INFO - org.springframework.security: INFO - org.springframework.security.oauth2: INFO -# org.springframework.boot.autoconfigure: DEBUG + org.springframework.security: trace + +spring: + security: + user: + name: user1 + password: password + oauth2: + authorizationserver: + client: + messaging-client: + registration: + client-id: "messaging-client" + client-secret: "{noop}secret" + client-authentication-methods: + - "client_secret_basic" + authorization-grant-types: + - "authorization_code" + - "refresh_token" + - "client_credentials" + redirect-uris: + - "http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc" + - "http://127.0.0.1:8080/authorized" + post-logout-redirect-uris: + - "http://127.0.0.1:8080/logged-out" + scopes: + - "openid" + - "profile" + - "message.read" + - "message.write" + require-authorization-consent: true diff --git a/samples/demo-authorizationserver/gradle.properties b/samples/demo-authorizationserver/gradle.properties new file mode 100644 index 000000000..712ac6a7c --- /dev/null +++ b/samples/demo-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0 diff --git a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle b/samples/demo-authorizationserver/samples-demo-authorizationserver.gradle similarity index 65% rename from samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle rename to samples/demo-authorizationserver/samples-demo-authorizationserver.gradle index 8036588ad..46a3b13d9 100644 --- a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle +++ b/samples/demo-authorizationserver/samples-demo-authorizationserver.gradle @@ -1,6 +1,6 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" - id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "org.springframework.boot" version "3.1.4" + id "io.spring.dependency-management" version "1.1.0" id "java" } @@ -10,17 +10,24 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/milestone" } } dependencies { implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-oauth2-client" + implementation "org.springframework.boot:spring-boot-starter-jdbc" implementation project(":spring-security-oauth2-authorization-server") + runtimeOnly "com.h2database:h2" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.springframework.security:spring-security-test" testImplementation "org.junit.jupiter:junit-jupiter" testImplementation "net.sourceforge.htmlunit:htmlunit" } + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java b/samples/demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java similarity index 77% rename from samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java rename to samples/demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java index 85ed23813..88d788b24 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java +++ b/samples/demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,13 +19,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; /** - * @author Daniel Garnier-Moiroux + * @author Joe Grandja + * @since 1.1 */ @SpringBootApplication -public class CustomConsentAuthorizationServerApplication { +public class DemoAuthorizationServerApplication { public static void main(String[] args) { - SpringApplication.run(CustomConsentAuthorizationServerApplication.class, args); + SpringApplication.run(DemoAuthorizationServerApplication.class, args); } } diff --git a/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java b/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java new file mode 100644 index 000000000..2ba2668ee --- /dev/null +++ b/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java @@ -0,0 +1,103 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.authentication; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import sample.web.authentication.DeviceClientAuthenticationConverter; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter; +import org.springframework.util.Assert; + +/** + * @author Joe Grandja + * @author Steve Riesenberg + * @since 1.1 + * @see DeviceClientAuthenticationToken + * @see DeviceClientAuthenticationConverter + * @see OAuth2ClientAuthenticationFilter + */ +public final class DeviceClientAuthenticationProvider implements AuthenticationProvider { + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1"; + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + + public DeviceClientAuthenticationProvider(RegisteredClientRepository registeredClientRepository) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + this.registeredClientRepository = registeredClientRepository; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + DeviceClientAuthenticationToken deviceClientAuthentication = + (DeviceClientAuthenticationToken) authentication; + + if (!ClientAuthenticationMethod.NONE.equals(deviceClientAuthentication.getClientAuthenticationMethod())) { + return null; + } + + String clientId = deviceClientAuthentication.getPrincipal().toString(); + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId); + if (registeredClient == null) { + throwInvalidClient(OAuth2ParameterNames.CLIENT_ID); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + if (!registeredClient.getClientAuthenticationMethods().contains( + deviceClientAuthentication.getClientAuthenticationMethod())) { + throwInvalidClient("authentication_method"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device client authentication parameters"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated device client"); + } + + return new DeviceClientAuthenticationToken(registeredClient, + deviceClientAuthentication.getClientAuthenticationMethod(), null); + } + + @Override + public boolean supports(Class authentication) { + return DeviceClientAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static void throwInvalidClient(String parameterName) { + OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_CLIENT, + "Device client authentication failed: " + parameterName, + ERROR_URI + ); + throw new OAuth2AuthenticationException(error); + } + +} diff --git a/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java b/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java new file mode 100644 index 000000000..4e9a3d2fb --- /dev/null +++ b/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.authentication; + +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Transient; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; + +/** + * @author Joe Grandja + * @author Steve Riesenberg + * @since 1.1 + */ +@Transient +public class DeviceClientAuthenticationToken extends OAuth2ClientAuthenticationToken { + + public DeviceClientAuthenticationToken(String clientId, ClientAuthenticationMethod clientAuthenticationMethod, + @Nullable Object credentials, @Nullable Map additionalParameters) { + super(clientId, clientAuthenticationMethod, credentials, additionalParameters); + } + + public DeviceClientAuthenticationToken(RegisteredClient registeredClient, ClientAuthenticationMethod clientAuthenticationMethod, + @Nullable Object credentials) { + super(registeredClient, clientAuthenticationMethod, credentials); + } + +} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java similarity index 62% rename from samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java rename to samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index f8df51481..93c8e1c53 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,21 +21,22 @@ import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import sample.authentication.DeviceClientAuthenticationProvider; +import sample.federation.FederatedIdentityIdTokenCustomizer; import sample.jose.Jwks; -import sample.security.FederatedIdentityConfigurer; -import sample.security.FederatedIdentityIdTokenCustomizer; +import sample.web.authentication.DeviceClientAuthenticationConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.oidc.OidcScopes; @@ -54,28 +55,79 @@ import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; /** + * @author Joe Grandja + * @author Daniel Garnier-Moiroux * @author Steve Riesenberg - * @since 0.2.3 + * @since 1.1 */ @Configuration(proxyBeanMethods = false) public class AuthorizationServerConfig { + private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent"; @Bean @Order(Ordered.HIGHEST_PRECEDENCE) - public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain authorizationServerSecurityFilterChain( + HttpSecurity http, RegisteredClientRepository registeredClientRepository, + AuthorizationServerSettings authorizationServerSettings) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + + /* + * This sample demonstrates the use of a public client that does not + * store credentials or authenticate with the authorization server. + * + * The following components show how to customize the authorization + * server to allow for device clients to perform requests to the + * OAuth 2.0 Device Authorization Endpoint and Token Endpoint without + * a clientId/clientSecret. + * + * CAUTION: These endpoints will not require any authentication, and can + * be accessed by any client that has a valid clientId. + * + * It is therefore RECOMMENDED to carefully monitor the use of these + * endpoints and employ any additional protections as needed, which is + * outside the scope of this sample. + */ + DeviceClientAuthenticationConverter deviceClientAuthenticationConverter = + new DeviceClientAuthenticationConverter( + authorizationServerSettings.getDeviceAuthorizationEndpoint()); + DeviceClientAuthenticationProvider deviceClientAuthenticationProvider = + new DeviceClientAuthenticationProvider(registeredClientRepository); + + // @formatter:off http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) - .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 - http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); - http.apply(new FederatedIdentityConfigurer()); - return http.build(); - } + .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint -> + deviceAuthorizationEndpoint.verificationUri("/activate") + ) + .deviceVerificationEndpoint(deviceVerificationEndpoint -> + deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI) + ) + .clientAuthentication(clientAuthentication -> + clientAuthentication + .authenticationConverter(deviceClientAuthenticationConverter) + .authenticationProvider(deviceClientAuthenticationProvider) + ) + .authorizationEndpoint(authorizationEndpoint -> + authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)) + .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + // @formatter:on - @Bean - public OAuth2TokenCustomizer idTokenCustomizer() { - return new FederatedIdentityIdTokenCustomizer(); + // @formatter:off + http + .exceptionHandling((exceptions) -> exceptions + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) + ) + .oauth2ResourceServer(oauth2ResourceServer -> + oauth2ResourceServer.jwt(Customizer.withDefaults())); + // @formatter:on + return http.build(); } // @formatter:off @@ -90,6 +142,7 @@ public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTe .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") .redirectUri("http://127.0.0.1:8080/authorized") + .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") @@ -97,24 +150,42 @@ public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTe .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); - // Save registered client in db as if in-memory + RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("device-messaging-client") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .scope("message.read") + .scope("message.write") + .build(); + + // Save registered client's in db as if in-memory JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); registeredClientRepository.save(registeredClient); + registeredClientRepository.save(deviceClient); return registeredClientRepository; } // @formatter:on @Bean - public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { + public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, + RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } @Bean - public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { + public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, + RegisteredClientRepository registeredClientRepository) { + // Will be used by the ConsentController return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); } + @Bean + public OAuth2TokenCustomizer idTokenCustomizer() { + return new FederatedIdentityIdTokenCustomizer(); + } + @Bean public JWKSource jwkSource() { RSAKey rsaKey = Jwks.generateRsa(); diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java similarity index 63% rename from samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java rename to samples/demo-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java index 99706e81d..7c553744c 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ b/samples/demo-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,23 +15,26 @@ */ package sample.config; -import sample.security.FederatedIdentityConfigurer; -import sample.security.UserRepositoryOAuth2UserHandler; +import sample.federation.FederatedIdentityAuthenticationSuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.session.HttpSessionEventPublisher; /** + * @author Joe Grandja * @author Steve Riesenberg - * @since 0.2.3 + * @since 1.1 */ @EnableWebSecurity @Configuration(proxyBeanMethods = false) @@ -40,20 +43,30 @@ public class DefaultSecurityConfig { // @formatter:off @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { - FederatedIdentityConfigurer federatedIdentityConfigurer = new FederatedIdentityConfigurer() - .oauth2UserHandler(new UserRepositoryOAuth2UserHandler()); http .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll() + .requestMatchers("/assets/**", "/login").permitAll() .anyRequest().authenticated() ) - .formLogin(Customizer.withDefaults()) - .apply(federatedIdentityConfigurer); + .formLogin(formLogin -> + formLogin + .loginPage("/login") + ) + .oauth2Login(oauth2Login -> + oauth2Login + .loginPage("/login") + .successHandler(authenticationSuccessHandler()) + ); + return http.build(); } // @formatter:on + private AuthenticationSuccessHandler authenticationSuccessHandler() { + return new FederatedIdentityAuthenticationSuccessHandler(); + } + // @formatter:off @Bean public UserDetailsService users() { @@ -66,4 +79,14 @@ public UserDetailsService users() { } // @formatter:on + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + } diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java b/samples/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationSuccessHandler.java similarity index 94% rename from samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java rename to samples/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationSuccessHandler.java index 13e7979f3..ed4c2409e 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java +++ b/samples/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationSuccessHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package sample.security; +package sample.federation; +// tag::imports[] import java.io.IOException; import java.util.function.Consumer; @@ -28,14 +29,16 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +// end::imports[] /** * An {@link AuthenticationSuccessHandler} for capturing the {@link OidcUser} or * {@link OAuth2User} for Federated Account Linking or JIT Account Provisioning. * * @author Steve Riesenberg - * @since 0.2.3 + * @since 1.1 */ +// tag::class[] public final class FederatedIdentityAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final AuthenticationSuccessHandler delegate = new SavedRequestAwareAuthenticationSuccessHandler(); @@ -66,3 +69,4 @@ public void setOidcUserHandler(Consumer oidcUserHandler) { } } +// end::class[] diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java b/samples/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityIdTokenCustomizer.java similarity index 95% rename from samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java rename to samples/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityIdTokenCustomizer.java index 201f884fb..0929ed41e 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java +++ b/samples/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityIdTokenCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package sample.security; +package sample.federation; +// tag::imports[] import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -30,14 +31,16 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; +// end::imports[] /** * An {@link OAuth2TokenCustomizer} to map claims from a federated identity to * the {@code id_token} produced by this authorization server. * * @author Steve Riesenberg - * @since 0.2.3 + * @since 1.1 */ +// tag::class[] public final class FederatedIdentityIdTokenCustomizer implements OAuth2TokenCustomizer { private static final Set ID_TOKEN_CLAIMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( @@ -89,3 +92,4 @@ private Map extractClaims(Authentication principal) { } } +// end::class[] diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java b/samples/demo-authorizationserver/src/main/java/sample/federation/UserRepositoryOAuth2UserHandler.java similarity index 91% rename from samples/federated-identity-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java rename to samples/demo-authorizationserver/src/main/java/sample/federation/UserRepositoryOAuth2UserHandler.java index b7094681a..95030deef 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java +++ b/samples/demo-authorizationserver/src/main/java/sample/federation/UserRepositoryOAuth2UserHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,20 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package sample.security; +package sample.federation; +// tag::imports[] import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import org.springframework.security.oauth2.core.user.OAuth2User; +// end::imports[] /** * Example {@link Consumer} to perform JIT provisioning of an {@link OAuth2User}. * * @author Steve Riesenberg - * @since 0.2.3 + * @since 1.1 */ +// tag::class[] public final class UserRepositoryOAuth2UserHandler implements Consumer { private final UserRepository userRepository = new UserRepository(); @@ -55,3 +58,4 @@ public void save(OAuth2User oauth2User) { } } +// end::class[] diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/demo-authorizationserver/src/main/java/sample/jose/Jwks.java similarity index 96% rename from samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java rename to samples/demo-authorizationserver/src/main/java/sample/jose/Jwks.java index 1f936056d..1f3c71427 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java +++ b/samples/demo-authorizationserver/src/main/java/sample/jose/Jwks.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ /** * @author Joe Grandja + * @since 1.1 */ public final class Jwks { @@ -70,4 +71,5 @@ public static OctetSequenceKey generateSecret() { .build(); // @formatter:on } + } diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/demo-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java similarity index 97% rename from samples/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java rename to samples/demo-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java index b101062ef..ec55abd74 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java +++ b/samples/demo-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2021 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ /** * @author Joe Grandja + * @since 1.1 */ final class KeyGeneratorUtils { @@ -81,4 +82,5 @@ static KeyPair generateEcKey() { } return keyPair; } + } diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java b/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java similarity index 91% rename from samples/custom-consent-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java rename to samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java index c98327d78..c21e6e87d 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java +++ b/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,7 +52,8 @@ public AuthorizationConsentController(RegisteredClientRepository registeredClien public String consent(Principal principal, Model model, @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId, @RequestParam(OAuth2ParameterNames.SCOPE) String scope, - @RequestParam(OAuth2ParameterNames.STATE) String state) { + @RequestParam(OAuth2ParameterNames.STATE) String state, + @RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) { // Remove scopes that were already approved Set scopesToApprove = new HashSet<>(); @@ -82,6 +83,12 @@ public String consent(Principal principal, Model model, model.addAttribute("scopes", withDescription(scopesToApprove)); model.addAttribute("previouslyApprovedScopes", withDescription(previouslyApprovedScopes)); model.addAttribute("principalName", principal.getName()); + model.addAttribute("userCode", userCode); + if (StringUtils.hasText(userCode)) { + model.addAttribute("requestURI", "/oauth2/device_verification"); + } else { + model.addAttribute("requestURI", "/oauth2/authorize"); + } return "consent"; } @@ -125,4 +132,5 @@ public static class ScopeWithDescription { this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION); } } + } diff --git a/samples/demo-authorizationserver/src/main/java/sample/web/DefaultErrorController.java b/samples/demo-authorizationserver/src/main/java/sample/web/DefaultErrorController.java new file mode 100644 index 000000000..af4a3c06d --- /dev/null +++ b/samples/demo-authorizationserver/src/main/java/sample/web/DefaultErrorController.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.web; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Controller +public class DefaultErrorController implements ErrorController { + + @RequestMapping("/error") + public String handleError(Model model, HttpServletRequest request) { + String errorMessage = getErrorMessage(request); + if (errorMessage.startsWith("[access_denied]")) { + model.addAttribute("errorTitle", "Access Denied"); + model.addAttribute("errorMessage", "You have denied access."); + } else { + model.addAttribute("errorTitle", "Error"); + model.addAttribute("errorMessage", errorMessage); + } + return "error"; + } + + private String getErrorMessage(HttpServletRequest request) { + String errorMessage = (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE); + return StringUtils.hasText(errorMessage) ? errorMessage : ""; + } + +} diff --git a/samples/demo-authorizationserver/src/main/java/sample/web/DeviceController.java b/samples/demo-authorizationserver/src/main/java/sample/web/DeviceController.java new file mode 100644 index 000000000..b9cc9ee88 --- /dev/null +++ b/samples/demo-authorizationserver/src/main/java/sample/web/DeviceController.java @@ -0,0 +1,47 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Controller +public class DeviceController { + + @GetMapping("/activate") + public String activate(@RequestParam(value = "user_code", required = false) String userCode) { + if (userCode != null) { + return "redirect:/oauth2/device_verification?user_code=" + userCode; + } + return "device-activate"; + } + + @GetMapping("/activated") + public String activated() { + return "device-activated"; + } + + @GetMapping(value = "/", params = "success") + public String success() { + return "device-activated"; + } + +} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/web/LoginController.java b/samples/demo-authorizationserver/src/main/java/sample/web/LoginController.java similarity index 92% rename from samples/federated-identity-authorizationserver/src/main/java/sample/web/LoginController.java rename to samples/demo-authorizationserver/src/main/java/sample/web/LoginController.java index 3de4f669c..df193e059 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/web/LoginController.java +++ b/samples/demo-authorizationserver/src/main/java/sample/web/LoginController.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ /** * @author Steve Riesenberg - * @since 0.2.3 + * @since 1.1 */ @Controller public class LoginController { diff --git a/samples/demo-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java b/samples/demo-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java new file mode 100644 index 000000000..aa1cbfefa --- /dev/null +++ b/samples/demo-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java @@ -0,0 +1,76 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.web.authentication; + +import jakarta.servlet.http.HttpServletRequest; + +import sample.authentication.DeviceClientAuthenticationToken; + +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.StringUtils; + +/** + * @author Joe Grandja + * @author Steve Riesenberg + * @since 1.1 + */ +public final class DeviceClientAuthenticationConverter implements AuthenticationConverter { + private final RequestMatcher deviceAuthorizationRequestMatcher; + private final RequestMatcher deviceAccessTokenRequestMatcher; + + public DeviceClientAuthenticationConverter(String deviceAuthorizationEndpointUri) { + RequestMatcher clientIdParameterMatcher = request -> + request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null; + this.deviceAuthorizationRequestMatcher = new AndRequestMatcher( + new AntPathRequestMatcher( + deviceAuthorizationEndpointUri, HttpMethod.POST.name()), + clientIdParameterMatcher); + this.deviceAccessTokenRequestMatcher = request -> + AuthorizationGrantType.DEVICE_CODE.getValue().equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE)) && + request.getParameter(OAuth2ParameterNames.DEVICE_CODE) != null && + request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null; + } + + @Nullable + @Override + public Authentication convert(HttpServletRequest request) { + if (!this.deviceAuthorizationRequestMatcher.matches(request) && + !this.deviceAccessTokenRequestMatcher.matches(request)) { + return null; + } + + // client_id (REQUIRED) + String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID); + if (!StringUtils.hasText(clientId) || + request.getParameterValues(OAuth2ParameterNames.CLIENT_ID).length != 1) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + return new DeviceClientAuthenticationToken(clientId, ClientAuthenticationMethod.NONE, null, null); + } + +} diff --git a/samples/federated-identity-authorizationserver/src/main/resources/application.yml b/samples/demo-authorizationserver/src/main/resources/application.yml similarity index 95% rename from samples/federated-identity-authorizationserver/src/main/resources/application.yml rename to samples/demo-authorizationserver/src/main/resources/application.yml index 71bbbd965..78f30d9d8 100644 --- a/samples/federated-identity-authorizationserver/src/main/resources/application.yml +++ b/samples/demo-authorizationserver/src/main/resources/application.yml @@ -30,4 +30,3 @@ logging: org.springframework.web: INFO org.springframework.security: INFO org.springframework.security.oauth2: INFO -# org.springframework.boot.autoconfigure: DEBUG diff --git a/samples/demo-authorizationserver/src/main/resources/static/assets/css/signin.css b/samples/demo-authorizationserver/src/main/resources/static/assets/css/signin.css new file mode 100644 index 000000000..2ee098f2a --- /dev/null +++ b/samples/demo-authorizationserver/src/main/resources/static/assets/css/signin.css @@ -0,0 +1,32 @@ +html, +body { + height: 100%; +} + +body { + display: flex; + align-items: start; + padding-top: 100px; + background-color: #f5f5f5; +} + +.form-signin { + max-width: 330px; + padding: 15px; +} + +.form-signin .form-floating:focus-within { + z-index: 2; +} + +.form-signin input[type="username"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.form-signin input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} diff --git a/samples/demo-authorizationserver/src/main/resources/static/assets/img/devices.png b/samples/demo-authorizationserver/src/main/resources/static/assets/img/devices.png new file mode 100644 index 000000000..fda6b12e3 Binary files /dev/null and b/samples/demo-authorizationserver/src/main/resources/static/assets/img/devices.png differ diff --git a/samples/demo-authorizationserver/src/main/resources/static/assets/img/github.png b/samples/demo-authorizationserver/src/main/resources/static/assets/img/github.png new file mode 100644 index 000000000..e28a83730 Binary files /dev/null and b/samples/demo-authorizationserver/src/main/resources/static/assets/img/github.png differ diff --git a/samples/demo-authorizationserver/src/main/resources/static/assets/img/google.png b/samples/demo-authorizationserver/src/main/resources/static/assets/img/google.png new file mode 100644 index 000000000..795dea319 Binary files /dev/null and b/samples/demo-authorizationserver/src/main/resources/static/assets/img/google.png differ diff --git a/samples/demo-authorizationserver/src/main/resources/templates/consent.html b/samples/demo-authorizationserver/src/main/resources/templates/consent.html new file mode 100644 index 000000000..918a86a42 --- /dev/null +++ b/samples/demo-authorizationserver/src/main/resources/templates/consent.html @@ -0,0 +1,104 @@ + + + + + + Custom consent page - Consent required + + + + +
+
+

App permissions

+
+
+
+

+ The application + + wants to access your account + +

+
+
+
+
+

+ You have provided the code + . + Verify that this code matches what is shown on your device. +

+
+
+
+
+

+ The following permissions are requested by the above app.
+ Please review these and consent if you approve. +

+
+
+
+
+
+ + + + +
+ + +

+
+ +

+ You have already granted the following permissions to the above app: +

+
+ + +

+
+ +
+ +
+
+ +
+
+
+
+
+
+

+ + Your consent to provide access is required.
+ If you do not approve, click Cancel, in which case no information will be shared with the app. +
+

+
+
+
+ + diff --git a/samples/demo-authorizationserver/src/main/resources/templates/device-activate.html b/samples/demo-authorizationserver/src/main/resources/templates/device-activate.html new file mode 100644 index 000000000..7540da074 --- /dev/null +++ b/samples/demo-authorizationserver/src/main/resources/templates/device-activate.html @@ -0,0 +1,33 @@ + + + + + + Spring Authorization Server sample + + + +
+
+
+

Device Activation

+

Enter the activation code to authorize the device.

+
+
+
+ + +
+
+ +
+
+
+
+
+ Devices +
+
+
+ + diff --git a/samples/demo-authorizationserver/src/main/resources/templates/device-activated.html b/samples/demo-authorizationserver/src/main/resources/templates/device-activated.html new file mode 100644 index 000000000..7d4181652 --- /dev/null +++ b/samples/demo-authorizationserver/src/main/resources/templates/device-activated.html @@ -0,0 +1,25 @@ + + + + + + Spring Authorization Server sample + + + +
+
+
+

Success!

+

+ You have successfully activated your device.
+ Please return to your device to continue. +

+
+
+ Devices +
+
+
+ + diff --git a/samples/demo-authorizationserver/src/main/resources/templates/error.html b/samples/demo-authorizationserver/src/main/resources/templates/error.html new file mode 100644 index 000000000..d2369119b --- /dev/null +++ b/samples/demo-authorizationserver/src/main/resources/templates/error.html @@ -0,0 +1,19 @@ + + + + + + Spring Authorization Server sample + + + +
+
+
+

+

+
+
+
+ + diff --git a/samples/demo-authorizationserver/src/main/resources/templates/login.html b/samples/demo-authorizationserver/src/main/resources/templates/login.html new file mode 100644 index 000000000..afa8b5916 --- /dev/null +++ b/samples/demo-authorizationserver/src/main/resources/templates/login.html @@ -0,0 +1,42 @@ + + + + + + Spring Authorization Server sample + + + + +
+ +
+ + diff --git a/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java new file mode 100644 index 000000000..36d121f02 --- /dev/null +++ b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import java.io.IOException; + +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.HtmlButton; +import com.gargoylesoftware.htmlunit.html.HtmlElement; +import com.gargoylesoftware.htmlunit.html.HtmlInput; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for the sample Authorization Server. + * + * @author Daniel Garnier-Moiroux + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public class DemoAuthorizationServerApplicationTests { + private static final String REDIRECT_URI = "http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc"; + + private static final String AUTHORIZATION_REQUEST = UriComponentsBuilder + .fromPath("/oauth2/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", "messaging-client") + .queryParam("scope", "openid") + .queryParam("state", "some-state") + .queryParam("redirect_uri", REDIRECT_URI) + .toUriString(); + + @Autowired + private WebClient webClient; + + @BeforeEach + public void setUp() { + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(true); + this.webClient.getOptions().setRedirectEnabled(true); + this.webClient.getCookieManager().clearCookies(); // log out + } + + @Test + public void whenLoginSuccessfulThenDisplayBadRequestError() throws IOException { + HtmlPage page = this.webClient.getPage("/"); + + assertLoginPage(page); + + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); + WebResponse signInResponse = signIn(page, "user1", "password").getWebResponse(); + + assertThat(signInResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); // there is no "default" index page + } + + @Test + public void whenLoginFailsThenDisplayBadCredentials() throws IOException { + HtmlPage page = this.webClient.getPage("/"); + + HtmlPage loginErrorPage = signIn(page, "user1", "wrong-password"); + + HtmlElement alert = loginErrorPage.querySelector("div[role=\"alert\"]"); + assertThat(alert).isNotNull(); + assertThat(alert.asNormalizedText()).isEqualTo("Invalid username or password."); + } + + @Test + public void whenNotLoggedInAndRequestingTokenThenRedirectsToLogin() throws IOException { + HtmlPage page = this.webClient.getPage(AUTHORIZATION_REQUEST); + + assertLoginPage(page); + } + + @Test + public void whenLoggingInAndRequestingTokenThenRedirectsToClientApplication() throws IOException { + // Log in + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); + this.webClient.getOptions().setRedirectEnabled(false); + signIn(this.webClient.getPage("/login"), "user1", "password"); + + // Request token + WebResponse response = this.webClient.getPage(AUTHORIZATION_REQUEST).getWebResponse(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); + String location = response.getResponseHeaderValue("location"); + assertThat(location).startsWith(REDIRECT_URI); + assertThat(location).contains("code="); + } + + private static

P signIn(HtmlPage page, String username, String password) throws IOException { + HtmlInput usernameInput = page.querySelector("input[name=\"username\"]"); + HtmlInput passwordInput = page.querySelector("input[name=\"password\"]"); + HtmlButton signInButton = page.querySelector("button"); + + usernameInput.type(username); + passwordInput.type(password); + return signInButton.click(); + } + + private static void assertLoginPage(HtmlPage page) { + assertThat(page.getUrl().toString()).endsWith("/login"); + + HtmlInput usernameInput = page.querySelector("input[name=\"username\"]"); + HtmlInput passwordInput = page.querySelector("input[name=\"password\"]"); + HtmlButton signInButton = page.querySelector("button"); + + assertThat(usernameInput).isNotNull(); + assertThat(passwordInput).isNotNull(); + assertThat(signInButton.getTextContent()).isEqualTo("Sign in"); + } + +} diff --git a/samples/custom-consent-authorizationserver/src/test/java/sample/CustomConsentAuthorizationServerTests.java b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java similarity index 95% rename from samples/custom-consent-authorizationserver/src/test/java/sample/CustomConsentAuthorizationServerTests.java rename to samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java index 762ccf811..d3a6a3f97 100644 --- a/samples/custom-consent-authorizationserver/src/test/java/sample/CustomConsentAuthorizationServerTests.java +++ b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,14 +43,14 @@ import static org.mockito.Mockito.when; /** - * Consent page integration tests for the sample Authorization Server serving a custom Consent page. + * Consent screen integration tests for the sample Authorization Server. * * @author Dmitriy Dubson */ @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc -public class CustomConsentAuthorizationServerTests { +public class DemoAuthorizationServerConsentTests { @Autowired private WebClient webClient; diff --git a/samples/demo-client/gradle.properties b/samples/demo-client/gradle.properties new file mode 100644 index 000000000..712ac6a7c --- /dev/null +++ b/samples/demo-client/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0 diff --git a/samples/messages-client/samples-messages-client.gradle b/samples/demo-client/samples-demo-client.gradle similarity index 65% rename from samples/messages-client/samples-messages-client.gradle rename to samples/demo-client/samples-demo-client.gradle index 4644d8e80..baf4ed752 100644 --- a/samples/messages-client/samples-messages-client.gradle +++ b/samples/demo-client/samples-demo-client.gradle @@ -1,6 +1,6 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" - id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "org.springframework.boot" version "3.1.4" + id "io.spring.dependency-management" version "1.1.0" id "java" } @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/milestone" } } dependencies { @@ -20,8 +20,8 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework:spring-webflux" implementation "io.projectreactor.netty:reactor-netty" - implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" implementation "org.webjars:webjars-locator-core" - implementation "org.webjars:bootstrap:3.4.1" - implementation "org.webjars:jquery:3.4.1" + implementation "org.webjars:bootstrap:5.2.3" + implementation "org.webjars:popper.js:2.9.3" + implementation "org.webjars:jquery:3.6.4" } diff --git a/samples/messages-client/src/main/java/sample/MessagesClientApplication.java b/samples/demo-client/src/main/java/sample/DemoClientApplication.java similarity index 89% rename from samples/messages-client/src/main/java/sample/MessagesClientApplication.java rename to samples/demo-client/src/main/java/sample/DemoClientApplication.java index a2db0f931..ce73e4d0f 100644 --- a/samples/messages-client/src/main/java/sample/MessagesClientApplication.java +++ b/samples/demo-client/src/main/java/sample/DemoClientApplication.java @@ -23,10 +23,10 @@ * @since 0.0.1 */ @SpringBootApplication -public class MessagesClientApplication { +public class DemoClientApplication { public static void main(String[] args) { - SpringApplication.run(MessagesClientApplication.class, args); + SpringApplication.run(DemoClientApplication.class, args); } } diff --git a/samples/demo-client/src/main/java/sample/authorization/DeviceCodeOAuth2AuthorizedClientProvider.java b/samples/demo-client/src/main/java/sample/authorization/DeviceCodeOAuth2AuthorizedClientProvider.java new file mode 100644 index 000000000..ce0fe95c9 --- /dev/null +++ b/samples/demo-client/src/main/java/sample/authorization/DeviceCodeOAuth2AuthorizedClientProvider.java @@ -0,0 +1,119 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.authorization; + +import java.time.Clock; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.oauth2.client.ClientAuthorizationException; +import org.springframework.security.oauth2.client.OAuth2AuthorizationContext; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider { + + private OAuth2AccessTokenResponseClient accessTokenResponseClient = + new OAuth2DeviceAccessTokenResponseClient(); + + private Duration clockSkew = Duration.ofSeconds(60); + + private Clock clock = Clock.systemUTC(); + + public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient accessTokenResponseClient) { + this.accessTokenResponseClient = accessTokenResponseClient; + } + + public void setClockSkew(Duration clockSkew) { + this.clockSkew = clockSkew; + } + + public void setClock(Clock clock) { + this.clock = clock; + } + + @Override + public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) { + Assert.notNull(context, "context cannot be null"); + ClientRegistration clientRegistration = context.getClientRegistration(); + if (!AuthorizationGrantType.DEVICE_CODE.equals(clientRegistration.getAuthorizationGrantType())) { + return null; + } + OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient(); + if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) { + // If client is already authorized but access token is NOT expired than no + // need for re-authorization + return null; + } + if (authorizedClient != null && authorizedClient.getRefreshToken() != null) { + // If client is already authorized but access token is expired and a + // refresh token is available, delegate to refresh_token. + return null; + } + // ***************************************************************** + // Get device_code set via DefaultOAuth2AuthorizedClientManager#setContextAttributesMapper() + // ***************************************************************** + String deviceCode = context.getAttribute(OAuth2ParameterNames.DEVICE_CODE); + // Attempt to authorize the client, which will repeatedly fail until the user grants authorization + OAuth2DeviceGrantRequest deviceGrantRequest = new OAuth2DeviceGrantRequest(clientRegistration, deviceCode); + OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, deviceGrantRequest); + return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(), + tokenResponse.getAccessToken(), tokenResponse.getRefreshToken()); + } + + private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration, + OAuth2DeviceGrantRequest deviceGrantRequest) { + try { + return this.accessTokenResponseClient.getTokenResponse(deviceGrantRequest); + } catch (OAuth2AuthorizationException ex) { + throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex); + } + } + + private boolean hasTokenExpired(OAuth2Token token) { + return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew)); + } + + public static Function> deviceCodeContextAttributesMapper() { + return (authorizeRequest) -> { + HttpServletRequest request = authorizeRequest.getAttribute(HttpServletRequest.class.getName()); + Assert.notNull(request, "request cannot be null"); + + // Obtain device code from request + String deviceCode = request.getParameter(OAuth2ParameterNames.DEVICE_CODE); + return (deviceCode != null) ? Collections.singletonMap(OAuth2ParameterNames.DEVICE_CODE, deviceCode) : + Collections.emptyMap(); + }; + } + +} diff --git a/samples/demo-client/src/main/java/sample/authorization/OAuth2DeviceAccessTokenResponseClient.java b/samples/demo-client/src/main/java/sample/authorization/OAuth2DeviceAccessTokenResponseClient.java new file mode 100644 index 000000000..2c3486ffd --- /dev/null +++ b/samples/demo-client/src/main/java/sample/authorization/OAuth2DeviceAccessTokenResponseClient.java @@ -0,0 +1,99 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.authorization; + +import java.util.Arrays; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +public final class OAuth2DeviceAccessTokenResponseClient implements OAuth2AccessTokenResponseClient { + + private RestOperations restOperations; + + public OAuth2DeviceAccessTokenResponseClient() { + RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), + new OAuth2AccessTokenResponseHttpMessageConverter())); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + this.restOperations = restTemplate; + } + + public void setRestOperations(RestOperations restOperations) { + this.restOperations = restOperations; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceGrantRequest deviceGrantRequest) { + ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration(); + + HttpHeaders headers = new HttpHeaders(); + /* + * This sample demonstrates the use of a public client that does not + * store credentials or authenticate with the authorization server. + * + * See DeviceClientAuthenticationProvider in the authorization server + * sample for an example customization that allows public clients. + * + * For a confidential client, change the client-authentication-method + * to client_secret_basic and set the client-secret to send the + * OAuth 2.0 Token Request with a clientId/clientSecret. + */ + if (!clientRegistration.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) { + headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + } + + MultiValueMap requestParameters = new LinkedMultiValueMap<>(); + requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, deviceGrantRequest.getGrantType().getValue()); + requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + requestParameters.add(OAuth2ParameterNames.DEVICE_CODE, deviceGrantRequest.getDeviceCode()); + + // @formatter:off + RequestEntity> requestEntity = + RequestEntity.post(deviceGrantRequest.getClientRegistration().getProviderDetails().getTokenUri()) + .headers(headers) + .body(requestParameters); + // @formatter:on + + try { + return this.restOperations.exchange(requestEntity, OAuth2AccessTokenResponse.class).getBody(); + } catch (RestClientException ex) { + OAuth2Error oauth2Error = new OAuth2Error("invalid_token_response", + "An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: " + + ex.getMessage(), null); + throw new OAuth2AuthorizationException(oauth2Error, ex); + } + } + +} diff --git a/samples/demo-client/src/main/java/sample/authorization/OAuth2DeviceGrantRequest.java b/samples/demo-client/src/main/java/sample/authorization/OAuth2DeviceGrantRequest.java new file mode 100644 index 000000000..5687e2695 --- /dev/null +++ b/samples/demo-client/src/main/java/sample/authorization/OAuth2DeviceGrantRequest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.authorization; + +import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.util.Assert; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +public final class OAuth2DeviceGrantRequest extends AbstractOAuth2AuthorizationGrantRequest { + + private final String deviceCode; + + public OAuth2DeviceGrantRequest(ClientRegistration clientRegistration, String deviceCode) { + super(AuthorizationGrantType.DEVICE_CODE, clientRegistration); + Assert.hasText(deviceCode, "deviceCode cannot be empty"); + this.deviceCode = deviceCode; + } + + public String getDeviceCode() { + return this.deviceCode; + } + +} diff --git a/samples/messages-client/src/main/java/sample/config/SecurityConfig.java b/samples/demo-client/src/main/java/sample/config/SecurityConfig.java similarity index 50% rename from samples/messages-client/src/main/java/sample/config/SecurityConfig.java rename to samples/demo-client/src/main/java/sample/config/SecurityConfig.java index 6532782fb..8379f76a9 100644 --- a/samples/messages-client/src/main/java/sample/config/SecurityConfig.java +++ b/samples/demo-client/src/main/java/sample/config/SecurityConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,12 +20,17 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import static org.springframework.security.config.Customizer.withDefaults; /** * @author Joe Grandja + * @author Dmitriy Dubson + * @author Steve Riesenberg * @since 0.0.1 */ @EnableWebSecurity @@ -33,22 +38,39 @@ public class SecurityConfig { @Bean - WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> web.ignoring().requestMatchers("/webjars/**"); + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**"); } // @formatter:off @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + ClientRegistrationRepository clientRegistrationRepository) throws Exception { http .authorizeHttpRequests(authorize -> - authorize.anyRequest().authenticated() + authorize + .requestMatchers("/logged-out").permitAll() + .anyRequest().authenticated() ) .oauth2Login(oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc")) - .oauth2Client(withDefaults()); + .oauth2Client(withDefaults()) + .logout(logout -> + logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository))); return http.build(); } // @formatter:on + private LogoutSuccessHandler oidcLogoutSuccessHandler( + ClientRegistrationRepository clientRegistrationRepository) { + OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = + new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository); + + // Set the location that the End-User's User Agent will be redirected to + // after the logout has been performed at the Provider + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/logged-out"); + + return oidcLogoutSuccessHandler; + } + } diff --git a/samples/messages-client/src/main/java/sample/config/WebClientConfig.java b/samples/demo-client/src/main/java/sample/config/WebClientConfig.java similarity index 78% rename from samples/messages-client/src/main/java/sample/config/WebClientConfig.java rename to samples/demo-client/src/main/java/sample/config/WebClientConfig.java index a03d2d136..274ddccfb 100644 --- a/samples/messages-client/src/main/java/sample/config/WebClientConfig.java +++ b/samples/demo-client/src/main/java/sample/config/WebClientConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ */ package sample.config; +import sample.authorization.DeviceCodeOAuth2AuthorizedClientProvider; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; @@ -28,35 +30,47 @@ /** * @author Joe Grandja + * @author Steve Riesenberg * @since 0.0.1 */ @Configuration public class WebClientConfig { @Bean - WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) { + public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) { ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); + // @formatter:off return WebClient.builder() .apply(oauth2Client.oauth2Configuration()) .build(); + // @formatter:on } @Bean - OAuth2AuthorizedClientManager authorizedClientManager( + public OAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientRepository authorizedClientRepository) { + // @formatter:off OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() .authorizationCode() .refreshToken() .clientCredentials() + .provider(new DeviceCodeOAuth2AuthorizedClientProvider()) .build(); + // @formatter:on + DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager( clientRegistrationRepository, authorizedClientRepository); authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + // Set a contextAttributesMapper to obtain device_code from the request + authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider + .deviceCodeContextAttributesMapper()); + return authorizedClientManager; } + } diff --git a/samples/messages-client/src/main/java/sample/web/AuthorizationController.java b/samples/demo-client/src/main/java/sample/web/AuthorizationController.java similarity index 86% rename from samples/messages-client/src/main/java/sample/web/AuthorizationController.java rename to samples/demo-client/src/main/java/sample/web/AuthorizationController.java index 8fb8326eb..c92d78962 100644 --- a/samples/messages-client/src/main/java/sample/web/AuthorizationController.java +++ b/samples/demo-client/src/main/java/sample/web/AuthorizationController.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,10 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId; import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient; @@ -93,4 +95,16 @@ public String clientCredentialsGrant(Model model) { return "index"; } + + @GetMapping(value = "/authorize", params = "grant_type=device_code") + public String deviceCodeGrant() { + return "device-activate"; + } + + @ExceptionHandler(WebClientResponseException.class) + public String handleError(Model model, WebClientResponseException ex) { + model.addAttribute("error", ex.getMessage()); + return "index"; + } + } diff --git a/samples/messages-client/src/main/java/sample/web/DefaultController.java b/samples/demo-client/src/main/java/sample/web/DefaultController.java similarity index 84% rename from samples/messages-client/src/main/java/sample/web/DefaultController.java rename to samples/demo-client/src/main/java/sample/web/DefaultController.java index 5661a75d8..88e242a78 100644 --- a/samples/messages-client/src/main/java/sample/web/DefaultController.java +++ b/samples/demo-client/src/main/java/sample/web/DefaultController.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 the original author or authors. + * Copyright 2020-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ /** * @author Joe Grandja + * @author Dmitriy Dubson * @since 0.0.1 */ @Controller @@ -34,4 +35,10 @@ public String root() { public String index() { return "index"; } + + @GetMapping("/logged-out") + public String loggedOut() { + return "logged-out"; + } + } diff --git a/samples/demo-client/src/main/java/sample/web/DeviceController.java b/samples/demo-client/src/main/java/sample/web/DeviceController.java new file mode 100644 index 000000000..9f03d74cf --- /dev/null +++ b/samples/demo-client/src/main/java/sample/web/DeviceController.java @@ -0,0 +1,192 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.web; + +import java.time.Instant; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Controller +public class DeviceController { + + private static final Set DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList( + "authorization_pending", + "slow_down", + "access_denied", + "expired_token" + )); + + private static final ParameterizedTypeReference> TYPE_REFERENCE = + new ParameterizedTypeReference<>() {}; + + private final ClientRegistrationRepository clientRegistrationRepository; + + private final WebClient webClient; + + private final String messagesBaseUri; + + public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient, + @Value("${messages.base-uri}") String messagesBaseUri) { + + this.clientRegistrationRepository = clientRegistrationRepository; + this.webClient = webClient; + this.messagesBaseUri = messagesBaseUri; + } + + @GetMapping("/device_authorize") + public String authorize(Model model) { + // @formatter:off + ClientRegistration clientRegistration = + this.clientRegistrationRepository.findByRegistrationId( + "messaging-client-device-code"); + // @formatter:on + + MultiValueMap requestParameters = new LinkedMultiValueMap<>(); + requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString( + clientRegistration.getScopes(), " ")); + + String deviceAuthorizationUri = (String) clientRegistration.getProviderDetails().getConfigurationMetadata().get("device_authorization_endpoint"); + + // @formatter:off + Map responseParameters = + this.webClient.post() + .uri(deviceAuthorizationUri) + .headers(headers -> { + /* + * This sample demonstrates the use of a public client that does not + * store credentials or authenticate with the authorization server. + * + * See DeviceClientAuthenticationProvider in the authorization server + * sample for an example customization that allows public clients. + * + * For a confidential client, change the client-authentication-method to + * client_secret_basic and set the client-secret to send the + * OAuth 2.0 Device Authorization Request with a clientId/clientSecret. + */ + if (!clientRegistration.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) { + headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + } + }) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(requestParameters)) + .retrieve() + .bodyToMono(TYPE_REFERENCE) + .block(); + // @formatter:on + + Objects.requireNonNull(responseParameters, "Device Authorization Response cannot be null"); + Instant issuedAt = Instant.now(); + Integer expiresIn = (Integer) responseParameters.get(OAuth2ParameterNames.EXPIRES_IN); + Instant expiresAt = issuedAt.plusSeconds(expiresIn); + + model.addAttribute("deviceCode", responseParameters.get(OAuth2ParameterNames.DEVICE_CODE)); + model.addAttribute("expiresAt", expiresAt); + model.addAttribute("userCode", responseParameters.get(OAuth2ParameterNames.USER_CODE)); + model.addAttribute("verificationUri", responseParameters.get(OAuth2ParameterNames.VERIFICATION_URI)); + // Note: You could use a QR-code to display this URL + model.addAttribute("verificationUriComplete", responseParameters.get( + OAuth2ParameterNames.VERIFICATION_URI_COMPLETE)); + + return "device-authorize"; + } + + /** + * @see #handleError(OAuth2AuthorizationException) + */ + @PostMapping("/device_authorize") + public ResponseEntity poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode, + @RegisteredOAuth2AuthorizedClient("messaging-client-device-code") + OAuth2AuthorizedClient authorizedClient) { + + /* + * The client will repeatedly poll until authorization is granted. + * + * The OAuth2AuthorizedClientManager uses the device_code parameter + * to make a token request, which returns authorization_pending until + * the user has granted authorization. + * + * If the user has denied authorization, access_denied is returned and + * polling should stop. + * + * If the device code expires, expired_token is returned and polling + * should stop. + * + * This endpoint simply returns 200 OK when the client is authorized. + */ + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @ExceptionHandler(OAuth2AuthorizationException.class) + public ResponseEntity handleError(OAuth2AuthorizationException ex) { + String errorCode = ex.getError().getErrorCode(); + if (DEVICE_GRANT_ERRORS.contains(errorCode)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getError()); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getError()); + } + + @GetMapping("/device_authorized") + public String authorized(Model model, + @RegisteredOAuth2AuthorizedClient("messaging-client-device-code") + OAuth2AuthorizedClient authorizedClient) { + + String[] messages = this.webClient.get() + .uri(this.messagesBaseUri) + .attributes(oauth2AuthorizedClient(authorizedClient)) + .retrieve() + .bodyToMono(String[].class) + .block(); + model.addAttribute("messages", messages); + + return "index"; + } + +} diff --git a/samples/messages-client/src/main/resources/application.yml b/samples/demo-client/src/main/resources/application.yml similarity index 80% rename from samples/messages-client/src/main/resources/application.yml rename to samples/demo-client/src/main/resources/application.yml index a7f212ddf..f39ef426d 100644 --- a/samples/messages-client/src/main/resources/application.yml +++ b/samples/demo-client/src/main/resources/application.yml @@ -7,7 +7,6 @@ logging: org.springframework.web: INFO org.springframework.security: INFO org.springframework.security.oauth2: INFO -# org.springframework.boot.autoconfigure: DEBUG spring: thymeleaf: @@ -39,6 +38,13 @@ spring: authorization-grant-type: client_credentials scope: message.read,message.write client-name: messaging-client-client-credentials + messaging-client-device-code: + provider: spring + client-id: device-messaging-client + client-authentication-method: none + authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code + scope: message.read,message.write + client-name: messaging-client-device-code provider: spring: issuer-uri: http://localhost:9000 diff --git a/samples/demo-client/src/main/resources/static/assets/img/devices.png b/samples/demo-client/src/main/resources/static/assets/img/devices.png new file mode 100644 index 000000000..fda6b12e3 Binary files /dev/null and b/samples/demo-client/src/main/resources/static/assets/img/devices.png differ diff --git a/samples/demo-client/src/main/resources/static/assets/img/spring-security.svg b/samples/demo-client/src/main/resources/static/assets/img/spring-security.svg new file mode 100644 index 000000000..897f986da --- /dev/null +++ b/samples/demo-client/src/main/resources/static/assets/img/spring-security.svg @@ -0,0 +1 @@ +logo-security \ No newline at end of file diff --git a/samples/demo-client/src/main/resources/templates/device-activate.html b/samples/demo-client/src/main/resources/templates/device-activate.html new file mode 100644 index 000000000..c704193e3 --- /dev/null +++ b/samples/demo-client/src/main/resources/templates/device-activate.html @@ -0,0 +1,27 @@ + + + + + + Spring Authorization Server sample + + + +

+
+
+
+

Activation Required

+

You must activate this device.

+ Activate +
+
+ Devices +
+
+
+ + + + + diff --git a/samples/demo-client/src/main/resources/templates/device-authorize.html b/samples/demo-client/src/main/resources/templates/device-authorize.html new file mode 100644 index 000000000..41ef5637a --- /dev/null +++ b/samples/demo-client/src/main/resources/templates/device-authorize.html @@ -0,0 +1,87 @@ + + + + + + Spring Authorization Server sample + + + +
+
+
+
+

Device Activation

+

Please visit on another device to continue.

+

Activation Code

+
+ +
+ +
+
+
+
+ Devices +
+
+
+ + + + + + diff --git a/samples/demo-client/src/main/resources/templates/index.html b/samples/demo-client/src/main/resources/templates/index.html new file mode 100644 index 000000000..c0ef10bfe --- /dev/null +++ b/samples/demo-client/src/main/resources/templates/index.html @@ -0,0 +1,19 @@ + + + + + + Spring Authorization Server sample + + + +
+
+
+
+
+ + + + + diff --git a/samples/demo-client/src/main/resources/templates/logged-out.html b/samples/demo-client/src/main/resources/templates/logged-out.html new file mode 100644 index 000000000..67a38ac42 --- /dev/null +++ b/samples/demo-client/src/main/resources/templates/logged-out.html @@ -0,0 +1,22 @@ + + + + + + Spring Authorization Server sample + + + +
+
+
+
+

You are now logged out.

+
+
+
+ + + + + diff --git a/samples/demo-client/src/main/resources/templates/page-templates.html b/samples/demo-client/src/main/resources/templates/page-templates.html new file mode 100644 index 000000000..6d9e17be7 --- /dev/null +++ b/samples/demo-client/src/main/resources/templates/page-templates.html @@ -0,0 +1,69 @@ + + + + + + Spring Authorization Server sample + + + + +
+
+ +
+
+
+ + + + + + + + + + + + + + +
Messages
#Message
+
+
+
+ + + + + diff --git a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle deleted file mode 100644 index bca16c539..000000000 --- a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle +++ /dev/null @@ -1,27 +0,0 @@ -plugins { - id "org.springframework.boot" version "3.0.0-RC2" - id "io.spring.dependency-management" version "1.0.11.RELEASE" - id "java" -} - -group = project.rootProject.group -version = project.rootProject.version -sourceCompatibility = "17" - -repositories { - mavenCentral() - maven { url 'https://repo.spring.io/milestone' } -} - -dependencies { - implementation "org.springframework.boot:spring-boot-starter-web" - implementation "org.springframework.boot:spring-boot-starter-security" - implementation "org.springframework.boot:spring-boot-starter-oauth2-client" - implementation "org.springframework.boot:spring-boot-starter-thymeleaf" - implementation "org.springframework.boot:spring-boot-starter-jdbc" - implementation "org.webjars:webjars-locator-core" - implementation "org.webjars:bootstrap:3.4.1" - implementation "org.webjars:jquery:3.4.1" - implementation project(":spring-security-oauth2-authorization-server") - runtimeOnly "com.h2database:h2" -} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/FederatedIdentityAuthorizationServerApplication.java b/samples/federated-identity-authorizationserver/src/main/java/sample/FederatedIdentityAuthorizationServerApplication.java deleted file mode 100644 index 4a6b99e4b..000000000 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/FederatedIdentityAuthorizationServerApplication.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** - * @author Steve Riesenberg - * @since 0.2.3 - */ -@SpringBootApplication -public class FederatedIdentityAuthorizationServerApplication { - - public static void main(String[] args) { - SpringApplication.run(FederatedIdentityAuthorizationServerApplication.class, args); - } - -} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/federated-identity-authorizationserver/src/main/java/sample/jose/Jwks.java deleted file mode 100644 index 43285fa54..000000000 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/jose/Jwks.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.jose; - -import java.security.KeyPair; -import java.security.interfaces.ECPrivateKey; -import java.security.interfaces.ECPublicKey; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.util.UUID; - -import javax.crypto.SecretKey; - -import com.nimbusds.jose.jwk.Curve; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.OctetSequenceKey; -import com.nimbusds.jose.jwk.RSAKey; - -/** - * @author Joe Grandja - * @since 0.1.0 - */ -public final class Jwks { - - private Jwks() { - } - - public static RSAKey generateRsa() { - KeyPair keyPair = KeyGeneratorUtils.generateRsaKey(); - RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); - RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); - // @formatter:off - return new RSAKey.Builder(publicKey) - .privateKey(privateKey) - .keyID(UUID.randomUUID().toString()) - .build(); - // @formatter:on - } - - public static ECKey generateEc() { - KeyPair keyPair = KeyGeneratorUtils.generateEcKey(); - ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); - ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate(); - Curve curve = Curve.forECParameterSpec(publicKey.getParams()); - // @formatter:off - return new ECKey.Builder(curve, publicKey) - .privateKey(privateKey) - .keyID(UUID.randomUUID().toString()) - .build(); - // @formatter:on - } - - public static OctetSequenceKey generateSecret() { - SecretKey secretKey = KeyGeneratorUtils.generateSecretKey(); - // @formatter:off - return new OctetSequenceKey.Builder(secretKey) - .keyID(UUID.randomUUID().toString()) - .build(); - // @formatter:on - } -} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/federated-identity-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java deleted file mode 100644 index 290247267..000000000 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.jose; - -import java.math.BigInteger; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.spec.ECFieldFp; -import java.security.spec.ECParameterSpec; -import java.security.spec.ECPoint; -import java.security.spec.EllipticCurve; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; - -/** - * @author Joe Grandja - * @since 0.1.0 - */ -final class KeyGeneratorUtils { - - private KeyGeneratorUtils() { - } - - static SecretKey generateSecretKey() { - SecretKey hmacKey; - try { - hmacKey = KeyGenerator.getInstance("HmacSha256").generateKey(); - } catch (Exception ex) { - throw new IllegalStateException(ex); - } - return hmacKey; - } - - static KeyPair generateRsaKey() { - KeyPair keyPair; - try { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(2048); - keyPair = keyPairGenerator.generateKeyPair(); - } catch (Exception ex) { - throw new IllegalStateException(ex); - } - return keyPair; - } - - static KeyPair generateEcKey() { - EllipticCurve ellipticCurve = new EllipticCurve( - new ECFieldFp( - new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951")), - new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"), - new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291")); - ECPoint ecPoint = new ECPoint( - new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"), - new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109")); - ECParameterSpec ecParameterSpec = new ECParameterSpec( - ellipticCurve, - ecPoint, - new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"), - 1); - - KeyPair keyPair; - try { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); - keyPairGenerator.initialize(ecParameterSpec); - keyPair = keyPairGenerator.generateKeyPair(); - } catch (Exception ex) { - throw new IllegalStateException(ex); - } - return keyPair; - } -} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java b/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java deleted file mode 100644 index b77e100b2..000000000 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.security; - -import java.io.IOException; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import org.springframework.http.server.ServletServerHttpRequest; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.DefaultRedirectStrategy; -import org.springframework.security.web.RedirectStrategy; -import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * An {@link AuthenticationEntryPoint} for initiating the login flow to an - * external provider using the {@code idp} query parameter, which represents the - * {@code registrationId} of the desired {@link ClientRegistration}. - * - * @author Steve Riesenberg - * @since 0.2.3 - */ -public final class FederatedIdentityAuthenticationEntryPoint implements AuthenticationEntryPoint { - - private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); - - private String authorizationRequestUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI - + "/{registrationId}"; - - private final AuthenticationEntryPoint delegate; - - private final ClientRegistrationRepository clientRegistrationRepository; - - public FederatedIdentityAuthenticationEntryPoint(String loginPageUrl, ClientRegistrationRepository clientRegistrationRepository) { - this.delegate = new LoginUrlAuthenticationEntryPoint(loginPageUrl); - this.clientRegistrationRepository = clientRegistrationRepository; - } - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException { - String idp = request.getParameter("idp"); - if (idp != null) { - ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(idp); - if (clientRegistration != null) { - String redirectUri = UriComponentsBuilder.fromHttpRequest(new ServletServerHttpRequest(request)) - .replaceQuery(null) - .replacePath(this.authorizationRequestUri) - .buildAndExpand(clientRegistration.getRegistrationId()) - .toUriString(); - this.redirectStrategy.sendRedirect(request, response, redirectUri); - return; - } - } - - this.delegate.commence(request, response, authenticationException); - } - - public void setAuthorizationRequestUri(String authorizationRequestUri) { - this.authorizationRequestUri = authorizationRequestUri; - } - -} diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java b/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java deleted file mode 100644 index 6cf39a2e6..000000000 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2020-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package sample.security; - -import java.util.function.Consumer; - -import org.springframework.context.ApplicationContext; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.util.Assert; - -/** - * A configurer for setting up Federated Identity Management. - * - * @author Steve Riesenberg - * @since 0.2.3 - */ -public final class FederatedIdentityConfigurer extends AbstractHttpConfigurer { - - private String loginPageUrl = "/login"; - - private String authorizationRequestUri; - - private Consumer oauth2UserHandler; - - private Consumer oidcUserHandler; - - /** - * @param loginPageUrl The URL of the login page, defaults to {@code "/login"} - * @return This configurer for additional configuration - */ - public FederatedIdentityConfigurer loginPageUrl(String loginPageUrl) { - Assert.hasText(loginPageUrl, "loginPageUrl cannot be empty"); - this.loginPageUrl = loginPageUrl; - return this; - } - - /** - * @param authorizationRequestUri The authorization request URI for initiating - * the login flow with an external IDP, defaults to {@code - * "/oauth2/authorization/{registrationId}"} - * @return This configurer for additional configuration - */ - public FederatedIdentityConfigurer authorizationRequestUri(String authorizationRequestUri) { - Assert.hasText(authorizationRequestUri, "authorizationRequestUri cannot be empty"); - this.authorizationRequestUri = authorizationRequestUri; - return this; - } - - /** - * @param oauth2UserHandler The {@link Consumer} for performing JIT account provisioning - * with an OAuth 2.0 IDP - * @return This configurer for additional configuration - */ - public FederatedIdentityConfigurer oauth2UserHandler(Consumer oauth2UserHandler) { - Assert.notNull(oauth2UserHandler, "oauth2UserHandler cannot be null"); - this.oauth2UserHandler = oauth2UserHandler; - return this; - } - - /** - * @param oidcUserHandler The {@link Consumer} for performing JIT account provisioning - * with an OpenID Connect 1.0 IDP - * @return This configurer for additional configuration - */ - public FederatedIdentityConfigurer oidcUserHandler(Consumer oidcUserHandler) { - Assert.notNull(oidcUserHandler, "oidcUserHandler cannot be null"); - this.oidcUserHandler = oidcUserHandler; - return this; - } - - // @formatter:off - @Override - public void init(HttpSecurity http) throws Exception { - ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); - ClientRegistrationRepository clientRegistrationRepository = - applicationContext.getBean(ClientRegistrationRepository.class); - FederatedIdentityAuthenticationEntryPoint authenticationEntryPoint = - new FederatedIdentityAuthenticationEntryPoint(this.loginPageUrl, clientRegistrationRepository); - if (this.authorizationRequestUri != null) { - authenticationEntryPoint.setAuthorizationRequestUri(this.authorizationRequestUri); - } - - FederatedIdentityAuthenticationSuccessHandler authenticationSuccessHandler = - new FederatedIdentityAuthenticationSuccessHandler(); - if (this.oauth2UserHandler != null) { - authenticationSuccessHandler.setOAuth2UserHandler(this.oauth2UserHandler); - } - if (this.oidcUserHandler != null) { - authenticationSuccessHandler.setOidcUserHandler(this.oidcUserHandler); - } - - http - .exceptionHandling(exceptionHandling -> - exceptionHandling.authenticationEntryPoint(authenticationEntryPoint) - ) - .oauth2Login(oauth2Login -> { - oauth2Login.successHandler(authenticationSuccessHandler); - if (this.authorizationRequestUri != null) { - String baseUri = this.authorizationRequestUri.replace("/{registrationId}", ""); - oauth2Login.authorizationEndpoint(authorizationEndpoint -> - authorizationEndpoint.baseUri(baseUri) - ); - } - }); - } - // @formatter:on - -} diff --git a/samples/federated-identity-authorizationserver/src/main/resources/templates/login.html b/samples/federated-identity-authorizationserver/src/main/resources/templates/login.html deleted file mode 100644 index 7c8323f5a..000000000 --- a/samples/federated-identity-authorizationserver/src/main/resources/templates/login.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - Spring Security Example - - - - -
- -
- - \ No newline at end of file diff --git a/samples/messages-client/src/main/resources/templates/index.html b/samples/messages-client/src/main/resources/templates/index.html deleted file mode 100644 index edf9d32be..000000000 --- a/samples/messages-client/src/main/resources/templates/index.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Spring Security OAuth 2.0 Sample - - - - - - -
- -
-
- -
-
-

Authorize the client using grant_type:

-
- - -
-
- - - - diff --git a/samples/messages-resource/samples-messages-resource.gradle b/samples/messages-resource/samples-messages-resource.gradle index 52c1df62a..31ddb7431 100644 --- a/samples/messages-resource/samples-messages-resource.gradle +++ b/samples/messages-resource/samples-messages-resource.gradle @@ -1,6 +1,6 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" - id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "org.springframework.boot" version "3.1.4" + id "io.spring.dependency-management" version "1.1.0" id "java" } @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/scripts/release/release-notes-sections.yml b/scripts/release/release-notes-sections.yml index 574c8cfbe..0524dc761 100644 --- a/scripts/release/release-notes-sections.yml +++ b/scripts/release/release-notes-sections.yml @@ -1,14 +1,25 @@ -releasenotes: +changelog: + repository: spring-projects/spring-authorization-server sections: - - title: "New Features" - emoji: ":star:" - labels: ["enhancement"] - - title: "Bug Fixes" - emoji: ":beetle:" - labels: ["bug", "regression"] - - title: "Dependency Upgrades" - emoji: ":hammer:" - labels: ["dependency-upgrade"] - - title: "Non-passive" - emoji: ":rewind:" - labels: ["breaks-passivity"] + - title: ":star: New Features" + labels: ["type: enhancement"] + sort: "title" + - title: ":beetle: Bug Fixes" + labels: ["type: bug", "type: regression"] + sort: "title" + - title: ":hammer: Dependency Upgrades" + labels: ["type: dependency-upgrade"] + sort: "title" + - title: ":rewind: Non-passive" + labels: ["type: breaks-passivity"] + sort: "title" + issues: + exclude: + labels: [ "status: duplicate" ] + ports: + - label: "status: forward-port" + bodyExpression: 'Forward port of issue #(\d+).*' + contributors: + title: ":heart: Contributors" + exclude: + names: ["jgrandja", "sjohnr"] diff --git a/settings.gradle b/settings.gradle index 893c709f1..4ab072cb8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,14 +1,12 @@ pluginManagement { repositories { gradlePluginPortal() - maven { url 'https://repo.spring.io/release' } - maven { url 'https://repo.spring.io/milestone' } } } plugins { - id "com.gradle.enterprise" version "3.11.1" - id "io.spring.ge.conventions" version "0.0.11" + id "com.gradle.enterprise" version "3.13.3" + id "io.spring.ge.conventions" version "0.0.13" } dependencyResolutionManagement {