KEYCLOAK-401 Added service account demo
This commit is contained in:
parent
d8c1081578
commit
1a34feda65
10 changed files with 404 additions and 0 deletions
|
@ -210,6 +210,14 @@ An pure HTML5/Javascript example using Keycloak to secure it.
|
||||||
If you are already logged in, you will not be asked for a username and password, but you will be redirected to
|
If you are already logged in, you will not be asked for a username and password, but you will be redirected to
|
||||||
an oauth grant page. This page asks you if you want to grant certain permissions to the third-part app.
|
an oauth grant page. This page asks you if you want to grant certain permissions to the third-part app.
|
||||||
|
|
||||||
|
Step 10: Service Account Example
|
||||||
|
================================
|
||||||
|
An example for retrieve service account dedicated to the Client Application itself (not to any user).
|
||||||
|
|
||||||
|
[http://localhost:8080/service-account-portal](http://localhost:8080/service-account-portal)
|
||||||
|
|
||||||
|
Client authentication is done with OAuth2 Client Credentials Grant in out-of-bound request (Not Keycloak login screen displayed)
|
||||||
|
|
||||||
Admin Console
|
Admin Console
|
||||||
==========================
|
==========================
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@
|
||||||
<module>database-service</module>
|
<module>database-service</module>
|
||||||
<module>third-party</module>
|
<module>third-party</module>
|
||||||
<module>third-party-cdi</module>
|
<module>third-party-cdi</module>
|
||||||
|
<module>service-account</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<profiles>
|
<profiles>
|
||||||
|
|
60
examples/demo-template/service-account/pom.xml
Normal file
60
examples/demo-template/service-account/pom.xml
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<parent>
|
||||||
|
<artifactId>keycloak-examples-demo-parent</artifactId>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<version>1.4.0.Final-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>org.keycloak.example.demo</groupId>
|
||||||
|
<artifactId>service-account-example</artifactId>
|
||||||
|
<packaging>war</packaging>
|
||||||
|
<name>Service Account Example App</name>
|
||||||
|
<description/>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jboss.spec.javax.servlet</groupId>
|
||||||
|
<artifactId>jboss-servlet-api_3.0_spec</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-core</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-adapter-core</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
|
<artifactId>httpclient</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>service-account-portal</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jboss.as.plugins</groupId>
|
||||||
|
<artifactId>jboss-as-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skip>false</skip>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.wildfly.plugins</groupId>
|
||||||
|
<artifactId>wildfly-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skip>false</skip>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
|
@ -0,0 +1,234 @@
|
||||||
|
package org.keycloak.example;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.http.HttpServlet;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.apache.http.HttpEntity;
|
||||||
|
import org.apache.http.HttpResponse;
|
||||||
|
import org.apache.http.NameValuePair;
|
||||||
|
import org.apache.http.client.HttpClient;
|
||||||
|
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||||
|
import org.apache.http.client.methods.HttpGet;
|
||||||
|
import org.apache.http.client.methods.HttpPost;
|
||||||
|
import org.apache.http.impl.client.DefaultHttpClient;
|
||||||
|
import org.apache.http.message.BasicNameValuePair;
|
||||||
|
import org.keycloak.OAuth2Constants;
|
||||||
|
import org.keycloak.RSATokenVerifier;
|
||||||
|
import org.keycloak.VerificationException;
|
||||||
|
import org.keycloak.adapters.KeycloakDeployment;
|
||||||
|
import org.keycloak.adapters.KeycloakDeploymentBuilder;
|
||||||
|
import org.keycloak.adapters.ServerRequest;
|
||||||
|
import org.keycloak.constants.ServiceAccountConstants;
|
||||||
|
import org.keycloak.representations.AccessToken;
|
||||||
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
|
import org.keycloak.util.BasicAuthHelper;
|
||||||
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||||
|
*/
|
||||||
|
public class ProductServiceAccountServlet extends HttpServlet {
|
||||||
|
|
||||||
|
public static final String ERROR = "error";
|
||||||
|
public static final String TOKEN = "token";
|
||||||
|
public static final String TOKEN_PARSED = "idTokenParsed";
|
||||||
|
public static final String REFRESH_TOKEN = "refreshToken";
|
||||||
|
public static final String PRODUCTS = "products";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init() throws ServletException {
|
||||||
|
InputStream config = getServletContext().getResourceAsStream("WEB-INF/keycloak.json");
|
||||||
|
KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config);
|
||||||
|
HttpClient client = new DefaultHttpClient();
|
||||||
|
|
||||||
|
getServletContext().setAttribute(KeycloakDeployment.class.getName(), deployment);
|
||||||
|
getServletContext().setAttribute(HttpClient.class.getName(), client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void destroy() {
|
||||||
|
getHttpClient().getConnectionManager().shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
|
||||||
|
String reqUri = req.getRequestURI();
|
||||||
|
if (reqUri.endsWith("/login")) {
|
||||||
|
serviceAccountLogin(req);
|
||||||
|
} else if (reqUri.endsWith("/refresh")) {
|
||||||
|
refreshToken(req);
|
||||||
|
} else if (reqUri.endsWith("/logout")){
|
||||||
|
logout(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't load products if some error happened during login,refresh or logout
|
||||||
|
if (req.getAttribute(ERROR) == null) {
|
||||||
|
loadProducts(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.getRequestDispatcher("/WEB-INF/page.jsp").forward(req, resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void serviceAccountLogin(HttpServletRequest req) {
|
||||||
|
KeycloakDeployment deployment = getKeycloakDeployment();
|
||||||
|
HttpClient client = getHttpClient();
|
||||||
|
|
||||||
|
String clientId = deployment.getResourceName();
|
||||||
|
String clientSecret = deployment.getResourceCredentials().get("secret");
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpPost post = new HttpPost(deployment.getTokenUrl());
|
||||||
|
List<NameValuePair> formparams = new ArrayList<NameValuePair>();
|
||||||
|
formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
|
||||||
|
|
||||||
|
String authHeader = BasicAuthHelper.createHeader(clientId, clientSecret);
|
||||||
|
post.addHeader("Authorization", authHeader);
|
||||||
|
|
||||||
|
UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8");
|
||||||
|
post.setEntity(form);
|
||||||
|
|
||||||
|
HttpResponse response = client.execute(post);
|
||||||
|
int status = response.getStatusLine().getStatusCode();
|
||||||
|
HttpEntity entity = response.getEntity();
|
||||||
|
if (status != 200) {
|
||||||
|
String json = getContent(entity);
|
||||||
|
String error = "Service account login failed. Bad status: " + status + " response: " + json;
|
||||||
|
req.setAttribute(ERROR, error);
|
||||||
|
} else if (entity == null) {
|
||||||
|
req.setAttribute(ERROR, "No entity");
|
||||||
|
} else {
|
||||||
|
String json = getContent(entity);
|
||||||
|
AccessTokenResponse tokenResp = JsonSerialization.readValue(json, AccessTokenResponse.class);
|
||||||
|
setTokens(req, deployment, tokenResp);
|
||||||
|
}
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
ioe.printStackTrace();
|
||||||
|
req.setAttribute(ERROR, "Service account login failed. IOException occured. See server.log for details. Message is: " + ioe.getMessage());
|
||||||
|
} catch (VerificationException vfe) {
|
||||||
|
req.setAttribute(ERROR, "Service account login failed. Failed to verify token Message is: " + vfe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setTokens(HttpServletRequest req, KeycloakDeployment deployment, AccessTokenResponse tokenResponse) throws IOException, VerificationException {
|
||||||
|
String token = tokenResponse.getToken();
|
||||||
|
String refreshToken = tokenResponse.getRefreshToken();
|
||||||
|
AccessToken tokenParsed = RSATokenVerifier.verifyToken(token, deployment.getRealmKey(), deployment.getRealmInfoUrl());
|
||||||
|
req.getSession().setAttribute(TOKEN, token);
|
||||||
|
req.getSession().setAttribute(REFRESH_TOKEN, refreshToken);
|
||||||
|
req.getSession().setAttribute(TOKEN_PARSED, tokenParsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadProducts(HttpServletRequest req) {
|
||||||
|
HttpClient client = getHttpClient();
|
||||||
|
String token = (String) req.getSession().getAttribute(TOKEN);
|
||||||
|
|
||||||
|
HttpGet get = new HttpGet("http://localhost:8080/database/products");
|
||||||
|
if (token != null) {
|
||||||
|
get.addHeader("Authorization", "Bearer " + token);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
HttpResponse response = client.execute(get);
|
||||||
|
HttpEntity entity = response.getEntity();
|
||||||
|
int status = response.getStatusLine().getStatusCode();
|
||||||
|
if (status != 200) {
|
||||||
|
String json = getContent(entity);
|
||||||
|
String error = "Failed retrieve products.";
|
||||||
|
|
||||||
|
if (status == 401) {
|
||||||
|
error = error + " You need to login first with the service account.";
|
||||||
|
} else if (status == 403) {
|
||||||
|
error = error + " Maybe service account user doesn't have needed role? Assign role 'user' in Keycloak admin console to user '" +
|
||||||
|
ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + getKeycloakDeployment().getResourceName() + "' and then logout and login again.";
|
||||||
|
}
|
||||||
|
error = error + " Status: " + status + ", Response: " + json;
|
||||||
|
req.setAttribute(ERROR, error);
|
||||||
|
} else if (entity == null) {
|
||||||
|
req.setAttribute(ERROR, "No entity");
|
||||||
|
} else {
|
||||||
|
String products = getContent(entity);
|
||||||
|
req.setAttribute(PRODUCTS, products);
|
||||||
|
}
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
ioe.printStackTrace();
|
||||||
|
req.setAttribute(ERROR, "Failed retrieve products. IOException occured. See server.log for details. Message is: " + ioe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshToken(HttpServletRequest req) {
|
||||||
|
KeycloakDeployment deployment = getKeycloakDeployment();
|
||||||
|
String refreshToken = (String) req.getSession().getAttribute(REFRESH_TOKEN);
|
||||||
|
if (refreshToken == null) {
|
||||||
|
req.setAttribute(ERROR, "No refresh token available. Please login first");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
AccessTokenResponse tokenResponse = ServerRequest.invokeRefresh(deployment, refreshToken);
|
||||||
|
setTokens(req, deployment, tokenResponse);
|
||||||
|
} catch (ServerRequest.HttpFailure hfe) {
|
||||||
|
hfe.printStackTrace();
|
||||||
|
req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Status was: " + hfe.getStatus() + ", Error is: " + hfe.getError());
|
||||||
|
} catch (Exception ioe) {
|
||||||
|
ioe.printStackTrace();
|
||||||
|
req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Message is: " + ioe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logout(HttpServletRequest req) {
|
||||||
|
KeycloakDeployment deployment = getKeycloakDeployment();
|
||||||
|
String refreshToken = (String) req.getSession().getAttribute(REFRESH_TOKEN);
|
||||||
|
if (refreshToken == null) {
|
||||||
|
req.setAttribute(ERROR, "No refresh token available. Please login first");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
ServerRequest.invokeLogout(deployment, refreshToken);
|
||||||
|
req.getSession().removeAttribute(TOKEN);
|
||||||
|
req.getSession().removeAttribute(REFRESH_TOKEN);
|
||||||
|
req.getSession().removeAttribute(TOKEN_PARSED);
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
ioe.printStackTrace();
|
||||||
|
req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Message is: " + ioe.getMessage());
|
||||||
|
} catch (ServerRequest.HttpFailure hfe) {
|
||||||
|
hfe.printStackTrace();
|
||||||
|
req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Status was: " + hfe.getStatus() + ", Error is: " + hfe.getError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getContent(HttpEntity entity) throws IOException {
|
||||||
|
if (entity == null) return null;
|
||||||
|
InputStream is = entity.getContent();
|
||||||
|
try {
|
||||||
|
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||||
|
int c;
|
||||||
|
while ((c = is.read()) != -1) {
|
||||||
|
os.write(c);
|
||||||
|
}
|
||||||
|
byte[] bytes = os.toByteArray();
|
||||||
|
String data = new String(bytes);
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
is.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeycloakDeployment getKeycloakDeployment() {
|
||||||
|
return (KeycloakDeployment) getServletContext().getAttribute(KeycloakDeployment.class.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient getHttpClient() {
|
||||||
|
return (HttpClient) getServletContext().getAttribute(HttpClient.class.getName());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
<jboss-deployment-structure>
|
||||||
|
<deployment>
|
||||||
|
<dependencies>
|
||||||
|
<!-- the Demo code uses classes in these modules. These are optional to import if you are not using
|
||||||
|
Apache Http Client or the HttpClientBuilder that comes with the adapter core -->
|
||||||
|
<module name="org.apache.httpcomponents"/>
|
||||||
|
</dependencies>
|
||||||
|
</deployment>
|
||||||
|
</jboss-deployment-structure>
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"realm" : "demo",
|
||||||
|
"realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB",
|
||||||
|
"auth-server-url" : "http://localhost:8080/auth",
|
||||||
|
"ssl-required" : "external",
|
||||||
|
"resource" : "product-sa-client",
|
||||||
|
"credentials": {
|
||||||
|
"secret": "password"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
<%@ page language="java" contentType="text/html; charset=ISO-8859-1"
|
||||||
|
pageEncoding="ISO-8859-1" %>
|
||||||
|
<%@ page import="org.keycloak.example.ProductServiceAccountServlet" %>
|
||||||
|
<%@ page import="org.keycloak.representations.AccessToken" %>
|
||||||
|
<%@ page import="org.keycloak.constants.ServiceAccountConstants" %>
|
||||||
|
<%@ page import="org.keycloak.util.Time" %>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Service account portal</title>
|
||||||
|
</head>
|
||||||
|
<body bgcolor="#FFFFFF">
|
||||||
|
<%
|
||||||
|
AccessToken token = (AccessToken) request.getSession().getAttribute(ProductServiceAccountServlet.TOKEN_PARSED);
|
||||||
|
String products = (String) request.getAttribute(ProductServiceAccountServlet.PRODUCTS);
|
||||||
|
String appError = (String) request.getAttribute(ProductServiceAccountServlet.ERROR);
|
||||||
|
%>
|
||||||
|
<h1>Service account portal</h1>
|
||||||
|
<p><a href="/service-account-portal/app/login">Login</a> | <a href="/service-account-portal/app/refresh">Refresh token</a> | <a
|
||||||
|
href="/service-account-portal/app/logout">Logout</a></p>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<% if (appError != null) { %>
|
||||||
|
<p><font color="red">
|
||||||
|
<b>Error: </b> <%= appError %>
|
||||||
|
</font></p>
|
||||||
|
<hr />
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (token != null) { %>
|
||||||
|
<p>
|
||||||
|
<b>Service account available</b><br />
|
||||||
|
Client ID: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID) %><br />
|
||||||
|
Client hostname: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_HOST) %><br />
|
||||||
|
Client address: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_ADDRESS) %><br />
|
||||||
|
Token expiration: <%= Time.toDate(token.getExpiration()) %><br />
|
||||||
|
<% if (token.isExpired()) { %>
|
||||||
|
<font color="red">Access token is expired. You may need to refresh</font><br />
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (products != null) { %>
|
||||||
|
<p>
|
||||||
|
<b>Products retrieved successfully from REST endpoint</b><br />
|
||||||
|
Product list: <%= products %>
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
|
||||||
|
version="3.0">
|
||||||
|
|
||||||
|
<module-name>service-account-portal</module-name>
|
||||||
|
|
||||||
|
<servlet>
|
||||||
|
<servlet-name>ServiceAccountExample</servlet-name>
|
||||||
|
<servlet-class>org.keycloak.example.ProductServiceAccountServlet</servlet-class>
|
||||||
|
</servlet>
|
||||||
|
|
||||||
|
<servlet-mapping>
|
||||||
|
<servlet-name>ServiceAccountExample</servlet-name>
|
||||||
|
<url-pattern>/app/*</url-pattern>
|
||||||
|
</servlet-mapping>
|
||||||
|
|
||||||
|
</web-app>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Refresh" content="0; URL=app">
|
||||||
|
</head>
|
||||||
|
</html>
|
|
@ -162,6 +162,12 @@
|
||||||
"publicClient": true,
|
"publicClient": true,
|
||||||
"directGrantsOnly": true,
|
"directGrantsOnly": true,
|
||||||
"consentRequired": true
|
"consentRequired": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "product-sa-client",
|
||||||
|
"enabled": true,
|
||||||
|
"secret": "password",
|
||||||
|
"serviceAccountsEnabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"clientScopeMappings": {
|
"clientScopeMappings": {
|
||||||
|
|
Loading…
Reference in a new issue