diff --git a/src/main/java/com/ysoft/geecon/OAuthResource.java b/src/main/java/com/ysoft/geecon/OAuthResource.java index 6695cd5..71a8e2d 100644 --- a/src/main/java/com/ysoft/geecon/OAuthResource.java +++ b/src/main/java/com/ysoft/geecon/OAuthResource.java @@ -14,17 +14,31 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; +import org.apache.commons.lang3.StringUtils; import java.util.List; @Path("/auth") public class OAuthResource { - @CheckedTemplate - public static class Templates { - public static native TemplateInstance login(String loginHint, String sessionId, String error); + @POST + @Produces(MediaType.TEXT_HTML) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Object post(AuthParams params, + @FormParam("sessionId") String sessionId, + @FormParam("username") String username, + @FormParam("password") String password, + @FormParam("scope") List scopes) { - public static native TemplateInstance consents(User user, OAuthClient client, List scopes, String sessionId, String error); + + sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException("Invalid session")); + var user = validateUser(username, password); + if (user == null) { + return Templates.login(username, sessionId, "invalid_credentials"); + } else { + AuthorizationSession session = sessionsRepo.assignUser(sessionId, user); + return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, ""); + } } @Inject @@ -43,49 +57,56 @@ public class OAuthResource { } @POST + @Path("consent") @Produces(MediaType.TEXT_HTML) @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Object post(AuthParams params, - @FormParam("sessionId") String sessionId, - @FormParam("username") String username, - @FormParam("password") String password, - @FormParam("scope") List scopes) { + public Response postConsent( + @FormParam("sessionId") String sessionId, + @FormParam("scope") List scopes) { + sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException("Invalid session")); + var session = sessionsRepo.authorizeSession(sessionId, scopes); - var session = sessionsRepo.getSession(sessionId).orElseThrow(() -> new OAuthException("Invalid session")); - if (session.user() == null) { - var user = validateUser(username, password); - if (user == null) { - return Templates.login(username, sessionId, "invalid_credentials"); - } else { - session = sessionsRepo.assignUser(sessionId, user); + String redirectUri = session.params().getRedirectUri(); + if (StringUtils.isNotBlank(redirectUri)) { + var responseTypes = session.params().getResponseTypes(); + + UriBuilder uri = UriBuilder.fromUri(redirectUri) + .fragment("") + .queryParam("state", session.params().getState()); + + if (responseTypes.contains(AuthParams.ResponseType.code)) { + uri.queryParam("code", sessionsRepo.generateAuthorizationCode(sessionId)); } - } - - if (session.acceptedScopes() == null) { - if (scopes == null || scopes.isEmpty()) { - return Templates.consents(session.user(), session.client(), session.params().getScopes(), sessionId, ""); + if (responseTypes.contains(AuthParams.ResponseType.token)) { + uri.queryParam("access_token", session.tokens().accessToken()); } + if (responseTypes.contains(AuthParams.ResponseType.id_token)) { + uri.queryParam("id_token", session.tokens().idToken()); + } + return Response.seeOther(uri.build()).build(); + } else { + return Response.ok(Templates.loginSuccess()).build(); } + } - session = sessionsRepo.authorizeSession(sessionId, scopes); + @POST + @Path("/device") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public DeviceResponse device(DeviceParams params) { + var client = validateClient(params); + AuthParams authParams = new AuthParams(); + authParams.setClientId(params.getClientId()); + String sessionId = sessionsRepo.newAuthorizationSession(authParams, client); - var responseTypes = params.getResponseTypes(); - - UriBuilder uri = UriBuilder.fromUri(params.getRedirectUri()) - .fragment("") - .queryParam("state", params.getState()); - - if (responseTypes.contains(AuthParams.ResponseType.code)) { - uri.queryParam("code", sessionsRepo.generateAuthorizationCode(sessionId)); - } - if (responseTypes.contains(AuthParams.ResponseType.token)) { - uri.queryParam("access_token", session.tokens().accessToken()); - } - if (responseTypes.contains(AuthParams.ResponseType.id_token)) { - uri.queryParam("id_token", session.tokens().idToken()); - } - return Response.seeOther(uri.build()).build(); + return new DeviceResponse( + sessionsRepo.generateAuthorizationCode(sessionId), + sessionsRepo.generateUserCode(sessionId), + "http://verificationuri/device-login", + 10, + 180 + ); } @POST @@ -99,6 +120,28 @@ public class OAuthResource { }; } + @GET + @Path("/device-login") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance enterDeviceCode() { + return Templates.deviceLogin(""); + } + + @POST + @Path("/device-login") + @Produces(MediaType.TEXT_HTML) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response postDeviceCode(@FormParam("code") String code) { + return sessionsRepo.redeemUserCode(code) + .map(session -> Response.ok(Templates.login(session.params().getLoginHint(), session.sessionId(), ""))) + .orElse(Response.status(404).entity(Templates.deviceLogin("invalid_code"))).build(); + } + + private OAuthClient validateClient(DeviceParams params) { + return clientsRepo.getClient(params.getClientId()) + .orElseThrow(() -> new RuntimeException("Not a valid client")); + } + private AccessTokenResponse redeemAuthorizationCode(TokenParams params) { validateClient(params); var session = sessionsRepo.redeemAuthorizationCode(params.getCode()) @@ -147,6 +190,17 @@ public class OAuthResource { } return client; } + + @CheckedTemplate + public static class Templates { + public static native TemplateInstance login(String loginHint, String sessionId, String error); + + public static native TemplateInstance loginSuccess(); + + public static native TemplateInstance consents(User user, OAuthClient client, List scopes, String sessionId, String error); + + public static native TemplateInstance deviceLogin(String error); + } } diff --git a/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java b/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java index d9a28fa..ec72349 100644 --- a/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java +++ b/src/main/java/com/ysoft/geecon/dto/AuthorizationSession.java @@ -4,21 +4,22 @@ import com.ysoft.geecon.repo.SecureRandomStrings; import java.util.List; -public record AuthorizationSession(AuthParams params, +public record AuthorizationSession(String sessionId, + AuthParams params, OAuthClient client, User user, List acceptedScopes, AccessTokenResponse tokens) { public AuthorizationSession(AuthParams params, OAuthClient client) { - this(params, client, null, null, null); + this(SecureRandomStrings.alphanumeric(50), params, client, null, null, null); } public AuthorizationSession withUser(User user) { - return new AuthorizationSession(params, client, user, acceptedScopes, tokens); + return new AuthorizationSession(sessionId, params, client, user, acceptedScopes, tokens); } public AuthorizationSession withScopes(List acceptedScopes) { - return new AuthorizationSession(params, client, user, acceptedScopes, tokens); + return new AuthorizationSession(sessionId, params, client, user, acceptedScopes, tokens); } public AuthorizationSession withGeneratedTokens() { @@ -30,7 +31,7 @@ public record AuthorizationSession(AuthParams params, SecureRandomStrings.alphanumeric(50), idToken ); - return new AuthorizationSession(params, client, user, acceptedScopes, tokens); + return new AuthorizationSession(sessionId, params, client, user, acceptedScopes, tokens); } public String scope() { diff --git a/src/main/java/com/ysoft/geecon/dto/DeviceParams.java b/src/main/java/com/ysoft/geecon/dto/DeviceParams.java new file mode 100644 index 0000000..1222d55 --- /dev/null +++ b/src/main/java/com/ysoft/geecon/dto/DeviceParams.java @@ -0,0 +1,18 @@ +package com.ysoft.geecon.dto; + +import jakarta.ws.rs.FormParam; + +public final class DeviceParams { + @FormParam("client_id") + String clientId; + + public String getClientId() { + return clientId; + } + + public AuthParams toAuthParams() { + var params = new AuthParams(); + params.setClientId(clientId); + return params; + } +} diff --git a/src/main/java/com/ysoft/geecon/dto/DeviceResponse.java b/src/main/java/com/ysoft/geecon/dto/DeviceResponse.java new file mode 100644 index 0000000..92ea27d --- /dev/null +++ b/src/main/java/com/ysoft/geecon/dto/DeviceResponse.java @@ -0,0 +1,12 @@ +package com.ysoft.geecon.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DeviceResponse( + @JsonProperty("device_code") String deviceCode, + @JsonProperty("user_code") String userCode, + @JsonProperty("verification_uri") String verificationUri, + @JsonProperty("interval") long interval, + @JsonProperty("expires_in") long expiresIn +) { +} diff --git a/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java b/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java index 6d8ab0a..0cc9248 100644 --- a/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java +++ b/src/main/java/com/ysoft/geecon/repo/ClientsRepo.java @@ -21,7 +21,7 @@ public class ClientsRepo { return Optional.ofNullable(clients.get(clientId)); } - private void register(OAuthClient client) { + public void register(OAuthClient client) { clients.put(client.clientId(), client); } } diff --git a/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java b/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java index 658282e..cb4a155 100644 --- a/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java +++ b/src/main/java/com/ysoft/geecon/repo/SessionsRepo.java @@ -12,15 +12,16 @@ import java.util.*; public class SessionsRepo { private final Map authorizationSessions = new HashMap<>(); private final Map sessionsByAuthorizationCode = new HashMap<>(); + private final Map sessionsByUserCode = new HashMap<>(); public Optional getSession(String sessionId) { return Optional.ofNullable(authorizationSessions.get(sessionId)); } public String newAuthorizationSession(AuthParams params, OAuthClient client) { - var id = SecureRandomStrings.alphanumeric(10); - authorizationSessions.put(id, new AuthorizationSession(params, client)); - return id; + AuthorizationSession session = new AuthorizationSession(params, client); + authorizationSessions.put(session.sessionId(), session); + return session.sessionId(); } public AuthorizationSession authorizeSession(String sessionId, List acceptedScopes) { @@ -39,9 +40,27 @@ public class SessionsRepo { return authCode; } + public String generateUserCode(String sessionId) { + var authCode = SecureRandomStrings.alphanumeric(8); + sessionsByUserCode.put(authCode, sessionId); + return authCode; + } + public Optional redeemAuthorizationCode(String authorizationCode) { var sessionId = Optional.ofNullable(sessionsByAuthorizationCode.get(authorizationCode)); sessionId.ifPresent(_id -> sessionsByAuthorizationCode.remove(authorizationCode)); return sessionId.map(authorizationSessions::get); } + + public Optional redeemUserCode(String userCode) { + var sessionId = Optional.ofNullable(sessionsByUserCode.get(userCode)); + sessionId.ifPresent(_id -> sessionsByUserCode.remove(userCode)); + return sessionId.map(authorizationSessions::get); + } + + + public Optional getByAuthorizationCode(String authorizationCode) { + var sessionId = Optional.ofNullable(sessionsByAuthorizationCode.get(authorizationCode)); + return sessionId.map(authorizationSessions::get); + } } diff --git a/src/main/java/com/ysoft/geecon/repo/UsersRepo.java b/src/main/java/com/ysoft/geecon/repo/UsersRepo.java index 9d9d774..7dbda6d 100644 --- a/src/main/java/com/ysoft/geecon/repo/UsersRepo.java +++ b/src/main/java/com/ysoft/geecon/repo/UsersRepo.java @@ -1,6 +1,5 @@ package com.ysoft.geecon.repo; -import com.ysoft.geecon.dto.OAuthClient; import com.ysoft.geecon.dto.User; import jakarta.enterprise.context.ApplicationScoped; @@ -20,7 +19,7 @@ public class UsersRepo { return Optional.ofNullable(users.get(username)); } - private void register(User user) { + public void register(User user) { users.put(user.login(), user); } } diff --git a/src/main/resources/templates/OAuthResource/consents.html b/src/main/resources/templates/OAuthResource/consents.html index e7444e7..eaad278 100644 --- a/src/main/resources/templates/OAuthResource/consents.html +++ b/src/main/resources/templates/OAuthResource/consents.html @@ -74,7 +74,7 @@

You are authorizing to {client.description}, which requests the following permissions from you:

-
+
    {#each scopes} diff --git a/src/main/resources/templates/OAuthResource/deviceLogin.html b/src/main/resources/templates/OAuthResource/deviceLogin.html new file mode 100644 index 0000000..2bf93a5 --- /dev/null +++ b/src/main/resources/templates/OAuthResource/deviceLogin.html @@ -0,0 +1,85 @@ + + + + + + Login Page + + + + + + diff --git a/src/main/resources/templates/OAuthResource/login.html b/src/main/resources/templates/OAuthResource/login.html index a0a19d2..637c0da 100644 --- a/src/main/resources/templates/OAuthResource/login.html +++ b/src/main/resources/templates/OAuthResource/login.html @@ -73,7 +73,7 @@ {#if error == "invalid_credentials"}
    Invalid credentials
    {/if} -
    +
    diff --git a/src/main/resources/templates/OAuthResource/loginSuccess.html b/src/main/resources/templates/OAuthResource/loginSuccess.html new file mode 100644 index 0000000..ad45367 --- /dev/null +++ b/src/main/resources/templates/OAuthResource/loginSuccess.html @@ -0,0 +1,76 @@ + + + + + + Login Page + + + + + + diff --git a/src/test/java/com/ysoft/geecon/DeviceAuthGrantTest.java b/src/test/java/com/ysoft/geecon/DeviceAuthGrantTest.java new file mode 100644 index 0000000..fc35072 --- /dev/null +++ b/src/test/java/com/ysoft/geecon/DeviceAuthGrantTest.java @@ -0,0 +1,70 @@ +package com.ysoft.geecon; + +import com.ysoft.geecon.dto.DeviceResponse; +import com.ysoft.geecon.dto.OAuthClient; +import com.ysoft.geecon.dto.User; +import com.ysoft.geecon.repo.ClientsRepo; +import com.ysoft.geecon.repo.UsersRepo; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.path.xml.XmlPath; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +public class DeviceAuthGrantTest { + @Inject + ClientsRepo clientsRepo; + @Inject + UsersRepo usersRepo; + + + @Test + public void deviceAuthGrant_invalidCode() { + given().formParam("code", "somecode"). + when().post("/auth/device-login"). + then().statusCode(404); + } + + @Test + public void deviceAuthGrant() { + clientsRepo.register(new OAuthClient("myclient", "", null, null)); + usersRepo.register(new User("bob", "password")); + + DeviceResponse deviceResponse = given(). + formParam("client_id", "myclient"). + when().post("/auth/device") + .then() + .statusCode(200) + .contentType(JSON) + .body("device_code", is(notNullValue())) + .body("user_code", is(notNullValue())) + .body("verification_uri", is(notNullValue())) + .body("interval", is(notNullValue())) + .body("expires_in", is(notNullValue())) + .extract().body().as(DeviceResponse.class); + + String sessionId = given().formParam("code", deviceResponse.userCode()). + when().post("/auth/device-login"). + then().statusCode(200) + .extract().body().xmlPath(XmlPath.CompatibilityMode.HTML) + .getString("html.body.div.form.input"); + + String body = given(). + formParam("sessionId", sessionId). + formParam("username", "bob"). + formParam("password", "password"). + when(). + post("auth") + .then().statusCode(200).extract().body().asString(); + + assertEquals("aaa", body); + + } + +} \ No newline at end of file