From f1f13088719042956d4ca3e6b157cba8709d5b7f Mon Sep 17 00:00:00 2001 From: dix Date: Fri, 22 Mar 2024 17:59:56 +0100 Subject: [PATCH 1/8] bufix/43: implements apple token revocation use-case --- .../renarde/oidc/impl/RenardeAppleClient.java | 18 ++++ .../oidc/impl/RernardeRevokeController.java | 86 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeAppleClient.java create mode 100644 oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeAppleClient.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeAppleClient.java new file mode 100644 index 00000000..b188b88b --- /dev/null +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeAppleClient.java @@ -0,0 +1,18 @@ +package io.quarkiverse.renarde.oidc.impl; + +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(baseUri = "https://appleid.apple.com") +public interface RenardeAppleClient { + + @POST + @Path("/auth/revoke") + void revokeAppleUser(@FormParam("client_id") String clientID, + @FormParam("client_secret") String clientSecret, + @FormParam("token") String token, + @FormParam("token_type_hint") String tokenTypeHint); +} diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java new file mode 100644 index 00000000..5050e0cd --- /dev/null +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java @@ -0,0 +1,86 @@ +package io.quarkiverse.renarde.oidc.impl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import io.quarkiverse.renarde.Controller; +import io.quarkiverse.renarde.security.RenardeSecurity; +import io.quarkus.oidc.AccessTokenCredential; +import io.smallrye.jwt.algorithm.SignatureAlgorithm; +import io.smallrye.jwt.build.Jwt; + +@Path("_renarde/security") +public class RernardeRevokeController extends Controller { + + @Inject + AccessTokenCredential accessToken; + + @RestClient + RenardeAppleClient renardeAppleClient; + + @ConfigProperty(name = "quarkus.oidc.apple.client-id") + String appleClientId; + + @ConfigProperty(name = "quarkus.oidc.apple.credentials.jwt.issuer") + String appleOidcIssuer; + + @ConfigProperty(name = "quarkus.oidc.apple.credentials.jwt.token-key-id") + String appleOidcKeyId; + + @ConfigProperty(name = "quarkus.oidc.apple.credentials.jwt.key-file", defaultValue = "AuthKey_XXX.p8") + String appleKeyFile; + + @Inject + public RenardeSecurity security; + + /** + * Logout action, redirects to index + */ + @Path("apple-revoke") + public Response revokeApple() { + String clientSecret = Jwt.audience("https://appleid.apple.com") + .subject(appleClientId) + .issuer(appleOidcIssuer) + .issuedAt(Instant.now().getEpochSecond()) + .expiresIn(Duration.ofHours(1)) + .jws() + .keyId(appleOidcKeyId) + .algorithm(SignatureAlgorithm.ES256) + .sign(getPrivateKey(String.format("src/main/resources/%s", appleKeyFile))); + + // Revoke token access for apple user + renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getToken(), "access_token"); + + return security.makeLogoutResponse(); + } + + private static PrivateKey getPrivateKey(String filename) { + try { + String content = Files.readString(Paths.get(filename)); + String privateKey = content.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + KeyFactory kf = KeyFactory.getInstance("EC"); + return kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException e) { + throw new RuntimeException(e); + } + } + +} From c465919f41ed9dfc7890c700bccf3e65d0daa9f4 Mon Sep 17 00:00:00 2001 From: dix Date: Fri, 22 Mar 2024 18:11:37 +0100 Subject: [PATCH 2/8] rm comments + empty lines --- .../renarde/oidc/impl/RernardeRevokeController.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java index 5050e0cd..9376caa0 100644 --- a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java @@ -49,9 +49,7 @@ public class RernardeRevokeController extends Controller { @Inject public RenardeSecurity security; - /** - * Logout action, redirects to index - */ + @Path("apple-revoke") public Response revokeApple() { String clientSecret = Jwt.audience("https://appleid.apple.com") @@ -63,10 +61,8 @@ public Response revokeApple() { .keyId(appleOidcKeyId) .algorithm(SignatureAlgorithm.ES256) .sign(getPrivateKey(String.format("src/main/resources/%s", appleKeyFile))); - // Revoke token access for apple user renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getToken(), "access_token"); - return security.makeLogoutResponse(); } From c66d00573c77451a26d42b6ec78c767577a17af9 Mon Sep 17 00:00:00 2001 From: dix Date: Fri, 22 Mar 2024 18:12:14 +0100 Subject: [PATCH 3/8] rm forgotten comments --- .../quarkiverse/renarde/oidc/impl/RernardeRevokeController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java index 9376caa0..7837364f 100644 --- a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java @@ -61,7 +61,6 @@ public Response revokeApple() { .keyId(appleOidcKeyId) .algorithm(SignatureAlgorithm.ES256) .sign(getPrivateKey(String.format("src/main/resources/%s", appleKeyFile))); - // Revoke token access for apple user renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getToken(), "access_token"); return security.makeLogoutResponse(); } From a9f5ff9fc327bff6693fe6522c0151fd22fbe1ac Mon Sep 17 00:00:00 2001 From: dix Date: Tue, 2 Apr 2024 14:56:27 +0200 Subject: [PATCH 4/8] add integration Test + add missing '@Authenticated' on /apple-revoke resource --- .../renarde/it/RenardeOidcTest.java | 59 +++++++++++++++++++ .../oidc/test/MockAppleOidcTestResource.java | 18 ++++++ .../oidc/impl/RernardeRevokeController.java | 9 +-- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java b/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java index f0681792..62ba4498 100644 --- a/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java +++ b/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java @@ -125,6 +125,65 @@ public void appleLoginTest() { verifyLoggedInAndLogout(cookieFilter, "q_session_apple"); } + @Test + public void appleRevokeTest() { + // login using Apple + RenardeCookieFilter cookieFilter = new RenardeCookieFilter(); + ValidatableResponse response = follow("/_renarde/security/login-apple", cookieFilter); + JsonPath json = response.statusCode(200) + .extract().body().jsonPath(); + String code = json.get("code"); + String state = json.get("state"); + + // complete registration (POST /oidc-success) + String location = given() + .when() + .filter(cookieFilter) + .formParam("state", state) + .formParam("code", code) + .redirects().follow(false) + .contentType("application/x-www-form-urlencoded") + .log().ifValidationFails() + .post("/_renarde/security/oidc-success") + .then() + .log().ifValidationFails() + .statusCode(302).extract().header("Location"); + Assertions.assertNotNull(findCookie(cookieFilter.getCookieStore(), "q_session_apple")); + // add user (GET /oidc-success) + follow(url + "/_renarde/security/oidc-success", cookieFilter).statusCode(200); + + // can access secure page + given().when() + .filter(cookieFilter) + .get("/SecureController/hello") + .then() + .statusCode(200); + + // Revoke access + String logoutCookie = given() + .when() + .filter(cookieFilter) + .redirects().follow(false) + .get("/_renarde/security/apple-revoke") + .then() + .statusCode(303) + .extract().headers() + .getValues("Set-Cookie") + .stream().filter(c -> c.startsWith("QuarkusUser=")).findFirst().get(); + + // Cookie is reset + Assertions.assertEquals("QuarkusUser=;Version=1;Path=/;Max-Age=0", logoutCookie); + // Secure page will redirect to login + Assertions.assertTrue( + given().when().filter(cookieFilter) + .redirects().follow(false) + .get("/SecureController/hello") + .then() + .statusCode(302).extract().header("Location") + .endsWith("_renarde/security/login")); + + } + private void verifyLoggedInAndLogout(RenardeCookieFilter cookieFilter, String cookieName) { // can go to protected page given() diff --git a/oidc-tests/src/main/java/io/quarkiverse/renarde/oidc/test/MockAppleOidcTestResource.java b/oidc-tests/src/main/java/io/quarkiverse/renarde/oidc/test/MockAppleOidcTestResource.java index 20874f64..68701254 100644 --- a/oidc-tests/src/main/java/io/quarkiverse/renarde/oidc/test/MockAppleOidcTestResource.java +++ b/oidc-tests/src/main/java/io/quarkiverse/renarde/oidc/test/MockAppleOidcTestResource.java @@ -35,6 +35,7 @@ protected void registerRoutes(Router router) { router.get("/auth/authorize").handler(this::authorize); router.post("/auth/token").handler(bodyHandler).handler(this::accessTokenJson); router.get("/auth/keys").handler(this::getKeys); + router.post("/auth/revoke").handler(this::revoke); KeyPairGenerator kpg; try { @@ -73,6 +74,7 @@ protected void registerRoutes(Router router) { public Map start() { Map ret = super.start(); ret.put("quarkus.oidc.apple.credentials.jwt.key-file", "test.oidc-apple-key.pem"); + ret.put("quarkus.rest-client.RenardeAppleClient.url", baseURI); return ret; } @@ -245,4 +247,20 @@ private void getKeys(RoutingContext rc) { .putHeader("Content-Type", "application/json") .endAndForget(data); } + + /** + * POST /auth/revoke + * Host: appleid.apple.com + * Content-Type: application/x-www-form-urlencoded + * + * client_id=$1 + * &client_secret=$2 + * &token=$3 + * &token_type_hint=access_token + * + * https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens/ + */ + private void revoke(RoutingContext rc) { + rc.response().setStatusCode(200).endAndForget(); + } } diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java index 7837364f..ecd9f3bd 100644 --- a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java @@ -22,6 +22,7 @@ import io.quarkiverse.renarde.Controller; import io.quarkiverse.renarde.security.RenardeSecurity; import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.security.Authenticated; import io.smallrye.jwt.algorithm.SignatureAlgorithm; import io.smallrye.jwt.build.Jwt; @@ -49,8 +50,8 @@ public class RernardeRevokeController extends Controller { @Inject public RenardeSecurity security; - @Path("apple-revoke") + @Authenticated public Response revokeApple() { String clientSecret = Jwt.audience("https://appleid.apple.com") .subject(appleClientId) @@ -60,14 +61,14 @@ public Response revokeApple() { .jws() .keyId(appleOidcKeyId) .algorithm(SignatureAlgorithm.ES256) - .sign(getPrivateKey(String.format("src/main/resources/%s", appleKeyFile))); + .sign(getPrivateKey(appleKeyFile)); renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getToken(), "access_token"); return security.makeLogoutResponse(); } - private static PrivateKey getPrivateKey(String filename) { + private PrivateKey getPrivateKey(String filename) { try { - String content = Files.readString(Paths.get(filename)); + String content = Files.readString(Paths.get("target/classes/" + filename)); String privateKey = content.replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s+", ""); From b0db3fb13b62c604e2233d2759d9c31d0239d7b1 Mon Sep 17 00:00:00 2001 From: dix Date: Tue, 2 Apr 2024 15:03:11 +0200 Subject: [PATCH 5/8] update integration Test: use location of oidc-success --- .../test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java b/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java index 62ba4498..ec4ea0b2 100644 --- a/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java +++ b/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java @@ -150,7 +150,7 @@ public void appleRevokeTest() { .statusCode(302).extract().header("Location"); Assertions.assertNotNull(findCookie(cookieFilter.getCookieStore(), "q_session_apple")); // add user (GET /oidc-success) - follow(url + "/_renarde/security/oidc-success", cookieFilter).statusCode(200); + follow(location.replace("https://", "http://"), cookieFilter).statusCode(200); // can access secure page given().when() From 74c8f2c42e6f22f2f13d59e261b52ce366fde48f Mon Sep 17 00:00:00 2001 From: dix Date: Wed, 17 Apr 2024 16:47:24 +0200 Subject: [PATCH 6/8] review: typo on RenardeRevokeController.java name --- ...rnardeRevokeController.java => RenardeRevokeController.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/{RernardeRevokeController.java => RenardeRevokeController.java} (97%) diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java similarity index 97% rename from oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java rename to oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java index ecd9f3bd..597e41f2 100644 --- a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RernardeRevokeController.java +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java @@ -27,7 +27,7 @@ import io.smallrye.jwt.build.Jwt; @Path("_renarde/security") -public class RernardeRevokeController extends Controller { +public class RenardeRevokeController extends Controller { @Inject AccessTokenCredential accessToken; From 8ef2b9fa611de76be531ce658c987f6c65e9ea40 Mon Sep 17 00:00:00 2001 From: dix Date: Wed, 17 Apr 2024 17:13:14 +0200 Subject: [PATCH 7/8] review: update integration test to check 'q_session' cookie after revocation --- .../renarde/it/RenardeOidcTest.java | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java b/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java index ec4ea0b2..146d4128 100644 --- a/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java +++ b/integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeOidcTest.java @@ -3,6 +3,8 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.CoreMatchers.containsString; +import java.util.List; + import org.apache.http.client.CookieStore; import org.apache.http.cookie.Cookie; import org.junit.jupiter.api.Assertions; @@ -127,7 +129,7 @@ public void appleLoginTest() { @Test public void appleRevokeTest() { - // login using Apple + // GIVEN a registered apple user RenardeCookieFilter cookieFilter = new RenardeCookieFilter(); ValidatableResponse response = follow("/_renarde/security/login-apple", cookieFilter); JsonPath json = response.statusCode(200) @@ -135,7 +137,6 @@ public void appleRevokeTest() { String code = json.get("code"); String state = json.get("state"); - // complete registration (POST /oidc-success) String location = given() .when() .filter(cookieFilter) @@ -147,20 +148,14 @@ public void appleRevokeTest() { .post("/_renarde/security/oidc-success") .then() .log().ifValidationFails() - .statusCode(302).extract().header("Location"); + .statusCode(302) + .extract().header("Location"); Assertions.assertNotNull(findCookie(cookieFilter.getCookieStore(), "q_session_apple")); // add user (GET /oidc-success) follow(location.replace("https://", "http://"), cookieFilter).statusCode(200); - // can access secure page - given().when() - .filter(cookieFilter) - .get("/SecureController/hello") - .then() - .statusCode(200); - - // Revoke access - String logoutCookie = given() + // WHEN Revoke access + List setCookieHeaderResp = given() .when() .filter(cookieFilter) .redirects().follow(false) @@ -168,12 +163,21 @@ public void appleRevokeTest() { .then() .statusCode(303) .extract().headers() - .getValues("Set-Cookie") - .stream().filter(c -> c.startsWith("QuarkusUser=")).findFirst().get(); - - // Cookie is reset - Assertions.assertEquals("QuarkusUser=;Version=1;Path=/;Max-Age=0", logoutCookie); - // Secure page will redirect to login + .getValues("Set-Cookie"); + + // THEN Cookie is reset (no more QuarkusUser nor apple session) + String userLogoutCookie = setCookieHeaderResp.stream() + .filter(c -> c.startsWith("QuarkusUser=")) + .findFirst() + .orElseThrow(); + Assertions.assertEquals("QuarkusUser=;Version=1;Path=/;Max-Age=0", userLogoutCookie); + String userSessionCookie = setCookieHeaderResp.stream() + .filter(c -> c.startsWith("q_session_apple=")) + .findFirst() + .orElseThrow(); + Assertions.assertEquals("q_session_apple=;Version=1;Path=/;Max-Age=0", userSessionCookie); + + // THEN Secure page will redirect to login Assertions.assertTrue( given().when().filter(cookieFilter) .redirects().follow(false) From f5940e1616caa756ab6bca690bff2a28489ceded Mon Sep 17 00:00:00 2001 From: dix Date: Mon, 22 Apr 2024 11:30:24 +0200 Subject: [PATCH 8/8] review: add refresh-token revocation + verify tenant on backend --- .../oidc/impl/RenardeRevokeController.java | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java index 597e41f2..f497863d 100644 --- a/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java +++ b/oidc/src/main/java/io/quarkiverse/renarde/oidc/impl/RenardeRevokeController.java @@ -25,6 +25,7 @@ import io.quarkus.security.Authenticated; import io.smallrye.jwt.algorithm.SignatureAlgorithm; import io.smallrye.jwt.build.Jwt; +import io.vertx.ext.web.RoutingContext; @Path("_renarde/security") public class RenardeRevokeController extends Controller { @@ -35,6 +36,9 @@ public class RenardeRevokeController extends Controller { @RestClient RenardeAppleClient renardeAppleClient; + @Inject + RoutingContext context; + @ConfigProperty(name = "quarkus.oidc.apple.client-id") String appleClientId; @@ -53,16 +57,27 @@ public class RenardeRevokeController extends Controller { @Path("apple-revoke") @Authenticated public Response revokeApple() { - String clientSecret = Jwt.audience("https://appleid.apple.com") - .subject(appleClientId) - .issuer(appleOidcIssuer) - .issuedAt(Instant.now().getEpochSecond()) - .expiresIn(Duration.ofHours(1)) - .jws() - .keyId(appleOidcKeyId) - .algorithm(SignatureAlgorithm.ES256) - .sign(getPrivateKey(appleKeyFile)); - renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getToken(), "access_token"); + String tenant = context.get("tenant-id"); + if ("apple".equalsIgnoreCase(tenant)) { + // Build secret using apple PK + String clientSecret = Jwt.audience("https://appleid.apple.com") + .subject(appleClientId) + .issuer(appleOidcIssuer) + .issuedAt(Instant.now().getEpochSecond()) + .expiresIn(Duration.ofHours(1)) + .jws() + .keyId(appleOidcKeyId) + .algorithm(SignatureAlgorithm.ES256) + .sign(getPrivateKey(appleKeyFile)); + // Invalid refresh token + if (null != accessToken.getRefreshToken()) { + renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getRefreshToken().getToken(), + "refresh_token"); + } + // Invalid access token + renardeAppleClient.revokeAppleUser(appleClientId, clientSecret, accessToken.getToken(), "access_token"); + } + // Invalid cookies return security.makeLogoutResponse(); }