Use cases
- The Keycloak root admin user wants to log in to the
master
realm and then do all further operations in a secondary realm-b
realm, such as creating a group. It would be desirable to not have to pass realm-b
for every call.
The Keycloak root admin user wants to log in to the master
realm and do most operations in the master
realm, except for a single operation in a realm-c
realm. This is apparently already covered by #3 (comment). I'll document this in the PR.
Prior Art
The keycloak-admin-client allows for use of a different realm via requiring the user to repeat the realm for every call:
// Create the user
return client.users.create(realmName, testUser).then((user) => {
// ...
});
Proposed API
This is a rough first version of the proposed API. The final version may look different.
const realmNameA = 'master';
const connectionSettings = {
baseUrl: 'http://keycloak:8080/auth',
realmName: realmNameA,
};
const keycloakClient = new KeycloakAdminClient(connectionSettings);
// Authenticate with `master` realm
await keycloakClient.auth(userSettings);
// Set defaults for all further requests
const realmNameB = 'realm-b';
keycloakClient.setConfig({
realmName: realmNameB,
// ...other options...
});
// One-off operation to create a group on `realm-c`
const realmNameC = 'realm-c';
await keycloakClient.groups.create({name: 'editor'}, {
realmName: realmNameC,
// ...other options...
}));
Technical Background
1) First instantiation of Keycloak admin client
The following user code...
const realmNameA = 'master';
const connectionSettings = {
baseUrl: 'http://keycloak:8080/auth',
realmName: realmNameA,
};
const keycloakClient = new KeycloakAdminClient(connectionSettings);
...does the following things:
1.1) Sets the realmName
property on the instance to original realmName ("realmNameA")
1.2) Passes that instance to the constructor of various resources
1.3) Passes the realmName further to the Resource constructor (via urlParams)
1.4) Passes the realmName further to the Agent constructor (via urlParams)
1.5) Sets the baseParams instance property, which will be used for URL parameters in all further requests
1.6) Further requests pass along the baseParams to the requestWithParams
method
1.7) The urlParams are templated
1.8) And passed along to Axios
// Excerpt: src/client.ts
constructor(args?: ClientArgs) {
// ...
this.realmName = args && args.realmName || defaultRealm; // 1.1
// ...
this.users = new Users(this); // 1.2
// more resources...
// ...
}
// Excerpt: src/resources/groups.ts
export class Groups extends Resource<{realm?: string}> {
// ...
constructor(client: KeycloakAdminClient) {
super(client, {
path: '/admin/realms/{realm}/groups',
urlParams: {
realm: client.realmName // 1.3
}
});
}
// Excerpt: src/resources/resource.ts
constructor(client: KeycloakAdminClient, settings: {
path?: string, urlParams?: ParamType
} = {}) {
this.agent = new Agent({
client,
...settings // 1.4
});
}
// Excerpt: src/resources/agent.ts
constructor({
client, path = '/', urlParams = {}
}: {
client: KeycloakAdminClient,
path?: string,
urlParams?: Record<string, any>
}) {
this.baseParams = urlParams; // 1.5
// ...
}
// ...
public request({
method,
path = '',
urlParams = [],
querystring = [],
catchNotFound = false,
keyTransform,
payloadKey
}: RequestArgs) {
return async (payload: any = {}) => {
const selected = [...Object.keys(this.baseParams), ...urlParams];
const mergedParams = {...this.baseParams, ...pick(payload, selected)}; // 1.6
// ...
return this.requestWithParams({
// ...
urlParams: mergedParams, // 1.6
// ...
});
};
}
// ...
private async requestWithParams(
{
method,
path,
payload,
urlParams,
queryParams,
catchNotFound,
payloadKey
}:
{
method: string,
path: string,
payload: any,
urlParams: any,
queryParams?: Record<string, any> | null,
catchNotFound: boolean,
payloadKey?: string
}) {
const newPath = join(this.basePath, path);
// parse
const temp = template.parse(newPath);
const parsedPath = temp.expand(urlParams); // 1.7
const url = `${this.baseUrl}${parsedPath}`; // 1.7
// prepare request configs
const requestConfig: AxiosRequestConfig = {
...this.requestConfigs,
method,
url, // 1.8
headers: {
Authorization: `bearer ${this.client.getAccessToken()}`
}
};
// ...
const res = await axios(requestConfig);
// ...
}
2) Authentication via call of auth
method
The following user code...
await keycloakClient.auth(userSettings);
...does the following things:
2.1) Passes realmNameA to getToken
2.2) Uses this to build the URL
2.3) The URL is passed along to Axios
// Excerpt: src/client.ts
public async auth(credential: Credential) {
const {accessToken} = await getToken({
baseUrl: this.baseUrl,
realmName: this.realmName, // 2.1
credential,
requestConfigs: this.requestConfigs
});
// ...
}
// Excerpt: src/utils/auth.ts
export const getToken = async (settings: Settings): Promise<TokenResponse> => {
// ...
const realmName = settings.realmName || defaultRealm;
const url = `${baseUrl}/realms/${realmName}/protocol/openid-connect/token`; // 2.2
// ...
const {data} = await axios.post(url, payload, configs); // 2.3
// ...
};
3) Call of resource methods
The following user code...
await keycloakClient.groups.create({name: 'editor'}));
...does the following things:
3.1) Call the agent's request method, using the existing instance properties
// Excerpt: src/resources/groups.ts
public create = this.makeRequest<GroupRepresentation, void>({
method: 'POST'
});
// Excerpt: src/resources/resource.ts
public makeRequest =
< PayloadType = any,
ResponseType = any>(args: RequestArgs): (payload?: PayloadType & ParamType) => Promise<ResponseType> => {
return this.agent.request(args); // 3.1
}