Merge pull request #1843 from stianst/client-reg

KEYCLOAK-1749 Add documentation and fixed clean-up of expired initial…
This commit is contained in:
Stian Thorgersen 2015-11-19 19:58:42 +01:00
commit aedd23a43d
15 changed files with 502 additions and 38 deletions

View file

@ -25,12 +25,12 @@ public class ClientRegistration {
private HttpUtil httpUtil;
public ClientRegistration(String authServerUrl, String realm) {
httpUtil = new HttpUtil(HttpClients.createDefault(), HttpUtil.getUrl(authServerUrl, "realms", realm, "clients"));
public static ClientRegistrationBuilder create() {
return new ClientRegistrationBuilder();
}
public ClientRegistration(String authServerUrl, String realm, HttpClient httpClient) {
httpUtil = new HttpUtil(httpClient, HttpUtil.getUrl(authServerUrl, "realms", realm, "clients"));
ClientRegistration(HttpUtil httpUtil) {
this.httpUtil = httpUtil;
}
public void close() throws ClientRegistrationException {
@ -92,4 +92,41 @@ public class ClientRegistration {
}
}
public static class ClientRegistrationBuilder {
private String url;
private HttpClient httpClient;
ClientRegistrationBuilder() {
}
public ClientRegistrationBuilder url(String realmUrl) {
url = realmUrl;
return this;
}
public ClientRegistrationBuilder url(String authUrl, String realm) {
url = HttpUtil.getUrl(authUrl, "realms", realm, "clients");
return this;
}
public ClientRegistrationBuilder httpClient(HttpClient httpClient) {
this.httpClient = httpClient;
return this;
}
public ClientRegistration build() {
if (url == null) {
throw new IllegalStateException("url not configured");
}
if (httpClient == null) {
httpClient = HttpClients.createDefault();
}
return new ClientRegistration(new HttpUtil(httpClient, url));
}
}
}

View file

@ -1,27 +1,68 @@
package org.keycloak.client.registration.cli;
import org.jboss.aesh.cl.parser.CommandLineParserException;
import org.jboss.aesh.console.AeshConsole;
import org.jboss.aesh.console.AeshConsoleBuilder;
import org.jboss.aesh.console.Prompt;
import org.jboss.aesh.console.command.Command;
import org.jboss.aesh.console.command.CommandNotFoundException;
import org.jboss.aesh.console.command.registry.AeshCommandRegistryBuilder;
import org.jboss.aesh.console.settings.Settings;
import org.jboss.aesh.console.settings.SettingsBuilder;
import org.jboss.aesh.terminal.Color;
import org.jboss.aesh.terminal.TerminalColor;
import org.jboss.aesh.terminal.TerminalString;
import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistration;
import org.keycloak.client.registration.cli.commands.CreateCommand;
import org.keycloak.client.registration.cli.commands.ExitCommand;
import org.keycloak.client.registration.cli.commands.SetupCommand;
import java.util.LinkedList;
import java.util.List;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class ClientRegistrationCLI {
public static void main(String[] args) {
private static ClientRegistration reg;
Settings settings = new SettingsBuilder().logging(true).create();
AeshConsole aeshConsole = new AeshConsoleBuilder().settings(settings)
.prompt(new Prompt("[aesh@rules]$ "))
// .command()
public static void main(String[] args) throws CommandLineParserException, CommandNotFoundException {
reg = ClientRegistration.create().url("http://localhost:8080/auth/realms/master").build();
reg.auth(Auth.token("..."));
Context context = new Context();
List<Command> commands = new LinkedList<>();
commands.add(new SetupCommand(context));
commands.add(new CreateCommand(context));
commands.add(new ExitCommand(context));
SettingsBuilder builder = new SettingsBuilder().logging(true);
builder.enableMan(true).readInputrc(false);
Settings settings = builder.create();
AeshCommandRegistryBuilder commandRegistryBuilder = new AeshCommandRegistryBuilder();
for (Command c : commands) {
commandRegistryBuilder.command(c);
}
AeshConsole aeshConsole = new AeshConsoleBuilder()
.commandRegistry(commandRegistryBuilder.create())
.settings(settings)
.prompt(new Prompt(new TerminalString("[clientreg]$ ",
new TerminalColor(Color.GREEN, Color.DEFAULT, Color.Intensity.BRIGHT))))
.create();
aeshConsole.start();
/*
if (args.length > 0) {
CommandContainer command = registry.getCommand(args[0], null);
ParserGenerator.parseAndPopulate(command, args[0], Arrays.copyOfRange(args, 1, args.length));
}*/
}
}

View file

@ -0,0 +1,37 @@
package org.keycloak.client.registration.cli;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.SerializationConfig;
import org.codehaus.jackson.map.annotate.JsonSerialize;
import org.keycloak.client.registration.ClientRegistration;
import org.keycloak.util.SystemPropertiesJsonParserFactory;
import java.io.IOException;
import java.io.InputStream;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class Context {
private static final ObjectMapper mapper = new ObjectMapper(new SystemPropertiesJsonParserFactory());
static {
mapper.setSerializationInclusion(JsonSerialize.Inclusion.NON_NULL);
mapper.enable(SerializationConfig.Feature.INDENT_OUTPUT);
}
private ClientRegistration reg;
public ClientRegistration getReg() {
return reg;
}
public void setReg(ClientRegistration reg) {
this.reg = reg;
}
public static <T> T readJson(InputStream bytes, Class<T> type) throws IOException {
return mapper.readValue(bytes, type);
}
}

View file

@ -0,0 +1,64 @@
package org.keycloak.client.registration.cli.commands;
import org.jboss.aesh.cl.Arguments;
import org.jboss.aesh.cl.CommandDefinition;
import org.jboss.aesh.cl.Option;
import org.jboss.aesh.console.command.Command;
import org.jboss.aesh.console.command.CommandResult;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.jboss.aesh.io.Resource;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.client.registration.cli.Context;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.util.List;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@CommandDefinition(name="create", description = "[OPTIONS] FILE")
public class CreateCommand implements Command {
@Option(shortName = 'h', hasValue = false, description = "display this help and exit")
private boolean help;
@Arguments(description = "files or directories thats listed")
private List<Resource> arguments;
private Context context;
public CreateCommand(Context context) {
this.context = context;
}
@Override
public CommandResult execute(CommandInvocation commandInvocation) throws IOException, InterruptedException {
System.out.println(help);
if(help) {
commandInvocation.getShell().out().println(commandInvocation.getHelpInfo("create"));
}
else {
if(arguments != null) {
for(Resource f : arguments) {
System.out.println(f.getAbsolutePath());
ClientRepresentation rep = JsonSerialization.readValue(f.read(), ClientRepresentation.class);
try {
context.getReg().create(rep);
} catch (ClientRegistrationException e) {
e.printStackTrace();
}
}
}
}
// reg.create();
return CommandResult.SUCCESS;
}
}

View file

@ -0,0 +1,29 @@
package org.keycloak.client.registration.cli.commands;
import org.jboss.aesh.cl.CommandDefinition;
import org.jboss.aesh.console.command.Command;
import org.jboss.aesh.console.command.CommandResult;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.keycloak.client.registration.cli.Context;
import java.io.IOException;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@CommandDefinition(name="exit", description = "Exit the program")
public class ExitCommand implements Command {
private Context context;
public ExitCommand(Context context) {
this.context = context;
}
@Override
public CommandResult execute(CommandInvocation commandInvocation) throws IOException, InterruptedException {
commandInvocation.stop();
return CommandResult.SUCCESS;
}
}

View file

@ -0,0 +1,48 @@
package org.keycloak.client.registration.cli.commands;
import org.jboss.aesh.cl.CommandDefinition;
import org.jboss.aesh.cl.Option;
import org.jboss.aesh.console.command.Command;
import org.jboss.aesh.console.command.CommandResult;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.jboss.aesh.io.Resource;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.client.registration.cli.Context;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@CommandDefinition(name="setup", description = "")
public class SetupCommand implements Command {
@Option(shortName = 'h', hasValue = false, description = "display this help and exit")
private boolean help;
private Context context;
public SetupCommand(Context context) {
this.context = context;
}
@Override
public CommandResult execute(CommandInvocation commandInvocation) throws IOException, InterruptedException {
System.out.println(help);
if(help) {
commandInvocation.getShell().out().println(commandInvocation.getHelpInfo("create"));
}
return CommandResult.SUCCESS;
}
private String promptForUsername(CommandInvocation invocation) throws InterruptedException {
invocation.print("username: ");
return invocation.getInputLine();
}
}

View file

@ -14,6 +14,6 @@
<modules>
<module>api</module>
<module>cli</module>
<!--<module>cli</module>-->
</modules>
</project>

View file

@ -48,6 +48,7 @@
<!ENTITY Recaptcha SYSTEM "modules/recaptcha.xml">
<!ENTITY AuthSPI SYSTEM "modules/auth-spi.xml">
<!ENTITY FilterAdapter SYSTEM "modules/servlet-filter-adapter.xml">
<!ENTITY ClientRegistration SYSTEM "modules/client-registration.xml">
]>
<book>
@ -116,6 +117,7 @@ This one is short
&MultiTenancy;
&JAAS;
</chapter>
&ClientRegistration;
&IdentityBroker;
&Themes;

View file

@ -63,6 +63,9 @@
<listitem>
<literal>manage-applications</literal> - Create, modify and delete applications in the realm
</listitem>
<listitem>
<literal>create-clients</literal> - Create clients in the realm
</listitem>
<listitem>
<literal>manage-clients</literal> - Create, modify and delete clients in the realm
</listitem>

View file

@ -0,0 +1,202 @@
<chapter id="client-registration">
<title>Client Registration</title>
<para>
In order for an application or service to utilize Keycloak it has to register a client in Keycloak. An
admin can do this through the admin console (or admin REST endpoints), but clients can also register themselves
through Keycloak's client registration service.
</para>
<para>
The Client Registration Service provides built-in support for Keycloak Client Representations, OpenID Connect
Client Meta Data and SAML Entity Descriptors. It's also possible to plugin custom client registration providers
if required. The Client Registration Service endpoint is <literal>&lt;KEYCLOAK URL&gt;/clients/&lt;provider&gt;</literal>.
</para>
<para>
The built-in supported <literal>providers</literal> are:
<itemizedlist>
<listitem><literal>default</literal> Keycloak Representations</listitem>
<listitem><literal>install</literal> Keycloak Adapter Configuration</listitem>
<!--<listitem><literal>openid-connect</literal> OpenID Connect Dynamic Client Registration</listitem>-->
<!--<listitem><literal>saml-ed</literal> SAML Entity Descriptors</listitem>-->
</itemizedlist>
The following sections will describe how to use the different providers.
</para>
<section>
<title>Authentication</title>
<para>
To invoke the Client Registration Services you need a token. The token can be a standard bearer token, a
initial access token or a registration access token.
</para>
<section>
<title>Bearer Token</title>
<para>
The bearertoken can be issued on behalf of a user or a Service Account. The following permissions are required
to invoke the endpoints (see <link linkend='admin-permissions'>Admin Permissions</link> for more details):
<itemizedlist>
<listitem>
<literal>create-client</literal> or <literal>manage-client</literal> - To create clients
</listitem>
<listitem>
<literal>view-client</literal> or <literal>manage-client</literal> - To view clients
</listitem>
<listitem>
<literal>manage-client</literal> - To update or delete clients
</listitem>
</itemizedlist>
If you are using a regular bearer token to create clients we recommend using a token from on behalf of a
Service Account with only the <literal>create-client</literal> role. See the
<link linkend="service-accounts">Service Account</link> section for more details.
</para>
</section>
<section>
<title>Initial Access Token</title>
<para>
The best approach to create new clients is by using initial access tokens. An initial access token can
only be used to create clients and has a configurable expiration as well as a configurable limit on
how many clients can be created.
</para>
<para>
An initial access token can be created through the admin console. To create a new initial access token
first select the realm in the admin console, then click on <literal>Realm Settings</literal> in the menu
on the left, followed by <literal>Initial Access Tokens</literal> in the tabs displayed in the page.
</para>
<para>
You will now be able to see any existing initial access tokens. If you have access you can delete tokens
that are no longer required. You can only retrieve the value of the token when you are creating it. To
create a new token click on <literal>Create</literal>. You can now optionally add how long the token
should be valid, also how many clients can be created using the token. After you click on <literal>Save</literal>
the token value is displayed. It is important that you copy/paste this token now as you won't be able
to retrieve it later. If you forget to copy/paste it, then delete the token and create another one.
The token value is used as a standard bearer token when invoking the Client Registration Services, by
adding it to the Authorization header in the request. For example:
<programlisting><![CDATA[
Authorization: bearer eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJmMjJmNzQyYy04ZjNlLTQ2M....
]]></programlisting>
</para>
</section>
<section>
<title>Registration Access Token</title>
<para>
When you create a client through the Client Registration Service the response will include a registration
access token. The registration access token provides access to retrieve the client configuration later, but
also to update or delete the client. The registration access token is included with the request in the
same way as a bearer token or initial access token. Registration access tokens are only valid once
when it's used the response will include a new token.
</para>
<para>
If a client was created outside of the Client Registration Service it won't have a registration access
token associated with it. You can create one through the admin console. This can also be useful if
you loose the token for a particular client. To create a new token find the client in the admin console
and click on <literal>Credentials</literal>. Then click on <literal>Generate registration access token</literal>.
</para>
</section>
</section>
<section>
<title>Keycloak Representations</title>
<para>
The <literal>default</literal> client registration provider can be used to create, retrieve, update and delete a client. It uses
Keycloaks Client Representation format which provides support for configuring clients exactly as they can
be configured through the admin console, including for example configuring protocol mappers.
</para>
<para>
To create a client create a Client Representation (JSON) then do a HTTP POST to:
<literal>&lt;KEYCLOAK URL&gt;/clients/&lt;provider&gt;/default</literal>. It will return a Client Representation
that also includes the registration access token. You should save the registration access token somewhere
if you want to retrieve the config, update or delete the client later.
</para>
<para>
To retrieve the Client Representation then do a HTTP GET to:
<literal>&lt;KEYCLOAK URL&gt;/clients/&lt;provider&gt;/default/&lt;client id&gt;</literal>. It will also
return a new registration access token.
</para>
<para>
To update the Client Representation then do a HTTP PUT to with the updated Client Representation to:
<literal>&lt;KEYCLOAK URL&gt;/clients/&lt;provider&gt;/default/&lt;client id&gt;</literal>. It will also
return a new registration access token.
</para>
<para>
To delete the Client Representation then do a HTTP DELETE to:
<literal>&lt;KEYCLOAK URL&gt;/clients/&lt;provider&gt;/default/&lt;client id&gt;</literal>
</para>
</section>
<section>
<title>Keycloak Adapter Configuration</title>
<para>
The <default>installation</default> client registration provider can be used to retrieve the adapter configuration
for a client. In addition to token authentication you can also authenticate with client credentials using
HTTP basic authentication. To do this include the following header in the request:
<programlisting><![CDATA[
Authorization: basic BASE64(client-id + ':' + client-secret)
]]></programlisting>
</para>
<para>
To retrieve the Adapter Configuration then do a HTTP GET to:
<literal>&lt;KEYCLOAK URL&gt;/clients/&lt;provider&gt;/installation/&lt;client id&gt;</literal>
</para>
<para>
No authentication is required for public clients. This means that for the JavaScript adapter you can
load the client configuration directly from Keycloak using the above URL.
</para>
</section>
<!--
<section>
<title>OpenID Connect Dynamic Client Registration</title>
<para>
TODO
</para>
</section>
-->
<!--
<section>
<title>SAML Entity Descriptors</title>
<para>
TODO
</para>
</section>
-->
<section>
<title>Client Registration Java API</title>
<para>
The Client Registration Java API makes it easy to use the Client Registration Service using Java. To use
include the dependency <literal>org.keycloak:keycloak-client-registration-api:&gt;VERSION&lt;</literal> from
Maven.
</para>
<para>
For full instructions on using the Client Registration refer to the JavaDocs. Below is an example of creating
a client:
<programlisting><![CDATA[
String initialAccessToken = "eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJmMjJmNzQyYy04ZjNlLTQ2M....";
ClientRepresentation client = new ClientRepresentation();
client.setClientId(CLIENT_ID);
ClientRegistration reg = ClientRegistration.create().url("http://keycloak/auth/realms/myrealm").build();
reg.auth(initialAccessToken);
client = reg.create(client);
String registrationAccessToken = client.getRegistrationAccessToken();
]]></programlisting>
</para>
</section>
<!--
<section>
<title>Client Registration CLI</title>
<para>
TODO
</para>
</section>
-->
</chapter>

View file

@ -339,7 +339,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
// Remove expired client initial access
map = new MapReduceTask(sessionCache)
.mappedWith(ClientInitialAccessMapper.create(realm.getId()).time(Time.currentTime()).remainingCount(0).emitKey())
.mappedWith(ClientInitialAccessMapper.create(realm.getId()).expired(Time.currentTime()).emitKey())
.reducedWith(new FirstResultReducer())
.execute();

View file

@ -24,8 +24,7 @@ public class ClientInitialAccessMapper implements Mapper<String, SessionEntity,
private EmitValue emit = EmitValue.ENTITY;
private Integer time;
private Integer remainingCount;
private Integer expired;
public static ClientInitialAccessMapper create(String realm) {
return new ClientInitialAccessMapper(realm);
@ -36,14 +35,8 @@ public class ClientInitialAccessMapper implements Mapper<String, SessionEntity,
return this;
}
public ClientInitialAccessMapper time(int time) {
this.time = time;
return this;
}
public ClientInitialAccessMapper remainingCount(int remainingCount) {
this.remainingCount = remainingCount;
public ClientInitialAccessMapper expired(int time) {
this.expired = time;
return this;
}
@ -59,21 +52,27 @@ public class ClientInitialAccessMapper implements Mapper<String, SessionEntity,
ClientInitialAccessEntity entity = (ClientInitialAccessEntity) e;
if (time != null && entity.getExpiration() > 0 && (entity.getTimestamp() + entity.getExpiration()) < time) {
return;
boolean include = false;
if (expired != null) {
if (entity.getRemainingCount() <= 0) {
include = true;
} else if (entity.getExpiration() > 0 && (entity.getTimestamp() + entity.getExpiration()) < expired) {
include = true;
}
} else {
include = true;
}
if (remainingCount != null && entity.getRemainingCount() == remainingCount) {
return;
}
switch (emit) {
case KEY:
collector.emit(key, key);
break;
case ENTITY:
collector.emit(key, entity);
break;
if (include) {
switch (emit) {
case KEY:
collector.emit(key, key);
break;
case ENTITY:
collector.emit(key, entity);
break;
}
}
}

View file

@ -37,7 +37,7 @@ public class ClientRegistrationTokenUtils {
}
public static String createInitialAccessToken(RealmModel realm, UriInfo uri, ClientInitialAccessModel model) {
return createToken(realm, uri, model.getId(), TYPE_INITIAL_ACCESS_TOKEN, model.getTimestamp() + model.getExpiration());
return createToken(realm, uri, model.getId(), TYPE_INITIAL_ACCESS_TOKEN, model.getExpiration() > 0 ? model.getTimestamp() + model.getExpiration() : 0);
}
public static JsonWebToken parseToken(RealmModel realm, UriInfo uri, String token) {

View file

@ -29,7 +29,7 @@ public abstract class AbstractClientRegistrationTest extends AbstractKeycloakTes
@Before
public void before() throws Exception {
reg = new ClientRegistration(testContext.getAuthServerContextRoot() + "/auth", "test");
reg = ClientRegistration.create().url(testContext.getAuthServerContextRoot() + "/auth", "test").build();
}
@After

View file

@ -26,13 +26,15 @@ public class InitialAccessTokenTest extends AbstractClientRegistrationTest {
}
@Test
public void create() throws ClientRegistrationException {
public void create() throws ClientRegistrationException, InterruptedException {
ClientInitialAccessPresentation response = resource.create(new ClientInitialAccessCreatePresentation());
reg.auth(Auth.token(response));
ClientRepresentation rep = new ClientRepresentation();
Thread.sleep(2);
ClientRepresentation created = reg.create(rep);
Assert.assertNotNull(created);