This commit is contained in:
Thomas Darimont 2026-02-03 11:58:41 -05:00 committed by GitHub
commit d96d2ebea8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 5277 additions and 4 deletions

View file

@ -155,6 +155,8 @@ public class Profile {
DB_TIDB("TiDB database type", Type.EXPERIMENTAL),
SSF("Shared Signals Framework", Type.EXPERIMENTAL),
HTTP_OPTIMIZED_SERIALIZERS("Optimized JSON serializers for better performance of the HTTP layer", Type.PREVIEW),
OPENAPI("OpenAPI specification served at runtime", Type.EXPERIMENTAL, CLIENT_ADMIN_API_V2),

View file

@ -2880,6 +2880,15 @@ fullName={{givenName}} {{familyName}}
deleteConfirm=Are you sure you want to permanently delete the provider '{{provider}}'?
compositesRemovedAlertDescription=All the associated roles have been removed
aliasHelp=The alias uniquely identifies an identity provider and it is also used to build the redirect uri.
ssfTransmitterIssuerHelp=The issuer URL of the SSF Transmitter. This is used to derive the transmitter metadata endpoint.
ssfTransmitterAccessToken=Transmitter Access Token
ssfTransmitterAccessTokenHelp=The Transmitter Access Token to perform SSF stream verification.
ssfStreamId=Stream ID
ssfStreamIdHelp=ID of the SSF stream registered with the Transmitter.
ssfStreamAudience=Audience
ssfStreamAudienceHelp=Audience URI configured for the Stream registered with the Transmitter. If empty the current realm issuer URI is used as audience. Multiple audience URIs can be provided as comma separated list.
ssfPushAuthorizationHeader=Push Authorization Header
ssfPushAuthorizationHeaderHelp='Authorization' header value expected to be sent by SSF Transmitters when Push delivery via HTTP is used.
selectRealm=Select realm
roleNameLdapAttribute=Role name LDAP attribute
javaKeystore=java-keystore

View file

@ -0,0 +1,99 @@
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import {
ActionGroup,
AlertVariant,
Button,
PageSection,
} from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toIdentityProvider } from "../routes/IdentityProvider";
import { toIdentityProviders } from "../routes/IdentityProviders";
import { SsfReceiverSettings } from "./SsfReceiverSettings";
type DiscoveryIdentityProvider = IdentityProviderRepresentation & {
discoveryEndpoint?: string;
};
export default function AddSsfReceiver() {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const navigate = useNavigate();
const id = "ssf-receiver";
const form = useForm<DiscoveryIdentityProvider>({
defaultValues: { alias: id, config: { allowCreate: "true" } },
mode: "onChange",
});
const {
handleSubmit,
formState: { isDirty },
} = form;
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const onSubmit = async (provider: DiscoveryIdentityProvider) => {
delete provider.discoveryEndpoint;
try {
await adminClient.identityProviders.create({
...provider,
providerId: id,
});
addAlert(t("createIdentityProviderSuccess"), AlertVariant.success);
navigate(
toIdentityProvider({
realm,
providerId: id,
alias: provider.alias!,
tab: "settings",
}),
);
} catch (error: any) {
addError("createIdentityProviderError", error);
}
};
return (
<>
<ViewHeader titleKey={t("addSsfReceiverProvider")} />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(onSubmit)}
>
<SsfReceiverSettings />
<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("add")}
</Button>
<Button
variant="link"
data-testid="cancel"
component={(props) => (
<Link {...props} to={toIdentityProviders({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
}

View file

@ -70,6 +70,7 @@ import { OIDCGeneralSettings } from "./OIDCGeneralSettings";
import { ReqAuthnConstraints } from "./ReqAuthnConstraintsSettings";
import { SamlGeneralSettings } from "./SamlGeneralSettings";
import { SpiffeSettings } from "./SpiffeSettings";
import { SsfReceiverSettings } from "./SsfReceiverSettings";
import { AdminEvents } from "../../events/AdminEvents";
import { UserProfileClaimsSettings } from "./OAuth2UserProfileClaimsSettings";
import { KubernetesSettings } from "./KubernetesSettings";
@ -425,6 +426,7 @@ export default function DetailSettings() {
const isSAML = provider.providerId!.includes("saml");
const isOAuth2 = provider.providerId!.includes("oauth2");
const isSPIFFE = provider.providerId!.includes("spiffe");
const isSsfReceiver = provider.providerId!.includes("ssf-receiver");
const isKubernetes = provider.providerId!.includes("kubernetes");
const isJWTAuthorizationGrant = provider.providerId!.includes(
"jwt-authorization-grant",
@ -463,7 +465,8 @@ export default function DetailSettings() {
const sections = [
{
title: t("generalSettings"),
isHidden: isSPIFFE || isKubernetes || isJWTAuthorizationGrant,
isHidden:
isSPIFFE || isKubernetes || isJWTAuthorizationGrant || isSsfReceiver,
panel: (
<FormAccess
role="manage-identity-providers"
@ -549,6 +552,20 @@ export default function DetailSettings() {
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isSsfReceiver,
panel: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<SsfReceiverSettings />
<FixedButtonsGroup name="idp-details" isSubmit reset={reset} />
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isJWTAuthorizationGrant,
@ -597,7 +614,8 @@ export default function DetailSettings() {
},
{
title: t("advancedSettings"),
isHidden: isSPIFFE || isKubernetes || isJWTAuthorizationGrant,
isHidden:
isSPIFFE || isKubernetes || isJWTAuthorizationGrant || isSsfReceiver,
panel: (
<FormAccess
role="manage-identity-providers"
@ -649,7 +667,12 @@ export default function DetailSettings() {
</Tab>
<Tab
id="mappers"
isHidden={isSPIFFE || isKubernetes || isJWTAuthorizationGrant}
isHidden={
isSPIFFE ||
isKubernetes ||
isJWTAuthorizationGrant ||
isSsfReceiver
}
data-testid="mappers-tab"
title={<TabTitleText>{t("mappers")}</TabTitleText>}
{...mappersTab}

View file

@ -0,0 +1,67 @@
import { PasswordControl, TextControl } from "@keycloak/keycloak-ui-shared";
import { useTranslation } from "react-i18next";
export const SsfReceiverSettings = () => {
const { t } = useTranslation();
return (
<>
<TextControl
name="alias"
label={t("alias")}
labelIcon={t("aliasHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.description"
label={t("description")}
labelIcon={t("descriptionHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.issuer"
label={t("issuer")}
labelIcon={t("ssfTransmitterIssuerHelp")}
rules={{
required: t("required"),
}}
/>
<PasswordControl
name="config.transmitterAccessToken"
label={t("ssfTransmitterAccessToken")}
labelIcon={t("ssfTransmitterAccessTokenHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.streamAudience"
label={t("ssfStreamAudience")}
labelIcon={t("ssfStreamAudienceHelp")}
/>
<TextControl
name="config.streamId"
label={t("ssfStreamId")}
labelIcon={t("ssfStreamIdHelp")}
rules={{
required: t("required"),
}}
/>
<PasswordControl
name="config.pushAuthorizationHeader"
label={t("ssfPushAuthorizationHeader")}
labelIcon={t("ssfPushAuthorizationHeaderHelp")}
/>
</>
);
};

View file

@ -0,0 +1,23 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generateEncodedPath } from "../../utils/generateEncodedPath";
import type { AppRouteObject } from "../../routes";
export type IdentityProviderSsfReceiverParams = { realm: string };
const AddSsfReceiver = lazy(() => import("../add/AddSsfReceiver"));
export const IdentityProviderSsfReceiverRoute: AppRouteObject = {
path: "/:realm/identity-providers/ssf-receiver/add",
element: <AddSsfReceiver />,
breadcrumb: (t) => t("addSsfReceiverProvider"),
handle: {
access: "manage-identity-providers",
},
};
export const toIdentityProviderSsfReceiver = (
params: IdentityProviderSsfReceiverParams,
): Partial<Path> => ({
pathname: generateEncodedPath(IdentityProviderSsfReceiverRoute.path, params),
});

View file

@ -0,0 +1,17 @@
package org.keycloak.protocol.ssf;
import org.keycloak.protocol.ssf.receiver.spi.SsfReceiverProvider;
import static org.keycloak.utils.KeycloakSessionUtil.getKeycloakSession;
/**
* Entry-point to lookup the SsfProvider.
*/
public class Ssf {
private Ssf() {}
public static SsfReceiverProvider receiverProvider() {
return getKeycloakSession().getProvider(SsfReceiverProvider.class);
}
}

View file

@ -0,0 +1,15 @@
package org.keycloak.protocol.ssf;
public class SsfException extends RuntimeException {
public SsfException() {
}
public SsfException(String message) {
super(message);
}
public SsfException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,174 @@
package org.keycloak.protocol.ssf.endpoint;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.event.parser.SecurityEventTokenParsingException;
import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.registration.SsfReceiverRegistrationProviderFactory;
import org.keycloak.protocol.ssf.receiver.spi.SsfReceiverProvider;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import org.keycloak.utils.KeycloakSessionUtil;
/**
* SsfPushDeliveryResource implements the RFC 8935 Push-Based Security Event Token (SET) Delivery Using HTTP.
* <p>
* See: https://www.rfc-editor.org/rfc/rfc8935.html
*/
public class SsfPushDeliveryResource {
protected static final Logger LOG = Logger.getLogger(SsfPushDeliveryResource.class);
public static final String APPLICATION_SECEVENT_JWT_TYPE = "application/secevent+jwt";
protected final SsfReceiverProvider ssfReceiverProvider;
public SsfPushDeliveryResource(SsfReceiverProvider ssfReceiverProvider) {
this.ssfReceiverProvider = ssfReceiverProvider;
}
/**
* Handles legacy SSF requests, which don't send the `Content-type: application/secevent+jwt` in the request.
*
* The endpoint is available via {@code $KC_ISSUER_URL/ssf/push/{receiverAlias}}
*
* @param receiverAlias
* @param encodedSecurityEventToken
* @param authToken
* @param contentType
* @return
*/
@Path("{receiverAlias}")
@POST
@Produces(MediaType.APPLICATION_JSON)
// @Consumes(APPLICATION_SECEVENT_JWT_TYPE) // some SSF providers don't set the correct content-type
public Response invalidSecurityEventTokenRequest(@PathParam("receiverAlias") String receiverAlias, //
String encodedSecurityEventToken, //
@HeaderParam(HttpHeaders.AUTHORIZATION) String authToken, //
@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType //
) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
/**
* Handles PUSH based SET delivery via HTTP.
*
* The endpoint is available via {@code $KC_ISSUER_URL/ssf/push/{receiverAlias}}
*
* @param receiverAlias
* @param encodedSecurityEventToken
* @param authToken
* @param contentType
* @return
*/
@Path("{receiverAlias}")
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(APPLICATION_SECEVENT_JWT_TYPE)
@Tag(name = KeycloakOpenAPI.Admin.Tags.SSF_PUSH)
@Operation(summary = "SSF Push delivery endpoint for this realm.")
@APIResponses(value = {
@APIResponse(responseCode = "204", description = "No content"),
@APIResponse(responseCode = "404", description = "Not found"),
})
public Response ingestSecurityEventToken(@PathParam("receiverAlias") String receiverAlias, //
String encodedSecurityEventToken, //
@HeaderParam(HttpHeaders.AUTHORIZATION) String authToken, //
@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType //
) {
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
KeycloakContext context = session.getContext();
SsfReceiver receiver = lookupReceiver(session, receiverAlias, context);
if (receiver == null) {
LOG.debugf("Ignoring security event token received for unknown receiver. receiverAlias=%s", receiverAlias);
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_INVALID_REQUEST, "Invalid receiver");
}
if (!receiver.getConfig().isEnabled()) {
LOG.debugf("Ignoring security event token received for disabled receiver. receiverAlias=%s", receiverAlias);
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_INVALID_REQUEST, "Receiver is disabled");
}
checkPushAuthorizationToken(session, receiver, authToken);
var eventContext = ssfReceiverProvider.createEventContext(null, receiver);
SecurityEventToken securityEventToken = parseSecurityEventToken(session, encodedSecurityEventToken, eventContext);
RealmModel realm = context.getRealm();
if (securityEventToken == null) {
LOG.debugf("Rejected invalid security event token. realm=%s receiverAlias=%s", realm.getName(), receiverAlias);
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_INVALID_REQUEST, "Invalid security event token");
}
// Security Event Token is parsed and signature validated from here on
LOG.debugf("Ingesting security event token. realm=%s receiverAlias=%s jti=%s", realm.getName(), receiverAlias, securityEventToken.getId());
// Security Event Token parsed
eventContext.setSecurityEventToken(securityEventToken);
handleEvents(session, securityEventToken, eventContext);
if (!eventContext.isProcessedSuccessfully()) {
// See 2.3. Failure Response https://www.rfc-editor.org/rfc/rfc8935.html#section-2.3
return Response.serverError().type(MediaType.APPLICATION_JSON).build();
}
// See 2.2. Success Response https://www.rfc-editor.org/rfc/rfc8935.html#section-2.2
return Response.accepted().type(MediaType.APPLICATION_JSON).build();
}
protected SsfReceiver lookupReceiver(KeycloakSession session, String receiverAlias, KeycloakContext context) {
return SsfReceiverRegistrationProviderFactory.getSsfReceiver(session, context.getRealm(), receiverAlias);
}
protected SecurityEventToken parseSecurityEventToken(KeycloakSession session, String encodedSecurityEventToken, SsfEventContext eventContext) {
try {
return ssfReceiverProvider.parseSecurityEventToken(encodedSecurityEventToken, eventContext);
} catch (SecurityEventTokenParsingException sepe) {
// see https://www.rfc-editor.org/rfc/rfc8935.html#section-2.4
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_INVALID_REQUEST, sepe.getMessage());
}
}
protected void handleEvents(KeycloakSession session, SecurityEventToken securityEventToken, SsfEventContext eventContext) {
ssfReceiverProvider.processEvents(securityEventToken, eventContext);
}
protected void checkPushAuthorizationToken(KeycloakSession session, SsfReceiver receiver, String receivedAuthHeader) {
String expectedAuthHeader = receiver.getConfig() != null ? receiver.getConfig().getPushAuthorizationHeader() : null;
if (expectedAuthHeader != null) {
if (!isValidPushAuthorizationHeader(receiver, receivedAuthHeader, expectedAuthHeader)) {
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_AUTHENTICATION_FAILED, "Invalid push authorization header");
}
}
}
protected boolean isValidPushAuthorizationHeader(SsfReceiver receiver, String authHeader, String expectedAuthHeader) {
return expectedAuthHeader.equals(authHeader);
}
}

View file

@ -0,0 +1,37 @@
package org.keycloak.protocol.ssf.endpoint;
import jakarta.ws.rs.Path;
import org.keycloak.protocol.ssf.Ssf;
import org.keycloak.services.resource.RealmResourceProvider;
/**
* Exposes the realm specific SSF resource endpoints.
*/
public class SsfRealmResourceProvider implements RealmResourceProvider {
@Override
public Object getResource() {
return this;
}
/**
* Endpoint for SET Push delivery via HTTP.
*
* The endpoint is available via {@code $KC_ISSUER_URL/ssf/push}
*
* @return
*/
@Path("/push")
public SsfPushDeliveryResource pushEndpoint() {
// push endpoint authentication checked by PushEndpoit directly.
return Ssf.receiverProvider().pushDeliveryEndpoint();
}
@Override
public void close() {
// NOOP
}
}

View file

@ -0,0 +1,49 @@
package org.keycloak.protocol.ssf.endpoint;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
public class SsfRealmResourceProviderFactory implements RealmResourceProviderFactory, EnvironmentDependentProviderFactory {
private static final SsfRealmResourceProvider INSTANCE = new SsfRealmResourceProvider();
/**
* The SSF endpoints are available under {@code $KC_ISSUER_URL/ssf}.
*
* @return
*/
@Override
public String getId() {
return "ssf";
}
@Override
public RealmResourceProvider create(KeycloakSession keycloakSession) {
return INSTANCE;
}
@Override
public void init(Config.Scope scope) {
// NOOP
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
// NOOP
}
@Override
public void close() {
// NOOP
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.SSF);
}
}

View file

@ -0,0 +1,59 @@
package org.keycloak.protocol.ssf.endpoint;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* HTTP Push delivery failure response.
*
* See https://www.rfc-editor.org/rfc/rfc8935.html#section-2.3
*/
public class SsfSetPushDeliveryFailureResponse {
public static final String ERROR_INVALID_REQUEST = "invalid_request";
public static final String ERROR_INVALID_KEY = "invalid_key";
public static final String ERROR_INVALID_ISSUER = "invalid_issuer";
public static final String ERROR_INVALID_AUDIENCE = "invalid_audience";
public static final String ERROR_AUTHENTICATION_FAILED = "authentication_failed";
public static final String ERROR_ACCESS_DENIED = "access_denied";
/*
* Non standard error
*/
public static final String ERROR_INTERNAL_ERROR = "internal_error";
@JsonProperty("err")
private final String error;
@JsonProperty("description")
private final String description;
public SsfSetPushDeliveryFailureResponse(String error, String description) {
this.error = error;
this.description = description;
}
public String getError() {
return error;
}
public String getDescription() {
return description;
}
public static WebApplicationException newFailureResponse(Response.Status status, String errorCode, String errorMessage) {
Response response = Response.status(status)
.type(MediaType.APPLICATION_JSON)
.entity(new SsfSetPushDeliveryFailureResponse(errorCode, errorMessage))
.build();
return new WebApplicationException(response);
}
}

View file

@ -0,0 +1,23 @@
package org.keycloak.protocol.ssf.endpoint.admin;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
/**
* Exposes the {@link SsfAdminResource}
*/
public class SsfAdminRealmResourceProvider implements AdminRealmResourceProvider {
@Override
public Object getResource(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
return new SsfAdminResource(session, realm, auth, adminEvent);
}
@Override
public void close() {
// NOOP
}
}

View file

@ -0,0 +1,35 @@
package org.keycloak.protocol.ssf.endpoint.admin;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider;
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory;
public class SsfAdminRealmResourceProviderFactory implements AdminRealmResourceProviderFactory {
@Override
public String getId() {
return "ssf";
}
@Override
public AdminRealmResourceProvider create(KeycloakSession session) {
return new SsfAdminRealmResourceProvider();
}
@Override
public void init(Config.Scope config) {
// NOOP
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// NOOP
}
@Override
public void close() {
// NOOP
}
}

View file

@ -0,0 +1,59 @@
package org.keycloak.protocol.ssf.endpoint.admin;
import jakarta.ws.rs.Path;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
/**
* SsfAdmin resource to manage SSF related components.
*
* The endpoint is available via {@code $KC_ADMIN_URL/admin/realms/{realm}/ssf}
*/
public class SsfAdminResource {
protected final KeycloakSession session;
protected final RealmModel realm;
protected final AdminPermissionEvaluator auth;
protected final AdminEventBuilder adminEvent;
public SsfAdminResource(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
this.session = session;
this.realm = realm;
this.auth = auth;
this.adminEvent = adminEvent;
}
/**
* Exposes the {@link SsfReceiverAdminResource} for managing SSF Receivers as a custom endpoint.
*
* Checks if the current user can access the SSF admin resource for receivers.
*
* The endpoint is available via {@code $KC_ADMIN_URL/admin/realms/{realm}/ssf/receivers}
* @return
*/
@Path("receivers")
public SsfReceiverAdminResource receiverManagementEndpoint() {
checkReceiverAdminResourceAccess();
return receiverAdminResource();
}
/**
* Provies the actual {@link SsfReceiverAdminResource}.
* @return
*/
protected SsfReceiverAdminResource receiverAdminResource() {
return new SsfReceiverAdminResource(session, auth);
}
/**
* Checks if the current user can access the SSF admin resource for receivers.
*/
protected void checkReceiverAdminResourceAccess() {
auth.realm().requireManageIdentityProviders();
}
}

View file

@ -0,0 +1,46 @@
package org.keycloak.protocol.ssf.endpoint.admin;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
/**
* SsfReceiverAdminResource provides access to SSF Receiver operations. SSS
*/
public class SsfReceiverAdminResource {
protected final KeycloakSession session;
protected final AdminPermissionEvaluator auth;
public SsfReceiverAdminResource(KeycloakSession session, AdminPermissionEvaluator auth) {
this.session = session;
this.auth = auth;
}
/**
* Exposes the {@link SsfVerificationResource} to verify the stream and event delivery setup for a SSF Receiver as a custom endpoint.
* <p>
* The endpoint is available via {@code $KC_ADMIN_URL/admin/realms/{realm}/ssf/receivers/{receiverAlias}/verify}
*
* @param alias
* @return
*/
@Tag(name = KeycloakOpenAPI.Admin.Tags.SSF_STREAM_VERIFICATION)
@Operation(summary = "Trigger SSF Stream Verification for the given receiver in this realm.")
@APIResponses(value = {
@APIResponse(responseCode = "202", description = "Accepted"),
@APIResponse(responseCode = "400", description = "Bad Request"),
})
@Path("/{receiverAlias}/verify")
public SsfVerificationResource verificationEndpoint(@PathParam("receiverAlias") String alias) {
return new SsfVerificationResource(session, alias);
}
}

View file

@ -0,0 +1,53 @@
package org.keycloak.protocol.ssf.endpoint.admin;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.ssf.endpoint.SsfSetPushDeliveryFailureResponse;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.registration.SsfReceiverRegistrationProviderFactory;
/**
* SsfVerificationResource is used to verify the stream and event delivery setup for a SSF Receiver
*/
public class SsfVerificationResource {
protected final KeycloakSession session;
protected final String receiverAlias;
public SsfVerificationResource(KeycloakSession session, String receiverAlias) {
this.session = session;
this.receiverAlias = receiverAlias;
}
/**
* This calls the verification_endpoint provided by the associated SSF Transmitter.
* <p>
* Note that the verification_endpoint is called with the current stream_id and the transmitter access token.
*
* @return
*/
@POST
public Response triggerVerification() {
RealmModel realm = session.getContext().getRealm();
SsfReceiver receiver = SsfReceiverRegistrationProviderFactory.getSsfReceiver(session, realm, receiverAlias);
if (receiver == null) {
return Response.status(Response.Status.NOT_FOUND).type(MediaType.APPLICATION_JSON_TYPE).build();
}
// TODO handle pending verifications
try {
receiver.requestVerification();
} catch (Exception e) {
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.INTERNAL_SERVER_ERROR, SsfSetPushDeliveryFailureResponse.ERROR_INTERNAL_ERROR, e.getMessage());
}
return Response.noContent().type(MediaType.APPLICATION_JSON).build();
}
}

View file

@ -0,0 +1,84 @@
package org.keycloak.protocol.ssf.event;
import java.util.LinkedHashMap;
import java.util.Map;
import org.keycloak.protocol.ssf.event.subjects.SubjectId;
import org.keycloak.protocol.ssf.event.subjects.SubjectIdJsonDeserializer;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
import org.keycloak.protocol.ssf.event.types.SsfEventMapJsonDeserializer;
import org.keycloak.representations.JsonWebToken;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/**
* Represents a RFC8417 Security Event Token (SET).
*
* See: https://datatracker.ietf.org/doc/html/rfc8417
*/
public class SecurityEventToken extends JsonWebToken {
/**
* 4.1.1. Explicit Typing of SETs
* @see https://openid.github.io/sharedsignals/openid-sharedsignals-framework-1_0.html#section-4.1.1
*/
public static final String TYPE = "secevent+jwt";
@JsonProperty("sub_id")
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
protected SubjectId subjectId;
@JsonProperty("txn")
protected String txn;
@JsonProperty("events")
@JsonDeserialize(using = SsfEventMapJsonDeserializer.class)
protected Map<String, SsfEvent> events;
public SecurityEventToken txn(String txn) {
setTxn(txn);
return this;
}
public SubjectId getSubjectId() {
return subjectId;
}
public void setSubjectId(SubjectId subjectId) {
this.subjectId = subjectId;
}
public SecurityEventToken subjectId(SubjectId subjectId) {
setSubjectId(subjectId);
return this;
}
public Map<String, SsfEvent> getEvents() {
if (events == null) {
events = new LinkedHashMap<>();
}
return events;
}
public void setEvents(Map<String, SsfEvent> events) {
this.events = events;
}
public String getTxn() {
return txn;
}
public void setTxn(String txn) {
this.txn = txn;
}
@Override
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

View file

@ -0,0 +1,115 @@
package org.keycloak.protocol.ssf.event;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.protocol.ssf.event.types.GenericSsfEvent;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
import org.keycloak.protocol.ssf.event.types.caep.AssuranceLevelChange;
import org.keycloak.protocol.ssf.event.types.caep.CaepEvent;
import org.keycloak.protocol.ssf.event.types.caep.CredentialChange;
import org.keycloak.protocol.ssf.event.types.caep.DeviceComplianceChange;
import org.keycloak.protocol.ssf.event.types.caep.SessionEstablished;
import org.keycloak.protocol.ssf.event.types.caep.SessionPresented;
import org.keycloak.protocol.ssf.event.types.caep.SessionRevoked;
import org.keycloak.protocol.ssf.event.types.caep.TokenClaimsChanged;
import org.keycloak.protocol.ssf.event.types.risc.AccountCredentialChangeRequired;
import org.keycloak.protocol.ssf.event.types.risc.AccountDisabled;
import org.keycloak.protocol.ssf.event.types.risc.AccountEnabled;
import org.keycloak.protocol.ssf.event.types.risc.AccountPurged;
import org.keycloak.protocol.ssf.event.types.risc.CredentialCompromise;
import org.keycloak.protocol.ssf.event.types.risc.IdentifierChanged;
import org.keycloak.protocol.ssf.event.types.risc.IdentifierRecycled;
import org.keycloak.protocol.ssf.event.types.risc.OptIn;
import org.keycloak.protocol.ssf.event.types.risc.OptOutCancelled;
import org.keycloak.protocol.ssf.event.types.risc.OptOutEffective;
import org.keycloak.protocol.ssf.event.types.risc.OptOutInitiated;
import org.keycloak.protocol.ssf.event.types.risc.RecoveryActivated;
import org.keycloak.protocol.ssf.event.types.risc.RecoveryInformationChanged;
import org.keycloak.protocol.ssf.event.types.risc.RiscEvent;
import org.keycloak.protocol.ssf.event.types.stream.StreamEvent;
import org.keycloak.protocol.ssf.event.types.stream.StreamUpdatedEvent;
import org.keycloak.protocol.ssf.event.types.stream.VerificationEvent;
/**
* Registry of Standard SSF Events.
*/
public class SsfStandardEvents {
/**
* Holds all standard SSF Stream events.
*/
public static final Map<String, Class<? extends StreamEvent>> STREAM_EVENT_TYPES;
/**
* Holds all standard CAEP events.
*/
public static final Map<String, Class<? extends CaepEvent>> CAEP_EVENT_TYPES;
/**
* Holds all standard RISC events.
*/
public static final Map<String, Class<? extends RiscEvent>> RISC_EVENT_TYPES;
static {
var ssfStreamEventTypes = new HashMap<String, Class<? extends StreamEvent>>();
List.of(//
new VerificationEvent(), //
new StreamUpdatedEvent() //
).forEach(ssfEvent -> ssfStreamEventTypes.put(ssfEvent.getEventType(), ssfEvent.getClass()));
STREAM_EVENT_TYPES = Collections.unmodifiableMap(ssfStreamEventTypes);
var caepEventTypes = new HashMap<String, Class<? extends CaepEvent>>();
List.of( //
new AssuranceLevelChange(), //
new CredentialChange(), //
new DeviceComplianceChange(), //
new SessionEstablished(), //
new SessionPresented(), //
new SessionRevoked(), //
new TokenClaimsChanged() //
).forEach(caepEvent -> caepEventTypes.put(caepEvent.getEventType(), caepEvent.getClass()));
CAEP_EVENT_TYPES = Collections.unmodifiableMap(caepEventTypes);
var riscEventTypes = new HashMap<String, Class<? extends RiscEvent>>();
List.of( //
new AccountCredentialChangeRequired(), //
new AccountDisabled(), //
new AccountEnabled(), //
new AccountPurged(), //
new CredentialCompromise(), //
new IdentifierChanged(), //
new IdentifierRecycled(), //
new OptIn(), //
new OptOutInitiated(), //
new OptOutCancelled(), //
new OptOutEffective(), //
new RecoveryActivated(), //
new RecoveryInformationChanged() //
).forEach(riscEvent -> riscEventTypes.put(riscEvent.getEventType(), riscEvent.getClass()));
RISC_EVENT_TYPES = Collections.unmodifiableMap(riscEventTypes);
}
public static Class<? extends SsfEvent> getSecurityEventType(String eventType) {
var streamEventTypes = STREAM_EVENT_TYPES.get(eventType);
if (streamEventTypes != null) {
return streamEventTypes;
}
var caepEventType = CAEP_EVENT_TYPES.get(eventType);
if (caepEventType != null) {
return caepEventType;
}
var riscEventType = RISC_EVENT_TYPES.get(eventType);
if (riscEventType != null) {
return riscEventType;
}
return GenericSsfEvent.class;
}
}

View file

@ -0,0 +1,162 @@
package org.keycloak.protocol.ssf.event.listener;
import java.io.IOException;
import java.util.List;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
import org.keycloak.protocol.ssf.event.subjects.SubjectId;
import org.keycloak.protocol.ssf.event.subjects.SubjectUserLookup;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
import org.keycloak.protocol.ssf.event.types.caep.SessionRevoked;
import org.jboss.logging.Logger;
import org.keycloak.util.JsonSerialization;
/**
* Default {@link SsfEventListener} implementation.
*/
public class DefaultSsfEventListener implements SsfEventListener {
protected static final Logger LOG = Logger.getLogger(DefaultSsfEventListener.class);
protected final KeycloakSession session;
public DefaultSsfEventListener(KeycloakSession session) {
this.session = session;
}
@Override
public void onEvent(SsfEventContext eventContext, String eventId, SsfEvent event) {
String eventType = event.getEventType();
SubjectId subjectId = event.getSubjectId();
var eventClass = event.getClass();
LOG.debugf("Security event received. eventId=%s eventType=%s subjectId=%s eventClass=%s", eventId, eventType, subjectId, eventClass.getName());
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
handleSecurityEvent(eventContext, event, realm, subjectId);
}
protected void handleSecurityEvent(SsfEventContext eventContext, SsfEvent ssfEvent, RealmModel realm, SubjectId subjectId) {
if (ssfEvent instanceof SessionRevoked sessionRevoked) {
handleSessionRevokedEvent(eventContext, realm, subjectId, sessionRevoked);
}
}
protected void handleSessionRevokedEvent(SsfEventContext eventContext, RealmModel realm, SubjectId subjectId, SessionRevoked ssfEvent) {
// TODO subject is usually refering to a user, but could also be UserSession, an IdentityProvider, Organization etc. so we might need to be more flexible here
List<UserSessionModel> userSessions = getUserSessions(realm, subjectId);
if (userSessions == null || userSessions.isEmpty()) {
return;
}
// TODO should this only affect online sessions or also offline sessions?
EventBuilder eventBuilder = new EventBuilder(realm, session);
UserModel user = userSessions.get(0).getUser();
for (var userSession : userSessions) {
if (!shouldRemoveUserSession(realm, userSession, eventContext)) {
continue;
}
removeUserSession(realm, userSession, eventContext);
if (isUserEventRecordingEnabled(realm, EventType.USER_SESSION_DELETED)) {
fireUserEvent(eventContext, ssfEvent, userSession, eventBuilder, user);
}
}
LOG.debugf("Removed %s sessions for user. realm=%s userId=%s for SessionRevoked event. reasonAdmin=%s reasonUser=%s",
userSessions.size(), realm.getName(), user.getId(), ssfEvent.getReasonAdmin(), ssfEvent.getReasonUser());
}
protected void removeUserSession(RealmModel realm, UserSessionModel userSession, SsfEventContext eventContext) {
session.sessions().removeUserSession(realm, userSession);
}
protected boolean shouldRemoveUserSession(RealmModel realm, UserSessionModel userSession, SsfEventContext eventContext) {
return true;
}
protected void fireUserEvent(SsfEventContext eventContext, SsfEvent ssfEvent, UserSessionModel userSession, EventBuilder eventBuilder, UserModel user) {
SecurityEventToken securityEventToken = eventContext.getSecurityEventToken();
String rawSubject = extractRawSubjectAsString(securityEventToken);
String rawSecurityEvent = extractSecurityEventAsString(ssfEvent);
eventBuilder.event(EventType.USER_SESSION_DELETED)
.user(user)
.session(userSession.getId())
.detail(Details.REASON, "user_session_revoked")
.detail("ssf_set_jti", securityEventToken.getId())
.detail("ssf_set_txn", securityEventToken.getTxn())
.detail("ssf_set_event_type", ssfEvent.getEventType())
.detail("ssf_set_issuer", securityEventToken.getIssuer())
.detail("ssf_set_event", rawSecurityEvent)
.detail("ssf_set_sub_id", rawSubject)
.detail("ssf_receiver_alias", eventContext.getReceiver().getConfig().getAlias())
.success();
}
protected String extractSecurityEventAsString(SsfEvent ssfEvent) {
String rawSecurityEvent;
try {
rawSecurityEvent = JsonSerialization.writeValueAsString(ssfEvent);
} catch (IOException e) {
rawSecurityEvent = "Failed to serialize SecurityEventToken";
}
return rawSecurityEvent;
}
protected String extractRawSubjectAsString(SecurityEventToken securityEventToken) {
String rawSubject;
try {
rawSubject = JsonSerialization.writeValueAsString(securityEventToken.getSubjectId());
} catch (IOException e) {
rawSubject = "Failed to serialize SubjectId";
}
return rawSubject;
}
protected boolean isUserEventRecordingEnabled(RealmModel realm, EventType eventType) {
return realm.isEventsEnabled() && realm.getEnabledEventTypesStream().anyMatch(type -> eventType.name().equals(type));
}
/**
* Should return the list of user sessions for the user identified via the {@link SubjectId}.
*
* @param realm
* @param subjectId
* @return
*/
protected List<UserSessionModel> getUserSessions(RealmModel realm, SubjectId subjectId) {
UserModel user = resolveUser(realm, subjectId);
if (user == null) {
return null;
}
return session.sessions().getUserSessionsStream(realm, user).toList();
}
/**
* Resolve {@UserModel} from {@link SubjectId}.
*
* @param realm
* @param subjectId
* @return
*/
protected UserModel resolveUser(RealmModel realm, SubjectId subjectId) {
return SubjectUserLookup.lookupUser(session, realm, subjectId);
}
}

View file

@ -0,0 +1,13 @@
package org.keycloak.protocol.ssf.event.listener;
import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
/**
* Handles events delivered via SSF.
*/
public interface SsfEventListener {
void onEvent(SsfEventContext eventContext, String eventId, SsfEvent event);
}

View file

@ -0,0 +1,144 @@
package org.keycloak.protocol.ssf.event.parser;
import java.nio.charset.StandardCharsets;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.keys.PublicKeyStorageProvider;
import org.keycloak.keys.PublicKeyStorageUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.keys.SsfTransmitterPublicKeyLoader;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterMetadata;
import org.jboss.logging.Logger;
/**
* Default implementation of a {@link SsfSecurityEventTokenParser}.
*/
public class DefaultSsfSecurityEventTokenParser implements SsfSecurityEventTokenParser {
protected static final Logger LOG = Logger.getLogger(DefaultSsfSecurityEventTokenParser.class);
protected final KeycloakSession session;
public DefaultSsfSecurityEventTokenParser(KeycloakSession session) {
this.session = session;
}
/**
* Parses the encoded SecurityEventToken in the context of the given {@link SsfReceiver} into a {@link SecurityEventToken}.
*
* The parsing decodes the SecurityEventToken and validates it's signature.
*
* @param encodedSecurityEventToken
* @param receiver
* @return
*/
@Override
public SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfReceiver receiver) {
try {
return decode(encodedSecurityEventToken, receiver);
} catch (Exception e) {
throw new SecurityEventTokenParsingException("Could not parse security event token", e);
}
}
/**
* Decode and validate the given encoded Security Event Token string.
* @param encodedSecurityEventToken
* @param receiver
* @return
*/
protected SecurityEventToken decode(String encodedSecurityEventToken, SsfReceiver receiver) {
if (encodedSecurityEventToken == null) {
return null;
}
try {
JWSInput jws = new JWSInput(encodedSecurityEventToken);
JWSHeader header = jws.getHeader();
String typ = header.getType();
if (!SecurityEventToken.TYPE.equals(typ)) {
throw new SecurityEventTokenParsingException("Invalid SET typ " + typ +". Expected: " + SecurityEventToken.TYPE);
}
String kid = header.getKeyId();
String alg = header.getRawAlgorithm();
KeyWrapper publicKey = getKeyWrapper(receiver, kid, alg);
if (publicKey == null) {
throw new SecurityEventTokenParsingException("Could not find publicKey with kid " + kid);
}
SignatureProvider signatureProvider = resolveSignatureProvider(alg);
if (signatureProvider == null) {
throw new SecurityEventTokenParsingException("Could not find verifier for alg " + alg);
}
byte[] tokenBytes = jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8);
boolean valid = verify(signatureProvider, publicKey, tokenBytes, jws);
if (!valid) {
return null;
}
return jws.readJsonContent(SecurityEventToken.class);
} catch (Exception e) {
LOG.debug("Failed to decode token", e);
return null;
}
}
protected KeyWrapper getKeyWrapper(SsfReceiver receiver, String kid, String alg) {
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), receiver.getConfig().getInternalId());
KeyWrapper publicKey = resolveTransmitterPublicKey(receiver, modelKey, kid, alg);
return publicKey;
}
/**
* Verify the token signature.
* @param signatureProvider
* @param publicKey
* @param tokenBytes
* @param jws
* @return
* @throws VerificationException
*/
protected boolean verify(SignatureProvider signatureProvider, KeyWrapper publicKey, byte[] tokenBytes, JWSInput jws) throws VerificationException {
return signatureProvider.verifier(publicKey)
.verify(tokenBytes, jws.getSignature());
}
/**
* Resolve Signature provider.
* @param alg
* @return
*/
protected SignatureProvider resolveSignatureProvider(String alg) {
return session.getProvider(SignatureProvider.class, alg);
}
/**
* Resolve public key of SSF Transmitter for signature validation.
* @param receiver
* @param modelKey
* @param kid
* @param alg
* @return
*/
protected KeyWrapper resolveTransmitterPublicKey(SsfReceiver receiver, String modelKey, String kid, String alg) {
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
SsfTransmitterMetadata transmitterMetadata = receiver.getTransmitterMetadata();
SsfTransmitterPublicKeyLoader loader = new SsfTransmitterPublicKeyLoader(session, transmitterMetadata);
KeyWrapper publicKey = keyStorage.getPublicKey(modelKey, kid, alg, loader);
return publicKey;
}
}

View file

@ -0,0 +1,14 @@
package org.keycloak.protocol.ssf.event.parser;
import org.keycloak.protocol.ssf.SsfException;
public class SecurityEventTokenParsingException extends SsfException {
public SecurityEventTokenParsingException(String message) {
super(message);
}
public SecurityEventTokenParsingException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,23 @@
package org.keycloak.protocol.ssf.event.parser;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
/**
* Parser for RFC8417 Security Event Token (SET).
*
* @see https://datatracker.ietf.org/doc/html/rfc8417
*/
public interface SsfSecurityEventTokenParser {
/**
* Parses the encoded SecurityEventToken in the context of the given {@link SsfReceiver} into a {@link SecurityEventToken}.
* <p>
* The parsing should decode the SecurityEventToken and validate it's signature.
*
* @param encodedSecurityEventToken
* @param receiver
* @return
*/
SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfReceiver receiver);
}

View file

@ -0,0 +1,271 @@
package org.keycloak.protocol.ssf.event.processor;
import java.util.Map;
import java.util.Set;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.ssf.endpoint.SsfSetPushDeliveryFailureResponse;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.event.SsfStandardEvents;
import org.keycloak.protocol.ssf.event.listener.SsfEventListener;
import org.keycloak.protocol.ssf.event.parser.SecurityEventTokenParsingException;
import org.keycloak.protocol.ssf.event.subjects.OpaqueSubjectId;
import org.keycloak.protocol.ssf.event.subjects.SubjectId;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
import org.keycloak.protocol.ssf.event.types.stream.StreamUpdatedEvent;
import org.keycloak.protocol.ssf.event.types.stream.VerificationEvent;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.registration.SsfReceiverRegistrationProviderConfig;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationException;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationState;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationStore;
import org.jboss.logging.Logger;
import org.keycloak.services.Urls;
import org.keycloak.urls.UrlType;
/**
* Default implementation of a {@link SsfEventProcessor}.
* <p>
* Handles processing of generic SSF events by delegation to {@link SsfEventListener SsfEventListener's} .
* SSF stream related events like the {@link VerificationEvent} and {@link StreamUpdatedEvent} are handled directly by this processor.
*/
public class DefaultSsfEventProcessor implements SsfEventProcessor {
protected static final Logger LOG = Logger.getLogger(DefaultSsfEventProcessor.class);
protected final SsfEventListener ssfEventListener;
protected final SsfStreamVerificationStore verificationStore;
public DefaultSsfEventProcessor(SsfEventListener ssfEventListener, SsfStreamVerificationStore verificationStore) {
this.ssfEventListener = ssfEventListener;
this.verificationStore = verificationStore;
}
@Override
public void processEvents(SecurityEventToken securityEventToken, SsfEventContext eventContext) {
KeycloakSession session = eventContext.getSession();
SsfReceiver receiver = eventContext.getReceiver();
validateSecurityEventToken(securityEventToken, session, receiver);
KeycloakContext keycloakContext = session.getContext();
Map<String, SsfEvent> events = securityEventToken.getEvents();
SsfReceiverRegistrationProviderConfig receiverProviderConfig = receiver.getConfig();
LOG.debugf("Processing SSF events for security event token. realm=%s jti=%s streamId=%s eventCount=%s", keycloakContext.getRealm().getName(), securityEventToken.getId(), receiverProviderConfig.getStreamId(), events.size());
for (var entry : events.entrySet()) {
String eventId = securityEventToken.getId();
String securityEventType = entry.getKey();
SsfEvent securityEventData = entry.getValue();
int successfullyProcessedEventCounter = 0;
try {
SsfEvent ssfEvent = narrowEventPayloadToSecurityEvent(securityEventType, securityEventData, securityEventToken);
if (ssfEvent instanceof VerificationEvent verificationEvent) {
// special case: handle verification event
// See: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#name-verification
if (events.size() > 1) {
LOG.warnf("Found more than one security event for token with verification request. %s", eventId);
}
boolean verified = handleVerificationEvent(eventContext, verificationEvent, eventId);
if (verified) {
successfullyProcessedEventCounter++;
break;
}
} else if (ssfEvent instanceof StreamUpdatedEvent streamUpdatedEvent) {
// special case: handle stream updated event, e.g. for stream enabled -> stream paused / disabled
// See: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#name-stream-updated-event
boolean streamUpdated = handleStreamUpdatedEvent(eventContext, streamUpdatedEvent, eventId, securityEventToken);
eventContext.setProcessedSuccessfully(streamUpdated);
if (streamUpdated) {
successfullyProcessedEventCounter++;
break;
}
} else {
// handle generic SSF event
handleEvent(eventContext, eventId, ssfEvent);
successfullyProcessedEventCounter++;
}
} catch (final SecurityEventTokenParsingException spe) {
eventContext.setProcessedSuccessfully(false);
throw spe;
}
boolean allEventsProcessedSuccessfully = successfullyProcessedEventCounter == events.size();
eventContext.setProcessedSuccessfully(allEventsProcessedSuccessfully);
}
}
/**
* Validate parsed Security Event Token.
* @param securityEventToken
* @param session
* @param receiver
*/
protected void validateSecurityEventToken(SecurityEventToken securityEventToken, KeycloakSession session, SsfReceiver receiver) {
checkIssuer(session, receiver, securityEventToken, securityEventToken.getIssuer());
checkAudience(session, receiver, securityEventToken, securityEventToken.getAudience());
}
protected SsfEvent narrowEventPayloadToSecurityEvent(String eventType, SsfEvent rawSsfEvent, SecurityEventToken securityEventToken) {
Class<? extends SsfEvent> eventClass = getEventType(eventType);
if (eventClass == null) {
throw new SecurityEventTokenParsingException("Could not parse security event. Unknown event type: " + eventType);
}
try {
SsfEvent ssfEvent = eventClass.cast(rawSsfEvent);
ssfEvent.setEventType(eventType);
if (ssfEvent.getSubjectId() == null) {
// use subjectId from SET if none was provided for the event explicitly.
ssfEvent.setSubjectId(securityEventToken.getSubjectId());
}
return ssfEvent;
} catch (Exception e) {
throw new SecurityEventTokenParsingException("Could not narrow security event", e);
}
}
protected Class<? extends SsfEvent> getEventType(String securityEventType) {
return SsfStandardEvents.getSecurityEventType(securityEventType);
}
protected boolean handleVerificationEvent(SsfEventContext eventContext, VerificationEvent verificationEvent, String jti) {
KeycloakContext keycloakContext = eventContext.getSession().getContext();
String streamId = extractStreamIdFromVerificationEvent(eventContext, verificationEvent);
RealmModel realm = keycloakContext.getRealm();
SsfReceiver receiver = eventContext.getReceiver();
SsfReceiverRegistrationProviderConfig receiverProviderConfig = receiver.getConfig();
if (!receiverProviderConfig.getStreamId().equals(streamId)) {
LOG.debugf("Verification failed! StreamId mismatch. jti=%s expectedStreamId=%s actualStreamId=%s", jti, receiverProviderConfig.getStreamId(), streamId);
return false;
}
SsfStreamVerificationState verificationState = getVerificationState(realm, receiver, receiverProviderConfig.getAlias(), receiverProviderConfig.getStreamId());
String givenState = verificationEvent.getState();
String expectedState = verificationState == null ? null : verificationState.getState();
if (givenState.equals(expectedState)) {
LOG.debugf("Verification successful. jti=%s state=%s", jti, givenState);
verificationStore.clearVerificationState(realm, receiverProviderConfig.getAlias(), receiverProviderConfig.getStreamId());
return true;
}
LOG.warnf("Verification failed. jti=%s state=%s", jti, givenState);
return false;
}
protected String extractStreamIdFromVerificationEvent(SsfEventContext eventContext, SsfEvent ssfEvent) {
// see: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-7.1.4.2
String streamId = null;
// See: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-7.1.4.1
// try to extract subjectId from securityEvent
SubjectId subjectId = ssfEvent.getSubjectId();
if (subjectId instanceof OpaqueSubjectId opaqueSubjectId) {
streamId = opaqueSubjectId.getId();
}
if (streamId == null) {
// as a fallback, try to extract subjectId from securityEventToken
subjectId = eventContext.getSecurityEventToken().getSubjectId();
if (subjectId instanceof OpaqueSubjectId opaqueSubjectId) {
streamId = opaqueSubjectId.getId();
}
}
// TODO find a reliable way to extract the streamId from the verification event
if (streamId == null) {
throw new SsfStreamVerificationException("Could not find stream id for verification request");
}
return streamId;
}
protected SsfStreamVerificationState getVerificationState(RealmModel realm, SsfReceiver receiver, String alias, String streamId) {
return verificationStore.getVerificationState(realm, alias, streamId);
}
protected boolean handleStreamUpdatedEvent(SsfEventContext eventContext, StreamUpdatedEvent streamUpdatedEvent, String jti, SecurityEventToken securityEventToken) {
KeycloakContext keycloakContext = eventContext.getSession().getContext();
RealmModel realm = keycloakContext.getRealm();
OpaqueSubjectId opaqueSubjectId = (OpaqueSubjectId) securityEventToken.getSubjectId();
// TODO handle stream status update, do we need to do anything here? currently streams are managed outside of Keycloak.
LOG.debugf("Handled stream updated event. realm=%s jti=%s streamId=%s newStatus=%s", realm.getName(), jti, opaqueSubjectId.getId(), streamUpdatedEvent.getStatus());
return true;
}
/**
* Deleagte generic SSF event handling to {@link SsfEventListener}.
*
* @param
* @param eventId
* @param event
*/
protected void handleEvent(SsfEventContext eventContext, String eventId, SsfEvent event) {
ssfEventListener.onEvent(eventContext, eventId, event);
}
protected void checkIssuer(KeycloakSession session, SsfReceiver receiver, SecurityEventToken securityEventToken, String issuer) {
String expectedIssuer = receiver.getConfig() != null ? receiver.getConfig().getIssuer() : null;
if (!isValidIssuer(receiver, expectedIssuer, issuer)) {
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_INVALID_ISSUER, "Invalid issuer");
}
}
protected void checkAudience(KeycloakSession session, SsfReceiver receiver, SecurityEventToken securityEventToken, String[] audience) {
Set<String> expectedAudience = receiver.getConfig() != null && receiver.getConfig().getStreamAudience() != null ? receiver.getConfig().streamAudience() : null;
if (expectedAudience == null) {
// No expected audience configured for receiver, fallback to realm issuer is no audience is set
String fallbackAudience = getFallbackAudience(session);
expectedAudience = Set.of(fallbackAudience);
}
if (!isValidAudience(receiver, expectedAudience, audience)) {
throw SsfSetPushDeliveryFailureResponse.newFailureResponse(Response.Status.BAD_REQUEST, SsfSetPushDeliveryFailureResponse.ERROR_INVALID_AUDIENCE, "Invalid audience");
}
}
protected String getFallbackAudience(KeycloakSession session) {
UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND);
return Urls.realmIssuer(frontendUriInfo.getBaseUri(), session.getContext().getRealm().getName());
}
protected boolean isValidIssuer(SsfReceiver receiver, String expectedIssuer, String issuer) {
return expectedIssuer.equals(issuer);
}
protected boolean isValidAudience(SsfReceiver receiver, Set<String> expectedAudience, String[] audience) {
return expectedAudience.containsAll(Set.of(audience));
}
}

View file

@ -0,0 +1,51 @@
package org.keycloak.protocol.ssf.event.processor;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
/**
* Context object for SecurityEventToken processing.
*/
public class SsfEventContext {
protected KeycloakSession session;
protected SsfReceiver receiver;
protected SecurityEventToken securityEventToken;
protected boolean processedSuccessfully;
public SecurityEventToken getSecurityEventToken() {
return securityEventToken;
}
public void setSecurityEventToken(SecurityEventToken securityEventToken) {
this.securityEventToken = securityEventToken;
}
protected void setProcessedSuccessfully(boolean processedSuccessfully) {
this.processedSuccessfully = processedSuccessfully;
}
public boolean isProcessedSuccessfully() {
return processedSuccessfully;
}
public KeycloakSession getSession() {
return session;
}
public void setSession(KeycloakSession session) {
this.session = session;
}
public SsfReceiver getReceiver() {
return receiver;
}
public void setReceiver(SsfReceiver receiver) {
this.receiver = receiver;
}
}

View file

@ -0,0 +1,11 @@
package org.keycloak.protocol.ssf.event.processor;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
/**
* Processor for the SsfEvents contained in a {@link SsfEventContext}.
*/
public interface SsfEventProcessor {
void processEvents(SecurityEventToken securityEventToken, SsfEventContext eventContext);
}

View file

@ -0,0 +1,30 @@
package org.keycloak.protocol.ssf.event.subjects;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-email-identifier-format
*/
public class AccountSubjectId extends SubjectId {
public static final String TYPE = "account";
protected String uri;
public AccountSubjectId() {
super(TYPE);
}
public String getUri() {
return uri;
}
public void setUri(String uri) {
this.uri = uri;
}
@Override
public String toString() {
return "AccountSubjectId{" +
"uri='" + uri + '\'' +
'}';
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.protocol.ssf.event.subjects;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-aliases-identifier-format
*/
public class AliasesSubjectId extends SubjectId {
public static final String TYPE = "aliases";
@JsonProperty("identifiers")
protected List<Map<String, String>> identifiers;
public AliasesSubjectId() {
super(TYPE);
}
public List<Map<String, String>> getIdentifiers() {
return identifiers;
}
public void setIdentifiers(List<Map<String, String>> identifiers) {
this.identifiers = identifiers;
}
@Override
public String toString() {
return "AliasesSubjectId{" +
"identifiers=" + identifiers +
'}';
}
}

View file

@ -0,0 +1,128 @@
package org.keycloak.protocol.ssf.event.subjects;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://openid.net/specs/openid-sse-framework-1_0.html#complex-subjects
*/
public class ComplexSubjectId extends SubjectId {
public static final String TYPE = "complex";
/**
* The user involved with the event
*/
@JsonProperty("user")
protected Map<String, String> user;
/**
* The device involved with the event
*/
@JsonProperty("device")
protected Map<String, String> device;
/**
* The session involved with the event
*/
@JsonProperty("session")
protected Map<String, String> session;
/**
* The application involved with the event
*/
@JsonProperty("application")
protected Map<String, String> application;
/**
* The tenant involved with the event
*/
@JsonProperty("tenant")
protected Map<String, String> tenant;
/**
* The org_unit involved with the event
*/
@JsonProperty("org_unit")
protected Map<String, String> orgUnit;
/**
* The group involved with the event
*/
@JsonProperty("group")
protected Map<String, String> group;
public ComplexSubjectId() {
super(TYPE);
}
public Map<String, String> getUser() {
return user;
}
public void setUser(Map<String, String> user) {
this.user = user;
}
public Map<String, String> getDevice() {
return device;
}
public void setDevice(Map<String, String> device) {
this.device = device;
}
public Map<String, String> getSession() {
return session;
}
public void setSession(Map<String, String> session) {
this.session = session;
}
public Map<String, String> getApplication() {
return application;
}
public void setApplication(Map<String, String> application) {
this.application = application;
}
public Map<String, String> getTenant() {
return tenant;
}
public void setTenant(Map<String, String> tenant) {
this.tenant = tenant;
}
public Map<String, String> getOrgUnit() {
return orgUnit;
}
public void setOrgUnit(Map<String, String> orgUnit) {
this.orgUnit = orgUnit;
}
public Map<String, String> getGroup() {
return group;
}
public void setGroup(Map<String, String> group) {
this.group = group;
}
@Override
public String toString() {
return "ComplexSubjectId{" +
"user=" + user +
", device=" + device +
", session=" + session +
", application=" + application +
", tenant=" + tenant +
", orgUnit=" + orgUnit +
", group=" + group +
'}';
}
}

View file

@ -0,0 +1,33 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-decentralized-identifier-di
*/
public class DidSubjectId extends SubjectId {
public static final String DID = "did";
@JsonProperty("url")
protected String url;
public DidSubjectId() {
super(DID);
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
@Override
public String toString() {
return "DidSubjectId{" +
"url='" + url + '\'' +
'}';
}
}

View file

@ -0,0 +1,33 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-email-identifier-format
*/
public class EmailSubjectId extends SubjectId {
public static final String TYPE = "email";
@JsonProperty("email")
protected String email;
public EmailSubjectId() {
super(TYPE);
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public String toString() {
return "EmailSubjectId{" +
"email='" + email + '\'' +
'}';
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.subjects;
public class GenericSubjectId extends SubjectId {
public GenericSubjectId() {
super(null);
}
@Override
public String toString() {
return "GenericSubjectId{" +
"format='" + format + '\'' +
", attributes=" + attributes +
'}';
}
}

View file

@ -0,0 +1,45 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-issuer-and-subject-identifi
*/
public class IssuerSubjectId extends SubjectId {
public static final String TYPE = "iss_sub";
@JsonProperty("iss")
protected String iss;
@JsonProperty("sub")
protected String sub;
public IssuerSubjectId() {
super(TYPE);
}
public String getIss() {
return iss;
}
public void setIss(String iss) {
this.iss = iss;
}
public String getSub() {
return sub;
}
public void setSub(String sub) {
this.sub = sub;
}
@Override
public String toString() {
return "IssuerSubjectId{" +
"iss='" + iss + '\'' +
", sub='" + sub + '\'' +
'}';
}
}

View file

@ -0,0 +1,45 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://openid.net/specs/openid-sse-framework-1_0.html#sub-id-jwt-id
*/
public class JwtSubjectId extends SubjectId {
public static final String TYPE = "jwt_id";
@JsonProperty("iss")
protected String iss;
@JsonProperty("jti")
protected String jti;
public JwtSubjectId() {
super(TYPE);
}
public String getIss() {
return iss;
}
public void setIss(String iss) {
this.iss = iss;
}
public String getJti() {
return jti;
}
public void setJti(String jti) {
this.jti = jti;
}
@Override
public String toString() {
return "JwtSubjectId{" +
"iss='" + iss + '\'' +
", jti='" + jti + '\'' +
'}';
}
}

View file

@ -0,0 +1,33 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-opaque-identifier-format
*/
public class OpaqueSubjectId extends SubjectId {
public static final String TYPE = "opaque";
@JsonProperty("id")
protected String id;
public OpaqueSubjectId() {
super(TYPE);
}
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
@Override
public String toString() {
return "OpaqueSubjectId{" +
"id='" + id + '\'' +
'}';
}
}

View file

@ -0,0 +1,33 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-phone-number-identifier-for
*/
public class PhoneNumberSubjectId extends SubjectId {
public static final String TYPE = "phone_number";
@JsonProperty("phone_number")
protected String phoneNumber;
public PhoneNumberSubjectId() {
super(TYPE);
}
public String getPhoneNumber() {
return phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
public String toString() {
return "PhoneNumberSubjectId{" +
"phoneNumber='" + phoneNumber + '\'' +
'}';
}
}

View file

@ -0,0 +1,29 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://openid.net/specs/openid-sse-framework-1_0.html#sub-id-saml-assertion-id
*/
public class SamlAssertionSubjectId extends SubjectId {
public static final String TYPE = "saml_assertion_id";
@JsonProperty("issuer")
protected String issuer;
@JsonProperty("assertion_id")
protected String assertionId;
public SamlAssertionSubjectId() {
super(TYPE);
}
@Override
public String toString() {
return "SamlAssertionSubjectId{" +
"issuer='" + issuer + '\'' +
", assertionId='" + assertionId + '\'' +
'}';
}
}

View file

@ -0,0 +1,48 @@
package org.keycloak.protocol.ssf.event.subjects;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* A Subject Identifier is structured information that describes a subject related to a security event, using named
* formats to define its encoding as JSON objects within Security Event Tokens.
*
* See: https://datatracker.ietf.org/doc/html/rfc9493
*/
public abstract class SubjectId {
@JsonProperty("format")
protected String format;
@JsonIgnore
protected Map<String, Object> attributes = new HashMap<>();
public SubjectId(String format) {
this.format = format;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
@JsonAnySetter
public void setAttribute(String key, Object value) {
attributes.put(key, value);
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
}

View file

@ -0,0 +1,52 @@
package org.keycloak.protocol.ssf.event.subjects;
import java.io.IOException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Custom dezerializer to deal with legacy SubjectIds.
*/
public class SubjectIdJsonDeserializer extends JsonDeserializer<SubjectId> {
@Override
public SubjectId deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
ObjectMapper mapper = (ObjectMapper) p.getCodec();
JsonNode node = mapper.readTree(p);
// Extract the format field
JsonNode formatNode = node.get("format");
boolean legacyRiscEventType = false;
if (formatNode == null) {
// legacy subject type format for older OpenID RISC Event types, see: https://openid.net/specs/openid-risc-event-types-1_0.html
formatNode = node.get("subject_type");
if (formatNode != null && formatNode.isTextual()) {
legacyRiscEventType = true;
}
}
if (formatNode == null || !formatNode.isTextual()) {
throw new IOException("Missing or invalid 'format' field in SubjectId");
}
String format = formatNode.asText();
if (legacyRiscEventType) {
// legacy subject type format for older OpenID RISC Event types, see: https://openid.net/specs/openid-risc-event-types-1_0.html
format = format.replace("-","_");
}
Class<? extends SubjectId> subjectClass = SubjectIds.getSubjectIdType(format);
if (subjectClass == null) {
throw new SubjectParsingException("Unknown SubjectId format: " + format);
}
SubjectId subjectId = mapper.treeToValue(node, subjectClass);
subjectId.setFormat(format);
return subjectId;
}
}

View file

@ -0,0 +1,44 @@
package org.keycloak.protocol.ssf.event.subjects;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Registry of SubjectId formats defined in RFC9493 Subject Identifiers.
* <p>
* See: https://datatracker.ietf.org/doc/html/rfc9493
*/
public class SubjectIds {
/**
* Holds all known standard SUBJECT_ID_FORMATS
*/
public final static Map<String, Class<? extends SubjectId>> SUBJECT_ID_FORMAT_TYPES;
static {
var map = new HashMap<String, Class<? extends SubjectId>>();
List.of(//
new AccountSubjectId(), //
new AliasesSubjectId(), //
new ComplexSubjectId(), //
new DidSubjectId(), //
new EmailSubjectId(), //
new IssuerSubjectId(), //
new JwtSubjectId(), //
new OpaqueSubjectId(), //
new PhoneNumberSubjectId(), //
new SamlAssertionSubjectId(), //
new UriSubjectId() //
).forEach(subjectId -> map.put(subjectId.getFormat(), subjectId.getClass()));
SUBJECT_ID_FORMAT_TYPES = map;
}
public static Class<? extends SubjectId> getSubjectIdType(String format) {
var subjectIdType = SUBJECT_ID_FORMAT_TYPES.get(format);
if (subjectIdType != null) {
return subjectIdType;
}
return GenericSubjectId.class;
}
}

View file

@ -0,0 +1,17 @@
package org.keycloak.protocol.ssf.event.subjects;
import org.keycloak.protocol.ssf.SsfException;
public class SubjectParsingException extends SsfException {
public SubjectParsingException() {
}
public SubjectParsingException(String message) {
super(message);
}
public SubjectParsingException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,57 @@
package org.keycloak.protocol.ssf.event.subjects;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.Urls;
import org.keycloak.urls.UrlType;
import org.jboss.logging.Logger;
public class SubjectUserLookup {
protected static final Logger log = Logger.getLogger(SubjectUserLookup.class);
public static UserModel lookupUser(KeycloakSession session, RealmModel realm, SubjectId subjectId) {
if (subjectId instanceof EmailSubjectId) {
return getUserByEmail(session, realm, ((EmailSubjectId) subjectId).getEmail());
}
if (subjectId instanceof OpaqueSubjectId) {
return getUserById(session, realm, ((OpaqueSubjectId) subjectId).getId());
}
if (subjectId instanceof IssuerSubjectId) {
var issuerSubjectId = (IssuerSubjectId) subjectId;
return getUserByIssuerSub(session, realm, issuerSubjectId.getIss(), issuerSubjectId.getSub());
}
log.warnf("Lookup failed for unknown subject id type. subjectId=%s", subjectId);
return null;
}
private static UserModel getUserByIssuerSub(KeycloakSession session, RealmModel realm, String iss, String sub) {
UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND);
String realmIssuer = Urls.realmIssuer(frontendUriInfo.getBaseUri(), session.getContext().getRealm().getName());
// TODO fixme cannot create current realmIssuer in async call context
if (realmIssuer.equals(iss)) {
return getUserById(session, realm, sub);
}
// TODO lookup user by identity provider links via session.identityProviders()
// session.users().getUserByFederatedIdentity(realm, new FederatedIdentityModel())
return null;
}
private static UserModel getUserById(KeycloakSession session, RealmModel realm, String userId) {
return session.users().getUserById(realm, userId);
}
private static UserModel getUserByEmail(KeycloakSession session, RealmModel realm, String email) {
return session.users().getUserByEmail(realm, email);
}
}

View file

@ -0,0 +1,33 @@
package org.keycloak.protocol.ssf.event.subjects;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* See: https://datatracker.ietf.org/doc/html/rfc9493#section-3.2.7
*/
public class UriSubjectId extends SubjectId {
public static final String TYPE = "uri";
@JsonProperty("uri")
protected String uri;
public UriSubjectId() {
super(TYPE);
}
public String getUri() {
return uri;
}
public void setUri(String uri) {
this.uri = uri;
}
@Override
public String toString() {
return "UriSubjectId{" +
"uri='" + uri + '\'' +
'}';
}
}

View file

@ -0,0 +1,24 @@
package org.keycloak.protocol.ssf.event.types;
/**
* Fallback {@link SsfEvent} if we encounter an unknown SsfEvent type.
*/
public class GenericSsfEvent extends SsfEvent {
public GenericSsfEvent() {
super(null);
}
@Override
public String toString() {
return "GenericSecurityEvent{" +
"subjectId=" + subjectId +
", eventType='" + eventType + '\'' +
", eventTimestamp=" + eventTimestamp +
", initiatingEntity=" + initiatingEntity +
", reasonAdmin=" + reasonAdmin +
", reasonUser=" + reasonUser +
", attributes=" + attributes +
'}';
}
}

View file

@ -0,0 +1,22 @@
package org.keycloak.protocol.ssf.event.types;
import com.fasterxml.jackson.annotation.JsonValue;
public enum InitiatingEntity {
ADMIN("admin"),
USER("user"),
POLICY("policy"),
SYSTEM("system"),
;
private final String code;
InitiatingEntity(String code) {
this.code = code;
}
@JsonValue
public String getCode() {
return code;
}
}

View file

@ -0,0 +1,121 @@
package org.keycloak.protocol.ssf.event.types;
import java.util.HashMap;
import java.util.Map;
import org.keycloak.protocol.ssf.event.subjects.SubjectId;
import org.keycloak.protocol.ssf.event.subjects.SubjectIdJsonDeserializer;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/**
* Represents a generic SSF event.
*
* See: https://datatracker.ietf.org/doc/html/rfc8417
*/
public abstract class SsfEvent {
@JsonProperty("subject")
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
protected SubjectId subjectId;
@JsonIgnore
protected String eventType;
/**
* The time of the event (UNIX timestamp)
*/
@JsonProperty("event_timestamp")
protected long eventTimestamp;
/**
* The entity that initiated the event
*/
@JsonProperty("initiating_entity")
protected InitiatingEntity initiatingEntity;
/**
* A localized administrative message intended for logging and auditing.
* key is language code, value is message.
*/
@JsonProperty("reason_admin")
protected Map<String, String> reasonAdmin;
/**
* A localized message intended for the end user.
* key is language code, value is message.
*/
@JsonProperty("reason_user")
protected Map<String, String> reasonUser;
@JsonIgnore
protected Map<String, Object> attributes = new HashMap<>();
public SsfEvent(String eventType) {
this.eventType = eventType;
}
public SubjectId getSubjectId() {
return subjectId;
}
public long getEventTimestamp() {
return eventTimestamp;
}
public void setEventTimestamp(long eventTimestamp) {
this.eventTimestamp = eventTimestamp;
}
public InitiatingEntity getInitiatingEntity() {
return initiatingEntity;
}
public void setInitiatingEntity(InitiatingEntity initiatingEntity) {
this.initiatingEntity = initiatingEntity;
}
public Map<String, String> getReasonAdmin() {
return reasonAdmin;
}
public void setReasonAdmin(Map<String, String> reasonAdmin) {
this.reasonAdmin = reasonAdmin;
}
public Map<String, String> getReasonUser() {
return reasonUser;
}
public void setReasonUser(Map<String, String> reasonUser) {
this.reasonUser = reasonUser;
}
public String getEventType() {
return eventType;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
@JsonAnySetter
public void setAttributeValue(String key, Object value) {
attributes.put(key, value);
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public void setSubjectId(SubjectId subjectId) {
this.subjectId = subjectId;
}
}

View file

@ -0,0 +1,63 @@
package org.keycloak.protocol.ssf.event.types;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.keycloak.protocol.ssf.event.SsfStandardEvents;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Custom deserializer for Security Events.
* <pre>
* "events" (Security Events) Claim
* This claim contains a set of event statements that each provide
* information describing a single logical event that has occurred
* about a security subject (e.g., a state change to the subject).
* Multiple event identifiers with the same value MUST NOT be used.
* The "events" claim MUST NOT be used to express multiple
* independent logical events.
*
* The value of the "events" claim is a JSON object whose members are
* name/value pairs whose names are URIs identifying the event
* statements being expressed. Event identifiers SHOULD be stable
* values (e.g., a permanent URL for an event specification). For
* each name present, the corresponding value MUST be a JSON object.
* The JSON object MAY be an empty object ("{}"), or it MAY be a JSON
* object containing data described by the profiling specification.
* </pre>
* See: https://datatracker.ietf.org/doc/html/rfc8417#section-2.2
*/
public class SsfEventMapJsonDeserializer extends JsonDeserializer<Map<String, SsfEvent>> {
@Override
public Map<String, SsfEvent> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
ObjectMapper mapper = (ObjectMapper) p.getCodec();
JsonNode node = mapper.readTree(p);
Map<String, SsfEvent> eventsMap = new HashMap<>();
for (Map.Entry<String, JsonNode> entry : node.properties()) {
String eventType = entry.getKey(); // Extracts event type key
JsonNode eventData = entry.getValue(); // Extracts event data
Class<? extends SsfEvent> eventClass = SsfStandardEvents.getSecurityEventType(eventType);
if (eventClass == null) {
throw new IOException("Unknown event type: " + eventType);
}
SsfEvent event = mapper.treeToValue(eventData, eventClass);
event.setEventType(eventType); // Manually set event type since it's not in JSON
eventsMap.put(eventType, event);
}
return eventsMap;
}
}

View file

@ -0,0 +1,87 @@
package org.keycloak.protocol.ssf.event.types.caep;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* The Assurance Level Change event signals that there has been a change in authentication method since the initial user login. This change can be from a weak authentication method to a strong authentication method, or vice versa.
*/
public class AssuranceLevelChange extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-assurance-level-change
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/assurance-level-change";
/**
* The namespace of the values in the current_level and previous_level claims.
*/
@JsonProperty("namespace")
protected String namespace;
/**
* The current assurance level, as defined in the specified namespace
*/
@JsonProperty("current_level")
protected String currentLevel;
/**
* The previous assurance level, as defined in the specified namespace If the Transmitter omits this value, the Receiver MUST assume that the previous assurance level is unknown to the Transmitter
*/
@JsonProperty("previous_level")
protected String previousLevel;
/**
* The assurance level increased or decreased If the Transmitter has specified the previous_level, then the Transmitter SHOULD provide a value for this claim. If present, this MUST be one of the following strings:
* increase, decrease.
*/
@JsonProperty("change_direction")
protected ChangeDirection changeDirection;
public AssuranceLevelChange() {
super(TYPE);
}
public String getNamespace() {
return namespace;
}
public void setNamespace(String namespace) {
this.namespace = namespace;
}
public ChangeDirection getChangeDirection() {
return changeDirection;
}
public void setChangeDirection(ChangeDirection changeDirection) {
this.changeDirection = changeDirection;
}
public enum ChangeDirection {
INCREASE("increase"),
DECREASE("decrease");
private final String type;
ChangeDirection(String type) {
this.type = type;
}
@JsonValue
public String getType() {
return type;
}
}
@Override
public String toString() {
return "AssuranceLevelChange{" +
"namespace='" + namespace + '\'' +
", currentLevel='" + currentLevel + '\'' +
", previousLevel='" + previousLevel + '\'' +
", changeDirection=" + changeDirection +
'}';
}
}

View file

@ -0,0 +1,15 @@
package org.keycloak.protocol.ssf.event.types.caep;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
/**
* Generic CaepEvent.
*
* See: https://openid.net/specs/openid-caep-1_0-final.html
*/
public abstract class CaepEvent extends SsfEvent {
public CaepEvent(String type) {
super(type);
}
}

View file

@ -0,0 +1,45 @@
package org.keycloak.protocol.ssf.event.types.caep;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
/**
* The official change types are create, revoke, update, deleted, but some legacy implementations use created etc.
* See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1
*/
public class ChangeTypeDeserializer extends JsonDeserializer<CredentialChange.ChangeType> {
private static final Map<String, CredentialChange.ChangeType> CHANGE_TYPE_MAP = new HashMap<>();
static {
// some existing SSF transmitters use (older) non standard change type identifiers.
CHANGE_TYPE_MAP.put("create", CredentialChange.ChangeType.CREATE);
CHANGE_TYPE_MAP.put("created", CredentialChange.ChangeType.CREATE); // Handle non-standard form
CHANGE_TYPE_MAP.put("revoke", CredentialChange.ChangeType.REVOKE);
CHANGE_TYPE_MAP.put("revoked", CredentialChange.ChangeType.REVOKE); // Handle non-standard form
CHANGE_TYPE_MAP.put("update", CredentialChange.ChangeType.UPDATE);
CHANGE_TYPE_MAP.put("updated", CredentialChange.ChangeType.UPDATE); // Handle non-standard form
CHANGE_TYPE_MAP.put("delete", CredentialChange.ChangeType.DELETE);
CHANGE_TYPE_MAP.put("deleted", CredentialChange.ChangeType.DELETE); // Handle non-standard form
}
@Override
public CredentialChange.ChangeType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String value = p.getText().toLowerCase(); // Normalize input
CredentialChange.ChangeType changeType = CHANGE_TYPE_MAP.get(value);
if (changeType == null) {
throw new IOException("Unknown changeType value: " + value);
}
return changeType;
}
}

View file

@ -0,0 +1,183 @@
package org.keycloak.protocol.ssf.event.types.caep;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/**
* The Credential Change event signals that a credential was created, changed, revoked or deleted.
*/
public class CredentialChange extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-credential-change
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/credential-change";
/**
* This MUST be one of the following strings, or any other credential type supported mutually by the Transmitter and the Receiver.
* password
* pin
* x509
* fido2-platform
* fido2-roaming
* fido-u2f
* verifiable-credential
* phone-voice
* phone-sms
* app
*/
@JsonProperty("credential_type")
protected CredentialType credentialType;
/**
* This MUST be one of the following strings:
*
* create
* revoke
* update
* delete
*/
@JsonProperty("change_type")
@JsonDeserialize(using = ChangeTypeDeserializer.class)
protected ChangeType changeType;
/**
* credential friendly name
*/
@JsonProperty("friendly_name")
protected String friendlyName;
/**
* issuer of the X.509 certificate as defined in [RFC5280]
*/
@JsonProperty("x509_issuer")
protected String x509Issuer;
/**
* serial number of the X.509 certificate as defined in [RFC5280]
*/
@JsonProperty("x509_serial")
protected String x509Serial;
/**
* FIDO2 Authenticator Attestation GUID as defined in [WebAuthn]
*/
@JsonProperty("fido2_aaguid")
protected String fido2Aaguid;
public CredentialChange() {
super(TYPE);
}
public CredentialType getCredentialType() {
return credentialType;
}
public void setCredentialType(CredentialType credentialType) {
this.credentialType = credentialType;
}
public ChangeType getChangeType() {
return changeType;
}
public void setChangeType(ChangeType changeType) {
this.changeType = changeType;
}
public String getFriendlyName() {
return friendlyName;
}
public void setFriendlyName(String friendlyName) {
this.friendlyName = friendlyName;
}
public String getX509Issuer() {
return x509Issuer;
}
public void setX509Issuer(String x509Issuer) {
this.x509Issuer = x509Issuer;
}
public String getX509Serial() {
return x509Serial;
}
public void setX509Serial(String x509Serial) {
this.x509Serial = x509Serial;
}
public String getFido2Aaguid() {
return fido2Aaguid;
}
public void setFido2Aaguid(String fido2Aaguid) {
this.fido2Aaguid = fido2Aaguid;
}
/**
* See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1
*/
public enum CredentialType {
PASSWORD("password"),
PIN("pin"),
X509("x509"),
FIDO2_PLATFORM("fido2-platform"),
FIDO2_ROAMING("fido2-roaming"),
FIDO2_U2F("fido-u2f"),
VERIFIABLE_CREDENTIAL("verifiable-credential"),
PHONE_VOICE("phone-voice"),
PHONE_SMS("phone-sms"),
APP("app");
private final String type;
CredentialType(String type) {
this.type = type;
}
@JsonValue
public String getType() {
return type;
}
}
/**
* See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1
*/
public enum ChangeType {
CREATE("create"),
REVOKE("revoke"),
UPDATE("update"),
DELETE("delete");
private final String type;
ChangeType(String type) {
this.type = type;
}
@JsonValue
public String getType() {
return type;
}
}
@Override
public String toString() {
return "CredentialChange{" +
"credentialType=" + credentialType +
", changeType=" + changeType +
", friendlyName='" + friendlyName + '\'' +
", x509Issuer='" + x509Issuer + '\'' +
", x509Serial='" + x509Serial + '\'' +
", fido2Aaguid='" + fido2Aaguid + '\'' +
'}';
}
}

View file

@ -0,0 +1,73 @@
package org.keycloak.protocol.ssf.event.types.caep;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* Device Compliance Change signals that a device's compliance status has changed.
*/
public class DeviceComplianceChange extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-device-compliance-change
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/device-compliance-change";
/**
* The compliance status prior to the change that triggered the event
* This MUST be one of the following strings: compliant, not-compliant
*/
@JsonProperty("previous_status")
protected ComplianceChange previousStatus;
/**
* The current status that triggered the event.
*/
@JsonProperty("current_status")
protected ComplianceChange currentStatus;
public DeviceComplianceChange() {
super(TYPE);
}
public ComplianceChange getPreviousStatus() {
return previousStatus;
}
public void setPreviousStatus(ComplianceChange previousStatus) {
this.previousStatus = previousStatus;
}
public ComplianceChange getCurrentStatus() {
return currentStatus;
}
public void setCurrentStatus(ComplianceChange currentStatus) {
this.currentStatus = currentStatus;
}
public enum ComplianceChange {
COMPLIANT("compliant"),
NOT_COMPLIANT("not-compliant");
private final String type;
ComplianceChange(String type) {
this.type = type;
}
@JsonValue
public String getType() {
return type;
}
}
@Override
public String toString() {
return "DeviceComplianceChange{" +
"previousStatus=" + previousStatus +
", currentStatus=" + currentStatus +
'}';
}
}

View file

@ -0,0 +1,86 @@
package org.keycloak.protocol.ssf.event.types.caep;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* A vendor may deploy mechanisms to gather and analyze various signals associated with subjects such as users, devices, etc. These signals, which can originate from diverse channels and methods beyond the scope of this event description, are processed to derive an abstracted risk level representing the subject's current threat status.
*
* The Risk Level Change event is employed by the Transmitter to communicate any modifications in a subject's assessed risk level at the time indicated by the event_timestamp field in the Risk Level Change event. The Transmitter may generate this event to indicate:
*/
public class RiskLevelChanged extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#section-3.8
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change";
/**
* Indicates the reason that contributed to the risk level changes by the Transmitter.
*/
@JsonProperty("risk_reason")
protected String riskReason;
/**
* Representing the principal entity involved in the observed risk event, as identified by the transmitter. The subject principal can be one of the following entities USER, DEVICE, SESSION, TENANT, ORG_UNIT, GROUP, or any other entity as defined in Section 2 of [SSF]. This claim identifies the primary subject associated with the event, and helps to contextualize the risk relative to the entity involved.
*/
@JsonProperty("principal")
protected String principal;
/**
* Indicates the current level of the risk for the subject. Value MUST be one of LOW, MEDIUM, HIGH
*/
@JsonProperty("current_level")
protected String currentLevel;
/**
* Indicates the previously known level of the risk for the subject. Value MUST be one of LOW, MEDIUM, HIGH. If the Transmitter omits this value, the Receiver MUST assume that the previous risk level is unknown to the Transmitter.
*/
@JsonProperty("previous_level")
protected String previousLevel;
public RiskLevelChanged() {
super(TYPE);
}
public String getRiskReason() {
return riskReason;
}
public void setRiskReason(String riskReason) {
this.riskReason = riskReason;
}
public String getPrincipal() {
return principal;
}
public void setPrincipal(String principal) {
this.principal = principal;
}
public String getCurrentLevel() {
return currentLevel;
}
public void setCurrentLevel(String currentLevel) {
this.currentLevel = currentLevel;
}
public String getPreviousLevel() {
return previousLevel;
}
public void setPreviousLevel(String previousLevel) {
this.previousLevel = previousLevel;
}
@Override
public String toString() {
return "RiskLevelChanged{" +
"riskReason='" + riskReason + '\'' +
", principal='" + principal + '\'' +
", currentLevel='" + currentLevel + '\'' +
", previousLevel='" + previousLevel + '\'' +
'}';
}
}

View file

@ -0,0 +1,108 @@
package org.keycloak.protocol.ssf.event.types.caep;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The Session Established event signifies that the Transmitter has established a new session for the subject.
* Receivers may use this information for a number of reasons, including:
* <ul>
* <li>A service acting as a Transmitter can close the loop with the IdP after a user has been federated from the IdP</li>
* <li>An IdP can detect unintended logins</li>
* <li>A Receiver can establish an inventory of user sessions</li>
* </ul>
* The event_timestamp in this event type specifies the time at which the session was established.
*/
public class SessionEstablished extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-session-established
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/session-established";
/**
* The array of IP addresses of the user as observed by the Transmitter. The value MUST be in the format of an array of strings, each one of which represents the RFC 4001 [RFC4001] string representation of an IP address. (NOTE, this can be different from the one observed by the Receiver for the same user because of network translation).
*/
@JsonProperty("ips")
protected Set<String> ips;
/**
* Fingerprint of the user agent computed by the Transmitter. (NOTE, this is not to identify the session, but to present some qualities of the session)
*/
@JsonProperty("fp_ua")
protected String fingerPrintUserAgent;
/**
* The authentication context class reference of the session, as established by the Transmitter. The value of this field MUST be interpreted in the same way as the corresponding field in an OpenID Connect ID Token [OpenID.Core]
*/
@JsonProperty("acr")
protected String acr;
/**
* The authentication methods reference of the session, as established by the Transmitter. The value of this field MUST be an array of strings, each of which MUST be interpreted in the same way as the corresponding field in an OpenID Connect ID Token [OpenID.Core]
*/
@JsonProperty("amr")
protected String amr;
/**
* The external session identifier, which may be used to correlate this session with a broader session (e.g., a federated session established using SAML)
*/
@JsonProperty("ext_id")
protected String extId;
public SessionEstablished() {
super(TYPE);
}
public Set<String> getIps() {
return ips;
}
public void setIps(Set<String> ips) {
this.ips = ips;
}
public String getFingerPrintUserAgent() {
return fingerPrintUserAgent;
}
public void setFingerPrintUserAgent(String fingerPrintUserAgent) {
this.fingerPrintUserAgent = fingerPrintUserAgent;
}
public String getAcr() {
return acr;
}
public void setAcr(String acr) {
this.acr = acr;
}
public String getAmr() {
return amr;
}
public void setAmr(String amr) {
this.amr = amr;
}
public String getExtId() {
return extId;
}
public void setExtId(String extId) {
this.extId = extId;
}
@Override
public String toString() {
return "SessionEstablished{" +
"ips=" + ips +
", fingerPrintUserAgent='" + fingerPrintUserAgent + '\'' +
", acr='" + acr + '\'' +
", amr='" + amr + '\'' +
", extId='" + extId + '\'' +
'}';
}
}

View file

@ -0,0 +1,76 @@
package org.keycloak.protocol.ssf.event.types.caep;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The Session Presented event signifies that the Transmitter has observed the session to be present at the Transmitter at the time indicated by the event_timestamp field in the Session Presented event.
* Receivers may use this information for reasons that include:
*<ul>
* <li>Detecting abnormal user activity</li>
* <li>Establishing an inventory of live sessions belonging to a user</li>
* </ul>
*/
public class SessionPresented extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-session-established
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/session-presented";
/**
* The array of IP addresses of the user as observed by the Transmitter. The value MUST be in the format of an array of strings, each one of which represents the RFC 4001 [RFC4001] string representation of an IP address. (NOTE, this can be different from the one observed by the Receiver for the same user because of network translation).
*/
@JsonProperty("ips")
protected Set<String> ips;
/**
* Fingerprint of the user agent computed by the Transmitter. (NOTE, this is not to identify the session, but to present some qualities of the session).
*/
@JsonProperty("fp_ua")
protected String fingerPrintUserAgent;
/**
* The external session identifier, which may be used to correlate this session with a broader session (e.g., a federated session established using SAML).
*/
@JsonProperty("ext_id")
protected String extId;
public SessionPresented() {
super(TYPE);
}
public Set<String> getIps() {
return ips;
}
public void setIps(Set<String> ips) {
this.ips = ips;
}
public String getFingerPrintUserAgent() {
return fingerPrintUserAgent;
}
public void setFingerPrintUserAgent(String fingerPrintUserAgent) {
this.fingerPrintUserAgent = fingerPrintUserAgent;
}
public String getExtId() {
return extId;
}
public void setExtId(String extId) {
this.extId = extId;
}
@Override
public String toString() {
return "SessionPresented{" +
"ips=" + ips +
", fingerPrintUserAgent='" + fingerPrintUserAgent + '\'' +
", extId='" + extId + '\'' +
'}';
}
}

View file

@ -0,0 +1,21 @@
package org.keycloak.protocol.ssf.event.types.caep;
/**
* Session Revoked signals that the session identified by the subject has been revoked. The explicit session identifier may be directly referenced in the subject or other properties of the session may be included to allow the receiver to identify applicable sessions.
*/
public class SessionRevoked extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-session-revoked
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/session-revoked";
public SessionRevoked() {
super(TYPE);
}
@Override
public String toString() {
return "SessionRevoked{}";
}
}

View file

@ -0,0 +1,41 @@
package org.keycloak.protocol.ssf.event.types.caep;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Token Claims Change signals that a claim in a token, identified by the subject claim, has changed.
*/
public class TokenClaimsChanged extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-token-claims-change
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/token-claims-change";
/**
* One or more claims with their new value(s)
*/
@JsonProperty("claims")
protected Map<String, Object> claims;
public TokenClaimsChanged() {
super(TYPE);
}
public Map<String, Object> getClaims() {
return claims;
}
public void setClaims(Map<String, Object> claims) {
this.claims = claims;
}
@Override
public String toString() {
return "TokenClaimsChanged{" +
"claims=" + claims +
'}';
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Account Credential Change Required signals that the account identified by the subject was required to change a credential. For example the user was required to go through a password change.
*/
public class AccountCredentialChangeRequired extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.1
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required";
public AccountCredentialChangeRequired() {
super(TYPE);
}
}

View file

@ -0,0 +1,35 @@
package org.keycloak.protocol.ssf.event.types.risc;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Account Disabled signals that the account identified by the subject has been disabled. The actual reason why the account was disabled might be specified with the nested reason attribute described below. The account may be enabled in the future.
*/
public class AccountDisabled extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.3
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/account-disabled";
/**
* optional, describes why was the account disabled.
* Possible values:
* - hijacking
* - bulk-account
*/
@JsonProperty("reason")
private String reason;
public AccountDisabled() {
super(TYPE);
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Account Enabled signals that the account identified by the subject has been enabled.
*/
public class AccountEnabled extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.4
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/account-enabled";
public AccountEnabled() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Account Purged signals that the account identified by the subject has been permanently deleted.
*/
public class AccountPurged extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.2
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/account-purged";
public AccountPurged() {
super(TYPE);
}
}

View file

@ -0,0 +1,32 @@
package org.keycloak.protocol.ssf.event.types.risc;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* A Credential Compromise event signals that the identifier specified in the subject was found to be compromised.
*/
public class CredentialCompromise extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.7
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/credential-compromise";
/**
* REQUIRED. The type of credential that is compromised. The value of this attribute must be one of the values specified for the similarly named field in the Credential Change event defined in the CAEP Specification.
*/
@JsonProperty("credential_type")
private String credentialType;
public CredentialCompromise() {
super(TYPE);
}
public String getCredentialType() {
return credentialType;
}
public void setCredentialType(String credentialType) {
this.credentialType = credentialType;
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.protocol.ssf.event.types.risc;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Identifier Changed signals that the identifier specified in the subject has changed. The subject type MUST be either email or phone, and it MUST specify the old value.
*
* This event SHOULD be issued only by the provider that is authoritative over the identifier. For example, if the person that owns john.doe@example.com goes through a name change and wants the new john.row@example.com email then only the email provider example.com SHOULD issue an Identifier Changed event as shown in the example below.
*
* If an identifier used as a username or recovery option is changed, at a provider that is not authoritative over that identifier, then Recovery Information Changed SHOULD be used instead.
*/
public class IdentifierChanged extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.5
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/identifier-changed";
/**
* optional, the new value of the identifier.
*/
@JsonProperty("new-value")
private String newValue;
public IdentifierChanged() {
super(TYPE);
}
public String getNewValue() {
return newValue;
}
public void setNewValue(String newValue) {
this.newValue = newValue;
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Identifier Recycled signals that the identifier specified in the subject was recycled, and now it belongs to a new user. The subject type MUST be either email or phone.
*/
public class IdentifierRecycled extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.6
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/identifier-recycled";
public IdentifierRecycled() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Opt In signals that the account identified by the subject opted into RISC event exchanges. The account is in the opt-in state.
*/
public class OptIn extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.8.1
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/opt-in";
public OptIn() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Opt Out Cancelled signals that the account identified by the subject cancelled the opt-out from RISC event exchanges. The account is in the opt-in state.
*/
public class OptOutCancelled extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.8.3
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/opt-out-cancelled";
public OptOutCancelled() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Opt Out Effective signals that the account identified by the subject was effectively opted out from RISC event exchanges. The account is in the opt-out state.
*/
public class OptOutEffective extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.8.4
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/opt-out-effective";
public OptOutEffective() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Opt Out Initiated signals that the account identified by the subject initiated to opt out from RISC event exchanges. The account is in the opt-out-initiated state.
*/
public class OptOutInitiated extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.8.1
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/opt-out-initiated";
public OptOutInitiated() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Recovery Activated signals that the account identified by the subject activated a recovery flow.
*/
public class RecoveryActivated extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.9
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/recovery-activated";
public RecoveryActivated() {
super(TYPE);
}
}

View file

@ -0,0 +1,16 @@
package org.keycloak.protocol.ssf.event.types.risc;
/**
* Recovery Information Changed signals that the account identified by the subject has changed some of its recovery information. For example a recovery email address was added or removed.
*/
public class RecoveryInformationChanged extends RiscEvent {
/**
* See: https://openid.net/specs/openid-risc-profile-specification-1_0.html#rfc.section.2.10
*/
public static final String TYPE = "https://schemas.openid.net/secevent/risc/event-type/recovery-information-changed";
public RecoveryInformationChanged() {
super(TYPE);
}
}

View file

@ -0,0 +1,15 @@
package org.keycloak.protocol.ssf.event.types.risc;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
/**
* Generic RISC event.
*
* See: https://openid.net/specs/openid-risc-1_0-final.html
*/
public abstract class RiscEvent extends SsfEvent {
public RiscEvent(String type) {
super(type);
}
}

View file

@ -0,0 +1,13 @@
package org.keycloak.protocol.ssf.event.types.stream;
import org.keycloak.protocol.ssf.event.types.SsfEvent;
/**
* Base class for all SSF stream related events.
*/
public abstract class StreamEvent extends SsfEvent {
public StreamEvent(String eventType) {
super(eventType);
}
}

View file

@ -0,0 +1,47 @@
package org.keycloak.protocol.ssf.event.types.stream;
import org.keycloak.protocol.ssf.stream.StreamStatus;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SSF Stream status updated event.
*
* See: https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html#name-stream-updated-event
*/
public class StreamUpdatedEvent extends StreamEvent {
public static final String TYPE = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated";
/**
* REQUIRED. Defines the new status of the stream.
*/
@JsonProperty("status")
protected StreamStatus status;
/**
* OPTIONAL. Provides a short description of why the Transmitter has updated the status.
*/
@JsonProperty("reason")
protected String reason;
public StreamUpdatedEvent() {
super(TYPE);
}
public StreamStatus getStatus() {
return status;
}
public void setStatus(StreamStatus status) {
this.status = status;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}

View file

@ -0,0 +1,35 @@
package org.keycloak.protocol.ssf.event.types.stream;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SSF Verification event.
*
* See: https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html#name-verification
*/
public class VerificationEvent extends StreamEvent {
public static final String TYPE = "https://schemas.openid.net/secevent/ssf/event-type/verification";
@JsonProperty("state")
protected String state;
public VerificationEvent() {
super(TYPE);
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
@Override
public String toString() {
return "VerificationEvent{" +
"state='" + state + '\'' +
'}';
}
}

View file

@ -0,0 +1,35 @@
package org.keycloak.protocol.ssf.keys;
import org.keycloak.crypto.PublicKeysWrapper;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.utils.JWKSHttpUtils;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterMetadata;
import org.keycloak.util.JWKSUtils;
/**
* {@link PublicKeyLoader} to fetch the public Keycloak from the SSF Transmitter metadata.
*/
public class SsfTransmitterPublicKeyLoader implements PublicKeyLoader {
protected final KeycloakSession session;
protected String jwksUri;
public SsfTransmitterPublicKeyLoader(KeycloakSession session, String jwksUri) {
this.session = session;
this.jwksUri = jwksUri;
}
public SsfTransmitterPublicKeyLoader(KeycloakSession session, SsfTransmitterMetadata transmitterMetadata) {
this(session, transmitterMetadata.getJwksUri());
}
@Override
public PublicKeysWrapper loadKeys() throws Exception {
JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, jwksUri);
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true);
}
}

View file

@ -0,0 +1,107 @@
package org.keycloak.protocol.ssf.receiver;
import java.util.UUID;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.ssf.receiver.registration.SsfReceiverRegistrationProviderConfig;
import org.keycloak.protocol.ssf.receiver.spi.SsfReceiverProvider;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterClient;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterMetadata;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationState;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationStore;
import org.jboss.logging.Logger;
public class DefaultSsfReceiver implements SsfReceiver {
protected static final Logger LOG = Logger.getLogger(DefaultSsfReceiver.class);
protected final KeycloakSession session;
protected final SsfReceiverProvider ssfReceiverProvider;
protected SsfReceiverRegistrationProviderConfig receiverProviderConfig;
public DefaultSsfReceiver(KeycloakSession session, SsfReceiverRegistrationProviderConfig receiverProviderConfig) {
this.session = session;
this.ssfReceiverProvider = session.getProvider(SsfReceiverProvider.class);
this.receiverProviderConfig = receiverProviderConfig;
}
@Override
public SsfReceiverRegistrationProviderConfig getConfig() {
return receiverProviderConfig;
}
@Override
public void close() {
// NOOP
}
@Override
public SsfTransmitterMetadata refreshTransmitterMetadata() {
SsfTransmitterClient ssfTransmitterClient = ssfReceiverProvider.transmitterClient();
RealmModel realm = session.getContext().getRealm();
boolean cleared = ssfTransmitterClient.clearTransmitterMetadata(this);
if (cleared) {
LOG.debugf("Cleared Transmitter metadata. realm=%s receiver=%s", realm.getName(), receiverProviderConfig.getAlias());
}
SsfTransmitterMetadata transmitterMetadata = ssfTransmitterClient.loadTransmitterMetadata(this);
LOG.debugf("Refreshed Transmitter metadata. realm=%s receiver=%s", realm.getName(), receiverProviderConfig.getAlias());
return transmitterMetadata;
}
@Override
public String getTransmitterConfigUrl() {
// TODO do we need a providerConfig.getTransmitterConfigUrl() override?
String transmitterConfigUrl = null;
if (transmitterConfigUrl == null) {
String configUrl = receiverProviderConfig.getIssuer();
if (!configUrl.endsWith("/")) {
configUrl+="/";
}
configUrl = configUrl + ".well-known/ssf-configuration";
transmitterConfigUrl = configUrl;
}
return transmitterConfigUrl;
}
@Override
public SsfTransmitterMetadata getTransmitterMetadata() {
SsfTransmitterClient ssfTransmitterClient = ssfReceiverProvider.transmitterClient();
SsfTransmitterMetadata transmitterMetadata = ssfTransmitterClient.loadTransmitterMetadata(this);
return transmitterMetadata;
}
@Override
public void requestVerification() {
SsfStreamVerificationStore storage = ssfReceiverProvider.verificationStore();
// store current verification state
RealmModel realm = session.getContext().getRealm();
SsfStreamVerificationState verificationState = storage.getVerificationState(realm, receiverProviderConfig.getAlias(), receiverProviderConfig.getStreamId());
if (verificationState != null) {
LOG.debugf("Resetting pending verification state for stream. %s", verificationState);
storage.clearVerificationState(realm, receiverProviderConfig.getAlias(), receiverProviderConfig.getStreamId());
}
SsfTransmitterClient ssfTransmitterClient = ssfReceiverProvider.transmitterClient();
SsfTransmitterMetadata transmitterMetadata = ssfTransmitterClient.loadTransmitterMetadata(this);
String state = UUID.randomUUID().toString();
// store current verification state
storage.setVerificationState(realm, receiverProviderConfig.getAlias(), receiverProviderConfig.getStreamId(), state);
ssfReceiverProvider.verificationClient().requestVerification(this, transmitterMetadata, state);
}
}

View file

@ -0,0 +1,25 @@
package org.keycloak.protocol.ssf.receiver;
import org.keycloak.protocol.ssf.receiver.registration.SsfReceiverRegistrationProviderConfig;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterMetadata;
import org.keycloak.provider.Provider;
/**
* Represents a SSF Receiver.
*/
public interface SsfReceiver extends Provider {
@Override
default void close() {
}
SsfReceiverRegistrationProviderConfig getConfig();
SsfTransmitterMetadata getTransmitterMetadata();
SsfTransmitterMetadata refreshTransmitterMetadata();
void requestVerification();
String getTransmitterConfigUrl();
}

View file

@ -0,0 +1,46 @@
package org.keycloak.protocol.ssf.receiver.registration;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.jboss.logging.Logger;
/**
* SsfReceiverRegistrationProvider is an adapter that uses the Identity Provider infrastructure to manage SSF Receivers.
*/
public class SsfReceiverRegistrationProvider implements IdentityProvider<SsfReceiverRegistrationProviderConfig> {
protected static final Logger LOG = Logger.getLogger(SsfReceiverRegistrationProvider.class);
private final KeycloakSession session;
private final SsfReceiverRegistrationProviderConfig model;
public SsfReceiverRegistrationProvider(KeycloakSession session, SsfReceiverRegistrationProviderConfig model) {
this.session = session;
this.model = model;
}
@Override
public SsfReceiverRegistrationProviderConfig getConfig() {
return new SsfReceiverRegistrationProviderConfig(model);
}
public void requestVerification() {
// TODO make this callable from the Admin UI via the SSF "Identity Provider" component.
// store current verification state
RealmModel realm = session.getContext().getRealm();
SsfReceiver ssfReceiver = SsfReceiverRegistrationProviderFactory.getSsfReceiver(session, realm, model.getAlias());
ssfReceiver.requestVerification();
}
@Override
public void close() {
// NOOP
}
}

View file

@ -0,0 +1,90 @@
package org.keycloak.protocol.ssf.receiver.registration;
import java.util.Set;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
/**
* Holds the user configuration of an SSF Receiver.
*/
public class SsfReceiverRegistrationProviderConfig extends IdentityProviderModel {
public static final String DESCRIPTION = "description";
public static final String STREAM_ID = "streamId";
public static final String STREAM_AUDIENCE = "streamAudience";
public static final String TRANSMITTER_ACCESS_TOKEN = "transmitterAccessToken";
public static final String PUSH_AUTHORIZATION_HEADER = "pushAuthorizationHeader";
public SsfReceiverRegistrationProviderConfig() {
}
public SsfReceiverRegistrationProviderConfig(IdentityProviderModel model) {
super(model);
}
public String getIssuer() {
return getConfig().get(ISSUER);
}
public void setIssuer(String issuer) {
getConfig().put(ISSUER, issuer);
}
public String getDescription() {
return getConfig().get(DESCRIPTION);
}
public void setDescription(String description) {
getConfig().put(DESCRIPTION, description);
}
public String getTransmitterAccessToken() {
return getConfig().get(TRANSMITTER_ACCESS_TOKEN);
}
public void setTransmitterAccessToken(String transmitterAccessToken) {
getConfig().put(TRANSMITTER_ACCESS_TOKEN, transmitterAccessToken);
}
public String getPushAuthorizationHeader() {
return getConfig().get(PUSH_AUTHORIZATION_HEADER);
}
public void setPushAuthorizationHeader(String pushAuthorizationHeader) {
getConfig().put(PUSH_AUTHORIZATION_HEADER, pushAuthorizationHeader);
}
public String getStreamId() {
return getConfig().get(STREAM_ID);
}
public void setStreamId(String streamId) {
getConfig().put(STREAM_ID, streamId);
}
public String getStreamAudience() {
return getConfig().get(STREAM_AUDIENCE);
}
public void setStreamAudience(String streamAudience) {
getConfig().put(STREAM_AUDIENCE, streamAudience);
}
public Set<String> streamAudience() {
String streamAudience = getStreamAudience();
if (streamAudience == null) {
return null;
}
return Set.of(streamAudience.split(","));
}
@Override
public void validate(RealmModel realm) {
super.validate(realm);
}
}

View file

@ -0,0 +1,65 @@
package org.keycloak.protocol.ssf.receiver.registration;
import java.util.Map;
import org.keycloak.Config;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.broker.provider.IdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.ssf.receiver.DefaultSsfReceiver;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
public class SsfReceiverRegistrationProviderFactory extends AbstractIdentityProviderFactory<SsfReceiverRegistrationProvider> implements IdentityProviderFactory<SsfReceiverRegistrationProvider>, EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "ssf-receiver";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getName() {
return "SSF Receiver";
}
@Override
public SsfReceiverRegistrationProvider create(KeycloakSession session, IdentityProviderModel model) {
return new SsfReceiverRegistrationProvider(session, adaptConfig(model));
}
@Override
public IdentityProviderModel createConfig() {
return new SsfReceiverRegistrationProviderConfig();
}
protected SsfReceiverRegistrationProviderConfig adaptConfig(IdentityProviderModel model) {
if (model instanceof SsfReceiverRegistrationProviderConfig ssfModel) {
return ssfModel;
}
return new SsfReceiverRegistrationProviderConfig(model);
}
public static SsfReceiver getSsfReceiver(KeycloakSession session, RealmModel realm, String alias) {
IdentityProviderModel maybeSsfReceiverProvider = session.identityProviders().getByAlias(alias);
SsfReceiverRegistrationProviderConfig receiverProviderConfig = null;
if (maybeSsfReceiverProvider != null && SsfReceiverRegistrationProviderFactory.PROVIDER_ID.equals(maybeSsfReceiverProvider.getProviderId())) {
receiverProviderConfig = new SsfReceiverRegistrationProviderConfig(maybeSsfReceiverProvider);
}
return new DefaultSsfReceiver(session, receiverProviderConfig);
}
@Override
public Map<String, String> parseConfig(KeycloakSession session, String config) {
throw new UnsupportedOperationException();
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.SSF);
}
}

View file

@ -0,0 +1,150 @@
package org.keycloak.protocol.ssf.receiver.spi;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.ssf.endpoint.SsfPushDeliveryResource;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.event.listener.DefaultSsfEventListener;
import org.keycloak.protocol.ssf.event.listener.SsfEventListener;
import org.keycloak.protocol.ssf.event.parser.DefaultSsfSecurityEventTokenParser;
import org.keycloak.protocol.ssf.event.parser.SsfSecurityEventTokenParser;
import org.keycloak.protocol.ssf.event.processor.DefaultSsfEventProcessor;
import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
import org.keycloak.protocol.ssf.event.processor.SsfEventProcessor;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.transmitter.DefaultSsfTransmitterClient;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterClient;
import org.keycloak.protocol.ssf.receiver.verification.DefaultSsfStreamSsfStreamVerificationStore;
import org.keycloak.protocol.ssf.receiver.verification.DefaultSsfVerificationClient;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationStore;
import org.keycloak.protocol.ssf.receiver.verification.SsfVerificationClient;
public class DefaultSsfReceiverProvider implements SsfReceiverProvider {
protected final KeycloakSession session;
protected SsfSecurityEventTokenParser securityEventTokenParser;
protected SsfEventProcessor eventProcessor;
protected SsfEventListener eventListener;
protected SsfPushDeliveryResource pushDeliveryEndpoint;
protected SsfVerificationClient securityEventsVerifier;
protected SsfStreamVerificationStore verificationStore;
protected SsfTransmitterClient transmitterClient;
protected SsfVerificationClient verificationClient;
public DefaultSsfReceiverProvider(KeycloakSession session) {
this.session = session;
}
protected SsfSecurityEventTokenParser getSsfEventParser() {
if (securityEventTokenParser == null) {
securityEventTokenParser = new DefaultSsfSecurityEventTokenParser(session);
}
return securityEventTokenParser;
}
protected SsfEventProcessor getSecurityEventProcessor() {
if (eventProcessor == null) {
eventProcessor = new DefaultSsfEventProcessor(
getEventListener(),
getVerificationStore()
);
}
return eventProcessor;
}
protected SsfPushDeliveryResource getPushEndpoint() {
if (pushDeliveryEndpoint == null) {
pushDeliveryEndpoint = new SsfPushDeliveryResource(this);
}
return pushDeliveryEndpoint;
}
protected SsfEventListener getEventListener() {
if (eventListener == null) {
eventListener = new DefaultSsfEventListener(session);
}
return eventListener;
}
protected SsfVerificationClient getSecurityEventsVerifier() {
if (securityEventsVerifier == null) {
securityEventsVerifier = new DefaultSsfVerificationClient(session);
}
return securityEventsVerifier;
}
protected SsfTransmitterClient getTransmitterClient() {
if (transmitterClient == null) {
transmitterClient = new DefaultSsfTransmitterClient(session);
}
return transmitterClient;
}
@Override
public SsfVerificationClient verificationClient() {
return getVerificationClient();
}
protected SsfVerificationClient getVerificationClient() {
if (verificationClient == null) {
verificationClient = new DefaultSsfVerificationClient(session);
}
return verificationClient;
}
@Override
public SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfEventContext eventContext) {
var parser = getSsfEventParser();
return parser.parseSecurityEventToken(encodedSecurityEventToken, eventContext.getReceiver());
}
@Override
public void processEvents(SecurityEventToken securityEventToken, SsfEventContext eventContext) {
eventProcessor().processEvents(securityEventToken, eventContext);
}
@Override
public SsfStreamVerificationStore verificationStore() {
return getVerificationStore();
}
protected SsfStreamVerificationStore getVerificationStore() {
if (verificationStore == null) {
verificationStore = new DefaultSsfStreamSsfStreamVerificationStore(session);
}
return verificationStore;
}
public SsfEventProcessor eventProcessor() {
return getSecurityEventProcessor();
}
@Override
public SsfPushDeliveryResource pushDeliveryEndpoint() {
return getPushEndpoint();
}
@Override
public SsfTransmitterClient transmitterClient() {
return getTransmitterClient();
}
@Override
public SsfEventContext createEventContext(SecurityEventToken securityEventToken, SsfReceiver receiver) {
SsfEventContext context = new SsfEventContext();
context.setSecurityEventToken(securityEventToken);
context.setSession(session);
context.setReceiver(receiver);
return context;
}
}

View file

@ -0,0 +1,39 @@
package org.keycloak.protocol.ssf.receiver.spi;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
public class DefaultSsfReceiverProviderFactory implements SsfReceiverProviderFactory, EnvironmentDependentProviderFactory {
@Override
public String getId() {
return "default";
}
@Override
public SsfReceiverProvider create(KeycloakSession keycloakSession) {
return new DefaultSsfReceiverProvider(keycloakSession);
}
@Override
public void init(Config.Scope scope) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.SSF);
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.protocol.ssf.receiver.spi;
import org.keycloak.protocol.ssf.endpoint.SsfPushDeliveryResource;
import org.keycloak.protocol.ssf.event.SecurityEventToken;
import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterClient;
import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationStore;
import org.keycloak.protocol.ssf.receiver.verification.SsfVerificationClient;
import org.keycloak.provider.Provider;
/**
* SsfProvider exposes the SSF Receiver infrastructure components.
*/
public interface SsfReceiverProvider extends Provider {
@Override
default void close() {
// NOOP
}
SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfEventContext eventContext);
SsfEventContext createEventContext(SecurityEventToken securityEventToken, SsfReceiver receiver);
void processEvents(SecurityEventToken securityEventToken, SsfEventContext eventContext);
SsfPushDeliveryResource pushDeliveryEndpoint();
SsfStreamVerificationStore verificationStore();
SsfVerificationClient verificationClient();
SsfTransmitterClient transmitterClient();
}

View file

@ -0,0 +1,6 @@
package org.keycloak.protocol.ssf.receiver.spi;
import org.keycloak.provider.ProviderFactory;
public interface SsfReceiverProviderFactory extends ProviderFactory<SsfReceiverProvider> {
}

View file

@ -0,0 +1,30 @@
package org.keycloak.protocol.ssf.receiver.spi;
import org.keycloak.provider.Provider;
import org.keycloak.provider.Spi;
/**
* SPI for Shared Signals Framework (SSF) Receiver support.
*/
public class SsfReceiverSpi implements Spi {
@Override
public String getName() {
return "ssf-receiver";
}
@Override
public boolean isInternal() {
return false;
}
@Override
public Class<? extends Provider> getProviderClass() {
return SsfReceiverProvider.class;
}
@Override
public Class<? extends SsfReceiverProviderFactory> getProviderFactoryClass() {
return SsfReceiverProviderFactory.class;
}
}

View file

@ -0,0 +1,130 @@
package org.keycloak.protocol.ssf.receiver.transmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.protocol.ssf.SsfException;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.util.JsonSerialization;
import org.jboss.logging.Logger;
public class DefaultSsfTransmitterClient implements SsfTransmitterClient {
protected static final Logger LOG = Logger.getLogger(DefaultSsfTransmitterClient.class);
protected final KeycloakSession session;
public DefaultSsfTransmitterClient(KeycloakSession session) {
this.session = session;
}
@Override
public SsfTransmitterMetadata loadTransmitterMetadata(SsfReceiver receiver) {
SsfTransmitterMetadata metadata = loadFromCache(receiver);
if (metadata != null) {
return metadata;
}
metadata = fetchTransmitterMetadata(receiver);
if (metadata != null) {
storeToCache(receiver, metadata);
}
return metadata;
}
@Override
public SsfTransmitterMetadata fetchTransmitterMetadata(SsfReceiver receiver) {
RealmModel realm = session.getContext().getRealm();
String url = receiver.getTransmitterConfigUrl();
LOG.debugf("Sending transmitter metadata request. realm=%s url=%s", realm.getName(), url);
var request = createHttpClient().doGet(url);
try (var response = request.asResponse()) {
LOG.debugf("Received transmitter metadata response. realm=%s status=%s", realm.getName(), response.getStatus());
if (response.getStatus() != 200) {
throw new SsfException("Expected a 200 response but got: " + response.getStatus());
}
SsfTransmitterMetadata metadata = response.asJson(SsfTransmitterMetadata.class);
return metadata;
} catch (Exception e) {
throw new SsfException("Could fetch transmitter metadata", e);
}
}
protected void storeToCache(SsfReceiver receiver, SsfTransmitterMetadata metadata) {
RealmModel realm = session.getContext().getRealm();
String url = receiver.getTransmitterConfigUrl();
SingleUseObjectProvider cache = getCache();
try {
String jsonData = JsonSerialization.writeValueAsString(metadata);
cache.put(makeCacheKey(url), getCacheLifespanSeconds(), Map.of("data", jsonData));
LOG.debugf("Stored transmitter metadata in cache. realm=%s url=%s", realm.getName(), url);
} catch (IOException e) {
throw new SsfException("Could not store transmitter metadata in cache", e);
}
}
protected long getCacheLifespanSeconds() {
return TimeUnit.HOURS.toSeconds(12);
}
protected SsfTransmitterMetadata loadFromCache(SsfReceiver receiver) {
String url = receiver.getTransmitterConfigUrl();
SingleUseObjectProvider cache = getCache();
Map<String, String> cachedTransmitterMetadata = cache.get(makeCacheKey(url));
if (cachedTransmitterMetadata != null) {
String jsonData = cachedTransmitterMetadata.get("data");
try {
RealmModel realm = session.getContext().getRealm();
SsfTransmitterMetadata metadata = JsonSerialization.readValue(jsonData, SsfTransmitterMetadata.class);
LOG.debugf("Loaded transmitter metadata from cache. realm=%s url=%s", realm.getName(), url);
return metadata;
} catch (IOException e) {
throw new SsfException("Could load transmitter metadata from cache", e);
}
}
return null;
}
protected SingleUseObjectProvider getCache() {
return session.getProvider(SingleUseObjectProvider.class);
}
@Override
public boolean clearTransmitterMetadata(SsfReceiver receiver) {
SingleUseObjectProvider cache = getCache();
String cacheKey = makeCacheKey(receiver.getTransmitterConfigUrl());
Map<String, String> cachedTransmitterMetadata = cache.get(cacheKey);
if (cachedTransmitterMetadata != null) {
cache.remove(cacheKey);
return true;
}
return false;
}
protected String makeCacheKey(String url) {
RealmModel realm = session.getContext().getRealm();
return "ssf:tm:" + realm.getName() + ":" + url.hashCode();
}
protected SimpleHttp createHttpClient() {
return SimpleHttp.create(session);
}
}

View file

@ -0,0 +1,15 @@
package org.keycloak.protocol.ssf.receiver.transmitter;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
/**
* Client to access metadata from a remote SSF Transmitter.
*/
public interface SsfTransmitterClient {
SsfTransmitterMetadata loadTransmitterMetadata(SsfReceiver receiver);
SsfTransmitterMetadata fetchTransmitterMetadata(SsfReceiver receiver);
boolean clearTransmitterMetadata(SsfReceiver receiver);
}

View file

@ -0,0 +1,178 @@
package org.keycloak.protocol.ssf.receiver.transmitter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
public class SsfTransmitterMetadata {
@JsonProperty("spec_version")
private String specVersion;
@JsonProperty("issuer")
private String issuer;
@JsonProperty("jwks_uri")
private String jwksUri;
@JsonProperty("delivery_methods_supported")
private Set<String> deliveryMethodSupported;
@JsonProperty("configuration_endpoint")
private String configurationEndpoint;
@JsonProperty("status_endpoint")
private String statusEndpoint;
@JsonProperty("add_subject_endpoint")
private String addSubjectEndpoint;
@JsonProperty("remove_subject_endpoint")
private String removeSubjectEndpoint;
@JsonProperty("verification_endpoint")
private String verificationEndpoint;
@JsonProperty("critical_subject_members")
private Set<String> criticalSubjectMembers;
@JsonProperty("default_subjects")
private String defaultSubjects;
@JsonProperty("authorization_schemes")
private List<Object> authorizationSchemes;
@JsonIgnore
private final Map<String, Object> metadata = new HashMap<String, Object>();
public String getSpecVersion() {
return specVersion;
}
public void setSpecVersion(String specVersion) {
this.specVersion = specVersion;
}
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
public String getJwksUri() {
return jwksUri;
}
public void setJwksUri(String jwksUri) {
this.jwksUri = jwksUri;
}
public Set<String> getDeliveryMethodSupported() {
return deliveryMethodSupported;
}
public void setDeliveryMethodSupported(Set<String> deliveryMethodSupported) {
this.deliveryMethodSupported = deliveryMethodSupported;
}
public String getConfigurationEndpoint() {
return configurationEndpoint;
}
public void setConfigurationEndpoint(String configurationEndpoint) {
this.configurationEndpoint = configurationEndpoint;
}
public String getStatusEndpoint() {
return statusEndpoint;
}
public void setStatusEndpoint(String statusEndpoint) {
this.statusEndpoint = statusEndpoint;
}
public String getAddSubjectEndpoint() {
return addSubjectEndpoint;
}
public void setAddSubjectEndpoint(String addSubjectEndpoint) {
this.addSubjectEndpoint = addSubjectEndpoint;
}
public String getRemoveSubjectEndpoint() {
return removeSubjectEndpoint;
}
public void setRemoveSubjectEndpoint(String removeSubjectEndpoint) {
this.removeSubjectEndpoint = removeSubjectEndpoint;
}
public String getVerificationEndpoint() {
return verificationEndpoint;
}
public void setVerificationEndpoint(String verificationEndpoint) {
this.verificationEndpoint = verificationEndpoint;
}
public Set<String> getCriticalSubjectMembers() {
return criticalSubjectMembers;
}
public void setCriticalSubjectMembers(Set<String> criticalSubjectMembers) {
this.criticalSubjectMembers = criticalSubjectMembers;
}
public String getDefaultSubjects() {
return defaultSubjects;
}
public void setDefaultSubjects(String defaultSubjects) {
this.defaultSubjects = defaultSubjects;
}
public List<Object> getAuthorizationSchemes() {
return authorizationSchemes;
}
public void setAuthorizationSchemes(List<Object> authorizationSchemes) {
this.authorizationSchemes = authorizationSchemes;
}
@JsonAnySetter
public void setMetadata(String key, Object value) {
metadata.put(key, value);
}
@JsonAnyGetter
public Map<String, Object> getMetadata() {
return metadata;
}
@Override
public String toString() {
return "SsfTransmitterMetadata{" +
"specVersion='" + specVersion + '\'' +
", issuer='" + issuer + '\'' +
", jwksUri='" + jwksUri + '\'' +
", deliveryMethodSupported=" + deliveryMethodSupported +
", configurationEndpoint='" + configurationEndpoint + '\'' +
", statusEndpoint='" + statusEndpoint + '\'' +
", addSubjectEndpoint='" + addSubjectEndpoint + '\'' +
", removeSubjectEndpoint='" + removeSubjectEndpoint + '\'' +
", verificationEndpoint='" + verificationEndpoint + '\'' +
", criticalSubjectMembers=" + criticalSubjectMembers +
", defaultSubjects='" + defaultSubjects + '\'' +
", authorizationSchemes=" + authorizationSchemes +
", metadata=" + metadata +
'}';
}
}

View file

@ -0,0 +1,75 @@
package org.keycloak.protocol.ssf.receiver.verification;
import java.util.Map;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
/**
* Default {@link SsfStreamVerificationStore} implementation that uses the {@link SingleUseObjectProvider} to manage the
* verification state of a stream associated with a SSF Receiver.
*/
public class DefaultSsfStreamSsfStreamVerificationStore implements SsfStreamVerificationStore {
public static final int DEFAULT_VERIFICATION_STATE_LIFESPAN_SECONDS = 300;
protected int verificationStateLifespanSeconds;
protected final KeycloakSession session;
public DefaultSsfStreamSsfStreamVerificationStore(KeycloakSession session) {
this(session, DEFAULT_VERIFICATION_STATE_LIFESPAN_SECONDS);
}
public DefaultSsfStreamSsfStreamVerificationStore(KeycloakSession session, int verificationStateLifespanSeconds) {
this.session = session;
this.verificationStateLifespanSeconds = verificationStateLifespanSeconds;
}
@Override
public void setVerificationState(RealmModel realm, String receiverAlias, String streamId, String state) {
// TODO check for pending verifications
var singleUseObject = session.getProvider(SingleUseObjectProvider.class);
String key = createVerificationKey(receiverAlias, streamId);
Map<String, String> verificationData = Map.of("state", state, "timestamp", String.valueOf(Time.currentTime()));
singleUseObject.put(key, verificationStateLifespanSeconds, verificationData);
}
protected String createVerificationKey(String receiverAlias, String streamId) {
return "ssf.verification:" + receiverAlias + ":" + streamId;
}
@Override
public SsfStreamVerificationState getVerificationState(RealmModel realm, String receiverAlias, String streamId) {
var singleUseObject = session.getProvider(SingleUseObjectProvider.class);
String key = createVerificationKey(receiverAlias, streamId);
Map<String, String> verificationData = singleUseObject.get(key);
if (verificationData == null) {
return null;
}
String state = verificationData.get("state");
long timestamp = Long.parseLong(verificationData.get("timestamp"));
SsfStreamVerificationState verificationState = new SsfStreamVerificationState();
verificationState.setTimestamp(timestamp);
verificationState.setState(state);
verificationState.setStreamId(streamId);
return verificationState;
}
@Override
public void clearVerificationState(RealmModel realm, String receiverAlias, String streamId) {
var singleUseObject = session.getProvider(SingleUseObjectProvider.class);
String key = createVerificationKey(receiverAlias, streamId);
singleUseObject.remove(key);
}
}

View file

@ -0,0 +1,48 @@
package org.keycloak.protocol.ssf.receiver.verification;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterMetadata;
import org.jboss.logging.Logger;
public class DefaultSsfVerificationClient implements SsfVerificationClient {
protected static final Logger LOG = Logger.getLogger(DefaultSsfVerificationClient.class);
protected final KeycloakSession session;
public DefaultSsfVerificationClient(KeycloakSession session) {
this.session = session;
}
@Override
public void requestVerification(SsfReceiver receiver, SsfTransmitterMetadata metadata, String state) {
var verificationRequest = new SsfStreamVerificationRequest();
verificationRequest.setStreamId(receiver.getConfig().getStreamId());
verificationRequest.setState(state);
LOG.debugf("Sending verification request to %s. %s", metadata.getVerificationEndpoint(), verificationRequest);
var verificationHttpCall = prepareHttpCall(metadata.getVerificationEndpoint(), receiver.getConfig().getTransmitterAccessToken(), verificationRequest);
try (var response = verificationHttpCall.asResponse()) {
LOG.debugf("Received verification response. status=%s", response.getStatus());
if (response.getStatus() != 204) {
throw new SsfStreamVerificationException("Expected a 204 response but got: " + response.getStatus());
}
} catch (Exception e) {
throw new SsfStreamVerificationException("Could not send verification request", e);
}
}
protected SimpleHttpRequest prepareHttpCall(String verifyUri, String token, SsfStreamVerificationRequest verificationRequest) {
return createHttpClient(session).doPost(verifyUri).auth(token).json(verificationRequest);
}
protected SimpleHttp createHttpClient(KeycloakSession session) {
return SimpleHttp.create(session);
}
}

View file

@ -0,0 +1,17 @@
package org.keycloak.protocol.ssf.receiver.verification;
import org.keycloak.protocol.ssf.SsfException;
public class SsfStreamVerificationException extends SsfException {
public SsfStreamVerificationException() {
}
public SsfStreamVerificationException(String message) {
super(message);
}
public SsfStreamVerificationException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.protocol.ssf.receiver.verification;
import com.fasterxml.jackson.annotation.JsonProperty;
public class SsfStreamVerificationRequest {
@JsonProperty("stream_id")
protected String streamId;
@JsonProperty("state")
protected String state;
public String getStreamId() {
return streamId;
}
public void setStreamId(String streamId) {
this.streamId = streamId;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
@Override
public String toString() {
return "VerificationRequest{" +
"streamId='" + streamId + '\'' +
", state='" + state + '\'' +
'}';
}
}

View file

@ -0,0 +1,43 @@
package org.keycloak.protocol.ssf.receiver.verification;
public class SsfStreamVerificationState {
protected String streamId;
protected String state;
protected long timestamp;
public String getStreamId() {
return streamId;
}
public void setStreamId(String streamId) {
this.streamId = streamId;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public long getTimestamp() {
return timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
@Override
public String toString() {
return "VerificationState{" +
"streamId='" + streamId + '\'' +
", state='" + state + '\'' +
", timestamp=" + timestamp +
'}';
}
}

View file

@ -0,0 +1,15 @@
package org.keycloak.protocol.ssf.receiver.verification;
import org.keycloak.models.RealmModel;
/**
* Store to handle the verification state.
*/
public interface SsfStreamVerificationStore {
void setVerificationState(RealmModel realm, String receiverAlias, String streamId, String state);
SsfStreamVerificationState getVerificationState(RealmModel realm, String receiverAlias, String streamId);
void clearVerificationState(RealmModel realm, String receiverAlias, String streamId);
}

View file

@ -0,0 +1,14 @@
package org.keycloak.protocol.ssf.receiver.verification;
import org.keycloak.protocol.ssf.receiver.SsfReceiver;
import org.keycloak.protocol.ssf.receiver.transmitter.SsfTransmitterMetadata;
/**
* Client to perform SSF Receiver stream verification with a remote SSF Transmitter.
*
* See: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-8.1.4
*/
public interface SsfVerificationClient {
void requestVerification(SsfReceiver receiver, SsfTransmitterMetadata transmitterMetadata, String state);
}

View file

@ -0,0 +1,19 @@
package org.keycloak.protocol.ssf.stream;
public enum StreamStatus {
/**
* The Transmitter MUST transmit events over the stream, according to the stream's configured delivery method.
*/
enabled,
/**
* The Transmitter MUST NOT transmit events over the stream. The Transmitter will hold any events it would have transmitted while paused, and SHOULD transmit them when the stream's status becomes "enabled". If a Transmitter holds successive events that affect the same Subject Principal, then the Transmitter MUST make sure that those events are transmitted in the order of time that they were generated OR the Transmitter MUST send only the last events that do not require the previous events affecting the same Subject Principal to be processed by the Receiver, because the previous events are either cancelled by the later events or the previous events are outdated.
*/
paused,
/**
* The Transmitter MUST NOT transmit events over the stream and will not hold any events for later transmission.
*/
disabled
}

View file

@ -58,6 +58,8 @@ public class KeycloakOpenAPI {
public static final String USERS = "Users";
public static final String ORGANIZATIONS = "Organizations";
public static final String WORKFLOWS = "Workflows";
public static final String SSF_PUSH = "SSF Push Delivery";
public static final String SSF_STREAM_VERIFICATION = "SSF Stream Verification";
private Tags() { }
}

View file

@ -21,4 +21,5 @@ org.keycloak.broker.saml.SAMLIdentityProviderFactory
org.keycloak.broker.oauth.OAuth2IdentityProviderFactory
org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory
org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory
org.keycloak.protocol.ssf.receiver.registration.SsfReceiverRegistrationProviderFactory
org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory

View file

@ -0,0 +1 @@
org.keycloak.protocol.ssf.receiver.spi.DefaultSsfReceiverProviderFactory

Some files were not shown because too many files have changed in this diff Show more