mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-03 20:39:33 -05:00
Merge 70628a7ae5 into 52119f839e
This commit is contained in:
commit
d96d2ebea8
105 changed files with 5277 additions and 4 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
17
services/src/main/java/org/keycloak/protocol/ssf/Ssf.java
Normal file
17
services/src/main/java/org/keycloak/protocol/ssf/Ssf.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.keycloak.protocol.ssf.receiver.spi;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface SsfReceiverProviderFactory extends ProviderFactory<SsfReceiverProvider> {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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() { }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Reference in a new issue