New JWT Authorization Grant Identity provider (#44176)

Closes #43570

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano
2025-11-19 09:18:23 +01:00
committed by GitHub
parent 731414e44a
commit 3e8b2f8ab7
26 changed files with 1026 additions and 524 deletions

View File

@@ -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

View File

@@ -0,0 +1,99 @@
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import {
ActionGroup,
AlertVariant,
Button,
PageSection,
} from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toIdentityProvider } from "../routes/IdentityProvider";
import { toIdentityProviders } from "../routes/IdentityProviders";
import 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<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("addJWTAuthorizationGrantProvider")} />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(onSubmit)}
>
<JWTAuthorizationGrantSettings />
<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("add")}
</Button>
<Button
variant="link"
data-testid="cancel"
component={(props) => (
<Link {...props} to={toIdentityProviders({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
}

View File

@@ -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<IdentityProviderParams>();
const isFeatureEnabled = useIsFeatureEnabled();
const form = useForm<IdentityProviderRepresentation>();
const { handleSubmit, getValues, reset } = form;
const { handleSubmit, getValues, reset, control } = form;
const [provider, setProvider] = useState<IdentityProviderRepresentation>();
const [selectedMapper, setSelectedMapper] =
useState<IdPWithMapperAttributes>();
@@ -409,6 +412,10 @@ export default function DetailSettings() {
}
},
});
const jwtAuthorizationGrantEnabled = useWatch({
control,
name: "config.jwtAuthorizationGrantEnabled",
});
if (!provider) {
return <KeycloakSpinner />;
@@ -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: (
<FormAccess
role="manage-identity-providers"
@@ -500,15 +510,29 @@ export default function DetailSettings() {
},
{
title: t("authorizationGrantSettings"),
isHidden: !isJwtAuthorizationGrantSupported,
isHidden: !isJWTAuthorizationGrantSupported,
panel: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<JwtAuthorizationGrantSettings />
</Form>
<>
<Text className="pf-v5-u-pb-lg">
{t("authorizationGrantSettingsHelp")}
</Text>
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<DefaultSwitchControl
name="config.jwtAuthorizationGrantEnabled"
label={t("jwtAuthorizationGrantIdpEnabled")}
labelIcon={t("jwtAuthorizationGrantIdpEnabledHelp")}
stringify
/>
{jwtAuthorizationGrantEnabled === "true" && (
<JWTAuthorizationGrantAssertionSettings />
)}
</Form>
</>
),
},
{
@@ -525,6 +549,20 @@ export default function DetailSettings() {
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isJWTAuthorizationGrant,
panel: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<JWTAuthorizationGrantSettings />
<FixedButtonsGroup name="idp-details" isSubmit reset={reset} />
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isKubernetes,
@@ -559,7 +597,7 @@ export default function DetailSettings() {
},
{
title: t("advancedSettings"),
isHidden: isSPIFFE || isKubernetes,
isHidden: isSPIFFE || isKubernetes || isJWTAuthorizationGrant,
panel: (
<FormAccess
role="manage-identity-providers"
@@ -611,7 +649,7 @@ export default function DetailSettings() {
</Tab>
<Tab
id="mappers"
isHidden={isSPIFFE || isKubernetes}
isHidden={isSPIFFE || isKubernetes || isJWTAuthorizationGrant}
data-testid="mappers-tab"
title={<TabTitleText>{t("mappers")}</TabTitleText>}
{...mappersTab}

View File

@@ -97,6 +97,7 @@ const Fields = ({ readOnly, isOIDC }: DiscoverySettingsProps) => {
<DefaultSwitchControl
name="config.useJwksUrl"
label={t("useJwksUrl")}
labelIcon={t("useJwksUrlHelp")}
isDisabled={readOnly}
stringify
/>

View File

@@ -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 (
<>
<DefaultSwitchControl
name="config.jwtAuthorizationGrantAssertionReuseAllowed"
label={t("jwtAuthorizationGrantAssertionReuseAllowed")}
labelIcon={t("jwtAuthorizationGrantAssertionReuseAllowedHelp")}
stringify
/>
<FormGroup
label={t("jwtAuthorizationGrantMaxAllowedAssertionExpiration")}
fieldId="jwtAuthorizationGrantMaxAllowedAssertionExpiration"
labelIcon={
<HelpItem
helpText={t(
"jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp",
)}
fieldLabelId="jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp"
/>
}
>
<Controller
name="config.jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp"
defaultValue={300}
control={control}
render={({ field }) => (
<TimeSelector
data-testid="jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp"
value={field.value!}
onChange={field.onChange}
units={["second", "minute", "hour"]}
/>
)}
/>
</FormGroup>
<SelectControl
name="config.jwtAuthorizationGrantAssertionSignatureAlg"
label={t("jwtAuthorizationGrantAssertionSignatureAlg")}
labelIcon={t("jwtAuthorizationGrantAssertionSignatureAlgHelp")}
options={[
{ key: "", value: t("algorithmNotSpecified") },
...sortProviders(providers).map((p) => ({ key: p, value: p })),
]}
controller={{
defaultValue: "",
}}
/>
</>
);
};

View File

@@ -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 (
<>
<TextControl
name="alias"
label={t("alias")}
labelIcon={t("aliasHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.issuer"
label={t("issuer")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.jwksUrl"
label={t("jwtAuthorizationGrantJWKSUrl")}
labelIcon={t("jwtAuthorizationGrantJWKSUrlHelp")}
rules={{
required: t("required"),
}}
/>
<JWTAuthorizationGrantAssertionSettings />
<NumberControl
name="config.jwtAuthorizationGrantAllowedClockSkew"
label={t("allowedClockSkew")}
labelIcon={t("allowedClockSkewHelp")}
controller={{ defaultValue: 0, rules: { min: 0, max: 2147483 } }}
/>
<Divider />
</>
);
}

View File

@@ -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 (
<>
<DefaultSwitchControl
name="config.jwtAuthorizationGrantEnabled"
label={t("jwtAuthorizationGrantIdpEnabled")}
labelIcon={t("jwtAuthorizationGrantIdpEnabledHelp")}
stringify
/>
{authorizationGrantEnabled === "true" && (
<>
<DefaultSwitchControl
name="config.jwtAuthorizationGrantAssertionReuseAllowed"
label={t("jwtAuthorizationGrantAssertionReuseAllowed")}
labelIcon={t("jwtAuthorizationGrantAssertionReuseAllowedHelp")}
stringify
/>
<FormGroup
label={t("jwtAuthorizationGrantMaxAllowedAssertionExpiration")}
fieldId="jwtAuthorizationGrantMaxAllowedAssertionExpiration"
labelIcon={
<HelpItem
helpText={t(
"jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp",
)}
fieldLabelId="jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp"
/>
}
>
<Controller
name="config.jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp"
defaultValue={300}
control={control}
render={({ field }) => (
<TimeSelector
data-testid="jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp"
value={field.value!}
onChange={field.onChange}
units={["second", "minute", "hour"]}
/>
)}
/>
</FormGroup>
<SelectControl
name="config.jwtAuthorizationGrantAssertionSignatureAlg"
label={t("jwtAuthorizationGrantAssertionSignatureAlg")}
labelIcon={t("jwtAuthorizationGrantAssertionSignatureAlgHelp")}
options={[
{ key: "", value: t("algorithmNotSpecified") },
...sortProviders(providers).map((p) => ({ key: p, value: p })),
]}
controller={{
defaultValue: "",
}}
/>
</>
)}
<Divider />
</>
);
};

View File

@@ -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,

View File

@@ -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: <AddJWTAuthorizationGrant />,
breadcrumb: (t) => t("addJWTAuthorizationGrantProvider"),
handle: {
access: "manage-identity-providers",
},
};
export const toIdentityProviderJWTAuthorizationGrant = (
params: IdentityProviderJWTAuthorizationGrantParams,
): Partial<Path> => ({
pathname: generateEncodedPath(
IdentityProviderJWTAuthorizationGrantRoute.path,
params,
),
});

View File

@@ -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());
}

View File

@@ -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 <C extends IdentityProviderModel> extends IdentityProvider<C> {
BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion) throws IdentityBrokerException;

View File

@@ -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();
}
/**

View File

@@ -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<String, String> 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();
}

View File

@@ -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<JWTAuthorizationGrantIdentityProviderConfig> {
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<String> 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() {
}
}

View File

@@ -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);
}
}

View File

@@ -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<JWTAuthorizationGrantIdentityProvider> 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<String, String> 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}

View File

@@ -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<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, JWTAuthorizationGrantProvider {
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, JWTAuthorizationGrantProvider<OIDCIdentityProviderConfig> {
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<OIDCIde
return validator.validate();
}
@Override
public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) throws IdentityBrokerException {
if (!getConfig().getJwtAuthorizationGrantEnabled()) {
if (!getConfig().getJWTAuthorizationGrantEnabled()) {
throw new IdentityBrokerException("JWT Authorization Granted is not enabled for the identity provider");
}
@@ -1089,7 +1086,6 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
user.setUsername(context.getJWT().getSubject());
user.setIdp(this);
return user;
}
@Override
@@ -1099,27 +1095,21 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
@Override
public boolean isAssertionReuseAllowed() {
return getConfig().getJwtAuthorizationGrantAssertionReuseAllowed();
return getConfig().getJWTAuthorizationGrantAssertionReuseAllowed();
}
@Override
public List<String> 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();
}
}

View File

@@ -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";

View File

@@ -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");
}

View File

@@ -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
org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory
org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory

View File

@@ -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");
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}