Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create an approved site when using device code flow #821

Open
wants to merge 49 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
058697b
Assign oidc-agent clients to whom approved it (#812)
federicaagostini Jul 25, 2024
19224bd
Fix Sonar issues
federicaagostini Jul 25, 2024
636f7b6
Add tests
federicaagostini Jul 25, 2024
6ee8e23
Restore IamOAuthConfirmationController class
federicaagostini Jul 25, 2024
e7d89e5
Add more tests
federicaagostini Jul 25, 2024
cc52027
Add some test
federicaagostini Jul 25, 2024
46ba6ad
Hopefully increase coverage
federicaagostini Jul 26, 2024
541bff2
Rename test class
federicaagostini Jul 31, 2024
df46a08
Fake commit to trigger Sonar
federicaagostini Jul 31, 2024
46dd366
Fix issues
federicaagostini Jul 31, 2024
100ff9d
Cosmetic fix
federicaagostini Jul 31, 2024
aa04ce1
Add approved sites when using device code flow
federicaagostini Jul 31, 2024
3dd7862
Add test
federicaagostini Jul 31, 2024
bf2b4a2
Remove issues
federicaagostini Jul 31, 2024
9ade9b1
Filter scope before device consent page
rmiccoli Aug 1, 2024
0e1954a
Hopefully fix tests
federicaagostini Aug 2, 2024
18d7c6a
Remove failing test
federicaagostini Aug 2, 2024
7fabb49
WIP: import device code approval phase in IAM
federicaagostini Aug 2, 2024
7781b7a
Change tests expectation
federicaagostini Aug 5, 2024
8a2cfd1
Use same consent logic as authz code flow
federicaagostini Aug 5, 2024
eb9690d
Fix tests
federicaagostini Aug 5, 2024
754cca5
Fix Sonar issues
federicaagostini Aug 6, 2024
e471749
Migrate TofuUserApprovalHandler class into IAM
federicaagostini Aug 6, 2024
ced7bfc
Use OAuth2 parameters
federicaagostini Aug 6, 2024
63aeda9
Fix Sonar issues
federicaagostini Aug 6, 2024
41df699
Improve coverage
federicaagostini Aug 6, 2024
911c1c3
Fix Sonar issues and improve coverage
federicaagostini Aug 7, 2024
732e487
Hopefully fix test
federicaagostini Aug 7, 2024
e333548
Fix Sonar issues
federicaagostini Aug 7, 2024
b4973c1
Cleanup
federicaagostini Aug 7, 2024
1bf90fd
Fix authZ code flow consent
federicaagostini Aug 7, 2024
cab9012
An already approved device code cannot be reused
federicaagostini Aug 7, 2024
e8d8f6b
Hopefully cleanup redundancy checks
federicaagostini Aug 7, 2024
2823adc
Small fixes
federicaagostini Aug 8, 2024
cd4b906
More tests
federicaagostini Aug 8, 2024
0204c94
Fix and add tests
federicaagostini Aug 8, 2024
337428a
Fix Sonar issue
federicaagostini Aug 8, 2024
d37f47f
Move scope filter into dedicated class
federicaagostini Aug 8, 2024
c3c77f1
Fix and add test
federicaagostini Aug 8, 2024
49dccc4
Change scope filter logic
federicaagostini Aug 8, 2024
eaaf0c6
Cleanup
federicaagostini Aug 8, 2024
e405c5d
Fix Sonar issue
federicaagostini Aug 8, 2024
af82375
One more test
federicaagostini Aug 9, 2024
c33c406
Correct sorting method and add tests
federicaagostini Aug 9, 2024
fefa959
Unify methods for showing info in consent page
federicaagostini Aug 12, 2024
b712586
Fix Sonar issues
federicaagostini Aug 12, 2024
f8ecfdd
Remove oidc-agent client linking
federicaagostini Oct 29, 2024
f682797
Rename test class
federicaagostini Oct 29, 2024
3795bd7
Remove unused import
federicaagostini Oct 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import org.mitre.discovery.web.DiscoveryEndpoint;
import org.mitre.oauth2.web.CorsFilter;
import org.mitre.oauth2.web.DeviceEndpoint;
import org.mitre.oauth2.web.OAuthConfirmationController;
import org.mitre.openid.connect.web.DynamicClientRegistrationEndpoint;
import org.mitre.openid.connect.web.JWKSetPublishingEndpoint;
Expand Down Expand Up @@ -77,7 +78,9 @@
@ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE,
value=CorsFilter.class),
@ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE,
value=OAuthConfirmationController.class)
value=OAuthConfirmationController.class),
@ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE,
value=DeviceEndpoint.class)
})
@EnableCaching
@EnableAutoConfiguration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
import org.mitre.openid.connect.service.impl.MatchLoginHintsAgainstUsers;
import org.mitre.openid.connect.service.impl.UUIDPairwiseIdentiferService;
import org.mitre.openid.connect.token.ConnectTokenEnhancer;
import org.mitre.openid.connect.token.TofuUserApprovalHandler;
import org.mitre.openid.connect.web.AuthenticationTimeStamper;
import org.mitre.openid.connect.web.ServerConfigInterceptor;
import org.mitre.uma.service.ResourceSetService;
Expand All @@ -83,6 +82,7 @@
import it.infn.mw.iam.core.client.IAMClientUserDetailsService;
import it.infn.mw.iam.core.jwk.IamJWKSetCacheService;
import it.infn.mw.iam.core.oauth.IamOAuth2RequestFactory;
import it.infn.mw.iam.core.oauth.IamUserApprovalHandler;
import it.infn.mw.iam.core.oauth.profile.JWTProfileResolver;
import it.infn.mw.iam.core.oauth.scope.IamSystemScopeService;
import it.infn.mw.iam.core.oauth.scope.matchers.ScopeMatcherOAuthRequestValidator;
Expand Down Expand Up @@ -129,8 +129,9 @@ public ConfigurationPropertiesBean config(IamProperties properties) {

config.setForceHttps(false);
config.setLocale(Locale.ENGLISH);

config.setAllowCompleteDeviceCodeUri(properties.getDeviceCode().getAllowCompleteVerificationUri());

config
.setAllowCompleteDeviceCodeUri(properties.getDeviceCode().getAllowCompleteVerificationUri());

return config;
}
Expand Down Expand Up @@ -162,10 +163,10 @@ OAuth2RequestValidator requestValidator(ScopeMatcherRegistry registry) {
return new ScopeMatcherOAuthRequestValidator(registry);
}

@Bean
@Bean("iamUserApprovalHandler")
UserApprovalHandler tofuApprovalHandler() {

return new TofuUserApprovalHandler();
return new IamUserApprovalHandler();
}

@Bean
Expand Down Expand Up @@ -203,8 +204,7 @@ public ServerConfigInterceptor serverConfigInterceptor() {
public FilterRegistrationBean<AuthorizationRequestFilter> disabledMitreFilterRegistration(
AuthorizationRequestFilter f) {

FilterRegistrationBean<AuthorizationRequestFilter> b =
new FilterRegistrationBean<>(f);
FilterRegistrationBean<AuthorizationRequestFilter> b = new FilterRegistrationBean<>(f);
b.setEnabled(false);
return b;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
/**
* Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
*
* 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
*
* http://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 it.infn.mw.iam.core.oauth;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpSession;

import org.apache.http.client.utils.URIBuilder;
import org.mitre.oauth2.exception.DeviceCodeCreationException;
import org.mitre.oauth2.model.ClientDetailsEntity;
import org.mitre.oauth2.model.DeviceCode;
import org.mitre.oauth2.model.SystemScope;
import org.mitre.oauth2.service.ClientDetailsEntityService;
import org.mitre.oauth2.service.DeviceCodeService;
import org.mitre.oauth2.service.SystemScopeService;
import org.mitre.oauth2.token.DeviceTokenGranter;
import org.mitre.openid.connect.config.ConfigurationPropertiesBean;
import org.mitre.openid.connect.service.ApprovedSiteService;
import org.mitre.openid.connect.view.HttpCodeView;
import org.mitre.openid.connect.view.JsonEntityView;
import org.mitre.openid.connect.view.JsonErrorView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.exceptions.InvalidClientException;
import org.springframework.security.oauth2.common.util.OAuth2Utils;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.OAuth2RequestFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

import com.google.common.collect.Sets;

import it.infn.mw.iam.api.account.AccountUtils;
import it.infn.mw.iam.api.client.service.ClientService;
import it.infn.mw.iam.api.common.NoSuchAccountError;
import it.infn.mw.iam.core.oauth.scope.pdp.ScopePolicyPDP;
import it.infn.mw.iam.persistence.model.IamAccount;
import static it.infn.mw.iam.core.oauth.IamUserApprovalHandler.OIDC_AGENT_PREFIX_NAME;

@SuppressWarnings("deprecation")
@Controller
public class IamDeviceEndpointController {

public static final String URL = "devicecode";
public static final String USER_URL = "device";

private static final String REQUEST_USER_CODE_STRING = "requestUserCode";
private static final String ERROR_STRING = "error";

public static final Logger logger = LoggerFactory.getLogger(IamDeviceEndpointController.class);

@Autowired
private ClientDetailsEntityService clientEntityService;

@Autowired
private SystemScopeService scopeService;

@Autowired
private ConfigurationPropertiesBean config;

@Autowired
private DeviceCodeService deviceCodeService;

@Autowired
private OAuth2RequestFactory oAuth2RequestFactory;

@Autowired
private AccountUtils accountUtils;

@Autowired
private ClientService clientService;

@Autowired
private ApprovedSiteService approvedSiteService;

@Autowired
private ScopePolicyPDP pdp;

@RequestMapping(value = "/" + URL, method = RequestMethod.POST,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public String requestDeviceCode(@RequestParam("client_id") String clientId,
@RequestParam(name = "scope", required = false) String scope, Map<String, String> parameters,
ModelMap model) {

ClientDetailsEntity client;
try {
client = clientEntityService.loadClientByClientId(clientId);
checkAuthzGrant(client);

} catch (IllegalArgumentException e) {
logger.error("IllegalArgumentException was thrown when attempting to load client", e);
model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
return HttpCodeView.VIEWNAME;
}

Set<String> requestedScopes = OAuth2Utils.parseParameterList(scope);
Set<String> allowedScopes = client.getScope();

if (!scopeService.scopesMatch(allowedScopes, requestedScopes)) {
logger.error("Client asked for {} but is allowed {}", requestedScopes, allowedScopes);
model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
model.put(JsonErrorView.ERROR, "invalid_scope");
return JsonErrorView.VIEWNAME;
}

try {
DeviceCode dc = deviceCodeService.createNewDeviceCode(requestedScopes, client, parameters);

Map<String, Object> response = new HashMap<>();
response.put("device_code", dc.getDeviceCode());
response.put("user_code", dc.getUserCode());
response.put("verification_uri", config.getIssuer() + USER_URL);
if (client.getDeviceCodeValiditySeconds() != null) {
response.put("expires_in", client.getDeviceCodeValiditySeconds());
}

if (config.isAllowCompleteDeviceCodeUri()) {
URI verificationUriComplete = new URIBuilder(config.getIssuer() + USER_URL)
.addParameter("user_code", dc.getUserCode())
.build();

response.put("verification_uri_complete", verificationUriComplete.toString());
}

model.put(JsonEntityView.ENTITY, response);


return JsonEntityView.VIEWNAME;
} catch (DeviceCodeCreationException dcce) {

model.put(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
model.put(JsonErrorView.ERROR, dcce.getError());
model.put(JsonErrorView.ERROR_MESSAGE, dcce.getMessage());

return JsonErrorView.VIEWNAME;
} catch (URISyntaxException use) {
logger
.error("unable to build verification_uri_complete due to wrong syntax of uri components");
model.put(HttpCodeView.CODE, HttpStatus.INTERNAL_SERVER_ERROR);

return HttpCodeView.VIEWNAME;
}

}

@PreAuthorize("hasRole('ROLE_USER')")
@RequestMapping(value = "/" + USER_URL, method = RequestMethod.GET)
public String requestUserCode(
@RequestParam(value = "user_code", required = false) String userCode, ModelMap model,
HttpSession session, Authentication authn) {

if (!config.isAllowCompleteDeviceCodeUri() || userCode == null) {
return REQUEST_USER_CODE_STRING;
} else {

return readUserCode(userCode, model, session, authn);
}
}

@PreAuthorize("hasRole('ROLE_USER')")
@RequestMapping(value = "/" + USER_URL + "/verify", method = RequestMethod.POST)
public String readUserCode(@RequestParam("user_code") String userCode, ModelMap model,
HttpSession session, Authentication authn) {

DeviceCode dc = deviceCodeService.lookUpByUserCode(userCode);

if (dc == null) {
model.addAttribute(ERROR_STRING, "noUserCode");
return REQUEST_USER_CODE_STRING;
}

if (dc.getExpiration() != null && dc.getExpiration().before(new Date())) {
model.addAttribute(ERROR_STRING, "expiredUserCode");
return REQUEST_USER_CODE_STRING;
}

if (dc.isApproved()) {
model.addAttribute(ERROR_STRING, "userCodeAlreadyApproved");
return REQUEST_USER_CODE_STRING;
}

ClientDetailsEntity client = clientEntityService.loadClientByClientId(dc.getClientId());

model.put("client", client);
model.put("dc", dc);

IamAccount account = accountUtils.getAuthenticatedUserAccount(authn)
.orElseThrow(() -> NoSuchAccountError.forUsername(authn.getName()));

sortScopesForApproval(dc, model, account);

AuthorizationRequest authorizationRequest =
oAuth2RequestFactory.createAuthorizationRequest(dc.getRequestParameters());

session.setAttribute("authorizationRequest", authorizationRequest);
session.setAttribute("deviceCode", dc);

return "approveDevice";
}

@PreAuthorize("hasRole('ROLE_USER')")
@RequestMapping(value = "/" + USER_URL + "/approve", method = RequestMethod.POST)
public String approveDevice(@RequestParam("user_code") String userCode,
@RequestParam(value = "user_oauth_approval") Boolean approve, ModelMap model,
Authentication auth, HttpSession session) {

AuthorizationRequest authorizationRequest =
(AuthorizationRequest) session.getAttribute("authorizationRequest");
DeviceCode dc = (DeviceCode) session.getAttribute("deviceCode");

if (!dc.getUserCode().equals(userCode)) {
model.addAttribute(ERROR_STRING, "userCodeMismatch");
return REQUEST_USER_CODE_STRING;
}

if (dc.getExpiration() != null && dc.getExpiration().before(new Date())) {
model.addAttribute(ERROR_STRING, "expiredUserCode");
return REQUEST_USER_CODE_STRING;
}

ClientDetailsEntity client = clientEntityService.loadClientByClientId(dc.getClientId());

model.put("client", client);

if (!approve) {
model.addAttribute("approved", false);
return "deviceApproved";
}

OAuth2Request o2req = oAuth2RequestFactory.createOAuth2Request(authorizationRequest);
OAuth2Authentication o2Auth = new OAuth2Authentication(o2req, auth);

deviceCodeService.approveDeviceCode(dc, o2Auth);

IamAccount account = accountUtils.getAuthenticatedUserAccount(auth)
.orElseThrow(() -> NoSuchAccountError.forUsername(auth.getName()));

sortScopesForApproval(dc, model, account);

model.put("approved", true);

Date timeout = null;
approvedSiteService.createApprovedSite(client.getClientId(), auth.getName(), timeout,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem here is the case where there is a scope policy. Since it is evaluated after this step, in the approved sites we will see also the filtered scope - and in fact we see it on the consent page and actually approve it, even though it is then removed from the scope policy filter in the access token.
In general, this mitre code needs to be improved later, but as a start I think it might be fine like this!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem here is the case where there is a scope policy. Since it is evaluated after this step, in the approved sites we will see also the filtered scope - and in fact we see it on the consent page and actually approve it, even though it is then removed from the scope policy filter in the access token. In general, this mitre code needs to be improved later, but as a start I think it might be fine like this!

Scope policy filter added before the device consent page!

dc.getScope());

if (client.getClientName().startsWith(OIDC_AGENT_PREFIX_NAME)) {
clientService.linkClientToAccount(client, account);
}

return "deviceApproved";
}

private void checkAuthzGrant(ClientDetailsEntity client) {
Collection<String> authorizedGrantTypes = client.getAuthorizedGrantTypes();
if (authorizedGrantTypes != null && !authorizedGrantTypes.isEmpty()
&& !authorizedGrantTypes.contains(DeviceTokenGranter.GRANT_TYPE)) {
throw new InvalidClientException("Unauthorized grant type: " + DeviceTokenGranter.GRANT_TYPE);
}
}

private void sortScopesForApproval(DeviceCode dc, ModelMap model, IamAccount account) {

Set<SystemScope> scopes = scopeService.fromStrings(dc.getScope());

Set<SystemScope> sortedScopes = new LinkedHashSet<>(scopes.size());
Set<SystemScope> systemScopes = scopeService.getAll();

Set<String> filteredScopes = pdp.filterScopes(scopeService.toStrings(scopes), account);

for (SystemScope s : systemScopes) {
if (scopeService.fromStrings(filteredScopes).contains(s)) {
sortedScopes.add(s);
}
}

sortedScopes.addAll(Sets.difference(scopeService.fromStrings(filteredScopes), systemScopes));

model.put("scopes", sortedScopes);

}

}
Loading
Loading