From a5487151163a2f519ed9a02d6fd3ce48ebc094b8 Mon Sep 17 00:00:00 2001 From: Mohamed Alaaser <65477790+mohamedalaaser@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:37:42 +0100 Subject: [PATCH 1/2] Use keycloak passkey extension (#79) Co-authored-by: Johannes Pahle <82645554+ITegs@users.noreply.github.com> --- .dockerignore | 7 + .example.env | 2 + .../components/header/header.component.html | 18 +- .../app/components/header/header.component.ts | 30 +- .../components/sign-in/sign-in.component.html | 33 ++ .../components/sign-in/sign-in.component.ts | 35 ++ .../src/app/core/security/keycloak.service.ts | 8 +- .../security/passkey-extension.service.ts | 183 +++++++++ .../core/security/security-store.service.ts | 69 +++- Client/src/app/layout.component.html | 33 ++ Client/src/app/layout.component.ts | 29 +- .../passkeys/account-passkeys.component.ts | 13 +- .../src/app/pages/index/index.component.html | 6 +- Client/src/app/pages/index/index.component.ts | 3 +- Client/tsconfig.app.json | 8 +- docker/docker-compose.dev.yaml | 10 +- docker/docker-compose.staging.yaml | 4 +- docker/keycloak/Dockerfile | 15 + keycloak-extension-passkey/pom.xml | 79 ++++ .../keycloak/PasskeyBrowserLoginService.java | 212 ++++++++++ .../keycloak/PasskeyChallengeService.java | 165 ++++++++ .../keycloak/PasskeyClientSupport.java | 45 +++ .../keycloak/PasskeyConfigResolver.java | 59 +++ .../com/example/keycloak/PasskeyRequest.java | 35 ++ .../keycloak/PasskeyWebAuthnService.java | 382 ++++++++++++++++++ .../example/keycloak/UserPasskeyProvider.java | 26 ++ .../keycloak/UserPasskeyProviderFactory.java | 37 ++ .../example/keycloak/UserPasskeyResource.java | 338 ++++++++++++++++ .../src/main/resources/META-INF/beans.xml | 4 + ...ices.resource.RealmResourceProviderFactory | 1 + module-management-realm.json | 2 +- 31 files changed, 1822 insertions(+), 69 deletions(-) create mode 100644 .dockerignore create mode 100644 Client/src/app/components/sign-in/sign-in.component.html create mode 100644 Client/src/app/components/sign-in/sign-in.component.ts create mode 100644 Client/src/app/core/security/passkey-extension.service.ts create mode 100644 docker/keycloak/Dockerfile create mode 100644 keycloak-extension-passkey/pom.xml create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyBrowserLoginService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyChallengeService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyClientSupport.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyConfigResolver.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyRequest.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/PasskeyWebAuthnService.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProvider.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyProviderFactory.java create mode 100644 keycloak-extension-passkey/src/main/java/com/example/keycloak/UserPasskeyResource.java create mode 100644 keycloak-extension-passkey/src/main/resources/META-INF/beans.xml create mode 100644 keycloak-extension-passkey/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4ca1fe66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Slim context for docker/keycloak/Dockerfile (build context: Module-Management root) +Client/node_modules +Client/dist +Server/build +Server/.gradle +**/.git +.git diff --git a/.example.env b/.example.env index ba1772e2..37436af4 100644 --- a/.example.env +++ b/.example.env @@ -46,3 +46,5 @@ EMBEDDING_MODEL_NAME= sentence-transformers/all-mpnet-base-v2 CHAT_MODEL_SOURCE = OpenAiChatModel EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel +KC_PASSKEY_CLIENT_ID=module-management +KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?|https://module\.aet\.cit\.tum\.de diff --git a/Client/src/app/components/header/header.component.html b/Client/src/app/components/header/header.component.html index b2a89521..b552d2f3 100644 --- a/Client/src/app/components/header/header.component.html +++ b/Client/src/app/components/header/header.component.html @@ -13,9 +13,7 @@ CIT Module Management -
+ You can add a + passkey + to sign in next time without leaving this app—using your device PIN, fingerprint, or security key. +
+Log in to access the Module Management application.
-Sign in to access the Module Management application.
+
+ * The SPI-managed constructor should be used for normal runtime operation.
+ */
+ public UserPasskeyResource() {
+ this.session = null;
+ this.allowedBrowserOrigin = Pattern.compile("^" + PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv() + "$");
+ this.clientId = PasskeyConfigResolver.resolveClientIdFromEnv();
+ this.clientSupport = new PasskeyClientSupport(this.clientId);
+ }
+
+ /**
+ * Creates the passkey resource with SPI-provided session and resolved configuration.
+ *
+ * @param session Keycloak request session
+ * @param allowedOriginPattern configured allowed browser origin regex (without anchors)
+ * @param clientId configured OIDC client identifier used for passkey flows
+ */
+ public UserPasskeyResource(KeycloakSession session, String allowedOriginPattern, String clientId) {
+ this.session = session;
+ String configuredPattern = PasskeyConfigResolver.firstNonBlank(
+ allowedOriginPattern,
+ PasskeyConfigResolver.resolveAllowedOriginPatternFromEnv()
+ );
+ this.allowedBrowserOrigin = Pattern.compile("^" + configuredPattern + "$");
+ this.clientId = PasskeyConfigResolver.firstNonBlank(
+ clientId,
+ PasskeyConfigResolver.resolveClientIdFromEnv()
+ );
+ this.clientSupport = new PasskeyClientSupport(this.clientId);
+ }
+
+ /**
+ * Handles browser CORS preflight requests for all passkey endpoints.
+ *
+ * @return preflight response with CORS headers when client configuration is available
+ */
+ @OPTIONS
+ @Path("{any:.*}")
+ public Response corsPreflight() {
+ Response.ResponseBuilder responseBuilder = Response.ok();
+ applyCors(responseBuilder, true);
+ return responseBuilder.build();
+ }
+
+ /**
+ * Issues a short-lived challenge used by registration and authentication requests.
+ *
+ * @return JSON object containing a base64url challenge value
+ */
+ @GET
+ @Path("challenge")
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response getChallenge() {
+ try {
+ return jsonOk(Map.of("challenge", challengeService().issueChallenge()));
+ } catch (IllegalStateException e) {
+ return handleServerConfigurationError("Passkey challenge creation failed due to server configuration", e);
+ }
+ }
+
+ /**
+ * Stores a new passwordless WebAuthn credential for the authenticated bearer token user.
+ *
+ * @param request passkey registration payload
+ * @return created response on success, error response otherwise
+ */
+ @POST
+ @Path("save")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response savePasskey(PasskeyRequest request) {
+ if (request == null) {
+ return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED);
+ }
+
+ UserModel user = getUserFromBearerToken();
+ if (user == null) {
+ return textResponse(Response.Status.UNAUTHORIZED, "Authenticated user not found from access token");
+ }
+
+ if (!challengeService().consumeChallenge(request.getChallenge())) {
+ return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE);
+ }
+
+ try {
+ webAuthnService().registerPasskey(user, request, request.getChallenge());
+ return textResponse(Response.Status.CREATED, "Passkey stored successfully");
+ } catch (IllegalArgumentException e) {
+ return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid registration payload: " + e.getMessage());
+ } catch (IllegalStateException e) {
+ return handleServerConfigurationError("Passkey registration failed due to server configuration", e);
+ }
+ }
+
+ /**
+ * Verifies a passkey assertion and continues the regular Keycloak browser login flow.
+ *
+ * @param request passkey authentication payload
+ * @return browser flow response on success, error response otherwise
+ */
+ @POST
+ @Path("authenticate")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response authenticatePasskey(PasskeyRequest request) {
+ if (request == null) {
+ return buildErrorResponse(Response.Status.BAD_REQUEST, ERROR_REQUEST_BODY_REQUIRED);
+ }
+
+ RealmModel realm = session().getContext().getRealm();
+ if (realm == null) {
+ return handleServerConfigurationError("Realm context unavailable for passkey authentication", new IllegalStateException("Realm context is unavailable"));
+ }
+
+ String requestCredentialId = webAuthnService().resolveCredentialId(request);
+ if (requestCredentialId == null) {
+ return buildErrorResponse(Response.Status.BAD_REQUEST, "credentialId or rawId is required");
+ }
+
+ if (!challengeService().consumeChallenge(request.getChallenge())) {
+ return buildErrorResponse(Response.Status.UNAUTHORIZED, ERROR_INVALID_OR_EXPIRED_CHALLENGE);
+ }
+
+ UserModel user = webAuthnService().findUserByCredentialId(realm, requestCredentialId);
+ if (user == null) {
+ return buildErrorResponse(Response.Status.NOT_FOUND, "User not found for credential");
+ }
+
+ if (!webAuthnService().hasPasskeyCredential(user, requestCredentialId)) {
+ return buildErrorResponse(Response.Status.NOT_FOUND, "No passkey found for user: " + user.getUsername());
+ }
+
+ try {
+ if (!webAuthnService().authenticatePasskey(user, request, requestCredentialId)) {
+ return buildErrorResponse(Response.Status.UNAUTHORIZED, "Invalid passkey");
+ }
+
+ return withCors(browserLoginService().completeLogin(user, realm));
+ } catch (IllegalArgumentException e) {
+ return buildErrorResponse(Response.Status.BAD_REQUEST, "Invalid authentication payload: " + e.getMessage());
+ } catch (IllegalStateException e) {
+ return handleServerConfigurationError("Passkey authentication failed due to server configuration", e);
+ } catch (Exception e) {
+ logger.error("Browser-flow completion after passkey authentication failed: " + e.getMessage(), e);
+ return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Authentication flow failed");
+ }
+ }
+
+ /**
+ * Resolves the current user from the bearer token in request headers using Keycloak's authenticator.
+ *
+ * @return authenticated user, or {@code null} when token validation fails
+ */
+ private UserModel getUserFromBearerToken() {
+ RealmModel realm = session().getContext().getRealm();
+ if (realm == null) {
+ return null;
+ }
+
+ AuthenticationManager.AuthResult authResult;
+ try {
+ authResult = new AppAuthManager.BearerTokenAuthenticator(session())
+ .setRealm(realm)
+ .setConnection(session().getContext().getConnection())
+ .setUriInfo(session().getContext().getUri())
+ .setHeaders(session().getContext().getRequestHeaders())
+ .authenticate();
+ } catch (NotAuthorizedException ignored) {
+ return null;
+ } catch (RuntimeException ignored) {
+ return null;
+ }
+
+ if (authResult == null || authResult.getUser() == null) {
+ return null;
+ }
+
+ if (clientId != null && !clientId.isBlank()) {
+ ClientModel tokenClient = authResult.getClient();
+ if (tokenClient == null || !clientId.equals(tokenClient.getClientId())) {
+ return null;
+ }
+ }
+
+ return authResult.getUser();
+ }
+
+ /**
+ * Returns the active SPI session and fails fast if the resource was created without it.
+ */
+ private KeycloakSession session() {
+ if (session == null) {
+ throw new IllegalStateException("UserPasskeyResource must be created by UserPasskeyProvider (SPI-managed session required).");
+ }
+ return session;
+ }
+
+ /**
+ * Creates the challenge service for the current request context.
+ */
+ private PasskeyChallengeService challengeService() {
+ return new PasskeyChallengeService(session(), clientSupport);
+ }
+
+ /**
+ * Creates the WebAuthn service for the current request context.
+ */
+ private PasskeyWebAuthnService webAuthnService() {
+ return new PasskeyWebAuthnService(session(), allowedBrowserOrigin);
+ }
+
+ /**
+ * Creates the browser-login service for the current request context.
+ */
+ private PasskeyBrowserLoginService browserLoginService() {
+ return new PasskeyBrowserLoginService(session(), clientSupport);
+ }
+
+ /**
+ * Logs and returns a uniform internal-server-error response for configuration problems.
+ */
+ private Response handleServerConfigurationError(String logMessage, IllegalStateException exception) {
+ logger.error(logMessage, exception);
+ return buildErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, ERROR_SERVER_CONFIGURATION);
+ }
+
+ /**
+ * Builds a 200 JSON response wrapped with CORS headers.
+ */
+ private Response jsonOk(Object payload) {
+ return jsonResponse(Response.Status.OK, payload);
+ }
+
+ /**
+ * Builds a JSON response with CORS headers.
+ */
+ private Response jsonResponse(Response.Status status, Object payload) {
+ Response.ResponseBuilder builder = status == Response.Status.OK
+ ? Response.ok(payload)
+ : Response.status(status).entity(payload);
+ return withCors(builder.type(MediaType.APPLICATION_JSON_TYPE).build());
+ }
+
+ /**
+ * Builds a plain-text response with CORS headers.
+ */
+ private Response textResponse(Response.Status status, String payload) {
+ return withCors(Response.status(status)
+ .entity(payload)
+ .type(MediaType.TEXT_PLAIN_TYPE)
+ .build());
+ }
+
+ /**
+ * Builds a Keycloak-style error response and applies CORS headers.
+ */
+ private Response buildErrorResponse(Response.Status status, String message) {
+ ErrorResponseException errorResponse = ErrorResponse.error(
+ PasskeyConfigResolver.firstNonBlank(message, ""),
+ status
+ );
+ return withCors(errorResponse.getResponse());
+ }
+
+ /**
+ * Rebuilds the response while appending CORS headers for configured clients.
+ */
+ private Response withCors(Response response) {
+ Response.ResponseBuilder responseBuilder = Response.fromResponse(response);
+ applyCors(responseBuilder, false);
+ return responseBuilder.build();
+ }
+
+ /**
+ * Applies Keycloak CORS settings based on configured client web origins.
+ *
+ * @param responseBuilder response builder to mutate
+ * @param preflight whether to apply preflight-specific headers
+ */
+ private void applyCors(Response.ResponseBuilder responseBuilder, boolean preflight) {
+ ClientModel corsClient = clientSupport.resolveConfiguredClient(session().getContext().getRealm());
+ if (corsClient == null) {
+ return;
+ }
+
+ Cors cors = Cors.builder()
+ .builder(responseBuilder)
+ .auth()
+ .allowedMethods("GET", "POST", "OPTIONS");
+
+ if (preflight) {
+ cors.preflight();
+ }
+
+ cors.allowedOrigins(session(), corsClient);
+ cors.add();
+ }
+}
diff --git a/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml
new file mode 100644
index 00000000..ded49af4
--- /dev/null
+++ b/keycloak-extension-passkey/src/main/resources/META-INF/beans.xml
@@ -0,0 +1,4 @@
+