KEYCLOAK-10169 OpenShift 4 Identity Provider

This commit is contained in:
Sebastian Laskawiec 2019-05-22 11:19:00 +02:00 committed by Hynek Mlnařík
parent 2f9d875840
commit 69d6613ab6
13 changed files with 471 additions and 1 deletions

View file

@ -35,6 +35,7 @@ import org.keycloak.social.instagram.InstagramIdentityProviderFactory;
import org.keycloak.social.linkedin.LinkedInIdentityProviderFactory;
import org.keycloak.social.microsoft.MicrosoftIdentityProviderFactory;
import org.keycloak.social.openshift.OpenshiftV3IdentityProviderFactory;
import org.keycloak.social.openshift.OpenshiftV4IdentityProviderFactory;
import org.keycloak.social.paypal.PayPalIdentityProviderFactory;
import org.keycloak.social.stackoverflow.StackoverflowIdentityProviderFactory;
import org.keycloak.social.twitter.TwitterIdentityProviderFactory;
@ -62,6 +63,7 @@ public class UsernameTemplateMapper extends AbstractClaimMapper {
LinkedInIdentityProviderFactory.PROVIDER_ID,
MicrosoftIdentityProviderFactory.PROVIDER_ID,
OpenshiftV3IdentityProviderFactory.PROVIDER_ID,
OpenshiftV4IdentityProviderFactory.PROVIDER_ID,
PayPalIdentityProviderFactory.PROVIDER_ID,
StackoverflowIdentityProviderFactory.PROVIDER_ID,
TwitterIdentityProviderFactory.PROVIDER_ID

View file

@ -0,0 +1,125 @@
package org.keycloak.social.openshift;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
/**
* Identity provider for Openshift V4.
*
* @author David Festal and Sebastian Łaskawiec
*/
public class OpenshiftV4IdentityProvider extends AbstractOAuth2IdentityProvider<OpenshiftV4IdentityProviderConfig> implements SocialIdentityProvider<OpenshiftV4IdentityProviderConfig> {
public static final String BASE_URL = "https://api.preview.openshift.com";
public static final String OPENSHIFT_OAUTH_METADATA_ENDPOINT = "/.well-known/oauth-authorization-server";
public static final String PROFILE_RESOURCE = "/apis/user.openshift.io/v1/users/~";
public static final String DEFAULT_SCOPE = "user:info";
public OpenshiftV4IdentityProvider(KeycloakSession session, OpenshiftV4IdentityProviderConfig config) {
super(session, config);
final String baseUrl = Optional.ofNullable(config.getBaseUrl()).orElse(BASE_URL);
Map<String, Object> oauthDescriptor = getAuthJson(session, config.getBaseUrl());
logger.debugv("Openshift v4 OAuth descriptor: {0}", oauthDescriptor);
config.setAuthorizationUrl((String) oauthDescriptor.get("authorization_endpoint"));
config.setTokenUrl((String) oauthDescriptor.get("token_endpoint"));
config.setUserInfoUrl(baseUrl + PROFILE_RESOURCE);
}
Map<String, Object> getAuthJson(KeycloakSession session, String baseUrl) {
try {
InputStream response = getOauthMetadataInputStream(session, baseUrl);
Map<String, Object> map = mapMetadata(response);
return map;
} catch (Exception e) {
throw new IdentityBrokerException("Could not initialize oAuth metadata", e);
}
}
InputStream getOauthMetadataInputStream(KeycloakSession session, String baseUrl) throws IOException {
HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient();
HttpGet getRequest = new HttpGet(baseUrl + OPENSHIFT_OAUTH_METADATA_ENDPOINT);
getRequest.addHeader("accept", "application/json");
HttpResponse response = httpClient.execute(getRequest);
if (response.getStatusLine().getStatusCode() != 200) {
throw new RuntimeException("Failed : HTTP error code : " + response.getStatusLine().getStatusCode());
}
return response.getEntity().getContent();
}
Map mapMetadata(InputStream response) throws IOException {
return new ObjectMapper().readValue(response, Map.class);
}
@Override
protected String getDefaultScopes() {
return DEFAULT_SCOPE;
}
@Override
protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) {
try {
final JsonNode profile = fetchProfile(accessToken);
final BrokeredIdentityContext user = extractUserContext(profile);
AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias());
return user;
} catch (Exception e) {
throw new IdentityBrokerException("Could not obtain user profile from Openshift.", e);
}
}
private BrokeredIdentityContext extractUserContext(JsonNode profile) {
JsonNode metadata = profile.get("metadata");
logger.debugv("extractUserContext: metadata = {0}", metadata);
final BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(metadata, "uid"));
user.setUsername(getJsonProperty(metadata, "name"));
user.setName(getJsonProperty(profile, "fullName"));
user.setIdpConfig(getConfig());
user.setIdp(this);
return user;
}
private JsonNode fetchProfile(String accessToken) throws IOException {
return SimpleHttp.doGet(getConfig().getUserInfoUrl(), this.session)
.header("Authorization", "Bearer " + accessToken)
.asJson();
}
@Override
protected boolean supportsExternalExchange() {
return true;
}
@Override
protected String getProfileEndpointForValidation(EventBuilder event) {
return getConfig().getUserInfoUrl();
}
@Override
protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) {
final BrokeredIdentityContext user = extractUserContext(profile);
AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias());
return user;
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.social.openshift;
import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;
import java.util.Map;
import java.util.Optional;
/**
* OpenShift 4 Identity Provider configuration class.
*
* @author David Festal and Sebastian Łaskawiec
*/
public class OpenshiftV4IdentityProviderConfig extends OAuth2IdentityProviderConfig {
private static final String BASE_URL = "baseUrl";
public OpenshiftV4IdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
}
private String trimTrailingSlash(String baseUrl) {
if (baseUrl != null && baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
return baseUrl;
}
public String getBaseUrl() {
return getConfig().get(BASE_URL);
}
public void setBaseUrl(String baseUrl) {
getConfig().put(BASE_URL, trimTrailingSlash(baseUrl));
}
}

View file

@ -0,0 +1,33 @@
package org.keycloak.social.openshift;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.broker.social.SocialIdentityProviderFactory;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
/**
* OpenShift 4 Identity Provider factory class.
*
* @author David Festal and Sebastian Łaskawiec
*/
public class OpenshiftV4IdentityProviderFactory extends AbstractIdentityProviderFactory<OpenshiftV4IdentityProvider> implements SocialIdentityProviderFactory<OpenshiftV4IdentityProvider> {
public static final String PROVIDER_ID = "openshift-v4";
public static final String NAME = "Openshift v4";
@Override
public String getName() {
return NAME;
}
@Override
public OpenshiftV4IdentityProvider create(KeycloakSession keycloakSession, IdentityProviderModel identityProviderModel) {
return new OpenshiftV4IdentityProvider(keycloakSession, new OpenshiftV4IdentityProviderConfig(identityProviderModel));
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -24,6 +24,7 @@ org.keycloak.social.stackoverflow.StackoverflowIdentityProviderFactory
org.keycloak.social.twitter.TwitterIdentityProviderFactory
org.keycloak.social.microsoft.MicrosoftIdentityProviderFactory
org.keycloak.social.openshift.OpenshiftV3IdentityProviderFactory
org.keycloak.social.openshift.OpenshiftV4IdentityProviderFactory
org.keycloak.social.gitlab.GitLabIdentityProviderFactory
org.keycloak.social.bitbucket.BitbucketIdentityProviderFactory
org.keycloak.social.instagram.InstagramIdentityProviderFactory

View file

@ -0,0 +1,76 @@
package org.keycloak.social.openshift;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.Charsets;
import org.apache.commons.io.IOUtils;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
public class OpenshiftV4IdentityProviderTest {
private final String TEST_OAUTH_METADATA_FILE = "/org/keycloak/test/social/openshift/OpenshiftV4-oauth-metadata.json";
private URL oauthMetadataFile;
private String authMetadata;
private Map<String, String> oauthMetadataMap;
@Before
public void before() throws Exception {
oauthMetadataFile = OpenshiftV4IdentityProviderTest.class.getResource(TEST_OAUTH_METADATA_FILE);
authMetadata = IOUtils.toString(oauthMetadataFile, Charsets.toCharset("UTF-8"));
ObjectMapper objectMapper = new ObjectMapper();
oauthMetadataMap = objectMapper.readValue(authMetadata, HashMap.class);
}
@Test
public void testExtractingConfigProperties() throws IOException {
//given
OpenshiftV4IdentityProviderConfig config = new OpenshiftV4IdentityProviderConfig(new IdentityProviderModel());
//when
new OpenshiftV4IdentityProvider(null, config) {
@Override
InputStream getOauthMetadataInputStream(KeycloakSession session, String baseUrl) {
return new ByteArrayInputStream(authMetadata.getBytes());
}
};
//then
Assert.assertEquals(OpenshiftV4IdentityProvider.BASE_URL + OpenshiftV4IdentityProvider.PROFILE_RESOURCE, config.getUserInfoUrl());
Assert.assertEquals(oauthMetadataMap.get("token_endpoint"), config.getTokenUrl());
Assert.assertEquals(oauthMetadataMap.get("authorization_endpoint"), config.getAuthorizationUrl());
}
@Test
public void testHttpClientErrors() throws IOException {
//given
OpenshiftV4IdentityProviderConfig config = new OpenshiftV4IdentityProviderConfig(new IdentityProviderModel());
//when
try {
new OpenshiftV4IdentityProvider(null, config) {
@Override
InputStream getOauthMetadataInputStream(KeycloakSession session, String baseUrl) {
throw new RuntimeException("Failed : HTTP error code : 500");
}
};
Assert.fail();
} catch (IdentityBrokerException e) {
//then
//OK
}
}
}

View file

@ -0,0 +1,24 @@
{
"issuer": "https://127.0.0.1",
"authorization_endpoint": "https://127.0.0.1:6443/oauth/authorize",
"token_endpoint": "https://127.0.0.1:6443/oauth/token",
"scopes_supported": [
"user:full",
"user:info",
"user:check-access",
"user:list-scoped-projects",
"user:list-projects"
],
"response_types_supported": [
"code",
"token"
],
"grant_types_supported": [
"authorization_code",
"implicit"
],
"code_challenge_methods_supported": [
"plain",
"S256"
]
}

View file

@ -80,6 +80,7 @@ import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GOOGLE_NON_
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.LINKEDIN;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.MICROSOFT;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.OPENSHIFT;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.OPENSHIFT4;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.PAYPAL;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.STACKOVERFLOW;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.TWITTER;
@ -115,6 +116,7 @@ public class SocialLoginTest extends AbstractKeycloakTest {
PAYPAL("paypal", PayPalLoginPage.class),
STACKOVERFLOW("stackoverflow", StackOverflowLoginPage.class),
OPENSHIFT("openshift-v3", OpenShiftLoginPage.class),
OPENSHIFT4("openshift-v4", OpenShiftLoginPage.class),
GITLAB("gitlab", GitLabLoginPage.class),
BITBUCKET("bitbucket", BitbucketLoginPage.class),
INSTAGRAM("instagram", InstagramLoginPage.class);
@ -235,6 +237,15 @@ public class SocialLoginTest extends AbstractKeycloakTest {
assertAccount();
}
@Test
public void openshift4Login() {
setTestProvider(OPENSHIFT4);
performLogin();
assertUpdateProfile(false, false, true);
assertAccount();
testTokenExchange();
}
@Test
@UncaughtServerErrorExpected
public void googleLogin() throws InterruptedException {
@ -382,7 +393,7 @@ public class SocialLoginTest extends AbstractKeycloakTest {
if (provider == STACKOVERFLOW) {
idp.getConfig().put("key", getConfig(provider, "clientKey"));
}
if (provider == OPENSHIFT) {
if (provider == OPENSHIFT || provider == OPENSHIFT4) {
idp.getConfig().put("baseUrl", getConfig(provider, "baseUrl"));
}
if (provider == PAYPAL) {

View file

@ -618,6 +618,8 @@ key=Key
#stackoverflow.key.tooltip=The Key obtained from Stack Overflow client registration.
#openshift.base-url=Base Url
#openshift.base-url.tooltip=Base Url to OpenShift Online API
#openshift4.base-url=Base Url
#openshift4.base-url.tooltip=Base Url to OpenShift Online API
#gitlab-application-id=Application Id
#gitlab-application-secret=Application Secret
#gitlab.application-id.tooltip=Application Id for the application you created in your GitLab Applications account menu

View file

@ -606,6 +606,8 @@ key=Key
stackoverflow.key.tooltip=Stack Overflow のクライアント登録で取得した Key を設定します。
openshift.base-url=ベースURL
openshift.base-url.tooltip=OpenShift Online APIのベースURL
openshift4.base-url=ベースURL
openshift4.base-url.tooltip=OpenShift Online APIのベースURL
gitlab-application-id=アプリケーションID
gitlab-application-secret=アプリケーションシークレット
gitlab.application-id.tooltip=GitLabアプリケーションのアカウントメニューで作成したアプリケーションのアプリケーションID

View file

@ -651,6 +651,8 @@ key=Key
stackoverflow.key.tooltip=The Key obtained from Stack Overflow client registration.
openshift.base-url=Base Url
openshift.base-url.tooltip=Base Url to OpenShift Online API
openshift4.base-url=Base Url
openshift4.base-url.tooltip=Base Url to OpenShift Online API
gitlab-application-id=Application Id
gitlab-application-secret=Application Secret
gitlab.application-id.tooltip=Application Id for the application you created in your GitLab Applications account menu

View file

@ -0,0 +1,7 @@
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="baseUrl"><span class="required">*</span> {{:: 'openshift4.base-url' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="baseUrl" type="text" ng-model="identityProvider.config.baseUrl" required>
</div>
<kc-tooltip>{{:: 'openshift4.base-url.tooltip' | translate}}</kc-tooltip>
</div>

View file

@ -0,0 +1,149 @@
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<ol class="breadcrumb">
<li><a href="#/realms/{{realm.realm}}/identity-provider-settings">{{:: 'identity-providers' | translate}}</a></li>
<li data-ng-hide="newIdentityProvider">{{provider.name}}</li>
<li data-ng-show="newIdentityProvider">{{:: 'add-identity-provider' | translate}}</li>
</ol>
<kc-tabs-identity-provider></kc-tabs-identity-provider>
<form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageIdentityProviders">
<fieldset>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="redirectUri">{{:: 'redirect-uri' | translate}}</label>
<div class="col-sm-6">
<input class="form-control" id="redirectUri" type="text" value="{{callbackUrl}}{{identityProvider.alias}}/endpoint" readonly kc-select-action="click">
</div>
<kc-tooltip>{{:: 'redirect-uri.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<fieldset>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="identifier"><span class="required">*</span> {{:: 'alias' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="identifier" type="text" ng-model="identityProvider.alias" data-ng-readonly="!newIdentityProvider" required>
</div>
<kc-tooltip>{{:: 'identity-provider.alias.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="displayName"> {{:: 'display-name' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="displayName" type="text" ng-model="identityProvider.displayName">
</div>
<kc-tooltip>{{:: 'identity-provider.display-name.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="clientId"><span class="required">*</span> {{:: 'client-id' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="clientId" type="text" ng-model="identityProvider.config.clientId" required>
</div>
<kc-tooltip>{{:: 'social.client-id.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="clientSecret"><span class="required">*</span> {{:: 'client-secret' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="clientSecret" kc-password ng-model="identityProvider.config.clientSecret" required>
</div>
<kc-tooltip>{{:: 'social.client-secret.tooltip' | translate}}</kc-tooltip>
</div>
<div data-ng-include data-src="resourceUrl + '/partials/realm-identity-provider-' + identityProvider.providerId + '-ext.html'"></div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="defaultScope">{{:: 'default-scopes' | translate}} </label>
<div class="col-md-6">
<input class="form-control" id="defaultScope" type="text" ng-model="identityProvider.config.defaultScope">
</div>
<kc-tooltip>{{:: 'social.default-scopes.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="storeToken">{{:: 'store-tokens' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.storeToken" id="storeToken" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'identity-provider.store-tokens.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="storedTokensReadable">{{:: 'stored-tokens-readable' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.addReadTokenRoleOnCreate" id="storedTokensReadable" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'identity-provider.stored-tokens-readable.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="enabled">{{:: 'enabled' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.enabled" id="enabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'identity-provider.enabled.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="disableUserInfo">{{:: 'disableUserInfo' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.config.disableUserInfo" id="disableUserInfo" onoffswitchvalue on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'identity-provider.disableUserInfo.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="trustEmail">{{:: 'trust-email' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.trustEmail" name="identityProvider.trustEmail" id="trustEmail" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'trust-email.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="linkOnly">{{:: 'link-only' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.linkOnly" name="identityProvider.trustEmail" id="linkOnly" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'linkOnly.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="hideOnLoginPage">{{:: 'hide-on-login-page' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.config.hideOnLoginPage" name="identityProvider.config.hideOnLoginPage" id="hideOnLoginPage" onoffswitchvalue on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'hide-on-login-page.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="guiOrder">{{:: 'gui-order' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="guiOrder" type="text" ng-model="identityProvider.config.guiOrder">
</div>
<kc-tooltip>{{:: 'gui-order.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="firstBrokerLoginFlowAlias">{{:: 'first-broker-login-flow' | translate}}</label>
<div class="col-md-6">
<div>
<select class="form-control" id="firstBrokerLoginFlowAlias"
ng-model="identityProvider.firstBrokerLoginFlowAlias"
ng-options="flow.alias as flow.alias for flow in authFlows"
required>
</select>
</div>
</div>
<kc-tooltip>{{:: 'first-broker-login-flow.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="postBrokerLoginFlowAlias">{{:: 'post-broker-login-flow' | translate}}</label>
<div class="col-md-6">
<div>
<select class="form-control" id="postBrokerLoginFlowAlias"
ng-model="identityProvider.postBrokerLoginFlowAlias"
ng-options="flow.alias as flow.alias for flow in postBrokerAuthFlows">
</select>
</div>
</div>
<kc-tooltip>{{:: 'post-broker-login-flow.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<div class="form-group">
<div class="col-md-10 col-md-offset-2">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>
<button kc-cancel data-ng-click="cancel()" data-ng-disabled="!changed">{{:: 'cancel' | translate}}</button>
</div>
</div>
</form>
</div>
<kc-menu></kc-menu>