Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ The *Client Authenticator* drop-down list specifies the type of credential to us

*Client ID and Secret*

This choice is the default setting. The secret is automatically generated. Click *Regenerate* to recreate the secret if necessary.
This choice is the default setting. A random secret is generated automatically, but you can override it with a specific value if needed. The client secret can also reference a value stored in an <<_vault-administration,external vault>>. Click *Regenerate* to recreate random secret if necessary.


As specified in the OAuth2 and OpenID Connect specifications, it is possible to authenticate client either with the client secret in the `Authorization: Basic`
header (together with `client_id`) or by sending `client_id` and `client_secret` as parameters in the HTTP POST method body. You can configure the *Allowed authentication
Expand Down Expand Up @@ -93,6 +94,7 @@ image:images/client-federated-jwt.png[]
*Signed JWT with Client Secret*

If you select this option, you can use a JWT signed by client secret instead of the private key.
The client secret can also reference a value stored in an <<_vault-administration,external vault>>.

The client secret will be used to sign the JWT by the client.

Expand Down
3 changes: 3 additions & 0 deletions docs/documentation/server_admin/topics/vault.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ In the <<_ldap,LDAP settings>> of LDAP-based user federation.
OIDC identity provider secret::
In the _Client Secret_ inside identity provider <<_identity_broker_oidc,OpenID Connect Config>>

OIDC client secret::
In the _Client Secret_ inside confidential <<_client-credentials,OpenID Connect Client>>.

[[_vault-key-resolvers]]
=== Key resolvers

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3038,6 +3038,7 @@ clientAuthenticationHelp=The client authentication method (cfr. https\://openid.
kerberosRealmHelp=Name of kerberos realm. For example, FOO.ORG.
roleCreateError=Could not create role\: {{error}}
clientSecretHelp=The client secret registered with the identity provider. This field is able to obtain its value from vault, use ${vault.ID} format.
oidcClientSecretHelp=The client secret registered with the client. This field is able to obtain its value from vault, use ${vault.ID} format.
offlineSessionMax=Offline Session Max
generatedUserInfoHelp=See the example User Info, which will be provided by the User Info Endpoint.
dynamicScopeFormat=Dynamic scope format
Expand Down
18 changes: 15 additions & 3 deletions js/apps/admin-ui/src/clients/credentials/ClientSecret.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import {
SplitItem,
} from "@patternfly/react-core";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useFormContext, Controller } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { PasswordInput } from "@keycloak/keycloak-ui-shared";
import { PasswordInput, HelpItem } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
Expand Down Expand Up @@ -47,7 +47,13 @@ const SecretInput = ({
<SplitItem isFilled>
<InputGroup>
<InputGroupItem isFill>
<PasswordInput id={id} value={secret} readOnly />
<Controller
name="secret"
control={form.control}
render={({ field }) => (
<PasswordInput id={id} {...field} isDisabled={!isManager} />
)}
/>
</InputGroupItem>
<InputGroupItem>
<CopyToClipboardButton
Expand Down Expand Up @@ -134,6 +140,12 @@ export const ClientSecret = ({ client, secret, toggle }: ClientSecretProps) => {
label={t("clientSecret")}
fieldId="kc-client-secret"
className="pf-v5-u-my-md"
labelIcon={
<HelpItem
helpText={t("oidcClientSecretHelp")}
fieldLabelId="kc-client-secret"
/>
}
>
<SecretInput
id="kc-client-secret"
Expand Down
20 changes: 7 additions & 13 deletions js/apps/admin-ui/src/clients/credentials/Credentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
Card,
CardBody,
ClipboardCopy,
Divider,
Form,
FormGroup,
PageSection,
Expand Down Expand Up @@ -212,24 +211,19 @@ export const Credentials = ({ client, save, refresh }: CredentialsProps) => {
/>
</Form>
)}
{selectedProvider?.supportsSecret && (
<ClientSecret
client={client}
secret={secret}
toggle={toggleClientSecretConfirm}
/>
)}
<ActionGroup>
<Button variant="primary" type="submit" isDisabled={!isDirty}>
{t("save")}
</Button>
</ActionGroup>
</CardBody>
{selectedProvider?.supportsSecret && (
<>
<Divider />
<CardBody>
<ClientSecret
client={client}
secret={secret}
toggle={toggleClientSecretConfirm}
/>
</CardBody>
</>
)}
</Card>
<Card isFlat>
<CardBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ConfiguredPerClientProvider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.ClientRepresentation;
Expand Down Expand Up @@ -52,7 +53,7 @@ public interface ClientAuthenticatorFactory extends ProviderFactory<ClientAuthen
*
* @return
*/
Map<String, Object> getAdapterConfiguration(ClientModel client);
Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client);

/**
* Get authentication methods for the specified protocol
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
Expand All @@ -43,6 +44,7 @@
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.utils.StringUtil;


/**
* Validates client based on "client_id" and "client_secret" sent either in request parameters or in "Authorization: Basic" header .
*
Expand Down Expand Up @@ -149,8 +151,8 @@ public void authenticateClient(ClientAuthenticationFlowContext context) {
return;
}

if (!client.validateSecret(clientSecret)) {
if (!wrapper.validateRotatedSecret(clientSecret)){
if (!wrapper.validateSecret(context.getSession(), clientSecret)) {
if (!wrapper.validateRotatedSecret(context.getSession(), clientSecret)){
reportFailedAuth(context);
return;
}
Expand Down Expand Up @@ -196,9 +198,10 @@ public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
}

@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
public Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client) {
Map<String, Object> result = new HashMap<>();
result.put(CredentialRepresentation.SECRET, client.getSecret());
String secret = client.getSecret();
result.put(CredentialRepresentation.SECRET, session.vault().getStringSecret(secret).get().orElse(secret));
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
}

@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
public Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client) {
return Collections.emptyMap();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import org.keycloak.keys.loader.PublicKeyStorageManager;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
Expand Down Expand Up @@ -171,7 +172,7 @@ public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
}

@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
public Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client) {
Map<String, Object> props = new HashMap<>();
props.put("client-keystore-file", "REPLACE WITH THE LOCATION OF YOUR KEYSTORE FILE");
props.put("client-keystore-type", "jks");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.keycloak.crypto.ClientSignatureVerifierProvider;
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
Expand Down Expand Up @@ -117,7 +118,8 @@ public boolean verifySignature(AbstractJWTClientValidator validator) {
signatureValid = jwt != null;
//try authenticate with client rotated secret
if (!signatureValid && wrapper.hasRotatedSecret() && !wrapper.isClientRotatedSecretExpired()) {
jwt = context.getSession().tokens().decodeClientJWT(validator.getClientAssertion(), wrapper.toRotatedClientModel(), JsonWebToken.class);
jwt = context.getSession().tokens().decodeClientJWT(validator.getClientAssertion(),
wrapper.toRotatedClientModel(context.getSession()), JsonWebToken.class);
signatureValid = jwt != null;
}
} catch (Exception e) {
Expand All @@ -142,7 +144,7 @@ public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
}

@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
public Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Should not this method use the same like ClientIdAndSecretAuthenticator.getAdapterConfiguration to actually obtain secret for adapters?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! I missed this one. It should also use the vault to get the secret.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only planned to add vault support for the client secret authenticator but I now tested the JWT client secret authenticator as well and it's not working with vault references. Not sure what's needed to implement vault support for that as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, but then this should be at least managed as follow-up. The JWTClientSecretAuthentication is just an authenticator that uses the secret as the HMAC to sign... We would need also tests for this. @mposolda WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. It would be good to update that one as well for consistency. It seems this authenticator uses key, which is created from client-secret and secret is obtained in ClientMacSignatureVerifierContext (not 100% sure it is this class. You can likely doublecheck).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mposolda @rmartinc I've now added support for JWT client secret authenticator + test case.

// e.g. client adapter's keycloak.json
// "credentials": {
// "secret-jwt": {
Expand All @@ -151,7 +153,8 @@ public Map<String, Object> getAdapterConfiguration(ClientModel client) {
// }
// }
Map<String, Object> props = new HashMap<>();
props.put("secret", client.getSecret());
String secret = client.getSecret();
props.put("secret", session.vault().getStringSecret(secret).get().orElse(secret));
String algorithm = client.getAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG);
if (algorithm != null) {
props.put("algorithm", algorithm);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
Expand Down Expand Up @@ -184,7 +185,7 @@ public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
}

@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
public Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client) {
return Collections.emptyMap();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public ClientMacSignatureVerifierContext(KeycloakSession session, ClientModel cl

private static KeyWrapper getKey(KeycloakSession session, ClientModel client, String algorithm) throws VerificationException {
if (algorithm == null) algorithm = Algorithm.HS256;
String clientSecretString = client.getSecret();
String secretRef = client.getSecret();
String clientSecretString = session.vault().getStringSecret(secretRef).get().orElse(secretRef);
SecretKey clientSecret = new SecretKeySpec(clientSecretString.getBytes(StandardCharsets.UTF_8), JavaAlgorithm.getJavaAlgorithm(algorithm));
KeyWrapper key = new KeyWrapper();
key.setSecretKey(clientSecret);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.keycloak.common.util.Time;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSecretConstants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.delegate.ClientModelLazyDelegate;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.utils.StringUtil;
Expand Down Expand Up @@ -101,8 +102,9 @@ public boolean hasRotatedSecret() {
return StringUtil.isNotBlank(getAttribute(CLIENT_ROTATED_SECRET)) && StringUtil.isNotBlank(getAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME));
}

public String getClientRotatedSecret() {
return getAttribute(CLIENT_ROTATED_SECRET);
public String getClientRotatedSecret(KeycloakSession session) {
String secret = getAttribute(CLIENT_ROTATED_SECRET);
return session == null ? getAttribute(CLIENT_ROTATED_SECRET) : session.vault().getStringSecret(secret).get().orElse(secret);
}

public void setClientRotatedSecret(String secret) {
Expand Down Expand Up @@ -180,8 +182,30 @@ public boolean isClientRotatedSecretExpired() {
return true;
}

public boolean validateSecret(KeycloakSession session, String secret) {
if (isClientSecretExpired()) {
return false;
}

ClientModel wrapper = new ClientModelLazyDelegate(() -> clientModel) {
@Override
public String getSecret() {
final String secret = clientModel.getSecret();
final String result = session.vault().getStringSecret(secret).get().orElse(secret);
return result;
}

@Override
public boolean validateSecret(String secret) {
return MessageDigest.isEqual(secret.getBytes(), getSecret().getBytes());
}
};

return wrapper.validateSecret(secret);
}

//validates the rotated secret (value and expiration)
public boolean validateRotatedSecret(String secret) {
public boolean validateRotatedSecret(KeycloakSession session, String secret) {

// there must exist a rotated_secret
if (hasRotatedSecret()) {
Expand All @@ -193,7 +217,7 @@ public boolean validateRotatedSecret(String secret) {
return false;
}

return MessageDigest.isEqual(secret.getBytes(), getClientRotatedSecret().getBytes());
return MessageDigest.isEqual(secret.getBytes(), getClientRotatedSecret(session).getBytes());

}

Expand All @@ -218,24 +242,27 @@ public String toJson() {
}
}

public ReadOnlyRotatedSecretClientModel toRotatedClientModel() throws InvalidObjectException {
public ReadOnlyRotatedSecretClientModel toRotatedClientModel(KeycloakSession session) throws InvalidObjectException {
if (Objects.isNull(this.clientModel))
throw new InvalidObjectException(getClass().getCanonicalName() + " does not have an attribute of type " + ClientModel.class.getCanonicalName());
return new ReadOnlyRotatedSecretClientModel(clientModel);
return new ReadOnlyRotatedSecretClientModel(session, clientModel);
}

/**
* Representation of a client model that passes information from a rotated secret. The goal is to act as a decorator/DTO just providing information and not updating objects persistently.
*/
public class ReadOnlyRotatedSecretClientModel extends ClientModelLazyDelegate {

private ReadOnlyRotatedSecretClientModel(ClientModel clientModel) {
private final KeycloakSession session;

private ReadOnlyRotatedSecretClientModel(KeycloakSession session, ClientModel clientModel) {
super(() -> clientModel);
this.session = session;
}

@Override
public String getSecret() {
return OIDCClientSecretConfigWrapper.this.getClientRotatedSecret();
return OIDCClientSecretConfigWrapper.this.getClientRotatedSecret(session);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public Response generateInstallation(KeycloakSession session, RealmModel realm,
public static Map<String, Object> getClientCredentialsAdapterConfig(KeycloakSession session, ClientModel client) {
String clientAuthenticator = client.getClientAuthenticatorType();
ClientAuthenticatorFactory authenticator = (ClientAuthenticatorFactory) session.getKeycloakSessionFactory().getProviderFactory(ClientAuthenticator.class, clientAuthenticator);
return authenticator.getAdapterConfiguration(client);
return authenticator.getAdapterConfiguration(session, client);
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,9 @@ private boolean showClientCredentialsAdapterConfig(ClientModel client) {

private Map<String, Object> getClientCredentialsAdapterConfig(ClientModel client) {
String clientAuthenticator = client.getClientAuthenticatorType();
ClientAuthenticatorFactory authenticator = (ClientAuthenticatorFactory) realmManager.getSession().getKeycloakSessionFactory().getProviderFactory(ClientAuthenticator.class, clientAuthenticator);
return authenticator.getAdapterConfiguration(client);
KeycloakSession session = realmManager.getSession();
ClientAuthenticatorFactory authenticator = (ClientAuthenticatorFactory) session.getKeycloakSessionFactory().getProviderFactory(ClientAuthenticator.class, clientAuthenticator);
return authenticator.getAdapterConfiguration(session, client);
}

private boolean isInternalClient(String realmName, String clientId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ public Response invalidateRotatedSecret() {

CredentialRepresentation rep = new CredentialRepresentation();
rep.setType(CredentialRepresentation.SECRET);
rep.setValue(wrapper.getClientRotatedSecret());
rep.setValue(wrapper.getClientRotatedSecret(session));

adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).representation(rep).success();

Expand Down Expand Up @@ -820,7 +820,7 @@ public CredentialRepresentation getClientRotatedSecret() {
if (!wrapper.hasRotatedSecret())
throw new NotFoundException("Client does not have a rotated secret");
else {
UserCredentialModel model = UserCredentialModel.secret(wrapper.getClientRotatedSecret());
UserCredentialModel model = UserCredentialModel.secret(wrapper.getClientRotatedSecret(session));
return ModelToRepresentation.toRepresentation(model);
}
}
Expand Down
Loading
Loading