From 1d6ca0dbb1bce73b6e75505b8fe3516e7a5b1d97 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 21 Nov 2022 13:04:14 -0500 Subject: [PATCH 001/250] Update to Spring Framework 5.3.24 Closes gh-978 --- buildSrc/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index a5fd3c59f..2b94d850f 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:5.3.23" + implementation "org.springframework:spring-core:5.3.24" } diff --git a/gradle.properties b/gradle.properties index f27b240c9..b5fe93f85 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=0.4.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=5.3.23 +springFrameworkVersion=5.3.24 springSecurityVersion=5.8.0-RC1 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From e9c21235a4115def6f1b9a39d5c432741fa358e7 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 21 Nov 2022 13:06:12 -0500 Subject: [PATCH 002/250] Update to Spring Security 5.8.0 Closes gh-979 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b5fe93f85..a6ea57852 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=5.3.24 -springSecurityVersion=5.8.0-RC1 +springSecurityVersion=5.8.0 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 From 02d0292cb165b4f19fde1743294d6d09ce815b71 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 21 Nov 2022 13:17:22 -0500 Subject: [PATCH 003/250] Update to jackson-bom 2.14.0 Closes gh-980 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index db9f50503..41b79dad1 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -9,7 +9,7 @@ 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.13.4.20221013") + api platform("com.fasterxml.jackson:jackson-bom:2.14.0") constraints { api "com.nimbusds:nimbus-jose-jwt:9.24.4" api "javax.servlet:javax.servlet-api:4.0.1" From 9e0b52ea915daf1ff6e424de954c676cee7ff686 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 21 Nov 2022 13:33:53 -0500 Subject: [PATCH 004/250] Release 0.4.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a6ea57852..f08e63b2d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=0.4.0-SNAPSHOT +version=0.4.0 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From e332a9dac58fe9af8b18142f55a56d089c0325b5 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 21 Nov 2022 13:40:03 -0500 Subject: [PATCH 005/250] Next Development Version --- gradle.properties | 2 +- .../authorization/util/SpringAuthorizationServerVersion.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index f08e63b2d..e3e4f3f01 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=0.4.0 +version=0.4.1-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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 2be196d4c..519464334 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 @@ -24,7 +24,7 @@ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 0; private static final int MINOR = 4; - private static final int PATCH = 0; + private static final int PATCH = 1; /** * Global Serialization value for Spring Authorization Server classes. From 917988134765d297ec1be022d4fc3201c01c09fc Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 21 Nov 2022 14:53:40 -0500 Subject: [PATCH 006/250] Next Development Version --- gradle.properties | 2 +- .../authorization/util/SpringAuthorizationServerVersion.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index deab73c47..323b48afb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.0 +version=1.0.1-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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..95e805978 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 @@ -24,7 +24,7 @@ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 1; private static final int MINOR = 0; - private static final int PATCH = 0; + private static final int PATCH = 1; /** * Global Serialization value for Spring Authorization Server classes. From 0d9fbb375f2482ea09b69f126f05c5d82fe37016 Mon Sep 17 00:00:00 2001 From: kuschzzp <38914005+kuschzzp@users.noreply.github.com> Date: Fri, 16 Dec 2022 17:14:09 +0800 Subject: [PATCH 007/250] Add authorizedScopes to sql in ref-doc Closes gh-1008 Related gh-829 --- docs/src/docs/asciidoc/guides/how-to-jpa.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc index a4c980ed5..89babeede 100644 --- a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc +++ b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc @@ -72,6 +72,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, From 5251eea3b8e811e1d1d5c78f40064288ea53eda8 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Fri, 23 Dec 2022 19:45:18 +0100 Subject: [PATCH 008/250] Update to Spring Boot 3.0.0 Closes gh-1023 --- .../examples/spring-authorization-server-docs-examples.gradle | 2 +- .../samples-custom-consent-authorizationserver.gradle | 2 +- .../samples-default-authorizationserver.gradle | 2 +- .../samples-federated-identity-authorizationserver.gradle | 2 +- samples/messages-client/samples-messages-client.gradle | 2 +- samples/messages-resource/samples-messages-resource.gradle | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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..402a31ab8 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 @@ -12,7 +12,7 @@ repositories { } dependencies { - implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.0-RC2") + implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.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" diff --git a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle index 8036588ad..6465a94c5 100644 --- a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle +++ b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle @@ -1,5 +1,5 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" + id "org.springframework.boot" version "3.0.0" id "io.spring.dependency-management" version "1.0.11.RELEASE" id "java" } diff --git a/samples/default-authorizationserver/samples-default-authorizationserver.gradle b/samples/default-authorizationserver/samples-default-authorizationserver.gradle index c0d0124dd..341e0c206 100644 --- a/samples/default-authorizationserver/samples-default-authorizationserver.gradle +++ b/samples/default-authorizationserver/samples-default-authorizationserver.gradle @@ -1,5 +1,5 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" + id "org.springframework.boot" version "3.0.0" id "io.spring.dependency-management" version "1.0.11.RELEASE" id "java" } diff --git a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle index bca16c539..ab8c75838 100644 --- a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle +++ b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle @@ -1,5 +1,5 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" + id "org.springframework.boot" version "3.0.0" id "io.spring.dependency-management" version "1.0.11.RELEASE" id "java" } diff --git a/samples/messages-client/samples-messages-client.gradle b/samples/messages-client/samples-messages-client.gradle index 4644d8e80..024b1b8b3 100644 --- a/samples/messages-client/samples-messages-client.gradle +++ b/samples/messages-client/samples-messages-client.gradle @@ -1,5 +1,5 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" + id "org.springframework.boot" version "3.0.0" id "io.spring.dependency-management" version "1.0.11.RELEASE" id "java" } diff --git a/samples/messages-resource/samples-messages-resource.gradle b/samples/messages-resource/samples-messages-resource.gradle index 52c1df62a..fbfcaba89 100644 --- a/samples/messages-resource/samples-messages-resource.gradle +++ b/samples/messages-resource/samples-messages-resource.gradle @@ -1,5 +1,5 @@ plugins { - id "org.springframework.boot" version "3.0.0-RC2" + id "org.springframework.boot" version "3.0.0" id "io.spring.dependency-management" version "1.0.11.RELEASE" id "java" } From 70fade4ffa6066c99cfed2365985de12c18fb1eb Mon Sep 17 00:00:00 2001 From: jongwooo Date: Sun, 5 Feb 2023 20:58:39 +0900 Subject: [PATCH 009/250] Replace deprecated command with environment file Closes gh-1062 Signed-off-by: jongwooo --- .github/workflows/continuous-integration-workflow.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index fb4523de7..309971003 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -24,10 +24,10 @@ jobs: if: env.RUN_JOBS == 'true' run: | # Run jobs if in upstream repository - echo "::set-output name=runjobs::true" + echo "runjobs=true" >> $GITHUB_OUTPUT # Extract version from gradle.properties version=$(cat gradle.properties | grep "version=" | awk -F'=' '{print $2}') - echo "::set-output name=project_version::$version" + echo "project_version=$version" >> $GITHUB_OUTPUT build: name: Build needs: [prerequisites] From 9767d1eabb4fa1486669d2f5e87a31059e955dc7 Mon Sep 17 00:00:00 2001 From: topiam Date: Sun, 18 Dec 2022 14:51:13 +0800 Subject: [PATCH 010/250] Fix redirect_uri resolver Closes gh-1012 --- ...th2AuthorizationCodeRequestAuthenticationProvider.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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..c93fa1576 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 @@ -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,11 @@ 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(); } From 1783bf761186545e200e782318eaa52f750295a3 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 15 Feb 2023 07:26:32 -0500 Subject: [PATCH 011/250] Polish gh-1013 --- ...tionCodeRequestAuthenticationProvider.java | 8 ++-- ...odeRequestAuthenticationProviderTests.java | 41 ++++++++++++------- .../client/TestRegisteredClients.java | 6 ++- .../OAuth2AuthorizationCodeGrantTests.java | 18 ++++---- .../annotation/web/configurers/OidcTests.java | 8 ++-- ...Auth2AuthorizationEndpointFilterTests.java | 12 ++++-- 6 files changed, 60 insertions(+), 33 deletions(-) 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 c93fa1576..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. @@ -350,9 +350,11 @@ private static void throwError(OAuth2Error error, String parameterName, throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthenticationResult); } - private static String resolveRedirectUri(OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + private static String resolveRedirectUri( + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, OAuth2AuthorizationRequest authorizationRequest, RegisteredClient registeredClient) { - if (authorizationCodeRequestAuthentication!=null && StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())){ + + if (authorizationCodeRequestAuthentication != null && StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) { return authorizationCodeRequestAuthentication.getRedirectUri(); } if (authorizationRequest != null && StringUtils.hasText(authorizationRequest.getRedirectUri())) { 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..f2e9eb7ea 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 -> @@ -301,10 +302,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 +321,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 +342,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 +360,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 +382,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 +404,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 +425,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 +476,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 +503,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 +521,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 +546,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 +571,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/client/TestRegisteredClients.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java index bf2c26403..6036f0bae 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,9 @@ 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") .scope("scope1"); } 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 ab57c54b5..c794097f3 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-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. @@ -289,13 +289,15 @@ private void assertAuthorizationRequestRedirectsToClient(String authorizationEnd RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); this.registeredClientRepository.save(registeredClient); + MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); MvcResult mvcResult = this.mvc.perform(get(authorizationEndpointUri) - .params(getAuthorizationRequestParameters(registeredClient)) + .params(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 expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=" + STATE_URL_ENCODED); String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code"); OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE); @@ -423,15 +425,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)) + .params(authorizationRequestParameters) .param(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) .param(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); @@ -527,7 +531,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); @@ -614,7 +618,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); 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 fc7c363ad..d83353086 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. @@ -182,13 +182,15 @@ 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)) + .params(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); 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 386f10724..ae76f3a18 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. @@ -299,7 +299,9 @@ 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( + request.getParameter(OAuth2ParameterNames.REDIRECT_URI) + + "?error=errorCode&error_description=errorDescription&error_uri=errorUri&state=state"); assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(this.principal); } @@ -560,7 +562,8 @@ public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationRespon .extracting(WebAuthenticationDetails::getRemoteAddress) .isEqualTo(REMOTE_ADDRESS); 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"); } @Test @@ -591,7 +594,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, From 743dba7e5f00675d0aeac0eb515bda8e4b866ae5 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 15 Feb 2023 10:39:15 -0500 Subject: [PATCH 012/250] Setup Forward Merge --- git/hooks/forward-merge | 135 ++++++++++++++++++++++++++++++++ git/hooks/prepare-forward-merge | 71 +++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100755 git/hooks/forward-merge create mode 100755 git/hooks/prepare-forward-merge 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..c32f023a8 --- /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.0.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) From 32022c120c03542e591b2ea5fc61d9173e1d48b8 Mon Sep 17 00:00:00 2001 From: luamas Date: Sat, 28 Jan 2023 18:22:33 +0800 Subject: [PATCH 013/250] HttpMessageConverters uses jakarta.json.bind.Jsonb Closes gh-1054 --- .../authorization/http/converter/HttpMessageConverters.java | 5 +++-- .../oidc/http/converter/HttpMessageConverters.java | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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/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() { From 26205a2d8df0f1c610ab2b6a380c61499c064c48 Mon Sep 17 00:00:00 2001 From: Andreas Fleig Date: Tue, 13 Dec 2022 10:33:04 +0100 Subject: [PATCH 014/250] Preserve encoding for authorization request redirect_uri parameter Closes gh-1011 --- .../web/OAuth2AuthorizationEndpointFilter.java | 17 +++++++++++++---- .../OAuth2AuthorizationEndpointFilterTests.java | 9 +++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) 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 c5c63bf77..428e10ba8 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. @@ -66,6 +66,9 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriBuilderFactory; import org.springframework.web.util.UriComponentsBuilder; /** @@ -296,8 +299,8 @@ private void sendAuthorizationResponse(HttpServletRequest request, HttpServletRe OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; - UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()) + UriBuilder uriBuilder = valuesOnlyEncodingUriBuilderFactory() + .uriString(authorizationCodeRequestAuthentication.getRedirectUri()) .queryParam(OAuth2ParameterNames.CODE, authorizationCodeRequestAuthentication.getAuthorizationCode().getTokenValue()); String redirectUri; if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) { @@ -306,7 +309,7 @@ private void sendAuthorizationResponse(HttpServletRequest request, HttpServletRe queryParams.put(OAuth2ParameterNames.STATE, authorizationCodeRequestAuthentication.getState()); redirectUri = uriBuilder.build(queryParams).toString(); } else { - redirectUri = uriBuilder.toUriString(); + redirectUri = uriBuilder.build().toString(); } this.redirectStrategy.sendRedirect(request, response, redirectUri); } @@ -351,6 +354,12 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r this.redirectStrategy.sendRedirect(request, response, redirectUri); } + private UriBuilderFactory valuesOnlyEncodingUriBuilderFactory() { + DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(); + uriBuilderFactory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY); + return uriBuilderFactory; + } + /** * For internal use only. */ 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 ae76f3a18..3597131d2 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 @@ -537,7 +537,12 @@ 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, @@ -563,7 +568,7 @@ public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationRespon .isEqualTo(REMOTE_ADDRESS); assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); assertThat(response.getRedirectedUrl()).isEqualTo( - request.getParameter(OAuth2ParameterNames.REDIRECT_URI) + "?code=code&state=state"); + "https://example.com?param=encoded%20parameter%20value&code=code&state=state"); } @Test From 30927ad5e736ceb6720d41dfc63e400942d1c729 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 15 Feb 2023 15:25:49 -0500 Subject: [PATCH 015/250] Polish gh-1011 --- .../OAuth2AuthorizationEndpointFilter.java | 36 ++++++------------- .../OAuth2AuthorizationCodeGrantTests.java | 12 +++++-- 2 files changed, 20 insertions(+), 28 deletions(-) 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 428e10ba8..76bcbe6ac 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 @@ -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.Set; import javax.servlet.FilterChain; @@ -66,10 +64,8 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.DefaultUriBuilderFactory; -import org.springframework.web.util.UriBuilder; -import org.springframework.web.util.UriBuilderFactory; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; /** * A {@code Filter} for the OAuth 2.0 Authorization Code Grant, @@ -299,18 +295,16 @@ private void sendAuthorizationResponse(HttpServletRequest request, HttpServletRe OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; - UriBuilder uriBuilder = valuesOnlyEncodingUriBuilderFactory() - .uriString(authorizationCodeRequestAuthentication.getRedirectUri()) + 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.build().toString(); + uriBuilder.queryParam( + OAuth2ParameterNames.STATE, + UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8)); } + redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded this.redirectStrategy.sendRedirect(request, response, redirectUri); } @@ -344,22 +338,14 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r } 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)); } + redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded this.redirectStrategy.sendRedirect(request, response, redirectUri); } - private UriBuilderFactory valuesOnlyEncodingUriBuilderFactory() { - DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(); - uriBuilderFactory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY); - return uriBuilderFactory; - } - /** * For internal use only. */ 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 c794097f3..7f75750d9 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 @@ -286,7 +286,12 @@ 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); @@ -296,8 +301,9 @@ private void assertAuthorizationRequestRedirectsToClient(String authorizationEnd .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 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); From 7c6516bbbbbe9c526ed77aaa8aa38ddf6fe36547 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Fri, 17 Feb 2023 17:41:21 -0500 Subject: [PATCH 016/250] Next Minor Version --- git/hooks/prepare-forward-merge | 2 +- gradle.properties | 2 +- .../util/SpringAuthorizationServerVersion.java | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index c32f023a8..928c97d0a 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -4,7 +4,7 @@ require 'net/http' require 'yaml' require 'logger' -$main_branch = "1.0.x" +$main_branch = "1.1.x" $log = Logger.new(STDOUT) $log.level = Logger::WARN diff --git a/gradle.properties b/gradle.properties index 323b48afb..bd8d54231 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.1-SNAPSHOT +version=1.1.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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 95e805978..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,8 +23,8 @@ */ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 1; - private static final int MINOR = 0; - private static final int PATCH = 1; + private static final int MINOR = 1; + private static final int PATCH = 0; /** * Global Serialization value for Spring Authorization Server classes. From 98e3fe807a56da3236bc92b4c07f61e61a80efbc Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 30 Jan 2023 10:47:36 -0500 Subject: [PATCH 017/250] Add OpenID Connect 1.0 Logout Endpoint Closes gh-266 --- .../java/sample/jpa/entity/client/Client.java | 12 +- .../AuthorizationRepository.java | 8 +- .../JpaOAuth2AuthorizationService.java | 7 +- .../client/JpaRegisteredClientRepository.java | 6 +- .../gettingStarted/SecurityConfigTests.java | 5 +- .../src/test/java/sample/jpa/JpaTests.java | 5 +- .../java/sample/util/RegisteredClients.java | 3 +- docs/src/docs/asciidoc/guides/how-to-jpa.adoc | 1 + .../InMemoryOAuth2AuthorizationService.java | 13 +- .../JdbcOAuth2AuthorizationService.java | 10 +- ...thorizationCodeAuthenticationProvider.java | 45 +- .../JdbcRegisteredClientRepository.java | 10 +- .../client/RegisteredClient.java | 67 ++- ...OAuth2AuthorizationEndpointConfigurer.java | 11 +- .../OAuth2AuthorizationServerConfigurer.java | 27 +- .../configurers/OAuth2ConfigurerUtils.java | 30 +- .../OAuth2TokenEndpointConfigurer.java | 3 + .../web/configurers/OidcConfigurer.java | 16 +- .../OidcLogoutEndpointConfigurer.java | 219 +++++++++ .../oidc/OidcClientMetadataClaimAccessor.java | 15 +- .../oidc/OidcClientMetadataClaimNames.java | 11 +- .../oidc/OidcClientRegistration.java | 38 +- .../oidc/OidcProviderConfiguration.java | 17 +- .../OidcProviderMetadataClaimAccessor.java | 14 +- .../oidc/OidcProviderMetadataClaimNames.java | 9 +- ...entRegistrationAuthenticationProvider.java | 11 +- .../OidcLogoutAuthenticationProvider.java | 181 ++++++++ .../OidcLogoutAuthenticationToken.java | 170 +++++++ ...ClientOidcClientRegistrationConverter.java | 7 +- ...lientRegistrationHttpMessageConverter.java | 3 +- .../oidc/web/OidcLogoutEndpointFilter.java | 218 +++++++++ ...dcProviderConfigurationEndpointFilter.java | 3 +- .../OidcLogoutAuthenticationConverter.java | 120 +++++ .../settings/AuthorizationServerSettings.java | 28 +- .../settings/ConfigurationSettingNames.java | 8 +- .../authorization/token/JwtGenerator.java | 15 +- .../OAuth2AuthorizationEndpointFilter.java | 19 + .../oauth2-registered-client-schema.sql | 1 + ...MemoryOAuth2AuthorizationServiceTests.java | 28 +- .../JdbcOAuth2AuthorizationServiceTests.java | 34 +- ...zationCodeAuthenticationProviderTests.java | 36 +- .../JdbcRegisteredClientRepositoryTests.java | 7 +- .../client/RegisteredClientTests.java | 43 +- .../client/TestRegisteredClients.java | 2 + .../OidcProviderConfigurationTests.java | 3 +- .../annotation/web/configurers/OidcTests.java | 58 ++- .../oidc/OidcClientRegistrationTests.java | 41 +- .../oidc/OidcProviderConfigurationTests.java | 18 +- ...gistrationAuthenticationProviderTests.java | 78 +++- ...OidcLogoutAuthenticationProviderTests.java | 426 ++++++++++++++++++ .../OidcLogoutAuthenticationTokenTests.java | 109 +++++ ...RegistrationHttpMessageConverterTests.java | 8 +- .../web/OidcLogoutEndpointFilterTests.java | 363 +++++++++++++++ ...viderConfigurationEndpointFilterTests.java | 5 +- .../AuthorizationServerSettingsTests.java | 16 +- .../token/JwtGeneratorTests.java | 18 +- ...Auth2AuthorizationEndpointFilterTests.java | 33 ++ ...custom-oauth2-registered-client-schema.sql | 1 + .../config/AuthorizationServerConfig.java | 3 +- .../sample/config/DefaultSecurityConfig.java | 15 +- .../config/AuthorizationServerConfig.java | 3 +- .../sample/config/DefaultSecurityConfig.java | 15 +- .../config/AuthorizationServerConfig.java | 3 +- .../sample/config/DefaultSecurityConfig.java | 15 +- .../java/sample/config/SecurityConfig.java | 24 +- .../src/main/resources/templates/index.html | 11 +- 66 files changed, 2718 insertions(+), 84 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java 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..5f5429856 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,12 @@ public interface AuthorizationRepository extends JpaRepository findByAuthorizationCodeValue(String authorizationCode); Optional findByAccessTokenValue(String accessToken); Optional findByRefreshTokenValue(String refreshToken); + Optional findByOidcIdTokenValue(String idToken); @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" ) - Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValue(@Param("token") String token); + Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(@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..a4428a87a 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. @@ -35,6 +35,7 @@ import org.springframework.security.oauth2.core.OAuth2Token; 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 +89,7 @@ public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) Optional result; if (tokenType == null) { - result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValue(token); + result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(token); } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { result = this.authorizationRepository.findByState(token); } else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) { @@ -97,6 +98,8 @@ 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 { result = Optional.empty(); } 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/test/java/sample/gettingStarted/SecurityConfigTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java index 22993e032..ed04a0438 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. @@ -102,7 +102,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..a11b78df2 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. @@ -117,7 +117,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(); 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..28d96b854 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. @@ -37,6 +37,7 @@ public static RegisteredClient messagingClient() { .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .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/guides/how-to-jpa.adoc b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc index 89babeede..69b7376de 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, 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..2d2ca5fb1 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. @@ -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.util.Assert; /** @@ -150,6 +152,7 @@ private static boolean hasToken(OAuth2Authorization authorization, String token, return matchesState(authorization, token) || matchesAuthorizationCode(authorization, token) || matchesAccessToken(authorization, token) || + matchesIdToken(authorization, token) || matchesRefreshToken(authorization, token); } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { return matchesState(authorization, token); @@ -157,6 +160,8 @@ private static boolean hasToken(OAuth2Authorization authorization, String token, 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); } @@ -185,6 +190,12 @@ 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 final class MaxSizeHashMap extends LinkedHashMap { private final int maxSize; 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..bb9276715 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. @@ -53,6 +53,7 @@ import org.springframework.security.oauth2.core.OAuth2Token; 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; @@ -112,11 +113,12 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic 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 = ?"; + "access_token_value = ? OR oidc_id_token_value = ? OR refresh_token_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 = ?"; // @formatter:off @@ -240,6 +242,7 @@ 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)); return findBy(UNKNOWN_TOKEN_TYPE_FILTER, parameters); } else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) { @@ -251,6 +254,9 @@ 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); 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..c9d9de0c6 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. @@ -16,8 +16,11 @@ package org.springframework.security.oauth2.server.authorization.authentication; import java.security.Principal; +import java.util.ArrayList; 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 +30,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 +57,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 +85,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. @@ -149,10 +156,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 +219,10 @@ public Authentication authenticate(Authentication authentication) throws Authent // ----- ID token ----- OidcIdToken idToken; if (authorizationRequest.getScopes().contains(OidcScopes.OPENID)) { + SessionInformation sessionInformation = getSessionInformation(principal); + if (sessionInformation != null) { + tokenContextBuilder.put(SessionInformation.class, sessionInformation); + } // @formatter:off tokenContext = tokenContextBuilder .tokenType(ID_TOKEN_TOKEN_TYPE) @@ -265,4 +278,32 @@ 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.0 + */ + 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; + } + } 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..3da2d3703 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. @@ -77,6 +77,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 +91,13 @@ 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 = ?" + + " redirect_uris = ?, post_logout_redirect_uris = ?, scopes = ?, client_settings = ?, token_settings = ?" + " WHERE " + PK_FILTER; // @formatter:on @@ -241,6 +242,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 +259,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 +357,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..3b7bcd09c 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.0 + */ + 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.0 + */ + 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.0 + */ + 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..19c2fd9eb 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 @@ -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. @@ -29,6 +29,8 @@ 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.core.Authentication; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2Token; @@ -240,8 +242,23 @@ 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. + SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity); + 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 = @@ -297,6 +314,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)); 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..50f0282e8 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. @@ -24,8 +24,14 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.GenericApplicationListenerAdapter; +import org.springframework.context.event.SmartApplicationListener; import org.springframework.core.ResolvableType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.context.DelegatingApplicationListener; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; @@ -180,6 +186,28 @@ static AuthorizationServerSettings getAuthorizationServerSettings(HttpSecurity h return authorizationServerSettings; } + static SessionRegistry getSessionRegistry(HttpSecurity httpSecurity) { + SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class); + if (sessionRegistry == null) { + sessionRegistry = getOptionalBean(httpSecurity, SessionRegistry.class); + if (sessionRegistry == null) { + sessionRegistry = new SessionRegistryImpl(); + registerDelegateApplicationListener(httpSecurity, (SessionRegistryImpl) sessionRegistry); + } + httpSecurity.setSharedObject(SessionRegistry.class, sessionRegistry); + } + return sessionRegistry; + } + + private static void registerDelegateApplicationListener(HttpSecurity httpSecurity, ApplicationListener delegate) { + DelegatingApplicationListener delegatingApplicationListener = getOptionalBean(httpSecurity, DelegatingApplicationListener.class); + if (delegatingApplicationListener == null) { + return; + } + SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate); + delegatingApplicationListener.addListener(smartListener); + } + static T getBean(HttpSecurity httpSecurity, Class type) { return httpSecurity.getSharedObject(ApplicationContext.class).getBean(type); } 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..1fb5813e7 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 @@ -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; @@ -216,9 +217,11 @@ private static List createDefaultAuthenticationProviders OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity); OAuth2TokenGenerator tokenGenerator = OAuth2ConfigurerUtils.getTokenGenerator(httpSecurity); + SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity); OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(authorizationService, tokenGenerator); + authorizationCodeAuthenticationProvider.setSessionRegistry(sessionRegistry); authenticationProviders.add(authorizationCodeAuthenticationProvider); OAuth2RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider = 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..f43bab9db 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..92580b6ec --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.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.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.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.0 + * @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), + OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity)); + authenticationProviders.add(oidcLogoutAuthenticationProvider); + + return authenticationProviders; + } + +} 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..8d36b9c30 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.0 + */ + 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..e8d755378 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.0 + */ + 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..036415ffe 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.0 + */ + 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.0 + */ + 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..e2b8567d7 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.0 + */ + 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..d67d1a040 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.0 + */ + 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..b4bd44717 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.0 + */ + 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..27a1cce23 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. @@ -174,6 +174,10 @@ private OidcClientRegistrationAuthenticationToken registerClient(OidcClientRegis throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, OidcClientMetadataClaimNames.REDIRECT_URIS); } + if (!isValidRedirectUris(clientRegistrationAuthentication.getClientRegistration().getPostLogoutRedirectUris())) { + throwInvalidClientRegistration("invalid_client_metadata", OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + } + if (!isValidTokenEndpointAuthenticationMethod(clientRegistrationAuthentication.getClientRegistration())) { throwInvalidClientRegistration("invalid_client_metadata", OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD); } @@ -371,6 +375,11 @@ public RegisteredClient convert(OidcClientRegistration clientRegistration) { 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 -> 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..7de465dd7 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.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.List; + +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.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.0 + * @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.getIdToken(), 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"); + } + + RegisteredClient registeredClient = this.registeredClientRepository.findById( + authorization.getRegisteredClientId()); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + OidcIdToken idToken = authorization.getToken(OidcIdToken.class).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_TOKEN, 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 session + SessionInformation sessionInformation = null; + Authentication userPrincipal = (Authentication) oidcLogoutAuthentication.getPrincipal(); + if (isPrincipalAuthenticated(userPrincipal) && + StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) { + sessionInformation = findSessionInformation( + userPrincipal, oidcLogoutAuthentication.getSessionId()); + if (sessionInformation != null) { + String sidClaim = idToken.getClaim("sid"); + if (!StringUtils.hasText(sidClaim) || + !sidClaim.equals(sessionInformation.getSessionId())) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, "sid"); + } + } + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated logout request"); + } + + return new OidcLogoutAuthenticationToken(oidcLogoutAuthentication.getIdToken(), userPrincipal, + sessionInformation, oidcLogoutAuthentication.getClientId(), + oidcLogoutAuthentication.getPostLogoutRedirectUri(), oidcLogoutAuthentication.getState()); + } + + @Override + public boolean supports(Class authentication) { + return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static boolean isPrincipalAuthenticated(Authentication principal) { + return principal != null && + !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && + principal.isAuthenticated(); + } + + 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); + } + +} 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..a9cc28c03 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationToken.java @@ -0,0 +1,170 @@ +/* + * 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.core.Authentication; +import org.springframework.security.core.session.SessionInformation; +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.0 + * @see AbstractAuthenticationToken + * @see OidcLogoutAuthenticationProvider + */ +public class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final String idToken; + private final Authentication principal; + private final String sessionId; + private final SessionInformation sessionInformation; + private final String clientId; + private final String postLogoutRedirectUri; + private final String state; + + /** + * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters. + * + * @param idToken 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 Client + * @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 idToken, Authentication principal, @Nullable String sessionId, + @Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) { + super(Collections.emptyList()); + Assert.hasText(idToken, "idToken cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + this.idToken = idToken; + this.principal = principal; + this.sessionId = sessionId; + this.sessionInformation = null; + 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 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 sessionInformation the End-User's current authenticated session information with the Client + * @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 idToken, Authentication principal, @Nullable SessionInformation sessionInformation, + @Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) { + super(Collections.emptyList()); + Assert.hasText(idToken, "idToken cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + this.idToken = idToken; + this.principal = principal; + this.sessionId = sessionInformation != null ? sessionInformation.getSessionId() : null; + this.sessionInformation = sessionInformation; + this.clientId = clientId; + this.postLogoutRedirectUri = postLogoutRedirectUri; + this.state = state; + setAuthenticated(true); + } + + /** + * Returns the authenticated principal representing the End-User. + * + * @return the authenticated principal + */ + @Override + public Object getPrincipal() { + return this.principal; + } + + @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 getIdToken() { + return this.idToken; + } + + /** + * Returns the End-User's current authenticated session identifier with the Client. + * + * @return the End-User's current authenticated session identifier + */ + @Nullable + public String getSessionId() { + return this.sessionId; + } + + /** + * Returns the End-User's current authenticated session information with the Client. + * + * @return the End-User's current authenticated session information + */ + @Nullable + public SessionInformation getSessionInformation() { + return this.sessionInformation; + } + + /** + * 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/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..0292d6173 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java @@ -0,0 +1,218 @@ +/* + * 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.0 + * @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.getSessionInformation() != null) { + // 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..608aafd5d 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. @@ -98,6 +98,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .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()) 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..481f8a5a4 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java @@ -0,0 +1,120 @@ +/* + * 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 java.util.Map; + +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.LinkedMultiValueMap; +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.0 + * @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 = getParameters(request); + + // id_token_hint (REQUIRED) // RECOMMENDED as per spec + String idTokenHint = request.getParameter("id_token_hint"); + if (!StringUtils.hasText(idTokenHint) || + request.getParameterValues("id_token_hint").length != 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 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; + } + + 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..a2bb0c80d 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 */ @@ -106,6 +106,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.0 + */ + public String getOidcLogoutEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT); + } + /** * Constructs a new {@link Builder} with the default settings. * @@ -119,7 +129,8 @@ public static Builder builder() { .tokenRevocationEndpoint("/oauth2/revoke") .tokenIntrospectionEndpoint("/oauth2/introspect") .oidcClientRegistrationEndpoint("/connect/register") - .oidcUserInfoEndpoint("/userinfo"); + .oidcUserInfoEndpoint("/userinfo") + .oidcLogoutEndpoint("/connect/logout"); } /** @@ -222,6 +233,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.0 + */ + 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..e7cc341dd 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. @@ -116,6 +116,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.0 + */ + public static final String OIDC_LOGOUT_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("oidc-logout-endpoint"); + private AuthorizationServer() { } 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..66ed255b4 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. @@ -20,6 +20,7 @@ import java.util.Collections; 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; @@ -126,7 +127,11 @@ public Jwt generate(OAuth2TokenContext context) { claimsBuilder.claim(IdTokenClaimNames.NONCE, nonce); } } - // TODO Add 'auth_time' claim + SessionInformation sessionInformation = context.get(SessionInformation.class); + if (sessionInformation != null) { + claimsBuilder.claim("sid", sessionInformation.getSessionId()); + claimsBuilder.claim(IdTokenClaimNames.AUTH_TIME, sessionInformation.getLastRequest()); + } } // @formatter:on @@ -147,6 +152,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/web/OAuth2AuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java index f30bca93a..a109dfd51 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 @@ -35,6 +35,7 @@ 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; @@ -54,6 +55,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; @@ -97,6 +99,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; /** @@ -182,6 +185,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } + this.sessionAuthenticationStrategy.onAuthentication( + authenticationResult, request, response); + this.authenticationSuccessHandler.onAuthenticationSuccess( request, response, authenticationResult); @@ -238,6 +244,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.0 + */ + 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. 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/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..5b08c5203 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. @@ -49,6 +49,7 @@ import org.springframework.security.oauth2.core.OAuth2Token; 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; @@ -76,6 +77,7 @@ 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 String ID = "id"; private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build(); private static final String PRINCIPAL_NAME = "principal"; @@ -344,6 +346,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()))) @@ -494,7 +522,7 @@ private static final class CustomJdbcOAuth2AuthorizationService extends JdbcOAut 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 = ?"; // @formatter:off private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES @@ -539,7 +567,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); } private OAuth2Authorization findBy(String filter, Object... args) { 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..d0bc3613e 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. @@ -19,8 +19,11 @@ import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +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 +34,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; @@ -95,6 +100,7 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests { private OAuth2TokenCustomizer jwtCustomizer; private OAuth2TokenCustomizer accessTokenCustomizer; private OAuth2TokenGenerator tokenGenerator; + private SessionRegistry sessionRegistry; private OAuth2AuthorizationCodeAuthenticationProvider authenticationProvider; @BeforeEach @@ -116,8 +122,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 +154,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(); @@ -456,6 +471,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 +492,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 +508,15 @@ 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().isSameAs(expectedSession); assertThat(idTokenContext.getJwsHeader()).isNotNull(); assertThat(idTokenContext.getClaims()).isNotNull(); 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 6036f0bae..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 @@ -38,6 +38,7 @@ public static RegisteredClient.Builder registeredClient() { .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"); } @@ -52,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/OidcProviderConfigurationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java index 5b304f7fb..f0f4da518 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. @@ -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 00a519df8..80e9f4a52 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 @@ -47,6 +47,7 @@ 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; @@ -123,6 +124,7 @@ 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; @@ -216,8 +218,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<>(); @@ -225,6 +228,59 @@ 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(); + } + + @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) + .params(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())) + .session(session)) + .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 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..5ca357998 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. @@ -62,6 +62,7 @@ public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() { .userInfoEndpoint("https://example.com/issuer1/userinfo") .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()) .clientRegistrationEndpoint("https://example.com/issuer1/connect/register") + .endSessionEndpoint("https://example.com/issuer1/connect/logout") .claim("a-claim", "a-value") .build(); @@ -77,6 +78,7 @@ public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() { assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo")); assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()); assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register")); + assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/logout")); assertThat(providerConfiguration.getClaim("a-claim")).isEqualTo("a-value"); } @@ -118,6 +120,7 @@ public void buildWhenClaimsProvidedThenCreated() { 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.END_SESSION_ENDPOINT, "https://example.com/issuer1/connect/logout"); claims.put("some-claim", "some-value"); OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build(); @@ -134,6 +137,7 @@ public void buildWhenClaimsProvidedThenCreated() { assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo")); assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register")); + assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/logout")); assertThat(providerConfiguration.getClaim("some-claim")).isEqualTo("some-value"); } @@ -150,6 +154,7 @@ public void buildWhenClaimsProvidedWithUrlsThenCreated() { 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.END_SESSION_ENDPOINT, url("https://example.com/issuer1/connect/logout")); claims.put("some-claim", "some-value"); OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build(); @@ -166,6 +171,7 @@ public void buildWhenClaimsProvidedWithUrlsThenCreated() { assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo")); assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register")); + assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/issuer1/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..3ebde3116 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. @@ -359,6 +359,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(); @@ -545,6 +617,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") @@ -588,6 +661,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 +677,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..1bb0db18b --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java @@ -0,0 +1,426 @@ +/* + * 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 java.time.temporal.ChronoUnit; +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.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.getIdToken()), 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.getIdToken()), 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.getIdToken()), 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_TOKEN); + assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID); + }); + verify(this.authorizationService).findByToken( + eq(authentication.getIdToken()), 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.getIdToken()), 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.getIdToken()), 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.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenValidIdTokenThenAuthenticated() { + 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", sessionId) + .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); + + 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.getIdToken()), 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.getTokenValue()); + assertThat(authenticationResult.getSessionId()).isEqualTo(sessionInformation.getSessionId()); + assertThat(authenticationResult.getSessionInformation()).isEqualTo(sessionInformation); + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPostLogoutRedirectUri()).isEqualTo(postLogoutRedirectUri); + assertThat(authenticationResult.getState()).isEqualTo(state); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + } + +} 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..f6af09d2e --- /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.sql.Date; +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.session.SessionInformation; + +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 idToken = "id-token"; + private final TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + private final String sessionId = "session-1"; + private final SessionInformation sessionInformation = new SessionInformation(this.principal, "session-2", Date.from(Instant.now())); + 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 constructorWhenIdTokenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + null, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idToken cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + null, this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idToken cannot be empty"); + } + + @Test + public void constructorWhenIdTokenEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + "", this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idToken cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + "", this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idToken cannot be empty"); + } + + @Test + public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + this.idToken, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("principal cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcLogoutAuthenticationToken( + this.idToken, null, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("principal cannot be null"); + } + + @Test + public void constructorWhenSessionIdProvidedThenCreated() { + 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.getIdToken()).isEqualTo(this.idToken); + assertThat(authentication.getSessionId()).isEqualTo(this.sessionId); + assertThat(authentication.getSessionInformation()).isNull(); + 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 constructorWhenSessionInformationProvidedThenCreated() { + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + this.idToken, this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state); + assertThat(authentication.getPrincipal()).isEqualTo(this.principal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getIdToken()).isEqualTo(this.idToken); + assertThat(authentication.getSessionId()).isEqualTo(this.sessionInformation.getSessionId()); + assertThat(authentication.getSessionInformation()).isEqualTo(this.sessionInformation); + 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/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..e973b5efe --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java @@ -0,0 +1,363 @@ +/* + * 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.time.Instant; +import java.util.Date; +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.core.session.SessionInformation; +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, (SessionInformation) 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, (SessionInformation) 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); + + SessionInformation sessionInformation = new SessionInformation( + this.principal, session.getId(), Date.from(Instant.now())); + + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", this.principal, sessionInformation, 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); + + SessionInformation sessionInformation = new SessionInformation( + this.principal, session.getId(), Date.from(Instant.now())); + + String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().iterator().next(); + String state = "state-1"; + OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( + "id-token", this.principal, sessionInformation, + registeredClient.getClientId(), postLogoutRedirectUri, state); + + 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..3d175e673 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. @@ -92,6 +92,7 @@ public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws E 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(); @@ -132,6 +134,7 @@ public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws E 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("\"end_session_endpoint\":\"https://example.com/issuer1/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..ccf3884fb 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(10); 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/token/JwtGeneratorTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java index c22a5492f..5d934686a 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,6 +28,7 @@ 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.endpoint.OAuth2AuthorizationRequest; @@ -46,7 +48,6 @@ 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.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 +68,7 @@ public class JwtGeneratorTests { private JwtEncoder jwtEncoder; private OAuth2TokenCustomizer jwtCustomizer; private JwtGenerator jwtGenerator; - private AuthorizationServerContext authorizationServerContext; + private TestAuthorizationServerContext authorizationServerContext; @BeforeEach public void setUp() { @@ -168,16 +169,21 @@ 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 @@ -238,6 +244,10 @@ private void assertGeneratedTokenType(OAuth2TokenContext tokenContext) { 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()); } } 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 b740460db..09fe2afae 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 @@ -58,6 +58,7 @@ 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 static org.assertj.core.api.Assertions.assertThat; @@ -151,6 +152,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"; @@ -383,6 +391,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(); 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/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index a2cf209b6..c22151d21 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/custom-consent-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. @@ -94,6 +94,7 @@ public RegisteredClientRepository registeredClientRepository() { .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/index") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") 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 index 7266901af..b4370c024 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ b/samples/custom-consent-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. @@ -19,11 +19,14 @@ 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.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.session.HttpSessionEventPublisher; import static org.springframework.security.config.Customizer.withDefaults; @@ -58,4 +61,14 @@ UserDetailsService users() { } // @formatter:on + @Bean + SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + } diff --git a/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index f1d9b7e4f..9abaa8e7b 100644 --- a/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/default-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. @@ -88,6 +88,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/index") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") diff --git a/samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java index 36c5f7f67..cb7bd1b5b 100644 --- a/samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ b/samples/default-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. @@ -19,11 +19,14 @@ 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.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.session.HttpSessionEventPublisher; import static org.springframework.security.config.Customizer.withDefaults; @@ -59,4 +62,14 @@ UserDetailsService users() { } // @formatter:on + @Bean + SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + } diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index f8df51481..2dd895edd 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/federated-identity-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. @@ -90,6 +90,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/index") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java index 99706e81d..f4ba86745 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ b/samples/federated-identity-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. @@ -23,11 +23,14 @@ 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.session.HttpSessionEventPublisher; /** * @author Steve Riesenberg @@ -66,4 +69,14 @@ public UserDetailsService users() { } // @formatter:on + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + } diff --git a/samples/messages-client/src/main/java/sample/config/SecurityConfig.java b/samples/messages-client/src/main/java/sample/config/SecurityConfig.java index 6532782fb..29ec525fc 100644 --- a/samples/messages-client/src/main/java/sample/config/SecurityConfig.java +++ b/samples/messages-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. @@ -15,12 +15,16 @@ */ package sample.config; +import org.springframework.beans.factory.annotation.Autowired; 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.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; @@ -32,6 +36,9 @@ @Configuration(proxyBeanMethods = false) public class SecurityConfig { + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + @Bean WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring().requestMatchers("/webjars/**"); @@ -46,9 +53,22 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { ) .oauth2Login(oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc")) - .oauth2Client(withDefaults()); + .oauth2Client(withDefaults()) + .logout(logout -> + logout.logoutSuccessHandler(oidcLogoutSuccessHandler())); return http.build(); } // @formatter:on + private LogoutSuccessHandler oidcLogoutSuccessHandler() { + OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = + new OidcClientInitiatedLogoutSuccessHandler(this.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}/index"); + + return oidcLogoutSuccessHandler; + } + } diff --git a/samples/messages-client/src/main/resources/templates/index.html b/samples/messages-client/src/main/resources/templates/index.html index edf9d32be..23085c1d4 100644 --- a/samples/messages-client/src/main/resources/templates/index.html +++ b/samples/messages-client/src/main/resources/templates/index.html @@ -1,5 +1,5 @@ - + Spring Security OAuth 2.0 Sample @@ -10,10 +10,11 @@

From 0cd594082e0beb5346eb0e2c94e8e82c71c11f1e Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Sun, 19 Feb 2023 05:23:59 -0500 Subject: [PATCH 018/250] Polish authorization error response encoding Issue gh-1011 --- .../OAuth2AuthorizationEndpointFilter.java | 14 +++++---- .../OAuth2AuthorizationCodeGrantTests.java | 31 +++++++++++++++++++ ...Auth2AuthorizationEndpointFilterTests.java | 18 ++++++----- 3 files changed, 50 insertions(+), 13 deletions(-) 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 76bcbe6ac..08b8aa5fd 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 @@ -298,13 +298,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, UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8)); } - redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded + String redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded this.redirectStrategy.sendRedirect(request, response, redirectUri); } @@ -331,18 +330,21 @@ 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, UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8)); } - redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded + String redirectUri = uriBuilder.build(true).toUriString(); // build(true) -> Components are explicitly encoded this.redirectStrategy.sendRedirect(request, response, redirectUri); } 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 7f75750d9..95d71a7dc 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 @@ -127,6 +127,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; @@ -455,6 +456,36 @@ 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) + .params(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 requestWhenCustomTokenGeneratorThenUsed() throws Exception { this.spring.register(AuthorizationServerConfigurationWithTokenGenerator.class).autowire(); 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 3597131d2..6dfd58cea 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 @@ -280,12 +280,17 @@ public void doFilterWhenAuthorizationRequestMultipleCodeChallengeMethodThenInval @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)); @@ -300,8 +305,7 @@ public void doFilterWhenAuthorizationRequestAuthenticationExceptionThenErrorResp assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); assertThat(response.getRedirectedUrl()).isEqualTo( - request.getParameter(OAuth2ParameterNames.REDIRECT_URI) + - "?error=errorCode&error_description=errorDescription&error_uri=errorUri&state=state"); + "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); } @@ -546,7 +550,7 @@ public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationRespon 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); @@ -568,7 +572,7 @@ public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationRespon .isEqualTo(REMOTE_ADDRESS); assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); assertThat(response.getRedirectedUrl()).isEqualTo( - "https://example.com?param=encoded%20parameter%20value&code=code&state=state"); + "https://example.com?param=encoded%20parameter%20value&code=code&state=client%20state"); } @Test From 7ba01c89daf110d1ec1817a7d1692ce3b1731895 Mon Sep 17 00:00:00 2001 From: Jerome Prinet Date: Tue, 14 Feb 2023 14:46:22 +0100 Subject: [PATCH 019/250] Update Gradle Enterprise plugin --- settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index 893c709f1..4c8761af0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,7 +7,7 @@ pluginManagement { } plugins { - id "com.gradle.enterprise" version "3.11.1" + id "com.gradle.enterprise" version "3.12.3" id "io.spring.ge.conventions" version "0.0.11" } From 813bafdc21692ce379c4f3f1f4ba8bd8a87fb401 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 09:48:25 -0500 Subject: [PATCH 020/250] Update to Spring Framework 5.3.25 Closes gh-1080 --- buildSrc/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 2b94d850f..6e541d159 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:5.3.24" + implementation "org.springframework:spring-core:5.3.25" } diff --git a/gradle.properties b/gradle.properties index e3e4f3f01..5e21ef7a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=0.4.1-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=5.3.24 +springFrameworkVersion=5.3.25 springSecurityVersion=5.8.0 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From 1c791c47561c7e6d057914388167fd54cc902e0a Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 09:51:34 -0500 Subject: [PATCH 021/250] Update to Spring Security 5.8.2 Closes gh-1081 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 5e21ef7a9..3910f171c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=5.3.25 -springSecurityVersion=5.8.0 +springSecurityVersion=5.8.2 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 From a7b099bd74c76d168fdd2e2acb0a5454c62a96db Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 10:04:08 -0500 Subject: [PATCH 022/250] Update to io.spring.nohttp:nohttp-checkstyle:0.0.11 Closes gh-1082 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 3910f171c..a90567c61 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,5 +7,5 @@ springSecurityVersion=5.8.2 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 -nohttpCheckstyleVersion=0.0.10 +nohttpCheckstyleVersion=0.0.11 jacocoToolVersion=0.8.7 From 262246b508e3648d674aae995907c20a5b277e8c Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 10:06:25 -0500 Subject: [PATCH 023/250] Update to io.spring.javaformat:spring-javaformat-checkstyle:0.0.35 Closes gh-1083 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a90567c61..9e351c75e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=5.3.25 springSecurityVersion=5.8.2 -springJavaformatVersion=0.0.31 +springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 nohttpCheckstyleVersion=0.0.11 From ca11e2921cf3a9883b7bfcb1a1f065b0f4951e27 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 10:12:19 -0500 Subject: [PATCH 024/250] Update to jackson-bom:2.14.2 Closes gh-1084 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 41b79dad1..ef3d083b2 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -9,7 +9,7 @@ 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.14.2") constraints { api "com.nimbusds:nimbus-jose-jwt:9.24.4" api "javax.servlet:javax.servlet-api:4.0.1" From 2177c484df1e9bd0ad547ed24f0c24a86ec7fd32 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 10:16:06 -0500 Subject: [PATCH 025/250] Update to junit-jupiter:5.9.2 Closes gh-1085 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index ef3d083b2..6d4c18ed0 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -13,7 +13,7 @@ dependencies { constraints { api "com.nimbusds:nimbus-jose-jwt:9.24.4" api "javax.servlet:javax.servlet-api:4.0.1" - api "org.junit.jupiter:junit-jupiter:5.9.1" + api "org.junit.jupiter:junit-jupiter:5.9.2" api "org.assertj:assertj-core:3.23.1" api "org.mockito:mockito-core:4.8.1" api "com.squareup.okhttp3:mockwebserver:4.10.0" From 36dc37143ed830efb4bbcc6abbad065870fe7c8b Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 10:57:52 -0500 Subject: [PATCH 026/250] Release 0.4.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 9e351c75e..5ec2558bc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=0.4.1-SNAPSHOT +version=0.4.1 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From fc6bf6160eab532be4586b7fc2408b65b8290783 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:11:48 -0500 Subject: [PATCH 027/250] Next Development Version --- git/hooks/prepare-forward-merge | 2 +- gradle.properties | 2 +- .../authorization/util/SpringAuthorizationServerVersion.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index c32f023a8..928c97d0a 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -4,7 +4,7 @@ require 'net/http' require 'yaml' require 'logger' -$main_branch = "1.0.x" +$main_branch = "1.1.x" $log = Logger.new(STDOUT) $log.level = Logger::WARN diff --git a/gradle.properties b/gradle.properties index 5ec2558bc..6a2df014e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=0.4.1 +version=0.4.2-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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 519464334..49d702e35 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. @@ -24,7 +24,7 @@ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 0; private static final int MINOR = 4; - private static final int PATCH = 1; + private static final int PATCH = 2; /** * Global Serialization value for Spring Authorization Server classes. From 419754bd86ccb88677681a91a0ef64c2c36a6680 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:48:01 -0500 Subject: [PATCH 028/250] Update to Spring Framework 6.0.5 Closes gh-1086 --- buildSrc/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 13c8a355c..dc0c46910 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" 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.5" } diff --git a/gradle.properties b/gradle.properties index 323b48afb..067f5e2a0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=1.0.1-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=6.0.0 +springFrameworkVersion=6.0.5 springSecurityVersion=6.0.0 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From 029725b4ac05ad3a0c2faf01ece344a6dd39ac7f Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:48:33 -0500 Subject: [PATCH 029/250] Update to Spring Security 6.0.2 Closes gh-1087 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 067f5e2a0..ef18f46d3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.5 -springSecurityVersion=6.0.0 +springSecurityVersion=6.0.2 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 From 0d9f3a9f085ba677458bbd45f4e5a6e1929c9cf9 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:50:43 -0500 Subject: [PATCH 030/250] Update to io.spring.nohttp:nohttp-checkstyle:0.0.11 Closes gh-1088 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ef18f46d3..2d151f95a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,5 +7,5 @@ springSecurityVersion=6.0.2 springJavaformatVersion=0.0.31 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 -nohttpCheckstyleVersion=0.0.10 +nohttpCheckstyleVersion=0.0.11 jacocoToolVersion=0.8.7 From d782e5c48bb94a848e1a10b2c09956952655eda2 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:51:17 -0500 Subject: [PATCH 031/250] Update to io.spring.javaformat:spring-javaformat-checkstyle:0.0.35 Closes gh-1089 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2d151f95a..d7725b7eb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.5 springSecurityVersion=6.0.2 -springJavaformatVersion=0.0.31 +springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 nohttpCheckstyleVersion=0.0.11 From 2f66551dec59596d20bf84c82a74a81193f685e8 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:52:06 -0500 Subject: [PATCH 032/250] Update to jackson-bom:2.14.2 Closes gh-1090 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 57f3dddee..0786491d8 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -9,7 +9,7 @@ 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.14.2") constraints { api "com.nimbusds:nimbus-jose-jwt:9.24.4" api "jakarta.servlet:jakarta.servlet-api:6.0.0" From ecf349522ed4cb706195107e90dee67cde5765ba Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 11:53:25 -0500 Subject: [PATCH 033/250] Update to junit-jupiter:5.9.2 Closes gh-1091 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 0786491d8..489883a95 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -13,7 +13,7 @@ dependencies { constraints { api "com.nimbusds:nimbus-jose-jwt:9.24.4" api "jakarta.servlet:jakarta.servlet-api:6.0.0" - api "org.junit.jupiter:junit-jupiter:5.9.1" + api "org.junit.jupiter:junit-jupiter:5.9.2" api "org.assertj:assertj-core:3.23.1" api "org.mockito:mockito-core:4.8.1" api "com.squareup.okhttp3:mockwebserver:4.10.0" From 3da46217baf885d7ebe338d2008b17f5df696d57 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 12:19:52 -0500 Subject: [PATCH 034/250] Release 1.0.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index d7725b7eb..abe499eb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.1-SNAPSHOT +version=1.0.1 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 2ee1a179e9861ff10eb9dddfff7261b56a0e40c2 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 12:36:51 -0500 Subject: [PATCH 035/250] Next Development Version --- git/hooks/prepare-forward-merge | 2 +- gradle.properties | 2 +- .../authorization/util/SpringAuthorizationServerVersion.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index c32f023a8..928c97d0a 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -4,7 +4,7 @@ require 'net/http' require 'yaml' require 'logger' -$main_branch = "1.0.x" +$main_branch = "1.1.x" $log = Logger.new(STDOUT) $log.level = Logger::WARN diff --git a/gradle.properties b/gradle.properties index abe499eb5..e20a3abf8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.1 +version=1.0.2-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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 95e805978..e3a311131 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. @@ -24,7 +24,7 @@ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 1; private static final int MINOR = 0; - private static final int PATCH = 1; + private static final int PATCH = 2; /** * Global Serialization value for Spring Authorization Server classes. From d6bac68a6ad4be30fea76ca2fd120957486c6720 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 14:08:47 -0500 Subject: [PATCH 036/250] Update to Spring Security 6.1.0-M1 Closes gh-1093 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 3b48e60f6..2f6d04a9e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.5 -springSecurityVersion=6.0.2 +springSecurityVersion=6.1.0-M1 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 From 4c9bc605ac1d18fe39d4832c139be1604772f2c4 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 14:21:53 -0500 Subject: [PATCH 037/250] Update to nimbus-jose-jwt:9.30.2 Closes gh-1094 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 489883a95..ade63f772 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -11,7 +11,7 @@ dependencies { api platform("org.springframework.security:spring-security-bom:$springSecurityVersion") api platform("com.fasterxml.jackson:jackson-bom:2.14.2") constraints { - api "com.nimbusds:nimbus-jose-jwt:9.24.4" + api "com.nimbusds:nimbus-jose-jwt:9.30.2" api "jakarta.servlet:jakarta.servlet-api:6.0.0" api "org.junit.jupiter:junit-jupiter:5.9.2" api "org.assertj:assertj-core:3.23.1" From 2bb112ee25d411f5e4a8e06038d1c890b09421fa Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 14:23:14 -0500 Subject: [PATCH 038/250] Update to assertj-core:3.24.2 Closes gh-1095 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index ade63f772..3177a301a 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -14,7 +14,7 @@ dependencies { api "com.nimbusds:nimbus-jose-jwt:9.30.2" api "jakarta.servlet:jakarta.servlet-api:6.0.0" api "org.junit.jupiter:junit-jupiter:5.9.2" - api "org.assertj:assertj-core:3.23.1" + api "org.assertj:assertj-core:3.24.2" api "org.mockito:mockito-core:4.8.1" api "com.squareup.okhttp3:mockwebserver:4.10.0" api "com.squareup.okhttp3:okhttp:4.10.0" From 397c69c74b9add7c51dca0cca39606a6c0dfdd67 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 14:23:58 -0500 Subject: [PATCH 039/250] Update to mockito-core:4.11.0 Closes gh-1096 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 3177a301a..d0a3560e6 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -15,7 +15,7 @@ dependencies { api "jakarta.servlet:jakarta.servlet-api:6.0.0" api "org.junit.jupiter:junit-jupiter:5.9.2" api "org.assertj:assertj-core:3.24.2" - api "org.mockito:mockito-core:4.8.1" + 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" From d4c66a20f714852509a2225c060b9e1a4092f7ab Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 14:10:34 -0500 Subject: [PATCH 040/250] Release 1.1.0-M1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2f6d04a9e..0ee799235 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0-SNAPSHOT +version=1.1.0-M1 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 81e05c60fc5179bc32bd3b05e94582f9098309fc Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Feb 2023 14:45:16 -0500 Subject: [PATCH 041/250] Next Development Version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0ee799235..2f6d04a9e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0-M1 +version=1.1.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 860133a352194fd9d329b76e0b0950df06bde6d0 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 23 Feb 2023 07:01:34 -0500 Subject: [PATCH 042/250] Add user property to deploy_docs workflow --- .github/workflows/continuous-integration-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 309971003..8e653d2f4 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -125,4 +125,4 @@ jobs: 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 + run: ./gradlew deployDocs -Duser.name=spring-builds+github -PdeployDocsSshKey="$DOCS_SSH_KEY" -PdeployDocsSshUsername="$DOCS_USERNAME" -PdeployDocsHost="$DOCS_HOST" --stacktrace From b7f842f6d07638c3c84fb097d18a0a770ea83e84 Mon Sep 17 00:00:00 2001 From: Siva Kumar Edupuganti Date: Tue, 21 Feb 2023 10:47:38 -0700 Subject: [PATCH 043/250] Fix broken support link Closes gh-1092 --- README.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.adoc b/README.adoc index 13705540e..6438c9576 100644 --- a/README.adoc +++ b/README.adoc @@ -79,7 +79,7 @@ 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. +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. From 63aa5d8933b46bb0157a03b095cf79eb3acbd604 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 31 Jan 2023 10:39:30 +0900 Subject: [PATCH 044/250] Fix client secret encoding when client dynamically registered Closes gh-1056 --- ...cClientRegistrationEndpointConfigurer.java | 7 +++- ...entRegistrationAuthenticationProvider.java | 38 ++++++++++++++++++- .../OidcClientRegistrationTests.java | 37 +++++++++++++++--- ...gistrationAuthenticationProviderTests.java | 26 ++++++++++++- 4 files changed, 99 insertions(+), 9 deletions(-) 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 4e5d3f060..c5c338932 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/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java index 97166d0d9..c47040dad 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 { @@ -91,6 +94,8 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe private final Converter clientRegistrationConverter; private Converter registeredClientConverter; + private PasswordEncoder passwordEncoder; + /** * Constructs an {@code OidcClientRegistrationAuthenticationProvider} using the provided parameters. * @@ -109,6 +114,21 @@ public OidcClientRegistrationAuthenticationProvider(RegisteredClientRepository r this.tokenGenerator = tokenGenerator; this.clientRegistrationConverter = new RegisteredClientOidcClientRegistrationConverter(); this.registeredClientConverter = new OidcClientRegistrationRegisteredClientConverter(); + this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /** + * Sets the {@link PasswordEncoder} used to encode the clientSecret + * the {@link RegisteredClient#getClientSecret() client secret}. + * If not set, the client secret will be encoded using + * {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()}. + * + * @param passwordEncoder the {@link PasswordEncoder} used to encode the clientSecret + * @since 1.1.0 + */ + public void setPasswordEncoder(PasswordEncoder passwordEncoder) { + Assert.notNull(passwordEncoder, "passwordEncoder cannot be null"); + this.passwordEncoder = passwordEncoder; } @Override @@ -183,7 +203,21 @@ private OidcClientRegistrationAuthenticationToken registerClient(OidcClientRegis } RegisteredClient registeredClient = this.registeredClientConverter.convert(clientRegistrationAuthentication.getClientRegistration()); - this.registeredClientRepository.save(registeredClient); + + // When secret exists, copy RegisteredClient and encode only secret + String rawClientSecret = registeredClient.getClientSecret(); + String clientSecret = null; + RegisteredClient saveRegisteredClient = null; + if (rawClientSecret != null) { + clientSecret = passwordEncoder.encode(rawClientSecret); + saveRegisteredClient = RegisteredClient.from(registeredClient) + .clientSecret(clientSecret) + .build(); + } else { + saveRegisteredClient = registeredClient; + } + + this.registeredClientRepository.save(saveRegisteredClient); if (this.logger.isTraceEnabled()) { this.logger.trace("Saved registered client"); 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 16395f30f..5f6b949b6 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. @@ -56,7 +56,7 @@ 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.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -111,6 +111,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; @@ -289,7 +290,6 @@ public void requestWhenClientConfigurationRequestAuthorizedThenClientRegistratio assertThat(clientConfigurationResponse.getClientId()).isEqualTo(clientRegistrationResponse.getClientId()); assertThat(clientConfigurationResponse.getClientIdIssuedAt()).isEqualTo(clientRegistrationResponse.getClientIdIssuedAt()); - assertThat(clientConfigurationResponse.getClientSecret()).isEqualTo(clientRegistrationResponse.getClientSecret()); assertThat(clientConfigurationResponse.getClientSecretExpiresAt()).isEqualTo(clientRegistrationResponse.getClientSecretExpiresAt()); assertThat(clientConfigurationResponse.getClientName()).isEqualTo(clientRegistrationResponse.getClientName()); assertThat(clientConfigurationResponse.getRedirectUris()) @@ -357,6 +357,34 @@ public void requestWhenClientRegistrationEndpointCustomizedThenUsed() throws Exc verifyNoInteractions(authenticationFailureHandler); } + // 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); + + MvcResult mvcResult = 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(); + } + @Test public void requestWhenClientRegistrationEndpointCustomizedWithAuthenticationFailureHandlerThenUsed() throws Exception { this.spring.register(CustomClientRegistrationConfiguration.class).autowire(); @@ -563,9 +591,8 @@ 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/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java index 3c51c8a67..bbf23604c 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; @@ -90,6 +92,8 @@ public class OidcClientRegistrationAuthenticationProviderTests { private AuthorizationServerSettings authorizationServerSettings; private OidcClientRegistrationAuthenticationProvider authenticationProvider; + private PasswordEncoder passwordEncoder; + @BeforeEach public void setUp() { this.registeredClientRepository = mock(RegisteredClientRepository.class); @@ -106,6 +110,18 @@ public Jwt generate(OAuth2TokenContext context) { AuthorizationServerContextHolder.setContext(new TestAuthorizationServerContext(this.authorizationServerSettings, null)); this.authenticationProvider = new OidcClientRegistrationAuthenticationProvider( this.registeredClientRepository, this.authorizationService, this.tokenGenerator); + 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.authenticationProvider.setPasswordEncoder(this.passwordEncoder); } @AfterEach @@ -141,6 +157,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(); @@ -472,6 +495,7 @@ public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvid assertThat(authenticationResult.getClientRegistration().getTokenEndpointAuthenticationSigningAlgorithm()) .isEqualTo(MacAlgorithm.HS256.getName()); assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNotNull(); + verify(this.passwordEncoder).encode(any()); // @formatter:off builder From addd6e13d57eb1567f951c9ac1102998faac7b90 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 6 Mar 2023 16:31:19 -0500 Subject: [PATCH 045/250] Polish gh-1056 --- ...cClientRegistrationEndpointConfigurer.java | 7 +--- ...entRegistrationAuthenticationProvider.java | 40 +++++++------------ .../OidcClientRegistrationTests.java | 30 +++++++------- ...gistrationAuthenticationProviderTests.java | 16 +++++--- 4 files changed, 41 insertions(+), 52 deletions(-) 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 c5c338932..4e5d3f060 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-2023 the original author or authors. + * 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. @@ -26,7 +26,6 @@ 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; @@ -222,10 +221,6 @@ 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/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java index c47040dad..a2ddbe4a1 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 @@ -30,6 +30,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; @@ -93,7 +94,6 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe private final OAuth2TokenGenerator tokenGenerator; private final Converter clientRegistrationConverter; private Converter registeredClientConverter; - private PasswordEncoder passwordEncoder; /** @@ -117,20 +117,6 @@ public OidcClientRegistrationAuthenticationProvider(RegisteredClientRepository r this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); } - /** - * Sets the {@link PasswordEncoder} used to encode the clientSecret - * the {@link RegisteredClient#getClientSecret() client secret}. - * If not set, the client secret will be encoded using - * {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()}. - * - * @param passwordEncoder the {@link PasswordEncoder} used to encode the clientSecret - * @since 1.1.0 - */ - public void setPasswordEncoder(PasswordEncoder passwordEncoder) { - Assert.notNull(passwordEncoder, "passwordEncoder cannot be null"); - this.passwordEncoder = passwordEncoder; - } - @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = @@ -187,6 +173,13 @@ public void setRegisteredClientConverter(Converter tokenGenerator; + private PasswordEncoder passwordEncoder; private AuthorizationServerSettings authorizationServerSettings; private OidcClientRegistrationAuthenticationProvider authenticationProvider; - private PasswordEncoder passwordEncoder; - @BeforeEach public void setUp() { this.registeredClientRepository = mock(RegisteredClientRepository.class); @@ -106,10 +107,6 @@ public Jwt generate(OAuth2TokenContext context) { return jwtGenerator.generate(context); } }); - 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.passwordEncoder = spy(new PasswordEncoder() { @Override public String encode(CharSequence rawPassword) { @@ -121,6 +118,10 @@ 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); } @@ -496,6 +497,7 @@ public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvid .isEqualTo(MacAlgorithm.HS256.getName()); assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNotNull(); verify(this.passwordEncoder).encode(any()); + reset(this.passwordEncoder); // @formatter:off builder @@ -507,6 +509,7 @@ public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvid assertThat(authenticationResult.getClientRegistration().getTokenEndpointAuthenticationSigningAlgorithm()) .isEqualTo(SignatureAlgorithm.RS256.getName()); assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNull(); + verifyNoInteractions(this.passwordEncoder); } @Test @@ -589,6 +592,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); From d197c188ba75c71aab546316ac9e1ec37f4b6fbc Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 7 Mar 2023 05:15:27 -0500 Subject: [PATCH 046/250] Allow PasswordEncoder to be configured in OidcClientRegistrationAuthenticationProvider Issue gh-1056 --- .../OidcClientRegistrationEndpointConfigurer.java | 7 ++++++- ...OidcClientRegistrationAuthenticationProvider.java | 12 ++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) 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/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java index 8448bb8a4..fa7a9049e 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 @@ -30,7 +30,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; @@ -173,9 +172,14 @@ public void setRegisteredClientConverter(Converter Date: Mon, 27 Feb 2023 10:23:39 -0600 Subject: [PATCH 047/250] Upgrade client secret when available Closes gh-1099 --- .../ClientSecretAuthenticationProvider.java | 7 +++++ .../InMemoryRegisteredClientRepository.java | 11 +++++-- .../JdbcRegisteredClientRepository.java | 7 ++--- ...ientSecretAuthenticationProviderTests.java | 27 ++++++++++++++++ ...MemoryRegisteredClientRepositoryTests.java | 10 +++--- .../OAuth2ClientCredentialsGrantTests.java | 31 +++++++++++++++++++ 6 files changed, 81 insertions(+), 12 deletions(-) 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..de673b65e 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 @@ -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(); + 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/client/InMemoryRegisteredClientRepository.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java index 38edf7dff..6c6a18869 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 @@ -72,9 +72,14 @@ public InMemoryRegisteredClientRepository(List registrations) @Override public void save(RegisteredClient registeredClient) { Assert.notNull(registeredClient, "registeredClient cannot be null"); - assertUniqueIdentifiers(registeredClient, this.idRegistrationMap); - this.idRegistrationMap.put(registeredClient.getId(), registeredClient); - this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); + if (this.idRegistrationMap.containsKey(registeredClient.getId())) { + this.idRegistrationMap.put(registeredClient.getId(), registeredClient); + this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); + } else { + assertUniqueIdentifiers(registeredClient, this.idRegistrationMap); + this.idRegistrationMap.put(registeredClient.getId(), registeredClient); + this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); + } } @Nullable 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 3da2d3703..37a5cb69e 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 @@ -96,8 +96,9 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor // @formatter:off private static final String UPDATE_REGISTERED_CLIENT_SQL = "UPDATE " + TABLE_NAME - + " SET client_name = ?, client_authentication_methods = ?, authorization_grant_types = ?," - + " redirect_uris = ?, post_logout_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 @@ -136,8 +137,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); 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..17f757ebe 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 @@ -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,28 @@ public void authenticateWhenValidCredentialsThenAuthenticated() { assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); } + @Test + public void authenticateWhenValidCredentialsAndNonExpiredThenPasswordUpgraded() { + 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(registeredClient).isNotSameAs(authenticationResult.getPrincipal()); + assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret()); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(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/client/InMemoryRegisteredClientRepositoryTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java index d7638ee8c..94048a28e 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 @@ -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.registration.getId(), "client-id", "client-secret-2"); + this.clients.save(registeredClient); + RegisteredClient savedClient = this.clients.findByClientId(registeredClient.getClientId()); + assertThat(savedClient.getClientSecret()).isEqualTo("client-secret-2"); } @Test 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..65c16c337 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 @@ -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; @@ -231,6 +232,27 @@ public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponse() thr verify(jwtCustomizer).customize(any()); } + @Test + public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponseAndSecretUpgraded() 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()); + assertThat(this.registeredClientRepository.findByClientId(registeredClient.getClientId()).getClientSecret()).startsWith("{bcrypt}"); + } + @Test public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception { this.spring.register(AuthorizationServerConfigurationCustomTokenEndpoint.class).autowire(); @@ -429,6 +451,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 { From ad01779479d72ede119c1d6110edc3fa9d1bff3a Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 7 Mar 2023 05:45:28 -0500 Subject: [PATCH 048/250] Polish gh-1105 --- .../ClientSecretAuthenticationProvider.java | 4 ++-- .../client/InMemoryRegisteredClientRepository.java | 11 ++++------- .../ClientSecretAuthenticationProviderTests.java | 7 +++---- .../InMemoryRegisteredClientRepositoryTests.java | 6 +++--- .../OAuth2ClientCredentialsGrantTests.java | 7 ++++--- 5 files changed, 16 insertions(+), 19 deletions(-) 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 de673b65e..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. @@ -126,7 +126,7 @@ public Authentication authenticate(Authentication authentication) throws Authent registeredClient = RegisteredClient.from(registeredClient) .clientSecret(this.passwordEncoder.encode(clientSecret)) .build(); - registeredClientRepository.save(registeredClient); + this.registeredClientRepository.save(registeredClient); } if (this.logger.isTraceEnabled()) { 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 6c6a18869..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,14 +72,11 @@ public InMemoryRegisteredClientRepository(List registrations) @Override public void save(RegisteredClient registeredClient) { Assert.notNull(registeredClient, "registeredClient cannot be null"); - if (this.idRegistrationMap.containsKey(registeredClient.getId())) { - this.idRegistrationMap.put(registeredClient.getId(), registeredClient); - this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); - } else { + if (!this.idRegistrationMap.containsKey(registeredClient.getId())) { assertUniqueIdentifiers(registeredClient, this.idRegistrationMap); - this.idRegistrationMap.put(registeredClient.getId(), registeredClient); - this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); } + this.idRegistrationMap.put(registeredClient.getId(), registeredClient); + this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); } @Nullable 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 17f757ebe..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. @@ -228,7 +228,7 @@ public void authenticateWhenValidCredentialsThenAuthenticated() { } @Test - public void authenticateWhenValidCredentialsAndNonExpiredThenPasswordUpgraded() { + public void authenticateWhenValidCredentialsAndRequiresUpgradingThenClientSecretUpgraded() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) .thenReturn(registeredClient); @@ -243,10 +243,9 @@ public void authenticateWhenValidCredentialsAndNonExpiredThenPasswordUpgraded() verify(this.passwordEncoder).encode(any()); verify(this.registeredClientRepository).save(any()); assertThat(authenticationResult.isAuthenticated()).isTrue(); - assertThat(registeredClient).isNotSameAs(authenticationResult.getPrincipal()); assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId()); assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret()); - assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getRegisteredClient()).isNotSameAs(registeredClient); } @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 94048a28e..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. @@ -155,10 +155,10 @@ public void saveWhenNullThenThrowIllegalArgumentException() { @Test public void saveWhenExistingIdThenUpdate() { RegisteredClient registeredClient = createRegisteredClient( - this.registration.getId(), "client-id", "client-secret-2"); + this.registration.getId(), "client-id-2", "client-secret-2"); this.clients.save(registeredClient); RegisteredClient savedClient = this.clients.findByClientId(registeredClient.getClientId()); - assertThat(savedClient.getClientSecret()).isEqualTo("client-secret-2"); + assertThat(savedClient).isEqualTo(registeredClient); } @Test 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 65c16c337..c84d35527 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. @@ -233,7 +233,7 @@ public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponse() thr } @Test - public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponseAndSecretUpgraded() throws Exception { + public void requestWhenTokenRequestPostsClientCredentialsAndRequiresUpgradingThenClientSecretUpgraded() throws Exception { this.spring.register(AuthorizationServerConfigurationCustomPasswordEncoder.class).autowire(); String clientSecret = "secret-2"; @@ -250,7 +250,8 @@ public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponseAndSec .andExpect(jsonPath("$.scope").value("scope1 scope2")); verify(jwtCustomizer).customize(any()); - assertThat(this.registeredClientRepository.findByClientId(registeredClient.getClientId()).getClientSecret()).startsWith("{bcrypt}"); + RegisteredClient updatedRegisteredClient = this.registeredClientRepository.findByClientId(registeredClient.getClientId()); + assertThat(updatedRegisteredClient.getClientSecret()).startsWith("{bcrypt}"); } @Test From 291ba8c92dcc3e396950bfc643b41b2e4a5e6cc4 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Thu, 9 Mar 2023 13:15:45 -0600 Subject: [PATCH 049/250] Add support for OAuth 2.0 Device Authorization Grant Closes gh-44 --- .../InMemoryOAuth2AuthorizationService.java | 18 ++ .../authorization/OAuth2Authorization.java | 14 +- ...orizationConsentAuthenticationContext.java | 6 +- ...rizationConsentAuthenticationProvider.java | 267 +++++++++++++++++ ...thorizationConsentAuthenticationToken.java | 103 +++++++ ...rizationRequestAuthenticationProvider.java | 274 +++++++++++++++++ ...thorizationRequestAuthenticationToken.java | 154 ++++++++++ ...Auth2DeviceCodeAuthenticationProvider.java | 259 ++++++++++++++++ .../OAuth2DeviceCodeAuthenticationToken.java | 59 ++++ ...iceVerificationAuthenticationProvider.java | 203 +++++++++++++ ...DeviceVerificationAuthenticationToken.java | 117 ++++++++ .../OAuth2AuthorizationServerConfigurer.java | 26 ++ .../OAuth2ClientAuthenticationConfigurer.java | 5 +- ...DeviceAuthorizationEndpointConfigurer.java | 229 ++++++++++++++ ...2DeviceVerificationEndpointConfigurer.java | 283 ++++++++++++++++++ .../OAuth2TokenEndpointConfigurer.java | 9 +- .../settings/AuthorizationServerSettings.java | 40 +++ .../settings/ConfigurationSettingNames.java | 16 + .../authorization/settings/TokenSettings.java | 26 +- .../authorization/web/DefaultConsentPage.java | 155 ++++++++++ .../OAuth2AuthorizationEndpointFilter.java | 108 +------ ...uth2DeviceAuthorizationEndpointFilter.java | 241 +++++++++++++++ ...Auth2DeviceVerificationEndpointFilter.java | 266 ++++++++++++++++ .../web/OAuth2TokenEndpointFilter.java | 6 +- ...izationConsentAuthenticationConverter.java | 115 +++++++ ...izationRequestAuthenticationConverter.java | 82 +++++ ...uth2DeviceCodeAuthenticationConverter.java | 81 +++++ ...ceVerificationAuthenticationConverter.java | 90 ++++++ .../authentication/OAuth2EndpointUtils.java | 11 +- .../OAuth2ClientCredentialsGrantTests.java | 15 +- .../AuthorizationServerSettingsTests.java | 2 +- .../settings/TokenSettingsTests.java | 7 +- .../samples-device-client.gradle | 27 ++ .../java/sample/DeviceClientApplication.java | 32 ++ .../java/sample/config/SecurityConfig.java | 56 ++++ .../java/sample/config/WebClientConfig.java | 71 +++++ .../java/sample/web/DeviceController.java | 192 ++++++++++++ .../sample/web/DeviceControllerAdvice.java | 52 ++++ ...iceCodeOAuth2AuthorizedClientProvider.java | 122 ++++++++ ...OAuth2DeviceAccessTokenResponseClient.java | 85 ++++++ .../OAuth2DeviceGrantRequest.java | 41 +++ .../src/main/resources/application.yml | 29 ++ .../resources/static/assets/css/style.css | 13 + .../main/resources/templates/authorize.html | 87 ++++++ .../main/resources/templates/authorized.html | 35 +++ .../src/main/resources/templates/index.html | 26 ++ ...es-device-grant-authorizationserver.gradle | 37 +++ ...ceGrantAuthorizationServerApplication.java | 32 ++ .../java/sample/config/SecurityConfig.java | 170 +++++++++++ .../java/sample/web/DeviceController.java | 47 +++ .../sample/web/DeviceErrorController.java | 48 +++ .../src/main/resources/application.yml | 6 + .../resources/static/assets/css/style.css | 13 + .../resources/templates/access-denied.html | 25 ++ .../main/resources/templates/activate.html | 33 ++ .../main/resources/templates/activated.html | 25 ++ .../src/main/resources/templates/error.html | 25 ++ 57 files changed, 4492 insertions(+), 124 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java create mode 100644 samples/device-client/samples-device-client.gradle create mode 100644 samples/device-client/src/main/java/sample/DeviceClientApplication.java create mode 100644 samples/device-client/src/main/java/sample/config/SecurityConfig.java create mode 100644 samples/device-client/src/main/java/sample/config/WebClientConfig.java create mode 100644 samples/device-client/src/main/java/sample/web/DeviceController.java create mode 100644 samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java create mode 100644 samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java create mode 100644 samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java create mode 100644 samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java create mode 100644 samples/device-client/src/main/resources/application.yml create mode 100644 samples/device-client/src/main/resources/static/assets/css/style.css create mode 100644 samples/device-client/src/main/resources/templates/authorize.html create mode 100644 samples/device-client/src/main/resources/templates/authorized.html create mode 100644 samples/device-client/src/main/resources/templates/index.html create mode 100644 samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java create mode 100644 samples/device-grant-authorizationserver/src/main/resources/application.yml create mode 100644 samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css create mode 100644 samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html create mode 100644 samples/device-grant-authorizationserver/src/main/resources/templates/activate.html create mode 100644 samples/device-grant-authorizationserver/src/main/resources/templates/activated.html create mode 100644 samples/device-grant-authorizationserver/src/main/resources/templates/error.html 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 2d2ca5fb1..abdc15d78 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 @@ -24,7 +24,9 @@ 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; @@ -164,6 +166,10 @@ private static boolean hasToken(OAuth2Authorization authorization, String token, 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; } @@ -196,6 +202,18 @@ private static boolean matchesIdToken(OAuth2Authorization authorization, String 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/OAuth2Authorization.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java index ef8bb69dd..afdfc3a60 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.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. @@ -253,6 +253,18 @@ public static class Token implements Serializable { */ public static final String INVALIDATED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("invalidated"); + /** + * The name of the metadata that indicates if access has been denied by the resource owner. + * Used with the OAuth 2.0 Device Authorization Grant. + */ + public static final String ACCESS_DENIED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_denied"); + + /** + * The name of the metadata that indicates if access has been denied by the resource owner. + * Used with the OAuth 2.0 Device Authorization Grant. + */ + public static final String ACCESS_GRANTED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_granted"); + /** * The name of the metadata used for the claims of the token. */ 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..343526fda 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. @@ -113,6 +113,10 @@ private Builder(OAuth2AuthorizationConsentAuthenticationToken authentication) { super(authentication); } + private Builder(OAuth2DeviceAuthorizationConsentAuthenticationToken authentication) { + super(authentication); + } + /** * Sets the {@link OAuth2AuthorizationConsent.Builder authorization consent builder}. * 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..e0b0a006f --- /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.security.Principal; +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.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.OAuth2AuthorizationException; +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.OAuth2AuthorizationRequest; +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 OAuth 2.0 Authorization Consent + * used in the 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 DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; + private 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"); + } + + Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal(); + + 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"); + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( + OAuth2AuthorizationRequest.class.getName()); + Set requestedScopes = authorizationRequest.getScopes(); + Set authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ? + new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) : + new HashSet<>(); + 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) + .authorizationRequest(authorizationRequest) + .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.ACCESS_DENIED_METADATA_NAME, true)) + .token(userCodeToken.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .build(); + this.authorizationService.save(authorization); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Invalidated device code and user code because authorization consent was denied"); + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED); + } + + OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); + if (!authorizationConsent.equals(currentAuthorizationConsent)) { + this.authorizationConsentService.save(authorizationConsent); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization consent"); + } + } + + OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) + .principalName(principal.getName()) + .authorizedScopes(authorizedScopes) + .token(deviceCodeToken.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) + .token(userCodeToken.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attribute(Principal.class.getName(), principal) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .build(); + this.authorizationService.save(updatedAuthorization); + + 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 authorization consent request"); + } + + return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, + deviceAuthorizationConsentAuthentication.getUserCode()); + } + + @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 authorization request.
  • + *
  • The {@link OAuth2Authorization} associated with the state token presented in the + * authorization consent request.
  • + *
  • The {@link OAuth2AuthorizationRequest} associated with the 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 void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, DEFAULT_ERROR_URI); + throw new OAuth2AuthorizationException(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..20cf0b0cc --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.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 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.core.Authentication; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Authorization Consent used + * in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + */ +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 request + * @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 request + * @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..12706dff5 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java @@ -0,0 +1,274 @@ +/* + * 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.util.Base64; +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.OAuth2AuthorizationRequest; +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 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"; + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + private 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"); + } + + // Validate client grant types has device_code grant type + if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.DEVICE_CODE)) { + throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID); + } + + 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()) { + 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()) { + logger.trace("Generated user code"); + } + + String authorizationUri = deviceAuthorizationRequestAuthentication.getAuthorizationUri(); + + Set requestedScopes = deviceAuthorizationRequestAuthentication.getScopes(); + + // @formatter:off + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(authorizationUri) + .clientId(registeredClient.getClientId()) + .scopes(requestedScopes) + .build(); + // @formatter:on + + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(clientPrincipal.getName()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(deviceCode) + .token(userCode) + .attribute(Principal.class.getName(), clientPrincipal) + .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest) + .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..414293646 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java @@ -0,0 +1,154 @@ +/* + * 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.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 OAuth 2.0 Device Authorization Request + * used in the Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AbstractAuthenticationToken + * @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 = additionalParameters; + 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 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..cc3b296fd --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java @@ -0,0 +1,259 @@ +/* + * 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.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.endpoint.OAuth2AuthorizationRequest; +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; + +/** + * An {@link AuthenticationProvider} implementation for 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"; + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + + 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 = OAuth2AuthenticationProviderUtils + .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"); + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( + OAuth2AuthorizationRequest.class.getName()); + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + + if (!registeredClient.getClientId().equals(authorizationRequest.getClientId())) { + 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'", registeredClient.getId())); + } + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + // In https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5, + // the following error codes are defined: + + // access_denied + // The authorization request was denied. + if (Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME))) { + OAuth2Error error = new OAuth2Error("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()) { + OAuth2Error error = new OAuth2Error("expired_token", 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. + + // 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 (!Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME))) { + OAuth2Error error = new OAuth2Error("authorization_pending", null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (!deviceCode.isActive()) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated 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) && + // Do not issue refresh token to public client + !clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) { + + 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"); + } + + 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..2df84805a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java @@ -0,0 +1,59 @@ +/* + * 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 used for 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..92777c123 --- /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.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +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 Verification {@code URI} + * (submission of the user code)} used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceVerificationAuthenticationToken + * @see OAuth2AuthorizationConsent + * @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 { + + private 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"); + } + + RegisteredClient registeredClient = this.registeredClientRepository.findById( + authorization.getRegisteredClientId()); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + Authentication principal = (Authentication) deviceVerificationAuthentication.getPrincipal(); + if (!isPrincipalAuthenticated(principal)) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Did not authenticate device authorization request since principal not authenticated"); + } + // Return the authorization request as-is where isAuthenticated() is false + return deviceVerificationAuthentication; + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + registeredClient.getId(), principal.getName()); + + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : null; + + if (requiresAuthorizationConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) { + String state = DEFAULT_STATE_GENERATOR.generateKey(); + authorization = OAuth2Authorization.from(authorization) + .attribute(OAuth2ParameterNames.STATE, state) + .build(); + + if (this.logger.isTraceEnabled()) { + logger.trace("Generated authorization consent state"); + } + + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationRequest.getAuthorizationUri(), + registeredClient.getClientId(), principal, deviceVerificationAuthentication.getUserCode(), state, + authorizationRequest.getScopes(), currentAuthorizedScopes); + } + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) + .principalName(principal.getName()) + .authorizedScopes(currentAuthorizedScopes) + .token(deviceCode.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) + .token(userCode.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attribute(Principal.class.getName(), principal) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .build(); + this.authorizationService.save(updatedAuthorization); + + 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 authorization consent request"); + } + + return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, + deviceVerificationAuthentication.getUserCode()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceVerificationAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static boolean requiresAuthorizationConsent(RegisteredClient registeredClient, + OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent authorizationConsent) { + + if (!registeredClient.getClientSettings().isRequireAuthorizationConsent()) { + return false; + } + + if (authorizationConsent != null && + authorizationConsent.getScopes().containsAll(authorizationRequest.getScopes())) { + 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..f24b06001 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java @@ -0,0 +1,117 @@ +/* + * 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.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 Verification {@code URI} + * (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 String clientId; + private final Authentication principal; + private final String userCode; + private final Map additionalParameters; + + /** + * 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 request + * @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.notNull(userCode, "userCode cannot be null"); + this.clientId = null; + this.principal = principal; + this.userCode = userCode; + this.additionalParameters = additionalParameters; + } + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters. + * + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization request + */ + public OAuth2DeviceVerificationAuthenticationToken(String clientId, Authentication principal, String userCode) { + super(Collections.emptyList()); + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + Assert.notNull(userCode, "userCode cannot be null"); + this.clientId = clientId; + this.principal = principal; + this.userCode = userCode; + this.additionalParameters = null; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the client identifier. + * + * @return the client identifier + */ + public String getClientId() { + return this.clientId; + } + + /** + * 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; + } + +} 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 19c2fd9eb..121d15a20 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 @@ -210,6 +210,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). * @@ -326,6 +350,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; } 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/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..b9e5d6155 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java @@ -0,0 +1,229 @@ +/* + * 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.OAuth2Error; +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; + +/** + * 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 authenticationConverters = new ArrayList<>(); + private Consumer> authenticationConvertersConsumer = (authenticationConverters) -> {}; + 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.authenticationConverters.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.authenticationConvertersConsumer = 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 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 OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the {@link OAuth2Error Error Response}. + * + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @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.authenticationConverters.isEmpty()) { + authenticationConverters.addAll(0, this.authenticationConverters); + } + this.authenticationConvertersConsumer.accept(authenticationConverters); + deviceAuthorizationEndpointFilter.setAuthenticationConverter( + new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.deviceAuthorizationResponseHandler != null) { + deviceAuthorizationEndpointFilter.setAuthenticationSuccessHandler(this.deviceAuthorizationResponseHandler); + } + if (this.errorResponseHandler != null) { + deviceAuthorizationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + if (this.verificationUri != null) { + 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 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..c973bddd5 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java @@ -0,0 +1,283 @@ +/* + * 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.OAuth2AuthorizationCodeRequestAuthenticationToken; +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.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.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 authenticationConverters = new ArrayList<>(); + private Consumer> authenticationConvertersConsumer = (authenticationConverters) -> {}; + 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 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 Authorization Request from {@link HttpServletRequest} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverter(AuthenticationConverter deviceVerificationRequestConverter) { + Assert.notNull(deviceVerificationRequestConverter, "deviceVerificationRequestConverter cannot be null"); + this.authenticationConverters.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.authenticationConvertersConsumer = deviceVerificationRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken}. + * + * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken} + * @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 OAuth2DeviceAuthorizationConsentAuthenticationToken} + * and returning the response. + * + * @param deviceVerificationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken} + * @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 authorization request
  • + *
  • {@code state} - a CSRF protection token
  • + *
  • @code 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 user {@code 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.authenticationConverters.isEmpty()) { + authenticationConverters.addAll(0, this.authenticationConverters); + } + this.authenticationConvertersConsumer.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.addFilterAfter(postProcess(deviceVerificationEndpointFilter), AuthorizationFilter.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 1fb5813e7..7229ae336 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. @@ -36,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; @@ -43,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; @@ -208,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; } @@ -232,6 +235,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/settings/AuthorizationServerSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java index a2bb0c80d..7dc502315 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 @@ -52,6 +52,24 @@ 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 Authorization endpoint + */ + 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 Authorization endpoint + */ + public String getDeviceVerificationEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT); + } + /** * Returns the OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}. * @@ -124,6 +142,8 @@ public String getOidcLogoutEndpoint() { 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") @@ -173,6 +193,26 @@ 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 + */ + 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 + */ + public Builder deviceVerificationEndpoint(String deviceVerificationEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT, deviceVerificationEndpoint); + } + /** * Sets the OAuth 2.0 Token endpoint. * 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 e7cc341dd..0e970ddd1 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 @@ -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. */ @@ -150,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..16c364b3c 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 30 minutes. + * + * @return the time-to-live for an authorization 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(30)) .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/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 451ceef58..d32d6d161 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 @@ -18,7 +18,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.HashSet; +import java.util.Collections; import java.util.Set; import jakarta.servlet.FilterChain; @@ -29,7 +29,6 @@ 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; @@ -288,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()); } } @@ -367,107 +366,4 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r 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/OAuth2DeviceAuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java new file mode 100644 index 000000000..13207d16a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java @@ -0,0 +1,241 @@ +/* + * 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.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.OAuth2AuthorizationCodeRequestAuthenticationException; +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 Grant, + * 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_authorize"; + + private static final String DEFAULT_DEVICE_VERIFICATION_URI = "/oauth2/device_verification"; + + private final AuthenticationManager authenticationManager; + private final RequestMatcher deviceAuthorizationEndpointMatcher; + private final HttpMessageConverter deviceAuthorizationHttpResponseConverter = + new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + private AuthenticationConverter authenticationConverter; + private AuthenticationDetailsSource authenticationDetailsSource = + new WebAuthenticationDetailsSource(); + private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendDeviceAuthorizationResponse; + private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + private String verificationUri = DEFAULT_DEVICE_VERIFICATION_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 { + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationToken = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationConverter.convert(request); + deviceAuthorizationRequestAuthenticationToken.setDetails( + this.authenticationDetailsSource.buildDetails(request)); + + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationTokenResult = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationManager.authenticate( + deviceAuthorizationRequestAuthenticationToken); + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, + deviceAuthorizationRequestAuthenticationTokenResult); + } 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 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 DeviceAuthorization Request from {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * 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 AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the 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 OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationException} + */ + 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 deviceAuthorizationRequestAuthenticationToken = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication; + + OAuth2DeviceCode deviceCode = deviceAuthorizationRequestAuthenticationToken.getDeviceCode(); + OAuth2UserCode userCode = deviceAuthorizationRequestAuthenticationToken.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..1c256bc41 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java @@ -0,0 +1,266 @@ +/* + * 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 Verification {@code URI} (submission of the user code) + * and OAuth 2.0 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 { + + 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; + + public OAuth2DeviceVerificationEndpointFilter(AuthenticationManager authenticationManager, String deviceVerificationEndpointUri) { + 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 an Authorization Request (or 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 an Authorization Request (or 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 OAuth2DeviceVerificationAuthenticationToken} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + */ + 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..91d67a90e 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. @@ -53,6 +53,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.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -136,7 +137,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/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..b4c180b2a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java @@ -0,0 +1,115 @@ +/* + * 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 an 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 DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; + private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3"; + 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())) { + return null; + } + + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + String authorizationUri = request.getRequestURL().toString(); + + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.USER_CODE, + DEVICE_ERROR_URI); + } + + // 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, DEFAULT_ERROR_URI); + } + + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + // 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, + DEFAULT_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.STATE) && + !key.equals(OAuth2ParameterNames.SCOPE) && + !key.equals(OAuth2ParameterNames.USER_CODE)) { + additionalParameters.put(key, value.get(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..ff795879f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java @@ -0,0 +1,82 @@ +/* + * 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.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 + */ +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.getParameters(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.get(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..0652459ec --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java @@ -0,0 +1,81 @@ +/* + * 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.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.OAuth2DeviceAuthorizationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract an 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 OAuth2DeviceAuthorizationEndpointFilter + */ +public final class OAuth2DeviceCodeAuthenticationConverter implements AuthenticationConverter { + + @Override + public Authentication convert(HttpServletRequest request) { + // grant_type (REQUIRED) + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.DEVICE_CODE.getValue().equals(grantType)) { + return null; + } + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + // 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.get(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..4d3bf3e24 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java @@ -0,0 +1,90 @@ +/* + * 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; +import org.springframework.util.StringUtils; + +/** + * 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/rfc8628#section-3.3"; + 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 = OAuth2EndpointUtils.getParameters(request); + + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!StringUtils.hasText(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.get(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..8d8ca979d 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,6 +26,7 @@ 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; @@ -81,4 +82,12 @@ 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(); + } + } 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 c84d35527..4fb563f24 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 @@ -24,13 +24,12 @@ import java.util.List; import java.util.function.Consumer; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -72,6 +71,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; @@ -90,6 +90,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; @@ -291,7 +292,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)); @@ -303,7 +305,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)); } 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 ccf3884fb..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 @@ -86,7 +86,7 @@ public void settingWhenCustomThenSet() { .settings(settings -> settings.put("name2", "value2")) .build(); - assertThat(authorizationServerSettings.getSettings()).hasSize(10); + assertThat(authorizationServerSettings.getSettings()).hasSize(12); assertThat(authorizationServerSettings.getSetting("name1")).isEqualTo("value1"); assertThat(authorizationServerSettings.getSetting("name2")).isEqualTo("value2"); } 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..230f3ca38 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,8 +34,9 @@ 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.getDeviceCodeTimeToLive()).isEqualTo(Duration.ofMinutes(30)); assertThat(tokenSettings.getAccessTokenTimeToLive()).isEqualTo(Duration.ofMinutes(5)); assertThat(tokenSettings.getAccessTokenFormat()).isEqualTo(OAuth2TokenFormat.SELF_CONTAINED); assertThat(tokenSettings.isReuseRefreshTokens()).isTrue(); @@ -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/samples/device-client/samples-device-client.gradle b/samples/device-client/samples-device-client.gradle new file mode 100644 index 000000000..3dc46577f --- /dev/null +++ b/samples/device-client/samples-device-client.gradle @@ -0,0 +1,27 @@ +plugins { + id "org.springframework.boot" version "3.0.0" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "java" +} + +group = project.rootProject.group +version = project.rootProject.version +sourceCompatibility = "17" + +repositories { + mavenCentral() +} + +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:spring-webflux" + implementation "org.webjars:webjars-locator-core" + implementation "org.webjars:bootstrap:3.4.1" + implementation "org.webjars:jquery:3.4.1" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.security:spring-security-test" +} diff --git a/samples/device-client/src/main/java/sample/DeviceClientApplication.java b/samples/device-client/src/main/java/sample/DeviceClientApplication.java new file mode 100644 index 000000000..b045c6ef7 --- /dev/null +++ b/samples/device-client/src/main/java/sample/DeviceClientApplication.java @@ -0,0 +1,32 @@ +/* + * 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 org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@SpringBootApplication +public class DeviceClientApplication { + + public static void main(String[] args) { + SpringApplication.run(DeviceClientApplication.class, args); + } + +} diff --git a/samples/device-client/src/main/java/sample/config/SecurityConfig.java b/samples/device-client/src/main/java/sample/config/SecurityConfig.java new file mode 100644 index 000000000..4f823847c --- /dev/null +++ b/samples/device-client/src/main/java/sample/config/SecurityConfig.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.config; + +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.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**"); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/", "/authorize").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/")) + ) + .oauth2Client(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + +} diff --git a/samples/device-client/src/main/java/sample/config/WebClientConfig.java b/samples/device-client/src/main/java/sample/config/WebClientConfig.java new file mode 100644 index 000000000..e4b895d06 --- /dev/null +++ b/samples/device-client/src/main/java/sample/config/WebClientConfig.java @@ -0,0 +1,71 @@ +/* + * 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.config; + +import sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) { + ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = + new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); + // @formatter:off + return WebClient.builder() + .apply(oauth2Client.oauth2Configuration()) + .build(); + // @formatter:on + } + + @Bean + public OAuth2AuthorizedClientManager authorizedClientManager( + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AuthorizedClientRepository authorizedClientRepository) { + + OAuth2AuthorizedClientProvider authorizedClientProvider = + OAuth2AuthorizedClientProviderBuilder.builder() + .provider(new DeviceCodeOAuth2AuthorizedClientProvider()) + .authorizationCode() + .refreshToken() + .build(); + 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/device-client/src/main/java/sample/web/DeviceController.java b/samples/device-client/src/main/java/sample/web/DeviceController.java new file mode 100644 index 000000000..40ae92666 --- /dev/null +++ b/samples/device-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.Map; +import java.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +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.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +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.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 ParameterizedTypeReference> TYPE_REFERENCE = + new ParameterizedTypeReference<>() {}; + + private final ClientRegistrationRepository clientRegistrationRepository; + + private final WebClient webClient; + + private final String messagesBaseUri; + + private final SecurityContextRepository securityContextRepository = + new HttpSessionSecurityContextRepository(); + + private final SecurityContextHolderStrategy securityContextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); + + public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient, + @Value("${messages.base-uri}") String messagesBaseUri) { + + this.clientRegistrationRepository = clientRegistrationRepository; + this.webClient = webClient; + this.messagesBaseUri = messagesBaseUri; + } + + @GetMapping("/") + public String index() { + return "index"; + } + + @GetMapping("/authorize") + public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) { + // @formatter:off + ClientRegistration clientRegistration = + this.clientRegistrationRepository.findByRegistrationId( + "messaging-client-device-grant"); + // @formatter:on + + MultiValueMap requestParameters = new LinkedMultiValueMap<>(); + requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString( + clientRegistration.getScopes(), " ")); + + // @formatter:off + Map responseParameters = + this.webClient.post() + .uri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .headers(headers -> 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); + String deviceCodeValue = (String) responseParameters.get(OAuth2ParameterNames.DEVICE_CODE); + + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, issuedAt, expiresAt); + saveSecurityContext(deviceCode, request, response); + + model.addAttribute("deviceCode", deviceCode.getTokenValue()); + model.addAttribute("expiresAt", deviceCode.getExpiresAt()); + 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 "authorize"; + } + + /** + * @see DeviceControllerAdvice + */ + @PostMapping("/authorize") + public ResponseEntity poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode, + @RegisteredOAuth2AuthorizedClient("messaging-client-device-grant") + 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 client is authorized. + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @GetMapping("/authorized") + public String authorized(Model model, + @RegisteredOAuth2AuthorizedClient("messaging-client-device-grant") + OAuth2AuthorizedClient authorizedClient) { + + String[] messages = this.webClient.get() + .uri(this.messagesBaseUri) + .attributes(oauth2AuthorizedClient(authorizedClient)) + .retrieve() + .bodyToMono(String[].class) + .block(); + model.addAttribute("messages", messages); + + return "authorized"; + } + + private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request, + HttpServletResponse response) { + + // @formatter:off + UsernamePasswordAuthenticationToken deviceAuthentication = + UsernamePasswordAuthenticationToken.authenticated( + deviceCode, null, AuthorityUtils.createAuthorityList("ROLE_DEVICE")); + // @formatter:on + + SecurityContext securityContext = this.securityContextHolderStrategy.createEmptyContext(); + securityContext.setAuthentication(deviceAuthentication); + this.securityContextHolderStrategy.setContext(securityContext); + this.securityContextRepository.saveContext(securityContext, request, response); + } + +} diff --git a/samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java b/samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java new file mode 100644 index 000000000..06a61422b --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.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 java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@ControllerAdvice +public class DeviceControllerAdvice { + + private static final Set DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList( + "authorization_pending", + "slow_down", + "access_denied", + "expired_token" + )); + + @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()); + } + +} diff --git a/samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java b/samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java new file mode 100644 index 000000000..4e75c06c9 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java @@ -0,0 +1,122 @@ +/* + * 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 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 DeviceCodeOAuth2AuthorizedClientProvider() { + } + + 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/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java new file mode 100644 index 000000000..7e212c416 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java @@ -0,0 +1,85 @@ +/* + * 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 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.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(); + 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/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java new file mode 100644 index 000000000..7a4172cd9 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/authentication/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.web.authentication; + +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 deviceCode; + } + +} diff --git a/samples/device-client/src/main/resources/application.yml b/samples/device-client/src/main/resources/application.yml new file mode 100644 index 000000000..e3cfff535 --- /dev/null +++ b/samples/device-client/src/main/resources/application.yml @@ -0,0 +1,29 @@ +server: + port: 8080 + +logging: + level: + root: INFO + org.springframework.security: trace + +spring: + thymeleaf: + cache: false + security: + oauth2: + client: + registration: + messaging-client-device-grant: + provider: spring + client-id: messaging-client + client-secret: secret + authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code + scope: message.read,message.write + client-name: messaging-client-device-grant + provider: + spring: + issuer-uri: http://localhost:9000 + authorization-uri: ${spring.security.oauth2.client.provider.spring.issuer-uri}/oauth2/device_authorization + +messages: + base-uri: http://127.0.0.1:8090/messages diff --git a/samples/device-client/src/main/resources/static/assets/css/style.css b/samples/device-client/src/main/resources/static/assets/css/style.css new file mode 100644 index 000000000..d50ee00e9 --- /dev/null +++ b/samples/device-client/src/main/resources/static/assets/css/style.css @@ -0,0 +1,13 @@ +html, body, .container, .jumbotron { + height: 100%; +} +.jumbotron { + margin-bottom: 0; +} +.gap { + margin-top: 70px; +} +.code { + font-size: 2em; + letter-spacing: 2rem; +} \ No newline at end of file diff --git a/samples/device-client/src/main/resources/templates/authorize.html b/samples/device-client/src/main/resources/templates/authorize.html new file mode 100644 index 000000000..01d497e6d --- /dev/null +++ b/samples/device-client/src/main/resources/templates/authorize.html @@ -0,0 +1,87 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Device Activation

+

Please visit on another device to continue.

+

Activation Code

+
+ +
+ +
+
+
+
+ Devices +
+
+
+
+ + + + \ No newline at end of file diff --git a/samples/device-client/src/main/resources/templates/authorized.html b/samples/device-client/src/main/resources/templates/authorized.html new file mode 100644 index 000000000..dd3cca3f1 --- /dev/null +++ b/samples/device-client/src/main/resources/templates/authorized.html @@ -0,0 +1,35 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Success!

+

This device has been activated.

+
+
+ Devices +
+
+

Messages:

+ + + + + + +
message
+
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-client/src/main/resources/templates/index.html b/samples/device-client/src/main/resources/templates/index.html new file mode 100644 index 000000000..b91baa396 --- /dev/null +++ b/samples/device-client/src/main/resources/templates/index.html @@ -0,0 +1,26 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Activation Required

+

You must activate this device. Please log in to continue.

+ Log In +
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle new file mode 100644 index 000000000..2f80f1d5a --- /dev/null +++ b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle @@ -0,0 +1,37 @@ +plugins { + id "org.springframework.boot" version "3.0.0" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "java" +} + +group = project.rootProject.group +version = project.rootProject.version +sourceCompatibility = "17" + +repositories { + maven { + url = "https://repo.spring.io/snapshot" + } + mavenCentral() +} + +// Temporarily use SNAPSHOT version +// TODO: Use 6.1.0-M2 version after release +ext["spring-security.version"] = "6.1.0-SNAPSHOT" + +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") + implementation "org.springframework.boot:spring-boot-starter-oauth2-client" + implementation "org.springframework.boot:spring-boot-starter-thymeleaf" + implementation "org.springframework:spring-webflux" + implementation "org.webjars:webjars-locator-core" + implementation "org.webjars:bootstrap:3.4.1" + implementation "org.webjars:jquery:3.4.1" + runtimeOnly "com.h2database:h2" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.security:spring-security-test" +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java b/samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java new file mode 100644 index 000000000..88df73211 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java @@ -0,0 +1,32 @@ +/* + * 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 org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@SpringBootApplication +public class DeviceGrantAuthorizationServerApplication { + + public static void main(String[] args) { + SpringApplication.run(DeviceGrantAuthorizationServerApplication.class, args); + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java new file mode 100644 index 000000000..ab66f4b87 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -0,0 +1,170 @@ +/* + * 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.config; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +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.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.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +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.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.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.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint + .verificationUri("/activate") + ) + .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(); + } + + @Bean + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()); + // @formatter:on + + return http.build(); + } + + @Bean + public UserDetailsService userDetailsService() { + // @formatter:off + UserDetails userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(userDetails); + } + + @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) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .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); + } + + @Bean + public JWKSource jwkSource() { + KeyPair keyPair = generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + private 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; + } + + @Bean + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder().build(); + } + +} \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java new file mode 100644 index 000000000..3b40e2b2c --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java @@ -0,0 +1,47 @@ +/* + * 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. + * 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 + */ +@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 "activate"; + } + + @GetMapping("/activated") + public String activated() { + return "activated"; + } + + @GetMapping(value = "/", params = "success") + public String success() { + return "activated"; + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java new file mode 100644 index 000000000..7ad9750ee --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java @@ -0,0 +1,48 @@ +/* + * 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.util.Map; + +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.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.ModelAndView; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Controller +public class DeviceErrorController implements ErrorController { + + @RequestMapping("/error") + public ModelAndView handleError(HttpServletRequest request) { + String message = getErrorMessage(request); + if (message.startsWith("[access_denied]")) { + return new ModelAndView("access-denied"); + } + return new ModelAndView("error", Map.of("message", message)); + } + + private String getErrorMessage(HttpServletRequest request) { + return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE); + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/resources/application.yml b/samples/device-grant-authorizationserver/src/main/resources/application.yml new file mode 100644 index 000000000..8865684c7 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 9000 + +logging: + level: + org.springframework.security: trace diff --git a/samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css b/samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css new file mode 100644 index 000000000..d50ee00e9 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css @@ -0,0 +1,13 @@ +html, body, .container, .jumbotron { + height: 100%; +} +.jumbotron { + margin-bottom: 0; +} +.gap { + margin-top: 70px; +} +.code { + font-size: 2em; + letter-spacing: 2rem; +} \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html b/samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html new file mode 100644 index 000000000..e69a32c8b --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html @@ -0,0 +1,25 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Access Denied

+

You have denied access. Please return to your device to continue.

+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html b/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html new file mode 100644 index 000000000..fa5d76cb7 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html @@ -0,0 +1,33 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+
+

Device Activation

+

Enter the activation code to authorize the device.

+

Activation Code

+
+ + +
+ +
+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/activated.html b/samples/device-grant-authorizationserver/src/main/resources/templates/activated.html new file mode 100644 index 000000000..02598da2f --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/activated.html @@ -0,0 +1,25 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Success!

+

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

+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/error.html b/samples/device-grant-authorizationserver/src/main/resources/templates/error.html new file mode 100644 index 000000000..110886461 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/error.html @@ -0,0 +1,25 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Error

+

+
+
+ Devices +
+
+
+
+ + \ No newline at end of file From 62b5d42fedc07b2a82f44c55e2364336cc5a2f5c Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Thu, 9 Mar 2023 14:12:34 -0600 Subject: [PATCH 050/250] Switch to spring-security SNAPSHOT dependencies Issue gh-44 --- .../spring-authorization-server-docs-examples.gradle | 4 ++-- gradle.properties | 2 +- .../custom-consent-authorizationserver/gradle.properties | 1 + .../samples-custom-consent-authorizationserver.gradle | 2 +- samples/default-authorizationserver/gradle.properties | 1 + .../samples-default-authorizationserver.gradle | 2 +- samples/device-client/gradle.properties | 1 + samples/device-client/samples-device-client.gradle | 1 + .../device-grant-authorizationserver/gradle.properties | 1 + .../samples-device-grant-authorizationserver.gradle | 8 +------- .../gradle.properties | 1 + .../samples-federated-identity-authorizationserver.gradle | 2 +- samples/messages-client/samples-messages-client.gradle | 2 -- .../messages-resource/samples-messages-resource.gradle | 1 - 14 files changed, 13 insertions(+), 16 deletions(-) create mode 100644 samples/custom-consent-authorizationserver/gradle.properties create mode 100644 samples/default-authorizationserver/gradle.properties create mode 100644 samples/device-client/gradle.properties create mode 100644 samples/device-grant-authorizationserver/gradle.properties create mode 100644 samples/federated-identity-authorizationserver/gradle.properties 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 402a31ab8..20c5f7f2a 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,18 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.0") + implementation platform("org.springframework.security:spring-security-bom:6.1.0-SNAPSHOT") 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/gradle.properties b/gradle.properties index 2f6d04a9e..c75357943 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.5 -springSecurityVersion=6.1.0-M1 +springSecurityVersion=6.1.0-SNAPSHOT springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 diff --git a/samples/custom-consent-authorizationserver/gradle.properties b/samples/custom-consent-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/custom-consent-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle index 6465a94c5..1b8b5373f 100644 --- a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle +++ b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/default-authorizationserver/gradle.properties b/samples/default-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/default-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/default-authorizationserver/samples-default-authorizationserver.gradle b/samples/default-authorizationserver/samples-default-authorizationserver.gradle index 341e0c206..4a3e850ea 100644 --- a/samples/default-authorizationserver/samples-default-authorizationserver.gradle +++ b/samples/default-authorizationserver/samples-default-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/device-client/gradle.properties b/samples/device-client/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/device-client/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/device-client/samples-device-client.gradle b/samples/device-client/samples-device-client.gradle index 3dc46577f..5b8971009 100644 --- a/samples/device-client/samples-device-client.gradle +++ b/samples/device-client/samples-device-client.gradle @@ -10,6 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/device-grant-authorizationserver/gradle.properties b/samples/device-grant-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/device-grant-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle index 2f80f1d5a..8530b31c4 100644 --- a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle +++ b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle @@ -9,16 +9,10 @@ version = project.rootProject.version sourceCompatibility = "17" repositories { - maven { - url = "https://repo.spring.io/snapshot" - } mavenCentral() + maven { url = "https://repo.spring.io/snapshot" } } -// Temporarily use SNAPSHOT version -// TODO: Use 6.1.0-M2 version after release -ext["spring-security.version"] = "6.1.0-SNAPSHOT" - dependencies { implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-security" diff --git a/samples/federated-identity-authorizationserver/gradle.properties b/samples/federated-identity-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/federated-identity-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle index ab8c75838..5c503b27f 100644 --- a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle +++ b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/messages-client/samples-messages-client.gradle b/samples/messages-client/samples-messages-client.gradle index 024b1b8b3..d3ce2c845 100644 --- a/samples/messages-client/samples-messages-client.gradle +++ b/samples/messages-client/samples-messages-client.gradle @@ -10,7 +10,6 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } } dependencies { @@ -20,7 +19,6 @@ 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" diff --git a/samples/messages-resource/samples-messages-resource.gradle b/samples/messages-resource/samples-messages-resource.gradle index fbfcaba89..02dc36f1e 100644 --- a/samples/messages-resource/samples-messages-resource.gradle +++ b/samples/messages-resource/samples-messages-resource.gradle @@ -10,7 +10,6 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } } dependencies { From cfca05dfd55c2e424b4a44ceb5352e6500dac8a6 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Fri, 10 Mar 2023 12:29:08 -0600 Subject: [PATCH 051/250] Use spring-security 6.1 in snapshot tests Issue gh-1106 --- .github/workflows/continuous-integration-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index a4f97ec05..9e5f4f5ea 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -80,7 +80,7 @@ jobs: 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 + run: ./gradlew test --refresh-dependencies -Duser.name=spring-builds+github -PartifactoryUsername="$ARTIFACTORY_USERNAME" -PartifactoryPassword="$ARTIFACTORY_PASSWORD" -PforceMavenRepositories=snapshot -PspringFrameworkVersion='6.0.+' -PspringSecurityVersion='6.1.+' -PlocksDisabled --stacktrace deploy_artifacts: name: Deploy Artifacts needs: [build, snapshot_tests] From 61aad39139ed08d634c09638d3701cf87a2ef9e1 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Fri, 10 Mar 2023 12:42:22 -0600 Subject: [PATCH 052/250] Update to actions/checkout@v3 Closes gh-1117 --- .github/workflows/continuous-integration-workflow.yml | 10 +++++----- .github/workflows/pr-build-workflow.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 8e653d2f4..540a399b5 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -18,7 +18,7 @@ jobs: runjobs: ${{ steps.continue.outputs.runjobs }} project_version: ${{ steps.continue.outputs.project_version }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - id: continue name: Determine if should continue if: env.RUN_JOBS == 'true' @@ -39,7 +39,7 @@ jobs: runs-on: ${{ matrix.os }} if: needs.prerequisites.outputs.runjobs steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v1 with: @@ -66,7 +66,7 @@ jobs: runs-on: ubuntu-latest if: needs.prerequisites.outputs.runjobs steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v1 with: @@ -86,7 +86,7 @@ jobs: needs: [build, snapshot_tests] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v1 with: @@ -110,7 +110,7 @@ jobs: needs: [build, snapshot_tests] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v1 with: diff --git a/.github/workflows/pr-build-workflow.yml b/.github/workflows/pr-build-workflow.yml index 704104dbc..9221ec18f 100644 --- a/.github/workflows/pr-build-workflow.yml +++ b/.github/workflows/pr-build-workflow.yml @@ -15,7 +15,7 @@ jobs: jdk: [8] fail-fast: false steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v1 with: From 75ded478181bfcb1649d5fca2f6d8ae9bfa36f71 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Fri, 10 Mar 2023 15:34:35 -0600 Subject: [PATCH 053/250] Use spring-io/spring-gradle-build-action Closes gh-1120 --- .../continuous-integration-workflow.yml | 35 +++++++++---------- .github/workflows/pr-build-workflow.yml | 6 +++- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 540a399b5..3e01ad3c6 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -35,23 +35,23 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] jdk: [8,11,17] + include: + - jdk: 8 + distribution: adopt + - jdk: 11 + distribution: adopt + - jdk: 17 + distribution: temurin fail-fast: false runs-on: ${{ matrix.os }} if: needs.prerequisites.outputs.runjobs steps: - 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 }} - - 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 + distribution: ${{ matrix.distribution }} - name: Build with Gradle env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} @@ -68,11 +68,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: 8 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + distribution: adopt - name: Snapshot Tests env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} @@ -80,7 +79,7 @@ jobs: 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='5.3.+' -PspringSecurityVersion='5.8.+' -PlocksDisabled --stacktrace + run: ./gradlew test --refresh-dependencies -Duser.name=spring-builds+github -PartifactoryUsername="$ARTIFACTORY_USERNAME" -PartifactoryPassword="$ARTIFACTORY_PASSWORD" -PforceMavenRepositories=snapshot -PspringFrameworkVersion='6.0.+' -PspringSecurityVersion='6.1.+' -PlocksDisabled --stacktrace deploy_artifacts: name: Deploy Artifacts needs: [build, snapshot_tests] @@ -88,11 +87,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: 8 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + distribution: adopt - name: Deploy Artifacts env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} @@ -112,11 +110,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: 8 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + distribution: adopt - name: Deploy Docs env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} diff --git a/.github/workflows/pr-build-workflow.yml b/.github/workflows/pr-build-workflow.yml index 9221ec18f..3cfdd8de4 100644 --- a/.github/workflows/pr-build-workflow.yml +++ b/.github/workflows/pr-build-workflow.yml @@ -13,12 +13,16 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] jdk: [8] + include: + - jdk: 8 + distribution: adopt fail-fast: false steps: - 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: ${{ matrix.distribution }} - name: Build with Gradle run: ./gradlew clean build From b44f24d97582976a148629ac46ea98f33f2eb9ae Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Fri, 10 Mar 2023 15:23:21 -0600 Subject: [PATCH 054/250] Use spring-io/spring-gradle-build-action Closes gh-1120 --- .../continuous-integration-workflow.yml | 26 ++++++------------- .github/workflows/pr-build-workflow.yml | 3 ++- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 94c4b1898..d477531ee 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -41,17 +41,10 @@ jobs: steps: - 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 }} - - 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 + distribution: temurin - name: Build with Gradle env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} @@ -68,11 +61,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: 17 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + distribution: temurin - name: Snapshot Tests env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} @@ -88,11 +80,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: 17 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + distribution: temurin - name: Deploy Artifacts env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} @@ -112,11 +103,10 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: spring-io/spring-gradle-build-action@v2 with: java-version: 17 - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + distribution: temurin - name: Deploy Docs env: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USER }} diff --git a/.github/workflows/pr-build-workflow.yml b/.github/workflows/pr-build-workflow.yml index 2776ccc55..8621c2b65 100644 --- a/.github/workflows/pr-build-workflow.yml +++ b/.github/workflows/pr-build-workflow.yml @@ -17,8 +17,9 @@ jobs: steps: - 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 From c0f0d7700a8f8920a97ad3e9e0f7c9c1e619c814 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Fri, 10 Mar 2023 15:46:36 -0600 Subject: [PATCH 055/250] Revert accidental change in versions Issue gh-1120 --- .github/workflows/continuous-integration-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 3e01ad3c6..b5f4b8334 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -79,7 +79,7 @@ jobs: 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.1.+' -PlocksDisabled --stacktrace + run: ./gradlew test --refresh-dependencies -Duser.name=spring-builds+github -PartifactoryUsername="$ARTIFACTORY_USERNAME" -PartifactoryPassword="$ARTIFACTORY_PASSWORD" -PforceMavenRepositories=snapshot -PspringFrameworkVersion='5.3.+' -PspringSecurityVersion='5.8.+' -PlocksDisabled --stacktrace deploy_artifacts: name: Deploy Artifacts needs: [build, snapshot_tests] From cc6b3dc7915f384d824844ded374ca2c3f8c67fe Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Wed, 15 Mar 2023 14:07:33 -0500 Subject: [PATCH 056/250] Polish gh-1106 --- ...rizationConsentAuthenticationProvider.java | 5 ++--- ...rizationRequestAuthenticationProvider.java | 4 ++-- ...Auth2DeviceCodeAuthenticationProvider.java | 10 ++++++---- ...iceVerificationAuthenticationProvider.java | 4 ++-- ...uth2DeviceAuthorizationEndpointFilter.java | 10 +++++----- ...Auth2DeviceVerificationEndpointFilter.java | 19 +++++++++++++++++++ ...izationConsentAuthenticationConverter.java | 5 ++++- ...izationRequestAuthenticationConverter.java | 4 ++++ ...uth2DeviceCodeAuthenticationConverter.java | 4 ++-- 9 files changed, 46 insertions(+), 19 deletions(-) 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 index e0b0a006f..07e8f5842 100644 --- 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 @@ -29,7 +29,6 @@ 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.OAuth2AuthorizationException; import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; @@ -63,7 +62,7 @@ public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider { private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; - private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); private final Log logger = LogFactory.getLog(getClass()); private final RegisteredClientRepository registeredClientRepository; @@ -261,7 +260,7 @@ public void setAuthorizationConsentCustomizer(Consumer userCode = authorization.getToken(OAuth2UserCode.class); OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) .principalName(principal.getName()) - .authorizedScopes(currentAuthorizedScopes) + .authorizedScopes(authorizationRequest.getScopes()) .token(deviceCode.getToken(), metadata -> metadata .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) .token(userCode.getToken(), metadata -> metadata 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 index 13207d16a..480c7d407 100644 --- 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 @@ -70,7 +70,7 @@ */ public final class OAuth2DeviceAuthorizationEndpointFilter extends OncePerRequestFilter { - private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorize"; + private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorization"; private static final String DEFAULT_DEVICE_VERIFICATION_URI = "/oauth2/device_verification"; @@ -88,10 +88,10 @@ public final class OAuth2DeviceAuthorizationEndpointFilter extends OncePerReques private String verificationUri = DEFAULT_DEVICE_VERIFICATION_URI; /** - * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. - * - * @param authenticationManager the authentication manager - */ + * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager) { this(authenticationManager, DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI); } 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 index 1c256bc41..0b13c2642 100644 --- 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 @@ -79,6 +79,8 @@ */ public final class OAuth2DeviceVerificationEndpointFilter extends OncePerRequestFilter { + private static final String DEFAULT_DEVICE_VERIFICATION_URI = "/oauth2/device_verification"; + private final AuthenticationManager authenticationManager; private final RequestMatcher deviceVerificationEndpointMatcher; private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @@ -90,7 +92,24 @@ public final class OAuth2DeviceVerificationEndpointFilter extends OncePerRequest private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; private String consentPage; + /** + * Construct an {@code OAuth2DeviceVerificationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ + public OAuth2DeviceVerificationEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_DEVICE_VERIFICATION_URI); + } + + /** + * Construct 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( 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 index b4c180b2a..7d6f5bfcf 100644 --- 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 @@ -75,7 +75,10 @@ public Authentication convert(HttpServletRequest request) { // 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, DEFAULT_ERROR_URI); + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.CLIENT_ID, + DEFAULT_ERROR_URI); } Authentication principal = SecurityContextHolder.getContext().getAuthentication(); 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 index ff795879f..dfb23c98a 100644 --- 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 @@ -28,6 +28,7 @@ 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; @@ -40,6 +41,9 @@ * * @author Steve Riesenberg * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceAuthorizationRequestAuthenticationToken + * @see OAuth2DeviceAuthorizationEndpointFilter */ public final class OAuth2DeviceAuthorizationRequestAuthenticationConverter implements AuthenticationConverter { 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 index 0652459ec..d0fcaa278 100644 --- 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 @@ -26,7 +26,7 @@ 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.OAuth2DeviceAuthorizationEndpointFilter; +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; @@ -41,7 +41,7 @@ * @since 1.1 * @see AuthenticationConverter * @see OAuth2DeviceCodeAuthenticationToken - * @see OAuth2DeviceAuthorizationEndpointFilter + * @see OAuth2TokenEndpointFilter */ public final class OAuth2DeviceCodeAuthenticationConverter implements AuthenticationConverter { From a3ac895b1e7aba718c50d31b2f4339859999cf8a Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 21 Mar 2023 10:04:26 -0500 Subject: [PATCH 057/250] Update to Spring Framework 6.0.7 Closes gh-1130 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index c75357943..dde5eeb0e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=1.1.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=6.0.5 +springFrameworkVersion=6.0.7 springSecurityVersion=6.1.0-SNAPSHOT springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From 58bba0727ca66b1630cda1634eb636effbb50cc0 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 21 Mar 2023 10:05:08 -0500 Subject: [PATCH 058/250] Update to Spring Security 1.1.0-M2 Closes gh-1131 --- .../examples/spring-authorization-server-docs-examples.gradle | 4 ++-- gradle.properties | 2 +- samples/custom-consent-authorizationserver/gradle.properties | 2 +- .../samples-custom-consent-authorizationserver.gradle | 2 +- samples/default-authorizationserver/gradle.properties | 2 +- .../samples-default-authorizationserver.gradle | 2 +- samples/device-client/gradle.properties | 2 +- samples/device-client/samples-device-client.gradle | 2 +- samples/device-grant-authorizationserver/gradle.properties | 2 +- .../samples-device-grant-authorizationserver.gradle | 2 +- .../federated-identity-authorizationserver/gradle.properties | 2 +- .../samples-federated-identity-authorizationserver.gradle | 2 +- samples/messages-client/samples-messages-client.gradle | 1 + samples/messages-resource/samples-messages-resource.gradle | 1 + settings.gradle | 2 +- 15 files changed, 16 insertions(+), 14 deletions(-) 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 20c5f7f2a..e0aaf259b 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,12 +8,12 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } } dependencies { implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.0") - implementation platform("org.springframework.security:spring-security-bom:6.1.0-SNAPSHOT") + implementation platform("org.springframework.security:spring-security-bom:6.1.0-M2") implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation "org.springframework.boot:spring-boot-starter-security" diff --git a/gradle.properties b/gradle.properties index dde5eeb0e..939e84417 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.7 -springSecurityVersion=6.1.0-SNAPSHOT +springSecurityVersion=6.1.0-M2 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 diff --git a/samples/custom-consent-authorizationserver/gradle.properties b/samples/custom-consent-authorizationserver/gradle.properties index 658d3b529..74915daef 100644 --- a/samples/custom-consent-authorizationserver/gradle.properties +++ b/samples/custom-consent-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-SNAPSHOT +spring-security.version=6.1.0-M2 diff --git a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle index 1b8b5373f..768ed5219 100644 --- a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle +++ b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/samples/default-authorizationserver/gradle.properties b/samples/default-authorizationserver/gradle.properties index 658d3b529..74915daef 100644 --- a/samples/default-authorizationserver/gradle.properties +++ b/samples/default-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-SNAPSHOT +spring-security.version=6.1.0-M2 diff --git a/samples/default-authorizationserver/samples-default-authorizationserver.gradle b/samples/default-authorizationserver/samples-default-authorizationserver.gradle index 4a3e850ea..61721414d 100644 --- a/samples/default-authorizationserver/samples-default-authorizationserver.gradle +++ b/samples/default-authorizationserver/samples-default-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/samples/device-client/gradle.properties b/samples/device-client/gradle.properties index 658d3b529..74915daef 100644 --- a/samples/device-client/gradle.properties +++ b/samples/device-client/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-SNAPSHOT +spring-security.version=6.1.0-M2 diff --git a/samples/device-client/samples-device-client.gradle b/samples/device-client/samples-device-client.gradle index 5b8971009..40e714eb8 100644 --- a/samples/device-client/samples-device-client.gradle +++ b/samples/device-client/samples-device-client.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/samples/device-grant-authorizationserver/gradle.properties b/samples/device-grant-authorizationserver/gradle.properties index 658d3b529..74915daef 100644 --- a/samples/device-grant-authorizationserver/gradle.properties +++ b/samples/device-grant-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-SNAPSHOT +spring-security.version=6.1.0-M2 diff --git a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle index 8530b31c4..9b13324e0 100644 --- a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle +++ b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url = "https://repo.spring.io/snapshot" } + maven { url = "https://repo.spring.io/milestone" } } dependencies { diff --git a/samples/federated-identity-authorizationserver/gradle.properties b/samples/federated-identity-authorizationserver/gradle.properties index 658d3b529..74915daef 100644 --- a/samples/federated-identity-authorizationserver/gradle.properties +++ b/samples/federated-identity-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-SNAPSHOT +spring-security.version=6.1.0-M2 diff --git a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle index 5c503b27f..7042fa45b 100644 --- a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle +++ b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/samples/messages-client/samples-messages-client.gradle b/samples/messages-client/samples-messages-client.gradle index d3ce2c845..1c95c65ff 100644 --- a/samples/messages-client/samples-messages-client.gradle +++ b/samples/messages-client/samples-messages-client.gradle @@ -10,6 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/samples/messages-resource/samples-messages-resource.gradle b/samples/messages-resource/samples-messages-resource.gradle index 02dc36f1e..fa9ec09fc 100644 --- a/samples/messages-resource/samples-messages-resource.gradle +++ b/samples/messages-resource/samples-messages-resource.gradle @@ -10,6 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() + maven { url "https://repo.spring.io/milestone" } } dependencies { diff --git a/settings.gradle b/settings.gradle index 4c8761af0..e017a26b4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ pluginManagement { repositories { gradlePluginPortal() maven { url 'https://repo.spring.io/release' } - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/milestone" } } } From 37fa1f1031fa431a9adba0c36644370205c048a0 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 21 Mar 2023 10:10:37 -0500 Subject: [PATCH 059/250] Update to nimbus-jose-jwt:9.31 Closes gh-1132 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index d0a3560e6..58d55918f 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -11,7 +11,7 @@ dependencies { api platform("org.springframework.security:spring-security-bom:$springSecurityVersion") api platform("com.fasterxml.jackson:jackson-bom:2.14.2") constraints { - api "com.nimbusds:nimbus-jose-jwt:9.30.2" + 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.2" api "org.assertj:assertj-core:3.24.2" From 0a8004aecd2d8639de401a01300fe339711831e5 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 21 Mar 2023 10:56:19 -0500 Subject: [PATCH 060/250] Update to Spring Framework 6.0.7 in buildSrc Issue gh-1130 --- buildSrc/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index dc0c46910..431501d0f 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:6.0.5" + implementation "org.springframework:spring-core:6.0.7" } From 81458eb02fac528bdc31d7184a5ac650b52380cd Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 21 Mar 2023 10:54:20 -0500 Subject: [PATCH 061/250] Release 1.1.0-M2 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 939e84417..8b7643af0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0-SNAPSHOT +version=1.1.0-M2 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 47ff4ad06e1f6743d57775d3158af999f700e221 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 21 Mar 2023 11:13:45 -0500 Subject: [PATCH 062/250] Next Development Version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8b7643af0..939e84417 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0-M2 +version=1.1.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 1354ca4549cf8e7f1584f873f71f2d3e5168cc97 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 21 Mar 2023 05:21:48 -0400 Subject: [PATCH 063/250] Polish gh-1106 Device Authorization Grant --- ...ractOAuth2AuthorizationServerMetadata.java | 17 +++- .../InMemoryOAuth2AuthorizationService.java | 4 +- .../authorization/OAuth2Authorization.java | 14 +-- ...horizationServerMetadataClaimAccessor.java | 13 ++- ...AuthorizationServerMetadataClaimNames.java | 9 +- ...orizationConsentAuthenticationContext.java | 6 +- ...rizationConsentAuthenticationProvider.java | 6 ++ ...rizationConsentAuthenticationProvider.java | 59 ++++++------- ...thorizationConsentAuthenticationToken.java | 9 +- ...rizationRequestAuthenticationProvider.java | 34 ++++---- ...thorizationRequestAuthenticationToken.java | 17 ++-- ...Auth2DeviceCodeAuthenticationProvider.java | 86 ++++++++++--------- .../OAuth2DeviceCodeAuthenticationToken.java | 6 +- ...iceVerificationAuthenticationProvider.java | 78 ++++++++--------- ...DeviceVerificationAuthenticationToken.java | 46 +++++----- .../OAuth2AuthorizationServerConfigurer.java | 5 +- ...DeviceAuthorizationEndpointConfigurer.java | 27 +++--- ...2DeviceVerificationEndpointConfigurer.java | 48 ++++++----- ...ionServerMetadataHttpMessageConverter.java | 3 +- ...dcProviderConfigurationEndpointFilter.java | 2 + .../settings/AuthorizationServerSettings.java | 10 ++- .../authorization/settings/TokenSettings.java | 6 +- ...orizationServerMetadataEndpointFilter.java | 4 +- ...uth2DeviceAuthorizationEndpointFilter.java | 62 +++++++------ ...Auth2DeviceVerificationEndpointFilter.java | 22 ++--- .../web/OAuth2TokenEndpointFilter.java | 2 + ...izationConsentAuthenticationConverter.java | 41 +++++---- ...uth2DeviceCodeAuthenticationConverter.java | 7 +- ...ceVerificationAuthenticationConverter.java | 5 +- .../OAuth2ClientCredentialsGrantTests.java | 7 +- ...viderConfigurationEndpointFilterTests.java | 2 +- .../settings/TokenSettingsTests.java | 2 +- ...tionServerMetadataEndpointFilterTests.java | 2 +- .../java/sample/config/SecurityConfig.java | 2 +- ...es-device-grant-authorizationserver.gradle | 2 - .../java/sample/config/SecurityConfig.java | 8 +- .../java/sample/web/DeviceController.java | 2 +- .../main/resources/templates/activate.html | 4 +- 38 files changed, 371 insertions(+), 308 deletions(-) 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 abdc15d78..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 @@ -155,7 +155,9 @@ private static boolean hasToken(OAuth2Authorization authorization, String token, matchesAuthorizationCode(authorization, token) || matchesAccessToken(authorization, token) || matchesIdToken(authorization, token) || - matchesRefreshToken(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())) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java index afdfc3a60..ef8bb69dd 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2023 the original author or authors. + * 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. @@ -253,18 +253,6 @@ public static class Token implements Serializable { */ public static final String INVALIDATED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("invalidated"); - /** - * The name of the metadata that indicates if access has been denied by the resource owner. - * Used with the OAuth 2.0 Device Authorization Grant. - */ - public static final String ACCESS_DENIED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_denied"); - - /** - * The name of the metadata that indicates if access has been denied by the resource owner. - * Used with the OAuth 2.0 Device Authorization Grant. - */ - public static final String ACCESS_GRANTED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_granted"); - /** * The name of the metadata used for the claims of the token. */ 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/OAuth2AuthorizationConsentAuthenticationContext.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java index 343526fda..c8e572c7a 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-2023 the original author or authors. + * 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. @@ -113,10 +113,6 @@ private Builder(OAuth2AuthorizationConsentAuthenticationToken authentication) { super(authentication); } - private Builder(OAuth2DeviceAuthorizationConsentAuthenticationToken authentication) { - super(authentication); - } - /** * Sets the {@link OAuth2AuthorizationConsent.Builder authorization consent builder}. * 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 index 07e8f5842..a3cc37aa6 100644 --- 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 @@ -15,7 +15,6 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; -import java.security.Principal; import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -24,6 +23,7 @@ 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; @@ -33,7 +33,6 @@ 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.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; @@ -45,8 +44,8 @@ import org.springframework.util.Assert; /** - * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Authorization Consent - * used in the Device Authorization Grant. + * An {@link AuthenticationProvider} implementation for the Device Authorization Consent + * used in the OAuth 2.0 Device Authorization Grant. * * @author Steve Riesenberg * @since 1.1 @@ -61,7 +60,7 @@ */ public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider { - private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; + 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()); @@ -104,7 +103,11 @@ public Authentication authenticate(Authentication authentication) throws Authent 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()); @@ -116,12 +119,8 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Retrieved registered client"); } - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( - OAuth2AuthorizationRequest.class.getName()); - Set requestedScopes = authorizationRequest.getScopes(); - Set authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ? - new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) : - new HashSet<>(); + Set requestedScopes = authorization.getAttribute(OAuth2ParameterNames.SCOPE); + Set authorizedScopes = new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()); if (!requestedScopes.containsAll(authorizedScopes)) { throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE); } @@ -162,7 +161,6 @@ public Authentication authenticate(Authentication authentication) throws Authent .authorizationConsent(authorizationConsentBuilder) .registeredClient(registeredClient) .authorization(authorization) - .authorizationRequest(authorizationRequest) .build(); // @formatter:on this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext); @@ -187,15 +185,16 @@ public Authentication authenticate(Authentication authentication) throws Authent } authorization = OAuth2Authorization.from(authorization) .token(deviceCodeToken.getToken(), metadata -> - metadata.put(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME, true)) + 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"); } - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED); + throwError(OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID); } OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); @@ -206,26 +205,23 @@ public Authentication authenticate(Authentication authentication) throws Authent } } - OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) - .principalName(principal.getName()) + authorization = OAuth2Authorization.from(authorization) .authorizedScopes(authorizedScopes) - .token(deviceCodeToken.getToken(), metadata -> metadata - .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) - .token(userCodeToken.getToken(), metadata -> metadata - .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) - .attribute(Principal.class.getName(), principal) + .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(updatedAuthorization); + 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 authorization consent request"); + this.logger.trace("Authenticated device authorization consent request"); } - return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, - deviceAuthorizationConsentAuthentication.getUserCode()); + return new OAuth2DeviceVerificationAuthenticationToken(principal, + deviceAuthorizationConsentAuthentication.getUserCode(), registeredClient.getClientId()); } @Override @@ -244,10 +240,9 @@ public boolean supports(Class authentication) { * prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}. *
  • The {@link Authentication} of type * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.
  • - *
  • The {@link RegisteredClient} associated with the authorization request.
  • + *
  • The {@link RegisteredClient} associated with the device authorization request.
  • *
  • The {@link OAuth2Authorization} associated with the state token presented in the - * authorization consent request.
  • - *
  • The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.
  • + * device authorization consent request. * * * @param authorizationConsentCustomizer the {@code Consumer} providing access to the @@ -258,8 +253,14 @@ public void setAuthorizationConsentCustomizer(Consumer 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"); } @@ -128,7 +137,7 @@ public Authentication authenticate(Authentication authentication) throws Authent } if (this.logger.isTraceEnabled()) { - logger.trace("Generated device code"); + this.logger.trace("Generated device code"); } // Generate a low-entropy string to use as the user code @@ -141,21 +150,9 @@ public Authentication authenticate(Authentication authentication) throws Authent } if (this.logger.isTraceEnabled()) { - logger.trace("Generated user code"); + this.logger.trace("Generated user code"); } - String authorizationUri = deviceAuthorizationRequestAuthentication.getAuthorizationUri(); - - Set requestedScopes = deviceAuthorizationRequestAuthentication.getScopes(); - - // @formatter:off - OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() - .authorizationUri(authorizationUri) - .clientId(registeredClient.getClientId()) - .scopes(requestedScopes) - .build(); - // @formatter:on - // @formatter:off OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) .principalName(clientPrincipal.getName()) @@ -163,7 +160,7 @@ public Authentication authenticate(Authentication authentication) throws Authent .token(deviceCode) .token(userCode) .attribute(Principal.class.getName(), clientPrincipal) - .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest) + .attribute(OAuth2ParameterNames.SCOPE, new HashSet<>(requestedScopes)) .build(); // @formatter:on this.authorizationService.save(authorization); @@ -176,7 +173,8 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Authenticated device authorization request"); } - return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, requestedScopes, deviceCode, userCode); + return new OAuth2DeviceAuthorizationRequestAuthenticationToken( + clientPrincipal, requestedScopes, deviceCode, userCode); } @Override 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 index 414293646..c80d9b647 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -29,12 +30,13 @@ import org.springframework.util.Assert; /** - * An {@link Authentication} implementation for the OAuth 2.0 Device Authorization Request - * used in the Device Authorization Grant. + * 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 { @@ -65,7 +67,10 @@ public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication client scopes != null ? new HashSet<>(scopes) : Collections.emptySet()); - this.additionalParameters = additionalParameters; + this.additionalParameters = Collections.unmodifiableMap( + additionalParameters != null ? + new HashMap<>(additionalParameters) : + Collections.emptyMap()); this.deviceCode = null; this.userCode = null; } @@ -109,16 +114,16 @@ public Object getCredentials() { /** * Returns the authorization {@code URI}. * - * @return the authorization {@code URI}. + * @return the authorization {@code URI} */ public String getAuthorizationUri() { - return authorizationUri; + return this.authorizationUri; } /** * Returns the requested scope(s). * - * @return the requested scope(s). + * @return the requested scope(s) */ public Set getScopes() { return this.scopes; 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 index 036539375..5e08ea541 100644 --- 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 @@ -26,7 +26,6 @@ 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.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2DeviceCode; @@ -34,7 +33,7 @@ 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.endpoint.OAuth2AuthorizationRequest; +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; @@ -46,8 +45,11 @@ 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 OAuth 2.0 Device Authorization Grant. + * 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 @@ -94,8 +96,8 @@ public Authentication authenticate(Authentication authentication) throws Authent OAuth2DeviceCodeAuthenticationToken deviceCodeAuthentication = (OAuth2DeviceCodeAuthenticationToken) authentication; - OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils - .getAuthenticatedClientElseThrowInvalidClient(deviceCodeAuthentication); + OAuth2ClientAuthenticationToken clientPrincipal = + getAuthenticatedClientElseThrowInvalidClient(deviceCodeAuthentication); RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); if (this.logger.isTraceEnabled()) { @@ -112,19 +114,17 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Retrieved authorization with device code"); } - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( - OAuth2AuthorizationRequest.class.getName()); - + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); - if (!registeredClient.getClientId().equals(authorizationRequest.getClientId())) { + 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'", registeredClient.getId())); + "Invalidated device code used by registered client '%s'", authorization.getRegisteredClientId())); } } throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); @@ -133,29 +133,6 @@ public Authentication authenticate(Authentication authentication) throws Authent // In https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5, // the following error codes are defined: - // access_denied - // The authorization request was denied. - if (Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME))) { - 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()) { - OAuth2Error error = new OAuth2Error(EXPIRED_TOKEN, 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. - // authorization_pending // The authorization request is still pending as the end user hasn't // yet completed the user-interaction steps (Section 3.3). The @@ -166,17 +143,43 @@ public Authentication authenticate(Authentication authentication) throws Authent // 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 (!Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME))) { + if (!userCode.isInvalidated()) { OAuth2Error error = new OAuth2Error(AUTHORIZATION_PENDING, null, DEVICE_ERROR_URI); throw new OAuth2AuthenticationException(error); } - if (!deviceCode.isActive()) { - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + // 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 token request parameters"); + this.logger.trace("Validated device token request parameters"); } // @formatter:off @@ -222,10 +225,7 @@ public Authentication authenticate(Authentication authentication) throws Authent // ----- Refresh token ----- OAuth2RefreshToken refreshToken = null; - if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) && - // Do not issue refresh token to public client - !clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) { - + if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN)) { tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build(); OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext); if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) { @@ -250,6 +250,10 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Saved authorization"); } + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated device token request"); + } + return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken); } 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 index 2df84805a..29f7cfdf5 100644 --- 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 @@ -23,7 +23,8 @@ import org.springframework.util.Assert; /** - * An {@link Authentication} implementation used for the OAuth 2.0 Device Authorization Grant. + * 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 @@ -41,7 +42,8 @@ public class OAuth2DeviceCodeAuthenticationToken extends OAuth2AuthorizationGran * @param clientPrincipal the authenticated client principal * @param additionalParameters the additional parameters */ - public OAuth2DeviceCodeAuthenticationToken(String deviceCode, Authentication clientPrincipal, @Nullable Map additionalParameters) { + 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; 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 index e4ca833de..d0b0c2e90 100644 --- 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 @@ -29,10 +29,8 @@ 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.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2UserCode; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; @@ -41,16 +39,17 @@ 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 Verification {@code URI} - * (submission of the user code)} used in the OAuth 2.0 Device Authorization Grant. + * 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 OAuth2AuthorizationConsent * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider * @see OAuth2DeviceCodeAuthenticationProvider @@ -105,38 +104,37 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Retrieved authorization with user code"); } - RegisteredClient registeredClient = this.registeredClientRepository.findById( - authorization.getRegisteredClientId()); - - if (this.logger.isTraceEnabled()) { - this.logger.trace("Retrieved registered client"); - } - Authentication principal = (Authentication) deviceVerificationAuthentication.getPrincipal(); if (!isPrincipalAuthenticated(principal)) { if (this.logger.isTraceEnabled()) { - this.logger.trace("Did not authenticate device authorization request since principal not authenticated"); + this.logger.trace("Did not authenticate device verification request since principal not authenticated"); } - // Return the authorization request as-is where isAuthenticated() is false + // Return the device verification request as-is where isAuthenticated() is false return deviceVerificationAuthentication; } - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + 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()); - Set currentAuthorizedScopes = currentAuthorizationConsent != null ? - currentAuthorizationConsent.getScopes() : null; - - if (requiresAuthorizationConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) { + 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()) { - logger.trace("Generated authorization consent state"); + this.logger.trace("Generated device authorization consent state"); } this.authorizationService.save(authorization); @@ -145,33 +143,39 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Saved authorization"); } - return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationRequest.getAuthorizationUri(), + 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, - authorizationRequest.getScopes(), currentAuthorizedScopes); + requestedScopes, currentAuthorizedScopes); } - OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); - OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) + // @formatter:off + authorization = OAuth2Authorization.from(authorization) .principalName(principal.getName()) - .authorizedScopes(authorizationRequest.getScopes()) - .token(deviceCode.getToken(), metadata -> metadata - .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) + .authorizedScopes(requestedScopes) .token(userCode.getToken(), metadata -> metadata .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) .attribute(Principal.class.getName(), principal) - .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .attributes(attributes -> attributes.remove(OAuth2ParameterNames.SCOPE)) .build(); - this.authorizationService.save(updatedAuthorization); + // @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 authorization consent request"); + this.logger.trace("Authenticated device verification request"); } - return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, - deviceVerificationAuthentication.getUserCode()); + return new OAuth2DeviceVerificationAuthenticationToken(principal, + deviceVerificationAuthentication.getUserCode(), registeredClient.getClientId()); } @Override @@ -179,15 +183,11 @@ public boolean supports(Class authentication) { return OAuth2DeviceVerificationAuthenticationToken.class.isAssignableFrom(authentication); } - private static boolean requiresAuthorizationConsent(RegisteredClient registeredClient, - OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent authorizationConsent) { - - if (!registeredClient.getClientSettings().isRequireAuthorizationConsent()) { - return false; - } + private static boolean requiresAuthorizationConsent( + Set requestedScopes, OAuth2AuthorizationConsent authorizationConsent) { if (authorizationConsent != null && - authorizationConsent.getScopes().containsAll(authorizationRequest.getScopes())) { + authorizationConsent.getScopes().containsAll(requestedScopes)) { return false; } 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 index f24b06001..40d40de2a 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -25,7 +26,7 @@ import org.springframework.util.Assert; /** - * An {@link Authentication} implementation for the Verification {@code URI} + * 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 @@ -35,44 +36,47 @@ */ public class OAuth2DeviceVerificationAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; - private final String clientId; 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 request + * @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.notNull(userCode, "userCode cannot be null"); - this.clientId = null; + Assert.hasText(userCode, "userCode cannot be empty"); this.principal = principal; this.userCode = userCode; - this.additionalParameters = additionalParameters; + this.additionalParameters = Collections.unmodifiableMap( + additionalParameters != null ? + new HashMap<>(additionalParameters) : + Collections.emptyMap()); + this.clientId = null; } /** * Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters. * - * @param clientId the client identifier * @param principal the {@code Principal} (Resource Owner) - * @param userCode the user code associated with the device authorization request + * @param userCode the user code associated with the device authorization response + * @param clientId the client identifier */ - public OAuth2DeviceVerificationAuthenticationToken(String clientId, Authentication principal, String userCode) { + public OAuth2DeviceVerificationAuthenticationToken(Authentication principal, String userCode, String clientId) { super(Collections.emptyList()); - Assert.hasText(clientId, "clientId cannot be empty"); Assert.notNull(principal, "principal cannot be null"); - Assert.notNull(userCode, "userCode cannot be null"); - this.clientId = clientId; + 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); } @@ -87,15 +91,6 @@ public Object getCredentials() { return ""; } - /** - * Returns the client identifier. - * - * @return the client identifier - */ - public String getClientId() { - return this.clientId; - } - /** * Returns the user code. * @@ -114,4 +109,13 @@ 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/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 121d15a20..dc6d204eb 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 @@ -67,6 +67,8 @@ * @see OAuth2TokenEndpointConfigurer * @see OAuth2TokenIntrospectionEndpointConfigurer * @see OAuth2TokenRevocationEndpointConfigurer + * @see OAuth2DeviceAuthorizationEndpointConfigurer + * @see OAuth2DeviceVerificationEndpointConfigurer * @see OidcConfigurer * @see RegisteredClientRepository * @see OAuth2AuthorizationService @@ -316,7 +318,8 @@ public void init(HttpSecurity httpSecurity) { new OrRequestMatcher( getRequestMatcher(OAuth2TokenEndpointConfigurer.class), getRequestMatcher(OAuth2TokenIntrospectionEndpointConfigurer.class), - getRequestMatcher(OAuth2TokenRevocationEndpointConfigurer.class)) + getRequestMatcher(OAuth2TokenRevocationEndpointConfigurer.class), + getRequestMatcher(OAuth2DeviceAuthorizationEndpointConfigurer.class)) ); } } 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 index b9e5d6155..765548f32 100644 --- 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 @@ -26,7 +26,9 @@ 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; @@ -41,6 +43,7 @@ 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. @@ -53,8 +56,8 @@ public final class OAuth2DeviceAuthorizationEndpointConfigurer extends AbstractOAuth2Configurer { private RequestMatcher requestMatcher; - private final List authenticationConverters = new ArrayList<>(); - private Consumer> authenticationConvertersConsumer = (authenticationConverters) -> {}; + private final List deviceAuthorizationRequestConverters = new ArrayList<>(); + private Consumer> deviceAuthorizationRequestConvertersConsumer = (deviceAuthorizationRequestConverters) -> {}; private final List authenticationProviders = new ArrayList<>(); private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; private AuthenticationSuccessHandler deviceAuthorizationResponseHandler; @@ -77,7 +80,7 @@ public final class OAuth2DeviceAuthorizationEndpointConfigurer extends AbstractO */ public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverter(AuthenticationConverter deviceAuthorizationRequestConverter) { Assert.notNull(deviceAuthorizationRequestConverter, "deviceAuthorizationRequestConverter cannot be null"); - this.authenticationConverters.add(deviceAuthorizationRequestConverter); + this.deviceAuthorizationRequestConverters.add(deviceAuthorizationRequestConverter); return this; } @@ -92,7 +95,7 @@ public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestCon public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverters( Consumer> deviceAuthorizationRequestConvertersConsumer) { Assert.notNull(deviceAuthorizationRequestConvertersConsumer, "deviceAuthorizationRequestConvertersConsumer cannot be null"); - this.authenticationConvertersConsumer = deviceAuthorizationRequestConvertersConsumer; + this.deviceAuthorizationRequestConvertersConsumer = deviceAuthorizationRequestConvertersConsumer; return this; } @@ -125,7 +128,7 @@ public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProviders( /** * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} - * and returning the Device Authorization Response. + * 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 @@ -136,10 +139,10 @@ public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationResponseHa } /** - * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * 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 OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration */ public OAuth2DeviceAuthorizationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) { @@ -184,10 +187,10 @@ public void configure(HttpSecurity builder) { authenticationManager, authorizationServerSettings.getDeviceAuthorizationEndpoint()); List authenticationConverters = createDefaultAuthenticationConverters(); - if (!this.authenticationConverters.isEmpty()) { - authenticationConverters.addAll(0, this.authenticationConverters); + if (!this.deviceAuthorizationRequestConverters.isEmpty()) { + authenticationConverters.addAll(0, this.deviceAuthorizationRequestConverters); } - this.authenticationConvertersConsumer.accept(authenticationConverters); + this.deviceAuthorizationRequestConvertersConsumer.accept(authenticationConverters); deviceAuthorizationEndpointFilter.setAuthenticationConverter( new DelegatingAuthenticationConverter(authenticationConverters)); if (this.deviceAuthorizationResponseHandler != null) { @@ -196,7 +199,7 @@ public void configure(HttpSecurity builder) { if (this.errorResponseHandler != null) { deviceAuthorizationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); } - if (this.verificationUri != null) { + if (StringUtils.hasText(this.verificationUri)) { deviceAuthorizationEndpointFilter.setVerificationUri(this.verificationUri); } builder.addFilterAfter(postProcess(deviceAuthorizationEndpointFilter), AuthorizationFilter.class); @@ -214,7 +217,7 @@ private static List createDefaultAuthenticationConverte return authenticationConverters; } - private List createDefaultAuthenticationProviders(HttpSecurity builder) { + private static List createDefaultAuthenticationProviders(HttpSecurity builder) { List authenticationProviders = new ArrayList<>(); OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(builder); 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 index c973bddd5..bc50f7b43 100644 --- 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 @@ -30,7 +30,6 @@ 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.OAuth2AuthorizationCodeRequestAuthenticationToken; 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; @@ -41,10 +40,10 @@ 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.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.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; @@ -62,8 +61,8 @@ public final class OAuth2DeviceVerificationEndpointConfigurer extends AbstractOAuth2Configurer { private RequestMatcher requestMatcher; - private final List authenticationConverters = new ArrayList<>(); - private Consumer> authenticationConvertersConsumer = (authenticationConverters) -> {}; + private final List deviceVerificationRequestConverters = new ArrayList<>(); + private Consumer> deviceVerificationRequestConvertersConsumer = (deviceVerificationRequestConverters) -> {}; private final List authenticationProviders = new ArrayList<>(); private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; private AuthenticationSuccessHandler deviceVerificationResponseHandler; @@ -78,15 +77,15 @@ public final class OAuth2DeviceVerificationEndpointConfigurer extends AbstractOA } /** - * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Consent) from {@link HttpServletRequest} + * 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 Authorization Request from {@link HttpServletRequest} + * @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.authenticationConverters.add(deviceVerificationRequestConverter); + this.deviceVerificationRequestConverters.add(deviceVerificationRequestConverter); return this; } @@ -101,14 +100,14 @@ public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConve public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverters( Consumer> deviceVerificationRequestConvertersConsumer) { Assert.notNull(deviceVerificationRequestConvertersConsumer, "deviceVerificationRequestConvertersConsumer cannot be null"); - this.authenticationConvertersConsumer = deviceVerificationRequestConvertersConsumer; + this.deviceVerificationRequestConvertersConsumer = deviceVerificationRequestConvertersConsumer; return this; } /** - * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken}. + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}. * - * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken} + * @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) { @@ -133,10 +132,10 @@ public OAuth2DeviceVerificationEndpointConfigurer authenticationProviders( } /** - * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} * and returning the response. * - * @param deviceVerificationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken} + * @param deviceVerificationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration */ public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationResponseHandler(AuthenticationSuccessHandler deviceVerificationResponseHandler) { @@ -166,9 +165,9 @@ public OAuth2DeviceVerificationEndpointConfigurer errorResponseHandler(Authentic * *
      *
    • {@code client_id} - the client identifier
    • - *
    • {@code scope} - a space-delimited list of scopes present in the authorization request
    • + *
    • {@code scope} - a space-delimited list of scopes present in the device authorization request
    • *
    • {@code state} - a CSRF protection token
    • - *
    • @code code} - the user code
    • + *
    • {@code user_code} - the user code
    • *
    * * In general, the consent page should create a form that submits @@ -181,7 +180,7 @@ public OAuth2DeviceVerificationEndpointConfigurer errorResponseHandler(Authentic *
  • 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 user {@code code} 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") @@ -198,9 +197,11 @@ public void init(HttpSecurity builder) { OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); this.requestMatcher = new OrRequestMatcher( new AntPathRequestMatcher( - authorizationServerSettings.getDeviceVerificationEndpoint(), HttpMethod.GET.name()), + authorizationServerSettings.getDeviceVerificationEndpoint(), + HttpMethod.GET.name()), new AntPathRequestMatcher( - authorizationServerSettings.getDeviceVerificationEndpoint(), HttpMethod.POST.name())); + authorizationServerSettings.getDeviceVerificationEndpoint(), + HttpMethod.POST.name())); List authenticationProviders = createDefaultAuthenticationProviders(builder); if (!this.authenticationProviders.isEmpty()) { @@ -214,18 +215,18 @@ public void init(HttpSecurity builder) { @Override public void configure(HttpSecurity builder) { AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); - AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); OAuth2DeviceVerificationEndpointFilter deviceVerificationEndpointFilter = new OAuth2DeviceVerificationEndpointFilter( - authenticationManager, authorizationServerSettings.getDeviceVerificationEndpoint()); + authenticationManager, + authorizationServerSettings.getDeviceVerificationEndpoint()); List authenticationConverters = createDefaultAuthenticationConverters(); - if (!this.authenticationConverters.isEmpty()) { - authenticationConverters.addAll(0, this.authenticationConverters); + if (!this.deviceVerificationRequestConverters.isEmpty()) { + authenticationConverters.addAll(0, this.deviceVerificationRequestConverters); } - this.authenticationConvertersConsumer.accept(authenticationConverters); + this.deviceVerificationRequestConvertersConsumer.accept(authenticationConverters); deviceVerificationEndpointFilter.setAuthenticationConverter( new DelegatingAuthenticationConverter(authenticationConverters)); if (this.deviceVerificationResponseHandler != null) { @@ -237,7 +238,7 @@ public void configure(HttpSecurity builder) { if (StringUtils.hasText(this.consentPage)) { deviceVerificationEndpointFilter.setConsentPage(this.consentPage); } - builder.addFilterAfter(postProcess(deviceVerificationEndpointFilter), AuthorizationFilter.class); + builder.addFilterBefore(postProcess(deviceVerificationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); } @Override @@ -247,6 +248,7 @@ RequestMatcher getRequestMatcher() { private static List createDefaultAuthenticationConverters() { List authenticationConverters = new ArrayList<>(); + authenticationConverters.add(new OAuth2DeviceVerificationAuthenticationConverter()); authenticationConverters.add(new OAuth2DeviceAuthorizationConsentAuthenticationConverter()); 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/web/OidcProviderConfigurationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java index 608aafd5d..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 @@ -94,6 +94,7 @@ 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())) @@ -103,6 +104,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/settings/AuthorizationServerSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java index 7dc502315..57af7f7ee 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 @@ -55,16 +55,18 @@ public String getAuthorizationEndpoint() { /** * Returns the OAuth 2.0 Device Authorization endpoint. The default is {@code /oauth2/device_authorization}. * - * @return the Authorization endpoint + * @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}. + * Returns the OAuth 2.0 Device Verification endpoint. The default is {@code /oauth2/device_verification}. * - * @return the Authorization endpoint + * @return the Device Verification endpoint + * @since 1.1 */ public String getDeviceVerificationEndpoint() { return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT); @@ -198,6 +200,7 @@ public Builder authorizationEndpoint(String authorizationEndpoint) { * * @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); @@ -208,6 +211,7 @@ public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) { * * @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); 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 16c364b3c..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 @@ -67,9 +67,9 @@ public OAuth2TokenFormat getAccessTokenFormat() { } /** - * Returns the time-to-live for a device code. The default is 30 minutes. + * Returns the time-to-live for a device code. The default is 5 minutes. * - * @return the time-to-live for an authorization code + * @return the time-to-live for a device code * @since 1.1 */ public Duration getDeviceCodeTimeToLive() { @@ -113,7 +113,7 @@ public static Builder builder() { .authorizationCodeTimeToLive(Duration.ofMinutes(5)) .accessTokenTimeToLive(Duration.ofMinutes(5)) .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) - .deviceCodeTimeToLive(Duration.ofMinutes(30)) + .deviceCodeTimeToLive(Duration.ofMinutes(5)) .reuseRefreshTokens(true) .refreshTokenTimeToLive(Duration.ofMinutes(60)) .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256); 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 index 480c7d407..f5473f923 100644 --- 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 @@ -27,6 +27,7 @@ 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; @@ -40,7 +41,6 @@ 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.OAuth2AuthorizationCodeRequestAuthenticationException; 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; @@ -56,7 +56,7 @@ import org.springframework.web.util.UriComponentsBuilder; /** - * A {@code Filter} for the OAuth 2.0 Device Authorization Grant, + * 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 @@ -72,20 +72,18 @@ public final class OAuth2DeviceAuthorizationEndpointFilter extends OncePerReques private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorization"; - private static final String DEFAULT_DEVICE_VERIFICATION_URI = "/oauth2/device_verification"; - private final AuthenticationManager authenticationManager; private final RequestMatcher deviceAuthorizationEndpointMatcher; private final HttpMessageConverter deviceAuthorizationHttpResponseConverter = new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); private final HttpMessageConverter errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter(); - private AuthenticationConverter authenticationConverter; private AuthenticationDetailsSource authenticationDetailsSource = new WebAuthenticationDetailsSource(); + private AuthenticationConverter authenticationConverter; private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendDeviceAuthorizationResponse; private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; - private String verificationUri = DEFAULT_DEVICE_VERIFICATION_URI; + private String verificationUri = OAuth2DeviceVerificationEndpointFilter.DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI; /** * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. @@ -121,17 +119,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } try { - OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationToken = - (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationConverter.convert(request); - deviceAuthorizationRequestAuthenticationToken.setDetails( - this.authenticationDetailsSource.buildDetails(request)); + Authentication deviceAuthorizationRequestAuthentication = this.authenticationConverter.convert(request); + if (deviceAuthorizationRequestAuthentication instanceof AbstractAuthenticationToken) { + ((AbstractAuthenticationToken) deviceAuthorizationRequestAuthentication) + .setDetails(this.authenticationDetailsSource.buildDetails(request)); + } - OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationTokenResult = - (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationManager.authenticate( - deviceAuthorizationRequestAuthenticationToken); + Authentication deviceAuthorizationRequestAuthenticationResult = + this.authenticationManager.authenticate(deviceAuthorizationRequestAuthentication); this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, - deviceAuthorizationRequestAuthenticationTokenResult); + deviceAuthorizationRequestAuthenticationResult); } catch (OAuth2AuthenticationException ex) { SecurityContextHolder.clearContext(); if (this.logger.isTraceEnabled()) { @@ -141,17 +139,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } - /** - * 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 DeviceAuthorization Request from {@link HttpServletRequest} - */ - public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { - Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); - this.authenticationConverter = authenticationConverter; - } - /** * Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}. * @@ -162,9 +149,20 @@ public void setAuthenticationDetailsSource(AuthenticationDetailsSourceSection 3.2 Token Endpoint */ public final class OAuth2TokenEndpointFilter extends OncePerRequestFilter { 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 index 7d6f5bfcf..d66523769 100644 --- 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 @@ -35,7 +35,7 @@ import org.springframework.util.StringUtils; /** - * Attempts to extract an Authorization Consent from {@link HttpServletRequest} + * 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. @@ -48,14 +48,14 @@ */ public final class OAuth2DeviceAuthorizationConsentAuthenticationConverter implements AuthenticationConverter { - private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; - private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3"; + 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())) { + if (!"POST".equals(request.getMethod()) || + request.getParameter(OAuth2ParameterNames.STATE) == null) { return null; } @@ -63,22 +63,14 @@ public Authentication convert(HttpServletRequest request) { String authorizationUri = request.getRequestURL().toString(); - // user_code (REQUIRED) - String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); - if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { - OAuth2EndpointUtils.throwError( - OAuth2ErrorCodes.INVALID_REQUEST, - OAuth2ParameterNames.USER_CODE, - DEVICE_ERROR_URI); - } - // client_id (REQUIRED) String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); - if (!StringUtils.hasText(clientId) || parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { + if (!StringUtils.hasText(clientId) || + parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { OAuth2EndpointUtils.throwError( OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, - DEFAULT_ERROR_URI); + ERROR_URI); } Authentication principal = SecurityContextHolder.getContext().getAuthentication(); @@ -86,13 +78,24 @@ public Authentication convert(HttpServletRequest request) { principal = ANONYMOUS_AUTHENTICATION; } + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!StringUtils.hasText(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) { + if (!StringUtils.hasText(state) || + parameters.get(OAuth2ParameterNames.STATE).size() != 1) { OAuth2EndpointUtils.throwError( OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, - DEFAULT_ERROR_URI); + ERROR_URI); } // scope (OPTIONAL) @@ -104,9 +107,9 @@ public Authentication convert(HttpServletRequest request) { 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) && - !key.equals(OAuth2ParameterNames.USER_CODE)) { + !key.equals(OAuth2ParameterNames.SCOPE)) { additionalParameters.put(key, value.get(0)); } }); 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 index d0fcaa278..8738b21ef 100644 --- 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 @@ -20,6 +20,7 @@ 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; @@ -32,7 +33,7 @@ import org.springframework.util.StringUtils; /** - * Attempts to extract an Access Token Request from {@link HttpServletRequest} for the + * 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. @@ -45,6 +46,7 @@ */ public final class OAuth2DeviceCodeAuthenticationConverter implements AuthenticationConverter { + @Nullable @Override public Authentication convert(HttpServletRequest request) { // grant_type (REQUIRED) @@ -59,7 +61,8 @@ public Authentication convert(HttpServletRequest request) { // device_code (REQUIRED) String deviceCode = parameters.getFirst(OAuth2ParameterNames.DEVICE_CODE); - if (!StringUtils.hasText(deviceCode) || parameters.get(OAuth2ParameterNames.DEVICE_CODE).size() != 1) { + if (!StringUtils.hasText(deviceCode) || + parameters.get(OAuth2ParameterNames.DEVICE_CODE).size() != 1) { OAuth2EndpointUtils.throwError( OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.DEVICE_CODE, 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 index 4d3bf3e24..b5248352d 100644 --- 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 @@ -46,7 +46,7 @@ */ public final class OAuth2DeviceVerificationAuthenticationConverter implements AuthenticationConverter { - private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3"; + 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")); @@ -64,7 +64,8 @@ public Authentication convert(HttpServletRequest request) { // user_code (REQUIRED) String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); - if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { + if (!StringUtils.hasText(userCode) || + parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { OAuth2EndpointUtils.throwError( OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.USER_CODE, 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 4fb563f24..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 @@ -24,12 +24,13 @@ import java.util.List; import java.util.function.Consumer; -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.SecurityContext; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; + +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; 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 3d175e673..6e4572525 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 @@ -126,7 +126,7 @@ public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws E assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/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("\"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/issuer1/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\""); 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 230f3ca38..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 @@ -36,9 +36,9 @@ public void buildWhenDefaultThenDefaultsAreSet() { TokenSettings tokenSettings = TokenSettings.builder().build(); assertThat(tokenSettings.getSettings()).hasSize(7); assertThat(tokenSettings.getAuthorizationCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5)); - assertThat(tokenSettings.getDeviceCodeTimeToLive()).isEqualTo(Duration.ofMinutes(30)); 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); 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..b68f077d4 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 @@ -122,7 +122,7 @@ public void doFilterWhenAuthorizationServerMetadataRequestThenMetadataResponse() 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("\"response_types_supported\":[\"code\"]"); - assertThat(authorizationServerMetadataResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\"]"); + 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/issuer1/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\""); diff --git a/samples/device-client/src/main/java/sample/config/SecurityConfig.java b/samples/device-client/src/main/java/sample/config/SecurityConfig.java index 4f823847c..525189e81 100644 --- a/samples/device-client/src/main/java/sample/config/SecurityConfig.java +++ b/samples/device-client/src/main/java/sample/config/SecurityConfig.java @@ -28,7 +28,7 @@ * @author Steve Riesenberg * @since 1.1 */ -@Configuration +@Configuration(proxyBeanMethods = false) @EnableWebSecurity public class SecurityConfig { diff --git a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle index 9b13324e0..cc860e047 100644 --- a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle +++ b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle @@ -18,9 +18,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-security" implementation "org.springframework.boot:spring-boot-starter-jdbc" implementation project(":spring-security-oauth2-authorization-server") - implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" - implementation "org.springframework:spring-webflux" implementation "org.webjars:webjars-locator-core" implementation "org.webjars:bootstrap:3.4.1" implementation "org.webjars:jquery:3.4.1" diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java index ab66f4b87..2ea1b33d2 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -56,7 +56,7 @@ * @author Steve Riesenberg * @since 1.1 */ -@Configuration +@Configuration(proxyBeanMethods = false) @EnableWebSecurity public class SecurityConfig { @@ -100,7 +100,7 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws public UserDetailsService userDetailsService() { // @formatter:off UserDetails userDetails = User.withDefaultPasswordEncoder() - .username("user") + .username("user1") .password("password") .roles("USER") .build(); @@ -144,7 +144,7 @@ public JWKSource jwkSource() { return new ImmutableJWKSet<>(jwkSet); } - private static KeyPair generateRsaKey() { + private static KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); @@ -167,4 +167,4 @@ public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } -} \ No newline at end of file +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java index 3b40e2b2c..418f241c0 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 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/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html b/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html index fa5d76cb7..4607e0de9 100644 --- a/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html @@ -12,7 +12,7 @@
    -
    +

    Device Activation

    Enter the activation code to authorize the device.

    Activation Code

    @@ -30,4 +30,4 @@

    Device Activation

    - \ No newline at end of file + From 5b690dfb3a2b530a359bef3f4c5405e014f2d847 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 27 Mar 2023 11:39:44 -0400 Subject: [PATCH 064/250] Avoid persisting client principal in device authorization request Issue gh-1106 --- .../OAuth2DeviceAuthorizationRequestAuthenticationProvider.java | 2 -- 1 file changed, 2 deletions(-) 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 index 86edb109e..d21ed9c41 100644 --- 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 @@ -15,7 +15,6 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; -import java.security.Principal; import java.time.Instant; import java.util.Base64; import java.util.HashSet; @@ -159,7 +158,6 @@ public Authentication authenticate(Authentication authentication) throws Authent .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .token(deviceCode) .token(userCode) - .attribute(Principal.class.getName(), clientPrincipal) .attribute(OAuth2ParameterNames.SCOPE, new HashSet<>(requestedScopes)) .build(); // @formatter:on From 5f39c8526483e5e0853a469403405dca8fdf889b Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 8 Mar 2023 14:06:52 -0500 Subject: [PATCH 065/250] Polish gh-1068 Issue gh-1077 --- ...thorizationCodeAuthenticationProvider.java | 2 +- .../client/RegisteredClient.java | 6 +- .../OAuth2AuthorizationServerConfigurer.java | 27 ++++- .../configurers/OAuth2ConfigurerUtils.java | 28 ----- .../OAuth2TokenEndpointConfigurer.java | 6 +- .../web/configurers/OidcConfigurer.java | 2 +- .../OidcLogoutEndpointConfigurer.java | 5 +- .../oidc/OidcClientMetadataClaimAccessor.java | 2 +- .../oidc/OidcClientMetadataClaimNames.java | 2 +- .../oidc/OidcClientRegistration.java | 4 +- .../oidc/OidcProviderConfiguration.java | 2 +- .../OidcProviderMetadataClaimAccessor.java | 2 +- .../oidc/OidcProviderMetadataClaimNames.java | 2 +- .../OidcLogoutAuthenticationProvider.java | 36 ++++--- .../OidcLogoutAuthenticationToken.java | 54 +++++----- .../oidc/web/OidcLogoutEndpointFilter.java | 16 ++- .../OidcLogoutAuthenticationConverter.java | 2 +- .../settings/AuthorizationServerSettings.java | 4 +- .../settings/ConfigurationSettingNames.java | 2 +- .../OAuth2AuthorizationEndpointFilter.java | 2 +- .../annotation/web/configurers/OidcTests.java | 101 ++++++++++++++++-- ...OidcLogoutAuthenticationProviderTests.java | 58 ++++++++-- .../OidcLogoutAuthenticationTokenTests.java | 52 ++++----- .../web/OidcLogoutEndpointFilterTests.java | 18 +--- 24 files changed, 285 insertions(+), 150 deletions(-) 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 c9d9de0c6..cb8fb9021 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 @@ -282,7 +282,7 @@ public boolean supports(Class 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.0 + * @since 1.1 */ public void setSessionRegistry(SessionRegistry sessionRegistry) { Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); 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 3b7bcd09c..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 @@ -152,7 +152,7 @@ public Set getRedirectUris() { * 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.0 + * @since 1.1 */ public Set getPostLogoutRedirectUris() { return this.postLogoutRedirectUris; @@ -447,7 +447,7 @@ public Builder redirectUris(Consumer> redirectUrisConsumer) { * * @param postLogoutRedirectUri the post logout redirect URI * @return the {@link Builder} - * @since 1.1.0 + * @since 1.1 */ public Builder postLogoutRedirectUri(String postLogoutRedirectUri) { this.postLogoutRedirectUris.add(postLogoutRedirectUri); @@ -460,7 +460,7 @@ public Builder postLogoutRedirectUri(String postLogoutRedirectUri) { * * @param postLogoutRedirectUrisConsumer a {@link Consumer} of the post logout redirect URI(s) * @return the {@link Builder} - * @since 1.1.0 + * @since 1.1 */ public Builder postLogoutRedirectUris(Consumer> postLogoutRedirectUrisConsumer) { postLogoutRedirectUrisConsumer.accept(this.postLogoutRedirectUris); 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 dc6d204eb..ab8cdb4b1 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,14 +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; @@ -270,7 +275,8 @@ public void init(HttpSecurity httpSecurity) { if (isOidcEnabled()) { // Add OpenID Connect session tracking capabilities. - SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity); + initSessionRegistry(httpSecurity); + SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class); OAuth2AuthorizationEndpointConfigurer authorizationEndpointConfigurer = getConfigurer(OAuth2AuthorizationEndpointConfigurer.class); authorizationEndpointConfigurer.setSessionAuthenticationStrategy((authentication, request, response) -> { @@ -388,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/OAuth2ConfigurerUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java index 50f0282e8..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 @@ -24,14 +24,8 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationListener; -import org.springframework.context.event.GenericApplicationListenerAdapter; -import org.springframework.context.event.SmartApplicationListener; import org.springframework.core.ResolvableType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.context.DelegatingApplicationListener; -import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; @@ -186,28 +180,6 @@ static AuthorizationServerSettings getAuthorizationServerSettings(HttpSecurity h return authorizationServerSettings; } - static SessionRegistry getSessionRegistry(HttpSecurity httpSecurity) { - SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class); - if (sessionRegistry == null) { - sessionRegistry = getOptionalBean(httpSecurity, SessionRegistry.class); - if (sessionRegistry == null) { - sessionRegistry = new SessionRegistryImpl(); - registerDelegateApplicationListener(httpSecurity, (SessionRegistryImpl) sessionRegistry); - } - httpSecurity.setSharedObject(SessionRegistry.class, sessionRegistry); - } - return sessionRegistry; - } - - private static void registerDelegateApplicationListener(HttpSecurity httpSecurity, ApplicationListener delegate) { - DelegatingApplicationListener delegatingApplicationListener = getOptionalBean(httpSecurity, DelegatingApplicationListener.class); - if (delegatingApplicationListener == null) { - return; - } - SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate); - delegatingApplicationListener.addListener(smartListener); - } - static T getBean(HttpSecurity httpSecurity, Class type) { return httpSecurity.getSharedObject(ApplicationContext.class).getBean(type); } 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 7229ae336..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 @@ -220,11 +220,13 @@ private static List createDefaultAuthenticationProviders OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity); OAuth2TokenGenerator tokenGenerator = OAuth2ConfigurerUtils.getTokenGenerator(httpSecurity); - SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity); OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(authorizationService, tokenGenerator); - authorizationCodeAuthenticationProvider.setSessionRegistry(sessionRegistry); + SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class); + if (sessionRegistry != null) { + authorizationCodeAuthenticationProvider.setSessionRegistry(sessionRegistry); + } authenticationProviders.add(authorizationCodeAuthenticationProvider); OAuth2RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider = 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 f43bab9db..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 @@ -72,7 +72,7 @@ public OidcConfigurer providerConfigurationEndpoint(Customizer logoutEndpointCustomizer) { logoutEndpointCustomizer.customize(getConfigurer(OidcLogoutEndpointConfigurer.class)); 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 index 92580b6ec..04b356ff8 100644 --- 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 @@ -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.server.authorization.oidc.authentication.OidcLogoutAuthenticationProvider; @@ -47,7 +48,7 @@ * Configurer for OpenID Connect 1.0 RP-Initiated Logout Endpoint. * * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 * @see OidcConfigurer#logoutEndpoint * @see OidcLogoutEndpointFilter */ @@ -210,7 +211,7 @@ private static List createDefaultAuthenticationProviders new OidcLogoutAuthenticationProvider( OAuth2ConfigurerUtils.getRegisteredClientRepository(httpSecurity), OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity), - OAuth2ConfigurerUtils.getSessionRegistry(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/oidc/OidcClientMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java index 8d36b9c30..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 @@ -101,7 +101,7 @@ default List getRedirectUris() { * 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.0 + * @since 1.1 */ default List getPostLogoutRedirectUris() { return getClaimAsStringList(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); 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 e8d755378..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 @@ -66,7 +66,7 @@ public final class OidcClientMetadataClaimNames { * {@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.0 + * @since 1.1 */ public static final String POST_LOGOUT_REDIRECT_URIS = "post_logout_redirect_uris"; 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 036415ffe..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 @@ -176,7 +176,7 @@ public Builder redirectUris(Consumer> redirectUrisConsumer) { * * @param postLogoutRedirectUri the post logout redirection {@code URI} used by the Client * @return the {@link Builder} for further configuration - * @since 1.1.0 + * @since 1.1 */ public Builder postLogoutRedirectUri(String postLogoutRedirectUri) { addClaimToClaimList(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUri); @@ -189,7 +189,7 @@ public Builder postLogoutRedirectUri(String postLogoutRedirectUri) { * * @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.0 + * @since 1.1 */ public Builder postLogoutRedirectUris(Consumer> postLogoutRedirectUrisConsumer) { acceptClaimValues(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUrisConsumer); 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 e2b8567d7..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 @@ -136,7 +136,7 @@ public Builder userInfoEndpoint(String userInfoEndpoint) { * * @param endSessionEndpoint the {@code URL} of the OpenID Connect 1.0 End Session Endpoint * @return the {@link Builder} for further configuration - * @since 1.1.0 + * @since 1.1 */ public Builder endSessionEndpoint(String endSessionEndpoint) { return claim(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, endSessionEndpoint); 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 d67d1a040..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 @@ -72,7 +72,7 @@ default URL getUserInfoEndpoint() { * 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.0 + * @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 b4bd44717..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 @@ -49,7 +49,7 @@ public final class OidcProviderMetadataClaimNames extends OAuth2AuthorizationSer /** * {@code end_session_endpoint} - the {@code URL} of the OpenID Connect 1.0 End Session Endpoint - * @since 1.1.0 + * @since 1.1 */ public static final String END_SESSION_ENDPOINT = "end_session_endpoint"; 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 index 7de465dd7..a40c7b077 100644 --- 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 @@ -46,7 +46,7 @@ * An {@link AuthenticationProvider} implementation for OpenID Connect 1.0 RP-Initiated Logout Endpoint. * * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 * @see RegisteredClientRepository * @see OAuth2AuthorizationService * @see SessionRegistry @@ -83,7 +83,7 @@ public Authentication authenticate(Authentication authentication) throws Authent (OidcLogoutAuthenticationToken) authentication; OAuth2Authorization authorization = this.authorizationService.findByToken( - oidcLogoutAuthentication.getIdToken(), ID_TOKEN_TOKEN_TYPE); + oidcLogoutAuthentication.getIdTokenHint(), ID_TOKEN_TOKEN_TYPE); if (authorization == null) { throwError(OAuth2ErrorCodes.INVALID_TOKEN, "id_token_hint"); } @@ -120,18 +120,24 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Validated logout request parameters"); } - // Validate user session - SessionInformation sessionInformation = null; + // Validate user identity Authentication userPrincipal = (Authentication) oidcLogoutAuthentication.getPrincipal(); - if (isPrincipalAuthenticated(userPrincipal) && - StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) { - sessionInformation = findSessionInformation( - userPrincipal, oidcLogoutAuthentication.getSessionId()); - if (sessionInformation != null) { - String sidClaim = idToken.getClaim("sid"); - if (!StringUtils.hasText(sidClaim) || - !sidClaim.equals(sessionInformation.getSessionId())) { - throwError(OAuth2ErrorCodes.INVALID_TOKEN, "sid"); + if (isPrincipalAuthenticated(userPrincipal)) { + if (!StringUtils.hasText(idToken.getSubject()) || + !idToken.getSubject().equals(userPrincipal.getName())) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.SUB); + } + + // Check for active session + if (StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) { + SessionInformation sessionInformation = findSessionInformation( + userPrincipal, oidcLogoutAuthentication.getSessionId()); + if (sessionInformation != null) { + String sidClaim = idToken.getClaim("sid"); + if (!StringUtils.hasText(sidClaim) || + !sidClaim.equals(sessionInformation.getSessionId())) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, "sid"); + } } } } @@ -140,8 +146,8 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Authenticated logout request"); } - return new OidcLogoutAuthenticationToken(oidcLogoutAuthentication.getIdToken(), userPrincipal, - sessionInformation, oidcLogoutAuthentication.getClientId(), + return new OidcLogoutAuthenticationToken(idToken, userPrincipal, + oidcLogoutAuthentication.getSessionId(), oidcLogoutAuthentication.getClientId(), oidcLogoutAuthentication.getPostLogoutRedirectUri(), oidcLogoutAuthentication.getState()); } 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 index a9cc28c03..5e3d06986 100644 --- 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 @@ -20,7 +20,7 @@ import org.springframework.lang.Nullable; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; import org.springframework.util.Assert; @@ -28,16 +28,16 @@ * An {@link Authentication} implementation used for OpenID Connect 1.0 RP-Initiated Logout Endpoint. * * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 * @see AbstractAuthenticationToken * @see OidcLogoutAuthenticationProvider */ public class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; - private final String idToken; + private final String idTokenHint; + private final OidcIdToken idToken; private final Authentication principal; private final String sessionId; - private final SessionInformation sessionInformation; private final String clientId; private final String postLogoutRedirectUri; private final String state; @@ -45,22 +45,22 @@ public class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken { /** * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters. * - * @param idToken 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 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 Client + * @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 idToken, Authentication principal, @Nullable String sessionId, + public OidcLogoutAuthenticationToken(String idTokenHint, Authentication principal, @Nullable String sessionId, @Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) { super(Collections.emptyList()); - Assert.hasText(idToken, "idToken cannot be empty"); + Assert.hasText(idTokenHint, "idTokenHint cannot be empty"); Assert.notNull(principal, "principal cannot be null"); - this.idToken = idToken; + this.idTokenHint = idTokenHint; + this.idToken = null; this.principal = principal; this.sessionId = sessionId; - this.sessionInformation = null; this.clientId = clientId; this.postLogoutRedirectUri = postLogoutRedirectUri; this.state = state; @@ -70,22 +70,22 @@ public OidcLogoutAuthenticationToken(String idToken, Authentication principal, @ /** * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters. * - * @param idToken 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 idToken the ID Token previously issued by the Provider to the Client * @param principal the authenticated principal representing the End-User - * @param sessionInformation the End-User's current authenticated session information with the Client + * @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 idToken, Authentication principal, @Nullable SessionInformation sessionInformation, + public OidcLogoutAuthenticationToken(OidcIdToken idToken, Authentication principal, @Nullable String sessionId, @Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) { super(Collections.emptyList()); - Assert.hasText(idToken, "idToken cannot be empty"); + 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 = sessionInformation != null ? sessionInformation.getSessionId() : null; - this.sessionInformation = sessionInformation; + this.sessionId = sessionId; this.clientId = clientId; this.postLogoutRedirectUri = postLogoutRedirectUri; this.state = state; @@ -95,7 +95,7 @@ public OidcLogoutAuthenticationToken(String idToken, Authentication principal, @ /** * Returns the authenticated principal representing the End-User. * - * @return the authenticated principal + * @return the authenticated principal representing the End-User */ @Override public Object getPrincipal() { @@ -113,28 +113,28 @@ public Object getCredentials() { * * @return the ID Token previously issued by the Provider to the Client */ - public String getIdToken() { - return this.idToken; + public String getIdTokenHint() { + return this.idTokenHint; } /** - * Returns the End-User's current authenticated session identifier with the Client. + * Returns the ID Token previously issued by the Provider to the Client. * - * @return the End-User's current authenticated session identifier + * @return the ID Token previously issued by the Provider to the Client */ @Nullable - public String getSessionId() { - return this.sessionId; + public OidcIdToken getIdToken() { + return this.idToken; } /** - * Returns the End-User's current authenticated session information with the Client. + * Returns the End-User's current authenticated session identifier with the Provider. * - * @return the End-User's current authenticated session information + * @return the End-User's current authenticated session identifier with the Provider */ @Nullable - public SessionInformation getSessionInformation() { - return this.sessionInformation; + public String getSessionId() { + return this.sessionId; } /** 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 index 0292d6173..b0cc3eea6 100644 --- 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 @@ -26,6 +26,7 @@ import org.springframework.core.log.LogMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -58,7 +59,7 @@ * A {@code Filter} that processes OpenID Connect 1.0 RP-Initiated Logout Requests. * * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 * @see OidcLogoutAuthenticationConverter * @see OidcLogoutAuthenticationProvider * @see 2. RP-Initiated Logout @@ -182,7 +183,7 @@ private void performLogout(HttpServletRequest request, HttpServletResponse respo OidcLogoutAuthenticationToken oidcLogoutAuthentication = (OidcLogoutAuthenticationToken) authentication; // Check for active user session - if (oidcLogoutAuthentication.getSessionInformation() != null) { + if (isSessionActive(oidcLogoutAuthentication)) { // Perform logout this.logoutHandler.logout(request, response, (Authentication) oidcLogoutAuthentication.getPrincipal()); @@ -215,4 +216,15 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); } + private static boolean isSessionActive(OidcLogoutAuthenticationToken oidcLogoutAuthentication) { + return isPrincipalAuthenticated((Authentication) oidcLogoutAuthentication.getPrincipal()) && + StringUtils.hasText(oidcLogoutAuthentication.getSessionId()); + } + + 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/oidc/web/authentication/OidcLogoutAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java index 481f8a5a4..530c2f062 100644 --- 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 @@ -40,7 +40,7 @@ * and then converts to an {@link OidcLogoutAuthenticationToken} used for authenticating the request. * * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 * @see AuthenticationConverter * @see OidcLogoutAuthenticationToken * @see OidcLogoutEndpointFilter 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 57af7f7ee..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 @@ -130,7 +130,7 @@ public String getOidcUserInfoEndpoint() { * 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.0 + * @since 1.1 */ public String getOidcLogoutEndpoint() { return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT); @@ -282,7 +282,7 @@ public Builder oidcUserInfoEndpoint(String oidcUserInfoEndpoint) { * * @param oidcLogoutEndpoint the OpenID Connect 1.0 Logout endpoint * @return the {@link Builder} for further configuration - * @since 1.1.0 + * @since 1.1 */ public Builder oidcLogoutEndpoint(String oidcLogoutEndpoint) { return setting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT, oidcLogoutEndpoint); 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 0e970ddd1..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 @@ -128,7 +128,7 @@ public static final class AuthorizationServer { /** * Set the OpenID Connect 1.0 Logout endpoint. - * @since 1.1.0 + * @since 1.1 */ public static final String OIDC_LOGOUT_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("oidc-logout-endpoint"); 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 d32d6d161..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 @@ -249,7 +249,7 @@ public void setAuthenticationFailureHandler(AuthenticationFailureHandler authent * 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.0 + * @since 1.1 */ public void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionAuthenticationStrategy) { Assert.notNull(sessionAuthenticationStrategy, "sessionAuthenticationStrategy cannot be null"); 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 80e9f4a52..71e56de4a 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 @@ -54,6 +54,8 @@ 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; @@ -131,6 +133,7 @@ public class OidcTests { private static JWKSource jwkSource; private static HttpMessageConverter accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + private static SessionRegistry sessionRegistry; public final SpringTestContext spring = new SpringTestContext(); @@ -163,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 @@ -187,8 +191,8 @@ public void requestWhenAuthenticationRequestThenTokenResponseIncludesIdToken() t MultiValueMap authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient); MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) - .params(authorizationRequestParameters) - .with(user("user").roles("A", "B"))) + .params(authorizationRequestParameters) + .with(user("user").roles("A", "B"))) .andExpect(status().is3xxRedirection()) .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); @@ -199,9 +203,9 @@ public void requestWhenAuthenticationRequestThenTokenResponseIncludesIdToken() t 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"))) @@ -259,8 +263,7 @@ public void requestWhenLogoutRequestThenLogout() throws Exception { mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI) .params(getTokenRequestParameters(registeredClient, authorization)) .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( - registeredClient.getClientId(), registeredClient.getClientSecret())) - .session(session)) + registeredClient.getClientId(), registeredClient.getClientSecret()))) .andExpect(status().isOk()) .andReturn(); @@ -283,6 +286,85 @@ public void requestWhenLogoutRequestThenLogout() throws Exception { 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) + .params(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) + .params(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 public void requestWhenCustomTokenGeneratorThenUsed() throws Exception { this.spring.register(AuthorizationServerConfigurationWithTokenGenerator.class).autowire(); @@ -403,6 +485,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/oidc/authentication/OidcLogoutAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java index 1bb0db18b..4928f4c1d 100644 --- 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 @@ -126,7 +126,7 @@ public void authenticateWhenIdTokenNotFoundThenThrowOAuth2AuthenticationExceptio }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); } @Test @@ -160,7 +160,7 @@ public void authenticateWhenMissingAudienceThenThrowOAuth2AuthenticationExceptio assertThat(error.getDescription()).contains(IdTokenClaimNames.AUD); }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); verify(this.registeredClientRepository).findById( eq(authorization.getRegisteredClientId())); } @@ -197,7 +197,7 @@ public void authenticateWhenInvalidAudienceThenThrowOAuth2AuthenticationExceptio assertThat(error.getDescription()).contains(IdTokenClaimNames.AUD); }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); verify(this.registeredClientRepository).findById( eq(authorization.getRegisteredClientId())); } @@ -234,7 +234,7 @@ public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationExceptio assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID); }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); verify(this.registeredClientRepository).findById( eq(authorization.getRegisteredClientId())); } @@ -272,7 +272,46 @@ public void authenticateWhenInvalidPostLogoutRedirectUriThenThrowOAuth2Authentic assertThat(error.getDescription()).contains("post_logout_redirect_uri"); }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); + verify(this.registeredClientRepository).findById( + eq(authorization.getRegisteredClientId())); + } + + @Test + public void authenticateWhenInvalidSubThenThrowOAuth2AuthenticationException() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OidcIdToken idToken = OidcIdToken.withTokenValue("id-token") + .issuer("https://provider.com") + .subject("other-sub") + .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())); } @@ -317,7 +356,7 @@ public void authenticateWhenMissingSidThenThrowOAuth2AuthenticationException() { assertThat(error.getDescription()).contains("sid"); }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); verify(this.registeredClientRepository).findById( eq(authorization.getRegisteredClientId())); } @@ -363,7 +402,7 @@ public void authenticateWhenInvalidSidThenThrowOAuth2AuthenticationException() { assertThat(error.getDescription()).contains("sid"); }); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); verify(this.registeredClientRepository).findById( eq(authorization.getRegisteredClientId())); } @@ -408,15 +447,14 @@ public void authenticateWhenValidIdTokenThenAuthenticated() { (OidcLogoutAuthenticationToken) this.authenticationProvider.authenticate(authentication); verify(this.authorizationService).findByToken( - eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE)); + 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.getTokenValue()); + assertThat(authenticationResult.getIdToken()).isEqualTo(idToken); assertThat(authenticationResult.getSessionId()).isEqualTo(sessionInformation.getSessionId()); - assertThat(authenticationResult.getSessionInformation()).isEqualTo(sessionInformation); assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); assertThat(authenticationResult.getPostLogoutRedirectUri()).isEqualTo(postLogoutRedirectUri); assertThat(authenticationResult.getState()).isEqualTo(state); 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 index f6af09d2e..3a3367333 100644 --- 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 @@ -15,13 +15,12 @@ */ package org.springframework.security.oauth2.server.authorization.oidc.authentication; -import java.sql.Date; import java.time.Instant; import org.junit.jupiter.api.Test; import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -32,59 +31,60 @@ * @author Joe Grandja */ public class OidcLogoutAuthenticationTokenTests { - private final String idToken = "id-token"; + 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 SessionInformation sessionInformation = new SessionInformation(this.principal, "session-2", Date.from(Instant.now())); 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 constructorWhenIdTokenNullThenThrowIllegalArgumentException() { + public void constructorWhenIdTokenHintEmptyThenThrowIllegalArgumentException() { assertThatIllegalArgumentException() .isThrownBy(() -> new OidcLogoutAuthenticationToken( - null, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) - .withMessage("idToken cannot be empty"); + "", this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idTokenHint cannot be empty"); assertThatIllegalArgumentException() .isThrownBy(() -> new OidcLogoutAuthenticationToken( - null, this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state)) - .withMessage("idToken cannot be empty"); + (String) null, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + .withMessage("idTokenHint cannot be empty"); } @Test - public void constructorWhenIdTokenEmptyThenThrowIllegalArgumentException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new OidcLogoutAuthenticationToken( - "", this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) - .withMessage("idToken cannot be empty"); + public void constructorWhenIdTokenNullThenThrowIllegalArgumentException() { assertThatIllegalArgumentException() .isThrownBy(() -> new OidcLogoutAuthenticationToken( - "", this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state)) - .withMessage("idToken cannot be empty"); + (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.idToken, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) + this.idTokenHint, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) .withMessage("principal cannot be null"); assertThatIllegalArgumentException() .isThrownBy(() -> new OidcLogoutAuthenticationToken( - this.idToken, null, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state)) + this.idToken, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state)) .withMessage("principal cannot be null"); } @Test - public void constructorWhenSessionIdProvidedThenCreated() { + public void constructorWhenIdTokenHintProvidedThenCreated() { OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( - this.idToken, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state); + 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.getIdToken()).isEqualTo(this.idToken); + assertThat(authentication.getIdTokenHint()).isEqualTo(this.idTokenHint); + assertThat(authentication.getIdToken()).isNull(); assertThat(authentication.getSessionId()).isEqualTo(this.sessionId); - assertThat(authentication.getSessionInformation()).isNull(); assertThat(authentication.getClientId()).isEqualTo(this.clientId); assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri); assertThat(authentication.getState()).isEqualTo(this.state); @@ -92,14 +92,14 @@ public void constructorWhenSessionIdProvidedThenCreated() { } @Test - public void constructorWhenSessionInformationProvidedThenCreated() { + public void constructorWhenIdTokenProvidedThenCreated() { OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( - this.idToken, this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state); + 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.sessionInformation.getSessionId()); - assertThat(authentication.getSessionInformation()).isEqualTo(this.sessionInformation); + assertThat(authentication.getSessionId()).isEqualTo(this.sessionId); assertThat(authentication.getClientId()).isEqualTo(this.clientId); assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri); assertThat(authentication.getState()).isEqualTo(this.state); 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 index e973b5efe..a678823ed 100644 --- 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 @@ -15,8 +15,6 @@ */ package org.springframework.security.oauth2.server.authorization.oidc.web; -import java.time.Instant; -import java.util.Date; import java.util.function.Consumer; import jakarta.servlet.FilterChain; @@ -38,7 +36,6 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.session.SessionInformation; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; @@ -217,7 +214,7 @@ public void doFilterWhenLogoutRequestAuthenticationExceptionThenErrorResponse() @Test public void doFilterWhenCustomAuthenticationConverterThenUsed() throws Exception { OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( - "id-token", this.principal, (SessionInformation) null, null, null, null); + "id-token", this.principal, null, null, null, null); AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); when(authenticationConverter.convert(any())).thenReturn(authentication); @@ -240,7 +237,7 @@ public void doFilterWhenCustomAuthenticationConverterThenUsed() throws Exception @Test public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception { OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( - "id-token", this.principal, (SessionInformation) null, null, null, null); + "id-token", this.principal, null, null, null, null); AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class); this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler); @@ -292,11 +289,8 @@ public void doFilterWhenLogoutRequestAuthenticatedThenLogout() throws Exception MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build()); MockHttpSession session = (MockHttpSession) request.getSession(true); - SessionInformation sessionInformation = new SessionInformation( - this.principal, session.getId(), Date.from(Instant.now())); - OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( - "id-token", this.principal, sessionInformation, null, null, null); + "id-token", this.principal, session.getId(), null, null, null); when(this.authenticationManager.authenticate(any())) .thenReturn(authentication); @@ -321,14 +315,12 @@ public void doFilterWhenLogoutRequestAuthenticatedWithPostLogoutRedirectUriThenP MockHttpServletRequest request = createLogoutRequest(registeredClient); MockHttpSession session = (MockHttpSession) request.getSession(true); - SessionInformation sessionInformation = new SessionInformation( - this.principal, session.getId(), Date.from(Instant.now())); - String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().iterator().next(); String state = "state-1"; OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken( - "id-token", this.principal, sessionInformation, + "id-token", this.principal, session.getId(), registeredClient.getClientId(), postLogoutRedirectUri, state); + authentication.setAuthenticated(true); when(this.authenticationManager.authenticate(any())) .thenReturn(authentication); From 3b1958e4df7ab9319ff94f061e5d429215e0abc4 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 30 Mar 2023 11:11:22 -0400 Subject: [PATCH 066/250] Add OidcLogoutAuthenticationToken.isPrincipalAuthenticated() Issue gh-1077 --- .../OidcLogoutAuthenticationProvider.java | 15 ++++----------- .../OidcLogoutAuthenticationToken.java | 11 +++++++++++ .../oidc/web/OidcLogoutEndpointFilter.java | 15 ++------------- .../OidcLogoutAuthenticationProviderTests.java | 2 +- 4 files changed, 18 insertions(+), 25 deletions(-) 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 index a40c7b077..3fa3c4593 100644 --- 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 @@ -20,7 +20,6 @@ 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; @@ -109,7 +108,7 @@ public Authentication authenticate(Authentication authentication) throws Authent } if (StringUtils.hasText(oidcLogoutAuthentication.getClientId()) && !oidcLogoutAuthentication.getClientId().equals(registeredClient.getClientId())) { - throwError(OAuth2ErrorCodes.INVALID_TOKEN, OAuth2ParameterNames.CLIENT_ID); + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); } if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri()) && !registeredClient.getPostLogoutRedirectUris().contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) { @@ -121,8 +120,8 @@ public Authentication authenticate(Authentication authentication) throws Authent } // Validate user identity - Authentication userPrincipal = (Authentication) oidcLogoutAuthentication.getPrincipal(); - if (isPrincipalAuthenticated(userPrincipal)) { + if (oidcLogoutAuthentication.isPrincipalAuthenticated()) { + Authentication userPrincipal = (Authentication) oidcLogoutAuthentication.getPrincipal(); if (!StringUtils.hasText(idToken.getSubject()) || !idToken.getSubject().equals(userPrincipal.getName())) { throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.SUB); @@ -146,7 +145,7 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Authenticated logout request"); } - return new OidcLogoutAuthenticationToken(idToken, userPrincipal, + return new OidcLogoutAuthenticationToken(idToken, (Authentication) oidcLogoutAuthentication.getPrincipal(), oidcLogoutAuthentication.getSessionId(), oidcLogoutAuthentication.getClientId(), oidcLogoutAuthentication.getPostLogoutRedirectUri(), oidcLogoutAuthentication.getState()); } @@ -156,12 +155,6 @@ public boolean supports(Class authentication) { return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication); } - private static boolean isPrincipalAuthenticated(Authentication principal) { - return principal != null && - !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && - principal.isAuthenticated(); - } - private SessionInformation findSessionInformation(Authentication principal, String sessionId) { List sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), true); SessionInformation sessionInformation = null; 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 index 5e3d06986..1e086c1bb 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -102,6 +103,16 @@ 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 ""; 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 index b0cc3eea6..b2d102eb1 100644 --- 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 @@ -26,7 +26,6 @@ import org.springframework.core.log.LogMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -183,7 +182,8 @@ private void performLogout(HttpServletRequest request, HttpServletResponse respo OidcLogoutAuthenticationToken oidcLogoutAuthentication = (OidcLogoutAuthenticationToken) authentication; // Check for active user session - if (isSessionActive(oidcLogoutAuthentication)) { + if (oidcLogoutAuthentication.isPrincipalAuthenticated() && + StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) { // Perform logout this.logoutHandler.logout(request, response, (Authentication) oidcLogoutAuthentication.getPrincipal()); @@ -216,15 +216,4 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); } - private static boolean isSessionActive(OidcLogoutAuthenticationToken oidcLogoutAuthentication) { - return isPrincipalAuthenticated((Authentication) oidcLogoutAuthentication.getPrincipal()) && - StringUtils.hasText(oidcLogoutAuthentication.getSessionId()); - } - - private static boolean isPrincipalAuthenticated(Authentication principal) { - return principal != null && - !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && - principal.isAuthenticated(); - } - } 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 index 4928f4c1d..bcb004512 100644 --- 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 @@ -230,7 +230,7 @@ public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationExceptio .isInstanceOf(OAuth2AuthenticationException.class) .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) .satisfies(error -> { - assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST); assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID); }); verify(this.authorizationService).findByToken( From 597abe18c3021525710259f58f1de57e6b95fe48 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 30 Mar 2023 12:30:01 -0400 Subject: [PATCH 067/250] Ensure ID Token is active before processing logout request Issue gh-1077 --- .../OidcLogoutAuthenticationProvider.java | 7 +++- ...OidcLogoutAuthenticationProviderTests.java | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 index 3fa3c4593..c680781f2 100644 --- 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 @@ -91,6 +91,11 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Retrieved authorization with ID Token"); } + OAuth2Authorization.Token authorizedIdToken = authorization.getToken(OidcIdToken.class); + if (!authorizedIdToken.isActive()) { + throwError(OAuth2ErrorCodes.INVALID_TOKEN, "id_token_hint"); + } + RegisteredClient registeredClient = this.registeredClientRepository.findById( authorization.getRegisteredClientId()); @@ -98,7 +103,7 @@ public Authentication authenticate(Authentication authentication) throws Authent this.logger.trace("Retrieved registered client"); } - OidcIdToken idToken = authorization.getToken(OidcIdToken.class).getToken(); + OidcIdToken idToken = authorizedIdToken.getToken(); // Validate client identity List audClaim = idToken.getAudience(); 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 index bcb004512..e58665144 100644 --- 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 @@ -129,6 +129,41 @@ public void authenticateWhenIdTokenNotFoundThenThrowOAuth2AuthenticationExceptio eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE)); } + @Test + public void authenticateWhenIdTokenNotActiveThenThrowOAuth2AuthenticationException() { + 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"); From ef4c5d7b6f0af6f53395ee6c9a6dad0890dbbf57 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 5 Apr 2023 15:13:05 -0400 Subject: [PATCH 068/250] Allow localhost in redirect_uri Closes gh-651 --- ...ionCodeRequestAuthenticationValidator.java | 26 +++++-------------- ...odeRequestAuthenticationProviderTests.java | 21 --------------- 2 files changed, 7 insertions(+), 40 deletions(-) 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/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 f2e9eb7ea..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 @@ -181,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(); From 25bc45cdffdaaa7b1c97fcbd61cb3a78801bdae7 Mon Sep 17 00:00:00 2001 From: HuiYeong Date: Thu, 30 Mar 2023 21:56:49 +0900 Subject: [PATCH 069/250] Fix refresh token error code INVALID_CLIENT to INVALID_GRANT Closes gh-1139 --- .../OAuth2RefreshTokenAuthenticationProvider.java | 4 ++-- .../OAuth2RefreshTokenAuthenticationProviderTests.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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..1e2ba2086 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. @@ -400,7 +400,7 @@ public void authenticateWhenRefreshTokenIssuedToAnotherClientThenThrowOAuth2Auth .isInstanceOf(OAuth2AuthenticationException.class) .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) .extracting("errorCode") - .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); } @Test From 1bfc54fe6ae6aa9c8f506e330912a288f752e4cb Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 11 Apr 2023 13:41:07 -0500 Subject: [PATCH 070/250] Do not require authorizationRequest for device grant Issue gh-1127 --- .../OAuth2AuthorizationConsentAuthenticationContext.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..aed3c3426 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 @@ -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; @@ -162,7 +163,10 @@ 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); + if (authorization.getAuthorizationGrantType().equals(AuthorizationGrantType.AUTHORIZATION_CODE)) { + Assert.notNull(get(OAuth2AuthorizationRequest.class), "authorizationRequest cannot be null"); + } return new OAuth2AuthorizationConsentAuthenticationContext(getContext()); } From 8e04da773d644bd335323abcbaee2ab89451a541 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Fri, 17 Mar 2023 18:05:15 -0500 Subject: [PATCH 071/250] Add tests for OAuth 2.0 Device Authorization Grant This commit adds tests for the following components: * AuthenticationConverters * AuthenticationProviders * Endpoint Filters Issue gh-44 Closes gh-1127 --- ...ionConsentAuthenticationProviderTests.java | 444 +++++++++++++++++ ...ionRequestAuthenticationProviderTests.java | 351 +++++++++++++ ...DeviceCodeAuthenticationProviderTests.java | 431 ++++++++++++++++ ...rificationAuthenticationProviderTests.java | 326 +++++++++++++ ...eviceAuthorizationEndpointFilterTests.java | 423 ++++++++++++++++ ...DeviceVerificationEndpointFilterTests.java | 460 ++++++++++++++++++ ...onConsentAuthenticationConverterTests.java | 295 +++++++++++ ...onRequestAuthenticationConverterTests.java | 120 +++++ ...eviceCodeAuthenticationConverterTests.java | 126 +++++ ...ificationAuthenticationConverterTests.java | 168 +++++++ 10 files changed, 3144 insertions(+) create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java 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..8e0b5ffeb --- /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 supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() { + 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..97bf5904a --- /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 supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() { + 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/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..8a3193f63 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java @@ -0,0 +1,423 @@ +/* + * 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"); + 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")); + // @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..43ee6f770 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java @@ -0,0 +1,460 @@ +/* + * 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 DEVICE_CODE = "EfYu_0jEL"; + 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 constructorWhenAuthenticationMangerIsNullThenThrowIllegalArgumentException() { + // @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); + 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"); + 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")); + } + + @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"); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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 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/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..c73ecd064 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java @@ -0,0 +1,295 @@ +/* + * 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 convertWhenBlankClientIdThenInvalidRequestError() { + 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 convertWhenBlankUserCodeThenInvalidRequestError() { + 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 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 convertWhenBlankStateParameterThenInvalidRequestError() { + 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-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", "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..147f74098 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java @@ -0,0 +1,120 @@ +/* + * 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-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", "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..605dc7b63 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java @@ -0,0 +1,126 @@ +/* + * 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-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", "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..0d19f1a91 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java @@ -0,0 +1,168 @@ +/* + * 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 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"); + 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 convertWhenBlankUserCodeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + 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 convertWhenMultipleUserCodeParametersThenInvalidRequestError() { + MockHttpServletRequest request = createRequest(); + 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 convertWhenMissingPrincipalThenReturnDeviceVerificationAuthentication() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . ")); + + 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("-", " . ")); + + 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-2"); + + 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", "value-2")); + } + + private static MockHttpServletRequest createRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(HttpMethod.GET.name()); + request.setRequestURI(VERIFICATION_URI); + return request; + } +} From cf7ecc161da61a69c45d1fc8bee7bb6c2ca3d419 Mon Sep 17 00:00:00 2001 From: Xu Xiaowei Date: Sun, 2 Apr 2023 01:26:37 +0800 Subject: [PATCH 072/250] JDBC device_code authorization Issue gh-1156 --- .../JdbcOAuth2AuthorizationService.java | 84 +++++++++++++++++-- .../authorization/OAuth2Authorization.java | 49 ++++++++++- .../server/authorization/OAuth2TokenType.java | 2 + .../oauth2-authorization-schema.sql | 8 ++ .../java/sample/config/SecurityConfig.java | 26 ++++++ 5 files changed, 156 insertions(+), 13 deletions(-) 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 bb9276715..64544947b 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 @@ -47,10 +47,7 @@ import org.springframework.jdbc.support.lob.LobHandler; import org.springframework.lang.Nullable; 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.OAuth2RefreshToken; -import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.*; 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; @@ -106,20 +103,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 oidc_id_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 @@ -129,7 +137,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 @@ -138,7 +146,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 @@ -244,6 +254,8 @@ public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType t 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)); @@ -260,6 +272,12 @@ public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType t } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { parameters.add(mapToSqlParameter("refresh_token_value", token)); return findBy(REFRESH_TOKEN_FILTER, parameters); + } else if (OAuth2TokenType.USER_CODE.equals(tokenType)) { + parameters.add(mapToSqlParameter("user_code_value", token)); + return findBy(USER_CODE_FILTER, parameters); + } else if (OAuth2TokenType.DEVICE_CODE.equals(tokenType)) { + parameters.add(mapToSqlParameter("device_code_value", token)); + return findBy(DEVICE_CODE_FILTER, parameters); } return null; } @@ -425,6 +443,35 @@ 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 = null; + Timestamp userCodeExpiresAt = rs.getTimestamp("user_code_expires_at"); + if (userCodeExpiresAt != null) { + tokenExpiresAt = userCodeExpiresAt.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 = null; + Timestamp deviceCodeExpiresAt = rs.getTimestamp("device_code_expires_at"); + if (deviceCodeExpiresAt != null) { + tokenExpiresAt = deviceCodeExpiresAt.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(); } @@ -545,6 +592,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; } @@ -670,6 +728,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/OAuth2Authorization.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java index ef8bb69dd..24cd6a59e 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java @@ -27,10 +27,7 @@ import java.util.function.Consumer; import org.springframework.lang.Nullable; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.OAuth2AccessToken; -import org.springframework.security.oauth2.core.OAuth2RefreshToken; -import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.*; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; import org.springframework.util.Assert; @@ -50,6 +47,8 @@ * @see OAuth2Token * @see OAuth2AccessToken * @see OAuth2RefreshToken + * @see OAuth2UserCode + * @see OAuth2DeviceCode */ public class OAuth2Authorization implements Serializable { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; @@ -129,6 +128,28 @@ public Token getRefreshToken() { return getToken(OAuth2RefreshToken.class); } + /** + * Returns the {@link Token} of type {@link OAuth2UserCode}. + * + * @return the {@link Token} of type {@link OAuth2UserCode}, or {@code null} if not + * available + */ + @Nullable + public Token getUserCode() { + return getToken(OAuth2UserCode.class); + } + + /** + * Returns the {@link Token} of type {@link OAuth2DeviceCode}. + * + * @return the {@link Token} of type {@link OAuth2DeviceCode}, or {@code null} if not + * available + */ + @Nullable + public Token getDeviceCode() { + return getToken(OAuth2DeviceCode.class); + } + /** * Returns the {@link Token} of type {@code tokenType}. * @@ -460,6 +481,26 @@ public Builder refreshToken(OAuth2RefreshToken refreshToken) { return token(refreshToken); } + /** + * Sets the {@link OAuth2UserCode user token}. + * + * @param userCode the {@link OAuth2UserCode} + * @return the {@link Builder} + */ + public Builder userCode(OAuth2UserCode userCode) { + return token(userCode); + } + + /** + * Sets the {@link OAuth2DeviceCode device token}. + * + * @param deviceCode the {@link OAuth2DeviceCode} + * @return the {@link Builder} + */ + public Builder deviceCode(OAuth2DeviceCode deviceCode) { + return token(deviceCode); + } + /** * Sets the {@link OAuth2Token token}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java index 8c25c8636..c1b9a8e02 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java @@ -31,6 +31,8 @@ public final class OAuth2TokenType implements Serializable { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; public static final OAuth2TokenType ACCESS_TOKEN = new OAuth2TokenType("access_token"); public static final OAuth2TokenType REFRESH_TOKEN = new OAuth2TokenType("refresh_token"); + public static final OAuth2TokenType USER_CODE = new OAuth2TokenType("user_code"); + public static final OAuth2TokenType DEVICE_CODE = new OAuth2TokenType("device_code"); private final String value; /** 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/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java index 2ea1b33d2..b88b1db0e 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -30,6 +30,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; 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.configuration.EnableWebSecurity; @@ -41,6 +45,8 @@ 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.JdbcOAuth2AuthorizationService; +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; @@ -131,6 +137,12 @@ public RegisteredClientRepository registeredClientRepository() { return new InMemoryRegisteredClientRepository(registeredClient); } + @Bean + public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); + } + @Bean public JWKSource jwkSource() { KeyPair keyPair = generateRsaKey(); @@ -167,4 +179,18 @@ 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 + } + } From 5c6879d9791480fc3527004e6e145d2eb0cf151b Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 11 Apr 2023 15:48:20 -0500 Subject: [PATCH 073/250] Polish gh-1143 --- .../JdbcOAuth2AuthorizationService.java | 27 +++++----- .../authorization/OAuth2Authorization.java | 49 ++----------------- .../server/authorization/OAuth2TokenType.java | 2 - 3 files changed, 16 insertions(+), 62 deletions(-) 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 64544947b..e11a3271e 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 @@ -47,7 +47,12 @@ import org.springframework.jdbc.support.lob.LobHandler; import org.springframework.lang.Nullable; import org.springframework.security.jackson2.SecurityJackson2Modules; -import org.springframework.security.oauth2.core.*; +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; @@ -118,8 +123,8 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic 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 oidc_id_token_value = ? OR refresh_token_value = ? OR " - + "user_code_value = ? OR device_code_value = ?"; + + "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 = ?"; @@ -272,10 +277,10 @@ public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType t } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { parameters.add(mapToSqlParameter("refresh_token_value", token)); return findBy(REFRESH_TOKEN_FILTER, parameters); - } else if (OAuth2TokenType.USER_CODE.equals(tokenType)) { + } else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) { parameters.add(mapToSqlParameter("user_code_value", token)); return findBy(USER_CODE_FILTER, parameters); - } else if (OAuth2TokenType.DEVICE_CODE.equals(tokenType)) { + } else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) { parameters.add(mapToSqlParameter("device_code_value", token)); return findBy(DEVICE_CODE_FILTER, parameters); } @@ -447,11 +452,7 @@ public OAuth2Authorization mapRow(ResultSet rs, int rowNum) throws SQLException String userCodeValue = getLobValue(rs, "user_code_value"); if (StringUtils.hasText(userCodeValue)) { tokenIssuedAt = rs.getTimestamp("user_code_issued_at").toInstant(); - tokenExpiresAt = null; - Timestamp userCodeExpiresAt = rs.getTimestamp("user_code_expires_at"); - if (userCodeExpiresAt != null) { - tokenExpiresAt = userCodeExpiresAt.toInstant(); - } + tokenExpiresAt = rs.getTimestamp("user_code_expires_at").toInstant(); Map userCodeMetadata = parseMap(getLobValue(rs, "user_code_metadata")); OAuth2UserCode userCode = new OAuth2UserCode(userCodeValue, tokenIssuedAt, tokenExpiresAt); @@ -461,11 +462,7 @@ public OAuth2Authorization mapRow(ResultSet rs, int rowNum) throws SQLException String deviceCodeValue = getLobValue(rs, "device_code_value"); if (StringUtils.hasText(deviceCodeValue)) { tokenIssuedAt = rs.getTimestamp("device_code_issued_at").toInstant(); - tokenExpiresAt = null; - Timestamp deviceCodeExpiresAt = rs.getTimestamp("device_code_expires_at"); - if (deviceCodeExpiresAt != null) { - tokenExpiresAt = deviceCodeExpiresAt.toInstant(); - } + tokenExpiresAt = rs.getTimestamp("device_code_expires_at").toInstant(); Map deviceCodeMetadata = parseMap(getLobValue(rs, "device_code_metadata")); OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, tokenIssuedAt, tokenExpiresAt); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java index 24cd6a59e..ef8bb69dd 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java @@ -27,7 +27,10 @@ import java.util.function.Consumer; import org.springframework.lang.Nullable; -import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; import org.springframework.util.Assert; @@ -47,8 +50,6 @@ * @see OAuth2Token * @see OAuth2AccessToken * @see OAuth2RefreshToken - * @see OAuth2UserCode - * @see OAuth2DeviceCode */ public class OAuth2Authorization implements Serializable { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; @@ -128,28 +129,6 @@ public Token getRefreshToken() { return getToken(OAuth2RefreshToken.class); } - /** - * Returns the {@link Token} of type {@link OAuth2UserCode}. - * - * @return the {@link Token} of type {@link OAuth2UserCode}, or {@code null} if not - * available - */ - @Nullable - public Token getUserCode() { - return getToken(OAuth2UserCode.class); - } - - /** - * Returns the {@link Token} of type {@link OAuth2DeviceCode}. - * - * @return the {@link Token} of type {@link OAuth2DeviceCode}, or {@code null} if not - * available - */ - @Nullable - public Token getDeviceCode() { - return getToken(OAuth2DeviceCode.class); - } - /** * Returns the {@link Token} of type {@code tokenType}. * @@ -481,26 +460,6 @@ public Builder refreshToken(OAuth2RefreshToken refreshToken) { return token(refreshToken); } - /** - * Sets the {@link OAuth2UserCode user token}. - * - * @param userCode the {@link OAuth2UserCode} - * @return the {@link Builder} - */ - public Builder userCode(OAuth2UserCode userCode) { - return token(userCode); - } - - /** - * Sets the {@link OAuth2DeviceCode device token}. - * - * @param deviceCode the {@link OAuth2DeviceCode} - * @return the {@link Builder} - */ - public Builder deviceCode(OAuth2DeviceCode deviceCode) { - return token(deviceCode); - } - /** * Sets the {@link OAuth2Token token}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java index c1b9a8e02..8c25c8636 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2TokenType.java @@ -31,8 +31,6 @@ public final class OAuth2TokenType implements Serializable { private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; public static final OAuth2TokenType ACCESS_TOKEN = new OAuth2TokenType("access_token"); public static final OAuth2TokenType REFRESH_TOKEN = new OAuth2TokenType("refresh_token"); - public static final OAuth2TokenType USER_CODE = new OAuth2TokenType("user_code"); - public static final OAuth2TokenType DEVICE_CODE = new OAuth2TokenType("device_code"); private final String value; /** From 13a61034eda67f74a2162c5edbaec5243218341e Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Wed, 12 Apr 2023 09:28:26 -0500 Subject: [PATCH 074/250] Add tests and update examples in docs Closes gh-1156 --- .../sample/gettingStarted/SecurityConfig.java | 1 + .../entity/authorization/Authorization.java | 80 +++++++- .../AuthorizationRepository.java | 8 +- .../JpaOAuth2AuthorizationService.java | 46 ++++- .../sample/AuthorizationCodeGrantFlow.java | 4 +- .../sample/DeviceAuthorizationGrantFlow.java | 188 ++++++++++++++++++ .../gettingStarted/SecurityConfigTests.java | 48 +++++ .../src/test/java/sample/jpa/JpaTests.java | 48 +++++ .../java/sample/util/RegisteredClients.java | 1 + docs/src/docs/asciidoc/guides/how-to-jpa.adoc | 8 + .../JdbcOAuth2AuthorizationServiceTests.java | 95 ++++++++- ...h2-authorization-schema-clob-data-type.sql | 8 + .../custom-oauth2-authorization-schema.sql | 8 + 13 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java 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..5f92ddf35 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 @@ -116,6 +116,7 @@ public RegisteredClientRepository registeredClientRepository() { .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") .redirectUri("http://127.0.0.1:8080/authorized") .scope(OidcScopes.OPENID) 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/repository/authorization/AuthorizationRepository.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java index 5f5429856..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 @@ -31,11 +31,15 @@ public interface AuthorizationRepository extends JpaRepository 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.oidcIdTokenValue = :token" + " or a.oidcIdTokenValue = :token" + + " or a.userCodeValue = :token" + + " or a.deviceCodeValue = :token" ) - Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(@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 a4428a87a..f24810f0f 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 @@ -31,8 +31,10 @@ 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; @@ -89,7 +91,7 @@ public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) Optional result; if (tokenType == null) { - result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(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())) { @@ -100,6 +102,10 @@ public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) 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(); } @@ -159,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.getUserCodeValue() != null) { + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode( + entity.getDeviceCodeValue(), + entity.getDeviceCodeIssuedAt(), + entity.getDeviceCodeExpiresAt()); + builder.token(deviceCode, metadata -> metadata.putAll(parseMap(entity.getDeviceCodeMetadata()))); + } + return builder.build(); } @@ -218,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; } @@ -260,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/test/java/sample/AuthorizationCodeGrantFlow.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java index 7339946ef..e58a9208f 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. @@ -109,7 +109,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 { 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..0adcdfc27 --- /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") + .params(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/gettingStarted/SecurityConfigTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java index ed04a0438..fe4949673 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 @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import sample.AuthorizationCodeGrantFlow; +import sample.DeviceAuthorizationGrantFlow; import sample.test.SpringTestContext; import sample.test.SpringTestContextExtension; @@ -116,6 +117,53 @@ public void oidcLoginWhenGettingStartedConfigUsedThenSuccess() throws Exception StringUtils.delimitedListToStringArray(scopes, " ")); } + @Test + public void deviceAuthorizationWhenGettingStartedConfigUsedThenSuccess() throws Exception { + this.spring.register(AuthorizationServerConfig.class).autowire(); + assertThat(this.registeredClientRepository).isInstanceOf(InMemoryRegisteredClientRepository.class); + assertThat(this.authorizationService).isInstanceOf(InMemoryOAuth2AuthorizationService.class); + assertThat(this.authorizationConsentService).isInstanceOf(InMemoryOAuth2AuthorizationConsentService.class); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client"); + assertThat(registeredClient).isNotNull(); + + 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)); } 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 a11b78df2..c223318cc 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 @@ -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; @@ -131,6 +132,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)); } 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 28d96b854..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 @@ -36,6 +36,7 @@ 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) diff --git a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc index 69b7376de..9f17c9902 100644 --- a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc +++ b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc @@ -95,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) ); ---- 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 5b08c5203..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 @@ -45,8 +45,10 @@ 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; @@ -70,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"; @@ -78,6 +81,8 @@ public class JdbcOAuth2AuthorizationServiceTests { 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"; @@ -394,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)); @@ -515,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 oidcIdTokenValue = ? OR refreshTokenValue = ?"; + "accessTokenValue = ? OR oidcIdTokenValue = ? OR refreshTokenValue = ? OR userCodeValue = ? OR " + + "deviceCodeValue = ?"; // @formatter:off private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES @@ -532,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; @@ -567,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, token); + return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token, token, token, token); } private OAuth2Authorization findBy(String filter, Object... args) { @@ -672,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(); } @@ -738,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/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) ); From cec90dd73dd06fb712e217f64719c9443348b3cd Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Wed, 12 Apr 2023 10:37:52 -0500 Subject: [PATCH 075/250] Polish ref-doc Issue gh-1156 --- .../service/authorization/JpaOAuth2AuthorizationService.java | 2 +- docs/src/docs/asciidoc/guides/how-to-jpa.adoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 f24810f0f..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 @@ -173,7 +173,7 @@ private OAuth2Authorization toObject(Authorization entity) { builder.token(userCode, metadata -> metadata.putAll(parseMap(entity.getUserCodeMetadata()))); } - if (entity.getUserCodeValue() != null) { + if (entity.getDeviceCodeValue() != null) { OAuth2DeviceCode deviceCode = new OAuth2DeviceCode( entity.getDeviceCodeValue(), entity.getDeviceCodeIssuedAt(), diff --git a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc index 9f17c9902..94feb9d74 100644 --- a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc +++ b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc @@ -185,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]] From be50f66b4c3dff7481f1c02a758d7b91413d2384 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 27 Mar 2023 09:43:46 -0400 Subject: [PATCH 076/250] Add customization to support public clients for device grant Issue gh-1157 --- .../java/sample/web/DeviceController.java | 4 +- ...OAuth2DeviceAccessTokenResponseClient.java | 2 +- .../src/main/resources/application.yml | 4 +- .../DeviceClientAuthenticationProvider.java | 93 +++++++++++++++++++ .../DeviceClientAuthenticationToken.java | 39 ++++++++ .../java/sample/config/SecurityConfig.java | 27 +++++- .../DeviceClientAuthenticationConverter.java | 71 ++++++++++++++ 7 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java create mode 100644 samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java diff --git a/samples/device-client/src/main/java/sample/web/DeviceController.java b/samples/device-client/src/main/java/sample/web/DeviceController.java index 40ae92666..a88b7006a 100644 --- a/samples/device-client/src/main/java/sample/web/DeviceController.java +++ b/samples/device-client/src/main/java/sample/web/DeviceController.java @@ -105,8 +105,8 @@ public String authorize(Model model, HttpServletRequest request, HttpServletResp Map responseParameters = this.webClient.post() .uri(clientRegistration.getProviderDetails().getAuthorizationUri()) - .headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(), - clientRegistration.getClientSecret())) +// .headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(), +// clientRegistration.getClientSecret())) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(BodyInserters.fromFormData(requestParameters)) .retrieve() diff --git a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java index 7e212c416..92915f48d 100644 --- a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java +++ b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java @@ -58,7 +58,7 @@ public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceGrantRequest devic ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration(); HttpHeaders headers = new HttpHeaders(); - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); +// headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); MultiValueMap requestParameters = new LinkedMultiValueMap<>(); requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, deviceGrantRequest.getGrantType().getValue()); diff --git a/samples/device-client/src/main/resources/application.yml b/samples/device-client/src/main/resources/application.yml index e3cfff535..692d4a142 100644 --- a/samples/device-client/src/main/resources/application.yml +++ b/samples/device-client/src/main/resources/application.yml @@ -15,8 +15,8 @@ spring: registration: messaging-client-device-grant: provider: spring - client-id: messaging-client - client-secret: secret + 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-grant diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java new file mode 100644 index 000000000..dc32f4d1f --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.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 sample.authentication; + +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.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.util.Assert; + +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/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java new file mode 100644 index 000000000..8b73963b5 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java @@ -0,0 +1,39 @@ +/* + * 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; + +@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/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java index b88b1db0e..9fb0cab72 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -26,6 +26,8 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import sample.authentication.DeviceClientAuthenticationProvider; +import sample.web.authentication.DeviceClientAuthenticationConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -68,12 +70,23 @@ public class SecurityConfig { @Bean @Order(1) - public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain authorizationServerSecurityFilterChain( + HttpSecurity http, RegisteredClientRepository registeredClientRepository, + AuthorizationServerSettings authorizationServerSettings) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint .verificationUri("/activate") ) + .clientAuthentication((clientAuthentication) -> + clientAuthentication + .authenticationConverter( + new DeviceClientAuthenticationConverter( + authorizationServerSettings.getDeviceAuthorizationEndpoint())) + .authenticationProvider( + new DeviceClientAuthenticationProvider( + registeredClientRepository)) + ) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 // @formatter:off @@ -124,7 +137,6 @@ public RegisteredClientRepository registeredClientRepository() { .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") .redirectUri("http://127.0.0.1:8080/authorized") .scope(OidcScopes.OPENID) @@ -134,7 +146,16 @@ public RegisteredClientRepository registeredClientRepository() { .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); - return new InMemoryRegisteredClientRepository(registeredClient); + 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(); + + return new InMemoryRegisteredClientRepository(registeredClient, deviceClient); } @Bean diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java new file mode 100644 index 000000000..5d46f889e --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java @@ -0,0 +1,71 @@ +/* + * 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; + +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); + } + +} From 7c166a3a72fcfd7fd1de5f35d3fa59f4b607c4b0 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Wed, 12 Apr 2023 11:43:27 -0500 Subject: [PATCH 077/250] Polish samples Closes gh-1157 --- .../java/sample/web/DeviceController.java | 47 +++++++++++++------ ...OAuth2DeviceAccessTokenResponseClient.java | 16 ++++++- .../DeviceClientAuthenticationProvider.java | 10 ++++ .../DeviceClientAuthenticationToken.java | 5 ++ .../java/sample/config/SecurityConfig.java | 36 ++++++++++---- .../DeviceClientAuthenticationConverter.java | 6 ++- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/samples/device-client/src/main/java/sample/web/DeviceController.java b/samples/device-client/src/main/java/sample/web/DeviceController.java index a88b7006a..a23a55aad 100644 --- a/samples/device-client/src/main/java/sample/web/DeviceController.java +++ b/samples/device-client/src/main/java/sample/web/DeviceController.java @@ -36,6 +36,7 @@ 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.OAuth2DeviceCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; @@ -105,8 +106,22 @@ public String authorize(Model model, HttpServletRequest request, HttpServletResp Map responseParameters = this.webClient.post() .uri(clientRegistration.getProviderDetails().getAuthorizationUri()) -// .headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(), -// clientRegistration.getClientSecret())) + .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() @@ -142,19 +157,21 @@ public ResponseEntity poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) @RegisteredOAuth2AuthorizedClient("messaging-client-device-grant") 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 client is authorized. + /* + * 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(); } diff --git a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java index 92915f48d..1b69a3c86 100644 --- a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java +++ b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java @@ -23,6 +23,7 @@ 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; @@ -58,7 +59,20 @@ public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceGrantRequest devic ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration(); HttpHeaders headers = new HttpHeaders(); -// headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + /* + * 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()); diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java index dc32f4d1f..2ba2668ee 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java @@ -17,6 +17,7 @@ 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; @@ -28,8 +29,17 @@ 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()); diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java index 8b73963b5..4e9a3d2fb 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java @@ -23,6 +23,11 @@ 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 { diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java index 9fb0cab72..23a2e0c36 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -74,20 +74,40 @@ 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) .deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint .verificationUri("/activate") ) - .clientAuthentication((clientAuthentication) -> - clientAuthentication - .authenticationConverter( - new DeviceClientAuthenticationConverter( - authorizationServerSettings.getDeviceAuthorizationEndpoint())) - .authenticationProvider( - new DeviceClientAuthenticationProvider( - registeredClientRepository)) + .clientAuthentication((clientAuthentication) -> clientAuthentication + .authenticationConverter(deviceClientAuthenticationConverter) + .authenticationProvider(deviceClientAuthenticationProvider) ) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + // @formatter:on // @formatter:off http diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java index 5d46f889e..14dd3e5ff 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java @@ -16,7 +16,6 @@ package sample.web.authentication; import jakarta.servlet.http.HttpServletRequest; - import sample.authentication.DeviceClientAuthenticationToken; import org.springframework.http.HttpMethod; @@ -33,6 +32,11 @@ 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; From ea1a5b1e933969a2e203461ee4c58da75ed3d86e Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Wed, 12 Apr 2023 17:01:51 -0500 Subject: [PATCH 078/250] Add documentation for OAuth 2.0 Device Authorization Grant Closes gh-1158 --- docs/src/docs/asciidoc/overview.adoc | 10 ++ .../src/docs/asciidoc/protocol-endpoints.adoc | 104 +++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/docs/src/docs/asciidoc/overview.adoc b/docs/src/docs/asciidoc/overview.adoc index e85ba590d..5b68d83aa 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,6 +60,8 @@ 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] @@ -67,6 +74,9 @@ Spring Authorization Server supports the following features: * 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]) diff --git a/docs/src/docs/asciidoc/protocol-endpoints.adoc b/docs/src/docs/asciidoc/protocol-endpoints.adoc index 83c2ea5e2..90bff8428 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_authorization") <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. From 213048b78053be8a0d3a04700dd3075f86e606c0 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 13 Apr 2023 09:25:08 -0400 Subject: [PATCH 079/250] Polish gh-1127 --- .../OAuth2AuthorizationConsentAuthenticationContext.java | 4 ++-- ...viceAuthorizationConsentAuthenticationProviderTests.java | 2 +- ...OAuth2DeviceVerificationAuthenticationProviderTests.java | 2 +- .../web/OAuth2DeviceVerificationEndpointFilterTests.java | 3 +-- ...iceAuthorizationConsentAuthenticationConverterTests.java | 6 +++--- ...Auth2DeviceVerificationAuthenticationConverterTests.java | 4 ++-- 6 files changed, 10 insertions(+), 11 deletions(-) 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 aed3c3426..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. @@ -162,8 +162,8 @@ 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"); 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"); } 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 index 8e0b5ffeb..19352a1cc 100644 --- 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 @@ -124,7 +124,7 @@ public void setAuthorizationConsentCustomizerWhenNullThenThrowIllegalArgumentExc } @Test - public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() { + public void supportsWhenTypeOAuth2DeviceAuthorizationConsentAuthenticationTokenThenReturnTrue() { assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationConsentAuthenticationToken.class)).isTrue(); } 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 index 97bf5904a..c340af7fa 100644 --- 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 @@ -118,7 +118,7 @@ public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgu } @Test - public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() { + public void supportsWhenTypeOAuth2DeviceVerificationAuthenticationTokenThenReturnTrue() { assertThat(this.authenticationProvider.supports(OAuth2DeviceVerificationAuthenticationToken.class)).isTrue(); } 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 index 43ee6f770..94e826681 100644 --- 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 @@ -75,7 +75,6 @@ public class OAuth2DeviceVerificationEndpointFilterTests { 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 DEVICE_CODE = "EfYu_0jEL"; private static final String USER_CODE = "BCDF-GHJK"; private AuthenticationManager authenticationManager; @@ -95,7 +94,7 @@ public void tearDown() { } @Test - public void constructorWhenAuthenticationMangerIsNullThenThrowIllegalArgumentException() { + public void constructorWhenAuthenticationManagerIsNullThenThrowIllegalArgumentException() { // @formatter:off assertThatIllegalArgumentException() .isThrownBy(() -> new OAuth2DeviceVerificationEndpointFilter(null)) 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 index c73ecd064..c22cb0232 100644 --- 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 @@ -86,7 +86,7 @@ public void convertWhenMissingClientIdThenInvalidRequestError() { } @Test - public void convertWhenBlankClientIdThenInvalidRequestError() { + public void convertWhenEmptyClientIdThenInvalidRequestError() { MockHttpServletRequest request = createRequest(); request.addParameter(OAuth2ParameterNames.STATE, STATE); request.addParameter(OAuth2ParameterNames.CLIENT_ID, ""); @@ -132,7 +132,7 @@ public void convertWhenMissingUserCodeThenInvalidRequestError() { } @Test - public void convertWhenBlankUserCodeThenInvalidRequestError() { + public void convertWhenEmptyUserCodeThenInvalidRequestError() { MockHttpServletRequest request = createRequest(); request.addParameter(OAuth2ParameterNames.STATE, STATE); request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); @@ -165,7 +165,7 @@ public void convertWhenMultipleUserCodeParametersThenInvalidRequestError() { } @Test - public void convertWhenBlankStateParameterThenInvalidRequestError() { + public void convertWhenEmptyStateParameterThenInvalidRequestError() { MockHttpServletRequest request = createRequest(); request.addParameter(OAuth2ParameterNames.STATE, ""); request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID); 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 index 0d19f1a91..639c7cca4 100644 --- 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 @@ -81,7 +81,7 @@ public void convertWhenMissingUserCodeThenReturnNull() { } @Test - public void convertWhenBlankUserCodeParametersThenInvalidRequestError() { + public void convertWhenEmptyUserCodeParameterThenInvalidRequestError() { MockHttpServletRequest request = createRequest(); request.addParameter(OAuth2ParameterNames.USER_CODE, ""); // @formatter:off @@ -95,7 +95,7 @@ public void convertWhenBlankUserCodeParametersThenInvalidRequestError() { } @Test - public void convertWhenMultipleUserCodeParametersThenInvalidRequestError() { + public void convertWhenMultipleUserCodeParameterThenInvalidRequestError() { MockHttpServletRequest request = createRequest(); request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE); request.addParameter(OAuth2ParameterNames.USER_CODE, "another"); From 128d439ff24a18dd0661be3f1c1bc9284a79f50c Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Fri, 14 Apr 2023 06:00:49 -0400 Subject: [PATCH 080/250] Polish gh-1158 --- .../docs/asciidoc/configuration-model.adoc | 40 ++++++++++++------- .../docs/asciidoc/core-model-components.adoc | 2 +- .../src/docs/asciidoc/protocol-endpoints.adoc | 4 +- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/src/docs/asciidoc/configuration-model.adoc b/docs/src/docs/asciidoc/configuration-model.adoc index 388c0203f..908db7ddc 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] @@ -93,7 +95,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 +115,16 @@ 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> + .userInfoEndpoint(userInfoEndpoint -> { }) <15> + .clientRegistrationEndpoint(clientRegistrationEndpoint -> { }) <16> ); return http.build(); @@ -133,13 +137,15 @@ 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> `userInfoEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-user-info-endpoint[OpenID Connect 1.0 UserInfo endpoint]. +<16> `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,6 +163,8 @@ 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") @@ -185,6 +193,8 @@ 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") diff --git a/docs/src/docs/asciidoc/core-model-components.adoc b/docs/src/docs/asciidoc/core-model-components.adoc index d3487d150..a345a78b6 100644 --- a/docs/src/docs/asciidoc/core-model-components.adoc +++ b/docs/src/docs/asciidoc/core-model-components.adoc @@ -84,7 +84,7 @@ 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. diff --git a/docs/src/docs/asciidoc/protocol-endpoints.adoc b/docs/src/docs/asciidoc/protocol-endpoints.adoc index 90bff8428..9640ba2e4 100644 --- a/docs/src/docs/asciidoc/protocol-endpoints.adoc +++ b/docs/src/docs/asciidoc/protocol-endpoints.adoc @@ -123,7 +123,7 @@ static class CustomRedirectUriValidator implements Consumer .deviceAuthorizationResponseHandler(deviceAuthorizationResponseHandler) <5> .errorResponseHandler(errorResponseHandler) <6> - .verificationUri("/oauth2/v1/device_authorization") <7> + .verificationUri("/oauth2/v1/device_verification") <7> ); return http.build(); From cea4677ffc260a8bc0efacf317dd2b2476e7ec81 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Fri, 14 Apr 2023 09:00:04 -0400 Subject: [PATCH 081/250] Add documentation for OpenID Connect 1.0 Logout Endpoint Closes gh-1069 --- .../docs/asciidoc/configuration-model.adoc | 13 +++-- .../docs/asciidoc/core-model-components.adoc | 45 +++++++++++++-- .../sample/gettingStarted/SecurityConfig.java | 1 + docs/src/docs/asciidoc/overview.adoc | 3 + .../src/docs/asciidoc/protocol-endpoints.adoc | 56 +++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/docs/src/docs/asciidoc/configuration-model.adoc b/docs/src/docs/asciidoc/configuration-model.adoc index 908db7ddc..1afa75d28 100644 --- a/docs/src/docs/asciidoc/configuration-model.adoc +++ b/docs/src/docs/asciidoc/configuration-model.adoc @@ -70,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] @@ -123,8 +124,9 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h .authorizationServerMetadataEndpoint(authorizationServerMetadataEndpoint -> { }) <13> .oidc(oidc -> oidc .providerConfigurationEndpoint(providerConfigurationEndpoint -> { }) <14> - .userInfoEndpoint(userInfoEndpoint -> { }) <15> - .clientRegistrationEndpoint(clientRegistrationEndpoint -> { }) <16> + .logoutEndpoint(logoutEndpoint -> { }) <15> + .userInfoEndpoint(userInfoEndpoint -> { }) <16> + .clientRegistrationEndpoint(clientRegistrationEndpoint -> { }) <17> ); return http.build(); @@ -144,8 +146,9 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h <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> `userInfoEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-user-info-endpoint[OpenID Connect 1.0 UserInfo endpoint]. -<16> `clientRegistrationEndpoint()`: The configurer for the xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[OpenID Connect 1.0 Client Registration 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 @@ -169,6 +172,7 @@ public final class AuthorizationServerSettings extends AbstractSettings { .tokenIntrospectionEndpoint("/oauth2/introspect") .tokenRevocationEndpoint("/oauth2/revoke") .jwkSetEndpoint("/oauth2/jwks") + .oidcLogoutEndpoint("/connect/logout") .oidcUserInfoEndpoint("/userinfo") .oidcClientRegistrationEndpoint("/connect/register"); } @@ -199,6 +203,7 @@ public AuthorizationServerSettings authorizationServerSettings() { .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 a345a78b6..755148e18 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> ... @@ -86,9 +87,10 @@ public class RegisteredClient implements Serializable { <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`, `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 @@ -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/src/main/java/sample/gettingStarted/SecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java index 5f92ddf35..bd7e9d1ac 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 @@ -119,6 +119,7 @@ public RegisteredClientRepository registeredClientRepository() { .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .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/index") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") diff --git a/docs/src/docs/asciidoc/overview.adoc b/docs/src/docs/asciidoc/overview.adoc index 5b68d83aa..2853bb4a3 100644 --- a/docs/src/docs/asciidoc/overview.adoc +++ b/docs/src/docs/asciidoc/overview.adoc @@ -68,6 +68,7 @@ Spring Authorization Server supports the following features: * 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] | @@ -83,6 +84,8 @@ Spring Authorization Server supports the following features: * 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 9640ba2e4..87874b2f0 100644 --- a/docs/src/docs/asciidoc/protocol-endpoints.adoc +++ b/docs/src/docs/asciidoc/protocol-endpoints.adoc @@ -433,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 From 0b1e77828af6b883e007b5ef4b2a5e11a8453376 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 15:50:45 -0400 Subject: [PATCH 082/250] Update Stack Overflow tag to spring-authorization-server --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- CONTRIBUTING.adoc | 2 +- README.adoc | 2 +- docs/src/docs/asciidoc/getting-help.adoc | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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 6438c9576..6afd14e5c 100644 --- a/README.adoc +++ b/README.adoc @@ -78,7 +78,7 @@ 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]. +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 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]. From ea11b66ccc2a235dc02680ef7095466b12e6eedd Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 16:37:14 -0400 Subject: [PATCH 083/250] Update to Spring Framework 5.3.27 Closes gh-1162 --- buildSrc/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 6e541d159..a35b4e4f3 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:5.3.25" + implementation "org.springframework:spring-core:5.3.27" } diff --git a/gradle.properties b/gradle.properties index 6a2df014e..476adcae3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=0.4.2-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=5.3.25 +springFrameworkVersion=5.3.27 springSecurityVersion=5.8.2 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From 5b16a48d1fb261e3713d1fd92cbe0f5d166bdafa Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 16:38:21 -0400 Subject: [PATCH 084/250] Update to Spring Security 5.8.3 Closes gh-1163 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 476adcae3..62f407b0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=5.3.27 -springSecurityVersion=5.8.2 +springSecurityVersion=5.8.3 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 From d2cb3d06057711c2c546db4a1bfee5b237681ba7 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 16:42:43 -0400 Subject: [PATCH 085/250] Update to io.spring.javaformat:spring-javaformat-checkstyle:0.0.38 Closes gh-1164 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 62f407b0c..ea189b174 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=5.3.27 springSecurityVersion=5.8.3 -springJavaformatVersion=0.0.35 +springJavaformatVersion=0.0.38 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 nohttpCheckstyleVersion=0.0.11 From 1bb708fbff1be90863d017fdfa67994a23f23255 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 17:09:23 -0400 Subject: [PATCH 086/250] Update to Spring Framework 6.0.8 Closes gh-1165 --- buildSrc/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index dc0c46910..00b9e9a6e 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:6.0.5" + implementation "org.springframework:spring-core:6.0.8" } diff --git a/gradle.properties b/gradle.properties index e20a3abf8..be54ba910 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=1.0.2-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=6.0.5 +springFrameworkVersion=6.0.8 springSecurityVersion=6.0.2 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From c7f5e7a1f9dfd901d90c1f2b17ebc11faaa329cb Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 17:09:43 -0400 Subject: [PATCH 087/250] Update to Spring Security 6.0.3 Closes gh-1166 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index be54ba910..78067456f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.8 -springSecurityVersion=6.0.2 +springSecurityVersion=6.0.3 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 From 9f763796e951a90f8b78c49b88426108fb6f9812 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 17 Apr 2023 17:11:09 -0400 Subject: [PATCH 088/250] Update to io.spring.javaformat:spring-javaformat-checkstyle:0.0.38 Closes gh-1167 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 78067456f..209a10937 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.8 springSecurityVersion=6.0.3 -springJavaformatVersion=0.0.35 +springJavaformatVersion=0.0.38 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 nohttpCheckstyleVersion=0.0.11 From 77f74cefe12a44d4a166aa5ab9fd52c664355e94 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 05:30:35 -0400 Subject: [PATCH 089/250] Update to Spring Framework 6.0.8 Closes gh-1168 --- buildSrc/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 431501d0f..00b9e9a6e 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -23,5 +23,5 @@ dependencies { implementation "org.hidetake:gradle-ssh-plugin:2.10.1" implementation "org.jfrog.buildinfo:build-info-extractor-gradle:4.26.1" implementation "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1" - implementation "org.springframework:spring-core:6.0.7" + implementation "org.springframework:spring-core:6.0.8" } diff --git a/gradle.properties b/gradle.properties index 939e84417..f465a767c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ version=1.1.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true -springFrameworkVersion=6.0.7 +springFrameworkVersion=6.0.8 springSecurityVersion=6.1.0-M2 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 From 36f6431e0f7bbe687cd0f956d8c9acb3441d06b0 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 05:32:56 -0400 Subject: [PATCH 090/250] Update to Spring Security 6.1.0-RC1 Closes gh-1169 --- .../examples/spring-authorization-server-docs-examples.gradle | 2 +- gradle.properties | 2 +- samples/custom-consent-authorizationserver/gradle.properties | 2 +- samples/default-authorizationserver/gradle.properties | 2 +- samples/device-client/gradle.properties | 2 +- samples/device-grant-authorizationserver/gradle.properties | 2 +- .../federated-identity-authorizationserver/gradle.properties | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) 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 e0aaf259b..42f5a29f1 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 @@ -13,7 +13,7 @@ repositories { dependencies { implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.0") - implementation platform("org.springframework.security:spring-security-bom:6.1.0-M2") + implementation platform("org.springframework.security:spring-security-bom:6.1.0-RC1") implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation "org.springframework.boot:spring-boot-starter-security" diff --git a/gradle.properties b/gradle.properties index f465a767c..b75afa48c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.8 -springSecurityVersion=6.1.0-M2 +springSecurityVersion=6.1.0-RC1 springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 diff --git a/samples/custom-consent-authorizationserver/gradle.properties b/samples/custom-consent-authorizationserver/gradle.properties index 74915daef..3d071be66 100644 --- a/samples/custom-consent-authorizationserver/gradle.properties +++ b/samples/custom-consent-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-M2 +spring-security.version=6.1.0-RC1 diff --git a/samples/default-authorizationserver/gradle.properties b/samples/default-authorizationserver/gradle.properties index 74915daef..3d071be66 100644 --- a/samples/default-authorizationserver/gradle.properties +++ b/samples/default-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-M2 +spring-security.version=6.1.0-RC1 diff --git a/samples/device-client/gradle.properties b/samples/device-client/gradle.properties index 74915daef..3d071be66 100644 --- a/samples/device-client/gradle.properties +++ b/samples/device-client/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-M2 +spring-security.version=6.1.0-RC1 diff --git a/samples/device-grant-authorizationserver/gradle.properties b/samples/device-grant-authorizationserver/gradle.properties index 74915daef..3d071be66 100644 --- a/samples/device-grant-authorizationserver/gradle.properties +++ b/samples/device-grant-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-M2 +spring-security.version=6.1.0-RC1 diff --git a/samples/federated-identity-authorizationserver/gradle.properties b/samples/federated-identity-authorizationserver/gradle.properties index 74915daef..3d071be66 100644 --- a/samples/federated-identity-authorizationserver/gradle.properties +++ b/samples/federated-identity-authorizationserver/gradle.properties @@ -1 +1 @@ -spring-security.version=6.1.0-M2 +spring-security.version=6.1.0-RC1 From 435db60dc1e88a9111347cd520df235a1a3e7c47 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 05:39:33 -0400 Subject: [PATCH 091/250] Update to io.spring.javaformat:spring-javaformat-checkstyle:0.0.38 Closes gh-1170 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b75afa48c..9c0b4e077 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.8 springSecurityVersion=6.1.0-RC1 -springJavaformatVersion=0.0.35 +springJavaformatVersion=0.0.38 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 nohttpCheckstyleVersion=0.0.11 From 1a345cba6686c8872e2151522bddd1c987f3cb17 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 05:51:18 -0400 Subject: [PATCH 092/250] Update to json-path:2.8.0 Closes gh-1171 --- dependencies/spring-authorization-server-dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 58d55918f..035cdc92d 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/dependencies/spring-authorization-server-dependencies.gradle @@ -18,7 +18,7 @@ dependencies { 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 "com.jayway.jsonpath:json-path:2.8.0" api "org.hsqldb:hsqldb:2.7.1" } } From 42a0393d44dbb5562e9a642923b44e757e591765 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 06:19:59 -0400 Subject: [PATCH 093/250] Release 0.4.2 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ea189b174..7211ebb8e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=0.4.2-SNAPSHOT +version=0.4.2 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 089096a8355618c04b2757ccfe7941717aaa7c60 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 06:56:49 -0400 Subject: [PATCH 094/250] Next Development Version --- gradle.properties | 2 +- .../authorization/util/SpringAuthorizationServerVersion.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 7211ebb8e..7522dda35 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=0.4.2 +version=0.4.3-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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 49d702e35..ced2bf0a8 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 @@ -24,7 +24,7 @@ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 0; private static final int MINOR = 4; - private static final int PATCH = 2; + private static final int PATCH = 3; /** * Global Serialization value for Spring Authorization Server classes. From 750eac85b250ebf165ce841fb1eede51d43fd466 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 07:08:36 -0400 Subject: [PATCH 095/250] Release 1.0.2 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 209a10937..be87b8622 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.2-SNAPSHOT +version=1.0.2 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 5b2b42f71c484f42923456fb17598b350d7bb923 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 07:14:59 -0400 Subject: [PATCH 096/250] Next Development Version --- gradle.properties | 2 +- .../authorization/util/SpringAuthorizationServerVersion.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index be87b8622..d76902607 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.2 +version=1.0.3-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true 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 e3a311131..85198a9af 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 @@ -24,7 +24,7 @@ public final class SpringAuthorizationServerVersion { private static final int MAJOR = 1; private static final int MINOR = 0; - private static final int PATCH = 2; + private static final int PATCH = 3; /** * Global Serialization value for Spring Authorization Server classes. From fa1f938a4ab36495a2ae47323a5feaf42dc1e30b Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 08:15:21 -0400 Subject: [PATCH 097/250] Release 1.1.0-RC1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 9c0b4e077..8ceb44abc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0-SNAPSHOT +version=1.1.0-RC1 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 71c2e5c6e4dac049af9198a0f31641769ef1750b Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 18 Apr 2023 08:20:38 -0400 Subject: [PATCH 098/250] Next Development Version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8ceb44abc..9c0b4e077 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0-RC1 +version=1.1.0-SNAPSHOT org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true From 7aee468d8bc05268c2c5a94178f5586c8246c24b Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 19 Apr 2023 12:01:37 -0400 Subject: [PATCH 099/250] Update to org.jfrog.buildinfo:build-info-extractor-gradle:4.29.0 Closes gh-1175 --- buildSrc/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index a35b4e4f3..aaaf80789 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:5.3.27" } From 616033606bc73d1174f5af37e8e63f9436cc839e Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 19 Apr 2023 14:25:33 -0400 Subject: [PATCH 100/250] Apply ArtifactoryPlugin to SpringRootProjectPlugin Closes gh-1177 --- .../io/spring/gradle/convention/SpringRootProjectPlugin.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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..857616eac 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. @@ -20,6 +20,7 @@ import org.gradle.api.Project; import org.gradle.api.plugins.BasePlugin; import org.gradle.api.plugins.PluginManager; +import org.jfrog.gradle.plugin.artifactory.ArtifactoryPlugin; import org.springframework.gradle.classpath.SpringCheckProhibitedDependenciesLifecyclePlugin; import org.springframework.gradle.maven.SpringNexusPlugin; @@ -38,6 +39,7 @@ public void apply(Project project) { pluginManager.apply(SpringNoHttpPlugin.class); pluginManager.apply(SpringNexusPlugin.class); pluginManager.apply(SpringCheckProhibitedDependenciesLifecyclePlugin.class); + pluginManager.apply(ArtifactoryPlugin.class); pluginManager.apply(SpringSonarQubePlugin.class); // Apply default repositories From bc947481ad81b6380b27a180b93c7c6c40678af0 Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Wed, 19 Apr 2023 20:15:06 +0100 Subject: [PATCH 101/250] Fix artifact build properties for Artifactory - Apply SpringArtifactoryPlugin in SpringRootProjectPlugin (which applies ArtifactoryPlugin) - In SpringArtifactoryPlugin don't set publication if MavenPublishPlugin is not applied Closes gh-1179 --- .../spring/gradle/convention/SpringRootProjectPlugin.java | 4 ++-- .../gradle/maven/SpringArtifactoryPlugin.java | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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 857616eac..169b433cc 100644 --- a/buildSrc/src/main/java/io/spring/gradle/convention/SpringRootProjectPlugin.java +++ b/buildSrc/src/main/java/io/spring/gradle/convention/SpringRootProjectPlugin.java @@ -20,9 +20,9 @@ import org.gradle.api.Project; import org.gradle.api.plugins.BasePlugin; import org.gradle.api.plugins.PluginManager; -import org.jfrog.gradle.plugin.artifactory.ArtifactoryPlugin; 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; @@ -39,7 +39,7 @@ public void apply(Project project) { pluginManager.apply(SpringNoHttpPlugin.class); pluginManager.apply(SpringNexusPlugin.class); pluginManager.apply(SpringCheckProhibitedDependenciesLifecyclePlugin.class); - pluginManager.apply(ArtifactoryPlugin.class); + pluginManager.apply(SpringArtifactoryPlugin.class); pluginManager.apply(SpringSonarQubePlugin.class); // Apply default repositories 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")); + }); }); }); } From b6ff06d6fe7eedfa09fc030bfa37af74213fb389 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 24 Apr 2023 11:14:21 -0400 Subject: [PATCH 102/250] Add test for dynamic client registration with custom metadata Issue gh-1172 --- .../OidcClientRegistrationTests.java | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) 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 ca178ed6d..900b85848 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 @@ -17,8 +17,10 @@ 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; @@ -39,6 +41,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; @@ -58,6 +61,8 @@ 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.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; @@ -400,6 +408,34 @@ public void requestWhenClientRegistersWithSecretThenClientAuthenticationSuccess( .andReturn(); } + @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 @@ -530,6 +566,147 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h // @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(OAuth2ResourceServerConfigurer::jwt) + .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 { From 813921ed0b8e163353e2726d9e172f1cd5c07063 Mon Sep 17 00:00:00 2001 From: Dmitriy Dubson Date: Mon, 17 Apr 2023 15:23:09 -0400 Subject: [PATCH 103/250] Add logout success page to default client sample Sample client (located in 'samples/messages-client' directory) is configured with a custom logout success page where the end-user is redirected to upon successful logout action. Fixes gh-1142 --- .../config/AuthorizationServerConfig.java | 2 +- .../config/AuthorizationServerConfig.java | 3 ++- .../config/AuthorizationServerConfig.java | 2 +- .../java/sample/config/SecurityConfig.java | 7 ++++-- .../java/sample/web/DefaultController.java | 9 +++++++- .../main/resources/templates/logged-out.html | 22 +++++++++++++++++++ 6 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 samples/messages-client/src/main/resources/templates/logged-out.html 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 index c22151d21..85b177b13 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -94,7 +94,7 @@ public RegisteredClientRepository registeredClientRepository() { .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/index") + .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") diff --git a/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index 9abaa8e7b..ad6bdb274 100644 --- a/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -54,6 +54,7 @@ /** * @author Joe Grandja + * @author Dmitriy Dubson * @since 0.0.1 */ @Configuration(proxyBeanMethods = false) @@ -88,7 +89,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/index") + .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") diff --git a/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index 2dd895edd..cdc1bfa18 100644 --- a/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -90,7 +90,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/index") + .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") diff --git a/samples/messages-client/src/main/java/sample/config/SecurityConfig.java b/samples/messages-client/src/main/java/sample/config/SecurityConfig.java index 29ec525fc..7ab8a457a 100644 --- a/samples/messages-client/src/main/java/sample/config/SecurityConfig.java +++ b/samples/messages-client/src/main/java/sample/config/SecurityConfig.java @@ -30,6 +30,7 @@ /** * @author Joe Grandja + * @author Dmitriy Dubson * @since 0.0.1 */ @EnableWebSecurity @@ -49,7 +50,9 @@ WebSecurityCustomizer webSecurityCustomizer() { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> - authorize.anyRequest().authenticated() + authorize + .requestMatchers("/logged-out").permitAll() + .anyRequest().authenticated() ) .oauth2Login(oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc")) @@ -66,7 +69,7 @@ private LogoutSuccessHandler oidcLogoutSuccessHandler() { // 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}/index"); + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/logged-out"); return oidcLogoutSuccessHandler; } diff --git a/samples/messages-client/src/main/java/sample/web/DefaultController.java b/samples/messages-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/messages-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/messages-client/src/main/resources/templates/logged-out.html b/samples/messages-client/src/main/resources/templates/logged-out.html new file mode 100644 index 000000000..3c8ec0a56 --- /dev/null +++ b/samples/messages-client/src/main/resources/templates/logged-out.html @@ -0,0 +1,22 @@ + + + + Spring Security OAuth 2.0 Sample + + + + + + +
    + +
    +
    +

    You are now logged out.

    + Go back home +
    + + + + From f70f28cdf944fcf503cf5c9f1707651e9e64fc70 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 27 Apr 2023 06:54:38 -0400 Subject: [PATCH 104/250] Add sample featured-authorizationserver Issue gh-1189 --- .../gradle.properties | 1 + ...amples-featured-authorizationserver.gradle | 27 ++++ ...eaturedAuthorizationServerApplication.java | 32 ++++ .../config/AuthorizationServerConfig.java | 150 ++++++++++++++++++ .../sample/config/DefaultSecurityConfig.java | 75 +++++++++ .../src/main/java/sample/jose/Jwks.java | 75 +++++++++ .../java/sample/jose/KeyGeneratorUtils.java | 86 ++++++++++ .../src/main/resources/application.yml | 9 ++ ...edAuthorizationServerApplicationTests.java | 137 ++++++++++++++++ ...aturedAuthorizationServerConsentTests.java | 126 +++++++++++++++ 10 files changed, 718 insertions(+) create mode 100644 samples/featured-authorizationserver/gradle.properties create mode 100644 samples/featured-authorizationserver/samples-featured-authorizationserver.gradle create mode 100644 samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java create mode 100644 samples/featured-authorizationserver/src/main/resources/application.yml create mode 100644 samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java create mode 100644 samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java diff --git a/samples/featured-authorizationserver/gradle.properties b/samples/featured-authorizationserver/gradle.properties new file mode 100644 index 000000000..3d071be66 --- /dev/null +++ b/samples/featured-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-RC1 diff --git a/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle b/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle new file mode 100644 index 000000000..61721414d --- /dev/null +++ b/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle @@ -0,0 +1,27 @@ +plugins { + id "org.springframework.boot" version "3.0.0" + 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-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" +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java b/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java new file mode 100644 index 000000000..4d59f2cc4 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java @@ -0,0 +1,32 @@ +/* + * 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 org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +@SpringBootApplication +public class FeaturedAuthorizationServerApplication { + + public static void main(String[] args) { + SpringApplication.run(FeaturedAuthorizationServerApplication.class, args); + } + +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java new file mode 100644 index 000000000..8e70f28f1 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -0,0 +1,150 @@ +/* + * 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.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.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 1.1.0 + */ +@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(oauth2ResourceServer -> + oauth2ResourceServer.jwt(Customizer.withDefaults())); + // @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") + .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") + .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/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java new file mode 100644 index 000000000..df0c80676 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java @@ -0,0 +1,75 @@ +/* + * 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.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.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.session.HttpSessionEventPublisher; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class DefaultSecurityConfig { + + // @formatter:off + @Bean + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> + authorize.anyRequest().authenticated() + ) + .formLogin(withDefaults()); + return http.build(); + } + // @formatter:on + + // @formatter:off + @Bean + public UserDetailsService users() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user1") + .password("password") + .roles("USER") + .build(); + return new InMemoryUserDetailsManager(user); + } + // @formatter:on + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java new file mode 100644 index 000000000..62f581fe4 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java @@ -0,0 +1,75 @@ +/* + * 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.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 1.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/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java new file mode 100644 index 000000000..e55e361a0 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.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 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 1.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/featured-authorizationserver/src/main/resources/application.yml b/samples/featured-authorizationserver/src/main/resources/application.yml new file mode 100644 index 000000000..ac3606472 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/resources/application.yml @@ -0,0 +1,9 @@ +server: + port: 9000 + +logging: + level: + root: INFO + org.springframework.web: INFO + org.springframework.security: INFO + org.springframework.security.oauth2: INFO diff --git a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java new file mode 100644 index 000000000..0d79cd1c4 --- /dev/null +++ b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java @@ -0,0 +1,137 @@ +/* + * 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 FeaturedAuthorizationServerApplicationTests { + 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 whenLoginSuccessfulThenDisplayNotFoundError() 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.NOT_FOUND.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.getTextContent()).isEqualTo("Bad credentials"); + } + + @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/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java new file mode 100644 index 000000000..da962796b --- /dev/null +++ b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java @@ -0,0 +1,126 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; + +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.DomElement; +import com.gargoylesoftware.htmlunit.html.HtmlCheckBoxInput; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Consent screen integration tests for the sample Authorization Server. + * + * @author Dmitriy Dubson + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public class FeaturedAuthorizationServerConsentTests { + + @Autowired + private WebClient webClient; + + @MockBean + private OAuth2AuthorizationConsentService authorizationConsentService; + + private final String redirectUri = "http://127.0.0.1/login/oauth2/code/messaging-client-oidc"; + + private final String authorizationRequestUri = UriComponentsBuilder + .fromPath("/oauth2/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", "messaging-client") + .queryParam("scope", "openid message.read message.write") + .queryParam("state", "state") + .queryParam("redirect_uri", this.redirectUri) + .toUriString(); + + @BeforeEach + public void setUp() { + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); + this.webClient.getOptions().setRedirectEnabled(true); + this.webClient.getCookieManager().clearCookies(); + when(this.authorizationConsentService.findById(any(), any())).thenReturn(null); + } + + @Test + @WithMockUser("user1") + public void whenUserConsentsToAllScopesThenReturnAuthorizationCode() throws IOException { + final HtmlPage consentPage = this.webClient.getPage(this.authorizationRequestUri); + assertThat(consentPage.getTitleText()).isEqualTo("Consent required"); + + List scopes = new ArrayList<>(); + consentPage.querySelectorAll("input[name='scope']").forEach(scope -> + scopes.add((HtmlCheckBoxInput) scope)); + for (HtmlCheckBoxInput scope : scopes) { + scope.click(); + } + + List scopeIds = new ArrayList<>(); + scopes.forEach(scope -> { + assertThat(scope.isChecked()).isTrue(); + scopeIds.add(scope.getId()); + }); + assertThat(scopeIds).containsExactlyInAnyOrder("message.read", "message.write"); + + DomElement submitConsentButton = consentPage.querySelector("button[id='submit-consent']"); + this.webClient.getOptions().setRedirectEnabled(false); + + WebResponse approveConsentResponse = submitConsentButton.click().getWebResponse(); + assertThat(approveConsentResponse.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); + String location = approveConsentResponse.getResponseHeaderValue("location"); + assertThat(location).startsWith(this.redirectUri); + assertThat(location).contains("code="); + } + + @Test + @WithMockUser("user1") + public void whenUserCancelsConsentThenReturnAccessDeniedError() throws IOException { + final HtmlPage consentPage = this.webClient.getPage(this.authorizationRequestUri); + assertThat(consentPage.getTitleText()).isEqualTo("Consent required"); + + DomElement cancelConsentButton = consentPage.querySelector("button[id='cancel-consent']"); + this.webClient.getOptions().setRedirectEnabled(false); + + WebResponse cancelConsentResponse = cancelConsentButton.click().getWebResponse(); + assertThat(cancelConsentResponse.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); + String location = cancelConsentResponse.getResponseHeaderValue("location"); + assertThat(location).startsWith(this.redirectUri); + assertThat(location).contains("error=access_denied"); + } + +} From 1485135325df152dcaf38015ab2c6d7a8b6f0513 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 27 Apr 2023 08:25:44 -0400 Subject: [PATCH 105/250] Merge custom-consent-authorizationserver into featured-authorizationserver Issue gh-1189 --- .../gradle.properties | 1 - ...-custom-consent-authorizationserver.gradle | 26 ---- ...ConsentAuthorizationServerApplication.java | 31 ----- .../config/AuthorizationServerConfig.java | 131 ------------------ .../sample/config/DefaultSecurityConfig.java | 74 ---------- .../src/main/java/sample/jose/Jwks.java | 73 ---------- .../java/sample/jose/KeyGeneratorUtils.java | 84 ----------- .../src/main/resources/application.yml | 10 -- ...CustomConsentAuthorizationServerTests.java | 126 ----------------- ...amples-featured-authorizationserver.gradle | 1 + .../config/AuthorizationServerConfig.java | 5 + .../web/AuthorizationConsentController.java | 3 +- .../src/main/resources/templates/consent.html | 5 - ...aturedAuthorizationServerConsentTests.java | 4 +- 14 files changed, 10 insertions(+), 564 deletions(-) delete mode 100644 samples/custom-consent-authorizationserver/gradle.properties delete mode 100644 samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle delete mode 100644 samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java delete mode 100644 samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java delete mode 100644 samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java delete mode 100644 samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java delete mode 100644 samples/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java delete mode 100644 samples/custom-consent-authorizationserver/src/main/resources/application.yml delete mode 100644 samples/custom-consent-authorizationserver/src/test/java/sample/CustomConsentAuthorizationServerTests.java rename samples/{custom-consent-authorizationserver => featured-authorizationserver}/src/main/java/sample/web/AuthorizationConsentController.java (98%) rename samples/{custom-consent-authorizationserver => featured-authorizationserver}/src/main/resources/templates/consent.html (97%) diff --git a/samples/custom-consent-authorizationserver/gradle.properties b/samples/custom-consent-authorizationserver/gradle.properties deleted file mode 100644 index 3d071be66..000000000 --- a/samples/custom-consent-authorizationserver/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -spring-security.version=6.1.0-RC1 diff --git a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle deleted file mode 100644 index 768ed5219..000000000 --- a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - id "org.springframework.boot" version "3.0.0" - 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-thymeleaf" - implementation "org.springframework.boot:spring-boot-starter-security" - implementation project(":spring-security-oauth2-authorization-server") - - 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" -} diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java b/samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java deleted file mode 100644 index 85ed23813..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/CustomConsentAuthorizationServerApplication.java +++ /dev/null @@ -1,31 +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; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** - * @author Daniel Garnier-Moiroux - */ -@SpringBootApplication -public class CustomConsentAuthorizationServerApplication { - - public static void main(String[] args) { - SpringApplication.run(CustomConsentAuthorizationServerApplication.class, args); - } - -} 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 85b177b13..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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.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") - .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") - .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 b4370c024..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.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.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.session.HttpSessionEventPublisher; - -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 - - @Bean - SessionRegistry sessionRegistry() { - return new SessionRegistryImpl(); - } - - @Bean - HttpSessionEventPublisher httpSessionEventPublisher() { - return new HttpSessionEventPublisher(); - } - -} diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java deleted file mode 100644 index 1f936056d..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/Jwks.java +++ /dev/null @@ -1,73 +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 - */ -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/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java deleted file mode 100644 index b101062ef..000000000 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java +++ /dev/null @@ -1,84 +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 - */ -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/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/test/java/sample/CustomConsentAuthorizationServerTests.java b/samples/custom-consent-authorizationserver/src/test/java/sample/CustomConsentAuthorizationServerTests.java deleted file mode 100644 index 762ccf811..000000000 --- a/samples/custom-consent-authorizationserver/src/test/java/sample/CustomConsentAuthorizationServerTests.java +++ /dev/null @@ -1,126 +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 java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.WebResponse; -import com.gargoylesoftware.htmlunit.html.DomElement; -import com.gargoylesoftware.htmlunit.html.HtmlCheckBoxInput; -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.boot.test.mock.mockito.MockBean; -import org.springframework.http.HttpStatus; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.web.util.UriComponentsBuilder; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -/** - * Consent page integration tests for the sample Authorization Server serving a custom Consent page. - * - * @author Dmitriy Dubson - */ -@ExtendWith(SpringExtension.class) -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureMockMvc -public class CustomConsentAuthorizationServerTests { - - @Autowired - private WebClient webClient; - - @MockBean - private OAuth2AuthorizationConsentService authorizationConsentService; - - private final String redirectUri = "http://127.0.0.1/login/oauth2/code/messaging-client-oidc"; - - private final String authorizationRequestUri = UriComponentsBuilder - .fromPath("/oauth2/authorize") - .queryParam("response_type", "code") - .queryParam("client_id", "messaging-client") - .queryParam("scope", "openid message.read message.write") - .queryParam("state", "state") - .queryParam("redirect_uri", this.redirectUri) - .toUriString(); - - @BeforeEach - public void setUp() { - this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); - this.webClient.getOptions().setRedirectEnabled(true); - this.webClient.getCookieManager().clearCookies(); - when(this.authorizationConsentService.findById(any(), any())).thenReturn(null); - } - - @Test - @WithMockUser("user1") - public void whenUserConsentsToAllScopesThenReturnAuthorizationCode() throws IOException { - final HtmlPage consentPage = this.webClient.getPage(this.authorizationRequestUri); - assertThat(consentPage.getTitleText()).isEqualTo("Custom consent page - Consent required"); - - List scopes = new ArrayList<>(); - consentPage.querySelectorAll("input[name='scope']").forEach(scope -> - scopes.add((HtmlCheckBoxInput) scope)); - for (HtmlCheckBoxInput scope : scopes) { - scope.click(); - } - - List scopeIds = new ArrayList<>(); - scopes.forEach(scope -> { - assertThat(scope.isChecked()).isTrue(); - scopeIds.add(scope.getId()); - }); - assertThat(scopeIds).containsExactlyInAnyOrder("message.read", "message.write"); - - DomElement submitConsentButton = consentPage.querySelector("button[id='submit-consent']"); - this.webClient.getOptions().setRedirectEnabled(false); - - WebResponse approveConsentResponse = submitConsentButton.click().getWebResponse(); - assertThat(approveConsentResponse.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); - String location = approveConsentResponse.getResponseHeaderValue("location"); - assertThat(location).startsWith(this.redirectUri); - assertThat(location).contains("code="); - } - - @Test - @WithMockUser("user1") - public void whenUserCancelsConsentThenReturnAccessDeniedError() throws IOException { - final HtmlPage consentPage = this.webClient.getPage(this.authorizationRequestUri); - assertThat(consentPage.getTitleText()).isEqualTo("Custom consent page - Consent required"); - - DomElement cancelConsentButton = consentPage.querySelector("button[id='cancel-consent']"); - this.webClient.getOptions().setRedirectEnabled(false); - - WebResponse cancelConsentResponse = cancelConsentButton.click().getWebResponse(); - assertThat(cancelConsentResponse.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); - String location = cancelConsentResponse.getResponseHeaderValue("location"); - assertThat(location).startsWith(this.redirectUri); - assertThat(location).contains("error=access_denied"); - } - -} diff --git a/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle b/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle index 61721414d..08f4a7208 100644 --- a/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle +++ b/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle @@ -15,6 +15,7 @@ repositories { 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-jdbc" implementation project(":spring-security-oauth2-authorization-server") diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index 8e70f28f1..f06656796 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -53,16 +53,20 @@ /** * @author Joe Grandja + * @author Daniel Garnier-Moiroux * @since 1.1.0 */ @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 { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .authorizationEndpoint(authorizationEndpoint -> + authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 // @formatter:off @@ -113,6 +117,7 @@ public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { + // Will be used by the ConsentController return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); } diff --git a/samples/custom-consent-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java b/samples/featured-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java similarity index 98% rename from samples/custom-consent-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java rename to samples/featured-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java index c98327d78..c1c8f2983 100644 --- a/samples/custom-consent-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java +++ b/samples/featured-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. @@ -125,4 +125,5 @@ public static class ScopeWithDescription { this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION); } } + } diff --git a/samples/custom-consent-authorizationserver/src/main/resources/templates/consent.html b/samples/featured-authorizationserver/src/main/resources/templates/consent.html similarity index 97% rename from samples/custom-consent-authorizationserver/src/main/resources/templates/consent.html rename to samples/featured-authorizationserver/src/main/resources/templates/consent.html index 761dfc281..fbfc8a375 100644 --- a/samples/custom-consent-authorizationserver/src/main/resources/templates/consent.html +++ b/samples/featured-authorizationserver/src/main/resources/templates/consent.html @@ -6,11 +6,6 @@ Custom consent page - Consent required - - \ 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 index 23085c1d4..0d162b0c8 100644 --- a/samples/messages-client/src/main/resources/templates/index.html +++ b/samples/messages-client/src/main/resources/templates/index.html @@ -30,11 +30,14 @@

    Authorize the client using
  • - Authorization Code  (Login to Spring Authorization Server using: user1/password) + Authorization Code
  • Client Credentials
  • +
  • + Device Code +
  • +
    +
    +

    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.

    - + +
    Date: Mon, 1 May 2023 10:06:12 -0400 Subject: [PATCH 112/250] Use current authentication for device authorization Issue gh-1189 --- .../java/sample/web/DeviceController.java | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/samples/messages-client/src/main/java/sample/web/DeviceController.java b/samples/messages-client/src/main/java/sample/web/DeviceController.java index 187f3eec3..9f03d74cf 100644 --- a/samples/messages-client/src/main/java/sample/web/DeviceController.java +++ b/samples/messages-client/src/main/java/sample/web/DeviceController.java @@ -22,30 +22,19 @@ import java.util.Objects; import java.util.Set; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - 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.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.AuthorityUtils; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.context.SecurityContextHolderStrategy; 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.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; -import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.util.LinkedMultiValueMap; @@ -83,12 +72,6 @@ public class DeviceController { private final String messagesBaseUri; - private final SecurityContextRepository securityContextRepository = - new HttpSessionSecurityContextRepository(); - - private final SecurityContextHolderStrategy securityContextHolderStrategy = - SecurityContextHolder.getContextHolderStrategy(); - public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient, @Value("${messages.base-uri}") String messagesBaseUri) { @@ -98,7 +81,7 @@ public DeviceController(ClientRegistrationRepository clientRegistrationRepositor } @GetMapping("/device_authorize") - public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) { + public String authorize(Model model) { // @formatter:off ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId( @@ -143,13 +126,9 @@ public String authorize(Model model, HttpServletRequest request, HttpServletResp Instant issuedAt = Instant.now(); Integer expiresIn = (Integer) responseParameters.get(OAuth2ParameterNames.EXPIRES_IN); Instant expiresAt = issuedAt.plusSeconds(expiresIn); - String deviceCodeValue = (String) responseParameters.get(OAuth2ParameterNames.DEVICE_CODE); - OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, issuedAt, expiresAt); - saveSecurityContext(deviceCode, request, response); - - model.addAttribute("deviceCode", deviceCode.getTokenValue()); - model.addAttribute("expiresAt", deviceCode.getExpiresAt()); + 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 @@ -210,19 +189,4 @@ public String authorized(Model model, return "index"; } - private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request, - HttpServletResponse response) { - - // @formatter:off - UsernamePasswordAuthenticationToken deviceAuthentication = - UsernamePasswordAuthenticationToken.authenticated( - deviceCode, null, AuthorityUtils.createAuthorityList("ROLE_DEVICE")); - // @formatter:on - - SecurityContext securityContext = this.securityContextHolderStrategy.createEmptyContext(); - securityContext.setAuthentication(deviceAuthentication); - this.securityContextHolderStrategy.setContext(securityContext); - this.securityContextRepository.saveContext(securityContext, request, response); - } - } From 043acf17b7b8432f0a0d37e802c78a8d4d0ae80c Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 1 May 2023 10:35:49 -0400 Subject: [PATCH 113/250] Reuse error handling Issue gh-1189 --- ...oller.java => DefaultErrorController.java} | 2 +- .../resources/templates/access-denied.html | 19 +++++++------------ .../src/main/resources/templates/error.html | 19 +++++++------------ 3 files changed, 15 insertions(+), 25 deletions(-) rename samples/featured-authorizationserver/src/main/java/sample/web/{DeviceErrorController.java => DefaultErrorController.java} (95%) diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/DeviceErrorController.java b/samples/featured-authorizationserver/src/main/java/sample/web/DefaultErrorController.java similarity index 95% rename from samples/featured-authorizationserver/src/main/java/sample/web/DeviceErrorController.java rename to samples/featured-authorizationserver/src/main/java/sample/web/DefaultErrorController.java index 7ad9750ee..f4e55c6e9 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/web/DeviceErrorController.java +++ b/samples/featured-authorizationserver/src/main/java/sample/web/DefaultErrorController.java @@ -30,7 +30,7 @@ * @since 1.1 */ @Controller -public class DeviceErrorController implements ErrorController { +public class DefaultErrorController implements ErrorController { @RequestMapping("/error") public ModelAndView handleError(HttpServletRequest request) { diff --git a/samples/featured-authorizationserver/src/main/resources/templates/access-denied.html b/samples/featured-authorizationserver/src/main/resources/templates/access-denied.html index e69a32c8b..c358bc7b4 100644 --- a/samples/featured-authorizationserver/src/main/resources/templates/access-denied.html +++ b/samples/featured-authorizationserver/src/main/resources/templates/access-denied.html @@ -3,23 +3,18 @@ - Device Grant Example + Spring Security Example -
    -
    -
    -
    -

    Access Denied

    -

    You have denied access. Please return to your device to continue.

    -
    -
    - Devices -
    +
    +
    +
    +

    Access Denied

    +

    You have denied access.

    - \ No newline at end of file + diff --git a/samples/featured-authorizationserver/src/main/resources/templates/error.html b/samples/featured-authorizationserver/src/main/resources/templates/error.html index 110886461..7bf6baae2 100644 --- a/samples/featured-authorizationserver/src/main/resources/templates/error.html +++ b/samples/featured-authorizationserver/src/main/resources/templates/error.html @@ -3,23 +3,18 @@ - Device Grant Example + Spring Security Example -
    -
    -
    -
    -

    Error

    -

    -
    -
    - Devices -
    +
    +
    +
    +

    Error

    +

    - \ No newline at end of file + From 23f97e245f4551c1debaf746e3fdb5e6dbb09dbf Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 1 May 2023 11:16:28 -0400 Subject: [PATCH 114/250] Handle web client response error Issue gh-1189 --- .../src/main/java/sample/web/AuthorizationController.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/samples/messages-client/src/main/java/sample/web/AuthorizationController.java b/samples/messages-client/src/main/java/sample/web/AuthorizationController.java index 22b670bd9..c92d78962 100644 --- a/samples/messages-client/src/main/java/sample/web/AuthorizationController.java +++ b/samples/messages-client/src/main/java/sample/web/AuthorizationController.java @@ -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; @@ -99,4 +101,10 @@ public String deviceCodeGrant() { return "device-activate"; } + @ExceptionHandler(WebClientResponseException.class) + public String handleError(Model model, WebClientResponseException ex) { + model.addAttribute("error", ex.getMessage()); + return "index"; + } + } From 89fd7b04784f4347982f4c51edf10383debe9095 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 1 May 2023 11:42:35 -0400 Subject: [PATCH 115/250] Update @since --- .../java/sample/FeaturedAuthorizationServerApplication.java | 2 +- .../src/main/java/sample/config/AuthorizationServerConfig.java | 2 +- .../src/main/java/sample/config/DefaultSecurityConfig.java | 2 +- .../src/main/java/sample/jose/Jwks.java | 2 +- .../src/main/java/sample/jose/KeyGeneratorUtils.java | 2 +- .../security/FederatedIdentityAuthenticationEntryPoint.java | 2 +- .../security/FederatedIdentityAuthenticationSuccessHandler.java | 2 +- .../main/java/sample/security/FederatedIdentityConfigurer.java | 2 +- .../sample/security/FederatedIdentityIdTokenCustomizer.java | 2 +- .../java/sample/security/UserRepositoryOAuth2UserHandler.java | 2 +- .../src/main/java/sample/web/LoginController.java | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java b/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java index 4d59f2cc4..87a4d740b 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java +++ b/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java @@ -20,7 +20,7 @@ /** * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 */ @SpringBootApplication public class FeaturedAuthorizationServerApplication { diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index f8ffd3f62..37295a863 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -61,7 +61,7 @@ * @author Joe Grandja * @author Daniel Garnier-Moiroux * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ @Configuration(proxyBeanMethods = false) public class AuthorizationServerConfig { diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java index 03f179d56..541dbaaa1 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java +++ b/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java @@ -36,7 +36,7 @@ /** * @author Joe Grandja * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ @EnableWebSecurity @Configuration(proxyBeanMethods = false) diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java index 62f581fe4..1f3c71427 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java +++ b/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java @@ -31,7 +31,7 @@ /** * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 */ public final class Jwks { diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java index e55e361a0..ec55abd74 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java +++ b/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java @@ -28,7 +28,7 @@ /** * @author Joe Grandja - * @since 1.1.0 + * @since 1.1 */ final class KeyGeneratorUtils { diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java index ee12a96e5..17e7bb5d7 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java +++ b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java @@ -38,7 +38,7 @@ * {@code registrationId} of the desired {@link ClientRegistration}. * * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ public final class FederatedIdentityAuthenticationEntryPoint implements AuthenticationEntryPoint { diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java index a68b6bead..7eaa49172 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java +++ b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java @@ -34,7 +34,7 @@ * {@link OAuth2User} for Federated Account Linking or JIT Account Provisioning. * * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ public final class FederatedIdentityAuthenticationSuccessHandler implements AuthenticationSuccessHandler { diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java index 8232e9ff2..d64877550 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java +++ b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java @@ -29,7 +29,7 @@ * A configurer for setting up Federated Identity Management. * * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ public final class FederatedIdentityConfigurer extends AbstractHttpConfigurer { diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java index eddfce830..4442e0852 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java +++ b/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java @@ -36,7 +36,7 @@ * the {@code id_token} produced by this authorization server. * * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ public final class FederatedIdentityIdTokenCustomizer implements OAuth2TokenCustomizer { diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java b/samples/featured-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java index bf65977b8..4857c81f5 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java +++ b/samples/featured-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java @@ -25,7 +25,7 @@ * Example {@link Consumer} to perform JIT provisioning of an {@link OAuth2User}. * * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ public final class UserRepositoryOAuth2UserHandler implements Consumer { diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/LoginController.java b/samples/featured-authorizationserver/src/main/java/sample/web/LoginController.java index fb0d4fbdd..df193e059 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/web/LoginController.java +++ b/samples/featured-authorizationserver/src/main/java/sample/web/LoginController.java @@ -20,7 +20,7 @@ /** * @author Steve Riesenberg - * @since 1.1.0 + * @since 1.1 */ @Controller public class LoginController { From 04dbf8dfe878d43b14ee27bc4b46c72387911dd7 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 1 May 2023 11:53:58 -0400 Subject: [PATCH 116/250] Rename featured-authorizationserver to demo-authorizationserver Issue gh-1189 --- .../gradle.properties | 0 .../samples-demo-authorizationserver.gradle} | 0 .../main/java/sample/DemoAuthorizationServerApplication.java} | 4 ++-- .../authentication/DeviceClientAuthenticationProvider.java | 0 .../authentication/DeviceClientAuthenticationToken.java | 0 .../main/java/sample/config/AuthorizationServerConfig.java | 0 .../src/main/java/sample/config/DefaultSecurityConfig.java | 0 .../src/main/java/sample/jose/Jwks.java | 0 .../src/main/java/sample/jose/KeyGeneratorUtils.java | 0 .../security/FederatedIdentityAuthenticationEntryPoint.java | 0 .../FederatedIdentityAuthenticationSuccessHandler.java | 0 .../java/sample/security/FederatedIdentityConfigurer.java | 0 .../sample/security/FederatedIdentityIdTokenCustomizer.java | 0 .../java/sample/security/UserRepositoryOAuth2UserHandler.java | 0 .../main/java/sample/web/AuthorizationConsentController.java | 0 .../src/main/java/sample/web/DefaultErrorController.java | 0 .../src/main/java/sample/web/DeviceController.java | 0 .../src/main/java/sample/web/LoginController.java | 0 .../authentication/DeviceClientAuthenticationConverter.java | 0 .../src/main/resources/application.yml | 0 .../src/main/resources/static/assets/css/style.css | 0 .../src/main/resources/templates/access-denied.html | 0 .../src/main/resources/templates/activate.html | 0 .../src/main/resources/templates/activated.html | 0 .../src/main/resources/templates/consent.html | 0 .../src/main/resources/templates/error.html | 0 .../src/main/resources/templates/login.html | 0 .../java/sample/DemoAuthorizationServerApplicationTests.java} | 2 +- .../java/sample/DemoAuthorizationServerConsentTests.java} | 2 +- 29 files changed, 4 insertions(+), 4 deletions(-) rename samples/{featured-authorizationserver => demo-authorizationserver}/gradle.properties (100%) rename samples/{featured-authorizationserver/samples-featured-authorizationserver.gradle => demo-authorizationserver/samples-demo-authorizationserver.gradle} (100%) rename samples/{featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java => demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java} (87%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/config/AuthorizationServerConfig.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/config/DefaultSecurityConfig.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/jose/Jwks.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/jose/KeyGeneratorUtils.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/security/FederatedIdentityConfigurer.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/web/AuthorizationConsentController.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/web/DefaultErrorController.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/web/DeviceController.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/web/LoginController.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/application.yml (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/static/assets/css/style.css (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/templates/access-denied.html (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/templates/activate.html (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/templates/activated.html (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/templates/consent.html (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/templates/error.html (100%) rename samples/{featured-authorizationserver => demo-authorizationserver}/src/main/resources/templates/login.html (100%) rename samples/{featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java => demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java} (98%) rename samples/{featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java => demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java} (98%) diff --git a/samples/featured-authorizationserver/gradle.properties b/samples/demo-authorizationserver/gradle.properties similarity index 100% rename from samples/featured-authorizationserver/gradle.properties rename to samples/demo-authorizationserver/gradle.properties diff --git a/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle b/samples/demo-authorizationserver/samples-demo-authorizationserver.gradle similarity index 100% rename from samples/featured-authorizationserver/samples-featured-authorizationserver.gradle rename to samples/demo-authorizationserver/samples-demo-authorizationserver.gradle diff --git a/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java b/samples/demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java similarity index 87% rename from samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java rename to samples/demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java index 87a4d740b..88d788b24 100644 --- a/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java +++ b/samples/demo-authorizationserver/src/main/java/sample/DemoAuthorizationServerApplication.java @@ -23,10 +23,10 @@ * @since 1.1 */ @SpringBootApplication -public class FeaturedAuthorizationServerApplication { +public class DemoAuthorizationServerApplication { public static void main(String[] args) { - SpringApplication.run(FeaturedAuthorizationServerApplication.class, args); + SpringApplication.run(DemoAuthorizationServerApplication.class, args); } } diff --git a/samples/featured-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java b/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java rename to samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java b/samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java rename to samples/demo-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java rename to samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java rename to samples/demo-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/demo-authorizationserver/src/main/java/sample/jose/Jwks.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java rename to samples/demo-authorizationserver/src/main/java/sample/jose/Jwks.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/demo-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java rename to samples/demo-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java b/samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java rename to samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationEntryPoint.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java b/samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java rename to samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityAuthenticationSuccessHandler.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java b/samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java rename to samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityConfigurer.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java b/samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java rename to samples/demo-authorizationserver/src/main/java/sample/security/FederatedIdentityIdTokenCustomizer.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java b/samples/demo-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java rename to samples/demo-authorizationserver/src/main/java/sample/security/UserRepositoryOAuth2UserHandler.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java b/samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java rename to samples/demo-authorizationserver/src/main/java/sample/web/AuthorizationConsentController.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/DefaultErrorController.java b/samples/demo-authorizationserver/src/main/java/sample/web/DefaultErrorController.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/web/DefaultErrorController.java rename to samples/demo-authorizationserver/src/main/java/sample/web/DefaultErrorController.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/DeviceController.java b/samples/demo-authorizationserver/src/main/java/sample/web/DeviceController.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/web/DeviceController.java rename to samples/demo-authorizationserver/src/main/java/sample/web/DeviceController.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/LoginController.java b/samples/demo-authorizationserver/src/main/java/sample/web/LoginController.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/web/LoginController.java rename to samples/demo-authorizationserver/src/main/java/sample/web/LoginController.java diff --git a/samples/featured-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java b/samples/demo-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java similarity index 100% rename from samples/featured-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java rename to samples/demo-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java diff --git a/samples/featured-authorizationserver/src/main/resources/application.yml b/samples/demo-authorizationserver/src/main/resources/application.yml similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/application.yml rename to samples/demo-authorizationserver/src/main/resources/application.yml diff --git a/samples/featured-authorizationserver/src/main/resources/static/assets/css/style.css b/samples/demo-authorizationserver/src/main/resources/static/assets/css/style.css similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/static/assets/css/style.css rename to samples/demo-authorizationserver/src/main/resources/static/assets/css/style.css diff --git a/samples/featured-authorizationserver/src/main/resources/templates/access-denied.html b/samples/demo-authorizationserver/src/main/resources/templates/access-denied.html similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/templates/access-denied.html rename to samples/demo-authorizationserver/src/main/resources/templates/access-denied.html diff --git a/samples/featured-authorizationserver/src/main/resources/templates/activate.html b/samples/demo-authorizationserver/src/main/resources/templates/activate.html similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/templates/activate.html rename to samples/demo-authorizationserver/src/main/resources/templates/activate.html diff --git a/samples/featured-authorizationserver/src/main/resources/templates/activated.html b/samples/demo-authorizationserver/src/main/resources/templates/activated.html similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/templates/activated.html rename to samples/demo-authorizationserver/src/main/resources/templates/activated.html diff --git a/samples/featured-authorizationserver/src/main/resources/templates/consent.html b/samples/demo-authorizationserver/src/main/resources/templates/consent.html similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/templates/consent.html rename to samples/demo-authorizationserver/src/main/resources/templates/consent.html diff --git a/samples/featured-authorizationserver/src/main/resources/templates/error.html b/samples/demo-authorizationserver/src/main/resources/templates/error.html similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/templates/error.html rename to samples/demo-authorizationserver/src/main/resources/templates/error.html diff --git a/samples/featured-authorizationserver/src/main/resources/templates/login.html b/samples/demo-authorizationserver/src/main/resources/templates/login.html similarity index 100% rename from samples/featured-authorizationserver/src/main/resources/templates/login.html rename to samples/demo-authorizationserver/src/main/resources/templates/login.html diff --git a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java similarity index 98% rename from samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java rename to samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java index 0d79cd1c4..92a3bc5c1 100644 --- a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java +++ b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerApplicationTests.java @@ -45,7 +45,7 @@ @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc -public class FeaturedAuthorizationServerApplicationTests { +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 diff --git a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java similarity index 98% rename from samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java rename to samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java index 7909863f2..d3a6a3f97 100644 --- a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java +++ b/samples/demo-authorizationserver/src/test/java/sample/DemoAuthorizationServerConsentTests.java @@ -50,7 +50,7 @@ @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc -public class FeaturedAuthorizationServerConsentTests { +public class DemoAuthorizationServerConsentTests { @Autowired private WebClient webClient; From 6a9468a8a0eb9c37fbe3f53568e4e4f262a42bd2 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 1 May 2023 12:00:21 -0400 Subject: [PATCH 117/250] Rename messages-client to demo-client Issue gh-1189 --- samples/{messages-client => demo-client}/gradle.properties | 0 .../samples-demo-client.gradle} | 0 .../src/main/java/sample/DemoClientApplication.java} | 4 ++-- .../src/main/java/sample/config/SecurityConfig.java | 0 .../src/main/java/sample/config/WebClientConfig.java | 0 .../src/main/java/sample/web/AuthorizationController.java | 0 .../src/main/java/sample/web/DefaultController.java | 0 .../src/main/java/sample/web/DeviceController.java | 0 .../DeviceCodeOAuth2AuthorizedClientProvider.java | 0 .../authentication/OAuth2DeviceAccessTokenResponseClient.java | 0 .../sample/web/authentication/OAuth2DeviceGrantRequest.java | 0 .../src/main/resources/application.yml | 0 .../src/main/resources/static/assets/css/style.css | 0 .../src/main/resources/templates/device-activate.html | 0 .../src/main/resources/templates/device-authorize.html | 0 .../src/main/resources/templates/index.html | 0 .../src/main/resources/templates/logged-out.html | 0 17 files changed, 2 insertions(+), 2 deletions(-) rename samples/{messages-client => demo-client}/gradle.properties (100%) rename samples/{messages-client/samples-messages-client.gradle => demo-client/samples-demo-client.gradle} (100%) rename samples/{messages-client/src/main/java/sample/MessagesClientApplication.java => demo-client/src/main/java/sample/DemoClientApplication.java} (89%) rename samples/{messages-client => demo-client}/src/main/java/sample/config/SecurityConfig.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/config/WebClientConfig.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/web/AuthorizationController.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/web/DefaultController.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/web/DeviceController.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java (100%) rename samples/{messages-client => demo-client}/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java (100%) rename samples/{messages-client => demo-client}/src/main/resources/application.yml (100%) rename samples/{messages-client => demo-client}/src/main/resources/static/assets/css/style.css (100%) rename samples/{messages-client => demo-client}/src/main/resources/templates/device-activate.html (100%) rename samples/{messages-client => demo-client}/src/main/resources/templates/device-authorize.html (100%) rename samples/{messages-client => demo-client}/src/main/resources/templates/index.html (100%) rename samples/{messages-client => demo-client}/src/main/resources/templates/logged-out.html (100%) diff --git a/samples/messages-client/gradle.properties b/samples/demo-client/gradle.properties similarity index 100% rename from samples/messages-client/gradle.properties rename to samples/demo-client/gradle.properties diff --git a/samples/messages-client/samples-messages-client.gradle b/samples/demo-client/samples-demo-client.gradle similarity index 100% rename from samples/messages-client/samples-messages-client.gradle rename to samples/demo-client/samples-demo-client.gradle 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/messages-client/src/main/java/sample/config/SecurityConfig.java b/samples/demo-client/src/main/java/sample/config/SecurityConfig.java similarity index 100% rename from samples/messages-client/src/main/java/sample/config/SecurityConfig.java rename to samples/demo-client/src/main/java/sample/config/SecurityConfig.java 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 100% rename from samples/messages-client/src/main/java/sample/config/WebClientConfig.java rename to samples/demo-client/src/main/java/sample/config/WebClientConfig.java 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 100% rename from samples/messages-client/src/main/java/sample/web/AuthorizationController.java rename to samples/demo-client/src/main/java/sample/web/AuthorizationController.java 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 100% rename from samples/messages-client/src/main/java/sample/web/DefaultController.java rename to samples/demo-client/src/main/java/sample/web/DefaultController.java diff --git a/samples/messages-client/src/main/java/sample/web/DeviceController.java b/samples/demo-client/src/main/java/sample/web/DeviceController.java similarity index 100% rename from samples/messages-client/src/main/java/sample/web/DeviceController.java rename to samples/demo-client/src/main/java/sample/web/DeviceController.java diff --git a/samples/messages-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java b/samples/demo-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java similarity index 100% rename from samples/messages-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java rename to samples/demo-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java diff --git a/samples/messages-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java b/samples/demo-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java similarity index 100% rename from samples/messages-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java rename to samples/demo-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java diff --git a/samples/messages-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java b/samples/demo-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java similarity index 100% rename from samples/messages-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java rename to samples/demo-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java diff --git a/samples/messages-client/src/main/resources/application.yml b/samples/demo-client/src/main/resources/application.yml similarity index 100% rename from samples/messages-client/src/main/resources/application.yml rename to samples/demo-client/src/main/resources/application.yml diff --git a/samples/messages-client/src/main/resources/static/assets/css/style.css b/samples/demo-client/src/main/resources/static/assets/css/style.css similarity index 100% rename from samples/messages-client/src/main/resources/static/assets/css/style.css rename to samples/demo-client/src/main/resources/static/assets/css/style.css diff --git a/samples/messages-client/src/main/resources/templates/device-activate.html b/samples/demo-client/src/main/resources/templates/device-activate.html similarity index 100% rename from samples/messages-client/src/main/resources/templates/device-activate.html rename to samples/demo-client/src/main/resources/templates/device-activate.html diff --git a/samples/messages-client/src/main/resources/templates/device-authorize.html b/samples/demo-client/src/main/resources/templates/device-authorize.html similarity index 100% rename from samples/messages-client/src/main/resources/templates/device-authorize.html rename to samples/demo-client/src/main/resources/templates/device-authorize.html diff --git a/samples/messages-client/src/main/resources/templates/index.html b/samples/demo-client/src/main/resources/templates/index.html similarity index 100% rename from samples/messages-client/src/main/resources/templates/index.html rename to samples/demo-client/src/main/resources/templates/index.html diff --git a/samples/messages-client/src/main/resources/templates/logged-out.html b/samples/demo-client/src/main/resources/templates/logged-out.html similarity index 100% rename from samples/messages-client/src/main/resources/templates/logged-out.html rename to samples/demo-client/src/main/resources/templates/logged-out.html From 943acef63392c6af99d9e596f7d04827b22d1592 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 1 May 2023 14:49:24 -0400 Subject: [PATCH 118/250] Update sample README Issue gh-1189 --- samples/README.adoc | 50 +++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 27 deletions(-) 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 From e0340f7b81cf674490aec44e56ec698a295a9a0a Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Mon, 1 May 2023 18:09:50 -0500 Subject: [PATCH 119/250] Add integration tests for device grant Issue gh-1116 --- .../OAuth2DeviceCodeGrantTests.java | 584 ++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java 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..e3ac6ece1 --- /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) + .params(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) + .params(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(); + } + + } + +} From f05592f023fdb72b3f01c31366ed49b5f69d2e33 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 2 May 2023 06:30:11 -0400 Subject: [PATCH 120/250] Update web ui design for demo-client Issue gh-1196 --- .../demo-client/samples-demo-client.gradle | 5 +- .../resources/static/assets/css/style.css | 13 -- .../resources/static/assets/img/devices.png | Bin 0 -> 19071 bytes .../static/assets/img/spring-security.svg | 1 + .../resources/templates/device-activate.html | 45 +++--- .../resources/templates/device-authorize.html | 152 +++++++++--------- .../src/main/resources/templates/index.html | 72 ++------- .../main/resources/templates/logged-out.html | 24 ++- .../resources/templates/page-templates.html | 65 ++++++++ 9 files changed, 196 insertions(+), 181 deletions(-) delete mode 100644 samples/demo-client/src/main/resources/static/assets/css/style.css create mode 100644 samples/demo-client/src/main/resources/static/assets/img/devices.png create mode 100644 samples/demo-client/src/main/resources/static/assets/img/spring-security.svg create mode 100644 samples/demo-client/src/main/resources/templates/page-templates.html diff --git a/samples/demo-client/samples-demo-client.gradle b/samples/demo-client/samples-demo-client.gradle index 1c95c65ff..3c374bdec 100644 --- a/samples/demo-client/samples-demo-client.gradle +++ b/samples/demo-client/samples-demo-client.gradle @@ -21,6 +21,7 @@ dependencies { implementation "org.springframework:spring-webflux" implementation "io.projectreactor.netty:reactor-netty" 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/demo-client/src/main/resources/static/assets/css/style.css b/samples/demo-client/src/main/resources/static/assets/css/style.css deleted file mode 100644 index d50ee00e9..000000000 --- a/samples/demo-client/src/main/resources/static/assets/css/style.css +++ /dev/null @@ -1,13 +0,0 @@ -html, body, .container, .jumbotron { - height: 100%; -} -.jumbotron { - margin-bottom: 0; -} -.gap { - margin-top: 70px; -} -.code { - font-size: 2em; - letter-spacing: 2rem; -} \ No newline at end of file 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 0000000000000000000000000000000000000000..fda6b12e312ff705176efdb8db7f8400ba9fb1d6 GIT binary patch literal 19071 zcmeIacTiK&*C?7qKm-(&rXa;akq%OnE+~l7dolD5At(@<5I_NuW}`^&y;mVX01*^K zdP@vNr4v9pfrNXoeZTw7eRKbKGjHb2d|^n=*=O&y*Is+|wUY>KP1Vy>EL0#6=(O71 z+qxhSBnSio+fh;gE&e4YvA_qJ!!3zY^$YpsdPEJnQN4ECTy0=yS z3&J0 zfYu`?JAfp=W)S@|!GE^>NB?6yrL~=W>|Bj+JHhPSJdUvv6}urKEBt?a^xuan!(HKz z^xUm%?Bs!&ehvALrvKji*F+}&HIbC)_5YmbKc4&lVJLx`uKb$Qu5VDl?&7 zAO5!Xa{my-tlmw-B4B&u?b_pcnOrk?b8Wt$qb!872^#b=zdpHU^X+hwgv%hd9xnWb zxeA7@iz2^8$hsIhbyhLsTChQ}VccjL^dKG<1@jiG!(GEr?YB?7;iJCP9{_n8lSuHi zKCvRzz0-SGlbNlm;Ar-(_6@o&#W6e{yl1PjqWtK+B(&wQbodj{JF z-SP3U?2JM-;p?#GdLBLX%FxlrNl!qK(^M~{Sjb%L5z*_5tPSMowwcUK!G;H`uNT$l z#IZNbcK8XZ*O%=s0Q{1Xq$r(Qw-*0B_OU#Y6FatRm%O$kv)1Ini(xk~iM4MRiDf}8#+vGcJ0~wXoTQ+r zDV-2?u$;~|gQJNPjwpuSk6L7O3vysxRn`Mp46e#|b8eH`#)j6UTgJoF(^H+*mY?ZB z5>xF=Mt9?y0#3KlG_#j~qTYf%;?HNF;@-(70Pl}2dtt7!7@@HCjLQ9Hv;WU+mxbKvURc-JQw zIqe_W&Edz?2dYxtC#*EQ;1U{!pOD3IYTrw@841b#_o_Kn`$grRY9anefGy2EC} zeWB*zxRqoXdI%+Fe^gMN_yyG7W}2M;*WeQ;xv7JNEO@PxKCJr$t?YE& z5q_%igG0*9uF$Y)tfpagb@0UWFyxyrS>uBs(D=+5!)d+sp&T=*{ZDB&#j=`hM}T~` z1Wk2W&Il7W3Y72Z4%Fq4vJM#mE;G(pr8J;Ld6q0ja!tQK_FmhL5k-gWUV5EVADVMAV?*!6r1ck&cE83lLid2JWo^< z{HwBq#{mhe)X}3m9)ApkE(`<~i3-eM`Ulh%06Jc9jqoo*e?XG~ppwp1fXe$lsGW`z6gEIR4EYQ6nFauqRf7D|@0EZc zR+Mz0_X{VTgZ~2U;8p}hMJI*)!(0Tw+-mDY?LXu5Kjioya{Lc>{1W5;5tje=x?@75 z=+rQUsvf@KZals0|?WMudQnT%(SugC<%ixMr z;!L*yJ$Fv{#XZLKfSn0t=Pu&zltTR8PJd7u{dh|N3cd-rYhu_s7=NDbwGhh_*4DRLF7$et11-eQZ_^2kjk7U z=MAqy?_X#vo-Ku)P6&1?<#AE|Z8U;?$QU->*boF5n8L%ushJ>BZkP(IVmYHh(cqoe zg@D55Jc$Jf%~z#FMUoM(bvT|5&#d7r{M2 z*YM{MHXpvtdn!>nkdLj(LM&mdq>w*$=hL~5QPI(i_?$fc7&e(5mm=xxpb?oDAY)vp z1K{f&+b_;wh$NM|StPk#q*odZuRG~lf^kVquI zOCq^rUV*MF>dDPh>{oWC8-v2M<q5t4p(@J-U-S?77^L#W9BOkQaP&Hx#c(c zoN<8M3sqZKQ%p@R4sqo1Qzu|*m)qYscfA*S>Qtdd)=JTq_-j%MaHt}RF@^Q~c}ubi zR_U6Pn2fO!jI_45h%B zy8jE1U~S=}|KQ@;03ZAsz!3F|ZO-F8=Tw_+fCiT2edQr@c%j-EM}H`0?`IUV?WUs~{VQ%1`+GO`8?av>qt5VVw6&KL_ zCcPfhf+J{H$b#CBG+i&qD&;0CD()E>eWQ2ar@Q1v{tKN7RpSUps+yvTbLr$cY5|N{ z6emyOZqBF4gi3hZGmZQlue4cZo!SdK2uC0J?=GW%vby2Bm~Y)@oW^qGpU|)x8tiYNHxUswFPhd)Yin-q_I=(J%VQ`}pcZ=bTZhrp$D* zEL>}TDk4e2@Mqge4$nT>gA#0w(`!Igie3_l5Y}FbI+ki6qGprXoE|lUZ@*Vjo$~qK zcYSdSo_^z@^=Ch&2DME6ZENFCbV_d-IwbgtU$oeMI_JIDe`$9g3-^XM{p|dNYs(yt z^ZUcAiUGkfWUUe|qqeR;%HOTNPCwiQBHiyiVd0yS)A`e?3&m3ww+_8LJ%@YK6{gs3 zxVHvu3m?5Y+sf$|dha(35wBoPveq|HzFhasUMXR9J25jxZh=G5Z&KUx%X=vpnexD) zRg2W7jShOPJ?S_II^L>lrr?i$3a0E}2f=>yS+My!Ixeutpgbe+rk}A_cKpb`rH?!F zk~t@=x9e$nO;ynqKs%&u5|5+hW4+bR{ESGCg=C;d(?iiy!*<%P*7fkc^4CQYQ9?GG zE7$wpHJ3Nf&Fj$F{_)y_E6BgeQyW{Ad*ZytQeSbfqI@n344=+yqIp&_&oJ{?RU>6jr(8o?W-o({dat{Ww-}eV*#6I-XfOW2m55h;%q_ zv40OYjaRiVwdh>hvO925KlPX4o++wAruF#>D!e<&M7`^9AuC@!`ck_ue;%?1qa?HO z)S5R_amAzpY3pL##>L$+YhT0gUmKz5PabkQ$hl4>(^CPeAw3gxR#9tU=jhtPC)4b* z2bf&^SB5{9X$5lYq#{x*L?zL6&`x#B^0`(}D_gn**Kch@mk&BQ@}W?}m5eUB&d9=y@0o4S6reNNL{$VppGEN|-%b+iS`~#W~0M&$b|BLA$ zKukx`e@Y+x7w9uR0BF9X-M^R)48(LN7RZYJYHZ4mYd}n=mt6G^a}W^IfjuDx#|-+b z@f!mW;{Fdg{=X*2#0~1~5+MfOxRm?357ZQ3gMACTLau@uN}R^v<~7|Na{;Evw$Yc* zfo#bQfR)?{mtRW!ufBsdpg({Q?i(_2YdAPWe+F4PSq7$l}YHxH$VgAS~dO_8rSp(Lo~ zjRxXkMoZ4acPb7C5rbzJY5>P_NUoo2b~*8iTp}QD7a)ZU%)xh6v~=#1VXm|?WyqD4 z04W#DuaR%$GIi09=|`XBPduA zkkw!W&B#A7!f+{qToVMA@BH0!2(VHLjHv!UVRcLZP=Z#KF#i=aO8uba|E)_Wx#!P~ z1tGoY2dy84d{$33*16%5CMs=IuS9A_25v8naPjNSelWDpX5uLQAHr;z_zT1b}>2%(&Z^%IDHkQ!qx?(iGS&yg#uubG$hG)*T{lc z*IromWe>C-@YRp}b|xI) z=Jjs40+kam=7&~;gnHkw@sNkf*tVs!l)8#jdrARhr&MwQK5-z=THnPXNe;Vh#IN(By4V{?F6jsY$hHQoU+uC+qD5wA zg3_T-#%PBASH5~~o}L|u*eHUurjz%*``vb20Nb^ik=GD)_OR6sv8aPG)Ys58pYWK> zWap344Pr2VudHV(r~4jMbuhw=9u_NQyh=jIDIBa+uO_BG9aN;$Czd!D%2VgrT(|%H z?lb%Dgh#qD%3cHG_5a^!)UeaVTOUw`~ow3RD+P&vLmrF1*8{yvt!<39O_}92tG&v6bf`&-zY6;4!l; zySTMRF3Cp@U7u4A5A=}(W3I3#U7e5WG*~xyVjNA-BquyoU3EkNSLcbjf$Xlw@3yog zcG0hvOz!5E)qg-|Q>&$73RtpO|@233_*|gX2Nkn;TW>$MwKMBFLZWWod86)e&%{SyLi8!MYtXc{*;uj5SlP(kTbrlhRZ3*7 z+%v{B!S}$h#}p1U@1Ur_rFbFe5?}>prR*`Sp^}AVh-f}!)pJBM4b2%cHbdpjYoRx#sxd^P&Q#Y}f19&up?j?xr&CGE`1@9h9eaBn8>9rpBlw@?r+DsOPq)>tzrk zDlG)1qX>7$Wp8vuT-yD3C6+9J#-idoZyPLa{9niXJkYxavgGND4&4FFW)r;Dulm`jo`V8bp~<; zj-zudkleGi*>}VQ%{1OIUvn{`H#z&q#k^|B*)}6=>jAO+*sP5E=Oao2UL-cCo9~|q zeF2!zOS@;h{v?XPWEj9#bv?mXeEY8nRbm3l4PRx3kMj?|)Vt#oz=WPIIIeg7ZlE*) zeAP#5+ET!M#t4M;ilwnL37Rg@;B5k{rCL_bme@Kz{J zbB>M4ui?m122y$s$UoeBwS7#7Q*`~Mg<)Y~U#skTR?YY z-#foBkZ_`R^&xn2UycOH*avu420|nfSK|^a5Jv=Mjz`t@y)0A29;=|{O-M#A^akdS0PwCrqz9B|;C*Rw ztKTAp5$Ss!JSj0JevG5peZl?eWX_bfF0y??l;V6DS3ccLdi+4+J)atjY33TYIiCKC z^`hU*-OYtHDxGV{!v{XX{v)C+I2pgK0vVt6jDud$i{wrg)OR&;odg z0-;8%mrrt%SrzU3H|#J|WR{^BFT)nNSpNkjEj8pCE7!f-P%Kf2mnl*xy)T_cYovQf zmI}$ENz*X!a=^VTSL)~UA^vh(uZz92kL84Y$GBE$Nz)YGpK=rS<`#0*_KHWVWy2NW z$HXm0V+Sy=AWutu%H_-j7$PUkarIZj)TN;{B3>;f0s>6;?djbpYc5*0$;DMQxsNX$ zG?6F2i7jou@nYGcz3fG=E%Wv+Z3%DOa|z{F1YcLz&rtWads1-HW22AEuIBVKc2(Vu zZ`M|-zaVr|Mj>Ojb?PJ~UM%fVPnU7m*BRp(kMSmWY*n-5=4 zWM^mJq4qO~eQB8J_bDcSsM3>pLextwF>6|i1Qk3bkrxAOs9SIm>pKKk=@eb6JexV!~}%v|cThtXPL5NNv)B=7g}0y_rXZ9VMMv}IJOSv{ep$2Rq- zuvH(Qi9`FzV+>pkN#1hIgZk#)Baze0x@+3ZWPhr85#^x)t$SLn*ZXAk6#O?T9S1JM zEl(;FXutlXE8sl8^B$5>ULVRy94@fy+ny>iC~j)Ag?GHZ)s3i5JH#>Z9R*Z*b^P=m z;qmLxVYdTsY+=q}m4Nne=Ay)T<@;(SLwo$!V+cIdSLOe(4oiw|}cRV>~zYstwl&uN8k7{AEk!OwUawwg0 zMkxB#NYBY(vX!FM31<4!s=!h)R{@PqFHnrP0#oZ}PMzuqA9N%W=?KES7v@Nc0YG(J zq9f@nJ>=DA51VLMeq-;-9`UJDdAfha`;MQa1 zD&qkCcsUb-PcN^U&P0{<}J-Bob&f`MKtZJL~5PBcYZS#^0s>Tm_a1UZ)A00 zYkWNMe*Tb=N%PT~LI82OZFo${qR?0%kNs$dHj-(lyt+#>G|-=p!ey5Sd0ZyT6Ho-D zh%sIdyIkQqQMJ`0+`MI8+;<^VsZGd^c~~)Hh%QM5j8`>g^!GoRr>{Y6nm@O>zl$dJ zr3Toi^f#zW%G6g&WO^Q(?E_#3aAWzcUKaFo zMpdBmgJl*L%-hA$nsa!a0xXF1>=!Fm0DNvG#zc9Wv-G6`^(hSO!soIcMOT(mm1n^c zEVndVjNG$NTL&Kd3&8})WB1YBnet$2$2Yv|e7enZWD8?+UyZ2%$|b%6f!*(^fBttG z!ZQ|t>`!DkK%9dw^v!*uBi$Z{tAsu-IqtxT!mC#dR_uFdB z>%O~0m7O?$|Bhj~d1}?c4OFGID298KCIm6yT5s6~ai}l!2&ZLxbQ6`DrgRBlP;sG* z-Iz%|zzhk!o-3&7Bu}=uj2V|eQ^eoSI04ytn4CVo9bf-#!r3!}7W3eT52z;jO%8Qv zl2yqaue%d+VVf?aC5ys8HkWzKjU?z(N%9U~F78f0p>4bZZ<-N@jIUDF6{MC>1h12= zAl~@V3d(<{;Q-;O1AM$z23gL6P&GEF1{06@FB)usk1_oAy2H|5G0Dx%MYvvrgUlrP z`CD}h88(4KI|fi6v~f$4wHx)VBZ8isHLk#4Btt1f1aE)Z)c{3}B@gEDOxM#Qgr_TAxvNJc&r{zke1p~!Kn3TYWE~vDl$I0TP{qF= zT>KW*(t;z@ht|X+<;SI~sgSG+#6^pq*ci8(G5vw=^ucG3GPT|PM2^*w8j#ZDt{n4g z`a)=x3_1Gxou+yGFrGrupDlOW)D#^^4AB+ZwToWTP?zvNQn|iP4=}{tMnH)#%8h4l z*MF}SlXgd|CQs@0*1gEHs*4X(0I!Z$zDEbHAa^kZQ#U1(@`agP=|kTG90(Pr+v@xR zPrccaXCvJHA{JJ4U6NYb<@Kpxdolri0u-GLq(a_t^&n+r#ekqQWRhKSNHhLeE}c)Q zFB&J9Qr92&lGDjhd}Mw-x3!M&NPmD1ptBCtePYq5!HL=SAJUK#tnX?0DwEW+C?<3Z zPK_$1b)*GKiRPT+@FprG0+P`)(P`2(M?*_1!vVQ)*7cxR&% z^6R4>K`!cGH-UtMSJ20Zz)2wq8yGdhY+P`)<^)iwI7f-d&R>5x_lj#3NqVpF?uZx- z-=lN17qa5~jL(N(BF$kAPbneH9hmE_SK^G(`I0M~G!$4`DlDo+`9& z!p>s2q;c$EISesG0SG2=W^p}z6+>QlQqO_x{Qh#3Om5u+#d<$}W6+AiqaPoU)wM3z z{OZVwcQq+VjF*EH&yj`D2C30-vXquwtc=2(C91baK9RoMF9=~eJY1NnwCR$E@3J=A zUQJjYFPn14hTn65@pRWSzD^}Gx{2hqdjmBafYbBWN5Yh0 zYShGUvoY>s7 zEGXLyjqLI=?mDbZJhqfDNoUKbb3+aAF_f!6@f!FO5HxbB@z7=OM})h087OJHJj7UW4~u2jV5HZZXc!yly}|4xt~PDP1V2 zcTIWo$I5m>`i(GIzWR-PWWWLyhXls-roxe1siLZ6NGVL|yo=r*>xF@2$yp|$2h%_z zzz+UKs$AeDYfr=b@G~eNGMB-2ec#4r*8AXD z^FzNrelj2>gl%3Uu6f@%=r5Kn)#y8IT;Mw!XY^H1OK!HF(R7X|iEh`NJy}tAiOL?~ ze|wj>hMek66n!5K#%1q8*Zhn|L~`tE%Ag9w!h=uL!tdJJZlPn+kM`5c2{UE;jM`@| z-k1*Dd(S~2xX9#f3!68+bH31S;BUYadV~DUY)=A;k>5~1Re|sOC!X}JS;vV6;B-Kb zP(ue}w@PD{W+Z04I-U4g4#<*Cs$E)U1M+Dy^C=!FLUi9i;e>HP8sS#i(yRR7hRZnNESq~2U9=zfk=#N^ZRrj1{z*+Yuka2B~fK;Pld zK3mjEj&R^wFL0&nmeXd zf06F#O_iy)UpQae=n~)@t`wwH$c{N<7@vty&9?XB+IwMr%Xh#je*{*q^c?bZG~bS) z4q!gxTDF(48eBAni|1`zILpYTDuWk8jRp#FUftL7_k3$xHte#toy>cgHT=wb97(1< zA8!YgRf!Mg(HRK$7xpoVN!$tG#xCp@xDvPUb$!{)OaklW^r5B!R{hfY=60Z=L&D+U z6l35>r{nDn@#WZVu`Ef!if#1Xx5(o&tSvrq58KF@n_z)N(W*h_5S}!j_WIhdpUZ=e zYN0`(8yeOypDozD^?_tlcS}J0y*ud2ev0zPgUNT{O>z9f717g$t25yoJDZEqc~W$M z4^U%hx!h4U70oI|DvLIHV8da}hQfwV9#LJOV!gkzVL0H{u|X>bJ6RLmPx(rv?zkGW zdp^a%O0&l>Cg<+`D~Q@ z&RVsIVwzt1(6dA{>^ELlLw@D$;m1@QI;?irO)f0Gb|mV&rFvqjwV&gAm1m0AcF8+h zed!Xa+peYwlkw7wC`YJQ*<*kd*UG`Ui(V_`5_Hh$!;K!2mq95`2gf)rQB_jK@TE2i zVp=PVn8>u55~)fDO;z3pU{Nh^zY)4$QL z-x9!k6fjl>CB4_*6t1L&1@tdJfiCl=Yb|Tj`4YFsh|gjI$9C)%2Xjd$uqR*fVK2p@ z;YWcSDhziSAO>Ob!~8wkzEq;`cesph>Dcj-IlX~ZAud*17OC0ss`K_Q>^j>mH1Iws zl8R^(kiUU8I>7Zk*g-pQ-tKe4>41hkiW1ldrh1Uh=4k4vkQeb0 zn4+;XcISk=4ZAJWk{~NN*LF3A(*;Lf{K%Y$P1{-?e3t&1p-CK4GG(ArYp2f~u;1S} z(syE%gX&2UT+n9ZemU2On%zqM*k@LF)5AwObxjvbl{0m&JoSSW&| z1vwa_gqzltI9^=gP`G{+2}Hd&PTMyZR!ztsL1N6;NvBO6QvI@{qM``UOFap~#f>sK zU%njzRLy{G7`bfM^^Gb=6 z_qhC?0X*QY3)wx6joIar9h6EX!wm*b26sX!O=91qC)_ZmJ-q#kk!`Z9 z5u+QA`dxl;YPayuP_{0B8NspUxBnRs1H zX@8;=)^1UgTsA4jA~77ky2lL;EuXE?BFZK*0 zthb#@xYO0=IhZyYZZ0L`1853KI#qHQBhf6#CC~Gq3>ydt6uYn4vH8Ya;b9{S$#Ww9@ag{mY zaM)it{7#{qGl!lmU@W;)x4DNpomap5?qnf7o`{W%EcRwg>1_1|6!nM#*@j`JWBb@e zY(~B4L%Mm&YBz01m0%+4XN3tthCn(-T&_s7POg$bptU6@54gcUy>O$*`N|c-3w_WK z;jw!q(j_^k%eKeSR&#j=%SnCSG#qiWD9yCx(V$0?>h_>y`BZysj82J!Q($j?e|BS` z0NbV`&yT%%{+?%;jmGG^*VY|P;&R0pp7HfPjqWy3@Au2D<9*|FAvDR^q2BpsTccGdJ6DG3Go9}(t*0@F;f+<2t;LZ zS`s{eq$i;mRKUZ}9aq{%8@hDMLl3+Cpjm8~Db=p5@uOdT=ygH8UVi7J^^TvBeCErf z%G46EsTgpnLCjK)-*QiAcfXsZiGK2Mxb(8g^7D4L+I^ogIe?^2jWOZq&X&r(=*|ub ztfZRZlg{{b`#5a%J6KL4-9kkW8ryYR>Z^$M=lP@0OIcc}F;n>W$X3kB)!4wT>_*un zEshYv^hkI?NR)g=_Sx+2P>FPv#H{bVHBli+?O{`$`a+gRWe-%A4MBVyy}np$!Gt5`*?_`E zIl#1fVYBSEJ}4~@A7V})KH)gbaJw)I(;+fs@VKDo^!$edM;_LbH#%F~%i2-35YUfb z;w|3A0M*r%2PkemhaH0)$p@fVC`!m9+XJ2 zs>hb%kK#)-@RfDw?M5jPn!MB38M zCDWEeWpO{Zs`Op~Jrm&oOP7NjuRE_vxP{>bF+QVMl{}T6{>Jadd2huq1ElVj(i+%@ z<=jW9BS4rYS0wn}1JC@->x6I5XlZWgC^suY-ccb2OC5M?UnP&&3xl32s&A27RJ35D ze>8Ast%mc4D{BRT^)u)^!CX$DVEM8{+GF!YZ(Lg2$iYrpUk~W!2K=Q{qi@vfd576 zO_r2%=%P4Gk=uG;V{rfxHA);3Ap+k*JPZ}?yXjCkDTM4m@_P!ukW;~u)Z;E+;0gq>xBmG4=hd*}bDztmB&C{j zi^mG$&U_uSll>yM`v5I?MYiV6jnTNmHP6TCn&;FA^Xet;a{9(4VC^!DfsOFz!!TC( zp~8f^3RoNEyhNK!K6!~{!2icf)kE+HY4SkJjH4g*LjJU-cdIDIk3926CYujdg2s7V zCx4p#9Hxp4mbzJF{z+1Ru#SV7C8}=!0FLdxZiXv3Ur?aGl!1?s+ens#5#&$|op9_! zB;dEIUT)B|nxUNf1yU#Tv zV_PD)9{x?nIsKq5G4UXK{F&w1DneeDnDO zNx`tXy5?t&yK5H^m#m^MiW0^U8+A+>_zHO&Paaxmd;Z2|L1}39MhB&*?U+kVa(~%_ zz(`mH(lXnM3fa(6Fj7!3>5h4^)M^IZ$UMxP&aV!W&|*06Qo?;oi@D_Vh1#wiu*L3& zgEfT=Z8$4i%+YYP#cHPo$9|(R-0+QO*Q(C35ZjaIgm=^P#^Zv$ge; zr|qdq=C!JWPtpTK9CwL{p)yK+BIRbE&w^ePNv=kCnL1R5FYh$@7Yfp=vm3IBUY)V<=f%2EuI(xFYF{1?K-nY z0KOf&$-(#o-}Vrhx|BG{zxx9xzoLxqb7jq}ez8Tk0+QS}?SGaI2?+^-WYo2>;2iGN z1woq5$P5yL(|x{0U6NZ@O($)kT;B)!mTBMUYl-->G90)>CnK;bJ@FQEN2YXRB>2Mh zv??1`j!JpyBIydO)M{FWtUrDq74X&Pdo0v86&a z=He;B3I4^=hocHq>{PpI7Y7V%tEf>@Ui0xcQkUa$<@k1guJc{9Gqxb**RSTZ)}jRi(XH7T)9k4wGV&x_!=B_?X4*fU8o{n z%yvl#ybXIcV;+6>z@n|)m$Nszpz5dpYtD4w;RoFRt^H$eok?%n1q z76#MLtxFI(orKDI5Qw7V__qN19~Lw|?*w!uMI z8&#bREz+okcFi~?l&IW>k((L;$r<5WaeYz-<~9AdeFqej2^VZZG^d_50%fpyvKpyY z_Wj;usdP9haBUEb-wAHT$xDxMy@^s>fee%lEjSD@R*2-e=i5+w%^h?wsva&eh`qxPQOv*FS+wf7Ni zA)!oro>?m0i-=PH-IlqdyyTl(?Gx){t!@FiLqcTdgy^6fcJHI5CS6%qX~$LkKex0v z&?6PK+YdsQuJoS)N4HLh2Vz`hs4Bep4xNVAp7WP^-cJy!t@h|C6SY1F_0rkTPxBkZ zPc~zx28yxDoZt!#VR1vC#@BnCWY)ZasR<+s=Zn3?dTQ@=2wq9HxON{%`c^P$CWfl^bt_<}9a+e%CHCJ}lLjlyS{Q+F6X9DC8XhGPrr2ITZO*<{DiFdxYU`%{ z>QdgkgoLYt^al{Cf(dNrZ)9wE#YJ!Ub!^LkMTDHXZ-IiZQICgBASu5hu&`LH(sB7h zk(t`n7)|oVKe|R0)CjrT1IOQB2hE#Jel|TnJa83$8ksf1n~5gtEt$YK`QQ80t%?X}|6-w@H-&ZMe)@vjdq&6EZ*v^&tK@ z-(E~6U2ElPKr{i05W~zBubZFm$a^zAZ;_1eA*xb4N4nM*Po2<4ekwdNC|+LfX?${$ zc6_2Gn>5~{)G{p)+HA+g@_3j{<_w$kkNM^0WEi+&cA~_we(t{63AA%)f~R+>CeAY0 z#(8p(cnH)ce%0%~ovUay!qnMo%u!qHwNH{-T7)s5%%v~59Am|;ILNKO0zaz zaH>y!B;+mapQ-4^-Rd6?ZQ+hj?--f;&K|fvbqyuYNs$|=iV~Bj8lML|ocS=#6nCI1 zR;$w{j})-1@%K3*jp2Z7>{2;-!He;4poKL{TU3LW#L`sbe42k?V`oS?Znew?yk2^C z+#7uK?;Mpi{d(8I(hFMZyByU&wl!E!H4O`mWW7|x=fl+K^fQugdgNh=-y+@m`gV*d zQ@(2*YV&(Eq{tvv5@|!J!USoMnAFsI#P(uZ>s!~Iq+{EK0xCk^R8@sfY>u)`>stOh z^lZ)aOlbfR(;H}oWa6VrebeG^3gnOGs1)jrr1bTv3_3^2m)$}6D9sR|B| z2e>T2aPJqSp08?aABrP&L`1lcQt@&G@+qY$nH6*6;Ny^z_8%@2>T|-?M56a3U>{E^ zpF%qGfGZ4|dykI+{3_96AWTWR^^<u~`@VzrWj|47-- z1Yw5A@^QD7E__Qnt(nZ3Q(QUgQg}^k@;^8QDaw#BB)T?7a1`juFgRna#8bT}j4~I+ z!|cxfo+krER%149niJVx@5TX3j#z<=T^kwG-0J7Gw_d{8Ga1Gnm#=}k;{TuD6tUza a1q~^)FZa(e1RnpylG+{3+ht0YA^!_vu?F@4 literal 0 HcmV?d00001 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 index 22a30ad8d..f248d3d34 100644 --- a/samples/demo-client/src/main/resources/templates/device-activate.html +++ b/samples/demo-client/src/main/resources/templates/device-activate.html @@ -1,26 +1,27 @@ - - - - Device Grant Example - - - - -
    -
    -
    -
    -

    Activation Required

    -

    You must activate this device.

    - Activate -
    -
    - Devices -
    -
    -
    + + + + 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 index e378ab9b9..95c3223b7 100644 --- a/samples/demo-client/src/main/resources/templates/device-authorize.html +++ b/samples/demo-client/src/main/resources/templates/device-authorize.html @@ -1,87 +1,87 @@ - - - - Device Grant Example - - - - -
    -
    -
    -
    -

    Device Activation

    -

    Please visit on another device to continue.

    -

    Activation Code

    -
    - - - - -
    -
    -
    - Devices -
    -
    + + + + Spring Authorization Server sample + + + +
    +
    +
    +
    +

    Device Activation

    +

    Please visit on another device to continue.

    +

    Activation Code

    +
    + +
    + +
    - - + + + - + window.addEventListener('load', schedule); + + diff --git a/samples/demo-client/src/main/resources/templates/index.html b/samples/demo-client/src/main/resources/templates/index.html index 0d162b0c8..c0ef10bfe 100644 --- a/samples/demo-client/src/main/resources/templates/index.html +++ b/samples/demo-client/src/main/resources/templates/index.html @@ -1,57 +1,19 @@ - - - Spring Security OAuth 2.0 Sample - - - - - - -
    - -
    -
    - -
    -
    -

    Authorize the client using grant_type:

    -
    - - -
    -
    - - - + + + + + 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 index 3c8ec0a56..a0ee9c530 100644 --- a/samples/demo-client/src/main/resources/templates/logged-out.html +++ b/samples/demo-client/src/main/resources/templates/logged-out.html @@ -1,22 +1,20 @@ - + - Spring Security OAuth 2.0 Sample - - - - + + + Spring Authorization Server sample + -
    - -
    +
    -

    You are now logged out.

    - Go back home +
    +

    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..acfdb0973 --- /dev/null +++ b/samples/demo-client/src/main/resources/templates/page-templates.html @@ -0,0 +1,65 @@ + + + + + + Spring Authorization Server sample + + + + +
    + +
    + + + + + + + + + + + + + + +
    Messages
    #Message
    +
    +
    + + + + + From 2c8ad696005b246cc1d3fdc8d356537f9a9c5763 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 3 May 2023 05:53:17 -0400 Subject: [PATCH 121/250] Polish web ui design for demo-client Issue gh-1196 --- .../main/resources/templates/logged-out.html | 6 ++- .../resources/templates/page-templates.html | 42 ++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/samples/demo-client/src/main/resources/templates/logged-out.html b/samples/demo-client/src/main/resources/templates/logged-out.html index a0ee9c530..585499d43 100644 --- a/samples/demo-client/src/main/resources/templates/logged-out.html +++ b/samples/demo-client/src/main/resources/templates/logged-out.html @@ -9,8 +9,10 @@
    -
    -

    You are now logged out.

    +
    +
    +

    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 index acfdb0973..4e83d9290 100644 --- a/samples/demo-client/src/main/resources/templates/page-templates.html +++ b/samples/demo-client/src/main/resources/templates/page-templates.html @@ -36,26 +36,30 @@
    -