diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 6f91cbc5185..920e1ffa51c 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -896,6 +896,7 @@ deleteError=Could not delete the provider {{error}} attributeDisplayName=Display name pkceEnabled=Use PKCE authorizationGrantSettings=Authorization Grant Settings +authorizationGrantSettingsHelp= This section is used to configure specific settings related to JWT Authorization Grant support as defined in RFC 7523. Not all settings are configured here, some belong to the identity provider, such as JWKS URL, Issuer and Allowed Clock Skew. jwtAuthorizationGrantIdpEnabled=JWT Authorization Grant jwtAuthorizationGrantIdpEnabledHelp=Enable the identity provider to act as a trust provider to validate authorization grant JWT assertions according to RFC 7523. jwtAuthorizationGrantAssertionReuseAllowed=Allow assertion reuse @@ -904,6 +905,9 @@ jwtAuthorizationGrantMaxAllowedAssertionExpiration=Max allowed assertion expirat jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp=Insert the max allowed expiration that the assertion can have. jwtAuthorizationGrantAssertionSignatureAlg=Assertion signature algorithm jwtAuthorizationGrantAssertionSignatureAlgHelp=Signature algorithm that should be used to sign the assertion, if not specified any signature algorithm will be valid. +addJWTAuthorizationGrantProvider=Add JWT Authorization Grant Provider +jwtAuthorizationGrantJWKSUrl=JWKS URL +jwtAuthorizationGrantJWKSUrlHelp=URL where identity provider keys in JWK format are stored. See the JWK specification for more details userProviderSaveSuccess=User federation provider successfully saved month=Month valueLabel=Value diff --git a/js/apps/admin-ui/src/identity-providers/add/AddJWTAuthorizationGrant.tsx b/js/apps/admin-ui/src/identity-providers/add/AddJWTAuthorizationGrant.tsx new file mode 100644 index 00000000000..dcc8f9710e7 --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/add/AddJWTAuthorizationGrant.tsx @@ -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 JWTAuthorizationGrantSettings from "./JWTAuthorizationGrantSettings"; +type DiscoveryIdentityProvider = IdentityProviderRepresentation & { + discoveryEndpoint?: string; +}; + +export default function AddJWTAuthorizationGrantConnect() { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const id = "jwt-authorization-grant"; + + const form = useForm({ + 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 ( + <> + + + + + + + + + + + + + + + ); +} diff --git a/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx index 6078ad20ba7..7416c674c9d 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx @@ -21,6 +21,7 @@ import { PageSection, Tab, TabTitleText, + Text, ToolbarItem, } from "@patternfly/react-core"; import { useMemo, useState } from "react"; @@ -72,7 +73,9 @@ import { SpiffeSettings } from "./SpiffeSettings"; import { AdminEvents } from "../../events/AdminEvents"; import { UserProfileClaimsSettings } from "./OAuth2UserProfileClaimsSettings"; import { KubernetesSettings } from "./KubernetesSettings"; -import { JwtAuthorizationGrantSettings } from "./JwtAuthorizationGrantSettings"; +import { JWTAuthorizationGrantAssertionSettings } from "./JWTAuthorizationGrantAssertionSettings"; +import JWTAuthorizationGrantSettings from "./JWTAuthorizationGrantSettings"; +import { DefaultSwitchControl } from "../../components/SwitchControl"; type HeaderProps = { onChange: (value: boolean) => void; @@ -263,7 +266,7 @@ export default function DetailSettings() { const { alias, providerId } = useParams(); const isFeatureEnabled = useIsFeatureEnabled(); const form = useForm(); - const { handleSubmit, getValues, reset } = form; + const { handleSubmit, getValues, reset, control } = form; const [provider, setProvider] = useState(); const [selectedMapper, setSelectedMapper] = useState(); @@ -409,6 +412,10 @@ export default function DetailSettings() { } }, }); + const jwtAuthorizationGrantEnabled = useWatch({ + control, + name: "config.jwtAuthorizationGrantEnabled", + }); if (!provider) { return ; @@ -419,8 +426,11 @@ export default function DetailSettings() { const isOAuth2 = provider.providerId!.includes("oauth2"); const isSPIFFE = provider.providerId!.includes("spiffe"); const isKubernetes = provider.providerId!.includes("kubernetes"); + const isJWTAuthorizationGrant = provider.providerId!.includes( + "jwt-authorization-grant", + ); const isSocial = !isOIDC && !isSAML && !isOAuth2; - const isJwtAuthorizationGrantSupported = + const isJWTAuthorizationGrantSupported = (isOAuth2 || isOIDC) && !!provider?.types?.includes(IdentityProviderType.JWT_AUTHORIZATION_GRANT) && isFeatureEnabled(Feature.JWTAuthorizationGrant); @@ -453,7 +463,7 @@ export default function DetailSettings() { const sections = [ { title: t("generalSettings"), - isHidden: isSPIFFE || isKubernetes, + isHidden: isSPIFFE || isKubernetes || isJWTAuthorizationGrant, panel: ( - - + <> + + {t("authorizationGrantSettingsHelp")} + +
+ + + {jwtAuthorizationGrantEnabled === "true" && ( + + )} + + ), }, { @@ -525,6 +549,20 @@ export default function DetailSettings() { ), }, + { + title: t("generalSettings"), + isHidden: !isJWTAuthorizationGrant, + panel: ( +
+ + + + ), + }, { title: t("generalSettings"), isHidden: !isKubernetes, @@ -559,7 +597,7 @@ export default function DetailSettings() { }, { title: t("advancedSettings"), - isHidden: isSPIFFE || isKubernetes, + isHidden: isSPIFFE || isKubernetes || isJWTAuthorizationGrant, panel: ( {t("mappers")}} {...mappersTab} diff --git a/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx index c90975d1ffc..037d3afa770 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx @@ -97,6 +97,7 @@ const Fields = ({ readOnly, isOIDC }: DiscoverySettingsProps) => { diff --git a/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantAssertionSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantAssertionSettings.tsx new file mode 100644 index 00000000000..abb213d4edb --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantAssertionSettings.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from "react-i18next"; +import { DefaultSwitchControl } from "../../components/SwitchControl"; +import { FormGroup } from "@patternfly/react-core"; +import { useFormContext, Controller } from "react-hook-form"; +import { TimeSelector } from "../../components/time-selector/TimeSelector"; +import { SelectControl, HelpItem } from "@keycloak/keycloak-ui-shared"; +import { sortProviders } from "../../util"; +import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; + +export const JWTAuthorizationGrantAssertionSettings = () => { + const { t } = useTranslation(); + const providers = useServerInfo().providers!.signature.providers; + const { control } = useFormContext(); + return ( + <> + + + + } + > + ( + + )} + /> + + ({ key: p, value: p })), + ]} + controller={{ + defaultValue: "", + }} + /> + + ); +}; diff --git a/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx new file mode 100644 index 00000000000..c553dfd7806 --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from "react-i18next"; + +import { TextControl, NumberControl } from "@keycloak/keycloak-ui-shared"; +import { JWTAuthorizationGrantAssertionSettings } from "./JWTAuthorizationGrantAssertionSettings"; +import { Divider } from "@patternfly/react-core"; +export default function JWTAuthorizationGrantSettings() { + const { t } = useTranslation(); + return ( + <> + + + + + + + + ); +} diff --git a/js/apps/admin-ui/src/identity-providers/add/JwtAuthorizationGrantSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/JwtAuthorizationGrantSettings.tsx deleted file mode 100644 index 5e09c62da49..00000000000 --- a/js/apps/admin-ui/src/identity-providers/add/JwtAuthorizationGrantSettings.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { DefaultSwitchControl } from "../../components/SwitchControl"; -import { Divider, FormGroup } from "@patternfly/react-core"; -import { useWatch, useFormContext, Controller } from "react-hook-form"; -import { TimeSelector } from "../../components/time-selector/TimeSelector"; -import { SelectControl, HelpItem } from "@keycloak/keycloak-ui-shared"; -import { sortProviders } from "../../util"; -import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; - -export const JwtAuthorizationGrantSettings = () => { - const { t } = useTranslation(); - const { control } = useFormContext(); - const authorizationGrantEnabled = useWatch({ - control, - name: "config.jwtAuthorizationGrantEnabled", - }); - const providers = useServerInfo().providers!.signature.providers; - return ( - <> - - {authorizationGrantEnabled === "true" && ( - <> - - - - } - > - ( - - )} - /> - - ({ key: p, value: p })), - ]} - controller={{ - defaultValue: "", - }} - /> - - )} - - - ); -}; diff --git a/js/apps/admin-ui/src/identity-providers/routes.ts b/js/apps/admin-ui/src/identity-providers/routes.ts index 0e09963e0b0..4a606fb91c7 100644 --- a/js/apps/admin-ui/src/identity-providers/routes.ts +++ b/js/apps/admin-ui/src/identity-providers/routes.ts @@ -10,6 +10,7 @@ import { IdentityProviderAddMapperRoute } from "./routes/AddMapper"; import { IdentityProviderEditMapperRoute } from "./routes/EditMapper"; import { IdentityProviderCreateRoute } from "./routes/IdentityProviderCreate"; import { IdentityProviderOAuth2Route } from "./routes/IdentityProviderOAuth2"; +import { IdentityProviderJWTAuthorizationGrantRoute } from "./routes/IdentityProviderJWTAuthorizationGrant"; const routes: AppRouteObject[] = [ IdentityProviderAddMapperRoute, @@ -18,6 +19,7 @@ const routes: AppRouteObject[] = [ IdentityProviderOidcRoute, IdentityProviderSamlRoute, IdentityProviderSpiffeRoute, + IdentityProviderJWTAuthorizationGrantRoute, IdentityProviderKubernetesRoute, IdentityProviderKeycloakOidcRoute, IdentityProviderCreateRoute, diff --git a/js/apps/admin-ui/src/identity-providers/routes/IdentityProviderJWTAuthorizationGrant.tsx b/js/apps/admin-ui/src/identity-providers/routes/IdentityProviderJWTAuthorizationGrant.tsx new file mode 100644 index 00000000000..22012cb9cba --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/routes/IdentityProviderJWTAuthorizationGrant.tsx @@ -0,0 +1,28 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type IdentityProviderJWTAuthorizationGrantParams = { realm: string }; + +const AddJWTAuthorizationGrant = lazy( + () => import("../add/AddJWTAuthorizationGrant"), +); + +export const IdentityProviderJWTAuthorizationGrantRoute: AppRouteObject = { + path: "/:realm/identity-providers/jwt-authorization-grant/add", + element: , + breadcrumb: (t) => t("addJWTAuthorizationGrantProvider"), + handle: { + access: "manage-identity-providers", + }, +}; + +export const toIdentityProviderJWTAuthorizationGrant = ( + params: IdentityProviderJWTAuthorizationGrantParams, +): Partial => ({ + pathname: generateEncodedPath( + IdentityProviderJWTAuthorizationGrantRoute.path, + params, + ), +}); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java index 4e3f614b802..d7c00805284 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java @@ -25,6 +25,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.keycloak.common.Profile; +import org.keycloak.models.IdentityProviderCapability; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderQuery; @@ -183,7 +184,7 @@ public class InfinispanIdentityProviderStorageProvider implements IdentityProvid if (cached == null) { Long loaded = realmCache.getCache().getCurrentRevision(cacheKey); - long count = idpDelegate.getAllStream(IdentityProviderQuery.userAuthentication(), 0, 1).count(); + long count = idpDelegate.getAllStream(IdentityProviderQuery.capability(IdentityProviderCapability.USER_LINKING), 0, 1).count(); cached = new CachedCount(loaded, getRealm(), cacheKey, count); realmCache.getCache().addRevisioned(cached, realmCache.getStartupRevision()); } diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java index b6b702691a6..e4fc644a999 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java @@ -17,9 +17,10 @@ package org.keycloak.broker.provider; import java.util.List; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; -public interface JWTAuthorizationGrantProvider { +public interface JWTAuthorizationGrantProvider extends IdentityProvider { BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion) throws IdentityBrokerException; diff --git a/server-spi/src/main/java/org/keycloak/models/IdentityProviderStorageProvider.java b/server-spi/src/main/java/org/keycloak/models/IdentityProviderStorageProvider.java index 71cf27f9359..342064d0312 100644 --- a/server-spi/src/main/java/org/keycloak/models/IdentityProviderStorageProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/IdentityProviderStorageProvider.java @@ -215,7 +215,7 @@ public interface IdentityProviderStorageProvider extends Provider { * otherwise. */ default boolean isIdentityFederationEnabled() { - return getAllStream(IdentityProviderQuery.userAuthentication(), 0, 1).findFirst().isPresent(); + return getAllStream(IdentityProviderQuery.capability(IdentityProviderCapability.USER_LINKING), 0, 1).findFirst().isPresent(); } /** diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java new file mode 100644 index 00000000000..e99f7d2a98d --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java @@ -0,0 +1,61 @@ +package org.keycloak.broker.jwtauthorizationgrant; + + +import java.util.Map; + +import static org.keycloak.broker.oidc.OIDCIdentityProviderConfig.JWKS_URL; +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ISSUER; + +public interface JWTAuthorizationGrantConfig { + + public static final String JWT_AUTHORIZATION_GRANT_ENABLED = "jwtAuthorizationGrantEnabled"; + + public static final String JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED = "jwtAuthorizationGrantAssertionReuseAllowed"; + + public static final String JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION = "jwtAuthorizationGrantMaxAllowedAssertionExpiration"; + + public static final String JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG = "jwtAuthorizationGrantAssertionSignatureAlg"; + + public static final String JWT_AUTHORIZATION_GRANT_ALLOWED_CLOCK_SKEW = "jwtAuthorizationGrantAllowedClockSkew"; + + Map getConfig(); + + default boolean getJWTAuthorizationGrantEnabled() { + return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_ENABLED, "false")); + } + + default boolean getJWTAuthorizationGrantAssertionReuseAllowed() { + return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED, "false")); + } + + default int getJWTAuthorizationGrantMaxAllowedAssertionExpiration() { + return Integer.parseInt(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION, "300")); + } + + default String getJWTAuthorizationGrantAssertionSignatureAlg() { + return getConfig().get(JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG); + } + + default int getJWTAuthorizationGrantAllowedClockSkew() { + String allowedClockSkew = getConfig().get(JWT_AUTHORIZATION_GRANT_ALLOWED_CLOCK_SKEW); + if (allowedClockSkew == null || allowedClockSkew.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(getConfig().get(JWT_AUTHORIZATION_GRANT_ALLOWED_CLOCK_SKEW)); + } catch (NumberFormatException e) { + // ignore it and use default + return 0; + } + } + + default String getIssuer() { + return getConfig().get(ISSUER); + } + + default String getJwksUrl() { + return getConfig().get(JWKS_URL); + } + + String getInternalId(); +} diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java new file mode 100644 index 00000000000..c168e203791 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java @@ -0,0 +1,111 @@ +package org.keycloak.broker.jwtauthorizationgrant; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +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.models.RealmModel; +import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; +import org.keycloak.services.Urls; +import org.keycloak.utils.StringUtil; + +import org.jboss.logging.Logger; + +public class JWTAuthorizationGrantIdentityProvider implements JWTAuthorizationGrantProvider { + private static final Logger LOGGER = Logger.getLogger(JWTAuthorizationGrantIdentityProvider.class); + + private final KeycloakSession session; + private final JWTAuthorizationGrantConfig config; + + public JWTAuthorizationGrantIdentityProvider(KeycloakSession session, JWTAuthorizationGrantConfig config) { + this.session = session; + this.config = config; + } + + @Override + public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) throws IdentityBrokerException { + // verify signature + if (!verifySignature(context.getJws())) { + throw new IdentityBrokerException("Invalid signature"); + } + + BrokeredIdentityContext user = new BrokeredIdentityContext(context.getJWT().getSubject(), getConfig()); + user.setUsername(context.getJWT().getSubject()); + return user; + } + + @Override + public int getAllowedClockSkew() { + return config.getJWTAuthorizationGrantAllowedClockSkew(); + } + + @Override + public boolean isAssertionReuseAllowed() { + return config.getJWTAuthorizationGrantAssertionReuseAllowed(); + } + + @Override + public List getAllowedAudienceForJWTGrant() { + RealmModel realm = session.getContext().getRealm(); + + URI baseUri = session.getContext().getUri().getBaseUri(); + String issuer = Urls.realmIssuer(baseUri, realm.getName()); + String tokenEndpoint = Urls.tokenEndpoint(baseUri, realm.getName()).toString(); + return List.of(issuer, tokenEndpoint); + } + + @Override + public int getMaxAllowedExpiration() { + return config.getJWTAuthorizationGrantMaxAllowedAssertionExpiration(); + } + + @Override + public String getAssertionSignatureAlg() { + String alg = config.getJWTAuthorizationGrantAssertionSignatureAlg(); + return StringUtil.isBlank(alg) ? null : alg; + } + + @Override + public JWTAuthorizationGrantIdentityProviderConfig getConfig() { + return this.config instanceof JWTAuthorizationGrantIdentityProviderConfig ? (JWTAuthorizationGrantIdentityProviderConfig)this.config : null; + } + + private boolean verifySignature(JWSInput jws) { + try { + String jwkurl = config.getJwksUrl(); + JWSHeader header = jws.getHeader(); + String kid = header.getKeyId(); + String alg = header.getRawAlgorithm(); + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId()); + + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + KeyWrapper publicKey = keyStorage.getPublicKey(modelKey, kid, alg, new JWTAuthorizationGrantJWKSEndpointLoader(session, jwkurl)); + + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, alg); + if (signatureProvider == null) { + LOGGER.debugf("Failed to verify token signature, signature provider not found for algorithm %s", alg); + return false; + } + + return signatureProvider.verifier(publicKey).verify(jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jws.getSignature()); + } catch (Exception e) { + LOGGER.debug("Failed to verify token signature", e); + return false; + } + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProviderConfig.java new file mode 100644 index 00000000000..53be9f9f640 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProviderConfig.java @@ -0,0 +1,23 @@ +package org.keycloak.broker.jwtauthorizationgrant; + +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.RealmModel; + +import static org.keycloak.broker.oidc.OIDCIdentityProviderConfig.JWKS_URL; +import static org.keycloak.common.util.UriUtils.checkUrl; + +public class JWTAuthorizationGrantIdentityProviderConfig extends IdentityProviderModel implements JWTAuthorizationGrantConfig { + + public JWTAuthorizationGrantIdentityProviderConfig() { + } + + public JWTAuthorizationGrantIdentityProviderConfig(IdentityProviderModel model) { + super(model); + } + + @Override + public void validate(RealmModel realm) { + checkUrl(realm.getSslRequired(), getIssuer(), ISSUER); + checkUrl(realm.getSslRequired(), getJwksUrl(), JWKS_URL); + } +} diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProviderFactory.java new file mode 100644 index 00000000000..ad368bc19f4 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProviderFactory.java @@ -0,0 +1,46 @@ +package org.keycloak.broker.jwtauthorizationgrant; + +import java.util.Map; + +import org.keycloak.Config; +import org.keycloak.broker.provider.AbstractIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +public class JWTAuthorizationGrantIdentityProviderFactory extends AbstractIdentityProviderFactory implements EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "jwt-authorization-grant"; + + @Override + public String getName() { + return "JWT Authorization Grant"; + } + + @Override + public JWTAuthorizationGrantIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new JWTAuthorizationGrantIdentityProvider(session, new JWTAuthorizationGrantIdentityProviderConfig(model)); + } + + @Override + public Map parseConfig(KeycloakSession session, String configString) { + throw new UnsupportedOperationException(); + } + + @Override + public IdentityProviderModel createConfig() { + return new JWTAuthorizationGrantIdentityProviderConfig(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.JWT_AUTHORIZATION_GRANT); + } + +} diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java new file mode 100644 index 00000000000..021440d0ae1 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java @@ -0,0 +1,28 @@ +package org.keycloak.broker.jwtauthorizationgrant; + +import org.keycloak.crypto.PublicKeysWrapper; +import org.keycloak.http.simple.SimpleHttp; +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.util.JWKSUtils; + + +public class JWTAuthorizationGrantJWKSEndpointLoader implements PublicKeyLoader { + + private final KeycloakSession session; + private final String jwksUrl; + + public JWTAuthorizationGrantJWKSEndpointLoader(KeycloakSession session, String jwksUrl) { + this.session = session; + this.jwksUrl = jwksUrl; + } + + @Override + public PublicKeysWrapper loadKeys() throws Exception { + JSONWebKeySet jwks = SimpleHttp.create(session).doGet(jwksUrl).asJson(JSONWebKeySet.class); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); + } + +} diff --git a/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java index 67323111a26..b266fc77085 100644 --- a/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java @@ -41,14 +41,6 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel { public static final String REQUIRES_SHORT_STATE_PARAMETER = "requiresShortStateParameter"; - public static final String JWT_AUTHORIZATION_GRANT_ENABLED = "jwtAuthorizationGrantEnabled"; - - public static final String JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED = "jwtAuthorizationGrantAssertionReuseAllowed"; - - public static final String JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION = "jwtAuthorizationGrantMaxAllowedAssertionExpiration"; - - public static final String JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG = "jwtAuthorizationGrantAssertionSignatureAlg"; - public OAuth2IdentityProviderConfig(IdentityProviderModel model) { super(model); } @@ -169,22 +161,6 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel { return Boolean.parseBoolean(getConfig().getOrDefault(PKCE_ENABLED, "false")); } - public boolean getJwtAuthorizationGrantEnabled() { - return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_ENABLED, "false")); - } - - public boolean getJwtAuthorizationGrantAssertionReuseAllowed() { - return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED, "false")); - } - - public int getJwtAuthorizationGrantMaxAllowedAssertionExpiration() { - return Integer.parseInt(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION, "300")); - } - - public String getJwtAuthorizationGrantAssertionSignatureAlg() { - return getConfig().get(JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG); - } - public void setPkceEnabled(boolean enabled) { getConfig().put(PKCE_ENABLED, String.valueOf(enabled)); } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 944f8d4361d..57f611bddb3 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -17,7 +17,6 @@ package org.keycloak.broker.oidc; import java.io.IOException; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -40,6 +39,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.authentication.authenticators.client.FederatedJWTClientValidator; +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProvider; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; import org.keycloak.broker.provider.AuthenticationRequest; import org.keycloak.broker.provider.BrokeredIdentityContext; @@ -84,7 +84,6 @@ import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorResponseException; -import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.IdentityBrokerService; @@ -93,7 +92,6 @@ import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.Booleans; import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; -import org.keycloak.utils.StringUtil; import org.keycloak.vault.VaultStringSecret; import com.fasterxml.jackson.databind.JsonNode; @@ -102,7 +100,7 @@ import org.jboss.logging.Logger; /** * @author Pedro Igor */ -public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider { +public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider { protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class); public static final String SCOPE_OPENID = "openid"; @@ -1074,9 +1072,8 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider getAllowedAudienceForJWTGrant() { - RealmModel realm = session.getContext().getRealm(); - - URI baseUri = session.getContext().getUri().getBaseUri(); - String issuer = Urls.realmIssuer(baseUri, realm.getName()); - String tokenEndpoint = Urls.tokenEndpoint(baseUri, realm.getName()).toString(); - return List.of(issuer, tokenEndpoint); + return new JWTAuthorizationGrantIdentityProvider(session, getConfig()).getAllowedAudienceForJWTGrant(); } @Override public int getMaxAllowedExpiration() { - return getConfig().getJwtAuthorizationGrantMaxAllowedAssertionExpiration(); + return getConfig().getJWTAuthorizationGrantMaxAllowedAssertionExpiration(); } @Override public String getAssertionSignatureAlg() { - String alg = getConfig().getJwtAuthorizationGrantAssertionSignatureAlg(); - return StringUtil.isBlank(alg) ? null : alg; + return getConfig().getJWTAuthorizationGrantAssertionSignatureAlg(); } } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java index b37c1d99c4c..6c96331d345 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java @@ -16,6 +16,7 @@ */ package org.keycloak.broker.oidc; +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig; import org.keycloak.common.enums.SslRequired; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.RealmModel; @@ -25,7 +26,7 @@ import static org.keycloak.common.util.UriUtils.checkUrl; /** * @author Pedro Igor */ -public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig { +public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig implements JWTAuthorizationGrantConfig { public static final String JWKS_URL = "jwksUrl"; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java index 486b5fbeb88..453e3a8b356 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java @@ -23,7 +23,6 @@ import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; -import org.keycloak.broker.provider.UserAuthenticationIdentityProvider; import org.keycloak.cache.AlternativeLookupProvider; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -77,8 +76,8 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { throw new RuntimeException("Identity Provider is not allowed for the client"); } - UserAuthenticationIdentityProvider identityProvider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias()); - if (!(identityProvider instanceof JWTAuthorizationGrantProvider jwtAuthorizationGrantProvider)) { + JWTAuthorizationGrantProvider jwtAuthorizationGrantProvider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel, JWTAuthorizationGrantProvider.class); + if (jwtAuthorizationGrantProvider == null) { throw new RuntimeException("Identity Provider is not configured for JWT Authorization Grant"); } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory index fb7e8413efe..b8cea42fac3 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory @@ -20,4 +20,5 @@ org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory org.keycloak.broker.saml.SAMLIdentityProviderFactory org.keycloak.broker.oauth.OAuth2IdentityProviderFactory org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory -org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory \ No newline at end of file +org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory +org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory \ No newline at end of file diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java index 06b570eb586..fd568bf32bd 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java @@ -12,7 +12,7 @@ public class DefaultOAuthClientConfiguration implements ClientConfig { .serviceAccountsEnabled(true) .directAccessGrantsEnabled(true) .attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true") - .attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_IDP, "authorization-grant-idp") + .attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_IDP, "authorization-grant-idp-alias") .secret("test-secret"); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java new file mode 100644 index 00000000000..5a8a8211e44 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java @@ -0,0 +1,379 @@ +package org.keycloak.tests.oauth; + +import java.util.List; +import java.util.UUID; + +import org.keycloak.OAuth2Constants; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Time; +import org.keycloak.crypto.Algorithm; +import org.keycloak.events.EventType; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.testframework.annotations.InjectEvents; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testframework.events.Events; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.OAuthIdentityProvider; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.ManagedUser; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testframework.realm.UserConfig; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet; +import org.keycloak.testframework.remote.timeoffset.TimeOffSet; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.tests.client.authentication.external.ClientAuthIdpServerConfig; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public abstract class AbstractJWTAuthorizationGrantTest { + + public static String IDP_ALIAS = "authorization-grant-idp-alias"; + public static final String IDP_ISSUER = "https://authorization-grant-issuer"; + + @InjectOAuthIdentityProvider(config = AbstractJWTAuthorizationGrantTest.AGIdpConfig.class) + OAuthIdentityProvider identityProvider; + + @InjectRealm(config = JWTAuthorizationGrantRealmConfig.class) + protected ManagedRealm realm; + + @InjectUser(config = FederatedUserConfiguration.class) + ManagedUser user; + + @InjectOAuthClient + OAuthClient oAuthClient; + + @InjectEvents + Events events; + + @InjectTimeOffSet + TimeOffSet timeOffSet; + + @Test + public void testPublicClient() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.client("test-public"); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Public client not allowed to use authorization grant", response, events.poll()); + oAuthClient.client("test-app", "test-secret"); + } + + @Test + public void testIdpNotAllowedForClient() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.client("authorization-grant-not-allowed-idp-client", "test-secret"); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Identity Provider is not allowed for the client", response, events.poll()); + oAuthClient.client("test-app", "test-secret"); + } + + @Test + public void testNotAllowedClient() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.client("authorization-grant-disabled-client", "test-secret"); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("JWT Authorization Grant is not supported for the requested client", response, events.poll()); + oAuthClient.client("test-app", "test-secret"); + } + + @Test + public void testMissingAssertionParameter() { + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(null).send(); + assertFailure("Missing parameter:" + OAuth2Constants.ASSERTION, response, events.poll()); + } + + @Test + public void testBadAssertionParameter() { + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest("fake-jwt").send(); + assertFailure("The provided assertion is not a valid JWT", response, events.poll()); + } + + @Test + public void testExpiredAssertion() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, null)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token exp claim is required", response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() - 1L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token is not active", response, events.poll()); + + //test max exp default settings + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 301L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token expiration is too far in the future and iat claim not present in token", response, events.poll()); + + //reduce max expiration to 10 seconds + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION, "10"); + }); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 11L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token expiration is too far in the future and iat claim not present in token", response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 5L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + //test with iat + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 20L, (long) Time.currentTime())); + timeOffSet.set(15); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token was issued too far in the past to be used now", response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 20L, (long) Time.currentTime())); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + } + + @Test + public void testAudience() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", null, IDP_ISSUER, Time.currentTime() + 300L)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid token audience", response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "fake-audience", IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid token audience", response, events.poll()); + + // Issuer as audience works + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + // Token endpoint as audience works + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getToken(), IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + // Introspection endpoint as audience does not work + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIntrospection(), IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid token audience", response, events.poll()); + + // Multiple audiences does not work + JsonWebToken jwtToken = createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER); + jwtToken.addAudience("fake"); + jwt = getIdentityProvider().encodeToken(jwtToken); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Multiple audiences not allowed", response, events.poll()); + + // Multiple audiences does not work (even if both are valid) + jwtToken = createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER); + jwtToken.addAudience(oAuthClient.getEndpoints().getToken()); + jwt = getIdentityProvider().encodeToken(jwtToken); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Multiple audiences not allowed", response, events.poll()); + } + + @Test + public void testBadIssuer() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), null, Time.currentTime() + 300L)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Missing claim: " + OAuth2Constants.ISSUER, response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), "fake-issuer", Time.currentTime() + 300L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("No Identity Provider for provided issuer", response, events.poll()); + } + + @Test + public void testBadSubject() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken(null, oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Missing claim: " + IDToken.SUBJECT, response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("fake-user", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("User not found", response, events.poll()); + } + + @Test + public void testReplayToken() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token reuse detected", response, events.poll()); + + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED, "true"); + }); + + jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + } + + @Test + public void testSignatureAlg() { + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG, Algorithm.ES256); + }); + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG, Algorithm.ES512); + }); + jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid signature algorithm", response, events.poll()); + } + + @Test + public void testInvalidSignature() { + JsonWebToken token = createDefaultAuthorizationGrantToken(); + OAuthIdentityProvider.OAuthIdentityProviderKeys newKeys = getIdentityProvider().createKeys(); + OAuthIdentityProvider.OAuthIdentityProviderKeys keys = getIdentityProvider().getKeys(); + newKeys.getKeyWrapper().setKid(keys.getKeyWrapper().getKid()); + String jwt = getIdentityProvider().encodeToken(token, newKeys); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid signature", response, events.poll()); + } + + @Test + public void testScope() { + oAuthClient.openid(false).scope("address phone"); + try { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + AccessToken token = assertSuccess("test-app", "basic-user", response); + MatcherAssert.assertThat(List.of(token.getScope().split(" ")), Matchers.containsInAnyOrder(new String[]{"email", "profile", "address", "phone"})); + + jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.scope("address phone wrong-scope"); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("invalid_scope", "Invalid scopes: address phone wrong-scope", response, events.poll()); + } finally { + oAuthClient.openid(true).scope(null); + } + } + + @Test + public void testSuccessGrant() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + } + + protected JsonWebToken createDefaultAuthorizationGrantToken() { + return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L); + } + + protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer) { + return createAuthorizationGrantToken(subject, audience, issuer, Time.currentTime() + 300L, (long) Time.currentTime()); + } + + protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp) { + return createAuthorizationGrantToken(subject, audience, issuer, exp, null); + } + + protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp, Long iat) { + JsonWebToken token = new JsonWebToken(); + token.id(UUID.randomUUID().toString()); + token.subject(subject); + token.audience(audience); + token.issuer(issuer); + token.exp(exp); + token.iat(iat); + return token; + } + + public OAuthIdentityProvider getIdentityProvider() { + return identityProvider; + } + + public static class AGIdpConfig implements OAuthIdentityProviderConfig { + + @Override + public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) { + return config; + } + } + + public static class JWTAuthorizationGrantServerConfig extends ClientAuthIdpServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return super.configure(config).features(Profile.Feature.JWT_AUTHORIZATION_GRANT); + } + } + + public static class JWTAuthorizationGrantRealmConfig implements RealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + realm.addClient("test-public").publicClient(true); + realm.addClient("authorization-grant-disabled-client").publicClient(false).secret("test-secret"); + realm.addClient("authorization-grant-not-allowed-idp-client").publicClient(false).attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true").secret("test-secret"); + return realm; + } + } + + public static class FederatedUserConfiguration implements UserConfig { + + @Override + public UserConfigBuilder configure(UserConfigBuilder user) { + return user + .username("basic-user") + .password("password") + .email("basic@localhost") + .name("First", "Last") + .federatedLink(IDP_ALIAS, "basic-user-id", "basic-user"); + } + } + + protected AccessToken assertSuccess(String expectedClientId, String username, AccessTokenResponse response) { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNull(response.getRefreshToken()); + AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class); + Assertions.assertNull(accessToken.getSessionId()); + MatcherAssert.assertThat(accessToken.getId(), Matchers.startsWith("trrtag:")); + Assertions.assertEquals(expectedClientId, accessToken.getIssuedFor()); + Assertions.assertEquals(username, accessToken.getPreferredUsername()); + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .clientId(expectedClientId) + .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) + .details("username", username); + return accessToken; + } + + protected void assertFailure(String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { + assertFailure("invalid_grant", expectedErrorDescription, response, event); + } + + protected void assertFailure(String expectedError, String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { + Assertions.assertFalse(response.isSuccess()); + Assertions.assertEquals(expectedError, response.getError()); + Assertions.assertEquals(expectedErrorDescription, response.getErrorDescription()); + EventAssertion.assertError(event) + .type(EventType.LOGIN_ERROR) + .error("invalid_request") + .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) + .details("reason", expectedErrorDescription); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java index 0452247368c..442dedea9f7 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java @@ -1,404 +1,36 @@ package org.keycloak.tests.oauth; -import java.util.List; -import java.util.UUID; - -import org.keycloak.OAuth2Constants; +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; -import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; -import org.keycloak.common.Profile; -import org.keycloak.common.util.Time; -import org.keycloak.crypto.Algorithm; -import org.keycloak.events.EventType; import org.keycloak.models.IdentityProviderModel; -import org.keycloak.protocol.oidc.OIDCConfigAttributes; -import org.keycloak.representations.AccessToken; -import org.keycloak.representations.IDToken; -import org.keycloak.representations.JsonWebToken; -import org.keycloak.representations.idm.EventRepresentation; -import org.keycloak.testframework.annotations.InjectEvents; import org.keycloak.testframework.annotations.InjectRealm; -import org.keycloak.testframework.annotations.InjectUser; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.testframework.events.EventAssertion; -import org.keycloak.testframework.events.Events; -import org.keycloak.testframework.oauth.OAuthClient; -import org.keycloak.testframework.oauth.OAuthIdentityProvider; -import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig; -import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder; -import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; -import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider; import org.keycloak.testframework.realm.ManagedRealm; -import org.keycloak.testframework.realm.ManagedUser; -import org.keycloak.testframework.realm.RealmConfig; import org.keycloak.testframework.realm.RealmConfigBuilder; -import org.keycloak.testframework.realm.UserConfig; -import org.keycloak.testframework.realm.UserConfigBuilder; -import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet; -import org.keycloak.testframework.remote.timeoffset.TimeOffSet; -import org.keycloak.testframework.server.KeycloakServerConfigBuilder; -import org.keycloak.tests.client.authentication.external.ClientAuthIdpServerConfig; import org.keycloak.testsuite.util.IdentityProviderBuilder; -import org.keycloak.testsuite.util.oauth.AccessTokenResponse; -import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; @KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class) -public class JWTAuthorizationGrantTest { +@TestMethodOrder(MethodOrderer.MethodName.class) +public class JWTAuthorizationGrantTest extends AbstractJWTAuthorizationGrantTest { - private static final String IDP_ALIAS = "authorization-grant-idp"; - private static final String IDP_ISSUER = "authorization-grant://mytrust-domain"; - - @InjectOAuthIdentityProvider(config = JWTAuthorizationGrantTest.AGIdpConfig.class) - OAuthIdentityProvider identityProvider; - - @InjectRealm(config = JWTAuthorizationGranthRealmConfig.class) + @InjectRealm(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig.class) protected ManagedRealm realm; - @InjectUser(config = FederatedUserConfiguration.class) - ManagedUser user; - - @InjectOAuthClient - OAuthClient oAuthClient; - - @InjectEvents - Events events; - - @InjectTimeOffSet - TimeOffSet timeOffSet; - - @Test - public void testPublicClient() { - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - oAuthClient.client("test-public"); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Public client not allowed to use authorization grant", response, events.poll()); - oAuthClient.client("test-app", "test-secret"); - } - - @Test - public void testIdpNotAllowedForClient() { - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - oAuthClient.client("authorization-grant-not-allowed-idp-client", "test-secret"); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Identity Provider is not allowed for the client", response, events.poll()); - oAuthClient.client("test-app", "test-secret"); - } - - @Test - public void testNotAllowedIdentityProvider() { - realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { - rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ENABLED, "false"); - }); - - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("JWT Authorization Granted is not enabled for the identity provider", response, events.poll()); - } - - @Test - public void testNotAllowedClient() { - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - oAuthClient.client("authorization-grant-disabled-client", "test-secret"); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("JWT Authorization Grant is not supported for the requested client", response, events.poll()); - oAuthClient.client("test-app", "test-secret"); - } - - @Test - public void testMissingAssertionParameter() { - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(null).send(); - assertFailure("Missing parameter:" + OAuth2Constants.ASSERTION, response, events.poll()); - } - - @Test - public void testBadAssertionParameter() { - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest("fake-jwt").send(); - assertFailure("The provided assertion is not a valid JWT", response, events.poll()); - } - - @Test - public void testExpiredAssertion() { - String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, null)); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Token exp claim is required", response, events.poll()); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() - 1L)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Token is not active", response, events.poll()); - - //test max exp default settings - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 301L)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Token expiration is too far in the future and iat claim not present in token", response, events.poll()); - - //reduce max expiration to 10 seconds - realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { - rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION, "10"); - }); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 11L)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Token expiration is too far in the future and iat claim not present in token", response, events.poll()); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 5L)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - - //test with iat - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 20L, (long) Time.currentTime())); - timeOffSet.set(15); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Token was issued too far in the past to be used now", response, events.poll()); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 20L, (long) Time.currentTime())); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - } - - @Test - public void testAudience() { - String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", null, IDP_ISSUER, Time.currentTime() + 300L)); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Invalid token audience", response, events.poll()); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "fake-audience", IDP_ISSUER)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Invalid token audience", response, events.poll()); - - // Issuer as audience works - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - - // Token endpoint as audience works - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getToken(), IDP_ISSUER)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - - // Introspection endpoint as audience does not work - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIntrospection(), IDP_ISSUER)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Invalid token audience", response, events.poll()); - - // Multiple audiences does not work - JsonWebToken jwtToken = createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER); - jwtToken.addAudience("fake"); - jwt = getIdentityProvider().encodeToken(jwtToken); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Multiple audiences not allowed", response, events.poll()); - - // Multiple audiences does not work (even if both are valid) - jwtToken = createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER); - jwtToken.addAudience(oAuthClient.getEndpoints().getToken()); - jwt = getIdentityProvider().encodeToken(jwtToken); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Multiple audiences not allowed", response, events.poll()); - } - - @Test - public void testBadIssuer() { - String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), null, Time.currentTime() + 300L)); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Missing claim: " + OAuth2Constants.ISSUER, response, events.poll()); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), "fake-issuer", Time.currentTime() + 300L)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("No Identity Provider for provided issuer", response, events.poll()); - } - - @Test - public void testBadSubject() { - String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken(null, oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L)); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Missing claim: " + IDToken.SUBJECT, response, events.poll()); - - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("fake-user", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L)); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("User not found", response, events.poll()); - } - - @Test - public void testReplayToken() { - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Token reuse detected", response, events.poll()); - - realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { - rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED, "true"); - }); - - jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - } - - @Test - public void testSignatureAlg() { - realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { - rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG, Algorithm.ES256); - }); - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - - realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { - rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG, Algorithm.ES512); - }); - jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Invalid signature algorithm", response, events.poll()); - } - - @Test - public void testInvalidSignature() { - JsonWebToken token = createDefaultAuthorizationGrantToken(); - OAuthIdentityProvider.OAuthIdentityProviderKeys newKeys = getIdentityProvider().createKeys(); - OAuthIdentityProvider.OAuthIdentityProviderKeys keys = getIdentityProvider().getKeys(); - newKeys.getKeyWrapper().setKid(keys.getKeyWrapper().getKid()); - String jwt = getIdentityProvider().encodeToken(token, newKeys); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("Invalid signature", response, events.poll()); - } - - @Test - public void testScope() { - oAuthClient.openid(false).scope("address phone"); - try { - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - AccessToken token = assertSuccess("test-app", "basic-user", response); - MatcherAssert.assertThat(List.of(token.getScope().split(" ")), Matchers.containsInAnyOrder(new String[]{"email", "profile", "address", "phone"})); - - jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - oAuthClient.scope("address phone wrong-scope"); - response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertFailure("invalid_scope", "Invalid scopes: address phone wrong-scope", response, events.poll()); - } finally { - oAuthClient.openid(true).scope(null); - } - } - - @Test - public void testSuccessGrant() { - String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); - AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); - assertSuccess("test-app", "basic-user", response); - } - - protected JsonWebToken createDefaultAuthorizationGrantToken() { - return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L); - } - - protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer) { - return createAuthorizationGrantToken(subject, audience, issuer, Time.currentTime() + 300L, (long) Time.currentTime()); - } - - protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp) { - return createAuthorizationGrantToken(subject, audience, issuer, exp, null); - } - - protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp, Long iat) { - JsonWebToken token = new JsonWebToken(); - token.id(UUID.randomUUID().toString()); - token.subject(subject); - token.audience(audience); - token.issuer(issuer); - token.exp(exp); - token.iat(iat); - return token; - } - - public OAuthIdentityProvider getIdentityProvider() { - return identityProvider; - } - - public static class AGIdpConfig implements OAuthIdentityProviderConfig { - - @Override - public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) { - return config; - } - } - - public static class JWTAuthorizationGrantServerConfig extends ClientAuthIdpServerConfig { - - @Override - public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { - return super.configure(config).features(Profile.Feature.JWT_AUTHORIZATION_GRANT); - } - } - - public static class JWTAuthorizationGranthRealmConfig implements RealmConfig { + public static class JWTAuthorizationGrantRealmConfig extends AbstractJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig { @Override public RealmConfigBuilder configure(RealmConfigBuilder realm) { - - realm.addClient("test-public").publicClient(true); - - realm.addClient("authorization-grant-disabled-client").publicClient(false).secret("test-secret"); - - realm.addClient("authorization-grant-not-allowed-idp-client").publicClient(false).attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true").secret("test-secret"); - - realm.identityProvider( - IdentityProviderBuilder.create() - .providerId(OIDCIdentityProviderFactory.PROVIDER_ID) - .alias(IDP_ALIAS) - .setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER) - .setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, Boolean.TRUE.toString()) - .setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks") - .setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.TRUE.toString()) - .setAttribute(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ENABLED, Boolean.TRUE.toString()) - .build()); + super.configure(realm); + realm.identityProvider(IdentityProviderBuilder.create() + .providerId(JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID) + .alias(IDP_ALIAS) + .setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER) + .setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks") + .build()); return realm; } } - - public static class FederatedUserConfiguration implements UserConfig { - - @Override - public UserConfigBuilder configure(UserConfigBuilder user) { - return user.username("basic-user").password("password").email("basic@localhost").name("First", "Last").federatedLink(IDP_ALIAS, "basic-user-id", "basic-user"); - } - } - - protected AccessToken assertSuccess(String expectedClientId, String username, AccessTokenResponse response) { - Assertions.assertTrue(response.isSuccess()); - Assertions.assertNull(response.getRefreshToken()); - AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class); - Assertions.assertNull(accessToken.getSessionId()); - MatcherAssert.assertThat(accessToken.getId(), Matchers.startsWith("trrtag:")); - Assertions.assertEquals(expectedClientId, accessToken.getIssuedFor()); - Assertions.assertEquals(username, accessToken.getPreferredUsername()); - EventAssertion.assertSuccess(events.poll()) - .type(EventType.LOGIN) - .clientId(expectedClientId) - .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) - .details("username", username); - return accessToken; - } - - protected void assertFailure(String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { - assertFailure("invalid_grant", expectedErrorDescription, response, event); - } - - protected void assertFailure(String expectedError, String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { - Assertions.assertFalse(response.isSuccess()); - Assertions.assertEquals(expectedError, response.getError()); - Assertions.assertEquals(expectedErrorDescription, response.getErrorDescription()); - EventAssertion.assertError(event) - .type(EventType.LOGIN_ERROR) - .error("invalid_request") - .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) - .details("reason", expectedErrorDescription); - } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java new file mode 100644 index 00000000000..c7733bdb51a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java @@ -0,0 +1,53 @@ +package org.keycloak.tests.oauth; + +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testsuite.util.IdentityProviderBuilder; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@KeycloakIntegrationTest(config = OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class) +@TestMethodOrder(MethodOrderer.MethodName.class) +public class OIDCIdentityProviderJWTAuthorizationGrantTest extends AbstractJWTAuthorizationGrantTest { + + @InjectRealm(config = OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig.class) + protected ManagedRealm realm; + + @Test + public void testNotAllowedIdentityProvider() { + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ENABLED, "false"); + }); + + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("JWT Authorization Granted is not enabled for the identity provider", response, events.poll()); + } + + public static class JWTAuthorizationGrantRealmConfig extends AbstractJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + super.configure(realm); + realm.identityProvider( + IdentityProviderBuilder.create() + .providerId(OIDCIdentityProviderFactory.PROVIDER_ID) + .alias(IDP_ALIAS) + .setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER) + .setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, Boolean.TRUE.toString()) + .setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks") + .setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.TRUE.toString()) + .setAttribute(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ENABLED, Boolean.TRUE.toString()) + .build()); + return realm; + } + } +}