diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCode.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCode.java index 4cda666fff..9ba5881fb3 100644 --- a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCode.java +++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCode.java @@ -21,8 +21,10 @@ import static org.forgerock.openam.utils.CollectionUtils.newList; import static org.forgerock.openam.utils.Time.*; +import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.List; import java.util.Map; import java.util.Set; @@ -115,6 +117,21 @@ public void setResourceOwnerId(String resourceOwnerId) { setStringProperty(OAuth2Constants.CoreTokenParams.USERNAME, resourceOwnerId); } + + + public void setAuthModules(String authModules) { + setStringProperty(AUTH_MODULES, authModules); + } + + + /** + * Get the auth modules string. + * @return list of auth modules. + */ + public String getAuthModules() { + return getStringProperty(AUTH_MODULES); + } + /** * Gets the Client ID parameter. * @return The Client ID. @@ -138,6 +155,14 @@ public String getNonce() { public String getAcrValues() { return getStringProperty(OAuth2Constants.Params.ACR_VALUES); } + + /** + * Sets the ACR Values for device code object. + */ + public void setAcrValues(String acrValues) { + setStringProperty(OAuth2Constants.Params.ACR_VALUES, acrValues); + } + /** * Gets the Code Challenge Method parameter. @@ -338,6 +363,7 @@ public boolean isAuthorized() { return Boolean.valueOf(getStringProperty("AUTHORIZED")); } + /** * {@inheritDoc} */ diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandler.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandler.java index ec67c700ef..a91fafade6 100644 --- a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandler.java +++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandler.java @@ -85,36 +85,36 @@ protected AccessToken handle(OAuth2Request request, ClientRegistration client, String clientId = client.getClientId(); DeviceCode deviceCode = tokenStore.readDeviceCode(clientId, code, request); - + if (deviceCode == null || !clientId.equals(deviceCode.getClientId()) || !request.getParameter(REALM).equals(deviceCode.getRealm())) { throw new AuthorizationDeclinedException(); } + + if (deviceCode.isAuthorized()) { + String grantType = request.getParameter(OAuth2Constants.Params.GRANT_TYPE); + Set scope = deviceCode.getScope(); + String resourceOwnerId = deviceCode.getResourceOwnerId(); + String validatedClaims = providerSettings.validateRequestedClaims( + deviceCode.getStringProperty(OAuth2Constants.Custom.CLAIMS)); + + AccessToken accessToken = generateAccessToken(providerSettings, grantType, clientId, resourceOwnerId, scope, + validatedClaims, deviceCode.getNonce(), request); + + providerSettings.additionalDataToReturnFromTokenEndpoint(accessToken,request); + + tryDeleteDeviceCode(clientId, code, request); // only once the response is complete + + return accessToken; + } - try { - if (deviceCode.isAuthorized()) { - String grantType = request.getParameter(OAuth2Constants.Params.GRANT_TYPE); - Set scope = deviceCode.getScope(); - String resourceOwnerId = deviceCode.getResourceOwnerId(); - String validatedClaims = providerSettings.validateRequestedClaims( - deviceCode.getStringProperty(OAuth2Constants.Custom.CLAIMS)); - return generateAccessToken(providerSettings, grantType, clientId, resourceOwnerId, scope, - validatedClaims, request); - } - - if (deviceCode.getExpiryTime() < currentTimeMillis()) { - throw new ExpiredTokenException(); - } - } finally { - if(deviceCode.isAuthorized() || deviceCode.getExpiryTime() < currentTimeMillis()) { - try { - tokenStore.deleteDeviceCode(clientId, code, request); - } catch (OAuth2Exception e) { - logger.warn("Could not delete issued/expired device code", e); - } - } + + // only reachable when not authorized - the branch above returns + if (deviceCode.getExpiryTime() < currentTimeMillis()) { + throw new ExpiredTokenException(); } + try { final long lastPollTime = deviceCode.getLastPollTime(); @@ -130,9 +130,17 @@ protected AccessToken handle(OAuth2Request request, ClientRegistration client, } private AccessToken generateAccessToken(OAuth2ProviderSettings providerSettings, String grantType, String clientId, - String resourceOwnerId, Set scope, String validatedClaims, OAuth2Request request) + String resourceOwnerId, Set scope, String validatedClaims, String nonce, OAuth2Request request) throws ServerException, NotFoundException { return accessTokenGenerator.generateAccessToken(providerSettings, grantType, clientId, resourceOwnerId, null, - scope, validatedClaims, null, null, request); + scope, validatedClaims, null, nonce, request); + } + + private void tryDeleteDeviceCode(String clientId, String code, OAuth2Request request) { + try { + tokenStore.deleteDeviceCode(clientId, code, request); + } catch (OAuth2Exception e) { + logger.warn("Could not delete issued/expired device code", e); + } } } diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/ResourceOwnerSessionValidator.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/ResourceOwnerSessionValidator.java index 32bffaceeb..d6caa62789 100644 --- a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/ResourceOwnerSessionValidator.java +++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/ResourceOwnerSessionValidator.java @@ -199,7 +199,8 @@ public ResourceOwner validate(OAuth2Request request) throws ResourceOwnerAuthent throw new LoginRequiredException(); } } else if (OAuth2Constants.TokenEndpoint.PASSWORD.equals(request.getParameter(GRANT_TYPE)) - || OAuth2Constants.TokenEndpoint.CLIENT_CREDENTIALS.equals(request.getParameter(GRANT_TYPE))) { + || OAuth2Constants.TokenEndpoint.CLIENT_CREDENTIALS.equals(request.getParameter(GRANT_TYPE)) + || OAuth2Constants.TokenEndpoint.DEVICE_CODE.equals(request.getParameter(GRANT_TYPE))) { // If we're doing password grant type, the SSOToken will have been created and deleted again within // OpenAMResourceOwnerAuthenticator. The request will not have a session, and so the token will have // been null from the attempted creation in L148. diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/DeviceCodeVerificationResource.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/DeviceCodeVerificationResource.java index a8b5789da2..5ccec6cec6 100644 --- a/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/DeviceCodeVerificationResource.java +++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/DeviceCodeVerificationResource.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.Set; +import com.iplanet.sso.SSOException; import com.iplanet.sso.SSOToken; import org.forgerock.oauth2.core.AuthorizationService; import org.forgerock.oauth2.core.ClientRegistration; @@ -71,6 +72,7 @@ import org.restlet.routing.Router; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.sun.identity.authentication.util.ISAuthConstants; /** * A restlet resource for user codes @@ -128,6 +130,7 @@ public DeviceCodeVerificationResource(XUIState xuiState, @Named("OAuth2Router") @Post public Representation verify(Representation body) throws ServerException, NotFoundException, InvalidGrantException, OAuth2RestletException { + final Request restletRequest = getRequest(); OAuth2Request request = requestFactory.create(restletRequest); @@ -168,10 +171,9 @@ public Representation verify(Representation body) throws ServerException, NotFou saveConsent(request); } if (consentGiven) { - ResourceOwner resourceOwner = resourceOwnerSessionValidator.validate(request); - deviceCode.setResourceOwnerId(resourceOwner.getId()); - deviceCode.setAuthorized(true); - tokenStore.updateDeviceCode(deviceCode, request); + + authorizeAndUpdateDeviceCode(deviceCode,request); + } else { tokenStore.deleteDeviceCode(deviceCode.getClientId(), deviceCode.getDeviceCode(), request); } @@ -179,11 +181,10 @@ public Representation verify(Representation body) throws ServerException, NotFou authorizationService.authorize(request); } } else { - ResourceOwner resourceOwner = resourceOwnerSessionValidator.validate(request); - deviceCode.setResourceOwnerId(resourceOwner.getId()); - deviceCode.setAuthorized(true); - tokenStore.updateDeviceCode(deviceCode, request); + + authorizeAndUpdateDeviceCode(deviceCode,request); } + } catch (IllegalArgumentException e) { if (e.getMessage().contains("client_id")) { throw new OAuth2RestletException(400, "invalid_request", e.getMessage(), @@ -300,4 +301,29 @@ private TemplateFactory getTemplateFactory(Context context) { protected void doCatch(Throwable throwable) { exceptionHandler.handle(throwable, getContext(), getRequest(), getResponse()); } + + + private void authorizeAndUpdateDeviceCode(DeviceCode deviceCode, OAuth2Request request) throws OAuth2Exception { + + ResourceOwner resourceOwner = resourceOwnerSessionValidator.validate(request); + deviceCode.setAcrValues(getAuthenticationContextClassReferenceFromRequest(request)); + SSOToken token = resourceOwnerSessionValidator.getResourceOwnerSession(request); + + if (token != null) { + + try { + deviceCode.setAuthModules(token.getProperty(ISAuthConstants.AUTH_TYPE)); + } catch (SSOException e) { + logger.warn("Could not get list of auth modules from authentication", e); + } + } + + deviceCode.setResourceOwnerId(resourceOwner.getId()); + deviceCode.setAuthorized(true); + tokenStore.updateDeviceCode(deviceCode, request); + } + + private String getAuthenticationContextClassReferenceFromRequest(OAuth2Request request) { + return (String) request.getRequest().getAttributes().get(OAuth2Constants.JWTTokenParams.ACR); + } } diff --git a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatefulTokenStore.java b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatefulTokenStore.java index 725cab3a47..153e7a3a82 100644 --- a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatefulTokenStore.java +++ b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatefulTokenStore.java @@ -407,6 +407,8 @@ private List getAMRFromAuthModules(OAuth2Request request, OAuth2Provider String authModules; if (request.getToken(AuthorizationCode.class) != null) { authModules = request.getToken(AuthorizationCode.class).getAuthModules(); + } else if (request.getToken(DeviceCode.class) != null) { + authModules = request.getToken(DeviceCode.class).getAuthModules(); } else if (request.getToken(RefreshToken.class) != null) { authModules = request.getToken(RefreshToken.class).getAuthModules(); } else { @@ -432,6 +434,8 @@ private List getAMRFromAuthModules(OAuth2Request request, OAuth2Provider private String getAuthenticationContextClassReference(OAuth2Request request) { if (request.getToken(AuthorizationCode.class) != null) { return request.getToken(AuthorizationCode.class).getAuthenticationContextClassReference(); + } else if(request.getToken(DeviceCode.class) != null){ + return request.getToken(DeviceCode.class).getAcrValues(); } else if (request.getToken(RefreshToken.class) != null) { return request.getToken(RefreshToken.class).getAuthenticationContextClassReference(); } else { @@ -897,7 +901,7 @@ public DeviceCode createDeviceCode(Set scope, ResourceOwner resourceOwne Integer maxAge, String claims, OAuth2Request request, String codeChallenge, String codeChallengeMethod) throws ServerException, NotFoundException { - logger.message("DefaultOAuthTokenStoreImpl::Creating Authorization code"); + logger.message("DefaultOAuthTokenStoreImpl::Creating Device code"); final OAuth2ProviderSettings providerSettings = providerSettingsFactory.get(request); final String deviceCode = UUID.randomUUID().toString(); diff --git a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatelessTokenStore.java b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatelessTokenStore.java index 7b76e397ed..a2358a4941 100644 --- a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatelessTokenStore.java +++ b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/StatelessTokenStore.java @@ -17,7 +17,6 @@ package org.forgerock.openam.oauth2; -import static com.sun.identity.shared.DateUtils.stringToDate; import static org.forgerock.json.JsonValue.json; import static org.forgerock.openam.oauth2.OAuth2Constants.Bearer.BEARER; import static org.forgerock.openam.oauth2.OAuth2Constants.CoreTokenParams.*; @@ -34,13 +33,11 @@ import jakarta.inject.Named; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; -import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -51,7 +48,6 @@ import com.iplanet.sso.SSOException; import com.iplanet.sso.SSOToken; import com.iplanet.sso.SSOTokenManager; -import com.iplanet.ums.IDynamicMembership; import com.sun.identity.authentication.util.ISAuthConstants; import com.sun.identity.idm.AMIdentity; import com.sun.identity.idm.IdRepoException; @@ -92,6 +88,7 @@ import org.forgerock.openam.cts.api.tokens.Token; import org.forgerock.openam.cts.exceptions.CoreTokenException; import org.forgerock.openam.oauth2.OAuth2Constants.ProofOfPossession; +import org.forgerock.openam.rest.jakarta.servlet.ServletUtils; import org.forgerock.openam.tokens.CoreTokenField; import org.forgerock.openam.utils.RealmNormaliser; import org.forgerock.openam.utils.StringUtils; @@ -100,6 +97,7 @@ import org.forgerock.util.encode.Base64; import org.forgerock.util.query.QueryFilter; import org.joda.time.Duration; +import org.restlet.Request; /** * Stateless implementation of the OAuth2 Token Store. @@ -201,8 +199,10 @@ public AccessToken createAccessToken(String grantType, String accessTokenType, S //realmAccess.put("roles", new HashSet<>(Arrays.asList( new String[] {"admin", "user"} ))); AuthorizationCode authCode = request.getToken(AuthorizationCode.class); + DeviceCode deviceCode = request.getToken(DeviceCode.class); + if (authCode != null) { - String sessionId = authCode.getSessionId(); + String sessionId = authCode.getSessionId(); if (StringUtils.isNotBlank(sessionId)) { try { final SSOTokenManager ssoTokenManager = SSOTokenManager.getInstance(); @@ -219,7 +219,7 @@ public AccessToken createAccessToken(String grantType, String accessTokenType, S String jwtId = UUID.randomUUID().toString(); JwtClaimsSetBuilder claimsSetBuilder = jwtBuilder.claims() - .jti(jwtId) + .jti(jwtId) .exp(newDate(expiryTime.getMillis())) .aud(Collections.singletonList(clientId)) .sub(resourceOwnerId) @@ -238,7 +238,7 @@ public AccessToken createAccessToken(String grantType, String accessTokenType, S .claim(AUDIT_TRACKING_ID, UUID.randomUUID().toString()) .claim(AUTH_GRANT_ID, refreshToken != null ? refreshToken.getAuthGrantId() : UUID.randomUUID().toString()) .claim(AUTH_TIME, authTime); - + // Propagate authentication context (acr) and authentication modules (amr) into the // stateless JWT access token, mirroring the behaviour of createRefreshToken. The values // are sourced from the AuthorizationCode (authorization_code grant) or from the previous @@ -246,18 +246,25 @@ public AccessToken createAccessToken(String grantType, String accessTokenType, S // the access token payload without an extra /oauth2/tokeninfo round-trip. String authModules = null; String acr = null; + if (authCode != null) { authModules = authCode.getAuthModules(); acr = authCode.getAuthenticationContextClassReference(); + } else if (deviceCode != null) { + authModules = deviceCode.getAuthModules(); + acr = deviceCode.getAcrValues(); } + RefreshToken currentRefreshToken = request.getToken(RefreshToken.class); if (currentRefreshToken != null) { authModules = currentRefreshToken.getAuthModules(); acr = currentRefreshToken.getAuthenticationContextClassReference(); } - if (authModules != null) { - claimsSetBuilder.claim(AUTH_MODULES, authModules); + + if (authModules != null){ + claimsSetBuilder.claim(AUTH_MODULES, authModules); } + if (acr != null) { claimsSetBuilder.claim(ACR, acr); } @@ -277,6 +284,7 @@ public AccessToken createAccessToken(String grantType, String accessTokenType, S if (authModules != null) { accessTokenContext.put("amr", authModules); } + Map modifiedClaims = accessTokenModifier.getModifiedClaims(request, realm, resourceOwnerId, clientId, scope, accessTokenContext); for (Map.Entry entry : modifiedClaims.entrySet()) { @@ -534,22 +542,30 @@ public RefreshToken createRefreshToken(String grantType, String clientId, String for(org.forgerock.oauth2.core.Token token : request.getTokens()) { if(token instanceof AuthorizationCode) { claimsSetBuilder.claim(NONCE, ((AuthorizationCode)token).getNonce()); + } else if(token instanceof DeviceCode) { + claimsSetBuilder.claim(NONCE, ((DeviceCode)token).getNonce()); } } + + String authModules = null; String acr = null; AuthorizationCode authorizationCode = request.getToken(AuthorizationCode.class); + DeviceCode deviceCode = request.getToken(DeviceCode.class); if (authorizationCode != null) { authModules = authorizationCode.getAuthModules(); acr = authorizationCode.getAuthenticationContextClassReference(); + } else if (deviceCode != null) { + authModules = deviceCode.getAuthModules(); + acr = deviceCode.getAcrValues(); } - + RefreshToken currentRefreshToken = request.getToken(RefreshToken.class); if (currentRefreshToken != null) { authModules = currentRefreshToken.getAuthModules(); acr = currentRefreshToken.getAuthenticationContextClassReference(); } - + if (authModules != null) { claimsSetBuilder.claim(AUTH_MODULES, authModules); } @@ -559,7 +575,7 @@ public RefreshToken createRefreshToken(String grantType, String clientId, String if (!StringUtils.isBlank(validatedClaims)) { claimsSetBuilder.claim(CLAIMS, validatedClaims); } - + // Run the configured OAuth2 Access Token Modification script (script context // OAUTH2_ACCESS_TOKEN_MODIFICATION) and merge any returned claims into the refresh token, so // that custom claims survive the refresh cycle. @@ -878,4 +894,5 @@ private JsonValue convertToken(StatelessToken token) { map.put(SCOPE, token.getScope()); return json(map); } -} + +} \ No newline at end of file diff --git a/openam-oauth2/src/test/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandlerTest.java b/openam-oauth2/src/test/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandlerTest.java new file mode 100644 index 0000000000..fcf0dd10f0 --- /dev/null +++ b/openam-oauth2/src/test/java/org/forgerock/oauth2/core/DeviceCodeGrantTypeHandlerTest.java @@ -0,0 +1,291 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2014-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. + */ + +package org.forgerock.oauth2.core; + +import static org.assertj.core.api.Assertions.fail; +import static org.forgerock.openam.oauth2.OAuth2Constants.DeviceCode.DEVICE_CODE; +import static org.forgerock.openam.oauth2.OAuth2Constants.Params.REALM; +import static org.forgerock.openam.utils.Time.currentTimeMillis; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.Set; + +import org.forgerock.oauth2.core.exceptions.AuthorizationDeclinedException; +import org.forgerock.oauth2.core.exceptions.AuthorizationPendingException; +import org.forgerock.oauth2.core.exceptions.BadRequestException; +import org.forgerock.oauth2.core.exceptions.ClientAuthenticationFailureFactory; +import org.forgerock.oauth2.core.exceptions.ExpiredTokenException; +import org.forgerock.openam.oauth2.OAuth2Constants; +import org.forgerock.openam.oauth2.OAuth2UrisFactory; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; + +/** + * Tests for DeviceCodeGrantTypeHandler. + */ +public class DeviceCodeGrantTypeHandlerTest { + + private DeviceCodeGrantTypeHandler grantTypeHandler; + + private TokenStore tokenStore; + private OAuth2ProviderSettings providerSettings; + private GrantTypeAccessTokenGenerator accessTokenGenerator; + + @BeforeMethod + public void setUp() { + + tokenStore = mock(TokenStore.class); + ClientRegistrationStore clientRegistrationStore = mock(ClientRegistrationStore.class); + ClientAuthenticationFailureFactory failureFactory = + mock(ClientAuthenticationFailureFactory.class); + OAuth2ProviderSettingsFactory providerSettingsFactory = + mock(OAuth2ProviderSettingsFactory.class); + ClientAuthenticator clientAuthenticator = mock(ClientAuthenticator.class); + OAuth2UrisFactory urisFactory = mock(OAuth2UrisFactory.class); + accessTokenGenerator = mock(GrantTypeAccessTokenGenerator.class); + + grantTypeHandler = new DeviceCodeGrantTypeHandler( + providerSettingsFactory, + clientAuthenticator, + tokenStore, + clientRegistrationStore, + failureFactory, + urisFactory, + accessTokenGenerator); + + providerSettings = mock(OAuth2ProviderSettings.class); + } + + @Test(expectedExceptions = BadRequestException.class) + public void handleShouldThrowBadRequestExceptionWhenDeviceCodeIsMissing() throws Exception { + + // Given + OAuth2Request request = mock(OAuth2Request.class); + ClientRegistration client = mock(ClientRegistration.class); + + given(request.getParameter(DEVICE_CODE)).willReturn(null); + + // When + grantTypeHandler.handle(request, client, providerSettings); + + // Then + // Expect BadRequestException + } + + @Test(expectedExceptions = AuthorizationDeclinedException.class) + public void handleShouldThrowAuthorizationDeclinedExceptionWhenDeviceCodeIsNotFound() + throws Exception { + + // Given + OAuth2Request request = mock(OAuth2Request.class); + ClientRegistration client = mock(ClientRegistration.class); + + given(request.getParameter(DEVICE_CODE)).willReturn("DEVICE_CODE"); + given(client.getClientId()).willReturn("CLIENT_ID"); + given(tokenStore.readDeviceCode("CLIENT_ID", "DEVICE_CODE", request)).willReturn(null); + + // When + grantTypeHandler.handle(request, client, providerSettings); + + // Then + // Expect AuthorizationDeclinedException + } + + @Test + public void shouldGenerateAccessTokenAndDeleteDeviceCodeWhenAuthorized() + throws Exception { + + // Given + OAuth2Request request = mock(OAuth2Request.class); + ClientRegistration client = mock(ClientRegistration.class); + DeviceCode deviceCode = mock(DeviceCode.class); + AccessToken accessToken = mock(AccessToken.class); + + Set scope = Collections.singleton("openid"); + + given(request.getParameter(DEVICE_CODE)).willReturn("DEVICE_CODE"); + given(request.getParameter(REALM)).willReturn("/REALM"); + given(request.getParameter(OAuth2Constants.Params.GRANT_TYPE)) + .willReturn("urn:ietf:params:oauth:grant-type:device_code"); + + given(client.getClientId()).willReturn("CLIENT_ID"); + + given(tokenStore.readDeviceCode("CLIENT_ID", "DEVICE_CODE", request)) + .willReturn(deviceCode); + + given(deviceCode.getClientId()).willReturn("CLIENT_ID"); + given(deviceCode.getRealm()).willReturn("/REALM"); + given(deviceCode.isAuthorized()).willReturn(true); + given(deviceCode.getScope()).willReturn(scope); + given(deviceCode.getResourceOwnerId()).willReturn("RESOURCE_OWNER"); + given(deviceCode.getNonce()).willReturn("NONCE"); + + given(providerSettings.validateRequestedClaims(any())) + .willReturn(null); + + given(accessTokenGenerator.generateAccessToken( + eq(providerSettings), + any(), + eq("CLIENT_ID"), + eq("RESOURCE_OWNER"), + any(), + eq(scope), + any(), + any(), + eq("NONCE"), + eq(request))) + .willReturn(accessToken); + + // When + AccessToken actualAccessToken = + grantTypeHandler.handle(request, client, providerSettings); + + // Then + verify(providerSettings) + .additionalDataToReturnFromTokenEndpoint(accessToken, request); + + verify(tokenStore) + .deleteDeviceCode("CLIENT_ID", "DEVICE_CODE", request); + + assertEquals(actualAccessToken, accessToken); + } + + @Test(expectedExceptions = ExpiredTokenException.class) + public void handleShouldThrowExpiredTokenExceptionWhenDeviceCodeHasExpired() + throws Exception { + + // Given + OAuth2Request request = mock(OAuth2Request.class); + ClientRegistration client = mock(ClientRegistration.class); + DeviceCode deviceCode = mock(DeviceCode.class); + + given(request.getParameter(DEVICE_CODE)).willReturn("DEVICE_CODE"); + given(request.getParameter(REALM)).willReturn("/REALM"); + + given(client.getClientId()).willReturn("CLIENT_ID"); + + given(tokenStore.readDeviceCode("CLIENT_ID", "DEVICE_CODE", request)) + .willReturn(deviceCode); + + given(deviceCode.getClientId()).willReturn("CLIENT_ID"); + given(deviceCode.getRealm()).willReturn("/REALM"); + given(deviceCode.isAuthorized()).willReturn(false); + given(deviceCode.getExpiryTime()).willReturn(currentTimeMillis() - 100); + + // When + grantTypeHandler.handle(request, client, providerSettings); + + // Then + // Expect ExpiredTokenException + } + + @Test + public void shouldUpdateDeviceCodeAfterAuthorizationPending() + throws Exception { + + // Given + OAuth2Request request = mock(OAuth2Request.class); + ClientRegistration client = mock(ClientRegistration.class); + DeviceCode deviceCode = mock(DeviceCode.class); + + given(request.getParameter(DEVICE_CODE)).willReturn("DEVICE_CODE"); + given(request.getParameter(REALM)).willReturn("/REALM"); + + given(client.getClientId()).willReturn("CLIENT_ID"); + + given(tokenStore.readDeviceCode("CLIENT_ID", "DEVICE_CODE", request)) + .willReturn(deviceCode); + + given(deviceCode.getClientId()).willReturn("CLIENT_ID"); + given(deviceCode.getRealm()).willReturn("/REALM"); + given(deviceCode.isAuthorized()).willReturn(false); + given(deviceCode.getExpiryTime()).willReturn(currentTimeMillis() + 10000); + given(deviceCode.getLastPollTime()).willReturn(0L); + + given(providerSettings.getDeviceCodePollInterval()).willReturn(5); + + try { + // When + grantTypeHandler.handle(request, client, providerSettings); + } catch (AuthorizationPendingException e) { + // Then + verify(deviceCode).poll(); + verify(tokenStore).updateDeviceCode(deviceCode, request); + } + } + + @Test + public void shouldNotDeleteDeviceCodeWhenAccessTokenGenerationFails() + throws Exception { + + // Given + OAuth2Request request = mock(OAuth2Request.class); + ClientRegistration client = mock(ClientRegistration.class); + DeviceCode deviceCode = mock(DeviceCode.class); + + Set scope = Collections.singleton("openid"); + + given(request.getParameter(DEVICE_CODE)).willReturn("DEVICE_CODE"); + given(request.getParameter(REALM)).willReturn("/REALM"); + given(request.getParameter(OAuth2Constants.Params.GRANT_TYPE)) + .willReturn("urn:ietf:params:oauth:grant-type:device_code"); + + given(client.getClientId()).willReturn("CLIENT_ID"); + + given(tokenStore.readDeviceCode("CLIENT_ID", "DEVICE_CODE", request)) + .willReturn(deviceCode); + + given(deviceCode.getClientId()).willReturn("CLIENT_ID"); + given(deviceCode.getRealm()).willReturn("/REALM"); + given(deviceCode.isAuthorized()).willReturn(true); + given(deviceCode.getScope()).willReturn(scope); + given(deviceCode.getResourceOwnerId()).willReturn("RESOURCE_OWNER"); + + given(providerSettings.validateRequestedClaims(any())) + .willReturn(null); + + given(accessTokenGenerator.generateAccessToken( + eq(providerSettings), + any(), + eq("CLIENT_ID"), + eq("RESOURCE_OWNER"), + any(), + eq(scope), + any(), + any(), + any(), + eq(request))) + .willThrow(new RuntimeException("Access token generation failed")); + + // When + try { + grantTypeHandler.handle(request, client, providerSettings); + fail("Expected access token generation to fail"); + } catch (RuntimeException e) { + // Then + verify(tokenStore, never()) + .deleteDeviceCode("CLIENT_ID", "DEVICE_CODE", request); + } + } +} \ No newline at end of file