mirror of
https://github.com/ysoftdevs/oauth-playground-server.git
synced 2026-03-22 17:19:49 +01:00
error handling split among:
1. user visible exception (directly shown to users as html) 2. redirect exception (pass back to redirect uri) 3. api exception (json)
This commit is contained in:
@@ -2,7 +2,9 @@ package com.ysoft.geecon;
|
||||
|
||||
import com.ysoft.geecon.dto.*;
|
||||
import com.ysoft.geecon.error.ErrorResponse;
|
||||
import com.ysoft.geecon.error.OAuthException;
|
||||
import com.ysoft.geecon.error.OAuthApiException;
|
||||
import com.ysoft.geecon.error.OAuthRedirectException;
|
||||
import com.ysoft.geecon.error.OAuthUserVisibleException;
|
||||
import com.ysoft.geecon.repo.ClientsRepo;
|
||||
import com.ysoft.geecon.repo.SessionsRepo;
|
||||
import com.ysoft.geecon.repo.UsersRepo;
|
||||
@@ -30,7 +32,8 @@ public class OAuthResource {
|
||||
@FormParam("password") String password) {
|
||||
|
||||
|
||||
sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException(ErrorResponse.Error.access_denied, "Invalid session"));
|
||||
sessionsRepo.getSession(sessionId).orElseThrow(
|
||||
() -> new OAuthUserVisibleException(ErrorResponse.Error.access_denied, "Invalid session"));
|
||||
var user = validateUser(username, password);
|
||||
if (user == null) {
|
||||
return Templates.login(username, sessionId, "invalid_credentials");
|
||||
@@ -65,7 +68,7 @@ public class OAuthResource {
|
||||
@FormParam("sessionId") String sessionId,
|
||||
@FormParam("scope") List<String> scopes) {
|
||||
|
||||
sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException(ErrorResponse.Error.access_denied, "Invalid session"));
|
||||
sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthUserVisibleException(ErrorResponse.Error.access_denied, "Invalid session"));
|
||||
var session = sessionsRepo.authorizeSession(sessionId, scopes);
|
||||
|
||||
String redirectUri = session.params().getRedirectUri();
|
||||
@@ -120,7 +123,7 @@ public class OAuthResource {
|
||||
return switch (params.getGrantType()) {
|
||||
case "authorization_code" -> redeemAuthorizationCode(params);
|
||||
case "urn:ietf:params:oauth:grant-type:device_code" -> redeemDeviceCode(params);
|
||||
default -> throw new OAuthException(ErrorResponse.Error.invalid_request, "Unsupported grant type");
|
||||
default -> throw new OAuthApiException(ErrorResponse.Error.invalid_request, "Unsupported grant type");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,23 +151,23 @@ public class OAuthResource {
|
||||
|
||||
private AccessTokenResponse redeemAuthorizationCode(TokenParams params) {
|
||||
var session = sessionsRepo.redeemAuthorizationCode(params.getCode())
|
||||
.orElseThrow(() -> new OAuthException(ErrorResponse.Error.access_denied, "Invalid code"));
|
||||
.orElseThrow(() -> new OAuthApiException(ErrorResponse.Error.access_denied, "Invalid code"));
|
||||
validateClient(params, session);
|
||||
if (!session.validateCodeChallenge(params.getCodeVerifier())) {
|
||||
throw new OAuthException(ErrorResponse.Error.access_denied, "Invalid code verifier");
|
||||
throw new OAuthApiException(ErrorResponse.Error.access_denied, "Invalid code verifier");
|
||||
}
|
||||
return session.tokens();
|
||||
}
|
||||
|
||||
private AccessTokenResponse redeemDeviceCode(TokenParams params) {
|
||||
var session = sessionsRepo.getByAuthorizationCode(params.getDeviceCode())
|
||||
.orElseThrow(() -> new OAuthException(ErrorResponse.Error.access_denied, "Invalid device code"));
|
||||
.orElseThrow(() -> new OAuthApiException(ErrorResponse.Error.access_denied, "Invalid device code"));
|
||||
validateClient(params, session);
|
||||
if (session.tokens() != null) {
|
||||
sessionsRepo.redeemAuthorizationCode(params.getDeviceCode());
|
||||
return session.tokens();
|
||||
} else {
|
||||
throw new OAuthException(ErrorResponse.Error.authorization_pending, "Authorization pending");
|
||||
throw new OAuthApiException(ErrorResponse.Error.authorization_pending, "Authorization pending");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,24 +179,30 @@ public class OAuthResource {
|
||||
|
||||
private OAuthClient validateClient(AuthParams params) {
|
||||
var client = clientsRepo.getClient(params.getClientId())
|
||||
.orElseThrow(() -> new OAuthException(ErrorResponse.Error.invalid_request, "Not a valid client"));
|
||||
// must NOT redirect to not validated client
|
||||
.orElseThrow(() -> new OAuthUserVisibleException(ErrorResponse.Error.invalid_request, "Not a valid client"));
|
||||
if (!client.validateRedirectUri(params.getRedirectUri())) {
|
||||
throw new OAuthException(ErrorResponse.Error.invalid_request, "Invalid redirect URI");
|
||||
// must NOT redirect to invalid redirect URI
|
||||
throw new OAuthUserVisibleException(ErrorResponse.Error.invalid_request, "Invalid redirect URI");
|
||||
}
|
||||
if (StringUtil.isNullOrEmpty(params.getState())) {
|
||||
throw new OAuthException(ErrorResponse.Error.invalid_request, "Invalid state");
|
||||
throw new OAuthRedirectException(params, ErrorResponse.Error.invalid_request, "Missing state");
|
||||
}
|
||||
if (!params.validateResponseType()) {
|
||||
throw new OAuthRedirectException(params, ErrorResponse.Error.unsupported_response_type,
|
||||
"Unsupported response type");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private OAuthClient validateClient(TokenParams params, AuthorizationSession session) {
|
||||
var client = clientsRepo.getClient(params.getClientId())
|
||||
.orElseThrow(() -> new OAuthException(ErrorResponse.Error.invalid_request, "Not a valid client"));
|
||||
.orElseThrow(() -> new OAuthApiException(ErrorResponse.Error.invalid_request, "Not a valid client"));
|
||||
if (!session.validateRedirectUri(params.getRedirectUri())) {
|
||||
throw new OAuthException(ErrorResponse.Error.invalid_request, "Invalid redirect URI");
|
||||
throw new OAuthApiException(ErrorResponse.Error.invalid_request, "Invalid redirect URI");
|
||||
}
|
||||
if (!client.validateSecret(params.getClientSecret())) {
|
||||
throw new OAuthException(ErrorResponse.Error.unauthorized_client, "Invalid secret");
|
||||
throw new OAuthApiException(ErrorResponse.Error.unauthorized_client, "Invalid secret");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ public class AuthParams {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public boolean validateResponseType() {
|
||||
try {
|
||||
return !getResponseTypes().isEmpty();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@RestQuery("login_hint")
|
||||
String loginHint;
|
||||
@RestQuery("response_type")
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
public record ErrorResponse(@JsonProperty("error") Error error,
|
||||
@JsonProperty("error_description") String description) {
|
||||
public enum Error {
|
||||
invalid_request, unauthorized_client, access_denied, invalid_scope, server_error, temporarily_unavailable, authorization_pending
|
||||
invalid_request, unauthorized_client, unsupported_response_type,
|
||||
access_denied, invalid_scope, server_error, temporarily_unavailable, authorization_pending
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,15 @@ package com.ysoft.geecon.error;
|
||||
import io.quarkus.qute.CheckedTemplate;
|
||||
import io.quarkus.qute.TemplateInstance;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.container.ResourceInfo;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@ApplicationScoped
|
||||
class ExceptionMappers {
|
||||
@Inject
|
||||
ResourceInfo resourceInfo;
|
||||
|
||||
@ServerExceptionMapper
|
||||
public Response exception(OAuthException exception) {
|
||||
Object entity = producesJson() ? exception.getResponse() : Templates.error(exception.getResponse());
|
||||
return Response.status(Response.Status.BAD_REQUEST).entity(entity).build();
|
||||
}
|
||||
|
||||
private boolean producesJson() {
|
||||
Produces annotation = resourceInfo.getResourceMethod().getAnnotation(Produces.class);
|
||||
if (annotation == null) {
|
||||
return false;
|
||||
}
|
||||
String[] produces = annotation.value();
|
||||
return Arrays.asList(produces).contains(MediaType.APPLICATION_JSON);
|
||||
return exception.getResponse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
20
src/main/java/com/ysoft/geecon/error/OAuthApiException.java
Normal file
20
src/main/java/com/ysoft/geecon/error/OAuthApiException.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.ysoft.geecon.error;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
public class OAuthApiException extends OAuthException {
|
||||
|
||||
public OAuthApiException(ErrorResponse response) {
|
||||
super("OAuth error: " + response.error() + " " + response.description(), response);
|
||||
}
|
||||
|
||||
public OAuthApiException(ErrorResponse.Error error, String description) {
|
||||
this(new ErrorResponse(error, description));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response getResponse() {
|
||||
return Response.status(Response.Status.BAD_REQUEST).entity(response).build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
package com.ysoft.geecon.error;
|
||||
|
||||
public class OAuthException extends RuntimeException {
|
||||
private final ErrorResponse response;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
public OAuthException(ErrorResponse response) {
|
||||
super("OAuth error: " + response.error() + " " + response.description());
|
||||
public abstract class OAuthException extends RuntimeException {
|
||||
protected final ErrorResponse response;
|
||||
|
||||
public OAuthException(String message, ErrorResponse response) {
|
||||
super(message);
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public OAuthException(ErrorResponse.Error error, String description) {
|
||||
this(new ErrorResponse(error, description));
|
||||
}
|
||||
|
||||
public ErrorResponse getResponse() {
|
||||
return response;
|
||||
}
|
||||
|
||||
public abstract Response getResponse();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ysoft.geecon.error;
|
||||
|
||||
import com.ysoft.geecon.dto.AuthParams;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
|
||||
public class OAuthRedirectException extends OAuthApiException {
|
||||
private final AuthParams authParams;
|
||||
|
||||
public OAuthRedirectException(AuthParams authParams, ErrorResponse.Error error, String description) {
|
||||
super(error, description);
|
||||
this.authParams = authParams;
|
||||
}
|
||||
|
||||
public AuthParams getAuthParams() {
|
||||
return authParams;
|
||||
}
|
||||
|
||||
public Response getResponse() {
|
||||
UriBuilder uri = UriBuilder.fromUri(authParams.getRedirectUri())
|
||||
.fragment("")
|
||||
.queryParam("state", authParams.getState())
|
||||
.queryParam("error", response.error())
|
||||
.queryParam("error_description", response.description());
|
||||
return Response.seeOther(uri.build()).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ysoft.geecon.error;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
public class OAuthUserVisibleException extends OAuthApiException {
|
||||
|
||||
public OAuthUserVisibleException(ErrorResponse.Error error, String description) {
|
||||
super(error, description);
|
||||
}
|
||||
|
||||
public Response getResponse() {
|
||||
Object entity = ExceptionMappers.Templates.error(response);
|
||||
return Response.status(Response.Status.BAD_REQUEST).entity(entity).build();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.ysoft.geecon;
|
||||
|
||||
import com.ysoft.geecon.dto.OAuthClient;
|
||||
import com.ysoft.geecon.dto.User;
|
||||
import com.ysoft.geecon.error.ErrorResponse;
|
||||
import com.ysoft.geecon.helpers.AuthorizationCodeFlow;
|
||||
import com.ysoft.geecon.helpers.ConsentScreen;
|
||||
import com.ysoft.geecon.helpers.LoginScreen;
|
||||
@@ -10,6 +11,7 @@ import com.ysoft.geecon.repo.UsersRepo;
|
||||
import io.quarkus.test.common.http.TestHTTPResource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import org.jsoup.Connection;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -56,6 +58,14 @@ public class AuthCodeGrantTest {
|
||||
assertThat(flow.getAccessToken(), is(notNullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authCodeGrant_invalidResponseType() throws IOException {
|
||||
AuthorizationCodeFlow flow = new AuthorizationCodeFlow(authUrl, CLIENT);
|
||||
Connection.Response response = flow.startExpectError(Map.of("response_type", ""));
|
||||
Map<String, String> query = flow.parseAndValidateRedirectError(response);
|
||||
assertThat(query.get("error"), is(ErrorResponse.Error.unsupported_response_type.name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void implicitGrant() throws IOException {
|
||||
AuthorizationCodeFlow flow = new AuthorizationCodeFlow(authUrl, CLIENT);
|
||||
|
||||
@@ -49,6 +49,20 @@ public class AuthorizationCodeFlow {
|
||||
return new LoginScreen(login);
|
||||
}
|
||||
|
||||
public Connection.Response startExpectError(Map<String, String> additionalData) throws IOException {
|
||||
var data = defaultQuery();
|
||||
if (additionalData != null) {
|
||||
data.putAll(additionalData);
|
||||
}
|
||||
|
||||
return Jsoup.connect(authUrl)
|
||||
.followRedirects(false)
|
||||
.data(data)
|
||||
.get()
|
||||
.connection()
|
||||
.response();
|
||||
}
|
||||
|
||||
private Map<String, String> defaultQuery() {
|
||||
var map = new HashMap<String, String>();
|
||||
map.put("client_id", client.clientId());
|
||||
@@ -76,6 +90,20 @@ public class AuthorizationCodeFlow {
|
||||
idToken = query.get("id_token");
|
||||
}
|
||||
|
||||
public Map<String, String> parseAndValidateRedirectError(Connection.Response response) {
|
||||
assertThat(response.statusCode(), is(303));
|
||||
assertThat(response.header("location"), startsWith(client.redirectUri()));
|
||||
|
||||
URI location = URI.create(Objects.requireNonNull(response.header("location")));
|
||||
Map<String, String> query = URLEncodedUtils.parse(location.getQuery(), Charset.defaultCharset())
|
||||
.stream().collect(Collectors.toMap(NameValuePair::getName, NameValuePair::getValue));
|
||||
|
||||
assertThat(query.get("state"), is(state));
|
||||
assertThat(query.get("error"), is(notNullValue()));
|
||||
assertThat(query.get("error_description"), is(notNullValue()));
|
||||
return query;
|
||||
}
|
||||
|
||||
public AccessTokenResponse exchangeCode() {
|
||||
Map<String, String> tokenForm = new HashMap<>();
|
||||
tokenForm.put("grant_type", "authorization_code");
|
||||
|
||||
Reference in New Issue
Block a user